Skip to content

perf(trace): reuse trace-code proofs under embedder publication generations - #174

Open
rlanday wants to merge 3 commits into
benletchford:masterfrom
rlanday:perf/authoritative-instruction-generation-entry-reset
Open

rlanday wants to merge 3 commits into
benletchford:masterfrom
rlanday:perf/authoritative-instruction-generation-entry-reset

Conversation

@rlanday

@rlanday rlanday commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Note

Updated September 8: #173 has merged; this PR is no longer stacked on it.
The published implementation consists of the generation change, the reviewed
nested-batch fallback, and its formatting fix, now reapplied as three linear
commits atop upstream 3d00fb8 / m68k 0.13.0. Published head 040b4973
includes the upstream memory-copy observers and host-rewrite regressions.
Its complete Git source tree is identical to the previously tested merge
head 449c73c5; only the history changed to satisfy CI's no-merge rule.

The Systemless publication integration described in sections 2.2–2.3 is
a separate, historical local experiment, not code included in this PR or
shipped by current Systemless. Its publication audit must be refreshed
before enabling nonzero generations in a current embedding.

Summary

This adds an opt-in instruction-publication generation to the throughput API.
An embedder that owns a classic-Mac-style instruction-cache publication
boundary calls run_batch_with_instruction_memory_generation(..., generation).
While the generation is stable and nonzero, a compiled trace that has proven
its guest bytes once enters its native body directly; it does not compare
captured code bytes on every entry. When the generation changes, m68k does not
discard compiled code: it withdraws each compiled trace's byte proof, and the
next entry re-proves the unchanged bytes with the existing shared validator (or
discards the trace if they changed).

The ordinary run_batch API supplies generation zero and retains exact
per-entry byte validation. The feature is off by default. Portable execution
is unchanged: it validates on every entry regardless of generation.

Historical performance evidence, not a current GUI claim. The following
measurements used the explicitly identified 3c63ca4 + #1220 experimental
Systemless stack and legacy fixed-instruction headless driver. They precede
the nested-batch review fix and today's upstream integration. The corrected
GUI-rate driver in Systemless #1596
subsequently exposed artificial retained-wait re-entry in that old driver.
Equal final ticks/pixels do not make its call frequency representative of a
windowed game. The numbers are preserved as historical experiments, not
current CPU/FPS forecasts or proof of a regression-free current build:

Workload Budget / pairs Host instructions Cycles User CPU time per run (A -> B medians)
SimCity 2000 400M / 6 -4.404% (0.9544-0.9571, 6/6) -4.267% (0.9463-0.9611, 6/6) 5.80 s -> 5.53 s (-4.639% (0.9369-0.9651, 6/6))
3 in Three 200M / 5 -1.761% (0.9824-0.9824, 5/5) -0.703% (0.9869-1.0047, 4/5) 11.79 s -> 11.70 s (-0.087% (0.9864-1.0129, 4/5))
Lemmings 1B / 6 -4.334% (0.9562-0.9568, 6/6) -0.915% (0.9877-0.9942, 6/6) 8.41 s -> 8.29 s (-1.191% (0.9798-1.0084, 5/6))
System's Twilight 100M / 6 -0.479% (0.9948-0.9956, 6/6) -0.126% (0.9358-1.0232, 3/6) 13.14 s -> 12.75 s (-1.860% (0.8449-1.0544, 4/6))
EV Override 100M / 6 -0.034%/tick median, 5/6 (0.9995-1.0003); aggregate -0.022% +0.611%/tick median, 2/6 (0.9961-1.0226); aggregate +0.730% 22.07 s -> 21.84 s (-0.544%/tick median, 3/6 (0.9607-1.0885); aggregate +1.005%)

Every deterministic pair reached the same tick count and screenshot hash
(SC2K 185,810; 3 in Three 822,511; Twilight 685,705; Lemmings 181,509).
These results do not establish that every workload is regression-free. EV
has mixed CPU results and positive cycle/aggregate CPU deltas; several other
workloads also have losing individual pairs. Fresh, fixed-simulated-time
measurements on the current integration are required before making a current
CPU claim. Methodology, provenance, noise handling and
the rejected designs are below.

How this fits with the other performance work

The font/retained-text overhaul, Systemless #1524, merged in 0.39.0. The effect sizes below are SC2K process CPU time for the stated replay, not GUI Activity Monitor percentages or a cross-workload ranking.

Change Relationship SC2K evidence and rendering era
#1596: fixed-time headless scheduling Measurement prerequisite; independent of the optimizations No claimed windowed CPU saving. Corrects artificial retained-wait re-entry in older instruction-quota benchmarks.
#1597: defer sound-slice chrome Independent runtime PR; does not require #1421/#1423 or m68k #174 About 41.0% less CPU, after #1524, measured on 0.39.0 with #1596's scheduling overlay. Largest demonstrated post-overhaul saving among these open PRs. Not a new 0.39.1 rerun.
#1423: device-colour mirror Prerequisite for #1421 Historical SC2K CPU −0.9%, before #1524, on an earlier implementation/legacy driver. Not current-mirror acceptance.
#1421: theme-artwork cache Stacked on and includes #1423 Historical CPU −20.8%, including #1423, before #1524/with the legacy driver. Do not add the two PR percentages.
m68k #174: publication generations Separate opt-in CPU-core API; needs an audited Systemless integration that is not shipped in these PRs Historical CPU −4.64% on a local integration before #1524/with the legacy driver; not a current Systemless benefit.

Draft #1598: retained-pixel hashing is stacked on #1597, not included in any of the five PRs above. It measured about31.2% additional SC2K CPU reduction on0.39.0 and31.6–40.4% on the refreshed0.39.1 stack; both are after #1524. Clock/load variation means that wider range is not evidence that0.39.1 made the patch better. EV is not cleared: its unrestricted current pair used2.369% more CPU; a severely throttled reverse pair is excluded. The PR remains draft for that check. A separate local bulk retained-fill prototype has no measured effect size yet. Sequential reductions apply to remaining work, not by adding percentages; there is no directly measured all-PR total.

What the text-rendering overhaul changed

#1524 added CPU-side retained high-resolution glyph coverage alongside guest framebuffer bytes. Glyph masks are cached: the new hot work is repeatedly erasing/repainting their pixels and updating subpixel, palette and snapshot state, not necessarily rasterizing font outlines again. GPU presentation does not remove this bookkeeping.

That changes what each optimization can save. #1421 avoids rebuilding themed background artwork, but replay still performs observed pixel writes and actual title glyphs are drawn separately. #1423 makes colour resolution cheaper, not retained-pixel bookkeeping. #1597 removes whole redundant paint passes, so it also avoids their new text cost. #1598 and the separate local bulk-fill work target that remaining per-pixel cost directly.

We have not run a matched before/after-#1524 experiment for the older PRs. Their percentage changes therefore cannot yet be quantified; the old numbers must not be ranked against the 41%/31% post-overhaul results as if they shared a baseline. More rendering CPU can also dilute a JIT optimization's percentage without making the JIT optimization itself slower. The mechanism explains why priorities changed, but is not proof of a particular slowdown or speedup caused by #1524 alone.

Theme is another comparison boundary: the 41% and 31% post-overhaul replays use classic-system7. #1421's non-classic theme-artwork cache is bypassed in that path, so it does not add its historical 20.8% saving to those runs. The old 5f21681 CLI default was systemless-default; the current CLI default is classic-system7. Changes in theme, scheduling and retained rendering must be separated before attributing a changed effect size specifically to #1524.

1. Why trace-code validation exists, and what it is not

A native trace embeds the meaning of the 68k instruction bytes observed while
recording and compiling it. Those bytes can change later: segment loading and
relocation, trap patching, generated code, ordinary self-modification. Running
the old native body afterward would execute stale semantics. So every trace
captures its contiguous guest-code segments, and trace entry compares the live
bytes with the captured bytes before running the body. A mismatch invalidates
the trace and returns the changed instruction to normal dispatch.

This is a runtime instruction-coherency proof about guest bytes. It is
unrelated to Cranelift's IR verifier, which checks the compiler's own
intermediate representation while compiling and stays enabled (see the closed
#128 discussion). Nothing here changes IR verification.

The proof is necessary for a generic embedding. It is repeated work when the
embedding already has a stronger publication boundary. Between publications,
unchanged SC2K traces validated the same bytes on every one of millions of
entries; removing that is most of the SC2K win.

2. The publication contract

A nonzero generation promises that every change which may become visible to
instruction fetch advances the value before another batch executes. A
publication must therefore end the current batch and advance the generation
before the changed code can run. Nonzero values are never reused for different
bytes within the thread's trace-cache lifetime and never wrap. Generation zero
withdraws the promise. Calling ordinary run_batch after a generation-aware
batch is a transition to zero.

This is a guest-correctness contract, not an inference from a write-free
sample. An embedding with coherent self-modifying RAM and no authoritative
publication observer must supply zero.

2.1 Why this obligation exists only with the feature on

Under exact validation (the default, and everything shipped today) every
trace entry re-compares the guest bytes, so any write of instruction bytes,
by the guest or by the host, is caught at the next entry: the trace is
discarded and re-recorded. That is churn, never a wrong result. With a
nonzero generation a proven trace skips that compare until the next
publication, so a write of instruction bytes that is not followed by a
publication can run stale native code. Nothing in this section is a
pre-existing bug; every publication point below is an obligation created by
the opt-in feature, and the feature stays off by default until the embedder's
set is complete.

2.2 Historical Systemless integration: publication points

Apple's statement of the contract (Inside Macintosh: Memory, "About Memory
Management Utilities"): "The system software automatically flushes the
instruction cache when you call certain traps that are often used to move
code from one location to another in memory. The system flushes the
instruction cache whenever you call _BlockMove, _Read, _LoadSeg, and
_UnloadSeg." "_BlockMove is not guaranteed to flush the instruction cache
for blocks that are 12 bytes or smaller." And the principle that decides the
HLE cases: "In general, you need to worry about stale instructions only when
your application moves code and not when the system software moves it."

The historical local integration published at these documented traps:

  • _LoadSeg (after loading or refreshing the CODE resource and patching its
    jump table), _UnloadSeg (publishes preceding writes even though Systemless
    retains the segment), and _Read (publishes even when it returns an error
    such as eofErr: the trap is the documented boundary and may also publish
    writes performed before it).
  • _BlockMove ($A02E) when the signed byte count is greater than 12. The
    immediate-bit form _BlockMoveData ($A22E) exists specifically to copy
    non-code without the flush and therefore does not publish; the handler tests
    the actual trap word's immediate bit.
  • _FlushCodeCache; _HWPriv selector 1 (FlushInstructionCache) and
    selector 9 (FlushCodeCacheRange). _HWPriv selector 3 (FlushDataCache)
    and selectors 4/5/6 (external-cache controls) are not instruction-publication
    boundaries and stay no-ops.
  • _HWPriv selector 0 (SwapInstructionCache): switching the emulated
    instruction cache off returns generation zero; switching it back on starts
    a fresh nonzero generation.

and, because Systemless replaces the ROM's implementation of the routines
behind those traps with host code, at every HLE path where "the system
software moves code" without passing through one of them (2.3).

2.3 The HLE audit: host writes of instruction bytes

Method: enumerate every Systemless path that writes bytes into guest RAM
(write_bytes, load, block_move, fill_bytes, and word/long writes of
opcode values), classify each by whether the bytes could later be fetched as
instructions and whether real Mac OS would have flushed at that point, and
publish where it would. Ordinary data writes (QuickDraw, Dialog Manager,
parameter blocks, framebuffer, sound buffers, PICT decoding) never publish.
Boot-time writes (init_app, ROM/system heap setup, PPC loader) precede any
trace and need nothing. The classes that do publish:

Class Paths Why real hardware flushes there Rule applied
Resource Manager loads reload_resource_data_from_file (every first load or reload of a purged handle: GetResource, Get1Resource, GetNamedResource, LoadResource, preloads), read_partial_resource, write_partial_resource The ROM Resource Manager reads from disk through _Read, a documented flush. Code resources (CDEF, WDEF, MDEF, LDEF, DRVR, INIT, hand-loaded CODE) arrive this way, into a heap block that may previously have held compiled code. Publish after the bytes land (rare events; no size rule, _Read has none).
Memory Manager copies and moves HandToHand, PtrToHand, PtrToXHand, HandAndHand, PtrAndHand; SetHandleSize when growth relocates the block (process and resource handles alike) The ROM implements these with _BlockMove, and Apple's principle is that system-moved code needs no application flush. Publish when more than 12 bytes were copied or the block relocated, mirroring _BlockMove's rule so tiny string copies stay silent. ReallocateHandle, EmptyHandle, DisposeHandle copy nothing and do not publish. _Munger is deliberately excluded: it edits text, and TextEdit calls it per keystroke.
Host-owned callback trampolines VBL, Time Manager, cursor, sound and double-buffer completion, file and ADB completion, dialog draw/filter procs, menu hook (30 operand writes in the runner; 114 writes converted in total including the one-time opcode writes and the boot-time ShieldCursor/FMSwapFont stubs) Not an OS routine at all: Systemless synthesizes a small 68k stub per callback kind and, on every fire, rewrites the MOVEA.L #imm and JSR abs.L operand words with the task record and callback address. Those are instruction bytes. New write_host_code_word/long helpers: skip unchanged bytes (a repeated callback publishes nothing), write and publish when they change.
Synthetic read-only code write_readonly_code_word Host-generated code that the guest executes. Already published on change (GPT-5.6's audit).

Considered and rejected for publication: _Munger (data, high frequency);
allocation-time zeroing (a freed block's stale trace can only be reached by
executing freed memory, and the loader that refills the block publishes);
shared_ram_region (changes storage, not bytes); the idle-probe write journal
(compares, never restores); PPC and Mixed Mode loading (PowerPC code is not
executed by the 68k trace JIT).

This was the writer audit for the historical integration tree identified in
section 5. It is not a proof that every writer in current Systemless is
covered; newer host callback, loader and memory paths must be audited before
that integration is enabled on today's upstream. Its diagnostic is the publication counter
reported at headless completion ([HEADLESS] Instruction-memory publications: N); the counter and each new class carry focused tests
(section 6). Publications per run with the complete set (SC2K had 5,923
before the HLE audit; the additional Memory Manager copies, resource loads
and callback rewrites cost about 0.16 points of the SC2K instruction win and
none of its cycle win):

Workload Budget Publications per run (candidate)
SimCity 2000 400M 10,438
3 in Three 200M 1,907
Lemmings 1B 1,811
System's Twilight 100M 1,930
EV Override 100M 6,387

2.4 Why the first measured number is superseded

An earlier prototype (m68k v19 with the first Systemless integration) omitted
_Read, _UnloadSeg, and the _BlockMove >12 rule and measured -4.24%
SC2K host instructions. That number must not be cited. Once the publication
set was completed, the same wholesale-invalidation mechanism became a ~2%
regression on SC2K (section 4, row C2), which is what forced the design
in section 3.

Why wholesale invalidation failed once publication was complete

The first mechanism emptied the whole trace cache and every admission table on
each transition. That is correct, and it is cheap when publication is rare.
SC2K, however, makes 5,923 ordinary _BlockMove calls in the 400M-instruction
replay, each larger than 12 bytes and therefore a documented cache flush. Each
one discarded every hot compiled trace, so the run became a recording and
recompilation storm. Measured against the exact-validation baseline, the
cache-complete wholesale build retired about 2% more host instructions on
SC2K (6.4-6.6% more than the incomplete-publication build in all five pairs;
log game-cpu-current-upstream-cache-publication-isolation-sc2k-400m-counters).

So the retained design must keep compiled code across publication and only
re-prove it.

3. How this implementation works

3.1 State

Three pieces of state carry the mechanism:

  • CpuCore::instruction_memory_generation (u32): the generation requested
    for the current batch, set at batch entry by
    run_batch_with_instruction_memory_generation and cleared to zero after
    it. Ordinary run_batch sets it to zero.
  • TraceJit::instruction_memory_generation (u32): the single generation the
    reachable trace cache currently represents.
  • CompiledTrace::checked_func: Cell<Option<NativeTraceFn>>: the trace's
    first-call entry. This field already existed as a plain Option; wrapping
    that existing per-trace field in a Cell lets the entry path update it
    through the shared &CompiledTrace without another per-trace generation
    field. Separate CPU/JIT state changes are described here and in section 3.5.

Plus two structures on TraceJit that are touched only when a link is
installed or restored and when a publication drains them, never on the
proven entry path:

  • linked_slot_indices: Vec<u16>: cache indices (of 16,384) whose compiled
    trace currently holds a first-call link, i.e. every slot compiled with a
    link or re-proven since the last publication.
  • linked_slot_members: Vec<u64> (256 words, 2 KiB): membership bitset that
    keeps the list duplicate-free.

3.2 The first-call entry is the byte proof

Every native CompiledTrace has two entry points. func is the unchecked
native body, used for repeated calls inside one already-validated Rust entry
(a self-loop trace can be re-entered many times per try_execute).
checked_func is the first-call entry and has always taken one of two
values: a generated wrapper that validates the trace's fixed bytes and then
calls the body (eligible x86-64 shapes: at most three segments of at most 47
bytes, or the profiled single 50-byte segment), or None, which selects the
shared Rust validator (validate_trace_code, SIMD-accelerated) before the
body is called.

Under a nonzero authoritative generation, checked_func takes a third
meaning: it is the trace's proof state.

checked_func value Meaning under a nonzero generation Entry behavior
Some(func) (aliases the body) proven in the current generation body is called directly; no validation
None proof withdrawn by a publication, or never proven shared validator runs; on success the link is restored to Some(func); on mismatch the trace is invalidated
Some(wrapper) cannot occur (see 3.4) n/a

The lifecycle of one compiled trace under nonzero generations:

  1. Compile. compile_ops receives authoritative_generation = true and
    generates no wrapper; checked_func is initialised to Some(func). The
    trace is born proven: nothing can have changed its bytes between the
    recording that just executed them and this install, because a publication
    would have ended the batch and cancelled the recording. This is exactly
    v19's direct link.
  2. Install. finish_recording_with_retry stores the trace in its
    direct-mapped slot and, if the trace was born with a link (a direct link
    under a nonzero generation, or a generated wrapper), records the slot in
    the linked list: if its membership bit is clear, set it and push the
    index. O(1), once per compile, never per entry.
  3. Entry (proven). try_execute loads checked_func once. It is Some,
    so generated_validation is true, the Rust validator is skipped, and the
    first native call goes to checked_func.unwrap_or(body), which is the body
    itself. This is byte-for-byte the v19 hot path: one 16-byte load from a
    trace object that was already being read, no generation compare, no
    side-table lookup, no extra branch.
  4. Publication. The embedder advances the generation and starts the next
    batch. The first JIT access in that batch
    (synchronize_instruction_memory_generation, called once from
    with_trace_jit_batch) sees the two generations differ and calls the cold
    reset_linked_entry_proofs. That drains linked_slot_indices: each
    listed slot that still holds a compiled trace gets
    checked_func.set(None); a listed slot that was evicted, rejected, or
    recompiled meanwhile needs nothing (a recompiled occupant is listed in
    its own right); the membership bitset is cleared. The in-progress
    recording and pending exit seed are cancelled and the CPU's probe/record
    skip filters are reset. No compiled code is freed (Cranelift function
    names must stay unique) and nothing is recompiled.
  5. Entry (unproven). checked_func is None, so the shared validator
    compares the captured bytes with live memory. If they match and
    cpu.instruction_memory_generation != 0, the entry sets
    checked_func = Some(trace.func), records the slot in the linked list
    (a bit test and, at most, one push; this is the rare re-proof path, not
    the proven path), and proceeds to the body. Every later entry until the
    next publication is step 3 again. If they do not match,
    invalidate_changed_trace empties the slot and hands the changed
    instruction to the interpreter, exactly as before this PR.
  6. Eviction / recompilation. A slot evicted and recompiled before the
    next publication is listed once (its bit is already set) and the next
    publication resets whichever trace occupies it. A trace reset by a
    publication and never entered again stays unlinked and unlisted.

The cost model is therefore: per entry, unchanged from v19; per compile, one
bit test; per publication, one pass over the slots linked since the previous
publication (the working set between two publications, typically tens to a
few hundred traces) plus one shared validation per such trace on its next
entry. SC2K's roughly 10,000 publications per 400M-instruction replay turn
into a few hundred resets and validations each, instead of discarding and
recompiling every hot trace (row C2), paying a bitset load on every one of
tens of millions of entries (row C3), or walking every slot that ever
compiled (the first entry-reset build, row C4a).

3.3 Generation zero and the transition back

Under generation zero the Valid arm never restores the link, so a trace
whose checked_func is None validates on every entry. A transition from
nonzero to zero is a transition like any other: step 4 resets every compiled
slot's link to None, so an authoritative direct link cannot leak into the
untracked interval. This is covered by the direct-link unit test and by the
batch test's last phase, which rewrites code and then calls ordinary
run_batch.

A trace compiled under generation zero with a generated wrapper that later
lives through a zero-to-nonzero transition is also reset to None and then
re-linked directly to its body after one validation; its wrapper is simply
never used again under nonzero generations. Going back to zero after that
leaves it on the shared validator instead of its wrapper. That is correct and
only marginally slower, for a mode Systemless enters only when the guest
disables its instruction cache.

3.4 Invariants (the safety argument)

  • A native body runs under a nonzero generation only if its bytes were
    proven in that generation: either it was compiled in it (step 1) or the
    shared validator accepted them after the most recent publication (step 5).
    There is no third path to Some(func).
  • No Some(wrapper) exists under a nonzero generation: authoritative compiles
    never generate one, and every zero-to-nonzero transition resets all
    compiled slots.
  • A recording never spans a publication: reset_compiled_entry_proofs
    cancels it and the CPU-side recording flag, and try_execute refuses to
    run while a recording is active.
  • The trace-return validation path (a generated wrapper reporting that a
    segment lies outside the fastmem window) is unchanged and unreachable from
    a direct link, because a direct link is the body and the body never
    reports that sentinel.
  • Portable execution ignores checked_func entirely and validates on every
    entry; the list bookkeeping is harmless there.
  • The admission side tables (earned_call_permission, structurally_rejected,
    compiled_before, no_terminal_strikes, deferred_linear) are retained
    across publication. They only decide whether and when current bytes get
    compiled; a stale verdict can at worst delay an optimization after code is
    replaced, never execute stale code. Clearing five 16K-entry arrays on each
    _BlockMove (as wholesale invalidation did) was measurable work for no
    correctness benefit. If a reviewer wants perfect admission freshness, the
    right follow-up is sparse tracking of active admission slots, not a scan.

3.5 Why these particular choices

  • Cell rather than a mutable borrow. try_execute holds
    &self.slots[idx] as &CompiledTrace while it may later need &mut self
    for invalidation; NativeTraceFn is Copy and Cell was already imported
    and used on neighbouring adaptive counters, so the proof update needs no
    borrow restructuring and no unsafe.
  • Reuse checked_func rather than add a field. Rows B6 and B9 showed
    that a new per-trace field or table on the entry path costs cycles on
    Lemmings and 3 in Three even when SC2K wins. The proof lives in a field the
    entry path already loads.
  • Drained linked list rather than a slot scan or a compiled-forever list.
    TraceSlot is large (a CompiledTrace carries three Vecs and about
    fifteen scalars), so scanning 16,384 slots on every publication would
    touch megabytes ten thousand times per SC2K run. A list of every slot that
    ever compiled (the first build of this design) was correct but reset every
    compiled trace on every publication, including the cold majority that no
    entry had re-proven; listing only slots whose link is live drains exactly
    the traces that need resetting and needs no compaction pass. The list is
    bounded by 16,384 u16s (32 KiB).
  • Batch-scoped JIT borrow, with the reviewed nested-batch fallback. Access to the thread-local
    trace JIT is one RefCell borrow per batch, with the borrowed address kept
    in a transient tail field of CpuCore and cleared by a panic-safe guard;
    the direct SC2K ablation credited 0.442% of the instruction win to it, and
    its placement after the hot CPU fields was itself measured (rows B18-B19).
    This lifetime change also affects ordinary run_batch, independently of
    the opt-in generation feature. Ben's review found that a bus callback
    running a second core could re-borrow the thread-local JIT and panic.
    The corrected implementation uses try_borrow_mut: an enclosing borrow
    makes the nested batch interpreter-only, leaves its batch pointer null,
    and disables its JIT execution/recording hooks. It never hands the nested
    run the outer mutable JIT pointer. The outer batch resumes with its
    exclusive borrow intact; a guard clears its pointer before that borrow
    ends, including unwinding. The regression test reproduces the original
    two-core bus callback. This fallback was added after the performance
    matrix below and has not been separately qualified by those numbers.

3.6 Why not something simpler

Every simpler candidate was built and measured (section 4). In order of
simplicity: (a) validating once per batch instead of once per entry is not
simpler in correctness terms, because an interpreted guest store to a
trace's code inside the same batch is unguarded, so it silently adopts the
same publication contract with a batch-sized window and no embedder
promise; (b) wholesale invalidation at each publication (C2) is the simplest
correct mechanism and loses about 6.5 points on SC2K because _BlockMove
publishes ten thousand times per run; (c) a generation number stored in each
trace and compared at entry (B6) is the obvious representation and costs
cycles on Lemmings at equal work; (d) a per-slot proof bitset (C3) removes
the same work as this PR and costs 1.9% cycles on 3 in Three. What remains is
this design: the proof lives in a field the entry path already loads, and
the only new state is a list that the entry path never reads. The audit in
section 2.3 cannot be made simpler either; each of its classes is a place
where real hardware flushes and the emulator's host code does not.

4. Rejected approaches and what they taught us

All experiments below reached the same simulated ticks and framebuffer hash
as their comparison arm unless stated. Branches, binaries and raw logs are
retained locally (.work/m68k-shape-aware-trace-admission branches,
.work/systemless-pr1220-postfix-profile/.profile-builds/,
.work/steady/logs/).

4.1 Family A: software page versions (m68k experiment/page-versioned-trace-validation)

Idea: each trace remembers a version per 4 KiB page of its code; every guest
store bumps the version of the pages it touches; matching versions skip byte
validation. Correct only if all three write families are observed (native JIT
stores, decoded/interpreter fastmem stores, host/HLE bus writes).

Variant SC2K result vs accepted baseline (2b04412) Lesson
A1 out-of-line matcher, eager page versions (941d2bd) 50M: +7.35% host instructions The universal write barrier, not the matcher, was the cost.
A2 generated matcher, eager versions (d4bef09) barrier alone +7.82%; enabling reuse saved only 0.283% A version compare beats a wide byte compare, but by far less than the barrier costs.
A3 lazy dirty-page journal (a6936e5) barrier +6.35%; reuse +0.046% (neutral) Deferring the increment still pays the page calculation and side-table store per write.
A4 watched bounding range folded into native store guards (0a68486) +3.10% (50M) / +3.20% (400M) with versions off; +34.27% on Call-through traces spanned $20E000-$866000, so the interval treated ~6.3 MiB of writable RAM as possible code and forced interpreter retries.
A5 sparse two-hash (Bloom) page filter (c3e6c61) +2.38% off; +10.21% on Fixed the range blow-up; hashing every native store still costs more than rechecking bytes.

Lesson: charging every ordinary guest store to save a smaller amount of trace
validation reverses the economics. Even the inactive Systemless write hook
cost about 2.4% after outlining its slow path. Page versions are only viable
if write observation is nearly free, which points at embedder publication
boundaries (this PR) or OS page protection (4.4).

4.2 Family B: representation of an explicit generation (m68k v6-v23)

All on the historical explicit-generation prototype with the first
(incomplete) Systemless publication set. These compare those specific builds
under the old driver; they do not prove guest correctness for the incomplete
contract or current GUI performance.

Variant Representative result Disposition and lesson
B6 per-trace generation field + compare on every entry SC2K -2.56% instr / -2.83% cycles; Lemmings +0.72% cycles / +1.20% CPU Rejected. A field and branch on every entry taxes workloads whose traces already validate cheaply.
B7 trust only multi-segment or >=16-byte traces SC2K -2.25%; Lemmings +1.37% cycles Rejected. Selective reuse shrank the win without removing the hot representation cost.
B8 generated generation-aware wrapper SC2K only -0.63%; Lemmings +3.80% instr / ~+6.9% CPU Rejected. Encoding policy in another generated entry increased compile work and code footprint and hurt the frequent-link workload.
B9 separate 16K stable-entry table SC2K -0.45%; Lemmings +4.13% instr / ~+5.5% CPU despite 5.47M linked entries Rejected. Links were active; the second hot lookup structure was itself the cost. Stable handles are not fast if reaching them adds a table.
B11 wholesale invalidation on transition SC2K -2.43% / -2.35%; Lemmings -1.83% / -2.47% Pivot. Paying once per rare publication beat remembering proof state per trace, as long as publication really is rare (it is not once _BlockMove publishes; see C2).
B12 monomorphized trusted/untrusted whole runners +0.44% instr / +0.54% cycles vs B11 (SC2K) Rejected. Duplicating the large runner raised code footprint / I-cache pressure; compile-time branch removal is not free.
B13 omit validators for authoritative traces SC2K -3.72% / -4.71%; Twilight ~+0.48% instr / +1.5-1.75% cycles Strong but incomplete: validator removal worked; a fixed per-batch synchronization overhead hurt low-JIT-coverage workloads.
B14 alias checked_func directly to the body neutral vs B13 on Twilight; still ~+0.5% instr vs baseline Retained mechanism (it is this PR's direct link); proved the per-entry link was not the Twilight cause.
B15 generation state inside TraceJit, synchronize at every JIT access Twilight neutral; SC2K -3.75%; 3 in Three cycles +0.9% Rejected. Removed a second macOS TLS lookup per batch but moved the compare into every probe.
B16 one sync helper per batch + old accessors disassembly still showed two TRACE_JIT TLV resolver calls Rejected before measuring: reusing a TLS key at source level does not make LLVM common the resolver across borrow scopes.
B17 long-lived &mut TraceJit through the interpreter SC2K -4.53% / -5.52%; 3 in Three cycles +1.56% Rejected. Fewer instructions, more cycles: the live reference changed register allocation and dependency chains in the interpreter loop.
B18 scoped borrow stored as a transient CpuCore pointer SC2K -4.36% / -4.48%; 3 in Three -2.40% / -2.43% Retained. Only trace paths load the pointer; a panic-safe guard clears it before the borrow ends.
B19 move that pointer after the hot CPU fields (2ab5005) EV -0.6..-1.3% cycles vs B18 Retained (the base of this PR). Inserting runtime-only state amid hot fields changes addressing and cache layout.
B20 emit the old validator but never call it SC2K +1.13% instr Rejected diagnostic. Uncalled generated code still costs Cranelift build/optimize/encode/allocate and code-cache footprint.
B21 no-validation trampoline for every eligible shape EV -0.97% cycles; SC2K +0.35% instr / +0.90% cycles; 3 in Three ~+1.3% cycles Rejected. A layout win for EV charged one-call-per-entry workloads.
B22 trampoline only for self-loops EV -0.10% cycles / +0.006% instr Rejected. EV's amortization correlates with self-loops, but self-loop-only layout did not keep the benefit.
B23 align every generated function to 64 bytes EV +3.20% cycles, instructions unchanged Rejected. Larger footprint and worse cache-set placement; alignment is not a substitute for measured layout.

Lessons from B: (1) anything added to the per-entry path costs cycles even
when it saves instructions, on some application; (2) retired instructions and
cycles must both be measured, on more than one application; (3) removing a
branch or abstraction in source is not automatically cheaper if it expands
native code or changes hot layout.

4.3 Family C: the cache-complete publication set

Historically measured against the exact-validation baseline on the then-current stack
(Systemless 3c63ca4 + #1220), 5 alternating pairs each unless stated.

Variant SC2K 400M 3 in Three 200M Disposition and lesson
C1 v19 direct link, incomplete publication set (...-generation-v19) -4.24% instr / -4.79% cycles / -4.84% CPU -2.16% instr, cycles noisy Superseded: missed _Read, _UnloadSeg, _BlockMove >12. Not a valid feature result.
C2 v19 wholesale invalidation, complete set (...-generation-v19-cache-complete) ~+2% instr vs baseline (+6.4-6.6% vs C1 in all 5 pairs) not run SC2K's 5,923 _BlockMove publications each discarded all hot code: recording/recompilation storm. Wholesale is only cheap when publication is rare.
C3 per-slot proof bitset (experiment/cache-complete-generation-bitset 990b9b2, ...-generation-bitset-cache-complete) -3.80% instr / -4.02% cycles / -4.64% CPU (5/5) -1.72% instr but +1.89% cycles (4/5 pairs worse) / +2.39% CPU Removes the work but adds a bitset load and dependency on every entry; the remaining instructions got costlier on 3 in Three. Fewer instructions, more cycles, again.
C4a entry reset, list of every slot ever compiled (640293a, ...-generation-entry-reset-cache-complete) -4.56% instr / -5.05% cycles / -5.07% CPU (6 pairs) -1.80% instr / -4.36% cycles / -6.34% CPU Same work removal as C3 with the v19 entry path restored; the 3 in Three regression disappears and SC2K improves further. Retained mechanism; the publication walk was still O(all compiled).
C4b C4a + the section 2.3 HLE audit, with a publication counter inserted mid-MacMemoryBus (...-hle-audit) -4.40% / -6.06% / -6.71% -1.76% / -0.30% / -2.01% Rejected. Lemmings cycles +2.36% (0/6) and EV +4.5%/tick (0/6) at unchanged instruction counts; directly against C4a, Lemmings +1.0% cycles. An eight-byte field inserted ahead of the bus's write-probe fields shifted every hot bus access.
C4c C4b with the counter at the struct tail and the m68k list drained to the slots re-proven since the last publication (50055f1, ...-hle-audit-v2) -4.43% / -4.12% / -4.53% -1.73% / +2.6% (1/5) / +3.8% Rejected. Lemmings fixed (vs C4a: cycles -2.31%, 5/6) but EV still +2.72% cycles (0/6) directly against C4a: the bus struct was still eight bytes larger than C4a's, moving everything the runner keeps after it.
C4d C4c with the counter in a thread-local cell (bus struct byte-identical to C4a) and the proof-restore path out of line (this PR, b43c984, ...-hle-audit-v3) see section 5 see section 5 Directly against C4a: EV cycles -1.01% (5/6), Twilight -1.09% (6/6), 3 in Three -0.39% (4/5), Lemmings -2.31% (5/6, measured at C4c). Field placement and struct size are part of the design on this codebase, not incidental.

4.4 Considered and not built: operating-system write watch (and why it is not recommended as future work)

What it would buy. The contract in section 2 is only as authoritative as
the embedder's list of publication points. An OS write watch would make a
nonzero generation authoritative for arbitrary direct writes to executable
RAM without a software barrier on ordinary stores: protect the host pages that
contain compiled trace bytes, take a fault on the first write to one, publish
a live zero token mid-batch so trace entry falls back to byte validation,
journal the page, and at the next batch boundary validate or discard the
affected traces, re-protect, and publish a fresh generation. It is a candidate for observing writes that bypass software write hooks;
complete software write observation could also cover unflushed changes, as
in family A, but was costly in those historical experiments.

What it costs. Measured on this Intel macOS host (4 KiB pages): one
mprotect read-only/read-write pair 1.3-1.8 us; rearm + one write fault +
disarm 9.2-12.8 us; one mach_vm_page_query 1.85-2.48 us; one-page
mincore 1.60 us; neither query API has a documented reset, so faults are
the only reusable signal. Structural requirements: a page-aligned owned-RAM
variant (Systemless RAM is a Vec, and protecting its edge pages could
protect unrelated allocator objects); a Mach exception server via
task_set_exception_ports (a SIGSEGV handler cannot legally call
mprotect), which must preserve and forward pre-existing debugger/crash
reporter ports; a live mid-batch epoch visible at every trace entry (today's
API is a batch snapshot); handling of cross-page stores that fault twice;
synchronization with non-CPU host threads that write guest RAM (audio and
PPC double-buffer paths); and an adaptive policy that retires pages from
protection when they fault repeatedly. That last point is the killer: guest
CODE handles are 4-byte aligned and routinely share a 4 KiB page (16 KiB on
Apple Silicon) with writable heap data, so code/data false sharing is the
norm, and one hot data page sharing a code page would cost ~10 us per batch.
This is a separate project, several times the size of this PR, with
platform-specific variants (Windows GetWriteWatch, Linux userfaultfd).

Compatibility boundary, not a universal safety proof. The protocol motivating the historical integration is the
documented 68040 one. Code that omits a required flush cannot rely on immediate instruction
coherence with caches enabled; historical compatibility workarounds included
switching the caches off (the Cache Switch control panel,
_HWPriv SwapInstructionCache), which this integration maps to generation
zero and exact validation. The residual risk is narrower than it looks: on
real hardware the 4 KiB instruction cache turns over quickly, so an omitted
flush often "works" there, whereas a trace cache retains a proof
indefinitely; but that only matters for code that is hot enough to compile
and is then modified in place without any of the documented flush points.
The trace-profile shadow audit (M68K_INSTRUCTION_GENERATION_AUDIT) found no
such case across the five fixtures.

The cheaper correctness work that does pay. One important gap is a Systemless HLE path substituting for an OS
routine that would have flushed on real hardware, or the host itself writing
instruction bytes. Section 2.3 records the historical integration audit: Resource Manager loads,
Memory Manager block copies and moves, and the runner's per-fire callback
trampoline operands now publish, each with a focused test, and the
publication counter makes the rate visible per workload.

Recommendation. Do not schedule OS write watch. Keep exact byte
validation as the portable fallback, keep the shadow audit available as a
CI/diagnostic check, and complete the HLE publication audit instead. Revisit
only if an application is found that runs correctly on real 68040 hardware
while modifying compiled code without any documented publication.

5. Performance methodology and results

Instrument. .work/steady/ab-game-cpu.sh: headless, deterministic input
script per workload, fresh fixture copy per run, periodic screenshots off, A/B
order alternated every pair. Per run it records macOS hardware
instructions retired and cycles elapsed and user CPU seconds from
/usr/bin/time -l, the final simulated tick count, and the SHA-256 of the
final screenshot. B/A ratios are formed within each pair; the tables report
the median ratio over pairs, the min-max ratio spread, the number of pairs
the candidate won, and the per-arm median absolute values. Summarizer:
.work/steady/paired-counters.py.

Arms.
A = .profile-builds/current-3c63ca4-pr1220-generation-baseline: Systemless
upstream 3c63ca4 + #1220 (c90a01e), m68k 8aa8956, exact validation.
B = .profile-builds/current-3c63ca4-pr1220-generation-entry-reset-hle-audit-v3:
the same Systemless plus the complete publication set (the trap audit and the
HLE audit of section 2.3), m68k b43c984 (an earlier implementation revision,
not the current PR head with the nested-batch fix and upstream refresh), cargo build --release --features instruction-generation (release
profile: fat LTO, one codegen unit). SHA-256 1bf0d6cee730f4dc9b098295ffbdc79622bcd3a60d13d71fcf7c12cf9d7941d5.

Equivalence. Deterministic workloads must reach the same tick count and
screenshot hash in every pair (they did: SC2K 185,810; 3 in Three 822,511;
Twilight 685,705; Lemmings 181,509). EV Override is clock-seeded, so its
runs land on one of a few output states; all twelve EV runs reached 550,405
ticks and each of the three observed hashes was produced by both arms, so
there is no candidate-only state. EV is compared per tick.

Noise handling. This matrix was run on a calm host (SC2K 5.4-6.8 s wall
per run; 1-minute load average under 10). An earlier matrix on the pre-audit
binary agreed on every workload to within a few tenths of a point on retired
instructions; its two runs that overlapped a host load spike (load average
32.9 on 16 cores) showed cycle ratios of 0.84-1.20 with unchanged
instruction ratios, and were rerun. The logs of both matrices are retained.

Workload Budget / pairs Host instructions (A -> B medians; ratio spread; wins) Cycles User CPU time Equivalence
SimCity 2000 400M / 6 53.09B -> 50.74B: -4.404% (0.9544-0.9571, 6/6) 22.36B -> 21.33B: -4.267% (0.9463-0.9611, 6/6) 5.80 s -> 5.53 s: -4.639% (0.9369-0.9651, 6/6) 185,810 ticks, same hash, 6/6
3 in Three 200M / 5 105.29B -> 103.43B: -1.761% (0.9824-0.9824, 5/5) 44.57B -> 44.24B: -0.703% (0.9869-1.0047, 4/5) 11.79 s -> 11.70 s: -0.087% (0.9864-1.0129, 4/5) 822,511 ticks, same hash, 5/5
Lemmings 1B / 6 93.05B -> 89.02B: -4.334% (0.9562-0.9568, 6/6) 31.50B -> 31.24B: -0.915% (0.9877-0.9942, 6/6) 8.41 s -> 8.29 s: -1.191% (0.9798-1.0084, 5/6) 181,509 ticks, same hash, 6/6
System's Twilight 100M / 6 121.06B -> 120.47B: -0.479% (0.9948-0.9956, 6/6) 48.03B -> 47.96B: -0.126% (0.9358-1.0232, 3/6) 13.14 s -> 12.75 s: -1.860% (0.8449-1.0544, 4/6) 685,705 ticks, same hash, 6/6
EV Override 100M / 6 227.50B -> 227.44B: -0.034%/tick median, 5/6 (0.9995-1.0003); aggregate -0.022% 86.59B -> 86.68B: +0.611%/tick median, 2/6 (0.9961-1.0226); aggregate +0.730% 22.07 s -> 21.84 s: -0.544%/tick median, 3/6 (0.9607-1.0885); aggregate +1.005% 550,405 ticks in all runs; every observed hash produced by both arms (clock-seeded)

Logs: .work/steady/logs/game-cpu-current-upstream-cache-complete-entry-reset-vs-baseline-{sc2k-400m-rerun,three-200m,lemmings-1b-rerun,twilight-100m-rerun,ev-100m-rerun}/
(plus the first runs ...-sc2k-400m, ...-lemmings-1b, ...-twilight-100m, ...-ev-100m, which overlapped host contention).

Reading the historical table: SC2K improves on every metric in all six
pairs. 3 in Three wins instructions in all five pairs, but cycles and CPU
in four of five, not every pair. Lemmings wins CPU in five of six; Twilight
wins cycles in three of six and CPU in four of six. EV is mixed, including
positive aggregate cycle/CPU deltas. None establishes a current GUI result. The contended first Twilight and Lemmings runs are retained beside
their calm reruns; in every case the retired-instruction result was stable
across both and only the cycle/CPU-time medians moved.

6. Verification

Current-head status: 040b4973 contains the generation API (18a5065),
nested-batch fallback (87a570c) and formatting fix (040b4973) atop upstream
3d00fb8 / 0.13.0, with no merge commits in the PR range. This is a
source-identical history replacement for 449c73c5: both resolve to Git tree
429862db1692429244634b4970cc9b6389e09b76, and their full-tree diff is empty.
The old head is preserved locally. The approved push used an explicit
--force-with-lease requiring the remote still to point to that exact old head.

Local validation completed on the identical 449c73c5 source tree (these
commands were not rerun solely for the history change):
cargo fmt --all -- --check; default-feature tests 894 passed, 0 failed,
9 ignored
; all-features tests 1,016 passed, 0 failed, 9 ignored; and
cargo clippy --all-targets --all-features --locked --offline -j1 -- -D warnings.
Both test runs include doctests and the new upstream copy/host-rewrite coverage.
The initial local attempt stopped because the pinned fixtures were uninitialized;
the completed runs followed initialization of the exact pinned submodules.

Previous-head CI
passed formatting, clippy, documentation, both test configurations and crate
packaging, then failed only the commit-message check because it disallows merge
commits. Correction to the earlier description: that check ran after, not
before, the test suites. The linear-history replacement addresses that failure
without changing code. Current-head CI
is green on 040b4973: formatting, strict clippy, documentation, default-
and all-feature tests, crate packaging, and the commit-message check all passed.
The PR-title check passed too; release/publishing jobs are intentionally skipped
for a pull request.

The review-response suites reported 888 default-feature tests and 889 with
instruction-generation after the nested-batch fix; those are historical
counts from that response, not new runs. The new upstream host-rewrite and
memory-copy observer regressions passed in the combined-tree local suites
and previous-head CI above.

Historical prototype validation (preserved provenance)

m68k 640293a:

  • cargo test --lib --features 'jit instruction-generation': 292 passed
    (291 before + 1 new)
  • cargo test --lib --features jit: 289 passed
  • cargo test --lib --features instruction-generation (portable executor):
    209 passed
  • cargo test --features 'jit instruction-generation' --test run_batch_tests:
    46 passed, including the ported end-to-end rewrite/zero-withdrawal test
  • cargo check --no-default-features and cargo check --features instruction-generation: pass, with the two pre-existing portable-only
    dead-code warnings
  • cargo clippy --features 'jit instruction-generation' --all-targets: clean
  • rustfmt --check: only the three hunks that already differ at 2ab5005;
    no formatting drift introduced

Systemless integration tree (3c63ca4 + #1220 + the publication diff
including the 2.3 audit) with m68k 640293a:

  • cargo test --lib --features instruction-generation: 4,911 passed,
    0 failed, 3 ignored (4,905 before the 2.3 audit; 6 tests added)
  • cargo test --lib (default features, helpers compiled without the flag):
    4,902 passed, 0 failed, 3 ignored
  • cargo check --no-default-features: passes (the same two pre-existing
    m68k portable-only warnings)
  • git diff --check: clean
  • cargo fmt --check is not run: upstream carries unrelated formatting drift
    and the repository must not be mass-formatted in this PR

Focused m68k tests added or rewritten:

  • authoritative_trace_uses_existing_first_entry_as_a_direct_link: born
    proven; a publication keeps the slot compiled and resets the link to
    None; one execution under the new nonzero generation runs the shared
    validator and restores the direct link; a transition to zero resets it and
    a successful validation under zero does not restore it; rewriting one
    instruction and publishing makes the next entry invalidate the trace and
    restart at the head with no register side effects.
  • linked_slot_list_is_deduplicated_and_drained_by_publication: no
    duplicate entries; a publication drains the list and withdraws the listed
    proof; a slot that lost its trace before the publication needs nothing;
    evict + recompile between publications lists the slot once and the new
    occupant is what gets reset. The direct-link test additionally asserts that
    a restored link is listed again for the next publication.
  • lazy_generation_sync_resets_only_on_transitions: a stable generation
    leaves cache and CPU filter state untouched; transitions (including to
    zero) cancel recording state and retain slots that hold no compiled code.
  • instruction_generation_transition_observes_rewritten_code_and_zero_withdraws_trust
    (batch test): a rewrite under a new generation is observed before the
    changed instruction can run; an unchanged publication re-proves the
    recompiled trace without recompiling; ordinary run_batch restores exact
    validation and observes a further rewrite.
  • Existing generated-wrapper tests updated to read through the Cell.

Systemless focused tests (in the publication diff): _BlockMove publishes
only the flushing form over twelve bytes; _Read publishes on success and on
eofErr; _UnloadSeg publishes; tokens differ across bus instances;
generation is zero while the instruction cache is disabled and flushes cannot
restore it until it is re-enabled. From the 2.3 audit: host code writes
publish only when the bytes change; HandToHand publishes only copies over
twelve bytes; SetHandleSize publishes only when the block moves; reloading
resource bytes publishes; ReadPartialResource publishes; a VBL trampoline
rewrite publishes only when the callback address changes.

@benletchford benletchford left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The batch-wide TLS borrow introduces a regression in ordinary run_batch, even with all optional features disabled. A host bus can run another CpuCore from a device/read callback; the new outer with_trace_jit_batch holds TRACE_JIT's RefCell borrow while calling that bus, and the inner batch panics with RefCell already borrowed.

I reproduced this with an AddressBus wrapper (no FastMem) whose first read_word runs one MOVEQ instruction on a separate CpuCore and LinearMemoryBus, then delegates the outer read. The outer batch also runs one MOVEQ. The identical integration test passes at #173 (8aa8956) and panics at this PR and #175. No instruction-generation API is involved.

Please preserve this existing embedding behavior when changing the cache lifetime, and add a regression test for a nested CPU batch from a bus callback. Merely bypassing RefCell's check with a raw pointer would alias the active mutable JIT reference; the nested execution needs a safe ownership/fallback design. The generation optimization is useful, but this default-path regression blocks merging it as written.

@rlanday

rlanday commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, confirmed and fixed. Your reproduction is now the regression test (nested_batch_from_a_bus_callback_runs_without_the_outer_batch_jit in tests/run_batch_tests.rs: a bus whose first word read runs one MOVEQ on a second core with its own LinearMemoryBus, then delegates; the outer batch runs one MOVEQ). It panics with RefCell already borrowed at the previous head and passes now.

The fix keeps the batch-wide lifetime but makes the borrow fallible: with_trace_jit_batch takes the thread-local JIT with try_borrow_mut. A batch that finds it held by an enclosing batch on the same thread runs interpreter-only: its batch pointer stays null, every JIT hook (try_execute, recording, probes, rejection checks) returns as if there were no JIT, and it starts no recording. No raw pointer is handed out for the nested run, so the outer batch's mutable JIT is never aliased, and it resumes untouched when the callback returns. The out-of-batch with_trace_jit borrow is now test-only.

Rebased onto current master (f62d228, so this PR no longer carries the merged stack). Suites: 888 tests with default features, 889 with instruction-generation, both green; clippy reports only the pre-existing dead-code notes.

@rlanday
rlanday force-pushed the perf/authoritative-instruction-generation-entry-reset branch from 958c352 to ff190fb Compare September 5, 2026 08:04
rlanday and others added 3 commits September 8, 2026 15:37
…ations

Add an opt-in `instruction-generation` feature and
`run_batch_with_instruction_memory_generation`. A nonzero generation
promises that every change visible to instruction fetch advances the
value before the next batch. Under a stable nonzero generation a compiled
trace that has proven its guest bytes once links its existing first-call
entry (`checked_func`) straight to the native body and skips per-entry
byte validation. A transition keeps compiled code and only withdraws each
trace's proof: the next entry runs the shared validator and restores the
link if the bytes are unchanged, or discards the trace if they changed.
Generation zero (and the ordinary `run_batch` API) keeps exact per-entry
validation. Publication drains a compact list of the slots whose link is
live, never the whole cache, and touches nothing on the proven entry path.

Access to the thread-local trace JIT becomes one batch-scoped borrow held
through a transient, panic-safe `CpuCore` tail pointer that only trace
paths load.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g it

A host bus may run another core's batch from a bus callback on the same
thread. Holding the thread-local JIT borrowed for the whole outer batch
made that inner run_batch panic with 'RefCell already borrowed', a
regression of the default path with every optional feature off. Take the
batch borrow with try_borrow_mut: a batch that finds the JIT held by an
enclosing batch runs interpreter-only (null batch pointer; every JIT hook
becomes a no-op, no recording starts) rather than aliasing the active
mutable JIT, and the outer batch resumes untouched. The regression test
runs one instruction on a second core from the first word read of the
outer bus and checks that both cores retire their instruction; it panics
without the fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqCuD9vsGeeij5DdYt3vcK
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants