Sscalascript.dev

Performance & Memory — v1.61

Status: spec landed 2026-05-28. All v1.61 themes shipped (under different milestone names) by 2026-06-02. See §3 below for the original roadmap; see §3b for the what-shipped summary and current benchmark numbers.

Companion docs:


1. Goals

GoalMetricTarget
Interpreter throughputwall-clock on arith-loop workload3–10× vs baseline
JS bundle size (trivial programs)ungzipped Hello World< 10 KB (from ~100 KB)
JS bundle size (non-trivial programs)median corpus≥ 50% smaller
JVM arithmetic (effectful code)_binOp dispatches / op0 (direct)
Allocation rateallocations / eval of arith-loop≥ 50% lower
Artifact size.scim / .scjvm files≥ 5× smaller

No optimization PR is merged without before/after benchmark numbers from the v1.61.0 harness.


2. Benchmark infrastructure (bench/)

Running

sbt cli/stage          # build the ssc CLI first
./bench.sh             # wall-clock all workloads (2 warmup + 7 reps)
./bench.sh --baseline  # same, writes bench/BASELINE.md
./bench.sh arith-loop recursion-fib  # specific workloads

# Quick non-blocking smoke checks
ssc bench --smoke
ssc bench --smoke --target-ms 250 --require-target
scripts/perf-smoke.sh --jmh

# JMH microbenchmarks (interpreter internals)
sbt "interpreterBench/Jmh/run"
sbt "interpreterBench/Jmh/run -i 5 -wi 3 -f 1 .*arithLoop.*"
sbt "interpreterBench/Jmh/run -rff bench/jmh-results.json -rf json"

# Bundle size tracking
./scripts/bundle-size.sh

Corpus workloads (bench/corpus/)

Workflow manifest and baseline policy: bench/perf-manifest.yaml and bench/README.md. Default benchmark runs are informational. They become blocking only when a caller passes an explicit target gate such as --require-target --target-ms N.

FileTests
arith-loop.sscwhile-loop summing 1..1M — IntV boxing, env lookup, while overhead
recursion-fib.sscnaive fib(30) — call overhead, FrameMap construction
recursion-tco.sscaccumulator sum to 100k — TCO path
pattern-match-heavy.sscsealed ADT × 5 cases × 100k matches — matchPat allocation
effect-pure.sscrunLogger { while loop }Computation/FlatMap wrapping for pure-body
effect-stream.sscrunStream { emit × 10k } — effect dispatch + Source allocation
tuple-monoid.ssc(1,2) ++ (3,4) × 100k — _tupleConcat hot path
hello-world.sscprintln("hello") — cold-start + bundle-size baseline

3. Optimization roadmap

v1.61.0 — Benchmark infrastructure ✓

Files: bench/, runtime/backend/interpreter-bench/, scripts/bundle-size.sh.

v1.61.1 — Interpreter dispatch table

Problem. DispatchRuntime.scala:24-468 is a 440-line linear pattern-match on (recv, name, args). Every method call walks through up to ~300 cases with string compares. Additionally, 7 sequential extensions.get lookups happen per call even with no extensions registered.

Fix. Precompute HashMap[(ReceiverTag, InternedName), (List[Value]) => Computation]. Intern method names + identifier names at parse time (String.intern()) so hot-path comparisons reduce to reference equality.

Files: DispatchRuntime.scala, Interpreter.scala, EvalRuntime.scala.

v1.61.2 — Computation pure-path elimination

Problem. Value.scala:266-301 — every Term.Apply, Term.ApplyInfix, Term.Select in a non-effectful block still allocates FlatMap(sub, k) + a closure because the interpreter wraps everything in Computation. Re-association in runUntilSuspension doubles the allocation per step.

Fix. Per-AST IdentityHashMap[Term, Boolean] purity cache populated at first eval. For known-pure sub-trees, call sites skip FlatMap wrapping and return Pure(v) directly. The existing fast path at EvalRuntime.scala:434+ is the template — extend systematically.

Files: Value.scala, EvalRuntime.scala, BlockRuntime.scala.

v1.61.3 — Env representation overhaul

Problem. BlockRuntime.scala:26,34-36,39,53,71,82 calls local.toMap on every statement in every block — O(N) copy per statement, quadratic over block length. The per-statement global refresh (local.keys.foreach { interp.globals.get(k) }) adds another O(local.size) pass. while loops rebuild the env map on every iteration.

Fix. Thread a FrameMap directly through evalBlock instead of converting to/from immutable Map on every statement. Use a single FrameMap across while iterations; only copy when needed for closures.

Files: BlockRuntime.scala, EvalRuntime.scala, CallRuntime.scala.

v1.61.4 — Pattern-match compilation

Problem. EvalRuntime.scala:661-674 tries cases linearly via PatternRuntime.matchPat, which allocates Some/None per attempt and re-looks up typeFieldOrder + re-does field extraction on every Pat.Extract.

Fix. Per Term.Match, compile a decision-tree closure Value => Option[(Env, Body)] cached by AST identity in IdentityHashMap[Term.Match, Value => Option[(Env, Body)]]. First call builds the tree; subsequent calls are direct function invocations.

Files: EvalRuntime.scala, PatternRuntime.scala.

v1.61.5 — JS codegen inlining

Problems.

Fix. Track statement vs expression context in JsGen. Drop IIFE wrappers in statement position. For known accessors emit obj[0], obj.length. Type-aware dispatch skip: if receiver type is known, emit obj.method(args) directly.

Files: runtime/backend/js/src/main/scala/scalascript/codegen/JsGen.scala.

v1.61.6 — Preamble sub-capabilities

Problem. JS Core preamble bundles HTML DSL, JWT, IndexedDb, optics, JSON, signals, generators (~100 KB+) into every program. hello-world.ssc ships them all.

Fix. Split Core into: Console, HtmlDsl, Optics, Scope, IndexedDb, Jwt, Json, Signal. Extend detectCapabilities to identify each. Ship only what's used. Identical split for JVM commonRuntime. Hello World target: < 10 KB ungzipped JS.

Files: JsGen.scala (preamble split + detectCapabilities), JvmGen.scala.

v1.61.7 — Memory representation

Problems.

Fix. Widen IntV pool to [-2048..16383]; add DoubleV 0.0/1.0 pool. Switch TupleV to Array[Value]. Split FunV into hot FunVCore + FunVMeta sidecar. Move Span to IdentityHashMap[AstNode, Span] sidecar. Use upickle.default.writeBinary (MessagePack) for artifacts; accept both formats on read.

Files: Value.scala, AST.scala, ArtifactIO.scala.


3b. What shipped (2026-05-28 → 2026-06-02)

The v1.61 themes all shipped, though often under the milestone names in WORK_QUEUE.md rather than the v1.61.x labels below. The strategy evolved from precomputed dispatch tables to a hot-spot JIT — faster to reach and with far larger gains on the target workloads.

v1.61 themeWhat shippedGains
v1.61.1 dispatch tableDispatchRuntime dispatch table → LMatch LExpr in tryLongWhileAssign; SlotTable replaces LinkedHashMapinstanceFieldAccess 2690 → 16.6 ms (162×)
v1.61.2 pure-pathComputation.purify cached wrappers (−38% Pure allocs); FastTier foreach pre-resolve; LApplyR1/LRefConst dual-bankarithLoop ~2–4×
v1.61.3 Env overhaulSlotTable + FrameMap throughout tryLong/MixedLongWhile; env not rebuilt per iterationincluded in SlotTable gains
v1.61.4 pattern-matchCompiledMatch + ctorTagsInt int-tag dispatch; ADT match → Java switch(int); LMatch scrutinee cachingpatternMatchHeavy ~1.3× from int-tag alone
v1.61.7 memory reprInstanceV.fieldsArr: Array[Value] replaces Map[String, Value]; direct index reads in PatternRuntime arm handlersrecursiveEval 12.9 ms (direction B activation)

Additionally, a register VM + BytecodeJIT layer shipped (v1.62-equivalent work, not in the original v1.61 spec), with substantially larger gains on integer-heavy workloads:

WorkloadBaseline (tree-walk)After JIT (2026-06-02)Gain
recursionFib (fib 30)~28.9 ms1.21 ms24×
recursionTco (sum 100k)~1.08 ms32 µs34×
arithLoop (sum 1M)~85 ms~3.1 ms27×
instanceFieldAccess~2690 ms16.6 ms162×
pureCallSum~13 ms0.28 ms47×

Full cross-backend numbers and JFR findings are in specs/vm-jit-next.md and docs/interpreter-perf-findings-2026-06.md.

Later work (2026-06)

AreaWhat shippedGains
Cold start (AppCDS)Application Class-Data Sharing (-XX:+AutoCreateSharedArchive) in bin/ssc + the install.sh launcher; archive auto-created on first run, auto-recreated on classpath change. Opt out with SSC_NO_CDS=1ssc run hello.ssc 378 → 182 ms (−51%); peak RSS 167 → 114 MB (−32%)
foldLeft VM compileList[Int].foldLeft lowered to an inline VM loop (SscVm list-iter opcodes + a VmCompiler recognizer)combinator-heavy folds JIT instead of tree-walking
typeclass-fold memoDefault-on memo for combineAll-style folds (FunV-local using-resolve cache)~19% on combineAll folds
real-workload-perf harnessestests/perf/coldstart/ (fresh-run wall + RSS), tests/perf/serverrss/ (steady-state server RSS + leak detection), GC-under-loadserver settles ~195 MB with no climb (no leak); light GC

Outstanding from v1.61 spec:


4. Risk register

#RiskMitigation
R1Dispatch table misses a pattern-match arm → silent behavior regressionComprehensive test pass; derive table from existing match by reflection if feasible
R2FrameMap mutation in concurrent contexts (async effects)Audit; restrict mutation to single-threaded eval; keep persistent copy for closures
R3.scim/.scjvm format change breaks existing artifactsWrite binary; accept both binary and legacy JSON on read until v1.62
R4Stripping Module.sourceText degrades error messagesBehind Production flag; default Development keeps source
R5Sub-capability split breaks programs using undetected featuresExtend detectCapabilities first; add capability-missing runtime error with clear message

5. Open questions (deferred to implementation milestones)

Lean: lazy with 256-entry warm cache.

Lean: parse-time, stored in AST sidecar.

Defer to v1.61.7 design phase.