Sscalascript.dev

Benchmarks

Single source of truth for every benchmark in this repo: what each one measures, when to use it, and the one-line command that runs it.

Everything goes through scripts/bench. Run scripts/bench help for the full command list; scripts/bench list queries both the interpreter and compiler benchmark projects and enumerates every available @Benchmark method without duplicates.

One command per case

You want to …Command
Run all interpreter benches (Javac JIT)scripts/bench interp
Run one interpreter benchscripts/bench interp recursionFib
Run all benches with AsmJitBackendscripts/bench asm
Run one bench with AsmJitBackendscripts/bench asm recursionFib
Wall-clock all backends (ssc, ssc-asm, jvm, js)./bench.sh
Wall-clock ASM backend only./bench.sh --backend ssc-asm
Wall-clock v2 VM/source backendsscripts/bench v2-backends [workload]
Wall-clock v2 VM/JVM-bytecode lanescripts/bench v2-bytecode [workload]
Compare interp vs JS vs JVMscripts/bench cross
Measure codegen timescripts/bench gen
Measure compile pipelinescripts/bench compile
Measure one compiler phasescripts/bench compile parseActors
Prove the off-mode still worksscripts/bench off recursionFib
Profile (alloc + GC) one interpreter benchscripts/bench profile recursionFib
Profile (alloc + GC) one compiler benchscripts/bench compile-profile parseActors
Wall-clock vs Scala/Nodescripts/bench wall
Verify bench infra is alivescripts/bench smoke
List every available benchscripts/bench list

Default JMH config is -wi 3 -i 5 -f 1 (~25 s per bench). Override with BENCH_WI, BENCH_MI, BENCH_F env vars when you want tighter A/B:

BENCH_F=2 scripts/bench interp recursionFib   # two forks for a stable A/B
BENCH_WI=1 BENCH_MI=1 scripts/bench interp    # quick smoke across all benches

What lives where

JMH (the sbt-driven microbenchmarks)

FileClassPurpose
runtime/backend/interpreter-bench/.../InterpreterBench.scalaInterpreterBenchInterpreter hot-path microbenchmarks (arith, recursion, pattern match, foreach, tuple, effects).
runtime/backend/interpreter-bench/.../RuntimeBench.scalaRuntimeBenchCross-backend EXECUTION speed: interp_X vs js_X (Node subprocess) vs jvm_X (standalone JAR).
runtime/backend/interpreter-bench/.../CrossBackendBench.scalaCrossBackendBenchCross-backend CODEGEN time: jvmGen_X / jsGen_X (no subprocess; measures the backend itself, not the output).
lang/core-bench/.../CompilerBench.scalaParserBench, TyperBench, UnifyBench, SsccFormatCompilerBenchParser, typer, unifier, and .sscc format benches — orthogonal to interpreter perf.

If you are micro-optimising the interpreter, almost everything you need is in InterpreterBench. The cross-backend benches are for periodic "how far are we from native?" checkpoints.

Wall-clock (subprocess-based)

PathPurposeHow to run
bench/corpus/*.ssc + bench/run.scWorkload sweep through the ssc CLI; checked-in summaries land in bench/BASELINE.md.scripts/runtime-bench.sh --baseline
bench/corpus/*.ssc + bench/run.sc --v2-backendsSame corpus shape, limited to v2 VM, v2 JVM source backend, and v2 Rust source backend. Use this for Phase-3 separate-backend measurement baselines; unsupported rows remain n/a.scripts/bench v2-backends [workload] or ./bench.sh --v2-backends ...
bench/corpus/*.ssc + bench/run.sc --v2-bytecodeSame corpus shape, limited to the v2 VM and the in-process v2 JVM bytecode lane. Use this for Phase-4 bytecode-lane A/B work.scripts/bench v2-bytecode [workload]
tests/bench/{fib,sum,list-ops}.{ssc,scala,js}Cross-language wall-clock: same workload in ScalaScript / Scala-direct / Node.scripts/bench wall (alias for scala-cli tests/bench/run.sc)

Use the wall-clock benches when you need cold-JVM, fresh-process numbers (JMH only measures the warmed-up steady state).

Anti-fold strategy (keeping the corpus honest across backends)

A constant-folding compiler can replace a whole benchmark loop with a single constant load, reporting a dishonest ~0 ms. The corpus defends against this uniformly but minimally:

LCG seed and consumes every result, so no backend can derive a closed form (see docs/bench/corpus-antifold.md).

aggressive than HotSpot/V8 (it solves affine/polynomial recurrences symbolically — even opaque inputs don't stop it). So bench/run.sc adds a single std::hint::black_box(...) on the first loop-carried reassignment of each emitted pub fn. One barrier is necessary and sufficient: measured on sumTco(100000,0) at -O3, 0 barriers folds to 0.000001 ms, one barrier on the carried accumulator gives an honest 0.10 ms, and the loop's time scales linearly with the trip count. The earlier harness wrapped every assignment (3–4 barriers/iter), which inflated rust loop cells 3–4× and made the column look slower than codegen-equal jvm; that redundant tax is gone (recursion-tco 0.34 → 0.025 ms, now ≈ jvm). A single irreducible barrier still taxes rust on loops jvm folds for free — that asymmetry is real, not a harness defect.

Adding a new workload to the dashboard

The wall-clock dashboard (bench/BASELINE.md) is fed by bench/corpus/*.ssc, auto-discovered by bench/run.sc — there is no registry to edit. To add one:

  1. Create bench/corpus/<name>.ssc. It is a normal .ssc markdown doc: a

title, a short paragraph saying what the workload measures and on which backends it is supported, then one scalascript ` fence defining the entry point. The harness (ssc bench --machine) calls one of:

seed; carry it through a generator so nothing constant-folds (see below).

Return a Long and make the loop consume every result into it, or a backend may delete the loop. Put any fixtures (a top-level val, a case class) above the def, inside the same fence.

  1. Keep it honest (anti-fold). Carry a per-iteration generator and feed its

output into the work:

(relies on 64-bit wrap; fine for interp/JVM/Rust).

escape a range): use MINSTD s = (s * 48271L) % 2147483647L (start (seed % 2147483646L) + 1L). Its product stays below 2⁵³, so it is exact in JS's f64 Number; a 64-bit-wrapping LCG overflows to NaN on JS and an arr(NaN) read returns undefined → crash → a misleading n/a. See vector-index.ssc / array-update.ssc for the pattern.

  1. Verify on one backend (fast): ./bench.sh --backend ssc <name>. A backend

that can't run the workload (e.g. Rust has no Vector/Array/LazyList, JS has no LazyList.from) reports n/a — that is the honest support signal, not a failure. Run the other backends with --backend jvm|js|rust.

  1. Publish numbers. ./bench.sh --baseline regenerates the whole corpus

table into bench/BASELINE.md (needs bin/ssc staged via sbt installBin, plus node / scala-cli / a Rust toolchain for those columns). To add just your rows without a full multi-backend sweep, run the single-backend commands above and paste a focused table into bench/BASELINE.md (as the Collection-type microbenchmarks section does).

The matching JMH micro (InterpreterBench/RuntimeBench) is optional and separate — add a method there only if you want a warmed-up steady-state number; note the scale caveat below.

⚠️ JMH and the corpus measure different scales under the same name

Several JMH methods in InterpreterBench share a name with a bench/corpus/*.ssc workload but run a different amount of work, so their absolute numbers are not directly comparable:

NameJMH InterpreterBenchbench/corpus/*.ssc
typeclassFoldcombineAll(List(1,2,3,4)) once (a micro-call)300 × combineAll(List(1..10)) (a macro loop)
stringSplitthe full 300-iteration parse-and-sum loopsame 300-iteration loop

So a reader comparing JMH typeclassFold ≈ 0.009 ms to the cross-backend table's typeclass-fold ≈ 1.8 ms is comparing one call to three thousand — both are honest, they just measure different scales. When a JMH method is a deliberate micro-call, prefer a …Macro sibling that mirrors the corpus loop for A/B work (e.g. typeclassFoldMacro was added for exactly this reason). If you add a JMH method that diverges from its corpus namesake, either give it a Macro/Micro suffix or note the divergence in its source comment.

What each interpreter bench is for

Listed in InterpreterBench.scala order. The name pattern is what you pass to scripts/bench interp <pattern> (regex; matched against the full method name).

BenchWhat it stresses
arithLoopTop-level while + arithmetic; no function calls. The simplest "is the loop interpreter alive?" target.
recursionFibClassic fib(30) — recursive int arithmetic. Today's main BytecodeJit target.
recursionFibDSame shape, Double params/return — exercises the BytecodeJit double subset.
recursionFibMulfib whose base case multiplies by a top-level val mul = 7 — exercises the free-name (global) read path.
recursionTcoTail-recursive sumTco — exercises the BytecodeJit while-loop emission for self-tail calls.
recursiveEvalRecursive Expr evaluator over Add/Mul/Num — the canonical ADT-match workload. BytecodeJit ADT-match target.
recursiveEvalMixedSame evaluator with a mixed (scale: Int, e: Expr) signature — tests per-param Object/long marshalling.
patternMatchHeavy3-arm Shape match over a List. Foreach + match + arithmetic.
patternMatchWide12-arm pure-int match — exercises the wide-arm dispatch table without Double noise.
patternMatchSetSame as Heavy but over a Set receiver — exercises dispatchSet.foreach.
pureCallSum1-param pure f(x) = x + 1 in a tight 1M loop — exercises the Tier-2b pure-call path.
pureCallSum22-param parallel: g(x, y) = x + y — exercises LApply2 raw-Long inlining.
tupleMonoid(1, 2) ++ (3, 4) in a loop — tuple-concat intrinsic.
effectPurerunLogger { compute(10000) } — a PURE arithmetic loop inside an effect context. Baseline for "effect-typed but effect-free" code. Measured ~0.006 ms — already near-free (the body optimises), which is itself the evidence that pure-in-effect overhead is small (cf. direct-style-eval §10).
effectOneShotA custom effect Bump performed N times, handler resumes exactly once (resume(1)). Isolates the per-perform dispatch cost of the one-shot regime (~µs/perform — the Perform→handle→resume→continuation trampoline, NOT Pure-wrapping). This is the regime that could in principle use the direct-style EffectPerform fast-path.
effectMultiShotmulti effect NonDet with 4 choose-points of 4 (256 branches); handler resumes once per option. Isolates the multi-shot trampoline — which REQUIRES the re-callable monadic continuation and therefore cannot use the exception model (direct-style-eval §10.1). Watch this stay correct + bounded as the effect runtime evolves. Both effect benches use interp.run (not runSections) so multiShotEffects is populated by EffectAnalysis.
instanceFieldAccessInline while: total += p match { case Pair(a,b) => a+b }. Post-LMatch (2026-06-02): whole loop in Long-slot array, ~16.6 ms/op (1M iters, 162× vs baseline 2690 ms). Remaining cost: HashMap reads inside CompiledMatch.runValueLong.
mapForeachMap(...).foreach((k, v) => …) — 2-arg callEntry path; not yet FastTier-covered.
option-chain / either-chain / hof-pipeline / range-sumWarmed HOF method-chain call targets. Kebab names are scripts/bench aliases for JMH methods optionChain, eitherChain, hofPipeline, and rangeSum.
typeclass-foldWarmed context-bound typeclass fold target. Alias for JMH method typeclassFold; classified separately from the monomorphic standard-library HOF receiver path.

Type-level lambdas — capability tracker (not a perf bench)

Type lambdas are surface-only (types are erased at runtime in this interpreter-first language), so there is nothing to micro-benchmark. The equivalent "see what works / where the progress is" artifact is lang/core/.../typer/TypeLambdaProgressTest.scala: [now] tests pin the current parser/SType behaviour; [target] tests are pending and flip to passing as type-lambda-p2 lands. The passing-vs-pending split is the progress dashboard (run sbt "core/testOnly scalascript.typer.TypeLambdaProgressTest"). A real parse/typecheck-throughput bench is only worth adding once the surface parses.

JIT backend selector

The bytecode JIT has two implementations of the JitBackend SPI:

SSC_JIT_BACKEND=BackendNotes
javac (default)JavacJitBackendAST → Java source → javax.tools.JavaCompiler → bytecode. Requires JDK (not JRE). ~50–100 ms cold-start per function.
asmAsmJitBackendAST → JVM bytecode directly via ASM 9.7. ~1–3 ms cold-start; no javax.tools dep. Steady-state performance identical.

To A/B the two backends:

scripts/bench interp recursionFib   # Javac (default)
scripts/bench asm    recursionFib   # ASM
scripts/bench asm                   # all benches with ASM backend

Expected: numbers within ±5% at steady state. Cold-start (first iter, low warmup) should show ASM 30–100 ms faster per function.

Off-mode A/B (proving fall-backs work)

The interpreter has two opt-out flags:

FlagEffect
SSC_JIT_BYTECODE=off / -Dssc.jit.bytecode=offDisables the bytecode JIT (both backends). Hot recursion falls back to SscVm.exec.
SSC_FASTTIER=off / -Dssc.fasttier=offDisables FastTier (foreach-accumulator / pure-call shortcuts) and the algebraic loop eliminators (invariant-call memoise + Gauss closed-form). Falls back to the general dispatcher. Use this to get an honest un-folded baseline for closed-form-able workloads (e.g. pureCallSum 0.003 ms on ↔ ~12 ms off).
SSC_JIT=off / -Dssc.jit=offDisables SscVm.exec as well — pure tree-walker.
scripts/bench off recursionFib    # both BYTECODE + FASTTIER off

Expected result today: recursionFib 1.2 ms (on) ↔ ~28 ms (off). A roughly 24× gap; anything closer means the bytecode JIT isn't actually firing.

For pure-tree-walk numbers (no SscVm.exec either):

SSC_JIT=off scripts/bench off recursionFib

Profiling

scripts/bench profile recursionFib
scripts/bench compile-profile parseActors

Both routes add -prof gc (deterministic alloc rate / norm) and -prof jfr:configName=profile (sampled allocation events + CPU) to the selected JMH project. JFR output lands in that project's generated <Class>.<method>-<Mode>/profile.jfr; open it with jfr view, jfr print, or JDK Mission Control.

For interpreting alloc samples: cross-check jdk.ObjectAllocationSample counts against gc.alloc.rate.norm — a sampler can over-attribute to a hot leaf, so deterministic numbers are the tie-breaker.

Bytecode size — the perf defect no profiler shows you

scripts/bytecode-size-census <classes-dir|jar> [threshold]   # default threshold 8000
tests/e2e/v2-jit-size.sh --self-test                         # the gate, plus both its verdicts

HotSpot ships -XX:+DontCompileHugeMethods on by default: a method whose bytecode exceeds -XX:HugeMethodLimit (8000) is never compiled by C1 or C2 and runs in the bytecode interpreter for the life of the process. There is no warning, no log line, and no behavioural difference — the method is simply 10-100× slower than the identical logic split across smaller methods, forever.

A JFR/JMH profile does not point at this. It shows the huge method as hot, which is what you already expected of a dispatch point; nothing in the sampled data says "and this one is interpreted". The census is what tells you, and it takes seconds.

Measured 2026-07-28: ssc.Prims's __method__ arm — the single dispatch point for every non-arithmetic operation in every ScalaScript program — was 49,384 bytecodes. Splitting it into sequential sub-8000 parts made the v2 runtime 2.4-10.8× faster across the bench corpus while the pure-arithmetic workloads (which never reach it) did not move. See BUGS.md v2-method-dispatch-never-jits and specs/v2-runtime-perf-vs-v1.md.

Rule of thumb: run the census whenever a big match grows, and treat anything ≥6000 as a method that needs splitting soon rather than a number to watch.

Comparing v1 against v2 in one table

./bench.sh --backends ssc,v2,v2-bytecode --reps 30 <workloads…>

--backends exists because the canned --v2-backends / --v2-bytecode modes cannot express a mixed v1+v2 column set, and cross-tier claims are only meaningful when every column was measured under one machine state. Two things to keep straight when reading the result:

--interpret reference lane and is slower by design. A "v2 is slow" claim built on the v2 column alone is measuring the reference lane.

drift between two runs bounds what counts as signal (typically ≤1.3×).

Measuring the v2 JIT — warm-up must outlast tier-up

SSC_V2_JIT=on compiles hot sites while the program runs (specs/v2-wide-jit.md). The bench times per-iteration work, so a short warm-up leaves compilation happening inside the measured window and the JIT reads slower than it is. The AOT lane has no such phase — it enters measurement already compiled — which makes a naive JIT-vs-AOT table an unfair comparison in the JIT's disfavour.

Use this for any steady-state JIT number:

SSC_V2_JIT=on SSC_V2_JIT_SYNC=1 \
  ssc-tools --backend v2 bench --machine --warmup-time 3000 --reps 8 <workload>

SSC_V2_JIT_SYNC=1 makes tier-up synchronous, so it finishes deterministically during warm-up instead of racing the measurement. Measured on float-loop, three runs each:

configurationrunsspread
async, --warmup-time 400 (the naive default)1.38 / 3.65 / 1.932.6×
async, --warmup-time 30002.14 / 1.69 / 1.251.7×
sync, --warmup-time 30001.40 / 1.24 / 1.291.13×
AOT lane, --warmup-time 30001.16 / 1.90 / 0.832.3×

The recipe is worth it for the variance, not just the mean: it is the only configuration here that reproduces, on a host where even the AOT lane swings 2.3×.

What this invalidates, stated so it is not repeated: a float-loop gap of 1.94× measured at --warmup-time 400 looked like a JIT code deficit and was not — in steady state the two lanes are indistinguishable (1.29 vs 1.16, inside AOT's own spread), and a direct 600 M-iteration probe puts them at parity with both spending ~60 % of samples in the same generated method.

Smoke + manifest

(InterpreterBench.arithLoop) and writes raw JSON to bench/jmh-smoke.json. The point is "did the JMH plumbing break?", not any perf claim.

interpreter — same intent, but exercises the CLI's bench subcommand.

policy reads from. Update it if you add a smoke target.

Default gate policy: informational. Numbers only become a non-zero exit code if the caller explicitly passes --require-target --target-ms N.

Adding a new benchmark

  1. Add a private val mod<Name>: Module = src("""…""") block at the top

of InterpreterBench.scala with a one-line comment explaining WHAT the workload is meant to stress (the WHY, not the WHAT of the code).

  1. Add a @Benchmark def <name>(): Unit = Interpreter(devNull).runSections(mod<Name>).
  2. Smoke-run it with BENCH_WI=1 BENCH_MI=1 scripts/bench interp <name>

and confirm the result looks sensible.

  1. Add a one-line row to the table above.
  2. If the new bench is going to appear in the smoke path, update

bench/perf-manifest.yaml.

Avoid:

constructors) — the bench harness skips that init, so use .toSet instead. Map(...) is fine because it routes through intrinsics/Core.

budget for routine A/B work.

Gotcha: stale incremental state → NoClassDefFoundError at bench init

If every interp bench suddenly fails at _jmh_tryInit with java.lang.NoClassDefFoundError: org/commonmark/ext/gfm/tables/TableCell (or a similar transitive class), it is stale incremental build state, not a missing dependency — lang/core correctly declares commonmark-ext-gfm-tables (the parser registers the GFM-tables extension on every parse). It shows up after heavy parallel-branch churn / interleaved cli/assembly builds leave the interpreterBench JMH-fork classpath inconsistent. Fix:

sbt "interpreterBench/clean" "interpreterBench/Jmh/compile"

then re-run. No source/dependency change is needed.

bench/BASELINE.md, bench/BUNDLE_SIZES.md).

numbers; the bench was removed 2026-06-02 as it was superseded by InterpreterBench + the off-mode flags).

Phase D FastTier).