Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
21 KiB
Library benchmarking environment
Purpose
"opencompany" wants to embed the OpenHuman Rust core as a library: no always-on RPC server, no Tauri shell, just the core linked in-process and driven directly. That changes what "resource usage" means. There is no single steady process to profile; there are per-use-case workloads (a long-running agent loop, a delegated multi-agent turn, a saved workflow run, a background subconscious pass, a memory ingest, a bare embed) that each have their own startup cost, steady-state footprint, and growth curve.
This document describes the benchmark environment built to measure that: a
pinned library-profile binary with eight scenarios, four driver scripts
under scripts/profile/, and the comparison point the team cares about
(ZeroClaw). It builds on the manual investigation in
docs/resource-profiling-session-2026-07-21.md;
read that document for the deep memory/CPU attribution work. This document is
about running repeatable benchmarks, not re-deriving those findings.
The eight scenarios
All scenarios run in target/release/library-profile <scenario>, replace
network inference with a deterministic provider (rss-bench feature), and
print one pretty-printed JSON result object to stdout (diagnostics go to
stderr). Each models a distinct embedding use case:
| Scenario | Models |
|---|---|
memory-ingest |
Canonicalizing and ingesting a batch of chat messages through the real extraction/admission/tree-queue pipeline. |
subagents |
A delegation turn: an orchestrator session spawns real subagents via spawn_parallel_agents and merges their findings. |
agent-turn |
The minimal embed case: one agent, one turn, no delegation, no workflow. The smallest useful "hello world" for a host that just wants a single reply. |
long-agent |
A long-running agent loop (OPENHUMAN_PROFILE_TURNS, default 25) in one process, to see whether RSS plateaus or grows per turn. |
workflow |
A saved automation run (flows_create + flows_run), representing the flows/automation embedding path rather than ad hoc chat. |
subconscious |
A background subconscious turn (the always-on reflective pass), distinct from an interactive chat turn. |
cold-phases |
Bootstrap attribution: per-phase checkpoints (config load, registry init, agent build, memory construction, first turn) so cold-start cost can be attributed to a phase instead of one lump sum. |
fleet |
N concurrent live agents with latency-realistic mock inference — the "100-1000 agents in a 2 GB / 2 vCPU server" question. See below. |
How to run
Six scripts under scripts/profile/ (each has -h/--help):
-
library-bench.sh— the primary RSS/duration benchmark. Builds the binaries, runs each scenario N fresh-process repeats (default 5), and aggregates median/min/max intosummary.json+summary.md../scripts/profile/library-bench.sh # default build, all scenarios ./scripts/profile/library-bench.sh --slim # --no-default-features recipe ./scripts/profile/library-bench.sh --scenarios "long-agent,subagents" --turns 50 --warm -
library-cpu.sh— asamplywrapper for one scenario's CPU profile, isolated from persistence/timezone noise by default../scripts/profile/library-cpu.sh subagents samply load target/profile/rust-library/subagents-cpu.json.gz -
library-heap.sh— builds therss-bench-dhatvariant and runs a scenario under dhat for live-heap attribution (allocation sites, retained bytes). RSS/timing under dhat are perturbed; don't compare those numbers tolibrary-bench.shoutput../scripts/profile/library-heap.sh memory-ingest # load target/profile/rust-library/dhat-memory-ingest.json at # https://nnethercote.github.io/dh_view/dh_view.html -
library-fleet.sh— sweeps thefleetscenario across a list of agent counts and gates the result against the 2 GB / 2 vCPU server budget (see below)../scripts/profile/library-fleet.sh --agents 100 --latency-ms 200 ./scripts/profile/library-fleet.sh --agents "50,100,500" --target 1000 --budget-mib 2048 -
library-instances.sh— sweeps N independent processes (not agents in one process) of a scenario, held alive viaOPENHUMAN_PROFILE_HOLD_SECS, and measures per-instance/aggregate cost — the many-processes counterpart tolibrary-fleet.sh's one-process model (see below)../scripts/profile/library-instances.sh --instances "10,25,50" --hold-secs 30 -
library-pool-gate.sh— the runtime-pool regression gate (#5106). Runsskill-runwith K parallel skill runs and asserts the process tree grows by ~one pooled worker, not K interpreters; reports pooled vs unpooled../scripts/profile/library-pool-gate.sh --concurrency 8 --workers 1
Default vs slim builds
Default-feature builds link every compile-time domain gate (voice, web3,
media, meet, skills, flows, mcp, tui) — the byte-identical desktop
recipe. The slim recipe drops everything not required by the harness:
GGML_NATIVE=OFF cargo build --release \
--no-default-features --features rss-bench \
--bin library-profile --bin rss-bench
--slim on library-bench.sh builds this recipe. Per the prior session,
compile-time gates shrink the binary substantially but only move settled RSS
by a few MiB — most of the RSS story is initialization and allocator
behavior, not linked code size.
Useful env knobs
| Variable | Effect |
|---|---|
OPENHUMAN_PROFILE_TURNS |
Turn count for long-agent (default 25). |
OPENHUMAN_PROFILE_PREWARM_SUBAGENTS=1 |
Run one warm-up turn before measuring (subagents/subconscious), isolating first-use cost from steady state. |
OPENHUMAN_PROFILE_DISABLE_MEMORY_WRITES=1 |
Disable memory.auto_save and episodic capture, isolating orchestration from persistence. |
OPENHUMAN_PROFILE_FORCE_UTC=1 |
Skip iana_time_zone/CoreFoundation timezone resolution. |
OPENHUMAN_PROFILE_HOLD_SECS / HOLD_BEFORE_SECS |
Pause the process at settled/baseline state for external inspection (vmmap, heap, malloc_history, Instruments). |
OPENHUMAN_PROFILE_DHAT_OUT |
Output path for dhat JSON (set by library-heap.sh). |
OPENHUMAN_PROFILE_SKILL_RUN_CONCURRENCY |
skill-run: number of parallel code_executor turns (K), each spawning a node_exec job (default 1). |
OPENHUMAN_PROFILE_SKILL_RUN_POOL |
skill-run: off disables the shared runtime pool (legacy per-call spawn; tree then shows ~K resident node children). Default on. |
OPENHUMAN_PROFILE_SKILL_RUN_POOL_WORKERS |
skill-run: pool size W when pooling is on (default 1). The scenario asserts child_count <= W for K > 1 — the #5106 regression gate. |
Metrics and interpretation
Every run reports baseline/settled/peak_rss_kib (macOS: proc_pid_rusage
RSS, getrusage peak, proc_pidinfo thread count), retained_delta_kib
(settled minus baseline), peak_delta_kib, and duration_ms. long-agent
additionally reports checkpoints[] so first-turn vs. last-turn growth is
visible directly.
RSS is not private heap. The prior session's deep attribution found a
~42 MiB slim-build snapshot broken down as roughly 15.2 MiB private physical
footprint, 3.18 MiB live heap, 18.7 MiB resident executable text, and ~9.4 MiB
of resident-but-mostly-inactive malloc pages (allocator high-water
retention). See
docs/resource-profiling-session-2026-07-21.md
for the full breakdown, the executable-paging finding (a cold turn faults in
~15 MiB of previously nonresident OpenHuman code), and the warmed-process
control showing steady-state turns cost ~0.5-1.9 MiB once warm rather than
the ~26-31 MiB a cold turn costs. Use library-bench.sh for the RSS/duration
headline numbers, library-cpu.sh when CPU attribution is the question, and
library-heap.sh only when RSS numbers need live-allocation attribution
(accepting the dhat perturbation).
The ZeroClaw comparison
ZeroClaw self-reports idling under 5 MiB RAM; the "7.8-12 MiB under load"
figure sometimes quoted alongside it has no locatable primary source, and even
the idle figure is vendor marketing with no third-party verification (see
docs/harness-comparison-2026-07-22.md).
OpenHuman's Rust core currently settles around 35-50 MiB depending on
scenario and feature set (see the baseline table below).
Treat this as a north star, not an apples-to-apples benchmark. ZeroClaw's
scope and feature set differ substantially from the OpenHuman core: OpenHuman
links a full agent/memory/tool/orchestration stack (SQLite-backed unified
memory, TinyCortex PII detection, prompt-injection detection, a builtin-agent
registry, tool catalogs, provider routing) that a narrower harness may not
carry at all. A closer gap is a meaningful signal that the initialization
graph is leaner; it is not evidence of feature parity, and a wider gap is not
automatically a regression if it comes from carrying more capability. Every
library-bench.sh summary includes a labeled comparison row/note for exactly
this reason: visible, but explicitly called out as external.
The 2 GB / 2 vCPU server budget
"opencompany" wants a single server to host 100-1000 live agents inside a 2 GB
RAM / 2 vCPU box. That is a budget question, not a per-scenario RSS question:
2048 MiB / 1000 agents is roughly 2 MiB per agent all-in, but the fixed
per-process base (allocator high water, code paging, registries, detectors —
the same ~30 MiB every scenario above pays once) amortizes across however many
agents share the process. What actually determines whether 1000 agents fit is
the marginal cost per additional agent once that base is paid, not the
per-agent average. library-fleet.sh runs the fleet scenario (N concurrent
live agents, latency-realistic mock inference so idle time looks like real
network waits rather than a busy loop) across a sweep of N and reports that
marginal cost directly (marginal_rss_kib_per_agent), alongside idle CPU over
a parked 10s window, thread count, and open FD count — all of which should
stay roughly flat as N grows if per-agent state is cheap and idle agents cost
~zero CPU.
Working targets: marginal cost ≤ 1.5 MiB/agent, threads and FDs flat (not
linear) in N, and idle CPU low regardless of N — an agent that isn't mid-turn
should not be spending cycles. OPENHUMAN_PROFILE_WORKER_THREADS=2 pins the
scenario's tokio runtime to 2 worker threads to simulate the 2 vCPU box rather
than scaling with the host's actual core count. The budget block in each
run's JSON (target_agents, ram_budget_mib, projected_rss_mib_at_target,
fits) projects the swept marginal cost out to the real target (default 1000
agents / 2048 MiB); library-fleet.sh aggregates medians per N into
summary.md and exits nonzero if any swept N projects fits: false, making
it usable as a CI-style regression gate (--no-gate to disable).
Caveats, stated plainly: these numbers are gathered on macOS, which has no
cgroup memory limit to enforce or observe locally — the budget check is a
projection from measured marginal cost, not a live "did it actually get
OOM-killed at N agents" test. macOS also lacks Linux's /proc/<pid>/smaps_rollup,
which would give true PSS (proportional shared memory) instead of RSS; RSS
overcounts shared pages (executable text, shared library mappings) in a way
that matters more as agent count grows and more of the process footprint is
genuinely shared. Treat the macOS numbers as an approximation of the target
Linux server, not a substitute for it. The JSON schema already has a Linux
path — proc_metrics reads /proc/<pid>/status and /proc/<pid>/stat on
Linux — so true validation should eventually mean running the same
library-profile fleet binary on a cgroup-limited Linux box (matching the 2
vCPU / 2 GB target) rather than trusting the macOS projection alone.
Fleet (one process) vs instances (many processes)
The budget section above measures one deployment shape: N agents sharing a single process. But "opencompany" may instead run OpenHuman as N independent processes or containers — one per tenant — rather than N agents inside one process. Those are different cost models and the fleet number does not answer the second one.
- Fleet (
library-fleet.sh) pays the ~30-50 MiB fixed base (allocator high water, code paging, registries, detectors) once, and amortizes it across however many agents share that process. Marginal cost per agent is what matters, and it can be well under 1 MiB once the base is paid. - Instances (
library-instances.sh) pays that same fixed base N times, once per process — minus whatever the OS actually shares across processes (resident executable text, shared library mappings). Summed RSS across instances therefore double-counts those shared pages; it is an upper bound, not the true footprint. True per-instance marginal cost is better read from summed PSS (Linux only — macOS has no PSS-equivalent metric), which divides shared pages across the processes that share them.
library-instances.sh spawns N held library-profile processes staggered
on startup, samples aggregate sum-RSS every 2s while they hold at settled
state, and reports median settled RSS/instance, mean and peak aggregate
sum-RSS, and summed PSS when available, plus a labeled 2 GB-box
extrapolation estimate:
./scripts/profile/library-instances.sh --instances "10,25,50" --hold-secs 30
This is still a macOS proxy, not container validation. True validation
means running the same binary under real cgroup memory limits (e.g.
docker run --memory=2g) on a Linux host and observing whether it survives
or gets OOM-killed at the target instance count — not projecting from local
sum-RSS. That is follow-up work, and it belongs on a Linux box: this repo's
own openhuman-core Docker build is currently blocked on Apple Silicon (the
whisper-rs-sys/whisper.cpp NEON fp16 intrinsics fail to compile under
arm64-Linux emulation with GCC 12 — see the umbrella repo's root CLAUDE.md
gotchas and docs/resource-profiling-session-2026-07-21.md). The path
around that blocker is either building for linux/amd64 under emulation (the
whisper AVX path has no NEON bug) or running the validation on a native Linux
host rather than macOS Docker Desktop.
Profiling escalation path
Start cheap, escalate only as needed:
-
library-bench.sh— RSS/duration medians across fresh processes. Answers "did this change move the needle" for most changes. -
library-cpu.sh(samply) — symbolized CPU profile when a scenario is slower than expected, or to attribute cold-path CPU to a specific phase (registry init, agent build, memory construction, SQLite init, TinyAgents turn runner were the top contributors in the prior session). -
library-heap.sh(dhat) — live-heap allocation sites and retained bytes when RSS is high but the cause isn't obvious from CPU alone (e.g. the TinyCortex PIIRegexSetfinding came from stack-logged allocation attribution, not CPU sampling). -
Instruments /
vmmap/heap/malloc_history— deepest macOS-native attribution, using theOPENHUMAN_PROFILE_HOLD_SECS/HOLD_BEFORE_SECShooks to pause the process at baseline or settled state:OPENHUMAN_PROFILE_HOLD_SECS=120 target/release/library-profile subagents & vmmap -summary <pid> heap -sH <pid> MallocStackLogging=1 OPENHUMAN_PROFILE_HOLD_SECS=120 \ target/release/library-profile subagents & malloc_history <pid> -allBySizeThis is what surfaced the PII-sanitizer regex cache and the first-turn executable-paging finding in the prior session; reach for it only once
library-bench.sh/library-cpu.sh/library-heap.shhave narrowed the question to a specific scenario and metric.
Current baseline numbers
From the 2026-07-21 profiling session (medians over five fresh processes unless noted; see that document for methodology and caveats):
| Scenario | Build | Median settled RSS | Median retained Δ |
|---|---|---|---|
| 1-agent roster | default | 38.7 MiB | - |
| 8-agent roster | default | 41.5 MiB | +2.8 MiB total (~0.40 MiB/agent) |
| 1-agent roster | slim | 35.5 MiB | - |
| 8-agent roster | slim | 38.7 MiB | +3.2 MiB total |
memory-ingest (100 msgs) |
default | 25.5 MiB | 9.31 MiB |
memory-ingest (100 msgs) |
slim | 23.8 MiB | 8.58 MiB |
subagents (cold, 2 children) |
default | 48.5 MiB | 30.8 MiB |
subagents (cold, 2 children) |
slim | 42.4 MiB | 25.7 MiB |
subagents (warmed repeat, persistence off) |
default | - | 0.52 MiB |
subagents (warmed repeat, normal capture) |
default | - | 1.84 MiB |
| ZeroClaw (external, idle, self-reported/unverified) | - | < 5 MiB | - |
First full library-bench.sh run of the new scenarios (default build, 5
fresh-process repeats, 2026-07-21, Apple Silicon macOS):
| Scenario | Build | Median settled RSS | Median retained Δ | Median duration |
|---|---|---|---|---|
agent-turn (cold, 1 turn) |
default | 47.6 MiB | 29.5 MiB | 102 ms |
subconscious (cold, no delegation) |
default | 47.9 MiB | 29.8 MiB | 138 ms |
subagents (cold, 2 children) |
default | 48.0 MiB | 29.9 MiB | 142 ms |
workflow (flows_create + flows_run) |
default | 50.9 MiB | 29.9 MiB | 110 ms |
long-agent (25 warmed turns) |
default | 65.8 MiB | 18.5 MiB | 1,361 ms |
cold-phases (9 bootstrap phases) |
default | 51.2 MiB | 36.5 MiB | 476 ms |
memory-ingest (100 msgs) |
default | 25.8 MiB | 9.3 MiB | 2,099 ms |
Notable structure behind these medians:
- The
long-agentper-turn series plateaus: typical turns add 30-150 KiB, and the 25-turn total (~16.8 MiB first-to-last) is dominated by two async persistence/compaction bursts of 6-8 MiB each, matching the prior session's warmed-repeat outlier observation. Steady-state growth is not linear. - Cold
agent-turn,subconscious,subagents, andworkflowall retain approximately the same ~29-30 MiB, confirming the cost is shared bootstrap (code paging, registries, detectors, allocator high water), not the specific workload on top of it. - A dhat run of
agent-turnmeasured 33.4 MB total allocated across 135,756 blocks, but only 5.0 MB peak live heap and 3.1 MB live at exit, again showing RSS is mostly not live heap data.
Fleet, instances, and runtime baselines (2026-07-22, post-PII-prefilter)
library-fleet.sh sweep (default build, 3 repeats, 3 turns/agent, 200 ms
mock latency, 2 worker threads, target 1000 agents / 2048 MiB):
| N agents | Marginal KiB/agent | Settled MiB | Idle CPU ms/10s | Threads | fds | p95 turn ms | Projected MiB @1000 | Fits |
|---|---|---|---|---|---|---|---|---|
| 50 | 1,985 | 223 | 3 | 71 | 420 | 2,848 | 1,956 | yes |
| 100 | 1,866 | 356 | 3 | 123 | 820 | 5,402 | 1,840 | yes |
| 500 | 1,770 | 1,393 | 3 | 211 | 3,220 | 25,484 | 1,747 | yes |
library-instances.sh (many-processes model, agent-turn held 20 s):
~47.8-48.2 MiB per instance flat at N=10/25/50 → roughly 42 instances per
2 GiB by sum-RSS (upper bound; macOS has no PSS). The one-process fleet
model is ~25x denser than the per-process model.
Runtime/subagent scenarios: skill-run measures a real node child at
~72-75 MB RSS (~121 MB process tree vs ~51 MB self) — the basis of the
runtime-pooling issue (tinyhumansai/openhuman#5106); subagent-storm shows
~0.78 MiB marginal per additional parallel subagent (K=8→32 cross-width).
Runtime pool (#5106): with the shared pool on (the default), a batch of K
concurrent skill-run turns shares a bounded set of warm node workers, so
the process tree grows by ~one pooled worker instead of K interpreters.
Measured at K=8 (max_workers=1, system node v24): pooled tree
child_count=1 at ~202 MiB, vs unpooled child_count=8 at ~690 MiB
(eight node children of ~73–75 MB each) — a ~490 MiB / 3.4× reduction that
grows with K. Compare the two regimes directly:
# Legacy: K interpreters resident at peak.
OPENHUMAN_PROFILE_SKILL_RUN_CONCURRENCY=8 OPENHUMAN_PROFILE_SKILL_RUN_POOL=off \
target/release/library-profile skill-run
# Pooled: child_count stays at the pool size (asserted), not K.
OPENHUMAN_PROFILE_SKILL_RUN_CONCURRENCY=8 OPENHUMAN_PROFILE_SKILL_RUN_POOL_WORKERS=1 \
target/release/library-profile skill-run
The pool is configured in [runtime_pool] (master switch + per-language
node/python max_workers, idle_ttl_secs, recycle_after_jobs,
max_queue_depth); enabled = false reverts every caller to the legacy
per-call spawn.
Watch-items from the sweep: thread count grows ~0.35/agent (needs attribution + cap before real 1000-agent runs), and p95 latency at N=500 on 2 workers shows CPU saturation is the load constraint, not memory.
See also
docs/resource-profiling-session-2026-07-21.md— the full manual investigation (deep attribution, cold-path CPU, library-design implications, recommended optimization order).scripts/profile/README.md— script quick reference.src/bin/library_profile/main.rs— the scenario implementations.