Skip to content

perf(vm): non-allocating finally-handler check on returns [2/4 of #39] - #41

Merged
nooga merged 2 commits into
nooga:mainfrom
mparrett:perf/vm-finally-nonalloc
Aug 24, 2026
Merged

perf(vm): non-allocating finally-handler check on returns [2/4 of #39]#41
nooga merged 2 commits into
nooga:mainfrom
mparrett:perf/vm-finally-nonalloc

Conversation

@mparrett

Copy link
Copy Markdown
Contributor

Part 2 of the #39 split.

findAllExceptionHandlers allocates a []*ExceptionHandler on every OpReturn/OpReturnUndefined, where the common case is an empty exception table and the result is immediately reduced to "first handler that is a finally". findPendingHandler fuses that filter into the scan and returns the handler directly.

It's an exact refactor of the selection logic: same frameCount/closure guards, same exception table, same iteration order, same pc >= TryStart && pc < TryEnd predicate. The includeIterCleanup flag covers the two call sites that also accept IsIteratorCleanup. findAllExceptionHandlers is unchanged and still used by the unwinding and generator paths.

Also reorders vm.frameCount == 1 && function != nil && function.Name == "<script>" so the plain load short-circuits before the string compare, which otherwise ran on every function return.

Where this sits in the #39 split

#39 bundled ~6 changes; per review it's split into four independent PRs, all branched off current main:

branch contents
1 perf/vm-numeric-compare-rem comparison fast path, integer %, drop unreachable per-op guards
2 perf/vm-finally-nonalloc non-allocating finally-handler check
3 perf/vm-dispatch-deadcode dead debug blocks, redundant IsNaN cleanup
4 perf/vm-array-index-accessor-atomic arrayIndexAccessorSeenatomic.Bool

The other three pieces you flagged as unmentioned — the IsObject() range check, the ToInteger() fast path, and the arrayIndexAccessorSeen latch itself — already landed on main separately, so they aren't repeated here. Together these four are the remainder of #39.

The four merge onto main in sequence with no conflicts; the union passes TestScripts, pkg/vm, pkg/compiler, and go test -race ./pkg/vm/.

🤖 Generated with Claude Code

Every OpReturn/OpReturnUndefined called findAllExceptionHandlers, which
walks the exception table and heap-allocates a []*ExceptionHandler
whenever the return PC sits inside any try region - then the caller
scanned the slice up to twice to find the first finally handler.
Replace with findPendingHandler, a single table-order scan returning
the same handler with no allocation, and apply it to the two generator
ActionReturn chain sites (which additionally match iterator-cleanup
handlers).

Also reorder the top-level-script check in both return opcodes so the
frameCount load short-circuits before the "<script>" string compare,
which otherwise ran on every function return.

Profile-driven: findAllExceptionHandlers was 1.8% flat in the recursion
benchmark's CPU profile; the allocation only fired for returns inside
try regions. Not separable from noise on the macro benchmarks (shared
machine). TestScripts green; Test262 language suite (which covers
try/finally semantics) clean on the series tip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mparrett
mparrett marked this pull request as ready for review July 25, 2026 20:03
It claimed to select what "the callers of findAllExceptionHandlers" used
to pick, but the generator-resume path is still a caller and picks by
different rules - last covering finally, and finally preferred over an
earlier iterator-cleanup handler. As written the comment invited a future
reader to convert that site too, which would be a real bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mparrett

mparrett commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Regression on the arithmetic workloads. 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 Δ
Arith +29.3%
Add +26.2%
MatrixMult +3.9%
FibPlaceholderRun −0.5%
SetIndex −6.0%

Arith was re-run with true per-launch interleaving because the size of it was surprising: +29.3% interleaved against +29.5% grouped, with a 1.0% spread within each arm. It reproduces.

The shape is odd and might point at the cause: Arith and Add regress hard, Fib and MatrixMult do not, and SetIndex improves. If the finally-handler check moved onto a path that tight arithmetic loops take but recursion and matrix work do not, that would fit — worth a look before this lands.

Floors from null controls in the same run: two commits compiling to byte-identical binaries measure up to 3.7% apart on ./tests, and a layout control is no worse — so anything under ~4% is not attributable. These are far outside that. Full write-up and raw data: perf-session-remeasure-results.md.

@mparrett

mparrett commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Correcting my verdict here: the +29.3% is real, and it is not this change.

The exposure runs the wrong way. Fib executes the OpReturn path this PR
changes about five million times per iteration and measured −0.5%.
Arith and Add, which execute roughly two returns against millions of loop
iterations, measured +29.3% and +26.2%. The benchmarks that regressed are the
ones that barely touch the changed code.

What is left is code layout — this change re-encodes 35.6% of (*VM).run's
65,397 instructions through spill-slot reassignment. Details and the missing
control in #52.

The non-allocating handler check still looks right on its merits, and it has no
demonstrated cost or benefit on this suite. I would leave this open and
unmeasured rather than close it as a regression.

@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: builds clean, merges without conflict alongside #40-43 (checked in sequence), go vet clean, full go test ./... (all packages) green, plus the vm-diff logic checked by hand (NaN comparison semantics, %-fast-path sign/overflow guards, findPendingHandler table-order equivalence to the old findAllExceptionHandlers scan, atomic.Bool race fix). See PR discussion for details.

@nooga
nooga merged commit d8fbe4b into nooga:main Aug 24, 2026
8 checks passed
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