Sscalascript.dev

Interpreter perf findings — 2026-06-02 JFR survey

A JFR-profile pass over the heaviest InterpreterBench benchmarks after the Phase C bytecode JIT scorecard landed, intended to surface the next optimization wave's targets before any code is written.

Profiling commands (all from scripts/bench profile <pat>):

Each :gc.alloc.rate.norm figure is bytes per op (the whole script run); JFR allocation samples were aggregated by objectClass, weight is sampler-weighted KB (not actual bytes — proportional, not absolute).

Findings ranked by lever

1. BytecodeJit.withInterp leaked ThreadLocalMap.Entry per call

Top sampled allocator on recursiveEval before the fix: 17 samples, weight ~10 MB/op (sampler-weighted). Stack:

ThreadLocal.set
BytecodeJit.withInterp
JitRuntime.invokeBytecode1

Cause: withInterp used interpTls.remove() when prev == null, which deletes the per-thread ThreadLocalMap.Entry; the next outer call's interpTls.set(interp) then re-allocates a fresh Entry. Net: one Entry allocation per outer bytecode-JIT invocation, ×millions per script.

Fix shipped this session (commit deferred until accompanying report lands): replace the if prev == null then remove() else set(prev) finally with unconditional interpTls.set(prev). Setting the slot to null keeps the Entry intact; readGlobalLong/Double already guards on interp == null. Per-thread memory cost: ~32 bytes that never shrink — negligible.

Result: ThreadLocalMap$Entry drops out of the top-10 allocators on recursiveEval (verified by re-profile after fix). Wall-clock unchanged within JMH noise — the alloc pressure was modest relative to total allocator bandwidth (~25 MB/sec on recursiveEval). Cleanup, not a speedup.

2. MethodHandle.invoke auto-boxes the Long return on every JIT call

Second top allocator on recursiveEval (post-fix top java.lang.Long samples remain at ~6, pre-fix were 14). Stack:

sun.invoke.util.ValueConversions.boxLong
java.lang.Long.valueOf
JitRuntime$.$anonfun$2(BytecodeJit$Result, Object)
BytecodeJit$.withInterp
JitRuntime$.invokeBytecode1

Cause: JitRuntime.invokeBytecode1/2 uses r.mh.invoke(arg) rather than r.mh.invokeExact(arg). invoke performs signature adaptation including primitive longjava.lang.Long autoboxing on the return path. invokeExact would emit no box but requires the call-site type ascription to exactly match the MH's MethodType.

Why it's deferred: BytecodeJit's Result has 4–5 distinct MH signatures by (paramIsRef, resultIsDouble):

  1. (long) long — int param, int result
  2. (Object) long — ref param, int result
  3. (double) double — Double-typed body
  4. (Object) double — ref param, double-typed body
  5. (long, long) long, (long, Object) long, etc. — 2-arg variants

To use invokeExact, invokeBytecode1/2 must dispatch on these cases (branching is cheap; JIT compiles each branch as a typed call site). Risk: a wrong type ascription throws WrongMethodTypeException at runtime — the test suite catches this, but a single missed combination would land a regression.

Recommended next-session work (phase-c-bytecode-invokeExact): specialize invokeBytecode1/2 into branches by (isRef, isDouble), calling mh.invokeExact(...) per signature. Skip the marshalBytecode boxing for the primitive cases (read Value.IntV.x: Long or Value.DoubleV.x: Double directly and pass primitively). The wrapBytecodeResult side becomes Computation.pureIntV(out) / Value.doubleV(out) with out typed as Long / Double. Expected win: modest wall-clock (autoboxing is HotSpot-optimized for hot integers via the long cache; expect 1–5% on recursiveEval-class shapes) but meaningful alloc-rate reduction. JFR-profile after to verify java.lang.Long drops out of the top samples.

3. patternMatchHeavy confirms phase-d-patternmatch-double-slot

Top-2 samples on patternMatchHeavy: **Computation$Pure (125 samples)

4.56 MB/op allocation. Other classes are noise (< 1% combined).

This is the per-iter interp.globals(accName) = Value.doubleV(acc) writeback inside FastTier.tryDoubleAccumForeach — exactly what WORK_QUEUE.md's phase-d-patternmatch-double-slot spec predicted (3.2 MB / op for the 100K outer iters). The DoubleV+Pure pair is the writeback allocation plus the surrounding Computation.Pure wrapper from the foreach driver. Removing the per-iter writeback (the slot-Tls trick in the spec) closes that 3.2 MB.

Recommendation: do phase-d-patternmatch-double-slot per its 3-commit-split protocol in a fresh agent session. The lever is alloc-rate, not wall-clock — JFR-profile after to verify the DoubleV+Pure pair drops proportionally. Wall-clock impact estimated 1–5% (per the WORK_QUEUE note); if the JFR drop is real and wall-clock is unchanged, the writeback allocations are GC-bandwidth absorbing rather than cycle-burning.

4. recursiveEval confirms phase-d-instancev-array-repr

Map$Map1 + Map$Map2 together: ~9.5 MB sampled weight on recursiveEval (out of ~78 MB total — ~12%). These are the InstanceV.fields: Map[String, Value] HashMaps lookup-allocated by every inst.fields().apply("name") in the generated Java match arms.

The phase-d-instancev-array-repr work currently running in the parallel agent's worktree targets exactly this — switching to positional Array[Value] storage eliminates the per-lookup HashMap walk and (on construction) the Map allocations themselves. JFR-profile after that lands should show Map$Map1/Map$Map2 drop out of recursiveEval's top-10, and instanceFieldAccess's 131 MB/op should fall sharply (this bench is the dedicated floor for that work — currently ~2.6 µs/iter for two HashMap field reads + a Term.Match dispatch).

5. recursiveEval's $colon$colon (List cons cells) — 17 MB sampled

Top sampled allocator post-fix on recursiveEval. Likely arg-list packing in CallRuntime/tcoTrampoline (every self-recursive call builds a List[Value] for the arg snapshot). Profiling didn't pinpoint a single hot caller — distributed across the runtime.

Possible lever (recursive-arg-list-arraywise): switch the tcoTrampoline arg snapshot from List[Value] to Array[Value]. Not trivial — the trampoline shape is load-bearing across multiple runtimes (TcoRuntime, CallRuntime, JitRuntime.tryBytecodeList). Defer to a multi-day project with its own JFR-baseline.

6. mapForeach: 43.6 MB/op on (k,v) => 2-arg closure path

Confirms the mapForeach bench's stated purpose — DispatchRuntime's Map.foreach callEntry path is not covered by FastTier. Each iteration allocates an entry tuple and dispatches through a generic 2-arg closure.

Recommendation: fasttier-2arg-callentry — extend FastTier with a tryDoubleAccumForeachMap / tryLongAccumForeachMap that recognizes a 2-arg foreach closure (the LApply2 shape) and bypasses the entry materialization. Floor bench is mapForeach (~1.07 µs/callEntry, 87 bytes/callEntry). Single-module change in FastTier + a hookup in DispatchRuntime.dispatchMap "foreach" case.

Non-findings (i.e. confirmed already-fast paths)

KB/op) and stable. The Phase C BytecodeJit + TCO loop emission paths are the dominant cost. Don't optimize.

cost is dominated by the HashMap.get("mul") lookup chain (one per fib base case ×~1.3M calls) and the autoboxing from finding #2. Both benches reach recursionFib-level perf if globals are pre-cached at MH-build time, but that requires a globals-mutation invalidation protocol — non-trivial. Defer.

emission gets us to JVM-codegen parity. Done.

walkArm coverage on recursiveEval

The phase-c-bytecode-wider-match work item flags shapes that currently bail in walkArm: guards, literal patterns, Pat.Bind, Pat.Alternative, nested matches. recursiveEval's def eval(e: Expr): Int = e match … has three flat arms over Pat.Extract(ctor, Pat.Var bindings) only — fully covered by walkArm. No bail on the hot path.

Conclusion: phase-c-bytecode-wider-match won't help recursiveEval. It remains valid as cleanup work, to be done when a specific bench surfaces a bailing shape. None currently does.

Summary — next optimization wave priorities

RankItemWhereExpected lever
1phase-d-instancev-array-reprparallel agentLarge — recursiveEval HashMap field reads
2phase-c-bytecode-invokeExactfresh sessionModest cycles + alloc on all Long/Double-result JIT calls
3phase-d-patternmatch-double-slotfresh sessionModest — patternMatchHeavy 3.2 MB/op DoubleV writeback
4fasttier-2arg-callentrysingle sessionModest — mapForeach 43 MB/op alloc
5recursive-arg-list-arraywisemulti-dayModest — recursive call arg snapshots

Deferred / no-bench: phase-c-bytecode-wider-match, phase-c-bytecode-mutual, MH globals precaching.

Methodology used

Per feedback_cross_module_commit_safety.md rule 1 ("JFR-profile-first") this session ran scripts/bench profile against each candidate, then cross-checked sampled allocator weights against deterministic -prof gc alloc.rate.norm. The two should move together — if a sampled weight changes and alloc.rate.norm doesn't, treat the sample as mis-attribution.

The withInterp fix in this session was the only fix landed: the alloc samples showed the leak unambiguously, the fix was a single line, and the post-fix profile confirmed ThreadLocalMap$Entry left the top-10. Everything else surfaced is documented above for a fresh-context session.


Session 2026-06-02 wins (post-original-survey)

Following the survey above, a second session landed 12+ commits on top of the JFR findings. Each was JFR-validated or A/B-proven.

Shipped commits

commitscopebench δ
e17c048aDouble-globals reads via readGlobalDouble (Phase C extension)recursionFibMulD parity with Int-globals shape (~6 ms)
46e8a2d2BytecodeJit.withInterp TLS-Entry hygiene + JFR profile surveyalloc cleanup; next-wave roadmap
280ae07fFastTier 2-arg foreach Map fast pathmapForeach 532 → 110 ms (4.8×)
88f2179fPure-const cache for Term.Tuple + Term.ApplyInfixtupleMonoid 415 → 132 ms (3.2×)
5f1b4ec1Pure-RHS hoist out of long-while looptupleMonoid 132 → 0.2 ms (660×)
262ac02dforeach-hoist Set receiverpatternMatchSet 117 → 8 ms (14.6×)
b0846e41foreach-hoist hierarchy (List/Set/Map × Long/Double)mapForeach 117 → 2.85 ms (41×), patternMatchWide 77 → 21 ms (3.7×)
4a983de6foreach-hoist (parallel agent)patternMatchHeavy 113 → 10 ms (11×)
7e7ebb71while-loop BytecodeJIT (parallel agent)arithLoop 2.86 → 0.28 ms (10.1×, JVM parity)
c2986e33phase-d-patternmatch-double-slot (parallel agent)patternMatchHeavy alloc 4.9 MB/op → 1.7 MB/op (−65%)
d48a2259Long + Map slot bypasspatternMatchWide 38 → 29 ms (−24%), mapForeach 4.7 → 3.95 ms (−17%)
14c23556LongEnvFn1/LongEnvFn2 traits — eliminate Long boxing in LApply/LApply2pureCallSum alloc 24 MB/op → 34 KB/op (−99.9%); pureCallSum2 48 MB/op → 115 KB/op (−99.8%); wall-clock pureCallSum −28%, pureCallSum2 −32%
54a65870Computation.purify reuses cached Pure wrappersrecursiveEval Pure samples 34 → 21 (−38%)
471b38d1invokeExact via typed JitInterfaces (parallel agent)unboxed dispatch for bytecode-jitted fns

Final bench state (2026-06-02, ms/op, tight n=30)

benchms/opstatus
arithLoop0.28JVM parity (while-JIT)
effectPure0.04floor
recursionTco0.034JVM parity (TCO loop)
tupleMonoid0.20floor (RHS hoist)
recursionFib1.20beats JVM-codegen (Phase C)
recursionFibD1.45floor
mapForeach3.31near floor (foreach-hoist + Map slot)
recursionFibMul5.85bound (Int globals HashMap)
recursionFibMulD6.0bound (Double globals)
patternMatchHeavy7.82foreach-hoist + double-slot
patternMatchSet8.0foreach-hoist Set
pureCallSum13per-iter eval dispatch — A.3 target
recursiveEval13HashMap field reads — B target
recursiveEvalMixed13.6same — B target
pureCallSum214per-iter eval dispatch — A.3 target
instanceFieldAccess15.6LMatch ceiling (HashMap residual)
patternMatchWide20.812-arm dispatch + Long slot

Strategic next phase

After this session, the cumulative wins exhausted every micro-opt the JFR survey suggested. The remaining headroom is in three strategic directions captured in ~/.discovering-knuth.md and specs/instancev-array-repr-spec.md:

proven pattern). WORK_QUEUE.md items phase-c-bytecode-block-single, phase-c-bytecode-if-in-while, phase-c-bytecode-pure-fn-call, phase-c-bytecode-foreach-static, phase-c-bytecode-block-multistat.

5 sub-phases). WORK_QUEUE.md items phase-d-instancev-array-repr-infra through …-flag-flip.

deferred until A+B stable). Blocked on direct-style-eval-spec.

Each direction's verification protocol carries forward from this survey: JFR-profile target bench → cross-check gc.alloc.rate.norm → A/B against pre-commit baseline → verify the target allocator class drops out of the top-10 samples.


2026-06-03 — Dual-bank LExpr + JIT-ability lint (Phase E)

After the WORK_QUEUE.md phase-c-bytecode-int-tag landed and Direction B's first commit (2df35b4b) flipped InstanceV array repr, a fresh JFR survey on the canonical while i < N do total = total + f(item) shape (where item is a val InstanceV, f JIT-compilable) found 400+ samples in evalCore — the JIT was firing for f's body but the OUTER while loop tree-walked because tryLongWhileAssign.compileExpr couldn't lower a ref-typed argument into the LExpr fold. compileExpr(Term.Name) does slotOf(name) < 0 → null; the bail propagated upward and the whole LExpr collapsed.

Root-cause investigation

The LApplyObjRef LExpr node (commit 13af281f, session 2026-06-02) patched ONE specific shape: f(item) with item a val InstanceV. But the fix only covered the leaf case. Any composite shape like f(g(x)), f(item.field), or g(scale, item) fell through and tree-walked again.

JFR-validated: the recursiveEvalMixed bench (def gEval(scale: Int, e: Expr): Int = e match { … }) showed 77 samples in the JIT-compiled GenJit_gEval.gEval AND 87 samples in evalCore — meaning JIT fired but the outer total + gEval(3, tree) loop was tree-walked because compileSlotLongFn2 couldn't fold a match-bodied function.

Structural fix: dual-bank LExpr

Adopted SscVm.exec(stack, refStack)'s proven dual-bank pattern: LExpr.eval(slots: Array[Long], refs: Array[AnyRef]). New parallel LRefExpr hierarchy for ref-returning subterms.

Five commits (e8c2f64b / f7fc2b34 / 96ab9004 / 0a43e4b1 / 1bd98d51) land Phase 1 (dual-bank infra + LApplyR1 + LRefFieldGet), Phase 2 (JitLint analyser + CLI), and Phase 1b (2-arg ref-mixed dispatch).

Bench impact

BenchPre-sessionPost Phase EΔ
nestedMatchExpr2700 ms8.5 ms318×
refFieldArg (NEW)~2700 ms (synthetic baseline)14 ms~170×
recursionFibMul5.96 ms1.29 ms4.6×
recursiveEvalMixed8.0 ms3.67 ms2.2×
recursiveEval7.5 ms3.7 ms2.0×
instanceFieldAccess16.5 ms8.4 ms-49%

JFR signal post-fix

Repeat JFR on recursiveEvalMixed after Phase 1b shows:

for the outer top-level Defn.Val tree = build(8), which doesn't go through tryLongWhileAssign at all).

the hot loop's actual dispatcher.

JIT-ability lint as a structural diagnostic

Phase 2's JitLint walks interp.globals and asks the live JitBackend.default.tryCompile cache for each Defn.Def. The CLI (ssc lint-jit <file>) emits human-readable reports with suggested-fix text per JitBailReason:

def withGuard (line 3)  [will NOT JIT]
  - function body has `case x if cond => …` — pattern guards aren't
    lowered into the generated switch/dispatch
      fix: move the guard into the arm body

The analyser uses the live backend.tryCompile result as ground truth, so the lint and the runtime can never drift silently. The structural classifier is in JitPredicates.classifyBailReasons (shared between backends) and is exposed as the JitBackend.classifyBailReasons SPI method, which backends may override to add backend-specific reasons. Categories: TryCatch, EffectReturn, UsingParams, VarargParam, PatternGuard, NonAdtScrutinee, NonExtractPattern, MixedReturnType, plus UnknownShape for cliffs the AST walk can't classify.

ssc lint-jit --backend asm targets AsmJitBackend specifically; ssc lint-jit --backend both runs both backends in a single pass and shows a per-function side-by-side diff ([JAVAC OK] [ASM FAIL]), which is the primary tool for surfacing ASM parity regressions without running a full benchmark.

Methodology lessons

  1. Silent bails are the worst class of regression. They show up

as "perf cliff" reports from users; no exception, no log line. The structural dual-bank fix closes whole categories of bails at once instead of chasing them one shape at a time.

  1. JFR evalCore samples are the gold direction-finder. Any time

JIT was supposed to fire and the bench is slower than expected, the bail is upstream of the JIT — climb the call stack until you find the LExpr that returned null.

  1. Lint must call the same predicates the runtime calls. The

tradition of "static analyser + dynamic runtime" inevitably drifts; sharing the predicate function pointer guarantees they never disagree.

The remaining structural items (LApplyR1ToRef, LRefMatch, pattern guards, predicate extraction) are tracked in WORK_QUEUE.md under the "Dual-bank LExpr roadmap" section.