Sscalascript.dev

Interpreter bench honesty audit

Companion to js-honesty-audit.md and rust-jvm-antifold-fairness.md. This one covers the interpreter column and the scripts/bench off baseline that the other audits rely on as ground truth.

Method

For an in-process interpreter cell the decisive de-fold A/B is scripts/bench interp <cell> (JIT on) vs scripts/bench off <cell> (SSC_FASTTIER=off SSC_JIT_BYTECODE=off). If off is dramatically slower the on number reflects genuine JIT optimisation of real work; if off is the same, the work is being eliminated by a path the off switch doesn't disable.

Findings (2026-06-11)

CellJIT onoff (before fix)off (after fix)Verdict
arithLoop0.248 ms2.746 ms2.746 mshonest — bytecode JIT speeds a real loop (11×)
instanceFieldAccess0.035 ms8.277 ms8.277 mshonest — JIT speeds real work (236×)
eitherChain0.002 ms0.017 mshonest — JIT speeds real work (8.5×)
optionChain0.002 ms(n/a)honest by analogy to eitherChain; off cannot be measured — the tree-walk path hits the bench-harness initBuiltins-skip gotcha (Undefined: None), not a fold
pureCallSum0.003 ms0.003 ms11.748 mswas a baseline artifact — see below

arithLoop, instanceFieldAccess, and eitherChain are honest: turning the JIT off makes the loop tree-walk for real, so the on-numbers are genuine throughput, not folds (consistent with the cross-backend audit's "interp runs the real loop").

optionChain ON is 0.002 ms (same JIT'd Option-chain shape as eitherChain), but its off run throws Undefined: None — the documented bench-harness initBuiltins-skip gotcha (docs/benchmarks.md): the no-JIT tree-walk resolves None as a name, which the harness never initialised. This is a harness artifact, not a fold and not a product bug (real ssc run initialises builtins). So the interp column is honest across every audited cell.

pureCallSum was the outlier. Its 0.003 ms is the Gauss closed-form fold (tryClosedFormPolyLoop, the T2.3 const-propagation feature) collapsing while i<N do { total = total + f(i); i += 1 } to a constant. That is a real interpreter feature and the on-number is legitimate — but before this fix the fold ran unconditionally (the dispatcher tryFastWhileAssign was gated only by debugHooks, never by FastTier.enabled or the bytecode flag). So scripts/bench off — documented as the no-JIT baseline — silently kept folding, reporting 0.003 ms for "JIT off". The de-folded cost was unmeasurable through the standard switch.

Fix

Gate the two algebraic loop eliminators (tryFoldInvariantAccumLoop + tryClosedFormPolyLoop) behind FastTier.enabled (EvalRuntime.scala, the Value.BoolV(true) arm of tryFastWhileAssign). Default behaviour is unchanged (fold on → 0.003 ms); SSC_FASTTIER=off now genuinely disables it, so the honest un-folded baseline (11.748 ms, a ~3900× fold contribution) is visible. docs/benchmarks.md updated to describe the switch accurately.

Verification: backendInterpreter/test 1605 green (folds still fire by default, so the SscVmTest closed-form/invariant cases pass unchanged); interp pureCallSum 0.003 ms unchanged; off pureCallSum 0.003 → 11.748 ms.

Relationship to the rest of T2.1

The cross-backend honesty audit (which cells are folds) is complete across the three audit docs: cross-backend-gap-analysis.md §3 (jvm/compiled fold cells: instance-field, tuple-monoid, bool-predicate, either-chain, option-chain, literal-match), js-honesty-audit.md (JS clean), and this doc (interp clean + the off-baseline defect, now fixed).

What remains of T2.1 is the fix for the compiled (jvm/js/rust) fold cells — direction (b), redesign those workloads to consume loop-varying data so no backend folds them. That is a per-workload benchmark-design project that changes what each cell measures; it is the open continuation. The interp side and the off baseline are now honest.

Direction-(b) fix landed: tuple_monoid (2026-06-11)

The one fold cell with an automated compiled cross-backend measurement (RuntimeBench.{jvm,js}_tupleMonoid) was loop-invariant on every backend:

loop cannot run in 11 ns).

tryHoistedPureWhile hoisted it and the empty counter loop folded (0.008 ms on and off — a different unconditional fold path than the FastTier folds).

Fix (direction b): build the tuple from the loop counter each iteration and accumulate all four components, so the result depends on the whole loop and no backend can fold. The interp variant keeps the ++ monoid op (the workload's intent: (i,i+1) ++ (i+2,i+3)). modTupleMonoidVal is left unchanged — it deliberately validates the constant-hoist optimisation.

Cellbeforeafternote
jvm_tupleMonoid0.011 µs205 µswas a fold; now real work (~18000×)
js_tupleMonoid26.7 µs1688 µsnow real per-iter tuple allocation
interp tupleMonoid0.008 ms~14 ms (1000 iters)on==off, no fold; tuple-++ ~20 µs/iter (typeclass dispatch, un-JIT'd)

The remaining jvm/js gap (205 vs 1688 µs) is now an honest codegen difference (HotSpot scalar-replaces the short-lived tuples; V8 allocates), not a fold.

Still open: instance-field, bool-predicate, either-chain, option-chain, literal-match are folds only in the interp-only InterpreterBench column (no automated compiled cross-backend cell exists for them); the cross-backend-gap doc measured their compiled folds ad-hoc. De-folding those is the same per-workload pattern shown here, applied as further slices.