Skip to content

perf(vm): pool callback args slices (CallArgs2/3/4) - #36

Merged
nooga merged 1 commit into
nooga:mainfrom
mparrett:perf/vm-callback-args
Aug 24, 2026
Merged

nooga merged 1 commit into
nooga:mainfrom
mparrett:perf/vm-callback-args

Conversation

@mparrett

Copy link
Copy Markdown
Contributor

Stacks on #33 (sentinel-register pool). Shares the vm.go / vm_init.go pooling regions, so this branch is cut on top of #33 and its diff includes that commit until #33 merges. Review/merge after #33; this branch then rebases onto main and its diff shrinks to the single callback-args commit. Net new commit here: ad0598e.

The bigger sibling of #33. Every builtin invoking a JS callback built a fresh []Value{element, index, array} — one slice per element across Array/TypedArray map/forEach/filter/reduce/some/every/find/sort, Array.from, Map/Set forEach, String replace/split, JSON replacer/reviver, Promise, and Object callbacks.

What this does

Add CallArgs2/CallArgs3/CallArgs4 on the VM: they pass the arguments through a pooled cap-4 buffer (same LIFO free-list discipline as #33) instead of a literal slice. Safe because Call copies the args out before returning — parameters into the callee's registers, and the arguments object (if accessed) is a full copy (NewArguments does make+copy) — so nothing retains the slice past the call; an audit found no native that stores it. 71 call sites converted across 10 builtin files. (The one Call whose argument is a comma-containing string literal was deliberately left un-converted.)

Design point for review: why pooling is safe — Call copies args into callee registers and NewArguments deep-copies, so nothing retains the slice.

Numbers

Local (M2, min of 6, on top of #33): a forEach+map+reduce+filter loop 1173 → 980 ms (~16%) with GC cycles collapsing 265 → 56 (~79% fewer). Combined with #33, callback-heavy code is ~30% faster with ~82% fewer GCs. The perf-label A/B is authoritative.

Verification

TestScripts green; vm+builtins tests under -race clean; broad callback correctness across all families verified. Test262 language 0 new failures (differential vs upstream/main control); built-ins Array/TypedArray/Map/Set/String/JSON/Promise/Object 0 new failures.


Part of a VM micro-optimization series (profile-driven, one slice per PR). A tracking issue with the full map and a reviewer's guide follows.

@nooga nooga left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Matt — the pooled CallArgs2/3/4 buffers are correctly cleared/returned and the LIFO-safety reasoning (nested calls fully unwind before pool return) holds up; one tiny non-blocking note is the put isn't deferred so a panic mid-Call leaks one buffer (harmless, falls back to alloc). Approving, but this now conflicts with main after #38 landed (both touch vm.go's call/index paths) — could you rebase and I'll merge right after? No code changes needed beyond the rebase.

Every builtin that invoked a JS callback built a fresh []Value for the args -
`Call(cb, this, []vm.Value{element, index, array})` - allocating one slice per
element across Array/TypedArray map/forEach/filter/reduce/some/every/find/sort,
Array.from, Map/Set forEach, String replace/split, JSON replacer/reviver,
Promise, and Object callbacks. This was the biggest remaining per-callback
allocation (larger than the sentinel-reg slice pooled in the previous commit).

Add CallArgs2/CallArgs3/CallArgs4 on the VM: they pass the arguments through a
pooled cap-4 buffer (argsBufPool, same LIFO free-list discipline as
sentinelRegPool) instead of a literal slice. Safe because Call copies the args
out before returning - parameters into the callee's registers, and the
`arguments` object (if accessed) is a full copy (NewArguments does make+copy) -
so nothing retains the slice past the call; the audit found no native that
stores the args slice. 71 call sites converted across 10 builtin files.

Measured (Apple M2, min of 6, on top of the sentinel-pool commit): a
forEach+map+reduce+filter loop 1173 -> 980 ms (~16%) with GC cycles collapsing
265 -> 56 (~79% fewer) - the args slice was the dominant callback allocation.
Combined with 6cef618, callback-heavy code is ~30% faster with ~82% fewer GCs
vs the campaign start.

Verification: TestScripts green; vm+builtins tests under -race clean; broad
callback correctness (Array/TypedArray map/filter/reduce/sort/some/every/find,
Array.from, Map/Set forEach, String split + regex replace with function, JSON
replacer, Object.entries) verified; Test262 language 0 new failures and
built-ins Array/TypedArray/Map/Set/String/JSON/Promise/Object 0 new failures.
function_init.go's error-message Call (a string literal containing commas) was
deliberately left un-converted to avoid mis-splitting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mparrett
mparrett force-pushed the perf/vm-callback-args branch from 01f4519 to 90c4413 Compare July 25, 2026 19:47
@mparrett

mparrett commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Measured on a dedicated c7a.2xlarge (EPYC 9R14), go1.26.0, against the shared merge base 3167412e85c2. Targeted A/B: alternating launches, b.N pinned at 8, median of 5–8 launches per arm. Negative is faster.

benchmark Δ
FibPlaceholderRun +0.5%
Arith −0.4%
Add +3.9%
SetIndex +0.1%
MatrixMult +0.1%

No measurable effect. Every one of these is inside the noise floor measured in the same run, so this reads as neutral rather than as a small win or loss.

That is worth stating plainly before it lands as a performance change — the pooling may still be worth having for allocation behaviour, but on these workloads it does not show up in wall time.

Incidentally this PR was the useful internal control for the whole session: it is the arm that came back at zero, which is what tells you the method reports "no change" cleanly rather than manufacturing an effect on whatever it is pointed at.

Floors from null controls in the same run: two commits compiling to byte-identical binaries measure up to 3.7% apart on ./tests. A layout control (real code added, never executed) is no worse — so anything under ~4% here is not attributable to the change. Full write-up and raw data: perf-session-remeasure-results.md.

@mparrett

mparrett commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Correction to my numbers above. I wrote that every one of these is inside the noise floor. Four of the five are; Add at +3.9% is not — the floor measured in that run is 3.73%, so it clears it by 0.2 points.

That does not change the reading, and I want to be precise about why rather than quietly restate it. The floor is the largest of fifteen readings taken between commits that compile to byte-identical binaries — an estimate with its own spread, not a constant. A cell 0.2 points past it is inside that uncertainty. So Add is at the floor, not above it, and this PR is best read as no demonstrated effect on wall time rather than as either a small win or a small regression.

The conclusion stands, and so does the reason this row is useful: a measurement method that reported movement wherever it was pointed would not return four-inside-one-at on a change like this. That is most of what makes the other five PRs' numbers worth anything.

Visual summary of all six, with the measured floor as the neutral band: remeasure-session.html.

@nooga nooga left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified locally: parent PR #33 already merged, so this branch's diff against main is now the net-new commit only. Builds clean, merges without conflict, go vet clean, full go test ./... green, plus go test -race clean on pkg/vm, pkg/builtins, tests. CallArgs2/3/4 pooling is safe because Call copies args into callee registers before returning, so nothing retains the pooled slice past the call - confirmed by spot-checking several of the 71 converted sites in pkg/builtins: mechanical 1:1 rewrites, no retention issues found.

@nooga
nooga merged commit 3d8acf1 into nooga:main Aug 24, 2026
8 checks passed
@mparrett
mparrett deleted the perf/vm-callback-args branch August 31, 2026 23:04
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.

2 participants