Conversation
63cb053 to
958c352
Compare
benletchford
left a comment
There was a problem hiding this comment.
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.
|
Thanks, confirmed and fixed. Your reproduction is now the regression test ( The fix keeps the batch-wide lifetime but makes the borrow fallible: Rebased onto current |
958c352 to
ff190fb
Compare
…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
449c73c to
040b497
Compare
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 head040b4973includes 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_batchAPI supplies generation zero and retains exactper-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 experimentalSystemless 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:
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.
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 old5f21681CLI default wassystemless-default; the current CLI default isclassic-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_batchafter a generation-awarebatch 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." "_BlockMoveis not guaranteed to flush the instruction cachefor 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 itsjump table),
_UnloadSeg(publishes preceding writes even though Systemlessretains the segment), and
_Read(publishes even when it returns an errorsuch as
eofErr: the trap is the documented boundary and may also publishwrites performed before it).
_BlockMove($A02E) when the signed byte count is greater than 12. Theimmediate-bit form
_BlockMoveData($A22E) exists specifically to copynon-code without the flush and therefore does not publish; the handler tests
the actual trap word's immediate bit.
_FlushCodeCache;_HWPrivselector 1 (FlushInstructionCache) andselector 9 (
FlushCodeCacheRange)._HWPrivselector 3 (FlushDataCache)and selectors 4/5/6 (external-cache controls) are not instruction-publication
boundaries and stay no-ops.
_HWPrivselector 0 (SwapInstructionCache): switching the emulatedinstruction 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 ofopcode 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 anytrace and need nothing. The classes that do publish:
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_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._Readhas none).HandToHand,PtrToHand,PtrToXHand,HandAndHand,PtrAndHand;SetHandleSizewhen growth relocates the block (process and resource handles alike)_BlockMove, and Apple's principle is that system-moved code needs no application flush._BlockMove's rule so tiny string copies stay silent.ReallocateHandle,EmptyHandle,DisposeHandlecopy nothing and do not publish._Mungeris deliberately excluded: it edits text, and TextEdit calls it per keystroke.ShieldCursor/FMSwapFontstubs)MOVEA.L #immandJSR abs.Loperand words with the task record and callback address. Those are instruction bytes.write_host_code_word/longhelpers: skip unchanged bytes (a repeated callback publishes nothing), write and publish when they change.write_readonly_code_wordConsidered 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):
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
_BlockMovecalls in the 400M-instructionreplay, 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 requestedfor the current batch, set at batch entry by
run_batch_with_instruction_memory_generationand cleared to zero afterit. Ordinary
run_batchsets it to zero.TraceJit::instruction_memory_generation(u32): the single generation thereachable trace cache currently represents.
CompiledTrace::checked_func: Cell<Option<NativeTraceFn>>: the trace'sfirst-call entry. This field already existed as a plain
Option; wrappingthat existing per-trace field in a
Celllets the entry path update itthrough the shared
&CompiledTracewithout another per-trace generationfield. Separate CPU/JIT state changes are described here and in section 3.5.
Plus two structures on
TraceJitthat are touched only when a link isinstalled 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 compiledtrace 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 thatkeeps the list duplicate-free.
3.2 The first-call entry is the byte proof
Every native
CompiledTracehas two entry points.funcis the uncheckednative 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_funcis the first-call entry and has always taken one of twovalues: 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 theshared Rust validator (
validate_trace_code, SIMD-accelerated) before thebody is called.
Under a nonzero authoritative generation,
checked_functakes a thirdmeaning: it is the trace's proof state.
checked_funcvalueSome(func)(aliases the body)NoneSome(func); on mismatch the trace is invalidatedSome(wrapper)The lifecycle of one compiled trace under nonzero generations:
compile_opsreceivesauthoritative_generation = trueandgenerates no wrapper;
checked_funcis initialised toSome(func). Thetrace 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.
finish_recording_with_retrystores the trace in itsdirect-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.
try_executeloadschecked_funconce. It isSome,so
generated_validationis true, the Rust validator is skipped, and thefirst native call goes to
checked_func.unwrap_or(body), which is the bodyitself. 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.
batch. The first JIT access in that batch
(
synchronize_instruction_memory_generation, called once fromwith_trace_jit_batch) sees the two generations differ and calls the coldreset_linked_entry_proofs. That drainslinked_slot_indices: eachlisted slot that still holds a compiled trace gets
checked_func.set(None); a listed slot that was evicted, rejected, orrecompiled 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.
checked_funcisNone, so the shared validatorcompares the captured bytes with live memory. If they match and
cpu.instruction_memory_generation != 0, the entry setschecked_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_traceempties the slot and hands the changedinstruction to the interpreter, exactly as before this PR.
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
Validarm never restores the link, so a tracewhose
checked_funcisNonevalidates on every entry. A transition fromnonzero 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 theuntracked 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
Noneand thenre-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)
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).Some(wrapper)exists under a nonzero generation: authoritative compilesnever generate one, and every zero-to-nonzero transition resets all
compiled slots.
reset_compiled_entry_proofscancels it and the CPU-side recording flag, and
try_executerefuses torun while a recording is active.
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.
checked_funcentirely and validates on everyentry; the list bookkeeping is harmless there.
earned_call_permission,structurally_rejected,compiled_before,no_terminal_strikes,deferred_linear) are retainedacross 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 nocorrectness 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
Cellrather than a mutable borrow.try_executeholds&self.slots[idx]as&CompiledTracewhile it may later need&mut selffor invalidation;
NativeTraceFnisCopyandCellwas already importedand used on neighbouring adaptive counters, so the proof update needs no
borrow restructuring and no unsafe.
checked_funcrather than add a field. Rows B6 and B9 showedthat 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.
TraceSlotis large (aCompiledTracecarries threeVecs and aboutfifteen 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).trace JIT is one
RefCellborrow per batch, with the borrowed address keptin a transient tail field of
CpuCoreand 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 ofthe 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 borrowmakes 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
_BlockMovepublishes 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-admissionbranches,.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).
2b04412)941d2bd)d4bef09)a6936e5)0a68486)$20E000-$866000, so the interval treated ~6.3 MiB of writable RAM as possible code and forced interpreter retries.c3e6c61)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.
_BlockMovepublishes; see C2).checked_funcdirectly to the bodyTraceJit, synchronize at every JIT accessTRACE_JITTLV resolver calls&mut TraceJitthrough the interpreterCpuCorepointer2ab5005)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....-generation-v19)_Read,_UnloadSeg,_BlockMove>12. Not a valid feature result....-generation-v19-cache-complete)_BlockMovepublications each discarded all hot code: recording/recompilation storm. Wholesale is only cheap when publication is rare.experiment/cache-complete-generation-bitset990b9b2,...-generation-bitset-cache-complete)640293a,...-generation-entry-reset-cache-complete)MacMemoryBus(...-hle-audit)50055f1,...-hle-audit-v2)b43c984,...-hle-audit-v3)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
mprotectread-only/read-write pair 1.3-1.8 us; rearm + one write fault +disarm 9.2-12.8 us; one
mach_vm_page_query1.85-2.48 us; one-pagemincore1.60 us; neither query API has a documented reset, so faults arethe only reusable signal. Structural requirements: a page-aligned owned-RAM
variant (Systemless RAM is a
Vec, and protecting its edge pages couldprotect unrelated allocator objects); a Mach exception server via
task_set_exception_ports(aSIGSEGVhandler cannot legally callmprotect), which must preserve and forward pre-existing debugger/crashreporter 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, Linuxuserfaultfd).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,
_HWPrivSwapInstructionCache), which this integration maps to generationzero 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 nosuch 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 inputscript per workload, fresh fixture copy per run, periodic screenshots off, A/B
order alternated every pair. Per run it records macOS hardware
instructions retiredandcycles elapsedand user CPU seconds from/usr/bin/time -l, the final simulated tick count, and the SHA-256 of thefinal 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: Systemlessupstream
3c63ca4+ #1220 (c90a01e), m68k8aa8956, 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(releaseprofile: 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.
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:
040b4973contains the generation API (18a5065),nested-batch fallback (
87a570c) and formatting fix (040b4973) atop upstream3d00fb8/ 0.13.0, with no merge commits in the PR range. This is asource-identical history replacement for
449c73c5: both resolve to Git tree429862db1692429244634b4970cc9b6389e09b76, and their full-tree diff is empty.The old head is preserved locally. The approved push used an explicit
--force-with-leaserequiring the remote still to point to that exact old head.Local validation completed on the identical
449c73c5source tree (thesecommands 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-generationafter the nested-batch fix; those are historicalcounts 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 passedcargo 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-featuresandcargo check --features instruction-generation: pass, with the two pre-existing portable-onlydead-code warnings
cargo clippy --features 'jit instruction-generation' --all-targets: cleanrustfmt --check: only the three hunks that already differ at2ab5005;no formatting drift introduced
Systemless integration tree (
3c63ca4+ #1220 + the publication diffincluding 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-existingm68k portable-only warnings)
git diff --check: cleancargo fmt --checkis not run: upstream carries unrelated formatting driftand 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: bornproven; a publication keeps the slot compiled and resets the link to
None; one execution under the new nonzero generation runs the sharedvalidator 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: noduplicate 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 generationleaves 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_batchrestores exactvalidation and observes a further rewrite.
Cell.Systemless focused tests (in the publication diff):
_BlockMovepublishesonly the flushing form over twelve bytes;
_Readpublishes on success and oneofErr;_UnloadSegpublishes; 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;
HandToHandpublishes only copies overtwelve bytes;
SetHandleSizepublishes only when the block moves; reloadingresource bytes publishes;
ReadPartialResourcepublishes; a VBL trampolinerewrite publishes only when the callback address changes.