Skip to content

jitv2: skip the FP exception model for ordinary operands - #328

Merged
techomancer merged 1 commit into
techomancer:mainfrom
irix7:perf/fpu-model
Oct 25, 2026
Merged

techomancer merged 1 commit into
techomancer:mainfrom
irix7:perf/fpu-model

Conversation

@belikh

@belikh belikh commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

jitv2: skip the FP exception model for ordinary operands

A guest fadd.s in a jitv2 build emits about 106 x86 instructions around one
vaddss. The cost is the exception model, not the arithmetic: two
per-operand denormal/quiet-NaN checks, the by-value signalling-NaN flags, the
result-denormal rule, and an FCSR update that stores Cause on every
instruction.

This classifies the operands once and gives the common case a straight-line
path, keeping the existing model out of line for everything else. The
interpreter is untouched.

The shortcut, exactly

If both operands are ordinary finite numbers — biased exponent neither zero
(zero/subnormal) nor all ones (infinity/NaN) — then:

  • fpu_check_denorm_operand is false for both operands, so nothing can trap
    as Unimplemented Operation;
  • no operand is a signalling NaN, so fpu_arith_flags_snan_only is zero;
  • for DIV, the divisor is nonzero, so the Divide-by-Zero flag cannot fire.

So the instruction provably raises no flag (this model raises no other
flag for ADD/SUB/MUL/DIV), and the fast arm only has to:

  1. compute the result and apply the unchanged Table 9-1/7-1 result-denormal
    rule (an underflowing result is reachable from ordinary operands, so that
    check stays on both arms);
  2. clear Cause — but only when it is set. Cause must reflect only the last
    instruction, and rewriting a Cause field that is already clear stores the
    value that is already in memory, which the guest cannot observe. Skipping
    that store also removes a store-to-load dependency through
    core.fpu_fcsr between consecutive FP instructions.

SQRT additionally requires a non-negative operand, since a negative nonzero
operand raises Invalid.

Everything else — zero, denormal, infinity, NaN, a negative square root, and
DIV by zero — takes the previous body verbatim, just in an out-of-line
cold block: the same two operand checks, the same by-value flags, the same
result-denormal rule and the same full FCSR update (Cause, sticky Flag,
Enable checks and EXC_FPE).

Static code check with the study's metric (IRIS_JIT_DISASM=1, median x86
instructions between consecutive vaddss in the compiled region): 106
before, 74 after
(the study measured 109 on its own image).

Measurements

Host: Intel Core i5-8500T, 6 cores, Linux x86_64 (load 2.78–3.59 during the
runs; other long-running work was present on the box, so all cells are
interleaved within each repeat).

Build: cargo build --release --features lightning,jitv2, commit ed17f88
plus this change vs the same tree without it. Runs: --cpu r4400 --cache shadow, five runs per cell, median with [min–max]; the interpreter
column is a separate unchanged lightning build, --cache full, three runs,
shown for scale.

Kernels are the study's isolated kernels (eight independent accumulators =
throughput, one dependent accumulator = latency), rebuilt as one kernel per
image
: the multi-kernel probe image was measured first and is dominated by
the host-page/code-placement sensitivity the study already documents (an
untouched cvt.s.d kernel moved 3x between builds), so it cannot attribute a
change to the FP model. One kernel per image removes that confound. ns per
guest FP operation:

kernel interpreter jitv2 before jitv2 after gain
fpu/add_s_thr 17.95 8.16 [8.01–8.18] 4.25 [4.21–4.39] 1.92x
fpu/add_s_lat 17.71 7.25 [7.10–7.26] 4.84 [4.78–4.85] 1.50x
fpu/add_d_thr 18.74 8.21 [8.09–8.72] 4.08 [3.83–4.42] 2.01x
fpu/add_d_lat 19.31 8.16 [7.70–8.41] 4.27 [4.19–4.35] 1.91x
fpu/mul_s_thr 18.47 8.18 [8.02–8.22] 4.41 [4.39–4.89] 1.86x
fpu/mul_s_lat 18.59 7.73 [7.23–8.16] 4.83 [4.80–4.99] 1.60x
fpu/mul_d_thr 19.06 8.16 [8.04–8.27] 3.99 [3.76–4.02] 2.04x
fpu/div_s_thr 18.35 8.52 [8.45–8.61] 4.20 [4.18–5.10] 2.03x
fpu/div_s_lat 18.10 8.45 [8.36–8.47] 7.22 [7.12–7.41] 1.17x
fpu/div_d_thr 19.65 7.71 [7.55–7.84] 4.29 [4.26–4.34] 1.80x
fpu/sqrt_s_thr 16.30 5.87 [5.78–5.96] 4.02 [3.87–4.07] 1.46x
fpu/sqrt_d_thr 17.31 6.08 [6.01–6.16] 3.45 [3.42–3.52] 1.76x
fpu/cvt_s_d (control) 14.45 4.04 [3.99–4.07] 3.84 [3.80–3.86] 1.05x
fpu/cvt_w_s (control) 19.54 10.48 [10.35–10.60] 11.08 [10.90–11.61] 0.95x
fpu/cvt_l_d (control) 19.95 11.29 [11.04–11.39] 11.57 [11.53–11.67] 0.98x
fpu/cmp_s (has add/sub) 68.03 17.94 [16.65–18.17] 14.47 [14.35–14.63] 1.24x

The conversion kernels are untouched by this change and stay within ±5%;
they are the control that the box was not drifting. Dependent DIV stays
limited by the host divider (as the study predicted: ~1.5x ceiling there),
and dependent SQRT by the host sqrt unit.

Reproduce (single-kernel image built from the study's bench-micro kernels;
the images are scratch, not committed):

CARGO_TARGET_DIR=... cargo build --release --bin iris --bin iris-bench \
    --features lightning,jitv2
iris-bench run --iris target/release/iris --elf <one-kernel>.elf \
    --config <bare.toml> --label <cell> --out <dir> --timeout 300 -- \
    --cpu r4400 --cache shadow

What proves the semantics

  • cargo test -p iris --features lightning,jitv2 — 1748 passed, 0 failed.
    This includes the whole jitv2 equivalence suite, which compares the JIT
    against the interpreter on a full CoreSnapshot (GPRs, FPRs, PC and
    fpu_fcsr
    ) for every FP instruction shape. A new test,
    arithmetic_clears_preexisting_cause_and_keeps_sticky_flags, seeds FCSR
    with Cause.V|Cause.Z and Flag.V|Flag.I set and checks, for ADD/SUB/MUL/DIV
    in S and D, that both engines clear Cause, keep the sticky flags and agree
    exactly — i.e. the "Cause is already set, clear it" arm is covered, not just
    the skip-the-store one.
  • cpu-tests (bare-metal MIPS FPU suite: arithmetic, denormals, traps,
    compare, FR0/FR1, breadth, vectors) run under the jitv2 build before and
    after: identical result, 2620 checks passed, 126 failed, same 29 test
    identities. The failures are the standing jitv2/interpreter FPU-model
    findings, unchanged by this PR.
  • cargo test --workspace — all test binaries pass (1429 tests, 0 failed).

Risk

The cold path is the previous code, unchanged, so any operand class for which
the fast-path proof does not hold still runs the full model. The only new
observable behaviour is which arm an instruction takes. The fast path's
arithmetic is the same IR op on the same operand bits as the cold path's, so
results (including overflow to infinity, which this model does not flag) are
identical.

The cost is code size: each FP arithmetic emitter now contains both arms
(measured +6.6% total compiled bytes on the multi-kernel probe image). Cold
blocks are marked cold so the hot path stays contiguous.

Credit

The measurement study, the ranked recommendation this implements and the
isolated-kernel method come from Jupiter Belic's irix7/iris fork:
clanker/perf/mem-and-fpu-hot-paths.md at 510adc5.

A guest `fadd.s` in a jitv2 build emitted about 106 x86 instructions around
one `vaddss`. The cost was the model, not the arithmetic: two per-operand
denormal/quiet-NaN checks, the by-value signalling-NaN flags, the
result-denormal rule, and an FCSR update that stored Cause on every
instruction.

Classify the operands once instead. If both are ordinary finite numbers —
biased exponent neither zero nor all ones — then no operand check can trap,
no signalling NaN can be present, and (for DIV) the divisor cannot be zero,
so the instruction provably raises no flag. That arm computes the result,
applies the unchanged result-denormal rule, and clears Cause only when it is
set: rewriting a Cause field that is already clear writes back the value
already in memory, which the guest cannot observe, and skipping it also
removes a store-to-load dependency through `core.fpu_fcsr` between
consecutive FP instructions. Everything else — zero, denormal, infinity,
NaN, and a negative square root — takes the previous body verbatim, out of
line, so the exception behaviour is the same code as before. The
interpreter is untouched.

Measured with the isolated kernels from the study below (single-kernel
images, R4400, `--cache shadow`, five interleaved runs per cell, median,
spread under 2% except where noted):

  kernel            before     after    gain
  fadd.s indep       8.16      4.25    1.92x
  fadd.s dep         7.25      4.84    1.50x
  fadd.d indep       8.21      4.08    2.01x
  fadd.d dep         8.16      4.27    1.91x
  fmul.s indep       8.18      4.41    1.86x
  fmul.s dep         7.73      4.83    1.60x
  fmul.d indep       8.16      3.99    2.04x
  fdiv.s indep       8.52      4.20    2.03x
  fdiv.s dep         8.45      7.22    1.17x
  fdiv.d indep       7.71      4.29    1.80x
  fsqrt.s indep      5.87      4.02    1.46x
  fsqrt.d indep      6.08      3.45    1.76x

The pure-conversion kernels, which this change does not touch, stay put
(cvt.s.d 4.04 -> 3.84, cvt.w.s 10.48 -> 11.08, cvt.l.d 11.29 -> 11.57),
which is the control.

Semantics: the jitv2 equivalence suite (JIT vs interpreter snapshots, FCSR
included, plus a new test that seeds Cause and Flag and checks that ordinary
operand arithmetic clears Cause and keeps the sticky flags), the FPU/FCSR
cpu-tests under jitv2 (identical pass/fail set before and after: 2620 checks
passed, 126 failed, same 29 test identities), and `cargo test --workspace`.

Credit: Jupiter Belic and irix7/iris — "Guest memory and FPU hot paths: what
is actually slow", https://github.com/irix7/iris/blob/510adc512dc59395ba3ba2a42426bda09b11b9e4/clanker/perf/mem-and-fpu-hot-paths.md ;
the measurements, the ranked recommendation this implements and the
isolated-kernel method are that fork's work.
@techomancer

Copy link
Copy Markdown
Owner

nice one!

@techomancer
techomancer merged commit 7e877c0 into techomancer:main Oct 25, 2026
14 checks passed
belikh added a commit to irix7/iris that referenced this pull request Oct 25, 2026
Adopt the upstream work that landed since the fork's last sync:

- jitv2: skip the FP exception model for ordinary operands (techomancer#328)
- jitv2: inline the FPU integer conversions (techomancer#332)
- serial: wall-clock timestamps for --serial-log (techomancer#325)
- physical: a sparse, host-mapped decode table over 40 bits
- pit: implement the 8254 read-back command
- O2: the AIC-7880 specification, shared [scsi.N] disks, fresh serial log
- docs: Odyssey references and the AIC-7880 emulation notes

Conflict resolutions:

- serial log: take upstream's one shared sink and the off|wall timestamp
  modes; delete the fork's guest/both synthetic-clock sink and its
  partial-line flusher. The CI and TCP paths still share the sink, and the
  fork's per-consumer console cursors are unchanged.
- config: use dev::serial_log::SerialLogTimestamps; remove the local enum,
  the microsecond formatter and the guest/both parsing tests.
- CHANGELOG/FEATURES/HELP: keep the fork's October sections and the
  instance/fleet entries, adopt upstream's JIT v2 and O2 text, and drop the
  duplicated O2 sync and machine-start entries.

Tests: cargo test --workspace --features lightning,jitv2.
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