What works: Interpreter-only boots IRIX to full working desktop. What's broken: ANY JIT (even ALU-only, even MAX_BLOCK_LEN=1) breaks the desktop after login. What's shared: Both paths call exec.step() for the interpreter burst. The JIT dispatch adds probe/translate/compile overhead between bursts.
The interpreter-only loop. One iteration = 10,000 steps + flush.
| # | Step | Code Location | Status |
|---|---|---|---|
| 1 | running.load check | dispatch.rs:583 | CONFIRMED |
| 2 | for _ in 0..1000: exec.step() x10 | dispatch.rs:580-596 | CONFIRMED |
| 2a | ...step() increments local_cycles | mips_exec.rs:860 | CONFIRMED |
| 2b | ...step() advances cp0_count | mips_exec.rs:881-882 | CONFIRMED |
| 2c | ...step() checks cp0_compare crossover | mips_exec.rs:883-886 | CONFIRMED |
| 2d | ...step() loads pending interrupts (atomic) | mips_exec.rs:870 | CONFIRMED |
| 2e | ...step() merges IP bits, checks IE/EXL/ERL | mips_exec.rs:888-908 | CONFIRMED |
| 2f | ...step() calls fetch_instr (nanotlb_translate + L1-I) | mips_exec.rs:911 | CONFIRMED |
| 2g | ...step() decodes + executes instruction | mips_exec.rs:912-929 | CONFIRMED |
| 2h | ...step() handles delay slot state machine | mips_exec.rs:4197-4230 | CONFIRMED |
| 3 | flush_cycles() | dispatch.rs:598 | CONFIRMED |
| 4 | Loop back to 1 | CONFIRMED |
Key property: Steps 2a-2h happen for EVERY instruction. No gaps. No overhead between instructions.
One inner-loop iteration = interpreter burst + probe overhead.
| # | Step | Code Location | Status |
|---|---|---|---|
| 1 | probe.next_interval() | dispatch.rs:190 | CONFIRMED (LFSR advance only) |
| 2 | Interpreter burst: exec.step() x burst | dispatch.rs:193-207 | CONFIRMED (same as baseline) |
| 3 | steps_in_batch += burst | dispatch.rs:208 | CONFIRMED (bookkeeping only) |
| 4 | running.load check | dispatch.rs:211 | CONFIRMED |
| 5 | Read (pc, in_delay_slot) from executor | dispatch.rs:214-218 | CONFIRMED (read-only) |
| 6 | PROM/exc/delay_slot exclusion check | dispatch.rs:220-225 | CONFIRMED (read-only) |
| 7 | translate_pc(exec, pc) | dispatch.rs:227-233 | SEE BELOW |
| 8 | cache.lookup(phys_pc) | dispatch.rs:235 | CONFIRMED (read-only HashMap) |
| 9a | ON HIT: continue (diagnostic skip) | dispatch.rs:237-243 | CONFIRMED |
| 9b | ON MISS: trace_block + compile | dispatch.rs:467-484 | SEE BELOW |
| 10 | Loop back to 1 | CONFIRMED |
| # | Step | Code Location | State mutation? |
|---|---|---|---|
| 7.1 | Call exec.debug_translate(virt_pc) | dispatch.rs:617 | |
| 7.2 | translate_impl::<true>(addr, AccessType::Debug) | mips_exec.rs:1392 | |
| 7.3 | Force privilege = Kernel | mips_exec.rs:1174-1175 | NO |
| 7.4 | KSEG0 (0x80-0x9F): phys = virt & 0x1FFFFFFF | mips_exec.rs:1225 | NO |
| 7.5 | KUSEG (0x00-0x7F): tlb_translate_impl::<true> | mips_exec.rs:1219 | NO (DEBUG=true skips all CP0 writes) |
| 7.6 | TLB lookup (self.tlb.translate) | mips_exec.rs:1360 | Benign (see resolution below) |
| 7.7 | On miss: returns exc, no CP0 writes | mips_exec.rs:1374 | NO |
| # | Step | Code Location | State mutation? |
|---|---|---|---|
| 9b.1 | trace_block(exec, pc, Alu) | dispatch.rs:471 | |
| 9b.2 | debug_fetch_instr(pc) | dispatch.rs:633, mips_exec.rs:1435 | |
| 9b.3 | fetch_instr_impl::<true>(addr) | mips_exec.rs:1436 | |
| 9b.4 | translate_impl::<true>(addr, Fetch) | mips_exec.rs:1457 | NO (DEBUG) |
| 9b.5 | cache.fetch(virt, phys) | mips_exec.rs:1469 | YES: L1-I CACHE FILL |
| 9b.6 | On L1-I miss: fill_l1i_line | mips_cache_v2.rs:1150-1201 | YES: L1-I tag+data, L2 fill |
| 9b.7 | decode_into | dispatch.rs:637 | NO (writes to local DecodedInstr) |
| 9b.8 | compiler.compile_block | dispatch.rs:472 | NO (Cranelift codegen, not CPU state) |
| 9b.9 | cache.insert | dispatch.rs:474 | NO (JIT code cache, not CPU state) |
Step 9b.5-9b.6: L1-I cache fill during trace_block.
This is a REAL state mutation. When the JIT dispatch traces a block on cache miss, debug_fetch_instr goes through the instruction cache. If the L1-I line isn't cached, it fills it. This means:
- The JIT dispatch is FILLING L1-I cache lines at times the interpreter wouldn't
- L1-I is a set-associative cache with LRU eviction
- Extra fills could EVICT lines the interpreter would have kept
- This changes the instruction cache hit/miss pattern for subsequent interpreter steps
CONFIRMED -> CONFIRMED -> DARK -> SYMPTOM
| | | |
interp probe tlb.translate 4dwm crashes
burst overhead + L1-I fill after login
Dark territory (2 items):
-
tlb.translate() internal mutation (step 7.6)
- Does the TLB lookup itself modify any internal state?
- E.g., LRU tracking, reference bits, statistics counters?
- If the TLB has side effects on read, debug_translate is NOT side-effect-free
-
L1-I cache fill during trace_block (step 9b.5-9b.6)
- On every cache MISS, trace_block fills L1-I lines
- This happens OUTSIDE the normal interpreter flow
- Could evict hot lines, changing timing and behavior
- Could cause the instruction cache to be in a different state than the interpreter expects
The TLB has per-access-type MRU lists (4 lists: Fetch, Read, Write, Debug). mru_promote() reorders the linked list to put the matched entry first. debug_translate uses AccessType::Debug (list 3), which is INDEPENDENT from the interpreter's AccessType::Fetch (list 0). The MRU mutation doesn't affect translation results, only lookup speed in the Debug list. Status: MAPPED, not the bug.
On every cache miss, trace_block calls debug_fetch_instr which goes through cache.fetch(). On L1-I miss, fill_l1i_line is called, which:
- Invalidates the existing L1-I line at that index
- May fill from L2 or from the bus
- Writes L1-I tag + data arrays
This happens OUTSIDE the normal interpreter execution flow. It's extra L1-I cache activity that the interpreter-only path never performs. Could evict hot lines and change the interpreter's cache behavior.
A test is currently running with JIT block execution completely disabled
(cache hits just continue). The dispatch loop still probes, translates,
traces, and compiles -- but never runs compiled code.
If desktop STILL breaks: Bug is in dispatch overhead:
- L1-I cache pollution from trace_block (mechanism 2)
- Some other side effect we haven't identified
- Next step: disable trace_block too (just probe + translate, no compile)
If desktop WORKS: Bug is in sync_from/sync_to or compiled code:
- Re-examine sync_to clearing in_delay_slot
- Check if compiled code has a rare codegen bug not caught by unit tests
- Next step: re-enable execution, add per-block logging of entry/exit state
One rebuild to cover all remaining dark spots:
- Add counter: L1-I fills from trace_block vs from normal step()
- Add counter: cache.fetch() calls from trace_block vs from step()
- Log PC address space (KUSEG/KSEG0) at each probe point
- If L1-I fills are significant: skip cache.fetch in debug_fetch_instr by using a separate read path that doesn't touch L1-I