Purpose: Track and validate performance budgets for pi_agent_rust.
Phase-0 canonical UX/SLI contract lives in docs/perf_sli_matrix.json (schema: pi.perf.sli_matrix.v1).
- Primary release-deciding metrics are user-visible E2E/responsiveness SLIs.
- Micro-benchmarks in this file are diagnostic/supporting metrics.
- Scenario-to-SLI mapping is keyed by
docs/e2e_scenario_matrix.jsonworkflow IDs. - Downstream PERF-3X validation beads must consume SLI results directly from the contract artifact.
# Run all benchmarks
cargo bench
# Run specific benchmark
cargo bench "truncation"
cargo bench "sse_parsing"
cargo bench "ext_policy"
cargo bench "ext_js_runtime"
# Run with baseline comparison
cargo bench -- --save-baseline main
cargo bench -- --baseline mainThese are the target performance metrics. Regressions beyond these thresholds should be investigated.
| Benchmark | Budget | Current | Status |
|---|---|---|---|
| startup/version | <100ms (p95) | ~11ms | ✅ |
| startup/help | <150ms (p95) | ~15ms | ✅ |
| startup/list_models | <200ms (p95) | ~25ms | ✅ |
| binary/size_mb | <20MB | ~7.6MB | ✅ |
| memory/version_peak | <50MB RSS | TBD | ⬜ |
| Benchmark | Budget | Current | Status |
|---|---|---|---|
| truncate_head (10K lines) | <1ms | ~250μs | ✅ |
| truncate_tail (10K lines) | <1ms | ~250μs | ✅ |
| sse_parse (100 events) | <100μs | ~50μs | ✅ |
| ext_policy/evaluate | <1μs | ~20ns | ✅ |
| ext_dispatch/decision | <10μs | ~100ns | ✅ |
| ext_protocol/parse | <100μs | ~5μs | ✅ |
| ext_js_runtime/cold_start | <200ms | ~308μs | ✅ |
| ext_js_runtime/warm_eval_noop | <25ms | ~3.50μs | ✅ |
| ext_js_runtime/warm_run_pending_jobs_empty | <1μs | ~84ns | ✅ |
| ext_js_runtime/tool_call_roundtrip | <500μs | ~43.9μs | ✅ |
| Benchmark | Budget | Current (debug) | Status |
|---|---|---|---|
| ext_cold_load_simple_p95 (100 extensions) | p95 < 200ms | 106ms | ✅ |
| ext_cold_load_per_ext_p99 (worst ext) | p99 < 100ms | 134ms (hjanuschka-plan-mode) | ⬜* |
| ext_warm_load_p95 (100 extensions) | p95 < 100ms | 734μs | ✅ |
| ext_warm_load_per_ext_p99 (worst ext) | p99 < 100ms | 926μs (jyaunches-pi-canvas) | ✅ |
| event_dispatch_p99 (AgentStart, PR mode) | p99 < 5ms | 616μs | ✅ |
*Cold load per-extension P99 exceeds debug-mode budget but is expected to pass in release (release cold loads are typically ~5-10ms). Budget assertions are release-only.
Baseline data: tests/perf/reports/ext_bench_baseline.json
Outlier analysis: tests/perf/reports/BASELINE_REPORT.md
These budgets target extension overhead, not end-to-end LLM latency.
- Cold start: first time an extension runtime is created/initialized for a process (cold caches).
- Warm start: extension runtime is already initialized (warm caches); measures steady-state overhead.
- Hook overhead: incremental latency added by routing a tool call through a no-op extension hook.
- Hostcall dispatch: cost to invoke a single hostcall across the connector boundary (no-op payload).
- Hardware class: GitHub Actions
ubuntu-latestrunner (x86_64). Treat numbers as CI budgets; local machines will vary. - Percentiles: budgets are specified as p95/p99 to avoid overfitting to median-only results on shared CI runners.
- Benchmarks: extension benchmarks will live under
benches/extensions.rs(planned) and should report:- cold vs warm timings separately
- a baseline (no extension) vs no-op extension delta for hook overhead
- enough samples to make percentile reporting meaningful on CI
Processing throughput for text truncation operations:
truncation/head/1000 time: [32 µs] thrpt: [2.3 GiB/s]
truncation/head/10000 time: [251 µs] thrpt: [3.0 GiB/s]
truncation/head/100000 time: [2.3 ms] thrpt: [3.3 GiB/s]
truncation/tail/1000 time: [~32 µs] thrpt: [~2.3 GiB/s]
truncation/tail/10000 time: [~251 µs] thrpt: [~3.0 GiB/s]
truncation/tail/100000 time: [~2.3 ms] thrpt: [~3.3 GiB/s]
Key observations:
- Throughput is consistent at 2.3-3.3 GiB/s regardless of input size
- Head and tail truncation have similar performance
- Well within the 1ms budget for typical file sizes (10K lines)
Server-Sent Events parsing throughput:
sse_parsing/parse/100 time: [50.129 µs 50.315 µs 50.504 µs]
thrpt: [1.9800 Melem/s 1.9875 Melem/s 1.9949 Melem/s]
sse_parsing/parse/1000 time: [495.54 µs 495.96 µs 496.40 µs]
thrpt: [2.0145 Melem/s 2.0163 Melem/s 2.0180 Melem/s]
benches/
├── bench_env.rs # Shared environment validation and fingerprinting
├── tools.rs # Core operation benchmarks
│ ├── truncation # Text truncation (head/tail)
│ ├── sse_parsing # SSE event parsing
│ ├── sse_stream # Streaming SSE parsing at various chunk sizes
│ └── streaming_clone # Arc<AssistantMessage> vs deep clone
├── extensions.rs # Connector dispatch + policy / protocol parsing
│ ├── ext_policy
│ ├── ext_required_capability
│ ├── ext_dispatch
│ ├── ext_protocol
│ ├── ext_js_runtime # QuickJS cold/warm start + no-op eval
│ ├── hostcall_* # Hostcall conversion, hashing, dispatch
│ └── js_serde_bridge # JS↔Rust serialization roundtrip
├── system.rs # System-level benchmarks (process spawn)
│ ├── startup # Startup time (version, help, list_models)
│ ├── memory # RSS memory measurement
│ └── binary # Binary size tracking
├── tui_perf.rs # TUI rendering benchmarks (PERF-8)
│ ├── build_conversation_content
│ ├── view # Full TUI render
│ ├── viewport_operations
│ └── markdown_rendering
└── session_save.rs # Session clone benchmarks
scripts/
└── bench_env_setup.sh # OS-level benchmark environment standardization
- Add benchmark function to
benches/tools.rs:
fn bench_new_operation(c: &mut Criterion) {
let mut group = c.benchmark_group("new_operation");
// Test with different input sizes
for size in [100, 1000, 10000] {
let input = generate_input(size);
group.throughput(Throughput::Elements(size as u64));
group.bench_with_input(
BenchmarkId::new("name", size),
&input,
|b, input| {
b.iter(|| pi::module::function(black_box(input)));
},
);
}
group.finish();
}
// Add to criterion_group!
criterion_group!(benches, ..., bench_new_operation);- Add performance budget to this document
- Run benchmark:
cargo bench new_operation
Performance regression detection in GitHub Actions:
# .github/workflows/bench.yml
name: Benchmarks
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
- name: Build release binary
run: cargo build --release
- name: Check binary size budget
run: |
SIZE_MB=$(stat --printf="%s" target/release/pi | awk '{printf "%.2f", $1/1024/1024}')
echo "Binary size: ${SIZE_MB}MB"
if (( $(echo "$SIZE_MB > 20" | bc -l) )); then
echo "::error::Binary size ${SIZE_MB}MB exceeds 20MB budget"
exit 1
fi
- name: Run benchmarks
run: |
cargo bench --bench tools -- --noplot
cargo bench --bench extensions -- --noplot
cargo bench --bench system -- --noplot
- name: Generate PiJS workload perf data (JSONL)
run: |
set -euxo pipefail
mkdir -p target/perf/perf
PI_BENCH_BUILD_PROFILE=perf cargo run --profile perf --example pijs_workload -- --iterations 2000 --tool-calls 1 > target/perf/perf/pijs_workload_perf.jsonl
PI_BENCH_BUILD_PROFILE=perf cargo run --profile perf --example pijs_workload -- --iterations 2000 --tool-calls 10 >> target/perf/perf/pijs_workload_perf.jsonl
- name: Perf budget gate
run: cargo test --test perf_budgets -- --nocapture
- name: Upload benchmark results
uses: actions/upload-artifact@v4
with:
name: benchmark-results
path: target/criterion/
retention-days: 30Compare against a known good baseline:
# Save baseline on main branch
cargo bench -- --save-baseline main
# After changes, compare
cargo bench -- --baseline main
# Look for regressions > 10%System benchmarks spawn real processes, so variance is higher than micro-benchmarks:
- Micro-benchmarks (tools.rs, extensions.rs): Use criterion defaults (100+ samples)
- System benchmarks (system.rs): Use 20 samples, 10s measurement time
- CI runners: Expect 2-3x variance vs local machines; focus on relative changes
- Percentiles: Report p95/p99 for budgets, not just mean
All benchmark suites use a shared environment module (benches/bench_env.rs) that:
- Validates the execution environment at startup (CPU governor, turbo boost, ASLR, THP)
- Fingerprints every run with OS, CPU, cores, memory, governor, turbo, ASLR, THP, and config hash
- Computes a noise score (0 = optimal) and warns when conditions are suboptimal
The scripts/bench_env_setup.sh script standardizes the OS for low-variance results:
# Check current environment suitability
./scripts/bench_env_setup.sh validate
# Apply optimal settings (requires root)
sudo ./scripts/bench_env_setup.sh apply
# Run benchmarks with CPU affinity and priority
./scripts/bench_env_setup.sh run cargo bench
# Emit JSON fingerprint for artifact tracking
./scripts/bench_env_setup.sh fingerprint
# Restore original settings
sudo ./scripts/bench_env_setup.sh restoreWhat it controls:
| Setting | Optimal | Why |
|---|---|---|
| CPU governor | performance |
Fixed frequency eliminates DVFS variance |
| Turbo boost | disabled | Prevents thermal-dependent frequency shifts |
| ASLR | disabled | Reproducible memory layouts |
| THP | never |
Avoids latency spikes from page coalescing |
Environment variables:
| Variable | Default | Description |
|---|---|---|
BENCH_CORES |
0,1 |
CPU cores for taskset affinity |
BENCH_GOVERNOR |
performance |
CPU frequency governor to set |
BENCH_NICE |
-20 |
Nice priority for bench processes |
Noise score interpretation:
| Score | Meaning |
|---|---|
| 0 | Optimal — all settings applied |
| 1-2 | Minor — THP or ASLR not ideal |
| 3-5 | Moderate — governor or turbo not controlled |
| 6-7 | High — multiple sources of variance |
CI applies environment setup automatically before benchmarks. The [bench-env] banner
in benchmark stderr output includes the noise score for every run.
This workstream uses a strict baseline → profile → prove → implement → verify loop.
- Use Criterion for stable micro-bench artifacts:
cargo bench --bench extensions -- ext_js_runtime. - Use
hyperfinefor end-to-end CLI paths (if installed):
hyperfine --warmup 3 --runs 10 'target/release/pi --version'- Use the PiJS workload harness for deterministic extension roundtrips:
scripts/bench_extension_workloads.shCommands:
hyperfine --warmup 3 --runs 10 'target/perf/pijs_workload --iterations 200 --tool-calls 1'
hyperfine --warmup 3 --runs 10 'target/perf/pijs_workload --iterations 200 --tool-calls 10'Summary (times in ms):
| Scenario | Mean ± σ | Min / Max | per_call_us | calls/sec |
|---|---|---|---|---|
| pijs_workload_200x1 | 16.96 ± 0.98 | 15.78 / 19.00 | 44 | 22,716 |
| pijs_workload_200x10 | 97.09 ± 4.27 | 93.08 / 105.57 | 43 | 22,883 |
JSONL logs (hyperfine + workload):
{"tool":"hyperfine","scenario":"pijs_workload_200x1","command":"target/perf/pijs_workload --iterations 200 --tool-calls 1","mean_ms":16.96,"stddev_ms":0.98,"min_ms":15.78,"max_ms":19.00}
{"tool":"hyperfine","scenario":"pijs_workload_200x10","command":"target/perf/pijs_workload --iterations 200 --tool-calls 10","mean_ms":97.09,"stddev_ms":4.27,"min_ms":93.08,"max_ms":105.57}
{"schema":"pi.perf.workload.v1","tool":"pijs_workload","scenario":"tool_call_roundtrip","iterations":200,"tool_calls_per_iteration":1,"total_calls":200,"elapsed_ms":8,"per_call_us":44,"calls_per_sec":22716,"build_profile":"perf"}
{"schema":"pi.perf.workload.v1","tool":"pijs_workload","scenario":"tool_call_roundtrip","iterations":200,"tool_calls_per_iteration":10,"total_calls":2000,"elapsed_ms":87,"per_call_us":43,"calls_per_sec":22883,"build_profile":"perf"}Raw artifacts (local):
target/perf/perf/hyperfine_pijs_workload_200x1_perf.jsontarget/perf/perf/hyperfine_pijs_workload_200x10_perf.jsontarget/perf/perf/pijs_workload_perf.jsonl
- CPU hotspots:
cargo flamegraph --bench extensions(requirescargo install flamegraph). - Allocations:
heaptrack cargo bench --bench extensions(Linux). - Flamegraph run (2026-02-05):
cargo flamegraph --bench extensions -- ext_js_runtime --noplotcompiled benches successfully, then failed during sampling becauseperf_event_paranoid=4on this host (no perf access). Retry on a host withCAP_PERFMON(or lowerperf_event_paranoid) and keep the resulting SVG as the flamegraph artifact.
Hotspot snapshot from Criterion new/estimates.json (mean point estimate):
| Benchmark | Mean (ns) | Mean (μs) | Relative cost vs warm_eval_noop |
|---|---|---|---|
ext_js_runtime/cold_start |
307,950.60 | 307.95 | 88.0× |
ext_js_runtime/tool_call_roundtrip |
43,915.12 | 43.92 | 12.6× |
ext_js_runtime/warm_eval_noop |
3,498.12 | 3.50 | 1.0× |
ext_js_runtime/warm_run_pending_jobs_empty |
84.45 | 0.08 | 0.02× |
- Keep outputs reproducible: record environment (
[bench-env] ... config_hash=...emitted bybenches/extensions.rs). - Store benchmark artifacts in
target/criterion/(Criterion JSON + reports). - Use
--save-baseline/--baselinecomparisons for regression detection.
| Opportunity | Evidence | Expected impact | Confidence | Effort | Score | Notes |
|---|---|---|---|---|---|---|
| Cache compiled extension setup program across repeated loads | ext_js_runtime/cold_start = 307.95μs dominates runtime hotspot table |
-150μs to -220μs cold-start cost on repeated extension loads | 4 | 3 | 5.33 | Keep module hash keyed by source+runtime config; preserve deterministic teardown semantics |
| Reduce JSON bridge overhead in hostcall tool path | ext_js_runtime/tool_call_roundtrip = 43.92μs and pijs_workload steady-state per-call = 43–46μs |
-8μs to -15μs per roundtrip | 3 | 2 | 4.50 | Target serialization/path allocation churn first; validate with criterion baseline diff |
Keep run_pending_jobs empty fast path as invariant |
ext_js_runtime/warm_run_pending_jobs_empty = 84.45ns |
Avoid regressions in scheduler idle overhead | 5 | 1 | 5.00 | No optimization work needed; treat as guardrail metric in future PRs |
# Record profile
cargo bench -- --profile-time 10
perf record -g target/release/deps/tools-*
# Analyze
perf reportheaptrack cargo bench
heaptrack_gui heaptrack.tools.*.gzcargo install flamegraph
cargo flamegraph --bench toolsTarget metrics for Rust vs TypeScript:
| Operation | TypeScript | Rust Target | Rust Actual |
|---|---|---|---|
| Startup | ~200ms | <100ms | 11.2ms ✅ |
| 10K line truncate | ~10ms | <1ms | 250μs ✅ |
| 100 SSE events | ~5ms | <100μs | 50.3μs ✅ |
| Binary size | N/A (Node) | <20MB | 7.6MB ✅ |
| Memory (idle) | ~80MB | <50MB | TBD |
Per-extension load time comparison across all 60 official extensions.
Both runtimes load the same unmodified .ts files. TS uses Bun/jiti (native V8-based eval).
Rust uses QuickJS with SWC transpilation.
| Metric | Rust (QuickJS) | TS (Bun/jiti) |
|---|---|---|
| Mean load time | 103ms | 2ms |
| Min load time | 96ms | 1ms |
| Max load time | 131ms | 51ms |
Known regression: Extension loading in Rust is ~50-100x slower due to:
- SWC TypeScript-to-JavaScript transpilation per-load
- QuickJS bytecode compilation (no JIT)
- Virtual module system resolution overhead
Why this is acceptable: The loading cost is a one-time cold-start per session. Steady-state operations are orders of magnitude faster in Rust:
- Tool call roundtrip: 44μs (Rust) vs ~5ms (TS)
- Policy evaluation: 20ns (Rust)
- Event hook dispatch: sub-50μs (Rust)
Planned mitigation: Compiled bytecode caching (see Opportunity Matrix above) to amortize cold-start across sessions.
Full per-extension data: tests/ext_conformance/reports/performance_comparison.json
Regenerate: cargo test --test performance_comparison generate_performance_comparison -- --nocapture
The unified benchmark harness (tests/ext_bench_harness.rs) runs extension load and event dispatch
scenarios with per-extension timeouts, budget checks, and full environment fingerprinting.
# PR mode — diverse 10-extension subset, 10 iterations, ~3-4s in debug
PI_BENCH_MODE=pr cargo test --test ext_bench_harness --features ext-conformance -- --nocapture
# Nightly mode — full safe corpus, 50 iterations
PI_BENCH_MODE=nightly cargo test --test ext_bench_harness --features ext-conformance -- --nocapture
# Custom mode — tune all parameters
PI_BENCH_MODE=custom PI_BENCH_MAX=25 PI_BENCH_ITERATIONS=20 PI_BENCH_EVENT_COUNT=100 \
cargo test --test ext_bench_harness --features ext-conformance -- --nocapture| Variable | Default | Description |
|---|---|---|
PI_BENCH_MODE |
pr |
Mode: pr, nightly, or custom |
PI_BENCH_MAX |
10 (pr) / 200 (nightly) / 20 (custom) | Max extensions to benchmark |
PI_BENCH_ITERATIONS |
10 (pr) / 50 (nightly) / 20 (custom) | Iterations per extension per scenario |
PI_BENCH_EVENT_COUNT |
50 (pr) / 200 (nightly) / 100 (custom) | Event dispatch iterations |
PI_BENCH_TIMEOUT_SECS |
30 | Per-extension timeout (skips slow extensions) |
PR mode selects a diverse representative subset to maximize API surface coverage:
- 2 official extensions (1 with tool registration, 1 with event subscriptions)
- 2 community extensions (1 with commands+events, 1 with tools+commands+flags)
- 2 npm-registry extensions (1 with commands, 1 with events)
- Remaining slots filled from safe pool in manifest order
This ensures each run exercises tools, commands, flags, and event hooks.
| Scenario | What it measures | Method |
|---|---|---|
cold_load |
Fresh runtime + context creation per iteration | New ExtensionManager + JsExtensionRuntimeHandle::start() + load_js_extensions() |
warm_load |
Repeated load on shared runtime (cache-hit path) | Single runtime, repeated load_js_extensions() after warmup |
event_dispatch |
Event hook dispatch latency across loaded extensions | dispatch_event(AgentStart, payload) on loaded corpus |
| Budget | Threshold | Enforced |
|---|---|---|
ext_cold_load_simple_p95 |
200ms | Release builds only |
event_dispatch_p99 |
5ms | Release builds only |
ext_warm_load_p95 |
100ms | Release builds only |
Budget assertions are skipped in debug builds (debug mode is naturally 5-10x slower).
All outputs go to target/perf/:
| File | Format | Content |
|---|---|---|
ext_bench_harness.jsonl |
JSONL | One pi.ext.rust_bench.v1 record per extension per scenario |
ext_bench_harness_report.json |
JSON | Full report with env, config, summaries, budget checks |
BENCH_HARNESS_REPORT.md |
Markdown | Human-readable summary with tables |
- P50/P95/P99 are computed per-extension from raw microsecond samples
- Cold load times include QuickJS runtime creation (~70ms in debug, ~5ms in release)
- Warm load times measure only the
load_js_extensions()call (~300-800us) - Event dispatch measures
dispatch_event()latency (~40-700us depending on loaded extensions) - Aggregate budget checks use the P95 across all per-extension P95 values
To intentionally update baseline thresholds:
- Run the harness in release mode to get accurate numbers:
cargo test --release --test ext_bench_harness --features ext-conformance -- --nocapture - Review
target/perf/ext_bench_harness_report.jsonfor actual P95/P99 values - Update the threshold constants in
check_budgets()intests/ext_bench_harness.rs - Document the justification in the commit message
- Run the harness 3 times and compare P95 values
- Variance > 20% between runs indicates environmental noise
- Consistent P95 increase > 50% across runs indicates a real regression
- Check the
envfingerprint in JSONL to ensure same hardware/build profile
- Benchmarks run in release mode with LTO enabled
- Times measured on standard CI hardware (GitHub Actions)
- Throughput measured in GiB/s or elements/sec
- Use
--save-baselineand--baselinefor regression detection