Sscalascript.dev

Corpus anti-fold: the LCG-seed idiom (2026-06-11)

The cross-language wall-clock table (bench/run.scssc bench --machine --backend <b>) compares the same bench/corpus/<name>.ssc workload across ssc, ssc-asm, jvm, js, rust. For a class of workloads the compiled backends (C2 / TurboFan / LLVM) reported sub-nanosecond times — not real throughput, but the optimiser having constant-folded a pure, loop-invariant, zero-input workload to a compile-time constant. The tree-walking interpreter cannot fold, so the table compared fake-fast compiled numbers against honest interpreter numbers (see cross-backend-gap-analysis.md §3).

This is the corpus-side counterpart of the JMH harness de-fold (bench-honesty-varying-data-p2, which fixed the one automated compiled fold cell). It closes the optional follow-up flagged in specs/backend-perf-gaps.md §T2.1.

Why the sink alone is not enough

ssc bench already wraps each workload() call in a sink (AtomicLong.getAndAdd on JVM) so HotSpot can't hoist the outer timing loop via scalar evolution. But the sink does nothing about workload-internal folds: a def workload(): T with no inputs is a deterministic constant, and C2 inlines it and precomputes the result.

Why a one-shot seed is not enough

Threading an opaque seed that is read once before the loop (val base = seed % k; … f(base) …) still leaves the inner loop closed-form in base: C2's scalar evolution computes Σ f(base) as a single expression in base, evaluated once per call. Measured: instance-field stayed at 2.7 µs (still folded). A linear loop-carried recurrence (count += 1 while in range) is also defeated — C2 solves counted-loop inductions. Measured: bool-predicate stayed at 3 ns.

The idiom: a carried 64-bit LCG

Each de-folded workload advances a non-linear loop-carried state — a 64-bit linear congruential generator — and derives the per-iteration input from it:

def workload(seed: Long): Long =
  var s = seed + 1                       // opaque start (harness feeds the sink)
  var acc = 0L
  var i = 0
  while i < N do
    s = s * 2862933555777941757L + 3037000493L   // Knuth MMIX LCG; wraps mod 2^64
    acc = acc + <real work using (s % k)>          // consume every result
    i = i + 1
  acc

Why it works:

cannot express s after N steps as a closed form, so they must execute every iteration. The interpreter never folds anyway, so its number was already honest — the idiom only changes the compiled columns.

atomic load on JVM) — not a compile-time constant — so the whole call can't be precomputed either.

blocks dead-store elimination of intermediate allocations (e.g. the tuple-monoid concats), so the allocation cost is actually measured.

does not distort the measured cost of the real work (monadic dispatch, tuple concat, InstanceV allocation, literal match).

The (s % k) input can be negative; workloads are written to tolerate the full range (it just exercises both branches of a predicate / both Left/Right arms, which is more representative, not less).

Harness support (tools/cli/.../Main.scala, BenchCmd)

generateWrapper is arity-aware:

real data-dependent loop (arith-loop, nested-loop, recursion-fib, …) stay as they are.

opaque seed: on JVM _ssc_sink.get() (atomic load, not constant-foldable); on interp/JS a monotonic _ssc_seed (they don't scalar-evolve, so a cheap varying value suffices). The raw sink value is used verbatim — (x | 1) & mask would let C2 re-derive a constant, so no normalisation is applied.

Workloads converted

instance-field, tuple-monoid, bool-predicate, either-chain, option-chain, literal-match. Each carries an LCG and consumes every result.

What this does NOT change

mutual-recursion, pattern-match-heavy, list-fold, range-sum, string-*, hof-pipeline, streams-pipeline, map-ops, *-monoid no-loop, effect-*, recursion-tco, hello-world) keep their no-arg signature and their numbers.

retained as a second line of defence for the no-arg workloads; seed workloads additionally receive the LCG defeat. (A follow-up may retire the patcher for seed workloads.)