Status: PHASE 0 RUN, NO-GO (2026-07-23). Not being built. Owner:
unassigned. Companion plan: pixman_accel_plan.md (steady-state rendering;
that plan is unaffected by this result).
ISH_JIT_TIMING=1 instrumentation was added (jit/jit.c + main.c, commit
365cd259) and measured on an -O2 CLI build (Arch aarch64 guest) across four
workload shapes, including a real GTK3 window built and painted under a
headless labwc session (not just interpreter boot):
| workload | wall | jit-compile | share |
|---|---|---|---|
| python3 interpreter boot | 0.28s | 16.7ms | 6.0% |
| 9-module stdlib import chain | 2.15s | 75ms | 3.5% |
| real GTK3 window (labwc+cairo) | 5.4s | 210ms | 3.9% |
Translation is consistently 3.5–6% of wall time, well under the ~30% GO gate below. This project is NOT justified by the data. Cold start is dominated by JIT execution of the translated code (gadget-chain dispatch overhead) and real application logic (GTK layout, cairo rasterization, Python bytecode interpretation), not the one-time cost of decode+gen. A perfect cache would save at most ~4–6% of wall time on these workloads.
The ISH_JIT_TIMING instrumentation itself was kept (cheap, off-by-default,
reusable for future overhead-share questions) but Phases 1–4 below are NOT
being executed. If this is ever revisited (e.g. a workload shape emerges
where translation genuinely dominates — a very large one-shot binary with
little interpreter/PLT indirection, perhaps), rerun Phase 0 for that specific
shape before resuming; do not assume the ratio generalizes from this data.
The likely better lever for cold start, per this same data, is JIT dispatch/
execution overhead — a different, unscoped project.
Every process translates its entire code footprint from scratch and throws the
result away at exec/exit. For interpreter/GUI stacks this dominates launch:
avahi-discover (Python + GTK3) takes 20–30 s to first window on the A10X iPad;
a 9-module Python stdlib import chain takes ~2.1 s even on an M-series Mac under
the -O2 CLI (measured 2026-07-23, build-o2/ish, Arch arm64 guest). The same
libraries (libc, libpython, GTK/GLib/Pango/cairo) are re-translated for every
process and every launch, byte-identical each time.
Every process translates its entire code footprint from scratch and throws the
result away at exec/exit. For interpreter/GUI stacks this dominates launch:
avahi-discover (Python + GTK3) takes 20–30 s to first window on the A10X iPad;
a 9-module Python stdlib import chain takes ~2.1 s even on an M-series Mac under
the -O2 CLI (measured 2026-07-23, build-o2/ish, Arch arm64 guest). The same
libraries (libc, libpython, GTK/GLib/Pango/cairo) are re-translated for every
process and every launch, byte-identical each time.
HLE was measured to NOT help this (symtab attach actually cost +15–30% on cold
start; fingerprint-only was neutral). That symtab overhead was a bug, fixed
2026-07-30: the "is this mapping a libc?" test ran per translated block
instead of per mapping, so every block paid a path resolution (SQLite on a
fakefs root) plus a pread. It is now memoized on struct data and symtab
attach is at parity with fingerprint-only. HLE still doesn't help cold start
— it just no longer hurts. The durable fix is to stop re-translating
unchanged file-backed code: persist translated blocks keyed by file content +
offset, reload them in any process that maps the same file.
Payoff ceiling to validate in Phase 0: the fraction of cold-start wall time spent inside block translation (decode + gen). If that fraction is under ~30% on device, this project should be re-scoped before building the heavy parts.
struct jit_block(jit/jit.h): header +unsigned long code[], a flat stream mixing (a) host function pointers to pre-compiled gadgets, (b) literal parameters (immediates, register numbers, sizes), (c) absolute guest addresses (branch targets, fault/return ips). One jit per address space (struct jithangs off the mmu); blocks are found via a hash keyed by guest ip plus a per-page list used for invalidation.- All emission funnels through a single primitive:
gen(struct gen_state *, unsigned long thing)(jit/gen.c:310). This is the load-bearing seam: a parallel tag stream can classify every emitted slot at emission time without touching the hundreds of per-instruction emitters. - Block chaining patches
jump_ipslots at runtime to other blocks' hostcode[]addresses (jit.c ~1476/1797, store-release). Chaining state is runtime-only and must NOT be serialized;old_jump_ip[]holds the unpatched values, and blocks are always insertable unchained. hle_try_emit(jit.c ~1053) can replace a block with an hle_call gadget; HLE attachment depends on runtime toggles, so cached blocks must record whether they were HLE blocks and under which flags.- Host gadget addresses change every app launch (ASLR): serialized blocks must store stable gadget IDs, not pointers.
- Guest load bias changes per process (PIE/ASLR in the guest): embedded guest addresses must be rebased by (new_bias − old_bias). Addresses decoded from the instruction stream are module-relative by construction (ip-relative branches made absolute), so a single per-mapping delta suffices. Anything not provably intra-module (none known today; verify in Phase 1) forces the block to be marked non-cacheable.
A cache bundle is keyed by:
- guest arch + engine ABI version (a build id string: git hash of jit/ + emu/, regenerated at build time — any emitter change invalidates everything),
- feature flags that alter emission (HLE on/off + hle-table version, multicore on/off if it changes emission, trace/instr flags → never cache),
- identity of the backing FILE: content hash. For fakefs, hash the host
backing file lazily and memoize in a new
ish_jitcachesqlite table (or xattr-style column onish_stat) invalidated on any write through fakefs. For realfs, hash on first use per boot, revalidate by (size, mtime) and rehash on mismatch. Anonymous or written-to (COW'd) pages are never cached.
One bundle file per (file hash, arch, engine version):
<cache dir>/<engine-ver>/<arch>/<file-hash>.jbc, containing:
- header (magic, version, counts, whole-file checksum),
- a page index: file-relative page offset → list of serialized blocks,
- per block: file-relative entry offset, end offset, flags (hle, arm64/riscv),
code length, then the code stream as (tag, value) pairs where tag ∈
{GADGET_ID, LITERAL, GUEST_ADDR_REBASE}; GUEST_ADDR_REBASE values are stored
module-relative (guest addr − mapping base at record time).
CLI cache dir:
~/.cache/ish-jitcache(overrideISH_JITCACHE_DIR). App:Library/Caches/jitcachein the container (purgeable by iOS — acceptable).
Build-time generated table jit/gadget_registry.inc: every gadget symbol that
can appear in code[] gets a stable dense ID (order = registry file order,
appended only). Runtime builds two maps at startup: id → host pointer (array)
and host pointer → id (hash, only needed when recording). Registry generation
script walks the same tables gen.c uses (gadgets.h arrays for each backend)
so it cannot drift silently; a startup assert cross-checks counts.
When ISH_JITCACHE=record (or "on" = record+use): gen() also appends a tag
byte to state->tags. Classification:
- literals: default;
- gadget pointers: emitters push them via the same
gen()— recognize by pointer-to-id hash hit at record time (no emitter changes needed); - guest addresses: cannot be distinguished from literals by value. gen.c gets
a second primitive
gen_addr(state, guest_addr_t)and the (small) set of call sites that emit guest addresses is migrated to it — grep-auditable: jump targets, ip constants for exits/faults, HLE claim ranges. Phase 1's differential harness exists precisely to catch a missed site. A background writer (one thread, low priority) drains completed blocks per (file, page) into bundles at process teardown and on a size trigger; a crash mid-write only loses cache (bundles are written to a temp name and renamed, checksummed).
On translation miss (jit.c compile loop, before decode): if the faulting ip falls in a clean file-backed executable mapping whose file has a bundle, deserialize all blocks for that page: map gadget IDs → pointers, add the mapping's bias to GUEST_ADDR_REBASE slots, insert into the jit hash unchained. Deserialization is a memcpy plus one pass of fixups — orders of magnitude cheaper than decode+gen. Blocks from the bundle that later get invalidated (page written) simply die through the existing invalidation path; the bundle is not touched (it describes the file, which didn't change).
- Never cache: pages modified since map (COW'd), anonymous mappings, guest JITs, blocks that crossed into a different mapping, blocks with any slot the recorder couldn't classify.
- Whole-bundle checksum + per-block bounds validation on load; any anomaly → drop bundle, log once, fall back to translation (never crash on bad cache).
ISH_JITCACHE=0/1/record/paranoid— paranoid translates AND loads, then memcmp()s the streams (CI mode; the core Phase 1/2 validation tool).
Add ISH_JIT_TIMING=1: accumulate wall time inside the compile path (decode+
gen+insert) per process, dump at exit. Run on device (or -O2 CLI with a CPU
handicap) for: python3 boot, the 9-import chain, bssh, avahi-discover. Deliver:
translation share of cold start + bytes/blocks per library. GO/NO-GO gate:
translation share ≥ ~30% on device-class hardware.
Gadget registry + generator + startup maps. Tag stream in gen() +
gen_addr() migration for the arm64-guest backend ONLY (the Wayland/GTK arch;
i386/amd64/riscv64 explicitly deferred, non-arm64 guests bypass the cache).
Serialize/deserialize in memory; paranoid differential over the arm64
regression suite (setup-regressions.sh binaries) — every block stream must
round-trip bit-identically modulo rebase slots. No disk, no keying yet.
Bundle format + writer thread + loader integration; file-hash plumbing for
fakefs (sqlite memo) and realfs; bias rebase (record module base via the
mapping's data->file_offset/vma start, already available at translation
time); engine-version string generation in the build. Validation: full arm64
regression suite + GTK/python workloads with cache cold→warm→hot; paranoid
sweep; kill -9 during write; corrupt-bundle fuzz (truncate/bit-flip → must
fall back cleanly). Measure: warm-cache python import chain and bssh launch
vs baseline (target ≥2x on translation-dominated launches per Phase 0 data).
Settings toggle (default OFF initially; flip after a soak), cache dir in the container + size cap w/ LRU by bundle atime (default 256 MB), "Clear JIT cache" row, invalidation on root import/update (content hash already covers it — the row is for disk space), multicore soak (the stress suite with cache hot), device measurement on the iPad: avahi-discover/bssh time-to-window.
riscv64 + amd64 backends (repeat the gen_addr audit per backend); intra-
bundle block pre-chaining hints; shipping pre-warmed bundles for the bundled
rootfs' libc/busybox.
- Unclassified guest-addr slots → wrong execution after rebase. Mitigated by the paranoid differential (bit-compare vs fresh translation both with ASLR biases varied) and by defaulting any doubt to "don't cache the block".
- gen.c churn: every future emitter change must keep the tag discipline; the engine-version key makes mistakes at worst a silent cache miss, and paranoid mode in CI catches tag drift.
- Fakefs write-detection: the content-hash memo must be invalidated on every write path (fakefs mutators are centralized enough: fd write/truncate/ mmap-writeback on the backing file). Realfs relies on size+mtime — document as best-effort, rehash on mismatch.
- Payoff risk: if Phase 0 shows translation is a minor share (plausible if interpreter arithmetic dominates), stop at Phase 0 and re-aim at the pixman provider / rendering path instead.
Roughly 4–6 weeks end-to-end for arm64-guest-only through Phase 3, heavily front-loaded with validation tooling. Phase 0 is cheap and decisive — do it first, alone.