Sscalascript.dev

Benchmark Analysis — 2026-06-04

Full run after install.sh rebuild + overnight agent work (all tasks from the 2026-06-03 analysis landed). New baseline established; two new items identified.

Protocol used

bash install.sh --dev    # rebuild ssc binary
bash bench.sh            # wall-clock cross-backend
scripts/bench interp     # JMH microbenchmarks (wi=3 mi=5 1 fork)

Results

bench.sh (wall-clock, ms/iter)

arith-loop:          interp 0.286   jvm 0.282   js 0.595
effect-pure:         interp 0.057   jvm n/a     js 0.005  ← JS 3× faster
effect-stream:       interp 30.1    jvm n/a     js n/a    ← interp only
hello-world:         interp 0.014   jvm 0.002   js 0.002
pattern-match-heavy: interp 0.554   jvm 0.577   js 35.2   ← interp BEATS JVM
recursion-fib:       interp 1.19    jvm 1.28    js 4.39   ← interp BEATS JVM
recursion-tco:       interp 0.042   jvm 0.029   js 0.126
tuple-monoid:        interp 0.230   jvm 0.137   js 0.027  ← js p2 landed (93×)

scripts/bench interp (JMH, ms/op)

arithLoop            0.283 ± 0.020
effectPure           0.016 ± 0.001
instanceFieldAccess  0.041 ± 0.001
mapForeach           0.189 ± 0.021   ← while-jit-map-foreach 11.4× (was 2.14)
matchBodyBaseline    0.044 ± 0.002
nestedMatchExpr      0.044 ± 0.006
patternGuard         0.045 ± 0.005
patternMatchHeavy    0.414 ± 0.047
patternMatchSet      0.202 ± 0.020
patternMatchWide     1.414 ± 0.237   ← open target
pureCallSum          0.263 ± 0.050
pureCallSum2         0.260 ± 0.035
pureCallSumBlock     0.253 ± 0.007
pureCallSumIf        0.265 ± 0.004
recursionFib         1.237 ± 0.179
recursionFibD        1.462 ± 0.189
recursionFibMul      1.321 ± 0.185
recursionFibMulD     1.602 ± 0.223
recursionTco         0.033 ± 0.008
recursiveEval        3.739 ± 0.752   ← physical floor (see below)
recursiveEvalMixed   3.661 ± 0.365   ← physical floor
refChainArg          0.367 ± 0.006
refFieldArg          0.046 ± 0.007
tupleMonoid          0.212 ± 0.028   ← 55% slower than JVM (open target)

What landed overnight (from 2026-06-03 analysis)

All five items from the prior analysis landed in a single session:

ItemResult
jit-match-recursive-descentINVOKESTATIC arm self-calls confirmed; 3.57ms IS the floor
while-jit-map-foreachmapForeach 2.14 → 0.189 ms (11.4×)
phase-c-bytecode-wider-matchwildcard/catch-all arms; patternMatchWide unchanged (floor)
js-codegen-opt-p2tuple-monoid JS 2.52 → 0.027 ms (93×)
asm-jit-parity Phase 1+2AsmJitBackend at full Javac parity incl. while-backend

Physical floor — recursiveEval / recursiveEvalMixed ~3.7 ms

WORK_QUEUE records: "3.57 ms represents the achievable floor for 1021-node INVOKESTATIC tree traversal at ~3.5 ns/node." The INVOKESTATIC recursive self-calls were already present in the JIT (walkLong's self-call case) — the spec's root cause was incorrect. At 3 GHz, 3.5 ns/node = ~10.5 cycles per recursive call including typeTag dispatch and fieldsArr extraction. Nothing to optimize here short of Direction C (direct-style eval).


Remaining targets

1. patternMatchWide 1.414 ms → ~0.2–0.4 ms

Item: phase-d-patternmatch-fused-foreach (already open in WORK_QUEUE).

12-constructor ADT, 50K × 12 = 600K eval calls via while-jit-mixed. The fused path calls ObjToLong.apply(o) per element — one indirect JVM call per ADT node. If JavacJitBackend compiled the entire foreach body inline (i.e. emitted switch(o.typeTag) { ... } inside the fused Java method), HotSpot could devirtualize and eliminate the indirect call entirely.

Current cost: 1.414ms / 600K = 2.36 ns/call. A fully fused inline match body should reach ~0.3–0.5 ns/call (similar to the tight arithLoop path).

Implementation: extend tryCompileWhileMixed — when the inner foreach body is o => { acc = acc + fn(o) } and fn compiles to an ObjToLong whose body is a match, emit the entire switch(inst.typeTag) { ... } inline in the fused Java method rather than calling _fn0.apply(o).

2. tupleMonoid interp 0.212 ms vs jvm 0.137 ms (55% gap)

Item: jit-tuple-concat-hoist (NEW — not yet in BACKLOG/WORK_QUEUE).

The bench: while i < 100000 do last = (1,2)++(3,4). Every iteration:

The JVM backend constant-folds this to a single pre-built object; the interpreter allocates three times per iteration (100K allocs).

The while-JIT already handles the i counter; the last = ... assignment falls through to EvalRuntime because its RHS is not a Long expression.

Fix: in tryLongWhileAssign (or a companion tryConstAssignHoist), detect assignments of the form name = expr where expr is a pure constant expression — no free names, no side effects, no loop-variable references. Pre-evaluate the RHS once before the JIT loop and store the Value in a pre-computed ref slot. The loop body becomes a single ref-store per iteration.

This generalises beyond tuples: any last = <pure-constant-expr> inside a while loop body that the JIT is already handling could benefit.

Bench target: tupleMonoid 0.212 → ~0.140 ms (JVM parity, ~1.5×).

3. effect-stream 30.1 ms — investigation needed

Item: effect-stream-jfr (NEW).

Only runs on interp; JVM/JS have no implementation. 30.1 ms vs effect-pure's 0.016 ms = 1880× slower for the stream variant. Before proposing any optimization, run scripts/bench profile effectStream (once the bench exists as a @Benchmark) to identify whether the cost is in:

This is a JFR investigation task, not a direct implementation.

4. effect-pure JS 3× faster than interp (minor)

effect-pure: interp 0.016 ms vs JS 0.005 ms. JS compiles the inner while i < 10000 loop to native V8 bytecode. Interp routes through runLogger handler overhead. Low priority — absolute numbers are small. Worth noting but not worth a dedicated item.


Action items

SlugStatusBench target
phase-d-patternmatch-fused-foreachopen in WORK_QUEUEpatternMatchWide 1.41 → ~0.3 ms
jit-tuple-concat-hoistNEWtupleMonoid 0.212 → ~0.14 ms
effect-stream-jfrNEWinvestigation only
direct-style-eval-specopen, deferredlong-term Direction C