vm: drop leaveFrame's unused *Frame parameter to unpin f from memory - #706
vm: drop leaveFrame's unused *Frame parameter to unpin f from memory#706mparrett wants to merge 1 commit into
Conversation
leaveFrame ignored its argument -- attrPopFrame pops the alloc-attribution
shadow stack by position, so the frame was never read. But runLoop passed it
from a deferred closure, and because f is also reassigned as the dispatch
loop descends into callees and returns to parents, capturing it forced the
compiler to box the interpreter's hottest pointer:
pkg/vm/vm.go:827:7: (*Frame).runLoop capturing by ref: f
(addr=false assign=true width=8)
Every f.ip, f.sp and f.code.code[f.ip] in the loop then reads through an
indirection, to supply an argument the callee discards. Removing the
parameter lets the defer be a direct call, and the capture goes away.
Behavior is unchanged today. Worth noting for anyone tempted to restore the
parameter: `defer leaveFrame(f)` and `defer func() { leaveFrame(f) }()` are
not equivalent -- deferred arguments evaluate at defer time (the entry frame),
the closure at return time (the current frame). That difference is invisible
only because the argument is unused, so the comments now say why the
parameter is absent.
Verified: the `capturing by ref` line is gone from `go build -gcflags='-m -m'
./pkg/vm`; go test ./pkg/vm ./pkg/rt ./test/... green; builds clean for
linux/amd64, js/wasm, plan9/amd64 and wasip1/wasm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Repeat A/B results, run 31315465796. Posting by hand: Run quality is good. N=7 interleaved ABBA on an EPYC 7763, The targeted families improve, slightly
Directionally right and consistent, and about the size one removed indirection should buy. Nowhere near gateable. Three families regress, also consistently
Four families would gate at 6%, two at 8%, none at 10%. What I think this is, and what I cannot showI am not going to call this noise. But the change cannot alter behavior — I cannot separate those two hypotheses with this instrument, which is the same gap as the uncalibrated budget in #705. So: happy to hold this PR until a comment-only control has gone through the same lane. That control is worth running regardless, since it is what #705 asks for and this run is nearly it. One incidental note for #700 and for whoever picks up the |
|
Retracting the reading in my results comment above. I said the regression pointed at code placement rather than logic, on the grounds that deleting an unread parameter cannot change behavior. An A/A run since then says otherwise. #708 touches one workflow file and no Go source, so the lane built base and head from identical code on the same tier and settings. With code held identical, the families that moved here sat at zero:
Across all 53 families the A/A run's largest median was 1.69% and it was clean at every candidate budget. Numbers in #705. So the deltas here are attributable to this branch's binary, not to the harness. Different runner draw, so it is not a perfectly controlled comparison, but a gap between 0.05% and 8.30% on the same family is not what runner variation looks like at N=7. My "cannot add work" argument was the wrong shape. The change removes no semantic operation, but it does change what the compiler can do in I would rather understand that than merge it. The escape-analysis win is real and the parameter is genuinely dead, but not at 8% on seq iteration, and a change this small should not need a footnote. Leaving the PR open and taking a look at what the compiler does differently at the One correction to my own aside as well: I suggested |
|
One number in my retraction above needs fixing, though the conclusion comes out stronger rather than weaker. I cited a 1.69% A/A floor from a single run. Two more A/A runs since then show that was a lucky draw — one family hit 13.43% with identical code. Details on #705. The floor is per-family, not global. That matters here because the families this PR moved are the stable ones, across all three A/A runs:
So the read stands: these deltas belong to the binary, and the likely mechanism is a changed inlining or register-allocation decision in |
|
Chased the mechanism. It is not what I guessed, and the answer has a consequence beyond this PR. Not inlining, not the defer
Nothing new became inlinable. And It is register allocation, and the function grew
Which is the change working as intended. Pinning So the closure was not merely wasteful. It was holding a large hot loop in an equilibrium that happened to be good, and the escape-analysis "fix" trades a real indirection for a worse global allocation. Removing an indirection from a 25 KB loop is not a local change. Consequence for folding
|
|
Converting to draft to signal the intent in the last finding. |
|
Read this one carefully because it touches the 2021 core, and the change itself is safe: I also traced the enter/leave balance, since Agree with holding it, on your own evidence — FrameDispatch +4.97%, SeqIteration/List/10 +7.20%, /01000 +8.30%, VectorConj/ArrayVector/10 +7.63%, against A/A noise of 0.3-1.9%. The mechanism you identified is the part worth keeping regardless of what happens to the diff: If it stays as a documented reference rather than a merge, that seems right to me. I have not re-run the benchmarks — the numbers are yours, but they are A/A-controlled and self-consistent. |
|
#719 removes |
runLoopInner's error arms inlined their constructors (fmt varargs setup, struct literals, source-map lookups) into the ~25KB hot function, adding code the register allocator must work around (#706/#719 FrameDispatch fragility). Extract them into //go:noinline helpers: same messages, same handleError routing, same control flow — only the construction bodies move out of line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
leaveFramenever read its*Frame.attrPopFramepops the alloc-attribution shadow stack by position, so the parameter was inert. ButrunLooppassed it from a deferred closure, andfis also reassigned as the dispatch loop descends into callees and returns to parents, so capturing it forced the compiler to box the interpreter's hottest pointer:assign=trueis the operative half. Everyf.ip,f.spandf.code.code[f.ip]in the dispatch loop reads through an indirection, to supply an argument the callee discards.Dropping the parameter lets the defer be a direct call. The capture is gone from escape analysis after the change, and no call site loses information, since none of the four was passing anything that got read.
Why the parameter is now documented as absent
defer leaveFrame(f)anddefer func() { leaveFrame(f) }()are not equivalent: deferred arguments evaluate at defer time, which is the entry frame, while the closure evaluates at return time, which is whichever frame is current. Today that difference is unobservable because the argument is unused. It stops being unobservable the moment someone gives the parameter meaning, so both the defer andleaveFrameitself now carry a comment saying why there is no frame to pass.On performance
This came out of measuring #700's entry-boundary cost, and the honest summary is that the effect is not demonstrated. Locally on an M2,
(some #(> % 999998) (range 1000000))under hyperfine (3 warmup, 20 runs) gave order-dependent results —main1.03 ± 0.03x faster with A first, this change 1.07 ± 0.09x faster with B first — and thepkg/vmmicros ran at ±20–30% σ with no family reaching significance. That is thermal, not signal.So judge this one on the dead parameter and the removed capture; any number would be a bonus. I would run it through the
perf-repeatEPYC lane to see whether the indirection shows up on quieter hardware, and will label it unless you would rather not spend the lane time.One related note for #700: the ~2% previously attributed to gating this defer cannot have come from the capture. Gating a defer whose body still references
fkeeps the by-ref capture, so that measurement was moving something else.Verification
capturing by refline absent fromgo build -gcflags='-m -m' ./pkg/vmgo test ./pkg/vm ./pkg/rt ./test/...green, includingTestGogenAOTDifflinux/amd64,js/wasm,plan9/amd64,wasip1/wasm