perf(vm): comparison fast path, integer %, non-allocating finally, dead-code cleanup - #39
perf(vm): comparison fast path, integer %, non-allocating finally, dead-code cleanup#39mparrett wants to merge 9 commits into
Conversation
Scaffolding only — no VM changes. Establishes the benchmarks that the follow-up hot-path optimization PRs are measured against, so each of those lands as a pure pkg/vm diff with a clean before/after on an already-merged harness (rather than every opt PR re-touching the shared bench registry). - tests/bench_test.go: BenchmarkAdd / BenchmarkArith / BenchmarkSetIndex, driving bench_add.ts / bench_arith.ts / bench_setindex.ts through the public driver API. - pkg/vm/isobject_bench_test.go: BenchmarkIsObject over a hot-path value mix, plus TestIsObjectExhaustive locking the IsObject == [TypeObject,TypeProxy] range invariant (passes on the current OR-chain). - pkg/vm/tointeger_bench_test.go: BenchmarkToInteger. Baseline on this commit: BenchmarkSetIndex ~778k allocs/op — the target the OpSetIndex allocation-elimination PR drives toward. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OpSetIndex hot path allocated on every indexed write while walking the prototype chain to check for inherited setters. Guard the walk so the common case (own data property / dense array element) stores in place without the per-write allocation. Measured against the bench harness (BenchmarkSetIndex, one full script run): B/op: 6,596,688 -> 1,790,512 (~73% less memory) allocs/op: 777,935 -> 577,679 (~26% fewer) array_inherited_index_setter.ts locks in that inherited-setter semantics are preserved — the case the guard must not skip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
IsObject compared the value type against each object-like ValueType in an OR-chain; on the hot path it is called predominantly on non-objects (numbers, in arithmetic/ToInteger guards), the chain's worst case. The object-like types occupy a contiguous [TypeObject, TypeProxy] enum range, so a single range check replaces the chain. The TestIsObjectExhaustive invariant (added with the harness) guards that the range stays equivalent to explicit membership for every ValueType. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bundles three related hot-path fast paths that share the same idiom: check for number operands up front and take a direct path, falling back to the general (boxing / coercion) route otherwise. - OpAdd: number + number adds directly instead of going through the general add (which also handles string concat and object coercion). - OpSubtract / OpMultiply / OpDivide / OpRemainder: same number-operand fast path. - ToInteger: fast-path number inputs ahead of the general coercion. Covered by BenchmarkAdd / BenchmarkArith (tests) and BenchmarkToInteger (pkg/vm) from the bench harness. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds on the previous commit's arithmetic fast path in two ways: - Cover the comparison group (<, >, <=, >=, ==, !=, ===, !==), which runs on every loop-bound check. JS NaN semantics need no special casing: Go float comparisons yield false when either operand is NaN (so the != case correctly makes NaN !== NaN true), matching ECMAScript exactly. - Dispatch on the value tags directly instead of IsNumber()/ToFloat(). ToFloat is a 12-case switch (inline cost 551 vs budget 80), so each call was a real function call; the two-tag check and payload read inline to a couple of instructions. BenchmarkFibPlaceholderRun: -8.4% vs parent (min of 3 x 3s) BenchmarkMatrixMult: -23.7% vs parent (min of 3 x 3s) Measured under background load with the parent's window less loaded (ratchet anchor 1.20 vs 1.48 ns/op), so deltas are conservative. Cumulative with the parent commit, on a quiet machine vs 263757b: fib 32.15ms -> 27.24ms (-15.3%), matrix 4.95ms -> 2.92ms (-41.0%). TestScripts green. Test262 language suite: 0 new failures / 0 new passes. Built-ins suite: 0 new failures across the ~60% that ran before the runner died of memory pressure (2443 failures, all pre-existing in baseline.txt). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Leftover "NUCLEAR DEBUG" scaffolding ran a type switch, function-object Name field loads, and two string comparisons into an empty if-body on every global load or store of a function value - i.e. on every call to a global function. The compiler cannot fully eliminate it because the Name loads traverse pointers. Pure deletion; no behavior change. Expected sub-1% effect; not measurable above the noise floor on the macro benchmarks (shared machine). TestScripts green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
- The second `ip >= len(code)` guard at the top of the dispatch loop was unreachable: the preceding block handles that condition and either continues or returns. - The explicit IsNaN guards in the relational slow path restate what Go float comparisons already guarantee (any comparison with a NaN operand yields false), which is exactly the ECMAScript behavior - two math.IsNaN calls per non-fast-path comparison for nothing. No behavior change. Expected sub-1% effect; not measurable above the noise floor on the macro benchmarks (shared machine). TestScripts green; Test262 language suite clean on the series tip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
math.Mod is a software frexp/ldexp loop and showed up as 7.6% of the recursion benchmark's CPU profile (workloads use `i % k` constantly). Go's truncated int64 remainder carries the dividend's sign, which is exactly ECMAScript's % semantics, so use it when it is provably safe: both operands integral and within +/-2^53 (float64<->int64 round-trips exactly; beyond that the conversion can saturate), nonzero divisor (zero divisor must yield NaN), and a non-signed-zero result (JS requires -0 for a negative or -0 dividend with zero remainder, which the integer path would lose - detected via math.Signbit and deferred to math.Mod). Adds tests/scripts/remainder_semantics.ts covering sign combinations, signed zeros (via 1/x), zero divisor, Infinity operands, and the non-integral fall-through. BenchmarkFibPlaceholderRun: 25.69ms -> 21.75ms (-15.3%, min of 3 x 2s) vs the previous commit. TestScripts green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nooga
left a comment
There was a problem hiding this comment.
Reviewed all four changes individually — the comparison fast path, integer `%` semantics, and the non-allocating `finally` refactor are each correct (verified against NaN/±0/sign edge cases and traced the finally-handler-selection logic against the old code path; `TestScripts` passes). Requesting changes for two reasons, not a rejection of the approach:
1. This is really 4+ unrelated changes bundled into one diff, beyond what the title says. Beside the stated comparison/%%/finally/dead-code items, it also touches `Value.IsObject()` (range-check rewrite), `Value.ToInteger()` (new fast path), and introduces a new process-global `arrayIndexAccessorSeen` latch that changes `OpSetIndex`'s inherited-setter walk — none of which are mentioned in the title/description. If something regresses later, this shape makes it hard to bisect which of the ~6 changes is at fault. Could you split this into smaller PRs (e.g. comparison+%%, finally, IsObject/ToInteger/array-setter-latch)?
2. Two concrete concerns worth addressing before merge:
- The dead-code cleanup removed the `ip >= len(code)` bounds guard, trading a graceful `runtimeError` for a Go panic/OOB if that invariant is ever violated by a future compiler bug. Given the project's "correctness first, no shortcuts" stance, I'd rather keep a cheap defensive check here than save a branch.
- `arrayIndexAccessorSeen` (object.go) is an unsynchronized global `bool`. It's fail-safe in the sense that a stale `false` just restores the slow path, so it's not a correctness bug — but it is a data race under `-race` or with concurrent VM instances, and worth at least a comment or atomic if that's a supported use case.
Also flagged as a minor, non-blocking irony: the "dead-code cleanup" itself reintroduces a few now-unreachable duplicate numeric-type guards inside `case OpAdd`/`OpSubtract`/`OpMultiply`/`OpDivide` that duplicate the combined fast path a few lines above — harmless, but worth a pass.
Happy to re-review quickly once split and the two concerns above are addressed — none of this is a fundamental problem with the approach.
Follow-up to the number-fast-path lane: the pieces of the dispatch-loop audit that lane didn't pick up. Five commits in distinct
vm.goregions.What this does
<,>,<=,>=with two number operands — loop bounds, sort inner loops — take the same tag-direct path as arithmetic. No NaN special-casing: Go float comparisons already yield ECMAScript's result for NaN operands.%.math.Modis a software frexp/ldexp loop and was ~7.6% of the recursion benchmark's profile. Go's truncated int64 remainder carries the dividend's sign, matching JS; guarded for integral operands within ±2^53, nonzero divisor, and signed-zero results (deferred tomath.Mod).OpReturn/OpReturnUndefinedcalledfindAllExceptionHandlers, which heap-allocates a handler slice whenever the return PC sits inside a try region. Replaced with a single table-order scan returning the same handler; top-level-return test short-circuits onframeCountbefore the"<script>"string compare.OpGetGlobal/OpSetGlobal(type switch +Nameloads + string compares into an empty if-body on every global load/store of a function). Plus an unreachableipguard at the loop head and redundantIsNaNguards in the relational slow path.Numbers
Local (M2): the
%integer path alone is ~−15% on the recursion benchmark; together with the parent lane this is the shootout's full column (~−50% Fib / Matrix / Add / Arith vs upstream tip — noisy machine, alloc/GC signal was clean). Per-commit figures are in the commit messages. Theperflabel's same-runner A/B is authoritative.Verification
TestScriptsgreen at every commit;tests/scripts/remainder_semantics.tspins%sign combinations, signed zeros (via1/x), zero divisor, Infinity operands, and the non-integral fall-through.IsCallablevsIsFunctionon async natives;OpInaccepted-type list) were deliberately preserved and will be filed separately.Made with Cursor