# Milestone 12: performance measurement pass + aarch64 test execution

Goal: close the two gaps the post-Phase-10 completeness sweep found — PLAN §18 has no measurement infrastructure (deferred at specs/milestone-7.md:113 and never delivered), and aarch64 tests are cross-built but never executed (PLAN.md:145 promised qemu).

## Rulings (binding)

1. **Bench harness = `tools/bench.zig` + `zig build bench`.** Not in src/ — src/ is the shipped product; tests.zig aggregates everything shippable and the bench must be a separate compilation anyway (one-file-one-module rule; matcher.zig and dns_cache.zig already belong to the test compilation). Module wiring per the fuzz pattern (build.zig:68-92): modules rooted at src/filter/matcher.zig and src/cache/dns_cache.zig — their relative import closures come along; no sqlite/mbedTLS linking needed. `if (b.args) |args| run.addArgs(args)`.

2. **What it measures** (PLAN §18 targets):
   - `filter`: `matcher.normalize` + `Snapshot.evaluate` per op against a Snapshot built from a generated, sorted `d{d:0>7}.example.com` list body (~1M lines, in-memory; DomainSet.max_count 4M and compiler.max_domains 2M leave headroom) plus a small wild body. Mixed case ratios: hit, miss, parent-walk. Target p95 < 1 ms.
   - `cache`: `buildKey` + `DnsCache.get` + `packet.setId` (the handler's hit path; TTL aging happens inside get) on a ~10k-entry cache prefilled with the fuzz corpus response. Mixed hit/miss. Target p95 < 5 ms.
   - `compile`: `compiler.compile` over a generated 1M-line hosts body — wall time, informational (no §18 target; no datapoint exists today).
   - Memory: `/proc/self/status` VmRSS (nothing in-repo wraps it; read it directly) plus the in-repo accounting (`Snapshot.memoryBytes`, `DnsCache.memoryBytes`). Target < 100 MB RSS with ~1M domains loaded.
   - Timing: `std.Io.Clock.awake.now(io)` / `durationTo` — std.time.Timer does not exist at 0.16. Percentiles: collect per-op nanos in a preallocated []u64, sort (std.mem.sort), report p50/p95/p99/max. No percentile helper exists; write it in the tool.

3. **Assertion policy.** Default run is informational (prints a table). `--assert` exits non-zero when a §18 target is exceeded — for the Pi 5 run, NOT for CI: required CI stays deterministic (AGENTS.md) and perf assertions on shared runners are flake generators. CI does not run the bench at all this milestone.

4. **Bench flags**: `--domains=N` (default 1_000_000), `--iters=N` (default 200_000), `--seed=N` (default fixed), `--assert`, subcommands `filter|cache|compile|all` (default all). Debug-mode runs print a one-line warning recommending `-Doptimize=ReleaseFast`.

5. **docs/performance.md**: the §18 targets table; measured numbers from THIS x86_64 host (dated, hardware named honestly, marked as not-the-target-platform); the in-memory accounting numbers; the one-command Pi 5 recipe (`zig build bench -Doptimize=ReleaseFast -- --assert`); a sentence on why CI does not gate on perf. The Pi 5 rows stay "to be measured on hardware".

6. **aarch64 execution = second test artifact, `zig build test-aarch64 -fqemu`.** Per stdlib evidence: Run steps try qemu only when enable_qemu (the `-fqemu` CLI flag; a *Build field, no per-step override) and exec `qemu-aarch64` bare from PATH — so CI installs `qemu-user` (NOT qemu-user-static, which ships the `-static` name Zig will not find; binfmt is unnecessary). Static musl means no sysroot/--libc-runtimes. build.zig: resolve aarch64-linux-musl, addTest with the same wiring as the native tests artifact (sqlite/mbedtls helpers are already target-parameterized), set `.linkage = .static` on the artifact (TestOptions has no linkage field), `run.skip_foreign_checks = true`, leave failing_to_execute_foreign_is_an_error true so a missing qemu is loud. Fuzz artifacts excluded.

7. **qemu scope: plain suite only, blocking.** No `-Dintegration` under qemu: the integration tests are multithreaded loopback TLS with wall-clock budgets, and qemu-user's 5-20x slowdown makes them a flake source — required CI stays deterministic. The plain suite (the aggregator's tests without the 12 fuzz-artifact tests: 1159 pass + 113 integration-gated skips; all pure DNS/filter/cache logic included) is the portable-correctness signal aarch64 needs. Recorded here as the engineering call closing PLAN.md:145's promise.

8. **CI additions**: one `test-aarch64` job (setup-zig, apt qemu-user, `zig build test-aarch64 -fqemu`); plus the missing §18 size assert — the cross job gains a second build WITHOUT `-Dweb-dist` (placeholder dist) and asserts the stripped copies < 10 MiB per arch (the < 15 MiB with-assets assert already exists). Job graph otherwise untouched.

9. **No src/ changes.** matcher/dns_cache/compiler public APIs are used as-is; if the bench needs something they do not expose, report — do not extend them.

## Sessions

V1 then V2 (both edit build.zig — sequenced, not parallel).

## Session V1: bench harness + performance doc

Owns tools/bench.zig, build.zig (the bench step only), docs/performance.md. Rulings 1-5. Runs the bench on this host (ReleaseFast) and writes the measured numbers into the doc.

### V1 As built

Delivered tools/bench.zig, the build.zig bench step, and docs/performance.md. One deviation from ruling 1's wiring: Zig 0.16 rejects separate modules rooted at matcher.zig and dns_cache.zig ("file exists in multiple modules" — their closures share model.zig/types.zig; verified with a minimal repro). Instead a b.addWriteFiles stage copies src/ plus a generated 7-line aggregator root (bench_core.zig) into one `core` module — the same staging trick the web assets use; a build.zig comment records why. The fuzz-corpus response is a byte-for-byte copy with provenance comment (importing corpus.zig recreates the module conflict; the corpus documents copies as house style). /proc/self/status reads use readerStreaming (procfs stats size 0, readFileAlloc returns empty). Measured on the build host (i7-14700K, ReleaseFast, defaults: 1M domains, 200k iters): filter p95 0.18 µs (target < 1 ms), cache p95 0.14 µs (target < 5 ms), VmRSS 31.8 MiB with the 1M-domain snapshot (target < 100 MiB), Snapshot.memoryBytes 28.0 MiB, compile 96 ms ≈ 10.4M lines/s (informational). --assert exit paths verified both ways (a 4M-domain run exceeds the 1M-scoped RSS target and exits 1; that was an exit-path exercise, not a target miss). No src/ changes; matcher/dns_cache/compiler APIs sufficed. Cache suite fixes entries at 10k (the handler-default Config.size); --domains shapes filter and compile only. Both suites 0 failed after the build.zig edit.

## Session V2: aarch64 test execution + CI (after V1)

Owns build.zig (the test-aarch64 artifact/step only), .gitea/workflows/ci.yml (the new job + the cross-job size-assert addition). Rulings 6-8. Runs `zig build test-aarch64 -fqemu` locally if qemu-aarch64 is installable/present; reports honestly if not.

### V2 As built

build.zig gained the test-aarch64 block after the bench block: aarch64-linux-musl addTest with wiring identical to the native tests artifact, `.linkage = .static` on the artifact, `run.skip_foreign_checks = true`, failing-to-execute left loud. Stdlib mechanics re-verified (Run.zig:71/78/226, Build.zig:73, system.zig:111 — bare `qemu-aarch64` from PATH). ci.yml gained the test-aarch64 job (apt qemu-user, `zig build test-aarch64 -fqemu`) and the cross job builds a second no-dist pair to `--prefix zig-out/nodist` (existing with-assets steps byte-identical) with a
< 10 MiB stripped assert; the new assert skips the static-linkage recheck (proven
on the same-config with-assets build). Local verification: real qemu execution — 1159 pass / 113 skip / 0 failed (native count minus the 12 excluded fuzz tests); no-dist stripped sizes 5,880,336 (x86_64) and 5,212,640 (aarch64) bytes. No -Dintegration under qemu per ruling 7.

## Review (Codex, as built)

Two rounds on one thread; round 2 returned "No findings."

Round 1 (2 important, 2 minor): bench.zig's explicit body.deinit left the earlier errdefer armed on an undefined list (double-free on any later error) → clearAndFree keeps the list valid. Ubuntu's qemu-user recommends qemu-user-binfmt, which apt installs by default — binfmt registration would bypass the -fqemu path CI intends to exercise → --no-install-recommends. performance.md's headroom claim overstated the memory margin (3x, not orders of magnitude) → corrected. Ruling 7 quoted the native aggregate test count instead of the aarch64 artifact's own (1159 + 113 skips, fuzz excluded) → corrected.

Final gates: plain 1171/1284 passed, 113 skipped (integration-gated), 0 failed; integration 1280/1284, 4 skipped (live-network by design), 0 failed; qemu aarch64 run 1159 passed, 113 skipped, 0 failed; cross ReleaseSafe with dist 18/18; no-dist stripped sizes 5.9/5.2 MB under the 10 MiB assert.

## Module layout (new)

tools/bench.zig, docs/performance.md.

## File ownership

V1 tools/bench.zig + docs/performance.md + build.zig(bench); V2 build.zig(aarch64) + ci.yml. Orchestrator: spec sync, review, commit.

## Acceptance (milestone complete)

- [ ] `zig build bench -Doptimize=ReleaseFast` runs all three suites and prints
      p50/p95/p99/max + memory; `--assert` enforces the §18 targets.
- [ ] docs/performance.md holds dated x86_64 numbers + the Pi 5 recipe.
- [ ] `zig build test-aarch64 -fqemu` passes locally under qemu (or its
      unavailability is recorded with the exact CI-equivalent command).
- [ ] CI: test-aarch64 job green-by-construction (same command CI runs); cross job
      asserts < 10 MiB stripped without assets, < 15 MiB with (existing).
- [ ] Both existing suites 0 failed; no src/ changes.

## Anti-requirements

- No perf assertions in CI; no bench in the test step.
- No qemu integration suite; no binfmt setup; no qemu-user-static.
- No new pub API on matcher/dns_cache/compiler; no synthetic-load DNS server benchmark (the ≥100 qps target is end-to-end on the Pi — the operator recipe covers it via the real binary, not a harness).
