diff --git a/docs/plans/2026-07-02-fork-instrument-size-overhead-eval.md b/docs/plans/2026-07-02-fork-instrument-size-overhead-eval.md new file mode 100644 index 000000000..5e1fae684 --- /dev/null +++ b/docs/plans/2026-07-02-fork-instrument-size-overhead-eval.md @@ -0,0 +1,423 @@ +# Fork-Instrumentation Size Overhead — Evaluation & Recommendation + +- Bead: kd-ok55 (discovered from kd-lfas) +- Date: 2026-07-02 +- Author: designer-adhoc-f8a9bbfb5b (Kandelo feature designer) +- Status: **Design/evaluation only — no emitter changes proposed for immediate landing.** +- Subject crate: `crates/fork-instrument/` (kernel tooling; distinct from package recipes) + +## TL;DR and recommendation + +`wasm-fork-instrument` roughly **doubles the code section** of every fork-using +language runtime: measured code-section growth is **+127% for perl** and +**+167% for ruby** (clean single-step). On a whole-shipped-binary basis that is +perl +72% over raw / ~42% of the shipped bytes. The cost is **real, large, and +general** across every fork-instrumented runtime — not a perl quirk. + +The dominant driver is **not** inefficient stubs. The primary, **measured** +driver is that the fork-path reverse-reachability closure captures **69–87% of +all functions** in these programs (measured across bash, coreutils, vim, git, +ruby, php, php-fpm). The *secondary* driver — the per-function cost, ~1,290 B on +an unoptimized ruby build — is, by a byte-level read of the emitter, dominated by +**per-scalar-local save/restore**, a term that is a hard inline floor under +WebAssembly's static-local-index rule. That per-function apportionment is +**analytically derived, not yet measured** (see the honesty note in +[Cost model](#cost-model-triangulated-for-a-and-b-analytical-for-c)); settling it +is exactly what the `--stats` enabler below unblocks. + +**Recommendation: DEFER** a dedicated size-reduction project, with two riders: + +1. **DO now (cheap, safe, high-leverage enabler):** add a `--stats` mode to + `wasm-fork-instrument` reporting functions instrumented / total, call sites, + spilled locals, and bytes added per section. It requires **no emitter change + and no ABI impact**, and every future size decision needs it (today the + numbers must be reverse-engineered with `wasm-objdump`). Tracked here as a + small follow-up. +2. **If/when the size work is prioritized**, pursue it in this risk-ordered + sequence, and **not** the order implied by the bead title: + 1. **Frame-pointer CSE** (cache `*(buf+0)` in a local) — ABI-neutral, + ~15–20% code reduction, lowest risk. + 2. **Fork-live-only local spill** (liveness-prune the dominant term) — + larger win, medium-high risk. + 3. **Tighter fork-path selection** (better `call_indirect` index + resolution / scoped dynamic-table-write unknownness) — the biggest + potential win but the highest correctness risk. + +**Explicit won't-fix-for-size:** reviving the scaffolded "runtime-dispatcher +trampoline" (table-driven dispatch) **to reduce size**. It targets the inline +dispatch-block term, which measurement shows is a *minor* fraction of the +overhead, while adding a REWIND-path `call_indirect` and per-function tables. +(It may still be worth finishing for the separate dispatch-*depth* limit tracked +in `2026-06-05-fork-instrument-recursive-bucketing-plan.md`; that is not a size +argument.) + +Every lever above touches a **correctness-critical, fuzz-gated emitter**: any +change must re-clear the ≥10,000-iteration fork fuzz gate plus the POSIX/libc +suites, because a subtle rewind defect corrupts forked child processes +*silently*. That validation cost — not implementation difficulty — is the main +reason to defer. + +## Problem statement + +kd-lfas established that the dominant size factor in fork-instrumented runtimes +is the fork instrumentation itself, not `--no-gc-sections` dead code: perl +5.40.3 raw `make -k` output is 4,232,959 B; after `wasm-fork-instrument` it is +7,291,919 B (+3,058,960 B, ~42% of the shipped binary, +72% over raw), while a +`wasm-opt -O2` DCE pass reclaims only ~446 KB. Because instrumentation must run +*after* `wasm-opt` (it hardcodes mutable-global offsets), `wasm-opt` cannot +touch the instrumentation stubs. + +This applies to **all** fork-instrumented packages (bash, dash, git, php, +php-fpm, vim, tcl, coreutils, nginx, dinit, ruby, lsof, netcat, perl, …), so a +proportional win compounds across the registry. + +kd-ok55 asks: **can the save/restore stubs be made smaller** (shared helper vs +per-function inlined stubs, table-driven unwind, only instrumenting functions +actually on a fork path), is it feasible, and is it worth it — do / defer / +won't-fix. + +## Non-goals + +- Not implementing any emitter change. This bead's acceptance is measurement + + assessment + recommendation. Per project guidance, implementation is out of + scope unless a bead explicitly asks for it. +- Not changing the fork ABI, save-buffer layout, or the `wpk_fork_*` export + contract. +- Not reviving Asyncify. `wasm-fork-instrument` is the active path. +- Not re-litigating correctness of the current tool. It is assumed correct and + fuzz-gated; this document only evaluates its size. +- Not a package-recipe change. The subject is `crates/fork-instrument/`. + +## Background: what the tool emits, and why + +`wasm-fork-instrument` gives WebAssembly POSIX `fork()` by rewriting every +function that can transitively reach the `kernel.kernel_fork` import so the host +can unwind the wasm call stack into linear memory, clone the instance, copy +memory, and rewind the child to the exact `fork()` call site. WebAssembly hides +its call stack from the embedder, so this **must** be compile-time per-function +machinery (`docs/fork-instrumentation.md`; `docs/plans/2026-04-20-fork-instrumentation-design.md`). + +Each instrumented function is rewritten to +`[state-test preamble] [ $unwind_save block { dispatch; wrapped calls } ] [postamble]` +(`crates/fork-instrument/src/instrument.rs:613`). All of it is emitted +**inline**; the five exported `wpk_fork_*` runtime functions are driven by the +host, never called from instrumented bodies. Concretely, per function: + +- **Preamble** — on `REWINDING`, rewind the frame cursor, reload the frame, and + deserialize each scalar local from the save buffer (`instrument.rs:2866`). +- **Dispatch** — a `br_table` keyed on `frame.call_index`, gated by + `state == REWINDING`, jumps straight to the matching `$POST_K` landing so the + pre-call body never re-executes on rewind (`instrument.rs:2114`). Bucketed at + the top level (`BUCKET_SIZE = 32`); flat per region when nested. +- **Per call site** — the original call is preserved; an UNWIND bridge writes + `frame.call_index` and branches to `$unwind_save` (`instrument.rs:2802`). + Non-pure call arguments are spilled to synthetic locals and reloaded; pure + scalar tails are replayed instead of spilled (`instrument.rs:1771`). +- **Postamble** — write the frame header (func index, zeroed catch header), + serialize each scalar local to the buffer, bump the frame cursor, return a + default value (`instrument.rs:2949`). +- **Ref-typed locals** (funcref/externref/exnref) spill to per-module aux tables + sized exactly to the assigned slot count (`instrument.rs:3543`). + +Selection is a reverse-reachability closure from `kernel_fork` +(`crates/fork-instrument/src/call_graph.rs:589`): direct callers are added +transitively and exactly; indirect callers are added when a `call_indirect` +can dispatch to a fork-reachable target of matching signature in the same table, +**bounded to two dispatch hops** to avoid closing over an entire interpreter +from one generic dispatcher. + +## Measurement + +Tool built from the convoy base (`scripts/build-fork-instrument-tool.sh`). +Fork-path counts via `wasm-fork-instrument … --discover-only` (JSON `count` +includes the seed import ⇒ instrumented `F = count − 1`). Section/function +counts via `wasm-objdump -h`. Shipped binaries read read-only from the primary +checkout (`primary_checkout_exception=read-only` recorded on the bead). Raw data +under `test-runs/kd-ok55/` (`real-selection.tsv`, `real-runtime-findings.md`, +`measurements.tsv`, `table.txt`, `section-dumps.txt`). + +### Selection ratio — fraction of all functions instrumented (shipped binaries) + +| runtime | defined funcs | instrumented F | **F / defined** | +|---|--:|--:|--:| +| bash | 1,800 | 1,445 | **80.3%** | +| coreutils | 3,351 | 2,313 | **69.0%** | +| vim | 4,423 | 3,489 | **78.9%** | +| git | 5,071 | 4,417 | **87.1%** | +| ruby | 7,183 | 6,238 | **86.8%** | +| php | 23,475 | 18,228 | **77.6%** | +| php-fpm | 23,699 | 18,401 | **77.6%** | + +The fork-path closure engulfs **69–87% of every program**. (Footnote: these are +already-instrumented shipped binaries, so the `defined` denominator includes the +5 `wpk_fork_*` runtime functions; against the true original count the ratios are +marginally *higher* — e.g. bash 1445/1795 = 80.5% — so the table is slightly +conservative. `--discover-only` is idempotent on instrumented binaries because +the original calls are preserved, so it recovers the same fork-path set.) This is +far above the original design's "well-scoped onlylist ⇒ +2–5% total module size" +expectation (`2026-04-20-fork-instrumentation-design.md` §7.2). In tightly +connected runtimes, `fork()`/`system()`/`popen()`/`posix_spawn()` sits near the +bottom of a dense call graph, so reverse reachability naturally captures most of +it; the conservative indirect closure (below) inflates it further. + +### Clean code-section delta on a real interpreter (ruby) + +Total-file deltas on real binaries are **confounded** because the tool drops +custom (debug/name) sections on rewrite — the raw no-cflags ruby carries ~12.6 MB +of debug info (raw 22.4 MB vs stripped `roots` 9.83 MB, identical 9,299 +functions). The confound-free metric is the **code section**, measured on a +single clean instrument step of the stripped build: + +| | code section | data section | funcs | +|---|--:|--:|--:| +| ruby (pre-instrument) | 5,946,789 B | 3,671,022 B | 9,299 | +| ruby (instrumented) | 15,858,681 B | 3,671,022 B (unchanged) | 9,304 (+5 `wpk_*`) | +| **delta** | **+9,911,892 B (+166.7%)** | 0 | — | + +Instrumented `F = 7,681` ⇒ **~1,290 B per instrumented function**. Overhead is +~100% code (data untouched), matching the synthetic finding that >98% of growth +is the Code section. (This is a no-cflags/unoptimized build, so 1,290 B/function +is an upper bound; the shipped optimized perl figure of +42% is the +representative optimized-runtime number.) + +Reference (kd-lfas, shipped optimized perl): raw 4,232,959 B → 7,291,919 B; +instrumented code section 5,477,124 B ⇒ raw code ~2.42 MB, **+127% code growth**. + +### Cost model (triangulated for A and B; analytical for C) + +Analytical (byte-level read of the emitter), synthetic scaling runs, and the +real-ruby total agree on the model *shape* and pin the fixed and per-call terms: + +``` +bytes(f) ≈ A + B·(fork_call_sites) + C·(scalar_locals) + C_ref·(ref_locals) + Sarg +``` + +| term | value | note | +|---|--:|---| +| **A** fixed boilerplate | ~110 B/function | preamble+dispatch+postamble frame writes; ~9% of real per-fn cost | +| **B** per fork call site | ~26–27 B | POST block + UNWIND bridge + br_table slot | +| **C** per scalar local | ~20 B | **dominant term for real local-heavy C functions** | +| **C_ref** per ref local | ~12 B + 1 table slot | | +| **Sarg** non-pure call args | ~24·m per such call | 0 when the arg tail is a pure scalar replay | +| module floor | ~483 B once | 5 `wpk_fork_*` funcs + 2 globals + exports | + +**What is measured vs. inferred (honesty note).** `A` and `B` are measured: the +synthetic `manyfn` scaling pins the marginal cost of a 1-fork-call, 0-local +function at 110.0 B, and the `dispatcher` scaling pins ~27 B/call-site. But +**both synthetic generators emit functions with no locals**, so the `C` term +(≈20 B/scalar-local) and `Sarg` are **not** validated by measurement — they come +from the byte-level read of the emitter alone. The real-ruby total (1,290 B/fn) +confirms the *sum* is large but does **not** decompose the ~1,180 B residual +among `C·L`, `B·K`, and `Sarg`. So treat "`C·L` is the largest per-function +term" as a well-reasoned hypothesis, not a measured fact. (Minor: because the +measured `A`≈110 already includes one call site's `B`, the pure fixed term is +~83 B; the terms are loosely pinned.) + +**Apportionment (build-dependent).** On the unoptimized no-cflags ruby +(~1,290 B/fn) the fixed boilerplate `A` is ~9%. On the *optimized* builds the +recommendation actually cares about (smaller per-function totals, fewer locals), +`A` is a **larger** share — plausibly ~15–20% — and the `C·L` dominance +correspondingly **weakens**. The 1,290 B/fn figure is from the no-cflags build in +sibling worktree `kd-drt.9` (read-only) and is not reproducible from this +worktree; it is an upper bound, not the representative case. A further caveat on +"not the dispatch shape": every `call_indirect` in an instrumented function is +wrapped **unconditionally** (`instrument.rs:1705`), so in indirect-heavy +interpreters the `B·K` term is larger than in ordinary C — which is also why +lever D (below) is worth doing. Net: the multiplier that is **measured** to +matter is **`F`** (69–87% of the program); **`L`** matters per the model but its +weight is unmeasured. `Σ_f (A + 20·L_f + 27·K_f)` is the right shape; the +coefficients on `L` await `--stats`. + +## Levers evaluated + +### The bead's three candidates + +**1. "Only instrument functions actually on a fork path" — ALREADY DONE; now +near its floor.** Selection is already a reverse-reachability closure, not +whole-module. The measurement shows the honest consequence: on real runtimes +that closure is 69–87% of the program. The remaining question is whether the set +is *over-approximated*. It partly is — see selection-tightening below — but the +easy version of this lever is exhausted. + +**2. "Shared helper vs per-function inlined stubs" — mostly INFEASIBLE; bounded +by wasm.** Two hard WebAssembly constraints cap it: + - **Static local indexing.** `local.get`/`local.set` take an immediate + index; a shared helper cannot read or write another frame's locals. The + per-local spill/reload (the *dominant* `C·L` term) therefore **must** stay + inline. Passing locals as helper arguments still emits one `local.get` per + local at the call site plus call overhead — no win. + - **Structured control flow.** `br_table` can only target enclosing labels; + the dispatch skeleton cannot live in a callee. It must stay inline. + + Only the **fixed frame-header writes** (cursor math, func-index/catch-header + stores, cursor bump) are memory-only and shareable into a helper — but that + is the ~9% `A` term. A shared boilerplate helper is therefore **low value**. + *(A related but distinct intra-function optimization — frame-pointer CSE — is + the real safe win; see below. It is not a "shared helper.")* + +**3. "Table-driven unwind" — feasible and half-built, but WON'T-FIX FOR SIZE.** +The "runtime-dispatcher trampoline" (extract post-call chunks into a per-function +funcref table, `call_indirect` on REWIND) was explicitly proposed as a code-size +lever and **scaffolded in-tree** (`instrument.rs` `emit_per_function_post_table`, +`extract_chunk_to_function`, `instrument_one_function_trampoline_dispatch` — the +last is a `panic!`/`unimplemented!` placeholder; `tests/trampoline.rs`). It was +set aside not on merit but because extending the inline switch-dispatch was a +smaller implementation diff (~80–120 LoC vs ~300–500). **However**, it targets +the inline **dispatch/POST_K** term, which the cost model shows is a *minor* +fraction of per-function cost (the `B·K` term, ~2–5% of a real function), while +it *adds* a REWIND-path `call_indirect` (perf) and per-function funcref tables +(size). As a **size** play it is net-marginal at best. Recommend won't-fix for +size; finish it only if the separate dispatch-depth limit +(`2026-06-05-…-recursive-bucketing-plan.md`) requires it. + +### Additional levers derived from the measurement (higher value) + +**A. Frame-pointer CSE — the best risk-adjusted win. LOW risk, ABI-neutral.** +`*(buf+0)` (the current frame base, `global.get $buf; i32.load 0`, ~5 B) is +recomputed at **every** local save, local restore, call-index store, cursor +bump, and dispatch. Computing it once into a synthetic local per region and +reusing it removes ~3 B at each of the ~`(2L + K + 3)` sites per function. +Estimated **~15–20% total code reduction**. It is pure intra-function common- +subexpression elimination: **no frame-layout change, no ABI change, no +`ABI_VERSION` bump** (structural snapshot may shift and must be regenerated). +This is the one change small and safe enough to consider landing on its own. +Two caveats: (i) the cached frame-base local must be **excluded from the +scalar-local spill set**, or the emitter will save/restore it like a user local — +growing `frame_size` (breaking ABI-neutrality) and cancelling the win; (ii) this +lever's payoff and the `C·L` dominance above are **coupled** — both scale with +`L`, so both are settled by the same `--stats` local histogram. If `L` turns out +modest, expect the low end (~15%) and a smaller `C·L` share. + +**B. Fork-live-only local spill — the biggest *safe-ish* structural win.** +Today every scalar local is spilled/reloaded. Only locals **live across the +fork call** need saving. A standard liveness pass that spills the live-across- +fork subset would cut the dominant `C·L` term substantially (interpreter +functions have many locals but few live at any one fork call site). Medium-high +risk: missing a live local silently corrupts the child. Well-understood analysis, +but must be conservative (spill on any doubt) and fuzz-gated. + +**C. Tighter fork-path selection — biggest potential, highest risk.** The +indirect closure is conservative: an unresolved/dynamic `call_indirect` index +pulls in *all same-signature functions in the table*, and a single dynamic table +write (`table.set/fill/grow`) makes the whole table match module-wide +(`call_graph.rs:328`,`:458`). Extending `IndexProof` (`call_graph.rs:211`) to +recover LLVM jump-table / GOT-constant function pointers, and scoping table-write +unknownness to resolvable `ref.func` writes, would shrink `F` toward the truly- +reachable set. This is a **may-analysis for correctness**: a missed real target +is an un-instrumented fork site → silent state corruption. Any unresolved index +must still fall back to the conservative set. High value, high risk. + +**D. Don't wrap non-fork `call_indirect` sites — safe, modest.** Once a function +is in the set, **every** `call_indirect` in it is wrapped as a fork landing with +no reachability check, unlike direct calls which are gated on the target being +fork-reachable (`instrument.rs:1705` vs `:1696`). Gating indirect wrapping on the +same `table_can_dispatch + types_match + target∈fork_path` predicate the closure +already trusts removes dispatch/spill code at indirect sites that provably cannot +reach fork — **no less sound than current selection**. Modest but among the +safest structural wins. + +**E. Skip the zeroed 8-byte catch header for no-catch functions — safe, minor.** +Functions with no fork-path `try_table` still write an 8-byte zero catch header +and reserve the header slot (`instrument.rs:2992`). A no-catch variant drops +~10 code B/function and shrinks the frame. Low risk; requires a frame-layout +variant (snapshot regen). + +## Is it worth it? + +- **The overhead is material.** For php (37 MB) and ruby (17 MB), even a 20% code + cut is multiple MB off download/parse/instantiate — most impactful on the + browser host. Not negligible. +- **But the binaries are correct and already better than the alternative.** The + design intends (and the retired full-module fork-continuation carve-out + confirms) that this tool is equal-or-smaller than what preceded it + (`docs/fork-instrumentation.md` §Performance envelope). Instrumentation size is + the price of POSIX fork on wasm, not a regression. +- **No lever is a free win.** Every emitter change must re-pass the ≥10,000-iter + fork fuzz gate, `scripts/run-posix-tests.sh`, `scripts/run-libc-tests.sh`, + `cargo test -p fork-instrument`, and re-verify fork-heavy demos on both hosts. + A rewind defect fails *silently in children*, the worst failure class. + +This is a genuine prioritization call for @brandon: real multi-MB wins are +available but gated behind correctness-critical work on a load-bearing tool. +Hence **defer**, with the safe CSE win and the `--stats` enabler broken out. + +## Recommended sequence (if prioritized) + +1. **`--stats` mode** (DO now; no emitter change). Report F/total, call sites, + spilled scalar/ref locals (a histogram of `L` and `K` per function), and + per-section byte deltas. This is a **prerequisite, not a nicety**: the + per-function apportionment — especially the `C·L` local term the whole + "defer + later liveness-prune" thesis rests on — is currently *unmeasured*, + so the size case cannot be fully adjudicated until these histograms exist. It + also gives every later step a regression signal. +2. **Frame-pointer CSE** (lever A). ABI-neutral, ~15–20%, lowest risk. Land + behind the full gate; regenerate `abi/snapshot.json` (structural only). +3. **Don't-wrap-non-fork-indirect-sites** (lever D) + **no-catch-header skip** + (lever E). Safe, additive. +4. **Fork-live-only local spill** (lever B). The big structural win; stage behind + its own fuzz campaign. +5. **Selection tightening** (lever C). Only with a dedicated correctness budget + and expanded fuzz coverage for indirect/dynamic-table shapes. + +Do **not** start with the trampoline; it is the smallest size component. + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| Silent child corruption from a rewind bug | Every step behind the ≥10k-iter fuzz gate + POSIX/libc suites + fork-heavy demo verification on Node and browser. Default-conservative in every analysis. | +| Selection tightening drops a real fork path | Keep the conservative fallback for any unresolved index/dynamic table; add indirect/dynamic-table fuzz fixtures before shrinking. | +| Liveness pruning misses a live local | Spill on any uncertainty; validate against the full save/restore round-trip fuzzer. | +| ABI/snapshot drift | CSE and dispatch-topology changes keep the frame/buffer ABI byte-identical ⇒ no `ABI_VERSION` bump, but regenerate and verify `abi/snapshot.json` (additive). Catch-header/frame-layout variants that change bytes require the graded ABI policy in `docs/abi-versioning.md`. | +| Instrumentation is not byte-reproducible across runs | Pre-existing for all fork-instrumented packages; unrelated to these levers but note it when comparing bottle shas. | +| Effort spent for a modest, risky win | The `--stats` + CSE steps are cheap and safe; gate the expensive levers (B, C) on measured demand. | + +## Test and documentation plan (for any future implementation) + +- **Emitter:** `cargo test -p fork-instrument --target "$HOST_TARGET"`; + `scripts/run-fork-instrument-fuzz.sh` at ≥10,000 iterations, zero validator + failures; add fixtures for any newly optimized shape (per the fuzz-to-fixture + rule in the crate README). +- **Kernel/host:** `cargo test -p kandelo --target aarch64-apple-darwin --lib`; + `cd host && npx vitest run`; `scripts/run-posix-tests.sh`; + `scripts/run-libc-tests.sh`; `bash scripts/check-abi-version.sh`. +- **Runtimes:** re-instrument bash/git/php/ruby/perl; confirm `--discover-only` + counts and byte deltas via `--stats`; run fork-heavy demos + (`wordpress`, `erlang-ring`, `process-lifecycle`) on Node and browser + (`./run.sh browser`). +- **Docs:** update `docs/fork-instrumentation.md` (dispatch/spill sections), + `crates/fork-instrument/README.md`, and `abi/snapshot.json` if the structural + snapshot shifts; note any `ABI_VERSION` change per `docs/abi-versioning.md`. + +## Open questions + +- What fraction of the 69–87% closure is *spurious* (indirect over-approximation) + vs genuinely fork-reachable? Answering needs a "direct-only vs full-closure" + count — a small addition to `--discover-only`/`--stats`. This decides whether + lever C is transformative or marginal. +- Average locals-live-across-fork vs total locals per fork-path function — sets + the ceiling on lever B. Also a `--stats` output. +- Does the browser host's parse/instantiate budget make even a 15–20% cut + worth the emitter risk on its own, independent of the larger levers? +- Should `--stats` land as its own tiny PR immediately (it is safe and unblocks + every measurement)? + +## Appendix: reproduction + +```sh +# Build the host-side tool +scripts/build-fork-instrument-tool.sh # -> tools/bin/wasm-fork-instrument +# (host env note: if cc can't find clang under a Nix SDK, prefix with +# DEVELOPER_DIR=/Library/Developer/CommandLineTools) + +# Selection ratio for any built runtime +F=$(tools/bin/wasm-fork-instrument .wasm --discover-only | jq .count) +echo "instrumented = $((F-1))" +wasm-objdump -h .wasm | awk '/^ Function /{print "defined =",$NF}' + +# Clean code-section delta (use a stripped/no-debug input to avoid the +# tool's custom-section drop confounding the total-file size) +tools/bin/wasm-fork-instrument .wasm -o /tmp/out.wasm +wasm-objdump -h .wasm | grep -E '^ +Code ' # before +wasm-objdump -h /tmp/out.wasm | grep -E '^ +Code ' # after +``` diff --git a/test-runs/kd-ok55/build-tool.log b/test-runs/kd-ok55/build-tool.log new file mode 100644 index 000000000..84739fee4 --- /dev/null +++ b/test-runs/kd-ok55/build-tool.log @@ -0,0 +1,21 @@ +=== build restart at Thu Jul 2 05:19:24 EDT 2026 with DEVELOPER_DIR=/Library/Developer/CommandLineTools === +==> Building wasm-fork-instrument for aarch64-apple-darwin... + Compiling proc-macro2 v1.0.106 + Compiling serde_core v1.0.228 + Compiling quote v1.0.45 + Compiling serde v1.0.228 + Compiling anyhow v1.0.102 + Compiling clap_builder v4.6.0 + Compiling syn v2.0.117 + Compiling indexmap v2.14.0 + Compiling hashbrown v0.16.1 + Compiling gimli v0.32.3 + Compiling wasmparser v0.245.1 + Compiling clap_derive v4.6.1 + Compiling walrus-macro v0.26.0 + Compiling clap v4.6.1 + Compiling walrus v0.26.1 + Compiling fork-instrument v0.1.0 (/Users/brandon/src/kandelo-gascity/worktrees/kandelo/kd-ok55-evaluate-fork-instrumentation-size-overhead-42-of-perl.w/crates/fork-instrument) + Finished `release` profile [optimized] target(s) in 13.63s +==> Installed /Users/brandon/src/kandelo-gascity/worktrees/kandelo/kd-ok55-evaluate-fork-instrumentation-size-overhead-42-of-perl.w/tools/bin/wasm-fork-instrument +=== build done at Thu Jul 2 05:19:37 EDT 2026 === diff --git a/test-runs/kd-ok55/measure.py b/test-runs/kd-ok55/measure.py new file mode 100644 index 000000000..710dc3844 --- /dev/null +++ b/test-runs/kd-ok55/measure.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Fork-instrumentation size-overhead measurement harness (kd-ok55). + +Compiles raw wasm inputs (hand-authored .wat test fixtures + synthetic +scaling modules), runs `wasm-fork-instrument` on each, and records: + raw_bytes, instrumented_bytes, delta, %-of-instrumented, + fork-path function count (--discover-only), and Code-section size + before/after (wasm-objdump -h). + +Everything runs on host; no package build required. Emits a TSV to +stdout and a formatted table to stderr. +""" +import os, re, subprocess, sys, tempfile, json + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +TOOL = os.path.join(REPO, "tools", "bin", "wasm-fork-instrument") +FIXDIR = os.path.join(REPO, "crates", "fork-instrument", "tests", "fixtures") +OUT = os.path.join(REPO, "test-runs", "kd-ok55") +WORK = os.path.join(OUT, "work") +os.makedirs(WORK, exist_ok=True) + +ENV = dict(os.environ, DEVELOPER_DIR="/Library/Developer/CommandLineTools") + +def sh(cmd, **kw): + return subprocess.run(cmd, capture_output=True, text=True, env=ENV, **kw) + +def wat2wasm(wat_path, wasm_path): + r = sh(["wat2wasm", wat_path, "-o", wasm_path]) + if r.returncode != 0: + r = sh(["wat2wasm", "--enable-all", wat_path, "-o", wasm_path]) + if r.returncode != 0: + raise RuntimeError(f"wat2wasm failed for {wat_path}: {r.stderr.strip()}") + +def instrument(raw, inst): + r = sh([TOOL, raw, "-o", inst]) + if r.returncode != 0: + raise RuntimeError(f"instrument failed for {raw}: {r.stderr.strip()}") + +def fork_path_count(raw): + r = sh([TOOL, raw, "--discover-only"]) + if r.returncode != 0: + return None + try: + return json.loads(r.stdout)["count"] + except Exception: + return None + +SEC_RE = re.compile(r"^\s*(\w+)\s+start=0x[0-9a-f]+\s+end=0x[0-9a-f]+\s+\(size=0x([0-9a-f]+)\)(?:\s+count:\s*(\d+))?") + +def sections(wasm): + """Return {section_name: (size_bytes, count_or_None)} from wasm-objdump -h. + 'Custom' rows are keyed by their quoted name so they don't collide.""" + r = sh(["wasm-objdump", "-h", wasm]) + out = {} + for line in r.stdout.splitlines(): + m = SEC_RE.match(line) + if not m: + continue + name = m.group(1) + if name == "Custom": + q = re.search(r'"([^"]+)"', line) + name = f"Custom:{q.group(1)}" if q else "Custom" + size = int(m.group(2), 16) + cnt = int(m.group(3)) if m.group(3) else None + out[name] = (size, cnt) + return out + +# ---- synthetic generators (mirror crates/.../tests/large_dispatcher.rs) ---- +def wat_direct_dispatcher(n): + body = "".join(" call $fork\n" + (" drop\n" if i + 1 < n else "") + for i in range(n)) + return f'''(module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $dispatcher (export "dispatcher") (result i32) +{body} ) + (memory 1)) +''' + +def wat_many_functions(m): + fns = "".join(f' (func $f{i} (export "f{i}") (result i32) call $fork)\n' + for i in range(m)) + return f'''(module + (import "kernel" "kernel_fork" (func $fork (result i32))) +{fns} (memory 1)) +''' + +def measure(label, raw_wasm): + inst = os.path.join(WORK, os.path.basename(raw_wasm).replace(".wasm", ".inst.wasm")) + instrument(raw_wasm, inst) + raw_b = os.path.getsize(raw_wasm) + inst_b = os.path.getsize(inst) + delta = inst_b - raw_b + pct_inst = 100.0 * delta / inst_b if inst_b else 0.0 + pct_raw = 100.0 * delta / raw_b if raw_b else 0.0 + fpc = fork_path_count(raw_wasm) + sraw = sections(raw_wasm) + sinst = sections(inst) + code_raw = sraw.get("Code", (0, None)) + code_inst = sinst.get("Code", (0, None)) + return dict(label=label, raw=raw_b, inst=inst_b, delta=delta, + pct_inst=pct_inst, pct_raw=pct_raw, fpc=fpc, + code_raw=code_raw[0], code_inst=code_inst[0], + nfuncs_raw=(sraw.get("Function", (0, None))[1] or 0) + + (sraw.get("Import", (0, None))[1] or 0), + nfuncs_inst=(sinst.get("Function", (0, None))[1] or 0), + sraw=sraw, sinst=sinst) + +rows = [] + +# Group A: real hand-authored raw fixtures (import kernel_fork, no wpk_* yet) +fixtures = [] +for root, _, files in os.walk(FIXDIR): + for fn in sorted(files): + if not fn.endswith(".wat"): + continue + p = os.path.join(root, fn) + txt = open(p).read() + if "kernel_fork" in txt and "wpk_fork_" not in txt: + fixtures.append(p) +for p in sorted(fixtures): + name = os.path.relpath(p, FIXDIR) + w = os.path.join(WORK, name.replace("/", "__").replace(".wat", ".wasm")) + wat2wasm(p, w) + rows.append(("fixture", measure(name, w))) + +# Group B: synthetic direct-dispatcher (single fn, N fork call-sites) +for n in (1, 10, 100, 1000): + wat = os.path.join(WORK, f"dispatcher_n{n}.wat") + open(wat, "w").write(wat_direct_dispatcher(n)) + w = wat.replace(".wat", ".wasm"); wat2wasm(wat, w) + rows.append(("dispatcher", measure(f"dispatcher_n{n} (1 fn, {n} fork-calls)", w))) + +# Group C: synthetic many-functions (M fns each 1 fork call) +for m in (1, 10, 100, 1000): + wat = os.path.join(WORK, f"manyfn_m{m}.wat") + open(wat, "w").write(wat_many_functions(m)) + w = wat.replace(".wat", ".wasm"); wat2wasm(wat, w) + rows.append(("manyfn", measure(f"manyfn_m{m} ({m} fork-path fns)", w))) + +# ---- output TSV ---- +tsv = os.path.join(OUT, "measurements.tsv") +with open(tsv, "w") as f: + f.write("group\tlabel\traw_bytes\tinst_bytes\tdelta_bytes\tpct_of_inst\tpct_of_raw\tforkpath_fns\tnfuncs_raw\tnfuncs_inst\tcode_raw\tcode_inst\tcode_delta\n") + for grp, r in rows: + f.write(f"{grp}\t{r['label']}\t{r['raw']}\t{r['inst']}\t{r['delta']}\t" + f"{r['pct_inst']:.1f}\t{r['pct_raw']:.1f}\t{r['fpc']}\t" + f"{r['nfuncs_raw']}\t{r['nfuncs_inst']}\t{r['code_raw']}\t" + f"{r['code_inst']}\t{r['code_inst']-r['code_raw']}\n") + +# ---- pretty table to stderr ---- +def p(*a): print(*a, file=sys.stderr) +p(f"\n{'label':<42} {'raw':>8} {'inst':>8} {'delta':>8} {'%inst':>6} {'fp':>4} {'code_raw':>8} {'code_inst':>9} {'code_Δ':>8}") +p("-" * 110) +for grp, r in rows: + p(f"{r['label']:<42} {r['raw']:>8} {r['inst']:>8} {r['delta']:>8} " + f"{r['pct_inst']:>5.1f}% {str(r['fpc']):>4} {r['code_raw']:>8} " + f"{r['code_inst']:>9} {r['code_inst']-r['code_raw']:>8}") + +# section breakdown for one representative + largest synthetic +p("\n=== Section breakdown (bytes): dispatcher_n1000 raw vs instrumented ===") +for grp, r in rows: + if r['label'].startswith("dispatcher_n1000"): + keys = sorted(set(r['sraw']) | set(r['sinst'])) + p(f"{'section':<20} {'raw':>10} {'inst':>10} {'delta':>10}") + for k in keys: + a = r['sraw'].get(k, (0, None))[0] + b = r['sinst'].get(k, (0, None))[0] + p(f"{k:<20} {a:>10} {b:>10} {b-a:>10}") +print(f"TSV written: {tsv}") diff --git a/test-runs/kd-ok55/measurements.tsv b/test-runs/kd-ok55/measurements.tsv new file mode 100644 index 000000000..2beed7520 --- /dev/null +++ b/test-runs/kd-ok55/measurements.tsv @@ -0,0 +1,21 @@ +group label raw_bytes inst_bytes delta_bytes pct_of_inst pct_of_raw forkpath_fns nfuncs_raw nfuncs_inst code_raw code_inst code_delta +fixture switch_dispatch/carryover_at_subregion.wat 93 625 532 85.1 572.0 2 2 6 22 227 205 +fixture switch_dispatch/direct_call_carryover_in_block.wat 113 819 706 86.2 624.8 3 3 7 35 414 379 +fixture switch_dispatch/guard_dispatch_non_fork_call.wat 118 647 529 81.8 448.3 2 3 6 24 226 202 +fixture switch_dispatch/nested_fork_call.wat 120 672 552 82.1 460.0 2 3 6 26 251 225 +fixture switch_dispatch/posix_spawn_class.wat 160 696 536 77.0 335.0 2 3 6 43 255 212 +fixture switch_dispatch/top_level_carryover.wat 114 838 724 86.4 635.1 3 3 7 36 433 397 +fixture switch_dispatch/waitpid_class.wat 113 618 505 81.7 446.9 2 3 6 19 197 178 +fixture trampoline/legacy_try_fork.wat 102 630 528 83.8 517.6 2 2 6 22 227 205 +fixture trampoline/nested_call_indirect.wat 128 789 661 83.8 516.4 3 3 7 41 375 334 +fixture trampoline/nested_carryover_in_loop.wat 135 907 772 85.1 571.9 3 3 7 57 502 445 +fixture trampoline/nested_multivalue_params.wat 98 662 564 85.2 575.5 2 2 6 21 258 237 +fixture trampoline/top_level_carryover.wat 114 838 724 86.4 635.1 3 3 7 36 433 397 +dispatcher dispatcher_n1 (1 fn, 1 fork-calls) 72 555 483 87.0 670.8 2 2 6 6 163 157 +dispatcher dispatcher_n10 (1 fn, 10 fork-calls) 99 808 709 87.7 716.2 2 2 6 33 416 383 +dispatcher dispatcher_n100 (1 fn, 100 fork-calls) 371 3486 3115 89.4 839.6 2 2 6 304 3094 2790 +dispatcher dispatcher_n1000 (1 fn, 1000 fork-calls) 3071 30456 27385 89.9 891.7 2 2 6 3004 30063 27059 +manyfn manyfn_m1 (1 fork-path fns) 64 547 483 88.3 754.7 2 2 6 6 163 157 +manyfn manyfn_m10 (10 fork-path fns) 163 1628 1465 90.0 898.8 11 11 15 51 1189 1138 +manyfn manyfn_m100 (100 fork-path fns) 1245 12554 11309 90.1 908.4 101 101 105 501 11485 10984 +manyfn manyfn_m1000 (1000 fork-path fns) 13822 124142 110320 88.9 798.1 1001 1001 1005 5002 114986 109984 diff --git a/test-runs/kd-ok55/policy-evidence.txt b/test-runs/kd-ok55/policy-evidence.txt new file mode 100644 index 000000000..def2dc77b --- /dev/null +++ b/test-runs/kd-ok55/policy-evidence.txt @@ -0,0 +1,25 @@ +== disabled-policy packages == +packages/registry/node-vfs/package.toml:27:fork_instrumentation = "disabled" +packages/registry/node/package.toml:21:fork_instrumentation = "disabled" +packages/registry/spidermonkey/package.toml:63:fork_instrumentation = "disabled" +packages/registry/spidermonkey-node/package.toml:21:fork_instrumentation = "disabled" + +== direct $FORK_INSTRUMENT / run-wasm-fork-instrument.sh callers in build scripts == +packages/registry/coreutils/build-coreutils.sh:255: FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" +packages/registry/bash/build-bash.sh:406:FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" +packages/registry/dash/build-dash.sh:162:FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" +packages/registry/git/build-git.sh:99:FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" +packages/registry/dinit/build-dinit.sh:203:FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" +packages/registry/lsof/build-lsof.sh:43:FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" +packages/registry/modeset/build-modeset.sh:36:"$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" \ +packages/registry/nginx/build-nginx-local.sh:405:FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" +packages/registry/netcat/build-netcat.sh:110:FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" +packages/registry/php/build-php.sh:331:FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" +packages/registry/ruby/build-ruby.sh:1129:FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" +packages/registry/vim/build-vim.sh:53:FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" +packages/registry/sqlite/build-testfixture.sh:280:FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" +packages/registry/tcl/build-tcl.sh:168: FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" + +== perl fork config + install (auto-policy path) == +307: -Dd_fork=define \ +531:install_local_binary perl "$SCRIPT_DIR/bin/perl.wasm" diff --git a/test-runs/kd-ok55/real-runtime-findings.md b/test-runs/kd-ok55/real-runtime-findings.md new file mode 100644 index 000000000..2f532d077 --- /dev/null +++ b/test-runs/kd-ok55/real-runtime-findings.md @@ -0,0 +1,39 @@ +# Real-runtime fork-instrumentation measurements (kd-ok55) + +Tool: tools/bin/wasm-fork-instrument (built from convoy base, aarch64-apple-darwin). +Method: `--discover-only` for fork-path count; `wasm-objdump -h` for section/function counts. +Inputs: shipped instrumented binaries in /Users/brandon/src/kandelo/packages/registry/*/bin/ +(read-only, primary_checkout_exception=read-only recorded on bead); ruby raw/stripped from +sibling worktree kd-drt.9 (read-only). + +## Selection ratio (fraction of ALL functions instrumented) — shipped binaries +| runtime | defined funcs | instrumented F | F/defined | +|---|--:|--:|--:| +| bash | 1800 | 1445 | 80.3% | +| coreutils | 3351 | 2313 | 69.0% | +| vim | 4423 | 3489 | 78.9% | +| git | 5071 | 4417 | 87.1% | +| ruby | 7183 | 6238 | 86.8% | +| php | 23475 | 18228 | 77.6% | +| php-fpm | 23699 | 18401 | 77.6% | + +=> The fork-path reverse-reachability closure captures ~69-87% of every program. + This, not per-function stub inefficiency, is the primary size driver. + +## Clean single-step CODE-section delta on real interpreter (ruby, stripped `roots` build) +raw code = 5,946,789 B (data 3,671,022 B, 9299 funcs) +instrumented = 15,858,681 B (data 3,671,022 B unchanged, 9304 funcs, 5 wpk exports) +CODE delta = +9,911,892 B = +166.7% of raw code +instrumented F = 7,681 => ~1,290 B per instrumented function (no-cflags build; upper bound) +Data unchanged => overhead is ~100% code. + +## Reference (kd-lfas, shipped optimized perl) +raw 4,232,959 B -> instrumented 7,291,919 B (+3,058,960 B, +72% of raw, 42% of shipped) +instrumented code section = 5,477,124 B (=> raw code ~2.42 MB, +127% code growth). + +## Cost model (triangulated: analytical + synthetic + real ruby) +bytes(f) ~= A + B*callsites + C*scalar_locals + C_ref*ref_locals + Sarg + A ~= 110 B (fixed preamble/postamble/dispatch boilerplate) -- ~9% of real per-fn cost + B ~= 26-27 B per fork-path call site + C ~= 20 B per scalar local <-- DOMINANT term for real local-heavy C functions +Module floor ~= 483 B (5 wpk_fork_* funcs + 2 globals + exports). diff --git a/test-runs/kd-ok55/real-selection.tsv b/test-runs/kd-ok55/real-selection.tsv new file mode 100644 index 000000000..5214414ba --- /dev/null +++ b/test-runs/kd-ok55/real-selection.tsv @@ -0,0 +1,8 @@ +binary size_B defined_funcs forkpath_count instrumented(F) F/defined_% +bash 2650187 1800 1446 1445 80.3 +coreutils 3948279 3351 2314 2313 69.0 +vim 6994969 4423 3490 3489 78.9 +git 8206340 5071 4418 4417 87.1 +ruby 16879420 7183 6239 6238 86.8 +php 36890652 23475 18229 18228 77.6 +php-fpm 37123997 23699 18402 18401 77.6 diff --git a/test-runs/kd-ok55/section-dumps.txt b/test-runs/kd-ok55/section-dumps.txt new file mode 100644 index 000000000..675aca284 --- /dev/null +++ b/test-runs/kd-ok55/section-dumps.txt @@ -0,0 +1,57 @@ +### nested_fork_call.wat (fixture) RAW + +switch_dispatch__nested_fork_call.wasm: file format wasm 0x1 + +Sections: + + Type start=0x0000000a end=0x00000015 (size=0x0000000b) count: 2 + Import start=0x00000017 end=0x0000003e (size=0x00000027) count: 2 + Function start=0x00000040 end=0x00000042 (size=0x00000002) count: 1 + Memory start=0x00000044 end=0x00000047 (size=0x00000003) count: 1 + Export start=0x00000049 end=0x0000005c (size=0x00000013) count: 2 + Code start=0x0000005e end=0x00000078 (size=0x0000001a) count: 1 + +### nested_fork_call INSTRUMENTED + +switch_dispatch__nested_fork_call.inst.wasm: file format wasm 0x1 + +Sections: + + Type start=0x0000000a end=0x0000001c (size=0x00000012) count: 4 + Import start=0x0000001e end=0x00000045 (size=0x00000027) count: 2 + Function start=0x00000047 end=0x0000004e (size=0x00000007) count: 6 + Memory start=0x00000050 end=0x00000053 (size=0x00000003) count: 1 + Global start=0x00000055 end=0x00000060 (size=0x0000000b) count: 2 + Export start=0x00000063 end=0x000000e3 (size=0x00000080) count: 7 + Code start=0x000000e6 end=0x000001e1 (size=0x000000fb) count: 6 + Custom start=0x000001e4 end=0x00000277 (size=0x00000093) "name" + Custom start=0x00000279 end=0x000002a0 (size=0x00000027) "producers" + +### manyfn_m1000 RAW + +manyfn_m1000.wasm: file format wasm 0x1 + +Sections: + + Type start=0x0000000a end=0x0000000f (size=0x00000005) count: 1 + Import start=0x00000011 end=0x00000027 (size=0x00000016) count: 1 + Function start=0x0000002a end=0x00000414 (size=0x000003ea) count: 1000 + Memory start=0x00000416 end=0x00000419 (size=0x00000003) count: 1 + Export start=0x0000041c end=0x00002271 (size=0x00001e55) count: 1000 + Code start=0x00002274 end=0x000035fe (size=0x0000138a) count: 1000 + +### manyfn_m1000 INSTRUMENTED + +manyfn_m1000.inst.wasm: file format wasm 0x1 + +Sections: + + Type start=0x0000000a end=0x00000016 (size=0x0000000c) count: 3 + Import start=0x00000018 end=0x0000002e (size=0x00000016) count: 1 + Function start=0x00000031 end=0x00000420 (size=0x000003ef) count: 1005 + Memory start=0x00000422 end=0x00000425 (size=0x00000003) count: 1 + Global start=0x00000427 end=0x00000432 (size=0x0000000b) count: 2 + Export start=0x00000435 end=0x000022fc (size=0x00001ec7) count: 1005 + Code start=0x00002300 end=0x0001e42a (size=0x0001c12a) count: 1005 + Custom start=0x0001e42d end=0x0001e4c5 (size=0x00000098) "name" + Custom start=0x0001e4c7 end=0x0001e4ee (size=0x00000027) "producers" diff --git a/test-runs/kd-ok55/table.txt b/test-runs/kd-ok55/table.txt new file mode 100644 index 000000000..3bf22c16a --- /dev/null +++ b/test-runs/kd-ok55/table.txt @@ -0,0 +1,35 @@ + +label raw inst delta %inst fp code_raw code_inst code_Δ +-------------------------------------------------------------------------------------------------------------- +switch_dispatch/carryover_at_subregion.wat 93 625 532 85.1% 2 22 227 205 +switch_dispatch/direct_call_carryover_in_block.wat 113 819 706 86.2% 3 35 414 379 +switch_dispatch/guard_dispatch_non_fork_call.wat 118 647 529 81.8% 2 24 226 202 +switch_dispatch/nested_fork_call.wat 120 672 552 82.1% 2 26 251 225 +switch_dispatch/posix_spawn_class.wat 160 696 536 77.0% 2 43 255 212 +switch_dispatch/top_level_carryover.wat 114 838 724 86.4% 3 36 433 397 +switch_dispatch/waitpid_class.wat 113 618 505 81.7% 2 19 197 178 +trampoline/legacy_try_fork.wat 102 630 528 83.8% 2 22 227 205 +trampoline/nested_call_indirect.wat 128 789 661 83.8% 3 41 375 334 +trampoline/nested_carryover_in_loop.wat 135 907 772 85.1% 3 57 502 445 +trampoline/nested_multivalue_params.wat 98 662 564 85.2% 2 21 258 237 +trampoline/top_level_carryover.wat 114 838 724 86.4% 3 36 433 397 +dispatcher_n1 (1 fn, 1 fork-calls) 72 555 483 87.0% 2 6 163 157 +dispatcher_n10 (1 fn, 10 fork-calls) 99 808 709 87.7% 2 33 416 383 +dispatcher_n100 (1 fn, 100 fork-calls) 371 3486 3115 89.4% 2 304 3094 2790 +dispatcher_n1000 (1 fn, 1000 fork-calls) 3071 30456 27385 89.9% 2 3004 30063 27059 +manyfn_m1 (1 fork-path fns) 64 547 483 88.3% 2 6 163 157 +manyfn_m10 (10 fork-path fns) 163 1628 1465 90.0% 11 51 1189 1138 +manyfn_m100 (100 fork-path fns) 1245 12554 11309 90.1% 101 501 11485 10984 +manyfn_m1000 (1000 fork-path fns) 13822 124142 110320 88.9% 1001 5002 114986 109984 + +=== Section breakdown (bytes): dispatcher_n1000 raw vs instrumented === +section raw inst delta +Code 3004 30063 27059 +Custom:name 0 147 147 +Custom:producers 0 39 39 +Export 14 123 109 +Function 2 7 5 +Global 0 11 11 +Import 22 22 0 +Memory 3 3 0 +Type 5 12 7 diff --git a/test-runs/kd-ok55/tool-help.txt b/test-runs/kd-ok55/tool-help.txt new file mode 100644 index 000000000..9ec3db4de --- /dev/null +++ b/test-runs/kd-ok55/tool-help.txt @@ -0,0 +1,12 @@ +Instrument a wasm module with save/restore machinery for POSIX fork() + +Usage: wasm-fork-instrument [OPTIONS] + +Arguments: + Input wasm file to instrument + +Options: + -o, --output Output path for the instrumented wasm file. Required unless `--discover-only` is set (analysis-only mode) + --entry The fully-qualified name of the import that triggers unwind. Format: `module.field`. Defaults to `kernel.kernel_fork` [default: kernel.kernel_fork] + --discover-only Analyze the module and print the discovered fork-path function set as JSON to stdout. Skips instrumentation and output emission. Useful for validating call-graph discovery against hand-maintained onlylists + -h, --help Print help