Skip to content

perf(vm): comparison fast path, integer %, non-allocating finally, dead-code cleanup - #39

Open
mparrett wants to merge 9 commits into
nooga:mainfrom
mparrett:perf/vm-dispatch-extras
Open

perf(vm): comparison fast path, integer %, non-allocating finally, dead-code cleanup#39
mparrett wants to merge 9 commits into
nooga:mainfrom
mparrett:perf/vm-dispatch-extras

Conversation

@mparrett

Copy link
Copy Markdown
Contributor

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.go regions.

Stacks on the number-fast-paths PR. Until that merges, this PR's diff includes its parent commits (same pattern as #35 on #29). Rebase onto main after the parent lands — do not --delete the parent branch on merge.

What this does

  • Numeric fast path for relational comparisons. <, >, <=, >= 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.
  • Integer fast path for %. math.Mod is 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 to math.Mod).
  • Non-allocating finally check on returns. Every OpReturn / OpReturnUndefined called findAllExceptionHandlers, 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 on frameCount before the "<script>" string compare.
  • Dead code out of the hot loop. Leftover debug blocks in OpGetGlobal / OpSetGlobal (type switch + Name loads + string compares into an empty if-body on every global load/store of a function). Plus an unreachable ip guard at the loop head and redundant IsNaN guards 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. The perf label's same-runner A/B is authoritative.

Verification

  • TestScripts green at every commit; tests/scripts/remainder_semantics.ts pins % sign combinations, signed zeros (via 1/x), zero divisor, Infinity operands, and the non-integral fall-through.
  • Test262 language on the merged stack: 0 new failures, 0 new passes vs upstream tip.
  • No semantics changes. Latent inconsistencies the audit surfaced (IsCallable vs IsFunction on async natives; OpIn accepted-type list) were deliberately preserved and will be filed separately.

Made with Cursor

mparrett and others added 9 commits July 11, 2026 17:17
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 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.

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.

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