Skip to content

perf: reuse pre-allocated arrays in TailCall trampoline - #1119

Open
He-Pin wants to merge 1 commit into
databricks:masterfrom
He-Pin:perf/trampoline-buffer-reuse
Open

perf: reuse pre-allocated arrays in TailCall trampoline#1119
He-Pin wants to merge 1 commit into
databricks:masterfrom
He-Pin:perf/trampoline-buffer-reuse

Conversation

@He-Pin

@He-Pin He-Pin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Motivation

Every tail-call position (Apply1/Apply2/Apply3) allocates a fresh Array[Eval](N) per trampoline step. In deep recursion (std.foldl, recursive functions), this creates millions of short-lived arrays, increasing GC pressure and L1/L2 cache pollution.

Modification

  • Rotating pool of 4 pre-allocated buffers per arity (1/2/3) in Evaluator
  • nextTcBufIdx() cycles through slots with (i+1) & 3
  • Buffer is consumed by Func.apply (copies into new scope) before the next TailCall overwrites it — 4 slots provide ample safety margin
  • Zero allocation in the hot tail-call loop

Result

Benchmark master this PR Delta
MainBenchmark.main 3.028 ± 0.536 ms/op 2.588 ± 0.367 ms/op -14.5%

JMH config: -f 2 -wi 5 -i 10 -w 1 -r 1 (2 forks × 10 measurement iterations)

All 424 tests pass. Thread-safe: Evaluator is single-threaded per interpreter instance (see CLAUDE.md threading model).

Test plan

  • ./mill 'sjsonnet.jvm[_]'.test — all pass
  • ./mill bench.runJmh ".*MainBenchmark.*" — 14.5% improvement
  • Deep nesting regression test (byterenderer_deep_nesting.jsonnet) passes

@He-Pin
He-Pin marked this pull request as draft August 12, 2026 05:34
@He-Pin
He-Pin force-pushed the perf/trampoline-buffer-reuse branch from da42b64 to 0df8c34 Compare August 13, 2026 06:31
Motivation:
Every tail-call position (Apply1/Apply2/Apply3) allocates a fresh
Array[Eval](N) per trampoline step. In deep recursion (std.foldl,
recursive functions), this creates millions of short-lived arrays,
increasing GC pressure and L1/L2 cache pollution.

Modification:
- Rotating pool of 4 pre-allocated buffers per arity (1/2/3) in Evaluator
- nextTailCallBufIdx() cycles through slots with (i+1) & 3
- Buffer is consumed by Func.apply (copies into new scope) before the
  next TailCall overwrites it — 4 slots provide ample safety margin.
  Safety: Func.apply copies Eval refs out of the buffer
  (extendSimple/arraycopy) before evalRhs can produce the next TailCall,
  so no slot is overwritten while live.
- Zero allocation in the hot tail-call loop

Result:
MainBenchmark.main: 3.028 ± 0.536 → 2.588 ± 0.367 ms/op (-14.5%)
JMH config: -f 2 -wi 5 -i 10 -w 1 -r 1 (2 forks × 10 measurement iterations)
All 424 tests pass. Thread-safe: Evaluator is single-threaded per
interpreter instance (see CLAUDE.md threading model).
@He-Pin
He-Pin force-pushed the perf/trampoline-buffer-reuse branch from 0df8c34 to d43b163 Compare August 13, 2026 10:19
@He-Pin
He-Pin marked this pull request as ready for review August 13, 2026 10:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant