kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

commit 6d3a2b1b89cd31db1855b9a57a11abeb1602c9d1
parent bee661f4e7b2b2b9fb0da3c8947dea48402a8e39
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Sat, 13 Jun 2026 09:53:49 -0700

doc(plan): emu as dynamic-analysis substrate (INSTRUMENT.md)

Architecture roadmap for Valgrind/callgrind/asan/ubsan-style debugging and
profiling tools, with the user-mode emulator as the dynamic-binary-
instrumentation substrate. Settles the design now, while only rv64 has a real
emu lifter (aa64/x64 are emu_stubs.c) and block chaining is undesigned-in-code,
so the instrumentation contract is built in rather than retrofitted.

- Three substrates delineated: A = emu DBI tools (focus), B = cross-frontend
  compiler sanitizers via the kit_cg_* waist, C = the existing host-JIT
  dbg/prof (DEBUG.md) — same vocabulary, different substrate.
- EmuToolHooks vtable as the spine (one-core-many-tools); fast-path-degrades-
  under-tools memory seam; shadow planes generalizing the per-page dirty/
  translated precedent; tiered stepping (1-insn blocks now, interp per-op
  yield later); reference impls for callgrind / memcheck / guest-dbg.
- Substrate punch-list (§10): the time-sensitive items that must precede the
  aa64/x64 lifters and the chaining loop (freeze the lifter memory contract,
  add the lift-time shim, decide the chaining/tool flag).
- Profiling: keep `kit prof` as host sampling, add a deterministic emu
  collector; one profile core, two collectors (perf-vs-callgrind split).
  Cross-linked from DEBUG.md §7; indexed in doc/plan/README.md.

Diffstat:
Mdoc/plan/DEBUG.md | 10++++++++++
Adoc/plan/INSTRUMENT.md | 665+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdoc/plan/README.md | 1+
3 files changed, 676 insertions(+), 0 deletions(-)

diff --git a/doc/plan/DEBUG.md b/doc/plan/DEBUG.md @@ -186,6 +186,16 @@ into a pre-allocated ring buffer and returns **without parking** — the one property that keeps sampling cheap and guest timing undisturbed — and PCs are symbolicated after the guest exits. +> **Scope — this is the *sampling* profiler, deliberately on the host-native +> substrate.** It is one of two complementary profilers: this one measures real +> wall/CPU time by statistically sampling natively-run kit code; the +> *deterministic* instruction-counting profiler (callgrind-style — exact, +> reproducible, any guest arch) lives on the emulator substrate +> ([INSTRUMENT.md](INSTRUMENT.md) §9, §12.1). `kit prof` is **not** being +> rebased onto the emulator: the two share a back-end (symbolication + folded / +> flat output), not a collector. See INSTRUMENT.md §9 for the perf-vs-callgrind +> split and the one-core-two-collectors factoring. + Public API (`include/kit.h`): - Add `on_sample(void* session, void* ucontext)` to `KitDbgSignalOps` (NULL = diff --git a/doc/plan/INSTRUMENT.md b/doc/plan/INSTRUMENT.md @@ -0,0 +1,665 @@ +# Instrumentation & Dynamic-Analysis Tools + +Forward-looking design for a family of debugging and profiling tools in the +Valgrind / callgrind / AddressSanitizer / UBSan tradition. The load-bearing +decision in this document is that kit's **user-mode emulator** (`src/emu`, +[../EMU.md](../EMU.md)) becomes the substrate for the *dynamic-binary- +instrumentation* (DBI) family, and that its design is fixed **now**, while the +emulator is still partial, so the instrumentation contract is built in from the +start rather than retrofitted. + +Status of the substrate at time of writing: + +- Only **rv64** ships a real `ArchEmuOps`; **aa64 and x64 are empty stubs** + (`src/arch/emu_stubs.c`). Whatever instrumentation contract the lifter must + honor can be specified before those two lifters are written. +- There is **no block chaining** yet — every block round-trips through the + dispatcher (`kit_emu_step`, `src/emu/emu.c:640`). That round-trip is a natural + per-block hook that *will disappear* when chaining lands, so any block-level + instrumentation must be designed to survive chaining. +- Guest memory is reached **only** through helper calls (`__emu_load*` / + `__emu_store*`, `src/emu/emu.h:460`) funneling through one choke point + (`emu_addr_space_ptr`, `src/emu/image.c:333`). Guest syscalls funnel through + one choke point (`emu_syscall`, `src/emu/runtime.c`). These are ideal, stable + observation seams today; the design must keep them stable as the emu optimizes. + +This is the right moment to get the seams right once. + +--- + +## 1. Three substrates, one document's focus + +Kit grows three distinct instrumentation substrates. They are complementary, not +competing; this document designs **A** in depth and situates **B** and **C**. + +| | Substrate | What it observes | Mechanism | Status | +|---|---|---|---|---| +| **A** | **Emu DBI tools** (this doc) | *Any* guest binary, instruction-by-instruction | Software emulation + per-block JIT translation; tool hooks at the runtime/lift boundary | emu partial (rv64 only) | +| **B** | **Compiler sanitizers** | kit-*compiled* programs, from *any* frontend | Compile-time IR instrumentation + a runtime library | not started | +| **C** | **Host-JIT debugger / profiler** | kit-compiled code running natively on the host | Software breakpoints, displaced single-step, FP-walk sampling | `kit dbg` shipped, `kit prof` planned ([DEBUG.md](DEBUG.md)) | + +The three differ on a single axis — *where the observed code runs*: + +- **A** runs guest code on a *synthetic* CPU (the emu). Exact and deterministic, + ~10–50× slowdown, needs no source and no recompile, works on closed binaries + and cross-architecture guests. This is the Valgrind/callgrind/memcheck niche. +- **B** runs *on the real host CPU* with checks baked into the binary at compile + time. Near-native speed, needs source + recompile, only sees instrumented + code. This is the ASan/UBSan/MSan/TSan niche. Its design is the + *CG-API-as-narrow-waist* story (see §11); it is frontend-neutral by + construction because every frontend funnels through the same `kit_cg_*` API. +- **C** runs kit-compiled code natively and interrupts it with host debug + primitives. Designed already in [DEBUG.md](DEBUG.md) / [../DBG.md](../DBG.md); + cross-referenced here only where seams are shared (disassembly, DWARF + symbolication, the `KitProfBuf` ring, folded-stack output). + +**Why the emu is the right home for Family A.** The emu already *is* a DBI +engine: it disassembles guest basic blocks, lifts them through the CG pipeline, +JITs them, and dispatches through a code cache — structurally identical to +Valgrind's disassemble-and-resynthesize core, with the kit CG/opt IR playing the +role of VEX. Adding the DBI tool family is therefore not a new engine; it is a +*tool-plugin layer* over an engine kit already has. The cost of getting it wrong +is high, though: the emu's hot path (dispatch, memory helpers, lifting) is +exactly where tools must hook, so the seams must be first-class. + +--- + +## 2. The architectural thesis: observation is a first-class emu concern + +The emu was conceived as a way to *run* a guest binary. Once the DBI tool family +is a primary customer, the emu's job widens: it must let a tool *observe* every +architecturally-visible event — instruction retirement, memory access, control +transfer (call/return/branch), and syscall — with enough fidelity to count, +check, shadow, or trap on each, while staying arch-neutral (so a tool written +once works for rv64, aa64, x64, and any future guest) and format/OS-neutral. + +Valgrind's enduring lesson is **one core, many tools**: a single instrumentation +core exposes a small hook vocabulary, and Memcheck, Callgrind, Cachegrind, +Massif, Helgrind are all *plugins* over it. Kit adopts the same shape. The +**`EmuToolHooks` vtable** (§4) is that hook vocabulary. Everything else in this +document is either (a) making sure the emu's hot seams can drive those hooks +without the hooks evaporating under future optimization, or (b) cataloguing the +tools that ride them. + +Two-way freeness is the prize, and it holds on two axes at once: + +- **New tool → every guest arch for free.** Hooks live above the per-arch + lifter, so a tool never contains arch-specific code. +- **New guest arch → every tool for free.** When the aa64/x64 lifters are + written to the contract in §10, every existing tool lights up for them. + +--- + +## 3. The four load-bearing decisions + +These were settled explicitly during design and shape everything below. + +1. **Tool seam = a hook vtable at the runtime boundary** (not an IR pass, not an + interpreter-only plane). `EmuToolHooks` is invoked by the runtime helpers, + the dispatcher, a thin lift-time shim, and the syscall trampoline. Arch- + neutral, shared across JIT and INTERP modes, and — critically — designed to + survive block chaining (§7). The CG-IR-pass and interpreter-plane approaches + are *compositional refinements* under this spine (§4.4), not the spine. + +2. **Memory = stable indirection whose fast path degrades under tools.** When no + memory tool is active, the lifter may inline a softmmu/TLB fast path. When any + memory tool is active, every access routes through the checked helper seam and + fires `on_mem_access`. "Fast when unobserved, fully observable when needed" — + the QEMU/Valgrind answer. Peak throughput is capped only while a memory tool + runs (§6). + +3. **Stepping = tiered.** A *single-instruction-block mode* (cap the block to one + guest instruction) delivers JIT-speed single-step, guest-PC breakpoints, and + watchpoints immediately, with no interpreter refactor. A later *per-IR-op + interpreter yield* enables rich interpreter-resident tools (§8). + +4. **Scope = full-suite design, one reference impl per class.** The hook ABI and + seams are general enough for the whole suite (callgrind, cachegrind, memcheck, + massif, helgrind, asan/ubsan, strace, a guest debugger); §12 carries a + worked reference implementation for one tool in each of the three tool + classes (profiler, memory-checker, debugger) to prove the seams. + +--- + +## 4. The `EmuToolHooks` ABI — the spine + +A tool is a struct of function pointers plus an opaque `user` context, registered +on a `KitEmu` before stepping begins. The emu core invokes hooks at fixed +observation points. A NULL hook means "not observed" and costs at most one +predicted-not-taken branch; a NULL *vtable* means "no tool", and the emu takes +its existing fast paths unchanged. + +### 4.1 The interface + +```c +/* include/kit/emu.h — public, embedder-facing */ +typedef struct KitEmuToolHooks { + /* Control flow / profiling. block_id is the guest PC of the block head; + * stable across re-translation (the bijective emu_block_<pc> identity). */ + void (*on_block_entry)(void* user, KitEmu*, uint64_t block_pc, + uint32_t guest_insn_count); + void (*on_insn_retire)(void* user, KitEmu*, uint64_t guest_pc, + const KitDecodedInsn*); /* step/cachegrind/cov */ + void (*on_branch)(void* user, KitEmu*, uint64_t from_pc, uint64_t to_pc, + KitEmuEdgeKind kind); /* CALL/RET/JMP/COND */ + + /* Memory. addr/size are guest; is_write distinguishes load vs store; + * host_ptr is the resolved host pointer AFTER the bounds check (NULL on a + * faulting access). Return KIT_EMU_TOOL_DENY to convert the access into a + * guest fault (watchpoint / poisoned-redzone hit). */ + KitEmuToolVerdict (*on_mem_access)(void* user, KitEmu*, uint64_t addr, + uint32_t size, bool is_write, + void* host_ptr, uint64_t guest_pc); + + /* Address-space lifecycle — lets a shadow plane track map/unmap/protect. */ + void (*on_map)(void* user, KitEmu*, uint64_t va, uint64_t len, uint8_t perms); + void (*on_unmap)(void* user, KitEmu*, uint64_t va, uint64_t len); + + /* Syscalls — strace, and allocation interception via brk/mmap. Fires + * around the existing single choke point. */ + void (*on_syscall_entry)(void* user, KitEmu*, const KitEmuSyscallRequest*); + void (*on_syscall_exit)(void* user, KitEmu*, const KitEmuSyscallRequest*, + const KitEmuSyscallResult*); + + void* user; +} KitEmuToolHooks; +``` + +`KitEmuEdgeKind` is derived directly from the decode flags already on +`KitDecodedInsn` (`KIT_DECODE_CALL/RET/BRANCH/TERMINATOR`, +`include/kit/arch.h`), so the emu classifies edges without arch-specific code. + +### 4.2 Where each hook is driven + +| Hook | Driven from | Frequency | +|---|---|---| +| `on_block_entry` | block **prologue** (baked in at lift time by a shared shim) | once per block execution — *survives chaining* | +| `on_insn_retire` | only emitted when a retire-level tool is active; per-guest-insn shim in the lifted body, or naturally in 1-insn-block step mode | per instruction | +| `on_branch` | block **epilogue** / terminator lowering | once per block exit | +| `on_mem_access` | the `__emu_*` memory helpers (`src/emu/runtime.c`) at the `emu_addr_space_ptr` choke point | per guest load/store (tool-active path only) | +| `on_map` / `on_unmap` | `emu_addr_space_map` / `_unmap` / `_protect` (`src/emu/image.c`) | per VM change | +| `on_syscall_*` | `emu_syscall` trampoline (`src/emu/runtime.c`) | per guest syscall | + +The crucial design point is **where `on_block_entry` lives**. It must be emitted +into the *block's own prologue* (a few CG ops the shared shim prepends to every +lifted block), **not** into the dispatcher loop. Today the dispatcher sees every +block because there is no chaining; once chaining lands, blocks jump directly to +one another and the dispatcher is bypassed. Counting in the prologue is invariant +to that change — this is exactly how real Callgrind bakes counters into the +translation rather than the dispatch loop. Driving `on_block_entry` from the +dispatcher would be the easy thing to do today and the wrong thing for the +chaining future; the contract forbids it. + +### 4.3 The lift-time shim (how hooks stay arch-neutral) + +The per-arch `lift_block` must not know about tools. Instead, `translate_block` +(`src/emu/emu.c:420`) wraps the arch lifter: + +``` +translate_block: + decode_block (arch) + cg_begin block fn + tool_prologue(cg, ctx) <-- shared: emits on_block_entry call iff a + block/profiling tool is active + arch->emu->lift_block(...) <-- unchanged per-arch body + tool_epilogue(cg, ctx) <-- shared: emits on_branch call iff active + cg_finish / link / cache +``` + +`tool_prologue` / `tool_epilogue` emit ordinary CG calls to new runtime helpers +(`__emu_tool_block_entry`, `__emu_tool_branch`) resolved by +`emu_runtime_extern_resolver` to thin C trampolines that forward to the active +`KitEmuToolHooks`. Because they are emitted by the shared wrapper, **every arch +gets them for free** and the rv64 lifter needs no change to acquire block/branch +observation; only the *memory* hook touches lifter-emitted code, and that is +already a helper call in the contract (§10). + +### 4.4 How the other two strategies compose under the spine + +The IR-pass and interpreter-plane strategies are not discarded; they slot in as +*implementations* of specific hooks for tools that need more than the boundary +gives: + +- **CG-IR instrumentation** is how a tool that needs *per-IR-op* work (e.g. a + precise Cachegrind that models every micro-op, or a UBSan-for-guest that traps + on guest-level overflow) injects code: it is a shared pass between + `arch->emu->lift_block` and `kit_cg_finish`, gated by the tool. It rides the + same lift-time shim point; the spine just calls a pass instead of emitting a + single call. +- **Interpreter per-op hooks** are how the heaviest shadow-value tools + (Memcheck-grade definedness / taint) get a uniform per-op execution point + (§8). The hook *vocabulary* is unchanged; only the driver (interpreter vs JIT'd + helper) differs. + +This is the payoff of choosing the vtable as the spine: a tool is defined by +*which hooks it implements*, and the emu is free to drive those hooks from a +helper call, an inlined check, an IR-pass insertion, or an interpreter step, +whichever the active configuration makes cheapest. + +--- + +## 5. Shadow memory as a first-class, extensible plane + +Most Family-A tools (memcheck A/V bits, ASan addressability, MSan definedness, +DRD/Helgrind access history, a watchpoint set) need *shadow memory*: metadata +indexed by guest address. The emu already has the precedent — every `EmuMap` +carries per-page `dirty_pages` and `translated_pages` bitmaps +(`src/emu/emu.h:91`). The design generalizes this into **named shadow planes**. + +```c +/* A tool registers a shadow plane describing bytes-of-shadow per guest byte. + * The emu allocates/extends/frees the plane in lockstep with EmuMap lifecycle + * (on_map/on_unmap/protect), so the tool never tracks the VM layout itself. */ +typedef struct EmuShadowPlaneDesc { + const char* name; + uint32_t shadow_bits_per_byte; /* memcheck V=8, ASan ~0.125 (1/8), watch=1 */ + uint32_t shadow_bits_per_page; /* coarse per-page planes (dirty-style) */ +} EmuShadowPlaneDesc; + +uint8_t* emu_shadow_for(EmuShadowPlane*, uint64_t va, uint64_t nbytes); +``` + +Two reasons this belongs in the emu core, not in each tool: + +1. The emu is the *only* module that knows the VM layout (`EmuAddrSpace` is the + sole guest→host translator, [../EMU.md](../EMU.md)). A tool that tracked maps + itself would duplicate split/merge/brk/mmap logic and drift. +2. The generation-counter invalidation that handles self-modifying guest code + already touches per-page metadata; shadow planes ride the same machinery, so + SMC and `mprotect` stay correct for shadows automatically. + +The fast/slow memory split (§6) reads cleanly against planes: with no plane +registered, the inline TLB path returns the host pointer directly; with a plane +registered, the access takes the helper path, which computes +`emu_shadow_for(addr)` alongside `emu_addr_space_ptr(addr)` and calls +`on_mem_access` with both in scope. + +--- + +## 6. The memory seam: stable indirection, fast path degrades under tools + +Memory access is simultaneously the emu's hottest path and its richest +observation point. The decision is to keep a **stable indirection** that a tool +can always intercept, while permitting an inline fast path when unobserved. + +``` +guest load/store in a lifted block + │ + ├── no memory tool active ──> inline softmmu TLB probe + │ (va→host_ptr, perm check) ──> host access + │ + └── memory tool active ─────> __emu_load/store* helper (current path) + │ + emu_addr_space_ptr (bounds/perm, image.c:333) + │ + on_mem_access(addr,size,is_write,host_ptr,pc) + │ verdict ALLOW ──> host access + └─ verdict DENY ──> emu_fault_deliver + (watchpoint / poison) +``` + +Design notes: + +- **Today the slow path is the only path** (the rv64 lifter always calls the + checked helper). So Family-A memory tools work *the moment* the hook is wired — + the fast path is a later throughput optimization, not a prerequisite. +- **The checked helpers already carry `fault_pc` / `next_pc`** + (`src/emu/emu.h:493`) and route faults through `emu_fault_deliver` so an OS + personality can synthesize a guest signal. A watchpoint or a poisoned-redzone + hit reuses that exact path: `on_mem_access` returns DENY, the helper delivers a + fault, and the tool (or the OS) decides whether it becomes a guest signal or a + host-visible stop. No new fault plumbing. +- **The fast path must be a true superset relationship**: the inline TLB and the + helper must agree byte-for-byte on bounds/permission semantics, so toggling a + tool changes *speed and observability only*, never guest-visible behavior. The + TLB is a cache over `emu_addr_space_ptr`, invalidated by the same generation + counter. +- A per-`KitEmu` flag `mem_tools_active` selects which path the lifter emits. + Because lifting is lazy and per-block, enabling a memory tool mid-run simply + drops the code cache (the generation-bump mechanism already does this) and + re-translates on the slow path on demand. + +--- + +## 7. Block chaining vs. instrumentation (don't paint the corner) + +[../EMU.md](../EMU.md) documents chaining as *design intent*: bump-allocate +blocks into one growing RX image (`EmuCodeRegion`) and patch direct jumps between +them, falling back to `__emu_dispatch` for not-yet-translated edges. Chaining +removes the dispatcher round-trip — and with it, any instrumentation that lived +in the dispatcher. + +The contract that keeps both working: + +1. **Block/branch/insn counting lives in the block body** (prologue/epilogue + shim, §4.3), never in the dispatcher. Counting is then invariant to chaining. +2. **Chaining is disabled while a tool that needs the dispatcher is active.** + Some tools genuinely want the dispatcher boundary (e.g. a coarse single-step + that stops between blocks, or a tool that re-checks a breakpoint set each + edge). For those, a `tool_requires_dispatch` flag forces the + one-block-per-image strategy. This is cheap and already the *current* default; + chaining is the opt-in fast mode, and "a heavy tool turns chaining off" is an + acceptable, explicit trade. +3. **`on_branch` is emitted at the terminator**, so even under chaining a + profiler sees every edge (the patched jump and the hook call coexist; the hook + is just more block-body code). + +The single rule: *no observation may depend on the dispatcher being on the hot +path.* Encode it in the shim, gate chaining on the tool, and the futures don't +collide. + +--- + +## 8. Stepping, breakpoints, watchpoints — a guest debugger + +This is Family A's debugger (distinct from the host-JIT `kit dbg` of Family C — +see §9). It debugs *guest* code under emulation. + +**Tier 1 — single-instruction-block mode (ship first).** Add a `KitEmu` step +mode that caps the block to one guest instruction (effectively +`EMU_MAX_INSTS_PER_BLOCK = 1` for the translate, plus a terminator after the +single insn). Then: + +- **Single-step** is one `kit_emu_step(e, 1)` — the dispatcher already returns + control after each block, so a 1-insn block *is* an instruction step. No + interpreter change. +- **Breakpoints (guest PC)** are a set checked in `on_block_entry` (or by + refusing to chain across a breakpointed PC): when the next block head is in the + set, stop. Because block identity is the guest PC, this is an O(1) set probe. +- **Watchpoints (guest address)** are a `DENY` verdict from `on_mem_access` when + the access range intersects the watch set — reusing the fault-delivery path. + This is the emu's answer to the "watchpoints need hardware debug registers" + blocker noted in [DEBUG.md §8](DEBUG.md): in the emu there is no hardware to + need — every access is already software-mediated. +- **Register / memory inspection** uses the arch-neutral `ArchEmuOps` + accessors (`get_gpr`/`set_gpr`/`get_sp`/`get_tp`, `src/arch/arch.h`) and + `emu_addr_space_ptr`. A debugger reads guest state without any arch-specific + code of its own. +- **Disassembly** for the debugger view reuses the shared `ArchDecodeOps` (the + same decoder `objdump` and `dbg` use), so listings come for free. +- **Symbolication** reuses the DWARF consumer (`src/debug/dwarf_*`) against the + guest image's debug sections — the same `kit_dwarf_addr_to_line` path Family C + uses. + +Tier 1 gives a usable `b`/`r`/`s`/`c`/`x`/`p`/watch REPL over any guest binary, +on any guest arch, with zero interpreter work. + +**Tier 2 — per-IR-op interpreter yield (later).** The interpreter currently runs +a whole frame to completion (`kit_interp_resume`, no per-op hook). Refactoring +`interp_run_stack` to yield between IR ops gives (a) sub-instruction stepping for +guest debugging, and (b) the uniform per-op execution point the heaviest +shadow-value tools want (Memcheck definedness, taint). This is a real change and +is deliberately deferred behind Tier 1 so a working debugger does not block on +it. + +--- + +## 9. Relationship to the host-JIT debugger and `kit prof` + +Family C ([DEBUG.md](DEBUG.md)) and Family A overlap in *vocabulary* but not in +*substrate*, and the doc must keep them from being conflated: + +| | Family C (`kit dbg`/`kit prof`) | Family A (emu tools) | +|---|---|---| +| Observed code | kit-compiled, native, on host CPU | any guest binary, on synthetic CPU | +| Breakpoint | software INT3/EBREAK patch, refcounted | guest-PC set in `on_block_entry` | +| Single-step | displaced step on real CPU (`ArchDbgOps`) | 1-insn block / interp yield | +| Watchpoint | needs HW debug regs (blocked, DEBUG.md §8) | `on_mem_access` DENY (free) | +| Sampling | SIGPROF + FP-walk ring (`KitProfBuf`) | deterministic block/insn counts | +| Speed | near-native | 10–50× | + +**Shared seams to reuse, not duplicate:** the `ArchDecodeOps` disassembler, the +`src/debug/dwarf_*` symbolication, the `KitProfBuf` folded-stack ring and +flamegraph output (a deterministic emu-callgrind can emit the same folded format +`kit prof` does), and the REPL command engine ([DEBUG.md §4](DEBUG.md) calls for +a factored, machine-readable command engine — the emu debugger should consume the +*same* engine so editors get both for one integration cost). + +The two are genuinely complementary: Family C answers "how does my native build +behave and where is its time going" at native speed; Family A answers "is this +binary *correct* — memory-safe, defined, race-free — and what is its exact +instruction-level cost" regardless of source or host arch. + +### Profiling is one tool with two collectors + +A specific and important case of the above: kit's sampling profiler (`kit prof`, +[DEBUG.md §7](DEBUG.md)) and the emu's deterministic profiler (§12.1) are **not +substitutes**, and `kit prof` should **not** be rebased onto the emu substrate. +They answer different questions — the classic `perf`-vs-`callgrind` split: + +| | `kit prof` — sampling (Family C) | `emu callgrind` — counting (Family A) | +|---|---|---| +| Measures | real wall/CPU **time** | exact **instruction counts** | +| µarch effects (cache/branch/TLB) | real, captured | invisible / only ever simulated | +| Determinism | statistical, varies run to run | bit-exact, reproducible | +| Speed | native | 10–50× | +| Target | native kit-JIT'd code | any guest ELF, any guest arch | + +Kit's own perf campaign already proves both are needed: macOS `sample` *missed* +the biggest −O0 win (the O(n²) `strtab_add`) because sampling sees self-time +leaves only, while Linux `valgrind callgrind` caught it through *inclusive* +instruction attribution. Moving `prof` onto the emu would lose real timing, the +ability to profile long / IO-bound programs (the 10–50× tax), and native-target +coverage; and *sampling inside the emu* is pointless — the emu can count exactly, +so exactness is its job. + +What they **should** share is everything downstream of collection: + +``` +collectors (front-end) shared profile core +────────────────────── ─────────────────── +host sampler: SIGPROF + FP-walk ┐ + weight = samples (≈ µs) ├─> weighted call graph / weighted stacks +emu counter: on_block_entry + ┘ → symbolication (kit_dwarf_* / addr_to_sym) + on_branch shadow call stack → folded (flamegraph), flat self/cumul, + weight = instructions (exact) speedscope / pprof / callgrind.out +``` + +Only the *raw collection* (a sample ring vs a per-block count + edge map) and the +*cost-unit label* (samples ≈ time vs instructions, exact) differ; the two +converge at the weighted-call-tree stage and share symbolication and every output +format from there on. So the architecture is **one profile core, two pluggable +collectors**: `KitProfBuf` stays the sampler's raw buffer, the emu counter keeps +its own count/edge map, and both lower into the shared aggregation / +symbolication / render layer. + +CLI: keep `kit prof` as sampling/native; expose the deterministic collector as +`kit prof --exact` (or `kit emu callgrind`). The payoff is an in-tree, +host-independent, deterministic callgrind that emits the same flamegraph the +sampler does — retiring the external-Linux-`valgrind` dependency the perf +campaign currently leans on. + +--- + +## 10. Substrate punch-list — make the emu tool-ready *now* + +The highest-value, time-sensitive work, because aa64/x64 lifters are unwritten +and chaining is undesigned-in-code. Doing these first means tools are additive +later instead of forcing an emu redesign. + +1. **Freeze the memory-access contract for lifters.** Specify (in + [../EMU.md](../EMU.md) and the `ArchEmuOps` doc-comment) that **every guest + load/store must go through the `__emu_*` memory helpers** — never an inlined + host access — *unless* it goes through the sanctioned softmmu TLB intrinsic + (§6) that itself preserves the helper fallback. Write this before aa64/x64 + lifters exist so all three honor it. The rv64 lifter already complies. +2. **Add the lift-time shim points** (`tool_prologue`/`tool_epilogue` in + `translate_block`) and the `__emu_tool_*` runtime trampolines, even if the + only tool initially is a no-op. This nails down *where* block/branch + observation enters, arch-neutrally. +3. **Generalize per-page metadata into shadow planes** (§5), built on the + existing `dirty_pages`/`translated_pages` precedent, wired to + map/unmap/protect. +4. **Route memory faults uniformly through `emu_fault_deliver`** for *all* + accesses (the checked variants already do; ensure the plain variants and the + future TLB path can escalate identically), so a tool DENY verdict has one + delivery path. +5. **Land the 1-insn-block step mode** (§8 Tier 1) — small, unblocks the whole + debugger class, and exercises `on_insn_retire` end-to-end. +6. **Define `KitEmuToolHooks` in `include/kit/emu.h`** and a + `kit_emu_set_tool_hooks(KitEmu*, const KitEmuToolHooks*)` setter; thread the + active vtable through the runtime trampolines and the syscall trampoline. +7. **Decide the chaining/tool interaction flag** (`tool_requires_dispatch`, §7) + as part of the chaining design rather than after it. + +Items 1, 2, and 7 are the irreversible-if-skipped ones: they constrain code that +is about to be written (the aa64/x64 lifters, the chaining loop). Everything else +can be added incrementally. + +--- + +## 11. Family B in brief — compiler sanitizers across all frontends + +Out of this document's primary scope but recorded so the substrates stay +coherent. Family B instruments kit-*compiled* programs at compile time, and its +design centerpiece is that **the public CG API is kit's narrow waist**: every +frontend (C, wasm, toy, future) funnels through `kit_cg_*`, so instrumenting +below that surface gives all frontends the tool for free. + +- **Language-neutral tools (ASan, MSan, coverage)**: a shared pass over the + opt-IR (late, post-DCE, LLVM-style) walking `kit_cg_load`/`kit_cg_store`/ + `kit_cg_call` nodes — zero per-frontend code, because C's `a[i]`, wasm's + `i32.load`, and toy's load all lower to the same IR node. +- **Language-specific policy (UBSan)**: the per-op trapping flags + *already exist* in the CG API — `KIT_CG_INTOP_TRAP_SIGNED_OVERFLOW` / + `TRAP_UNSIGNED_OVERFLOW` (`include/kit/cg.h:846`). The frontend's only job is + to OR the flag in per its language's UB rules (C does; wasm doesn't); the + shared lowering turns the flag into a check + `__kit_ubsan_handle_*` call, + reading the already-sticky `kit_cg_set_loc` for the report location. +- **Shared runtime + reporting**: `__kit_{ubsan,asan}_*` handlers, shadow setup, + and malloc replacement live once in `rt/`; reports reuse the existing DWARF / + addr2line / backtrace machinery, which is already frontend-neutral. + +This is a separate roadmap to write when Family A's tier-1 lands; it shares only +the *philosophy* (instrument the narrow waist) with Family A, not the substrate. + +--- + +## 12. Reference implementations — one per tool class + +Concrete sketches proving the §4 hooks suffice for each class. + +### 12.1 Profiler class — `emu callgrind` (deterministic) + +**Goal:** exact, reproducible instruction counts with inclusive caller→callee +attribution, output in `callgrind.out` format for `callgrind_annotate` / +KCachegrind, and folded stacks for the shared flamegraph path (§9). + +**Hooks used:** `on_block_entry` (per-block instruction count from +`guest_insn_count`), `on_branch` (maintain a shadow call stack: push on CALL, pop +on RET, attribute self-cost to the current frame and inclusive cost up the +stack), optionally `on_mem_access` for a Cachegrind-style cache model. + +``` +state: shadow call stack of (fn_pc, self_count); map (caller,callee)->count +on_block_entry(pc, n): cur.self += n; total += n +on_branch(from,to,kind): + CALL: edges[(cur.fn,to)]++; push(to) + RET: pop() + else: if to starts a known fn, treat as tail edge +on exit: walk frames, write callgrind.out (fn = nearest symbol via DWARF) +``` + +Determinism falls out of counting rather than sampling — the same property that +makes this directly useful for kit's *own* `-O0` perf campaign (it retires the +external-Linux-callgrind dependency the perf work currently leans on, and runs on +any host for any guest arch). ~one file (`src/emu/tool_callgrind.c`) + a driver +verb. + +### 12.2 Memory-checker class — `emu memcheck` (addressability + redzones) + +**Goal:** detect invalid reads/writes, heap buffer overflows, and use-after-free +on an unmodified guest binary — Memcheck's core, minus (initially) bit-precise +definedness, which waits for the Tier-2 interpreter (§8). + +**Hooks used:** `on_mem_access` (consult the A-bit shadow plane; DENY → fault on +an unaddressable byte), `on_syscall_entry/exit` (intercept `brk`/`mmap` to set +addressability), and allocation interception via the **import-binding** mechanism +the emu already has (`emu_call_host_import`, [../EMU.md](../EMU.md)): bind the +guest's `malloc`/`free` so the tool can poison redzones around each block and +quarantine freed blocks. + +``` +shadow plane "A": 1 bit per guest byte (addressable?) +on_map(va,len): mark addressable per perms +malloc(n) intercept: underflow+overflow redzones poisoned; body addressable +free(p) intercept: poison body; quarantine (delay reuse) -> use-after-free +on_mem_access(addr,size,is_write,_,pc): + if any byte in [addr,addr+size) not addressable: report + DENY +``` + +The redzone + quarantine logic is identical to ASan's runtime; here it lives in +the tool, driven by the import-binding and shadow-plane seams. Adding the V-bit +(definedness) plane later is the same plane mechanism plus per-op propagation in +the Tier-2 interpreter — the hook vocabulary does not change. + +### 12.3 Debugger class — `emu dbg` (guest-level interactive debugger) + +**Goal:** a `gdb`-style REPL over guest execution: breakpoints, single-step, +watchpoints, register/memory/backtrace inspection, source listing — for any +guest binary and arch. + +**Hooks used:** `on_block_entry` (breakpoint set probe), 1-insn-block step mode +(single-step), `on_mem_access` DENY (watchpoints). Inspection uses `ArchEmuOps` +accessors, `emu_addr_space_ptr`, the shared `ArchDecodeOps` disassembler, and the +DWARF consumer for `file:line` and locals. + +``` +b <sym|addr>: resolve via DWARF/symbols -> guest PC into breakpoint set +r: kit_emu_step until on_block_entry hits a breakpoint +s: one 1-insn-block step +watch <addr>: add to watch set -> on_mem_access DENY on intersect +bt: FP-walk the guest stack (arch fp/lr offsets from ArchEmuOps) +p <reg|var>: get_gpr / DWARF loc-expr eval over guest memory +x <addr>: emu_addr_space_ptr + format +``` + +This reuses the factored REPL command engine that [DEBUG.md §4](DEBUG.md) already +calls for, so the guest debugger and the host-JIT debugger present one command +surface to editors/IDEs. + +--- + +## 13. Phasing + +1. **Substrate (the §10 punch-list).** Hook ABI, lift-time shim, shadow planes, + 1-insn step mode, memory-contract freeze, chaining/tool flag. No user-facing + tool yet — this is the load-bearing, time-sensitive work. +2. **Tier-1 tools.** `emu callgrind` (§12.1), `emu strace` (`on_syscall_*` only — + nearly free), and the `emu dbg` watchpoint/breakpoint/step debugger (§12.3). + These exercise every hook except the heaviest and deliver immediate value + (including the in-tree deterministic profiler kit's perf work wants). +3. **Memory checker.** `emu memcheck` addressability + redzones (§12.2) on the + shadow-plane + import-binding seams. +4. **Tier-2 interpreter yield** (§8) → bit-precise definedness (memcheck V-bits / + MSan-for-guest), taint, and sub-instruction stepping. +5. **Family B** (compiler sanitizers, §11) as its own roadmap once Tier-1 proves + the report/runtime patterns. + +Each phase is independently shippable; phase 1 is the only one whose *omission* +would force later rework, which is the whole reason this document exists now. + +--- + +## 14. Open questions / risks + +- **Fast-path TLB vs. shadow coherence.** The inline TLB (§6) must be provably a + pure cache over `emu_addr_space_ptr`; any divergence becomes a heisenbug that + appears only with a tool off. Needs a differential test: every access under a + "shadow-everything, allow-everything" null tool must match the fast path. +- **Interpreter yield cost.** Yielding per IR-op (§8 Tier 2) may regress INTERP + throughput even when no tool is active; gate it so the non-stepping interp + stays a straight loop. +- **Multi-threaded guests.** The emu models one thread today; Helgrind/DRD-class + tools and per-thread profiling need the same thread-enumeration widening + [DEBUG.md §8](DEBUG.md) flags for Family C. Out of scope until a guest needs it, + but the `EmuThread` already being a first-class struct keeps the door open. +- **Hook overhead when "inactive".** Confirm a NULL vtable truly costs nothing on + the hot path (no per-access branch) — the lifter must emit the fast path, not a + runtime check, when no tool is registered. +- **Where Cachegrind's cache model lives.** A precise model wants per-access + observation (the `on_mem_access` path) but is meaningless without realistic + cache parameters per guest µarch; decide whether to ship a single generic model + or make it configurable. +</content> +</invoke> diff --git a/doc/plan/README.md b/doc/plan/README.md @@ -14,6 +14,7 @@ shrinks to whatever remains open. | [LINKER.md](LINKER.md) | Incremental linking: the file-based object-link redesign and remaining non-ELF format coverage. | [../LINK.md](../LINK.md) | | [JIT.md](JIT.md) | Function-level hot reload, Go-runtime-style codegen support, and remaining JIT host-portability work. | [../JIT.md](../JIT.md) | | [DEBUG.md](DEBUG.md) | The Windows debugger host adapter, x64/rv64 displaced single-step, profiling, and DWARF gaps. | [../DBG.md](../DBG.md), [../DWARF.md](../DWARF.md) | +| [INSTRUMENT.md](INSTRUMENT.md) | Debugging/profiling tools in the Valgrind/callgrind/asan/ubsan tradition: the emulator as a dynamic-binary-instrumentation substrate (the `EmuToolHooks` ABI, shadow planes, the memory seam, a guest debugger), plus the cross-frontend compiler-sanitizer story. | [../EMU.md](../EMU.md) | | [WASM.md](WASM.md) | Completing the Wasm object backend and remaining parser/validator coverage. | [../WASM.md](../WASM.md) | | [ARCH.md](ARCH.md) | Remaining native-backend completeness for x64/rv64 relative to the aa64 reference, and per-call cost follow-ups. | [../ARCH.md](../ARCH.md) | | [BOOTSTRAP.md](BOOTSTRAP.md) | The 3-stage self-build reproducibility goal and the open `-O1` issues blocking it. | [../BUILD.md](../BUILD.md) |