Reject shared value caches as a runtime optimization
Date: 2026-06-28
Area: runtime
Pull Request: #900
Reducing allocation count is not, by itself, a runtime lever in this engine, so shared caches of boxed TGocciaValue instances — interning or pooling them to avoid allocation — are rejected as a performance optimization. The only value reuse the engine actually has is the handful of special-value singletons returned by RuntimeCopy and the register-boxing paths (0, 1, NaN, ±Infinity, -0; see ADR 0002). Every attempt to add caching beyond that fixed set has been measured and rejected: dictionary-based string interning (ADR 0013, −4% across 172 benchmarks) and the boxed-number range cache described below. A SmallInt 0–255 cache that earlier docs described as if implemented never actually existed in the source (corrected alongside this ADR) — itself a sign of how readily the C/C++ "fewer allocations ⇒ faster" intuition takes hold. This ADR exists so it is not imported again.
Alongside the #900 typed-array element unboxing, a lazy, GC-pinned cache of boxed small integers (range −32768..1024) plus ±Infinity/NaN singleton reuse was spiked into the bytecode VM's RegisterToValue — the register→TGocciaValue boxing site that feeds call arguments. On the sm/TypedArray/sort_large_countingsort.js workload it cut heap allocations 4,719,119 → 3,534,333 (−25%, deterministic), yet runtime did not move: interleaved medians 6920 ms → 7072 ms (+2.2%, flat-to-worse), a fibonacci benchmark +0.6% (noise), and boot time unchanged. FreePascal's allocator plus the mark-and-sweep GC make these short-lived boxed values cheap to create and reclaim, so the cache's per-box branch (range check + array index + nil check) offsets whatever the avoided allocation saved — the same mechanism that made string interning a regression.
The one form of value reuse worth keeping — and not superseded — is the special-value singleton set of ADR 0002 (0, 1, NaN, ±0, ±Infinity, plus true/false, null/undefined), reused by RuntimeCopy and RegisterToValue. It is a tiny, fixed set matched by direct comparison with a high hit rate on the path it sits on — not an array, not a range, not content-keyed. The boundary was measured on both sides: disabling the singleton reuse (always allocating) costs +786k allocations and only ~1.4–1.7% on the allocation-heavy sort_large_countingsort.js test, within noise on typical integer code — a small, essentially free win; widening it to a small-integer range (the spike above) removed more allocations (−1.18M) for no runtime gain (+2.2%). So even the kept cache barely moves runtime, and everything past the narrow fixed set is pure cost — the singleton set is the measured sweet spot, kept because it is free rather than because it is a meaningful speedup. If boxed-value allocation ever shows up as a measured bottleneck, the lever to evaluate is arena/pool allocation that lowers per-object GC cost without a per-box lookup — not a content- or range-keyed value cache.
Guardrail for any future attempt: measure with interleaved before/after binaries (alternate per repetition, compare medians via the runner's --bare), never sequential batches. The first, sequential measurement here falsely showed −13% on the test and +63% on a fibonacci bench purely from machine-load drift, which interleaving erased. Allocation count is deterministic and hardware-independent, but it is not, on its own, evidence of a runtime win. core-patterns.md § String Interning — Attempted and Rejected. garbage-collector.md.