Benchmarks
Published numbers on cited hardware. No number ships without a bench behind
it, and no bench with a missed target is left out of this page. How to run
the suite:
benches/README.md.
Targets and status
Every target the project has set, and where it stands. Do not conflate these into a single threshold — they measure different things.
| Scope | Group | Target | Status |
|---|---|---|---|
| Policy eval alone | policy_eval/allow_all, policy_eval/multi_rule | < 100 µs | pass |
| Combined policy + capability | hot_path/multi_rule | < 1 ms | pass |
| Warm cell instantiation | instantiation/warm | < 0.5 ms | pass |
| Cold instantiation | instantiation/cold | < 5 ms | miss — 39.490 ms, ~7.9× over |
| ed25519 line signing | audit_signing/sign_outcome_line | < 50 µs | pass |
Wrap relay per recorded tools/call | wrap_relay/tools_call_recorded | 0.5–2 ms (informational) | miss — 4.371 ms/call, ~2.19× over |
| Audit emission | audit_emission/begin_complete | no target set (fsync-bound) | n/a |
| Rate-limit path, capability alone, attribution splits | — | informational only | n/a |
Both misses are stated rather than retired quietly. Cold instantiation is
dominated by a Cranelift compile of the component (~29.4 ms), not by engine
setup (~1–2% of the median), so the 5 ms target does not survive contact with
an AOT compile of this size; Execution Report §7 was amended rather than the
measurement reshaped. Wrap’s per-call cost is two sync_all calls under the
shipped G3 durability default, which alone cost ~4.2 ms on this filesystem —
reaching 2 ms needs a batched-or-deferred-fsync decision, not tuning. Neither
number was improved by narrowing a bench or adding private-API-shaped surface
(PRD §10).
The three results files follow, verbatim.
Hot-path Criterion results (AEG-16 / OQ-15 T5)
Hardware / OS
uname -a
Linux botzrDev 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun 5 18:30:46 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
lscpu (selected):
Architecture: x86_64
Model name: AMD Ryzen AI 5 340 w/ Radeon 840M
CPU(s): 4
Thread(s) per core: 2
Core(s) per socket: 2
Hypervisor vendor: Microsoft
Virtualization type: full
rustc -Vv:
rustc 1.96.0 (ac68faa20 2026-05-25)
host: x86_64-unknown-linux-gnu
Environment notes: WSL2 / Linux; Criterion 0.5.1; Gnuplot not installed (plotters backend). Date: 2026-07-09.
Command
cargo bench -p botzr-aegis-policy -p botzr-aegis-capability -p botzr-aegis-runtime
Summary vs targets
| Group | Median (approx.) | Target | Status |
|---|---|---|---|
policy_eval/allow_all | 13.4 ns | < 100 µs | pass |
policy_eval/multi_rule | 31.8 ns | < 100 µs | pass |
policy_eval/rate_limit | 183 ns | informational (mutex) | n/a |
capability_resolve/registered_tool | 2.43 µs | no hard gate | n/a — bench id and measured path both changed 2026-08-24; see AILAB-707 below |
hot_path/allow_all | 2.67 µs | (floor) | n/a — superseded 2026-09-07; see AILAB-846 below |
hot_path/multi_rule (stations 1–2 only — not the cost of a call) | 2.71 µs | < 1 ms | pass — superseded 2026-09-07; see AILAB-846 below |
Criterion reports [lower median upper] as time/op; medians above are the middle sample.
What the last two rows are not. hot_path mirrors stations 1–2 and calls
neither sandbox, audit nor wasmtime inside b.iter — that non-goal is
deliberate and still stands. So 2.71 µs is a component figure, not what an
audited call costs, and it must not be quoted as one: the same call against the
shipped Volatile sink is roughly an order of magnitude more, and against a
Durable sink a further two to three orders on top of that — a span, not a
figure, because the Durable arm did not reproduce across two sessions. Those
are the audited_call group
in cell_and_audit.md,
which is where the end-to-end number lives.
Criterion text tables
capability_resolve/registered_tool
time: [2.3959 µs 2.4329 µs 2.4751 µs]
policy_eval/allow_all time: [13.240 ns 13.408 ns 13.582 ns]
policy_eval/multi_rule time: [31.025 ns 31.775 ns 32.898 ns]
policy_eval/rate_limit time: [181.31 ns 182.74 ns 184.38 ns]
hot_path/allow_all time: [2.6091 µs 2.6727 µs 2.7417 µs]
hot_path/multi_rule time: [2.6453 µs 2.7136 µs 2.7872 µs]
Notes
- Combined hot path is dominated by capability grant minting (~2.4 µs alone); policy eval is tens of nanoseconds.
- Rate-limit path is ~6× multi-rule policy eval but still far below 100 µs; kept informational because it takes a
Mutex. - HTML reports live under
target/criterion/and are not committed.
AILAB-707 update — 2026-08-24
AILAB-707 routed both benchmarks off CapabilityResolver::register, which is
#[deprecated] as a cross-crate visibility fence. A published benchmark that
suppressed a deprecation to reach a forbidden path was the claim-integrity
defect; the suppressions are gone. The table above is not retroactively
edited — it records what was measured on 2026-07-09, under the ids that existed
then.
What changed in each measured path
capability_resolve/registered_tool→capability_resolve/mint_from_manifest. Renamed because the tool is genuinely no longer registered.botzr-aegis-capabilitycannot reachRuntime::register_tool(runtime depends on capability, so the reverse edge is a dependency cycle), so the bench moved toresolve_manifest, the supported one-off mint route shipped foraegis wrap --confine. Two pieces of work left the measured path: theHashMap<ToolId, ToolManifest>registry lookup, and theResourceCeiling::combinefold. Ceiling semantics are unchanged — the old call passedResourceCeiling::default()and the standing ceiling is also default, socombinewas a no-op. The number should therefore have moved slightly down.hot_path/allow_all,hot_path/multi_rule— measured work unchanged. The bench lives insidebotzr-aegis-runtime, so it now registers throughRuntime::register_toolin setup and measures againstrt.capabilities(). The closure still runsevaluate→decision.limits→resolve_with_ceiling, the registry is still a one-entry map, and registration is setup, never measured. The fixture manifest’sToolKindbecameHostso the handler matches; nothing on the production mint path branches on kind (everyToolKindin capability’smint.rs/narrow.rsis inside#[cfg(test)], and policy never reads it). These two numbers should therefore not move at all.
Measurements — and why they are not a re-baseline
Hardware / OS as above, except the toolchain: rustc 1.86.0 (05f9846f8 2025-03-31), LLVM 19.1.7, on Linux 6.6.87.2-microsoft-standard-WSL2, AMD Ryzen
AI 5 340 (4 CPU / 2 cores), Criterion 0.5.1, plotters backend. The 2026-07-09
table above was taken on rustc 1.96.0, so it is not a valid before-value
for this change.
Medians, µs. Pre-change was measured on clean 746ae42 before any edit; runs
1–3 are three consecutive runs of the same post-change binary.
| Bench | pre-change | post r1 | post r2 | post r3 |
|---|---|---|---|---|
capability_resolve/mint_from_manifest | 2.5731 | 2.6472 | 2.5833 | 2.6314 |
hot_path/allow_all | 2.6238 | 3.6966 | 2.7764 | 2.7480 |
hot_path/multi_rule | 2.7286 | 2.8755 | 3.4096 | 2.9367 |
Between those identical-code runs Criterion reported, in order: +42%
“regressed”, −20% “improved”, +12% “regressed”, −10% “improved”, +7%
“regressed”, −5.6% “improved” — all at p < 0.05. A benchmark whose measured
work provably did not change (hot_path/*) swung by 42 points between runs of
the same binary.
Conclusion: no change is attributable to the edit, because this box cannot
resolve an effect of the expected size. The run-to-run noise floor here is
roughly ±20%, and the predicted effects are one hash lookup and three
Option<u64> mins. Note also that capability_resolve moved up ~2% while the
work it does strictly decreased — the sign is wrong, which is itself evidence
that noise dominates.
The published medians above are therefore left as they are rather than overwritten with these runs: replacing a measurement with a noisier one, taken on a different toolchain, would publish noise as evidence. A clean re-baseline of the whole suite — quiet machine, pinned toolchain, all groups — is owed and is not this ticket’s scope.
AILAB-846 update — canonicalizing at registration, 2026-09-07
What changed, and which of these groups can see it. Capability resolution
used to std::fs::canonicalize every path a manifest declared, on every call.
It now happens once, when the tool is registered, and resolution reuses the
result. Exactly one of the two groups below runs that code:
hot_path/*registers throughRuntime::register_toolin setup and callsresolve_with_ceilinginsideb.iter— the changed path.capability_resolve/mint_from_manifestcallsresolve_manifest, which mints from a manifest the caller already holds and has no registration to have prepared. That path still canonicalizes per call, deliberately: it is whataegis wrap --confineuses. It is the control here, not the result.
Hardware / OS as in the block at the top of this file, with the toolchain
stated rather than inherited: rustc 1.86.0 (05f9846f8 2025-03-31) — the
version rust-toolchain.toml pins — on Linux 6.6.87.2-microsoft-standard-WSL2,
AMD Ryzen AI 5 340 (4 CPU / 2 cores), WSL2, Criterion 0.5.1, plotters backend,
bench profile. The 2026-07-09 table above was taken on rustc 1.96.0 and is
not a valid before-value; the before column here was measured today, from a
worktree at 9b1d484, in the same session and on the same toolchain as the
after column.
Criterion [lower median upper]:
| Bench | before (9b1d484) | after | median delta |
|---|---|---|---|
hot_path/allow_all | [2.2905 2.3150 2.3428] µs | [221.18 224.61 228.43] ns | −90.3% (10.3×) |
hot_path/multi_rule | [2.6515 2.7314 2.8171] µs | [259.60 263.44 267.90] ns | −90.4% (10.4×) |
capability_resolve/mint_from_manifest (control — unchanged path) | [2.2668 2.2997 2.3351] µs | [2.4060 2.4457 2.4908] µs | +6.3% |
Why these are readable when AILAB-707’s were not. That section, above,
measured a predicted effect of one hash lookup and three Option<u64> mins
against a ±20% run-to-run noise floor, and correctly concluded nothing was attributable.
Here the effect is 10×: fifty times the noise floor, and the two arms move in
opposite directions in the way the change predicts — the group that stopped
touching the filesystem fell by an order of magnitude, and the group that still
touches it did not move outside noise. The control’s +6.3% is the wrong sign
for a speed-up and is exactly what a control should do. The before-value is also
corroborated rather than a single sample: AILAB-707’s pre-change
hot_path/multi_rule on this same toolchain was 2.7286 µs, against 2.7314 µs
measured today.
What the saving is, and what it is not. It is 2.47 µs off stations 1–2.
Stations 1–2 are not a call — that is the label the row above carries and it
still applies. Against the end-to-end figures in
cell_and_audit.md,
2.47 µs is:
- 6.6 – 7.5% of an audited call against the shipped Volatile sink (32.9 – 37.4 µs)
- 0.014 – 0.086% of an audited call against a Durable sink (2.86 – 17.5 ms)
So a call did not get ten times faster and no sentence here should be read that way. The 10× is a component figure for a component that is a small and, on the durable arm, negligible share of what a call costs. This work was queued for the grant-id spelling gap and the process-global counter; the speed-up is a side effect of doing it, not the case for it.
Provisional, like everything else in this directory. These were taken on a box that swings ±20% on identical binaries and was doing other work at the time. The shape — an order of magnitude on the changed path, noise on the control — is what survives; the digits are not a baseline. AILAB-796 is the ticket that re-measures on a quiet machine.
Cell instantiation + audit emission Criterion results (AEG-005 / AILAB-683)
Covers the two AEG-005 rows hot_path.md deliberately does not: wasmtime
instantiation (warm and cold) and audit record emission. Policy and capability
numbers stay in
hot_path.md.
Hardware / OS
uname -a
Linux botzrDev 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun 5 18:30:46 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
lscpu (selected):
Architecture: x86_64
Model name: AMD Ryzen AI 5 340 w/ Radeon 840M
CPU(s): 4
Thread(s) per core: 2
Core(s) per socket: 2
Hypervisor vendor: Microsoft
Virtualization type: full
rustc -Vv:
rustc 1.86.0 (05f9846f8 2025-03-31)
host: x86_64-unknown-linux-gnu
Environment notes: WSL2 / Linux; Criterion 0.5.1; Gnuplot not installed
(plotters backend); wasmtime 36.0.13 and wasmtime-wasi 36.0.12
(Cargo.lock); bench profile (optimized). Date: 2026-08-10.
Toolchain differs from hot_path.md. That file cites rustc 1.96.0; this run
used the repo-pinned 1.86 (rust-toolchain.toml). Numbers across the two files
are not strictly comparable.
Command
cargo bench -p botzr-aegis-sandbox -p botzr-aegis-audit
Summary vs targets
| Group | Median | Target | Status |
|---|---|---|---|
instantiation/warm | 49.339 µs | < 0.5 ms | pass (~10× under) |
instantiation/cold | 39.490 ms | < 5 ms | fail (~7.9× over) |
instantiation/cold_engine_only | 456.08 µs | informational | n/a |
instantiation/cold_compile_only | 29.351 ms | informational | n/a |
audit_emission/begin_complete | 4.7185 ms | none, deliberately — see below | n/a |
audit_emission/serialize_only | 468.46 ns | informational | n/a |
Criterion reports [lower median upper] as time/op; medians above are the
middle sample. All six come from a single process run.
Criterion text tables
instantiation/warm time: [48.175 µs 49.339 µs 50.624 µs]
instantiation/cold time: [36.838 ms 39.490 ms 42.364 ms]
instantiation/cold_engine_only
time: [438.01 µs 456.08 µs 478.53 µs]
instantiation/cold_compile_only
time: [28.335 ms 29.351 ms 30.455 ms]
audit_emission/begin_complete
time: [4.6635 ms 4.7185 ms 4.7730 ms]
audit_emission/serialize_only
time: [462.98 ns 468.46 ns 474.43 ns]
Stability across runs
This box is noisy for the compile-bound groups, so the verdicts below rest on four consecutive runs rather than the single published one. Blank cells are groups that did not exist yet in that run.
| Run | warm | cold | cold_engine_only | cold_compile_only | begin_complete |
|---|---|---|---|---|---|
| 1 | 54.519 µs | 27.799 ms | — | — | 4.6620 ms |
| 2 | 57.894 µs | 37.237 ms | 412.58 µs | 29.806 ms | — |
| 3 | 54.493 µs | 29.700 ms | 441.35 µs | 29.601 ms | 5.0875 ms |
| 4 (published) | 49.339 µs | 39.490 ms | 456.08 µs | 29.351 ms | 4.7185 ms |
cold swings 27.8–39.5 ms (~±20% around its mean) while its two components are
comparatively stable — engine construction 412–456 µs, compile/link
29.4–29.8 ms. In run 4 the components sum to ~29.8 ms against a 39.5 ms cold
median, i.e. the splits do not reconcile with cold run-for-run; only the
per-component medians are stable enough to attribute with. Both verdicts hold in
every run regardless: warm clears 0.5 ms by 8.6–10.1×, cold misses 5 ms by
5.6–7.9×.
What each group actually measures
instantiation/warm — includes a WIT run and a tokio runtime, not
instantiation alone. PreparedTool.tool_pre is private and there is
deliberately no public instantiate-only API (adding one to make a bench possible
is forbidden by PRD §10), so the warm iteration goes through
SandboxEngine::execute. That is, per iteration:
block_onruns the guest future on the engine’s tokio runtime (crates/botzr-aegis-sandbox/src/engine.rs). These numbers were measured before AILAB-809, when a fresh current-thread runtime was built per call and was therefore inside the median; the engine now builds one runtime inSandboxEngine::newand reuses it, so this step is amortized and the figure below is an upper bound on today’s cost (re-measuring is AILAB-796’s);build_store— WASI ctx from the grant, memory limiter, epoch deadline;tool_pre.instantiate_async— the actual warm instantiation;- the WIT
runexport.
The fixture is tests/fixtures/echo-tool/echo.wasm, whose run is an identity
copy of a 4-byte input, so guest-side work is negligible — but the
canonical-ABI lower/lift of list<u8> is inside the median. 49.339 µs is an
upper bound on warm re-instantiation, not an isolated instantiate cost, and
the four components above were not separated because doing so would require new
public API.
The warm cache being measured is PreparedTool { tool_pre }
(crates/botzr-aegis-sandbox/src/engine.rs:160-161), populated by
linker.instantiate_pre at engine.rs:81 and re-instantiated at
engine.rs:286-289. The InstancePre in PreparedFixture is a different,
#[cfg(feature = "test-utils")] object and is not what these numbers cover.
instantiation/cold — fresh Engine + compile/link, no execute.
SandboxEngine::new() + prepare(ECHO) per iteration. It deliberately does not
call execute, so cold-compile cost is never conflated with warm
instantiation-from-cache.
Why cold misses 5 ms: it is prepare, not engine setup. Engine construction
— config, linker, WASI link, epoch ticker spawn, plus the ticker join that
Drop pays — is 456.08 µs, roughly 1–2% of every cold median observed.
prepare on an already-built engine is 29.351 ms: that is Component::new
(Cranelift compilation of the 62 KB wit-bindgen component) plus
linker.instantiate_pre link resolution plus ToolPre::new, dominated by the
Cranelift compile but not exclusively it. There is no epoch-ticker thread storm.
The 5 ms target does not survive contact with an AOT compile of a component of
this size on 4 vCPUs; reaching it needs a different mechanism, not tuning.
Execution Report §7 was amended accordingly rather than the measurement being
reshaped to fit.
audit_emission/begin_complete — the full two-line durable cycle.
CallSession::begin (emits the intent line) → set policy allowed / capability
granted / execution success / metrics → complete (emits the outcome line).
Two JSONL lines means two sync_all calls
(crates/botzr-aegis-audit/src/writer.rs:74); the fsync-per-line is the shipped
G3 durability default and was not stripped for the bench. One writer is reused
across iterations so TempDir creation is not timed.
The cycle is fsync-bound, and that is measured rather than assumed.
serialize_only runs the same two records through the already-public
to_json_line with no file write and no fsync: 468.46 ns, or ~0.01% of
the 4.7185 ms cycle. Serialization is not a meaningful cost here; essentially
the entire median is write + fsync latency on WSL2’s filesystem. Expect it to
move by an order of magnitude on a different filesystem, and do not cite it as
an Aegis-side overhead figure.
There is no target on that row, and the blank is a decision rather than an
omission (AILAB-851). serialize_only settles what the median is made of:
468.46 ns of Aegis-side work inside a 4.7185 ms cycle, so ~99.99% of it is
write + fsync latency on WSL2’s filesystem. A number in that column would
therefore be a durability target for someone else’s storage stack — it would
pass on an NVMe host and fail on a network mount without a line of this
repository changing, which is a claim about the hardware wearing a claim about
Aegis. The sentence above already forbids citing this figure as Aegis-side
overhead; a target would quietly license exactly that. What the row is for is
attribution, and it does that job with serialize_only beside it. If a
durability target is ever wanted it belongs on a named storage profile, not on
this row.
Isolated ed25519 signing (AILAB-620)
Added because the previous section makes the emission cycle unusable as a
crypto number: at 4.7185 ms it is a measurement of fsync, and quoting a 50 µs
signing target against it would be a claim about the filesystem. This group
measures one thing — SigningKey::sign over bytes already in hand.
Hardware / OS / toolchain: identical to the run above (AMD Ryzen AI 5 340,
4 vCPUs, WSL2 Linux 6.6.87.2, rustc 1.86.0, Criterion 0.5.1, bench profile,
plotters backend). Date: 2026-08-11.
cargo bench -p botzr-aegis-audit --bench sign
| Group | Median | Target | Status |
|---|---|---|---|
audit_signing/sign_outcome_line | 13.765 µs | < 50 µs (AILAB-620) | pass (~3.6× under) |
audit_signing/sign_outcome_line
time: [13.641 µs 13.765 µs 13.907 µs]
Found 4 outliers among 100 measurements (4.00%)
4 (4.00%) high mild
Per iteration: one ed25519 signature over the canonical signing input of a
representative outcome line — the JCS form with signature absent and
key_id present (ADR-0003), built once outside the loop. No AuditWriter,
no file, no fsync, no canonicalization inside the measured region, so the median
is the signature and nothing else. The key comes from insecure_dev_key:
signing cost is a property of ed25519, not of which 32 bytes the seed holds, and
a fixed seed keeps the bench deterministic. Nothing here reads a key file.
What this does and does not license. 13.765 µs is the cost of signing a
line. It is not the cost of emitting one — that is begin_complete above, and
it is ~340× larger because of two sync_all calls. Adding signing to the shipped
emit path is therefore invisible against fsync; that is a statement about how
expensive durability is, not about how cheap ed25519 is. Cite the two numbers
together or neither.
An audited call, end to end (AILAB-851)
Added because nothing here or in
hot_path.md
measured a call. hot_path/multi_rule is stations 1–2 at 2.71 µs;
audit_emission/begin_complete is the two-line durable cycle on its own. Neither
is what one Runtime::execute_host_call costs, and the two do not add up to it
either. This group drives the whole pipeline — POLICY → CAPABILITY → SANDBOX →
AUDIT — with a host handler as the executable, so wasmtime compilation stays in
instantiation/cold where it belongs.
It lives in this file rather than a fourth one because
benches/README.md
declares exactly three scopes and all three are included verbatim into the book’s
Benchmarks chapter. This file already owns audit record emission; an audited call
is that scope one level up.
Two arms, one variable. Both runtimes are built identically and differ only in
the sink: durable_sink is a FileChainSink with a provisioned key — the
configuration that retains evidence, and the one a Durable sink requires a real
key for (ADR-0012) — and volatile_sink is the shipped default, an in-memory
Chain that emits, signs and chains every record and then keeps none of it past
the process.
EVERY NUMBER BELOW IS PROVISIONAL
This box swings ±20% on identical binaries — the instantiation/cold table above
is the evidence — and these were taken while it was doing other work. For this
group there is now direct evidence rather than an inherited caution: re-running it
in a second independent session on the same box did not reproduce the first
session’s durable_sink figures at all. The two sessions’ ranges do not overlap,
a 6.1× spread across six runs, and the table below records every one of them.
AILAB-796 is the ticket that re-measures on a quiet machine, and until it
lands nothing here should be quoted as a median. What is worth reading is the
shape: two to three orders of magnitude between the two sinks.
Hardware / OS / toolchain: identical to the first run in this file (AMD Ryzen
AI 5 340, 4 vCPUs, WSL2 Linux 6.6.87.2, rustc 1.86.0, Criterion 0.5.1, bench
profile, plotters backend). Date: 2026-09-01.
cargo bench -p botzr-aegis-runtime --bench audited_call
| Group | Range across six runs (provisional) | Target | Status |
|---|---|---|---|
audited_call/durable_sink | 2.86 – 17.5 ms — spans 6.1× across six runs in two sessions; not reproducible | none — see begin_complete above | n/a |
audited_call/volatile_sink | 32.9 – 37.4 µs — reproduces within 1.14× | none set | n/a |
audited_call/durable_sink
time: [3.0238 ms 3.2586 ms 3.5437 ms]
Found 2 outliers among 20 measurements (10.00%)
audited_call/volatile_sink
time: [34.153 µs 34.432 µs 34.799 µs]
Found 2 outliers among 20 measurements (10.00%)
Stability across six runs in two independent sessions
Two independent sessions on the same box, three runs each. Nothing about the binary, the hardware or the bench parameters differs between them.
| Run | Session | durable_sink | volatile_sink | ratio |
|---|---|---|---|---|
| 1 | 2026-09-01 | 3.2586 ms | 34.432 µs | 94.6× |
| 2 | 2026-09-01 | 2.8585 ms | 34.791 µs | 82.2× |
| 3 | 2026-09-01 | 3.7153 ms | 32.889 µs | 113.0× |
| 4 | 2026-09-07 | 17.505 ms | 34.889 µs | 501.7× |
| 5 | 2026-09-07 | 9.832 ms | 37.370 µs | 263.1× |
| 6 | 2026-09-07 | 13.895 ms | 33.566 µs | 414.0× |
The volatile arm is the only reproducible number here, and the ratio is the
least stable thing in the group. volatile_sink holds inside 1.14× across all
six runs and both sessions. durable_sink spans 6.12×, and the two sessions do
not merely differ — their ranges do not overlap at all, the second session’s
fastest run being 2.6× the first session’s slowest. Because the durable term
dominates the quotient, the ratio inherits every bit of that variance and spans
82×–502×. So the honest claim is two to three orders of magnitude, not a
figure, and no median for the durable arm belongs in this file at all. Anything
tighter needs AILAB-796.
What the pair shows
The configuration that is fast is the configuration that retains nothing.
Both arms emit two signed, chained records per call; the only difference is
whether those records survive the process. That difference spans 82×–502× across
the six runs above — and it is almost entirely fsync, for the reason
serialize_only established above: serialization is 468 ns against a
millisecond-scale cycle. Evidence that outlives
the process costs two sync_all calls, and no amount of Aegis-side tuning
touches that number.
It also puts hot_path/multi_rule in proportion. Stations 1–2 are 2.71 µs;
the same call reaching AUDIT against the unretained default is 34.4 µs, about
13× more; against a retaining sink it is 2.86–17.5 ms, another 82×–502× beyond
that. The published 2.71 µs covers well under a percent of a durable call and
must not be quoted as its cost.
The cross-check that would not reconcile, and its resolution
An end-to-end audited call appeared to measure less than
audit_emission/begin_complete (3.2586 ms against 4.7185 ms) while doing
strictly more work: the pipeline emits the same two lines through
CallSession::begin and complete
(crates/botzr-aegis-runtime/src/pipeline.rs),
and adds policy, capability, the request digest and handler dispatch on top.
That is not possible if both figures measure the same fsync work under the same
conditions, so at least one of them had to be non-comparable.
The six-run table above resolves it, and the split falls exactly on the
session boundary. All three runs from 2026-09-01 land below begin_complete’s
4.7185 ms (3.2586, 2.8585, 3.7153 ms); all three from 2026-09-07 land above it
(17.505, 9.832, 13.895 ms). Neither figure was wrong when it was taken — the
first session simply caught a quiet fsync window. The two numbers are not
separable at this measurement precision: the fsync term dominates both, and it
varies by more than the gap between them. The earlier explanation that
emission.rs writes a larger fixture record than this group’s grant remains
true, but it is not needed to account for the discrepancy and cannot be measured
apart from the noise.
That this was answerable one session later is entirely because the failure was published instead of omitted — a number that does not reconcile is evidence about the measurement, and hiding it would have left the next reader to rediscover it. AILAB-796 should still re-measure both in one process run.
Why the sample budget is capped
MemoryChainSink holds an Arc<Mutex<Vec<u8>>> and never truncates, so the
volatile arm retains every byte it emits for the life of the bench process. At
Criterion’s default budget it would append hundreds of megabytes and start
measuring the allocator. The group therefore sets a 500 ms warm-up, a 2 s
measurement window and 20 samples; peak RSS for the run above was 124 MB. Both
arms get the same budget, so the comparison is unaffected — but the sample count
is lower than the rest of this file, which is a second reason to treat these as
provisional.
Notes
- The sandbox crate’s benches are compiled with the
test-utilsfeature on, because a pre-existing self dev-dependency (crates/botzr-aegis-sandbox/Cargo.toml:43) enables it for all dev targets sotests/sandbox.rscan reach the fixture API. The bench uses notest-utilsitem: the feature gates only additiveprepare_fixture/execute_fixture/PreparedFixture, none of which is on the measuredprepare/executepath. - No public API was added to either crate to make these benches possible.
serialize_onlyusesto_json_line, which was already exported. - HTML reports live under
target/criterion/and are not committed.
aegis wrap relay overhead Criterion results (AILAB-625)
One number, measured end to end: what it costs to put aegis wrap between an
MCP client and a stdio child server. Per iteration a whole
run_wrap_with_streams session runs — signing key loaded, AuditWriter opened,
child spawned, 50 scripted JSON-RPC lines relayed and answered, child reaped,
close line written.
What it deliberately does not cover. This is not a confinement cost: this
run sets confinement: None, so wrap records without confining and the
capability/sandbox stations never run here. It is not a policy or wasmtime
number either — those live in
hot_path.md
and
cell_and_audit.md.
It is
not a portable figure: a recorded call is two JSONL lines and therefore two
sync_all calls, so the median is dominated by this filesystem. And it is not a
measure of any real MCP server’s work — the child is the in-repo mirror fixture,
which answers from a match.
Hardware / OS
uname -a
Linux botzrDev 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun 5 18:30:46 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
lscpu (selected):
Architecture: x86_64
Model name: AMD Ryzen AI 5 340 w/ Radeon 840M
CPU(s): 4
Thread(s) per core: 2
Core(s) per socket: 2
Hypervisor vendor: Microsoft
Virtualization type: full
rustc -Vv:
rustc 1.86.0 (05f9846f8 2025-03-31)
host: x86_64-unknown-linux-gnu
LLVM version: 19.1.7
Environment notes: WSL2 / Linux; Criterion 0.5.1 (Cargo.lock); Gnuplot not
installed (plotters backend); bench profile (optimized); repo-pinned toolchain
1.86 (rust-toolchain.toml). Date: 2026-08-12.
Same box and same toolchain as cell_and_audit.md, so the audit crate’s
audit_emission/begin_complete (4.7185 ms, 2026-08-10) is directly
comparable and is used below as the reference for the fsync cost sitting inside
every recorded call.
Command
cargo bench -p botzr-aegis-wrap
Summary vs targets
Criterion reports [lower median upper] per iteration (50 calls);
Throughput::Elements(50) turns the thrpt row into the per-call figure.
Per-call medians below are the published iteration median ÷ 50, which agrees
with the reciprocal of the thrpt median to four significant figures.
| Group | Median / session (50 calls) | Median / call | Target | Status |
|---|---|---|---|---|
wrap_relay/tools_call_recorded | 218.54 ms | 4.371 ms | 0.5–2 ms (informational) | miss (~2.19× over the 2 ms ceiling) |
wrap_relay/ping_relayed_only | 6.8027 ms | 136.05 µs | informational (attribution baseline) | n/a |
Derived attribution — recording, per recorded call: 4.235 ms.
(218.54 ms − 6.8027 ms) ÷ 50. Everything the two groups share — key load,
AuditWriter::open, child spawn, 50 relayed lines and 50 responses, reap,
close — cancels in the subtraction, leaving the per-call intent + outcome
pair. That 4.235 ms lands within 11% of the audit crate’s own two-line
begin_complete cycle (4.7185 ms), which is the expected result if wrap’s
recording is exactly two durable lines and nothing else.
The miss is the honest outcome, not a regression. The 0.5–2 ms target
(spec §1.9 / §3.4) is informational and predates the measurement. Two sync_all
calls on this filesystem cost ~4.2 ms on their own, so no arrangement of wrap
code reaches 2 ms per recorded call while durability stays at the shipped G3
default. Reaching it needs a different mechanism (batched or deferred fsync),
which is a durability decision, not a tuning exercise. Nothing was narrowed,
stripped, or made private-API-shaped to improve the number (PRD §10).
Criterion text tables
Raw output of the published run, verbatim:
Gnuplot not found, using plotters backend
Benchmarking wrap_relay/tools_call_recorded
Benchmarking wrap_relay/tools_call_recorded: Warming up for 3.0000 s
Warning: Unable to complete 100 samples in 5.0s. You may wish to increase target time to 19.8s, or reduce sample count to 20.
Benchmarking wrap_relay/tools_call_recorded: Collecting 100 samples in estimated 19.757 s (100 iterations)
Benchmarking wrap_relay/tools_call_recorded: Analyzing
wrap_relay/tools_call_recorded
time: [210.32 ms 218.54 ms 227.62 ms]
thrpt: [219.66 elem/s 228.80 elem/s 237.74 elem/s]
Found 11 outliers among 100 measurements (11.00%)
6 (6.00%) high mild
5 (5.00%) high severe
Benchmarking wrap_relay/ping_relayed_only
Benchmarking wrap_relay/ping_relayed_only: Warming up for 3.0000 s
Benchmarking wrap_relay/ping_relayed_only: Collecting 100 samples in estimated 5.0924 s (400 iterations)
Benchmarking wrap_relay/ping_relayed_only: Analyzing
wrap_relay/ping_relayed_only
time: [6.3020 ms 6.8027 ms 7.3371 ms]
thrpt: [6.8147 Kelem/s 7.3500 Kelem/s 7.9339 Kelem/s]
Found 18 outliers among 100 measurements (18.00%)
11 (11.00%) high mild
7 (7.00%) high severe
The Criterion warning is left in: the recorded group needs ~20 s for 100 samples, and the run was not shortened by cutting the sample count.
Stability across runs
Two consecutive runs of the same command on the same box. Run 1 is published
above; run 2 is the immediate repeat, so Criterion also printed its
change: comparison against run 1.
| Run | tools_call_recorded / call | ping_relayed_only / call | derived recording / call | outliers (rec / ping) |
|---|---|---|---|---|
| 1 (published) | 4.371 ms | 136.05 µs | 4.235 ms | 11% / 18% |
| 2 | 4.176 ms | 136.70 µs | 4.040 ms | 3% / 6% |
Criterion’s own verdict on run 2 was No change in performance detected for
both groups (p = 0.06 and p = 0.92). The published run is the noisier of the
two and its median is the slower of the two; the verdict is a miss in both.
What each group actually measures
wrap_relay/tools_call_recorded — the shipped recording path, whole session.
Every one of the 50 client lines is a tools/call with a distinct id, so every
line takes the full recording route in
crates/botzr-aegis-wrap/src/record.rs:59-119: serde_json::from_str on the raw
line, RequestDigest::of_request_bytes over the verbatim bytes
(record.rs:78), PolicySetHash::of_canonical_bytes over the pass-through
constant (record.rs:79), CallSession::begin (record.rs:110) — one intent
line, fsynced before the request is relayed
(crates/botzr-aegis-wrap/src/relay.rs:199-203) — and then, on the child’s
matching response, complete_relayed (record.rs:129-161) with the deny_all
grant, the response digest, the metrics and complete() — the second line, and
the second fsync (crates/botzr-aegis-audit/src/writer.rs:218). Also inside the
iteration and not amortised away: load_signing_key and AuditWriter::open
(relay.rs:90-91), the child spawn (relay.rs:93-102), the bounded reap
(relay.rs:319-333), the stderr-tee drain (relay.rs:127), and the close
line the AuditWriter writes on drop.
wrap_relay/ping_relayed_only — the same session with the recording removed.
ping is not tools/call, so observe_client_line returns None at
record.rs:66 before any digest, session or audit line exists; the line is
relayed with zero interception and the mirror child answers
{"result":{"mirrored":"ping"}}. The group therefore carries the identical
fixed cost — key, writer open/close, spawn, reap, drain — plus 50 round
trips of pure byte relay.
Its per-call figure is an upper bound on byte relay, not a measurement of it. 136.05 µs/call is 6.8027 ms of session cost divided by 50, and most of that session cost is the fixed part (one process spawn, one key load, two fsynced Session lines). This bench does not separate spawn from per-line relay, and no third group was added to try: the only figure it is used for is the subtraction above, where the fixed part cancels.
What is outside the measured region: the TempDir, the signing key
generation (generate_signing_key, once, hoisted), and both scripted request
bodies — all built before the first iteration. Inside the region but negligible
against milliseconds: a Vec<String> argv clone, a PathBuf join, and a few KB
of script.to_vec() memcpy per session.
Why the audit file is fresh per iteration. Each session writes to
recorded-<n>.jsonl / relayed-<n>.jsonl in one hoisted TempDir. Reusing a
single path would make AuditWriter::open recover the previous Session’s tail
from an ever-growing file, so the per-iteration cost would climb with the sample
number — a non-stationary measurement. File creation is consequently inside the
timed region, which is the honest trade and is far below the fsync cost that
dominates it.
Notes
- No public API was added to any crate to make this bench possible (PRD §10).
A group isolating the recording work alone — parse + digest +
CallSessionbegin/complete, with no child and no byte relay — was considered and skipped:record::observe_client_lineandcomplete_relayedarepub(crate)and a bench is an external crate, so the only way to reach them would have been to widenbotzr-aegis-wrap’s public surface for a measurement. The attribution is obtained by differencing two shipped-path groups instead, andbenches/results/cell_and_audit.md’saudit_emission/begin_completealready isolates the durable cycle from the other side. - Child-process spawn is amortised across the 50 calls in every group. A per-call figure derived from a one-call-per-process session would be much larger and would not describe how wrap is used — a wrap process lives for a whole client session.
- The outlier rates (up to 18% in run 1) are consistent with the 20 ms poll
granularity of
reap(relay.rs:47,relay.rs:319-333) and ofdrain(relay.rs:303-312) occasionally landing inside an iteration when the child has not yet been reaped by the time the pump breaks. That mechanism is a hypothesis from reading the code, not something this bench measured; per-sample data would be needed to confirm it. - Wrap does not meter the child, so nothing in these numbers is the child’s own
resource use.
CallMetrics::peak_memory_bytesis recorded as0= “not measured” (record.rs:152-155). - HTML reports live under
target/criterion/and are not committed.