perf(vm): pool callback args slices (CallArgs2/3/4) - #36
Conversation
nooga
left a comment
There was a problem hiding this comment.
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>
01f4519 to
90c4413
Compare
|
Measured on a dedicated
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 |
|
Correction to my numbers above. I wrote that every one of these is inside the noise floor. Four of the five are; 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 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: |
nooga
left a comment
There was a problem hiding this comment.
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.
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/CallArgs4on 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 becauseCallcopies the args out before returning — parameters into the callee's registers, and theargumentsobject (if accessed) is a full copy (NewArgumentsdoes 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 oneCallwhose argument is a comma-containing string literal was deliberately left un-converted.)Design point for review: why pooling is safe —
Callcopies args into callee registers andNewArgumentsdeep-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
TestScriptsgreen; vm+builtins tests under-raceclean; 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.