Skip to content

jitv2: count every retired instruction the way the interpreter does - #439

Merged
techomancer merged 2 commits into
techomancer:mainfrom
jamesbraid:jitv2-icount
Nov 7, 2026
Merged

techomancer merged 2 commits into
techomancer:mainfrom
jamesbraid:jitv2-icount

Conversation

@jamesbraid

@jamesbraid jamesbraid commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

iris counts every guest instruction executed. That count drives the guest's CP0 Count timer and, under ci_clock, guest timing. The interpreter adds 1 per instruction. Compiled code avoids a store per instruction. It adds instructions up while compiling and writes the total to the counter only at certain places: where a compiled block exits and where a branch lands. If execution leaves by a path that doesn't write the total, those instructions are never counted.

Three such paths made compiled code count less than the interpreter. Guest timing then depended on when a region finished compiling. With this change both engines count the same. An instruction counts once when it executes, including delay slots and a faulting instruction. An annulled branch-likely slot does not count.

Branch-likely, not taken

A branch-likely is the MIPS branch that skips its delay slot when not taken. The code that wrote the total sat in the delay slot, which only the taken path runs. The not-taken path dropped every instruction since the last write. A loop in fpu/scalar_d counted 17 instructions per pass instead of 26. The not-taken path now starts from the count at the branch and writes it where it leaves the block or lands on a point that expects a written total.

Exception or early exit mid-block

An exception, a pending interrupt or a busy bus in the middle of a block returned without writing the total so far. These exits now write it.

First instruction of a block, entered from the run loop

The code that counts a block's first instruction sat on the path taken by branches inside the block. When the run loop jumped in from outside, it skipped that code. This only matters when the first instruction writes the total itself: a CP0 instruction, or one that branches back to a loop top. The TLB refill handler starts with dmfc0 and is entered this way on every TLB miss. sys/tlb_miss counted 25 instructions per refill instead of 26. The counting now sits where both entries run it.

None of this adds work per instruction or a new branch on the hot path. The extra writes run only on these exit paths.

Effect on counts

Deterministic mode (iris-bench run --deterministic: synchronous compile, guest clock), R10000. Guest instructions per kernel iteration, for the 12 kernels that differed by more than 0.1%:

kernel interpreter compiled, 45b7f74 compiled, this branch
codec/lz 139773412 90955130 139773412
fpu/scalar_d 26.00 17.00 26.00
fpu/scalar_s 26.00 17.00 26.00
codec/rle 18817082.67 16473712.33 18817082.67
sys/tlb_miss 26052.77 23685.47 26052.77
int/dhrystone 428.06 390.05 428.06
fpu/transcend 322.12 310.04 322.12
fpu/whetstone 14556.02 14275.02 14556.02
img/sharpen5x5 47143988 46558771 47143988
img/rotate90 1440267.58 1428745.56 1440267.58
codec/huffman 40449492 40189507 40449492
fpu/linpack 3291609.33 3274508.68 3291609.33

On 45b7f74, 30 of 46 kernels counted differently compiled. The other 18 were off by about one instruction per timed run. On this branch all 46 match the interpreter exactly. Checksums are unchanged.

Guest MIPS is computed from this counter, so it rises for these kernels with no change in speed. The speed comparison below uses work-unit rates instead.

Performance

Ship builds of 45b7f74 and this branch, R10000, bare machine, interleaved real-clock runs pinned to one CCD. Work units per second, median, Mann-Whitney. No kernel is slower at p < 0.05 in either layout. Only codec/rle moves in both layouts, 29% faster, which these measurements do not explain. Every other significant result appears in one layout only, which is the signature of code placement rather than this change.

kernel plain (n=30) p equal alignment (n=15) p
codec/rle +29.0% 6e-10 +29.3% 3e-6
mem/stream_triad +11.9% 0.0008 -0.9% 0.74
sys/tlb_miss +7.8% 0.012 -0.7% 0.59
img/convolve3x3 +6.4% 0.014 +2.1% 0.14
codec/lz +2.5% 0.035 -0.3% 0.51
int/bitops +0.8% 0.17 +3.4% 0.009
fpu/scalar_s +0.7% 0.68 +2.0% 0.042
int/branch +1.0% 0.52 +1.8% 0.031

The other 38 kernels are within noise in both layouts. "Equal alignment" builds every function on a 64-byte boundary, to take host code placement out of the comparison.

Host instructions retired over the whole deterministic suite (perf stat, n=2 each): 517.49 G on 45b7f74 and 514.27 G on this branch, spread under 0.002% within each build. This is not a like-for-like cost comparison. The guest clock now advances faster on kernels that used to under-count, so the suite runs 6.8% fewer iterations (35.1 M against 37.7 M) for 2.0% more guest instructions.

Tests

New tests in src/cpu/jitv2/equiv_test.rs run hand-assembled code through the real dispatch path on both engines. Each requires identical state and an exact hand-computed count:

  • loops_with_likely_branches_count_like_the_interpreter: loops with a not-taken beql and bc1tl, a beql to its own fall-through word (taken and not), and a bnel back edge falling out of the loop.
  • exception_mid_run_counts_like_the_interpreter: an overflowing add after two addiu in one region counts 3.
  • region_entered_at_a_flushing_word_counts_it: a refill-handler-shaped region (dmfc0 entry, dmtc0, stopping at tlbwr), and an entry that falls into a loop top.

All fail without their fix: beql loop 302 against 502, exception 0 against 3, refill handler 6 against 7, loop-top entry 11 against 12. cargo test --features jitv2 --lib passes in release and debug (2959 tests). cargo check is clean with lightning, jitv2_lockstep and jitv2_opcodefusion.

iris counts every guest instruction executed. The count drives the
guest's CP0 Count timer and, under ci_clock, guest timing. The
interpreter adds 1 per instruction. Compiled code adds the instructions
up while compiling and writes the total to the counter only where a
block exits or a branch lands. A path that leaves without writing the
total loses those instructions.

Two paths did that:

- A branch-likely that is not taken. It annuls its delay slot, and the
  code that wrote the total sat in the slot. The not-taken path dropped
  every instruction since the last write. fpu/scalar_d counted 17 per
  loop pass instead of 26.
- An exception or bail-out (pending interrupt, busy bus) in the middle
  of a block. It returned without writing the total so far.

Write the pending total on both paths. The writes run only on those
exits, so no per-instruction work or new branch is added.
iris counts every guest instruction executed, and the count drives the
guest's CP0 Count timer. Compiled code adds instructions up while
compiling and writes the total to the counter at certain points. For a
block's first instruction that write is sometimes part of the
instruction's own code: a CP0 instruction, or an instruction that
branches back to the top of a loop.

When the run loop jumps into a block from outside, it skipped the part
of the first instruction that does the counting. A TLB refill handler
starts with a CP0 read and is entered this way on every TLB miss.
sys/tlb_miss counted 25 instructions per refill instead of 26.

Put the counting where both ways into the block run it. Nothing is
added per instruction. The same code moves one step later.
@jamesbraid
jamesbraid marked this pull request as ready for review November 7, 2026 04:59
@techomancer
techomancer merged commit 19e35da into techomancer:main Nov 7, 2026
14 checks passed
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