Skip to content

perf: jit fusion 8phase#106

Merged
siyul-park merged 18 commits into
mainfrom
worktree-perf-jit-fusion-8phase
Jul 5, 2026
Merged

perf: jit fusion 8phase#106
siyul-park merged 18 commits into
mainfrom
worktree-perf-jit-fusion-8phase

Conversation

@siyul-park

@siyul-park siyul-park commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Issue #104, covering guard emission, fusion, and tracer feedback optimization, is mostly unimplemented. Findings from the investigation:

  • threaded/fusion: impossible idx < 0 checks, misplaced host frame-overflow check, nested dispatch in fuseLocalConst, unspecialized upvalue/global access, and triple-duplicated host cleanup all remain.
  • JIT: guardIndex helper exists but has zero callers (dead code); approximately 40 lines of duplicated guard chains exist across 4 array/struct handlers; most guards use inline exits; boxableI64 uses an LSL/ASR pattern; the brIf hot path is the taken branch; marked is overly conservative; i64 GET is trace-terminal.
  • Additional findings outside the issue: missing compare+BR_IF fusion, which is the largest fusion gap; release slow-path slice allocation; Refs() per-walk allocation; Reset performs an O(constants) rescan; JIT constant rematerialization / snapshot copies; W^X per-reloc mprotect; wrapFunc reflection allocations.

User decision: full scope — all of #104, additional findings, and risky items including i64 continuation and shape feedback, with phase-by-phase benchmark gates.

Benchmark Protocol — Common to All Phases

Run before starting and after completing each phase, and record the numbers in the commit body:

go test -bench 'BenchmarkFib35' -run xxx -count 5 ./benchmarks
go test -bench 'BenchmarkJIT' -run xxx -count 5 ./benchmarks
go test -bench 'Refs|TypedMap_Get' -benchmem -run xxx ./types

Gate: the target benchmark must improve, or remain tied for pure refactor phases, with no regressions elsewhere. Before committing, go test -race ./... must be green.

Each phase is an independent commit unit. Proceed from lower risk to higher risk.

Phase 1 — Remove Impossible Checks + Relocate CALL Overflow Check — Lowest Risk

  • interp/threaded.go: remove idx < 0 checks for byte/uint16 decoded operands — LOCAL_GET :415, LOCAL_SET :501, LOCAL_TEE :546, GLOBAL_GET :344, GLOBAL_SET :361, GLOBAL_TEE :390, CONST_GET :590, REF_TEST :746, REF_CAST :773, ARRAY_NEW :2693, ARRAY_NEW_DEFAULT :2830, STRUCT_NEW :3408/:3444, MAP_NEW :3543/:3664. Keep upper-bound checks. Runtime indices such as ARRAY_SET/FILL/DELETE/COPY legitimately need idx < 0; keep those.
  • interp/jit_arm64.go:811: remove dead idx < 0 check in global lowering.
  • interp/threaded.go:151: move i.fp == len(i.frames) check from before the callee type switch :155 into the function/closure arms. Host calls :220 do not push a frame.
  • interp/fuse.go:307-309: remove the same check from fuseHostFunction CALL. Fused RETURN_CALL :340 already does not have it — keep behavior consistent.
  • Tests: add rows to the existing runTests table — host call succeeds when frames are exhausted, while function/closure calls still return ErrFrameOverflow.

Phase 2 — Remove Core Runtime Allocations — Outside Issue, Largest Allocation Impact

  1. Convert Refs() to append style: change the types.Traceable interface to Refs(dst []Ref) []Ref. Implementations: types/array.go:76, map.go:205, struct.go:122, closure.go:29, coroutine.go:43, host.go:74. Callers, including GC mark/sweep, release walk, and keep, should use a reusable buffer owned by *Interpreter.
  2. Release scratch stack: replace stack := []int{addr} at interp.go:1530 with a reused *Interpreter field, resliced with [:0]. Keep release iterative; do not make it recursive.
  3. Reset ref-constant count cache: replace the rescan at interp.go:643-647 with a value computed once, targeting the pool Put → Reset hot path.
  4. Promoted path for unboxI64: interp.go:1349-1357 — reduce operand-level release/rebox round trips in i64 arithmetic. Preserve net refcount behavior.
  5. wrapFunc: marshal.go:589-632 — cache the reflect.New argument slots and seen map per wrapped closure, then reset them between calls.
  6. Map iteration: types/map.go:112-145,298-328 — remove per-Next cost from reflect.MapRange.
  • Tests: update existing Test<Type>_Refs tests for the new Refs signature, keeping one test per symbol.
  • Gate: Refs benchmark should reach approximately zero allocs/op, and Fib35 should be tied or better.

Phase 3 — Remove Threaded Duplication + Scalar Specialization

  1. Unify host arg cleanup: the identical loops in threaded.go:4422-4436, fuse.go:321-335, and fuse.go:356-370 should become a single *Interpreter method. If the host signature is known at compile time to contain only non-ref/non-promotable scalar parameters — I32/I1/I8/F32/F64 — emit a scalar-only closure with no scan. If any ref or I64 param exists, keep the generic path.
  2. Typed-array bounds duplication: collapse the 7 duplicated bounds sequences in ARRAY_SET :2969-2999, FILL :3029-3077, DELETE :3270-3312, COPY :3124+, and :4762-4792 into a shared method, per §1.4. Keep runtime negative checks.
  3. Specialize GLOBAL_GET/SET/TEE: mirror the local-specialization pattern in threaded.go:468-482 and related code, skipping retain/release for I32/F32/F64.
  4. Specialize UPVAL_GET/SET: :661-662/:679-682 — skip retain/release for I32/F32/F64 based on capture kind. Keep I64 because it can be promoted; keep KindRef behavior.
  • Tests: add runTests rows — scalar-only host call, ref-param host call using fused+generic path, scalar upvalue, and promoted-i64 upvalue.

Phase 4 — Fusion: Flatten fuseLocalConst + Add Branch Fusion — Largest Interpreter Impact

  1. Flatten fuseLocalConst in fuse.go:450-532: current behavior is push → advance IP → inner(i), causing a second indirect call. Replace with flat closures per (kind, op) — directly read the local slot, write the result once, and remove the SP round trip and inner sp == 0 check. Preserve the retainBox semantics for the I64 variant at :512.
  2. Add compare+BR_IF fusion — the largest missing fusion gap: fuse I32_LT/LE/GT/GE/EQ/NE, plus I64/F32/F64 variants, followed by BR_IF into a single closure that compares and branches directly without boxed bool push/pop. Also combine with chore: Configure Renovate #1 for loop-header shapes such as LOCAL_GET; CONST; cmp; BR_IF.
  3. Add I32_EQZ+BR_IF and const+BR_IF using the same plumbing as chore(deps): update dependency go to v1.25.2 #2.
  4. Add local+local binop fusion: LOCAL_GET a; LOCAL_GET b; binop should directly read both slots.
  • Constraints: keep the containsYield guard; abandon fusion if a branch target enters the fused region, which is the key correctness risk for multi-instruction windows; verify that the existing label checks cover 4-wide windows; keep fusion disabled in precise mode, because the tracer must observe exact boundaries.
  • Tests: add loop-program rows for each fused shape, a row where a branch target falls into the middle of a fused window to confirm fusion is rejected, and a row adjacent to YIELD.
  • Gate: Fib35 interp-only with WithThreshold(-1) must improve — expected 10–25%. JIT must remain tied.

Phase 5 — JIT Structural Cleanup: Shared Guards + Cold Exit + Branch Shape

  1. New helpers, following issue naming policy: guardItab, guardRC, and retainRef are allowed; avoid *OrFail and *Unsigned.

    • guardItab(ctx, itab, want, pre, ip) — fail on mismatch. Keep existing matchItab for multi-way cases.
    • guardRC(ctx, addr, pre, ip) — rc check, following the signature conventions of existing guardTag :3308 and releaseRef :3291.
    • retainRef(ctx, addr) — counterpart to releaseRef.
  2. Rewrite guardIndex as a one-branch unsigned guard at :3094-3114: current code uses CMPI/BGE + CMP/BLT, two branches. Replace with CMP idx, n + BCS fail; negative values become huge values under unsigned comparison and are caught together. Since len <= 63-bit, this is safe — document this in a comment. Then replace inline two-branch copies in array/struct handlers, such as :2586-2589, with calls to guardIndex, resolving the current zero-caller dead-code state.

  3. Compose guard chains in arrayGet(:2510), arraySet(:2645), structGet(:2743), structSet(:2836) using heapValue + guardItab + sliceHeader + guardIndex + guardRC/releaseRef. refGet, arrayLen, errorGet, coroDone, and coroValue already follow this style. Remove around 120 lines of duplication.

  4. Move inline exits to cold sideExit for guardI64 :1510, boxableI64 :1528, i32/i64 divide-by-zero :924/:1178, guardTag :3316, call-target :1719, and releaseRef rc <= 1 :3297. For releaseRef, preserve check-first ordering — decrement only after the guard passes.

  5. Add SBFX builder in asm/arm64/instr.go: the encoder already uses SBFM internally in encoder.go:55,297; only builder/plumbing is needed. Replace LSL/ASR pairs in boxableI64 :1522-1526, sign64 :1539, arrayGet i64 :2621, and structGet i64 :2812 with one SBFX instruction.

  6. Invert brIf direction at :1583-1592: make the recorded direction the physical fall-through path, and make divergence the cold branch, matching the backedge shape at :2162.

  7. Reduce marked at :3409: change kind == KindRef to kind == KindRef && raw, meaning function markers only. This expands continuation coverage.

  8. Reduce compile-time churn: replace 16 occurrences of append([]value(nil), ctx.values...) with a reusable lowering scratch; clean up double append(ctx.values[:0], pre...) in guardTag/releaseRef; memoize itab/rcBase/heap-base constants per trace, resolving repeated LDIs such as the 7 matchItab loads in arrayLen :2479-2485.

  • Tests: add TestSBFX in asm/arm64 comparing encoded bytes; add t.Run cases to existing JIT tests — unsigned guard deopts on negative index, rc == 1 release side-exit, and continuation enabled by relaxed marked.
  • Gate: jit_issue60/101 and Fib35-JIT must improve — expected 5–15%. Interpreter must remain tied.

Phase 6 — W^X Batching — Warmup Latency

  • asm/buffer.go: Write :42 currently does unseal → copy → seal, causing two mprotect calls plus icache flush. Link reloc patching at :87-108 currently does unseal/seal per relocation. Batch this so each compilation unit performs one unseal → all writes and relocations → one seal + icache sync. Preserve the Unseal → Append → Seal → Call invariant; missing icache flush on Darwin/ARM64 can cause intermittent SIGILL.
  • Keep the old mapping behavior in grow :129-148, because installed callables may still reference it. Only document this.
  • Gate: improve the first JIT benchmark iteration, including compilation. Steady-state must remain tied.

Phase 7 — Tracer Feedback: Narrow Input + Constant Facts + Exit Ordering

  1. Step narrow input in trace.go:34-44: record divisor, shift amount, array/struct index, branch condition, and BR_TABLE raw index. At :345, only the resolved target is currently recorded. Keep step size constrained because it scales per trace × opLimit=1024.
  2. Value constant facts in jit.go at :71-77: add known bool plus i32/i64 fields. Lowerer removal targets: div-zero guards for known non-zero divisors, boxable guards for proven in-range values, and shift masks for known shift amounts. Facts derived from observations/seen values still require guards; only constants originating from the code stream can remove guards without guards. Document this distinction.
  3. Order continuations by exit hit count: tree.hits/exits in trace.go:52-58 are already tracked, but pending order uses discovery order with a fixed pendingLimit=256 in jit.go:26. Prioritize hot exits.
  4. Relax tracer locking: replace shared Tracer.mu per sampled tick in observe with a per-interpreter local buffer, merging once when the trace completes. This targets pool warmup contention.
  • Tests: constant-divisor trace correctness, traced-variable zero divisor deopts to ErrDivideByZero, and exit-heat ordering. Keep tests inline; do not add helpers, per §6.8.
  • Gate: JIT benchmark improves by 3–10%, plus pool warmup benchmark improvement.

Phase 8 — Risky Items: Shape Feedback + i64 GET Continuation — Two Independent Commits, Individually Revertible

  1. Shape feedback: add shape struct{ itab, typ uintptr } to steps. Record shapes for REF_GET/ARRAY_LEN/ARRAY_GET/ARRAY_SET/STRUCT_GET/STRUCT_SET/ERROR_GET/ERROR_CODE/CORO_DONE/CORO_VALUE. The lowerer should replace multi-way matchItab cascades with a single guardItab, with mismatch going to op.ip side-exit. For structs, guard both itab == heapStruct and exact typ match. Ensure the container ref is still unreleased at the side-exit point — guard before release.

  2. i64 ARRAY_GET/STRUCT_GET continuation: remove the tracer terminal rule in trace.go:209-217 and the lowerer terminal behavior at :2639, :2830, then push raw KindI64 and continue the trace. Validate three cases:

    • If a later guard fails, deopt with the post-get stack shape; flush must handle heap promotion for non-boxable i64.
    • No double release of the container ref; audit the pre-snapshot point.
    • Non-boxable results still fall back.
  • Tests: polymorphic shape deopt correctness, monomorphic single guardItab confirmation, arithmetic on i64 elements greater than 2^47 followed by forced deopt, and confirmation that loops over i64 fields extend the trace.
  • Gate: go test -race ./... plus deopt tests are hard gates. If they fail, revert only the corresponding commit.

Convention Notes — Repo Rules

  • New Interpreter/lowering fields: scratch buffers are runtime-state slots; compiler caches are infrastructure slots, per §2.5.
  • Extracted helpers should be *Interpreter/lowerer methods, per §1.5. Keep single-call-site helpers of 15 lines or less inline, per §1.4.
  • No test helpers; one test per symbol; inline t.Run, per §6.1/§6.3/§6.8.
  • If the JIT contract changes, update docs/jit-internals.md in the same commit. If terminality changes, update docs/instruction-set.md in the same commit, per §8.
  • After each phase completes, leave a progress comment on issue Optimize guard emission, fusion, and tracer feedback #104 mapping the completed items.

Impact / Risk Ranking

  • Impact: P4 fusion > P2 allocation ≈ P5 JIT codegen > P8 > P7 > P3 > P6 > P1
  • Risk: P8 ≫ P4 > P7 > P5 > P6 > P2 > P3 > P1 — the order matches increasing risk.

Summary by CodeRabbit

  • New Features
    • Added ARM64 SBFX instruction support for bitfield-related encoding.
  • Performance Improvements
    • Reduced allocations by switching tracing Refs APIs to append into a caller-provided slice.
    • Improved code generation/linking by batching code buffer writes and applying relocations before resealing.
    • Enhanced JIT branch lowering, guards, and hot-path fusion.
  • API Changes
    • Updated Refs across traceable/runtime types from Refs() to Refs(dst []Ref) []Ref.
  • Bug Fixes
    • Strengthened bounds checks and refined guard/deoptimization and liveness behavior across arrays, structs, coroutines, and host calls.
  • Documentation / Tests
    • Refreshed JIT/architecture/memory-model docs and expanded test coverage for encoding and ref/buffer behavior.

siyul-park and others added 11 commits July 4, 2026 18:37
…ating host calls

Decoded byte/uint16 operands (LOCAL_GET/SET/TEE, GLOBAL_GET/SET/TEE, CONST_GET,
REF_TEST/CAST, ARRAY_NEW[_DEFAULT], STRUCT_NEW[_DEFAULT], MAP_NEW[_DEFAULT], and
the arm64 JIT's global lowering) decode idx as unsigned at compile time, so the
idx < 0 half of their bound checks can never trigger; only the upper-bound half
is reachable and is kept unchanged. Stack-derived runtime indices (ARRAY_SET/
GET/FILL/COPY/DELETE, STRUCT_GET/SET) are untouched since those genuinely can be
negative.

Also moves the i.fp == len(i.frames) frame-overflow check in the threaded CALL
handler (and removes its duplicate in fuseHostFunction's fused CALL path) so it
only gates the *types.Function/*types.Closure arms. Host calls never push a VM
frame, so they should never be blocked by frame exhaustion; RETURN_CALL's fused
host path already had no such check, this makes CALL consistent with it.

Phase 1 of an 8-phase perf overhaul (issue #104); pure dead-code removal + a
call-path reorder, no behavior change for function/closure overflow (regression
test still asserts ErrFrameOverflow). Added a host-call-succeeds-under-frame-
exhaustion case to TestWithFrame alongside the existing regression case.

Bench (5-sample, Apple M4 Pro, arm64), flat vs. pre-phase baseline as expected:

BenchmarkFib35/minivm_interp-12    ~670-675 ms/op   432 B/op   3 allocs/op
BenchmarkFib35/minivm_jit-12       ~46.2-46.5 ms/op 8733 B/op 81 allocs/op
BenchmarkJITIssue101/interp-12     ~1740-1775 ns/op
BenchmarkJITIssue101/jit-12        ~2842-2869 ns/op
BenchmarkJITIssue60/indirect_call_fib_via_local/{interp,jit}-12  ~819-822 us / ~57.0-57.5 us
BenchmarkJITIssue60/closure_counter_loop/{interp,jit}-12         ~17.3-17.4 us / ~1.12-1.13 us
BenchmarkJITIssue60/typed_array_sum/{interp,jit}-12              ~35.6-36.9 us / ~2.55-2.56 us

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase 2 of the minivm performance overhaul (issue #104 + extra findings).

- types.Traceable.Refs() -> Refs(dst []Ref) []Ref (append-style); callers in
  interp (release/trace/mark/sweep) now route through a shared *Interpreter
  scratch buffer via a new refs() helper instead of allocating a fresh slice
  per Traceable walk.
- Interpreter.Reset caches the ref-constant heap-base count once at
  construction instead of rescanning constants on every pooled Reset.
- wrapFunc (host function marshaling) caches its reflect arg-slot storage and
  return-value seen map across calls instead of allocating fresh per call;
  safe because HostFunction closures are never shared across Interpreters and
  recursive vm.Run from a host function is a documented invariant violation.
- MapIterator no longer uses reflect.MapRange per Next(); keys/entries are
  snapshotted once at construction.

Attempted a 5th change (reusing the release() worklist as an *Interpreter
field) but reverted it after benchmarking: it regressed
BenchmarkJITIssue60/closure_counter_loop/jit by ~25-45% (verified against a
live re-run of the prior commit, ruling out thermal noise) for no measurable
allocation win, since Go's escape analysis already kept the original local
slice cheap for this shallow-worklist workload. unboxI64's release/re-box
round-trip (a 6th planned change) was investigated and left alone: the
interpreter's free-list already reuses the just-freed slot in the common
case, so a bespoke in-place rewrite would add refcount-invariant risk for
marginal further gain.

Benchmarks (Apple M4 Pro, vs a live re-run of the prior commit to control for
session-long thermal drift):
- Fib35 interp/jit, JITIssue101, indirect_call_fib_via_local,
  typed_array_sum: flat
- closure_counter_loop interp/jit: same latency, allocs 4->2 per op
- types.BenchmarkTypedArray_Refs/child_refs: 7.58ns/1 alloc -> 2.08ns/0 allocs
- types.BenchmarkMap_Refs/child_refs: 30.5ns/1 alloc -> 24.9ns/0 allocs
- types.BenchmarkStruct_Refs/child_refs: 7.41ns/1 alloc -> 1.23ns/0 allocs

go test -race ./... green across all 14 packages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task reviewer for the Phase 2 commit found a real GC-correctness
regression in the snapshot-based MapIterator: for a generic *Map, the
construction-time snapshot embeds ref-typed key/value components
without retaining them, so a MAP_DELETE on an entry still queued in
the snapshot (not yet visited) could free it via the map's own
refcount before the iterator reached it - a dangling ref / potential
use-after-free. reflect.MapRange over the live map has no such
hazard: Go guarantees a key deleted mid-range is simply never
produced, matching docs/instruction-set.md's existing MAP_ITER
description ("without snapshotting keys"), which was correct all
along and did not need updating.

Also fixes a Minor finding: refsScratch (added in the Phase 2 commit)
was separated from the rest of the runtime-state field group by a
stray blank line; moved adjacent to rc with no separator, per
docs/coding-patterns.md §2.5.

go test -race ./... green across all 14 packages. types Refs/TypedMap_Get
benchmarks unaffected (0 allocs/op preserved for child_refs cases).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ialize UPVAL scalar retain/release

Phase 3 of the interpreter/JIT performance overhaul (issue #104 + extra
findings): collapses three near-duplicate host-call arg release loops into
Interpreter.releaseArgs, and five duplicated typed-array bounds-check blocks
(ARRAY_SET/FILL/DELETE/COPY, arrayGetAt) into Interpreter.checkBounds.

fuseHostFunction now proves at compile time whether every host param is a
non-ref, non-promotable scalar (I32/I1/I8/F32/F64 via Kind.Repr()) and emits
a closure that skips the arg-release scan entirely when so; I64 stays
excluded because it can promote to a heap ref, and callHost's runtime-
dispatched call site (target unknown until CALL executes) keeps the generic
scanning path unconditionally.

UPVAL_GET/UPVAL_SET gain the same compile-time scalar specialization as the
existing LOCAL_GET/SET/TEE, based on Function.Captures' declared per-slot
kind (threadedCompiler.captures, threaded through Compile's 3 call sites).
I64 and KindRef captures keep the generic ref-aware path.

GLOBAL_GET/SET/TEE intentionally left unchanged: globals have no static
per-index type (docs/verification.md, program/verify.go), so there is
nothing to prove at compile time, and retainBox/releaseBox's runtime check
is already fully inlined at every call site (confirmed via
`go build -gcflags=-m`) -- a hand-written runtime dispatch would duplicate
that check for no measurable benefit.

bench (Apple M4 Pro): Fib35 interp/jit and JITIssue60/101 match baseline;
closure_counter_loop improves (72B/4 allocs -> 56B/2 allocs, ~16800ns ->
~15100ns interp) from the UPVAL fast path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase 3 task review found the brief's required UPVAL_GET+ref-capture
retain test was missing (only UPVAL_SET's ref/promoted-i64 cases were
covered). Adds a WithTick(1)+WithHook sample of the capture's rc across
every tick, proving retainBox's copy briefly holds a second owner
alongside the closure's own capture slot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…+BR_IF fusion

Phase 4 of the interpreter-threaded fusion plan. Finishes work an earlier
session left uncommitted (fuseLocalConst/fuseLocalLocal flattening) and
implements the new comparison+BR_IF superinstruction category.

Fixed two bugs found in the already-written flattening code:
- fuseLocalLocal (LOCAL_GET a; LOCAL_GET b; binop) was implemented but never
  wired into LOCAL_GET's compile function -- dead code, now wired in.
- Every fuseLocalLocal{I32,I64,F32,F64} case advanced i.fr.ip by 3 instead of
  5 (the fused window is LOCAL_GET(2)+LOCAL_GET(2)+binop(1)=5 bytes), landing
  execution mid-instruction. This crashed optimize.TestOptimizer_Optimize's
  O3 subtests as soon as the dead code above was activated; fixed by
  correcting all 80 occurrences.

Added the new comparison+BR_IF fusion: I32/I64 EQZ + 10 comparisons, F32/F64
6 comparisons, and I32_CONST;BR_IF, via two shared helpers (peekBrIf,
Interpreter.branchIf) so the branch-taking logic exists once instead of once
per opcode. The 4-instruction LOCAL_GET+CONST+cmp+BR_IF composition falls out
naturally by extending fuseLocal{I32,I64,F32,F64}Const's comparison cases
with the same peek, no bespoke closure needed.

Added tests for all of the above, including two branch-into-fused-window
tests proving the probe-then-restore c.ip discipline holds when a separate
branch lands directly on a byte offset a fusion consumed at runtime.

Bench (Apple M4 Pro, -count 5, vs parent commit fb3888b):
  Fib35 interp-only: 659.8ms -> 501.6ms avg (~24% faster, within the
    plan's expected 10-25% range)
  Fib35 JIT mode:    46.86ms -> 46.29ms avg (~1.2%, parity as expected;
    this phase doesn't touch JIT lowering)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase 5 of the threaded/fusion/JIT plan.

Adds SBFX encoding/builder support and uses it for boxed i64 sign-extension and boxability checks. Reworks ARM64 guard lowering so common failure paths branch to deferred side exits, routes BR_IF divergence through branchOrExit labels, shares guardHeap/guardItab/guardIndex/guardRC, and reuses one lowering scratch slice for pre-op guard snapshots. The marked check now blocks only raw ref markers, allowing learned continuations over materialized ref operands.

Tests cover SBFX encoding, learned continuations over live materialized refs, and negative-index ARRAY_GET deopt through the unsigned guard. The rc==1 release side-exit was not forced with a white-box refcount mutation because repo test rules forbid fabricating unreachable state via unexported field writes.

Benchmarks (Apple M4 Pro): BenchmarkFib35 minivm_jit 53.5-54.1ms, minivm_interp 541.5-544.7ms; BenchmarkJITIssue101 jit 2.94-2.96us; Issue60 indirect-call jit 72.7-73.5us, closure jit 1.14-1.16us, typed_array_sum jit 2.67-2.69us; Refs/TypedMap_Get remain 0 allocs/op.

Verification: go test ./asm/arm64 ./interp; go test -race ./...; benchmarks run from benchmarks/ module for BenchmarkFib35 and BenchmarkJIT; go test -bench 'Refs|TypedMap_Get' -benchmem -run xxx ./types.
Phase 6 of the threaded/fusion/JIT plan.

Link now writes all code chunks and patches relocations while the active buffer mapping is open, then seals once. Public Buffer.Write keeps the old sealed-on-return contract. Grown-out old mappings are still sealed before archival, and patches to old mappings still use their own writable/executable round-trip.

Benchmarks (Apple M4 Pro): BenchmarkFib35 minivm_jit 53.7-54.0ms, minivm_interp 540.8-558.2ms; BenchmarkJITIssue101 jit 2.94-3.04us; Issue60 indirect-call jit 72.7-72.9us, closure jit steady 1.12-1.14us after first sample, typed_array_sum jit 2.70-2.81us; Refs/TypedMap_Get remain 0 allocs/op.

Verification: go test ./asm/... ./interp; go test -race ./...; benchmarks run from benchmarks/ module for BenchmarkFib35 and BenchmarkJIT; go test -bench 'Refs|TypedMap_Get' -benchmem -run xxx ./types.
Phase 7 of the threaded/fusion/JIT plan.

The tracer now records one narrow input per relevant step, and branch snapshots carry exit hit counts. ARM64 lowering keeps bytecode constants as known i32/i64 facts, removes divide-by-zero guards for known non-zero divisors, guards observed non-zero variable divisors before specializing the hot path, uses immediate masked shifts for known shift counts, orders pending continuations by hot exit count, and reloads i64 operands through the proper boxed-lane sign extension.

Observed variable shift guards were tried and removed: they regressed BenchmarkJITIssue60/closure_counter_loop/jit from the steady 1.12-1.14us range to about 1.57-1.64us, so the final code keeps only code-stream known shift specialization. Tracer local-buffer locking was left unchanged because capture replay already runs outside Tracer.mu and a merge buffer would broaden the pool/tracer protocol.

Tests cover constant non-zero i32/i64 divisors and variable divisor deopts for alternate non-zero and zero inputs. docs/jit-internals.md now documents speculative observed numeric facts and hot-exit pending ordering.

Benchmarks (Apple M4 Pro): BenchmarkFib35 minivm_jit 53.8-55.2ms, minivm_interp 538.4-543.6ms; BenchmarkJITIssue101 jit 2.966-3.019us; Issue60 indirect-call jit 72.6-73.4us, closure jit 1.156-1.167us, typed_array_sum jit 2.690-2.724us; Refs/TypedMap_Get remain 0 allocs/op.

Verification: go test ./interp; go test -race ./...; benchmarks run from benchmarks/ module for BenchmarkFib35 and BenchmarkJIT; go test -bench 'Refs|TypedMap_Get' -benchmem -run xxx ./types.
Phase 8 shape-feedback commit from the threaded/fusion/JIT plan.

The tracer now records heap shapes for ref, array, struct, error, and coroutine fast paths: heap interface table plus an exact struct type pointer where relevant. ARM64 lowering uses recorded shapes to collapse ref.get and array.len multi-way itab checks into guarded monomorphic paths, validates array get/set shapes, and guards struct get/set against the recorded struct type before using the fields table.

Tests warm typed shapes, then run mismatched array and struct shapes and assert both correct threaded results and recorded side-exit hits. docs/jit-internals.md documents recorded heap shape and exact struct type guards.

Benchmarks (Apple M4 Pro): BenchmarkFib35 minivm_jit 53.7-54.3ms, minivm_interp 538.7-551.8ms; BenchmarkJITIssue101 jit 2.922-2.977us; Issue60 indirect-call jit 73.1-74.0us, typed_array_sum jit 2.583-2.655us in full run and 2.612-2.651us in focused recount. Full-run closure jit showed a noisy 1.54-1.56us sample, but focused recount was steady 1.120-1.144us.

Verification: go test ./interp; go test -race ./...; benchmarks run from benchmarks/ module for BenchmarkFib35 and BenchmarkJIT; focused closure_counter_loop/jit and typed_array_sum/jit recounts; go test -bench 'Refs|TypedMap_Get' -benchmem -run xxx ./types.
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR updates buffered Refs handling across types and interp, expands interpreter and JIT capture/shape and guard logic, adds ARM64 SBFX support, switches buffer/link writes to batched patching, and adds a .gitignore entry.

Changes

Refs API buffer-reuse refactor

Layer / File(s) Summary
Traceable interface and type-level Refs
types/value.go, types/array.go, types/closure.go, types/error.go, types/map.go, types/string.go, types/struct.go, types/*_test.go
Refs changes to Refs(dst []Ref) []Ref across the interface and implementations, appending into dst; tests update to pass nil or reused slices.
Interp-side Refs and GC scratch buffer
interp/coroutine.go, interp/host.go, interp/interp.go, interp/*_test.go, docs/memory-model.md
Coroutine, HostObject, and GC paths use the buffered Refs form, and the memory-model docs describe the new append-style contract.

Interpreter capture, bounds, and fusion changes

Layer / File(s) Summary
Capture metadata and trace wiring
interp/interp.go, interp/trace.go, interp/jit.go, interp/marshal.go
Interpreter and compiler paths carry capture kinds through Compile, and tracing records branch legs, shapes, and operand metadata for JIT compilation.
Bounds helpers and opcode checks
interp/threaded.go
Adds shared bounds helpers and simplifies range checks across globals, locals, arrays, refs, structs, and maps.
Comparison and branch fusion
interp/threaded.go
I32_CONST and integer/float comparisons fuse with following BR_IF patterns via peekBrIf and branchIf.
Interpreter and JIT test coverage
interp/interp_test.go, interp/host_test.go
Adds cases for host calls, array bounds failures, fusion behavior, ref cleanup, capture ownership, and JIT deoptimization and continuation handling.

JIT lowering, guard paths, and trace shape recording

Layer / File(s) Summary
Lowering state and trace metadata
interp/jit.go, interp/trace.go
Lowering adds operand metadata and saved-state snapshotting, and tracing records branch legs, shapes, and capture kinds for traced code.
ARM64 guard, boxing, and branch lowering
interp/jit_arm64.go
The ARM64 lowerer adds stable pending ordering, known-value propagation, SBFX-based i64 handling, branch-or-exit lowering, heap and refcount guards, and updated ref/array/struct/error/coroutine paths.

ARM64 SBFX instruction encoding

Layer / File(s) Summary
SBFX opcode, builder, decoder, and encoder case
asm/arm64/instr.go, asm/arm64/encoder.go, asm/arm64/encoder_test.go
Adds OpSBFX, SBFX(), decodeRegImm2, and an encoder case with golden-word coverage.

Batched buffer writes and relocation patching

Layer / File(s) Summary
Buffer write and patch refactor
asm/buffer.go, asm/buffer_test.go
Buffer.Write delegates to write, writeBatch writes multiple chunks under one lock, and patch adds a batch-aware fast path, with tests covering success and error sealing.
Link uses batched writes
asm/link.go
Link writes code through writeBatch and routes relocation writes through buf.patch with a batch flag.

Tooling ignore list update

Layer / File(s) Summary
Ignore new tooling artifacts
.gitignore
.superpowers/ is added to the ignore list next to .codegraph/.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Sequence Diagram(s)

sequenceDiagram
  participant Tracer
  participant threadedCompiler
  participant ARM64Lowerer
  participant JITRuntime
  Tracer->>threadedCompiler: Compile(code, locals, captures)
  threadedCompiler->>ARM64Lowerer: emit fused branches and guards
  ARM64Lowerer->>JITRuntime: branchOrExit / guard failures / side exits
  JITRuntime->>Tracer: record shapes, legs, and hits
Loading

Estimated code review effort: 5 (Critical) | ~120 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and matches the PR's main focus on phased JIT fusion performance work.
Description check ✅ Passed It covers the main changes, related issue, and benchmark protocol, though not under the exact template headings.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-perf-jit-fusion-8phase

Comment @coderabbitai help to get the list of available commands.

Allow boxable i64 ARRAY_GET and STRUCT_GET results to remain raw native values so later trace ops can continue without a terminal threaded resume. Guard nonboxable loaded values before committing the read, preserving interpreter-owned heap-promoted i64 boxing.

Verification:

- go test ./interp

- go test -race ./...

- benchmarks: go test -bench 'BenchmarkFib35' -run xxx -count 5 . (benchmarks/)

- benchmarks: go test -bench 'BenchmarkJIT' -run xxx -count 5 . (benchmarks/)

- types: go test -bench 'Refs|TypedMap_Get' -benchmem -run xxx .

Benchmark ranges:

- Fib35 minivm_interp: 525.7-532.6 ms/op, 15532 B/op, 25 allocs/op

- Fib35 minivm_jit: 52.8-53.5 ms/op, 11792 B/op, 97 allocs/op

- JITIssue101/jit: 2.904-2.958 us/op, 0 allocs/op

- JITIssue60 indirect_call_fib_via_local/jit: 72.8-74.5 us/op, 119-121 B/op, 0 allocs/op

- JITIssue60 closure_counter_loop/jit: 1.119-1.169 us/op, 57 B/op, 2 allocs/op

- JITIssue60 typed_array_sum/jit: 2.633-2.658 us/op, 8 B/op, 0 allocs/op

- Types Refs/TypedMap_Get: 0 allocs/op across measured cases
@siyul-park
siyul-park force-pushed the worktree-perf-jit-fusion-8phase branch from 8c31fcc to 043d9f1 Compare July 5, 2026 01:54

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
interp/marshal.go (1)

607-609: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Optional: use SetZero() to reset each argument.

reflect.Zero(...) allocates a fresh zero Value per parameter per call; in[idx].SetZero() (Go 1.20+) resets in place without that allocation, which is in the spirit of this PR's scratch-reuse changes.

♻️ Proposed tweak
 		for idx := range in {
-			in[idx].Set(reflect.Zero(fnType.In(idx)))
+			in[idx].SetZero()
 			if err := unmarshal.value(params[idx], in[idx]); err != nil {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@interp/marshal.go` around lines 607 - 609, In the argument-reset loop inside
marshal.go, replace the per-parameter zero-value assignment on in[idx] with
in-place resetting via SetZero() so the scratch slice reuse avoids extra
allocations. Keep the existing unmarshal.value flow in the same loop, and make
sure the change is applied in the function that prepares fnType.In(idx) inputs
before invoking unmarshal.
asm/buffer.go (1)

139-142: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Document the batch && current precondition. The fast path copies directly into mem and depends on writeBatch’s unsealed window. A short note here would make the invariant explicit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@asm/buffer.go` around lines 139 - 142, Document the fast path in writeBatch
by adding a short note near the batch && current branch that makes the
unsealed-window precondition explicit. Reference writeBatch and the direct mem
copy path so it is clear this optimization only applies when the batch is
current and the buffer is still writable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@interp/interp.go`:
- Around line 283-289: baseHeap is being computed too broadly in Reset and can
overcount constants that do not actually allocate heap slots. Update the logic
around the i.constants scan so it only increments for constants that really
consume heap, and exclude Ref/Boxed cases that go through BoxRef without
allocating. Keep the fix localized to Reset and use the existing Kind/BoxRef
handling in interp.go to align the count with the initialized heap/rc length.

---

Nitpick comments:
In `@asm/buffer.go`:
- Around line 139-142: Document the fast path in writeBatch by adding a short
note near the batch && current branch that makes the unsealed-window
precondition explicit. Reference writeBatch and the direct mem copy path so it
is clear this optimization only applies when the batch is current and the buffer
is still writable.

In `@interp/marshal.go`:
- Around line 607-609: In the argument-reset loop inside marshal.go, replace the
per-parameter zero-value assignment on in[idx] with in-place resetting via
SetZero() so the scratch slice reuse avoids extra allocations. Keep the existing
unmarshal.value flow in the same loop, and make sure the change is applied in
the function that prepares fnType.In(idx) inputs before invoking unmarshal.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 89cc0cf3-41b3-4805-8494-a9c679462d61

📥 Commits

Reviewing files that changed from the base of the PR and between 3ee4b45 and f531a6e.

📒 Files selected for processing (33)
  • .gitignore
  • asm/arm64/encoder.go
  • asm/arm64/encoder_test.go
  • asm/arm64/instr.go
  • asm/buffer.go
  • asm/link.go
  • docs/architecture.md
  • docs/jit-internals.md
  • interp/coroutine.go
  • interp/coroutine_test.go
  • interp/fuse.go
  • interp/host.go
  • interp/host_test.go
  • interp/interp.go
  • interp/interp_test.go
  • interp/jit.go
  • interp/jit_arm64.go
  • interp/marshal.go
  • interp/threaded.go
  • interp/trace.go
  • types/array.go
  • types/array_test.go
  • types/closure.go
  • types/closure_test.go
  • types/error.go
  • types/error_test.go
  • types/map.go
  • types/map_test.go
  • types/string.go
  • types/string_test.go
  • types/struct.go
  • types/struct_test.go
  • types/value.go

Comment thread interp/interp.go Outdated
@siyul-park

Copy link
Copy Markdown
Owner Author

Overall, the direction looks good. In particular, the PR correctly targets real hot paths: removing unreachable guards, fixing the host-call frame check, eliminating Reset/Refs allocations, unifying host argument cleanup, adding compare+BR_IF fusion, and allowing i64 get traces to continue.

That said, the PR’s optimization scope is large, and the amount of added code and abstraction complexity is significant. Some commits also appear to introduce possible performance regressions based on the benchmark numbers included in the PR itself. I think the following items should be checked or addressed before merge.

  1. Possible performance regression: JIT time and allocations increase after the JIT phases

Looking at the benchmark numbers in the commit messages for the same benchmark, BenchmarkFib35/minivm_jit is around 46.246.5ms in the earlier phases, and around 46.29ms avg after phase 4. After perf(jit): share ARM64 guards and cold exits, it becomes 53.554.1ms, and the final commit still reports 52.853.5ms. That looks like roughly a 1417% slowdown.

The JIT allocation numbers also appear to increase from 8733 B/op, 81 allocs/op in the earlier benchmark to 11792 B/op, 97 allocs/op in the final benchmark. Since this is a performance PR, an increase in both runtime and allocations on a hot JIT benchmark needs an explanation before merge.

There may be differences in measurement conditions, thermal noise, or benchmark setup, so please include:

  • base vs final measurements under the same conditions
  • benchstat for each phase
  • JIT compile time vs execution time split
  • binary/text size impact
  • results for BenchmarkFib35, BenchmarkJITIssue101, and polymorphic array/struct workloads

The regression appears to start around share ARM64 guards and cold exits, so that commit may be safer as a separate PR unless the cause is identified and justified.

  1. checkBounds should be made negative-size and overflow safe now that it is centralized

In interp/interp.go, checkBounds(offset, size, length) currently uses this shape:

if offset < 0 || offset+size > length {
panic(ErrIndexOutOfRange)
}

This does not explicitly reject size < 0, and it is also vulnerable to offset + size overflow. For paths like ARRAY_FILL and ARRAY_COPY, where size comes from the stack, a negative size may pass this check if offset+size <= length, potentially leading to a slice panic or incorrect behavior later.

Since the bounds logic is now centralized, I suggest using the safer form:

if offset < 0 || size < 0 || size > length-offset {
panic(ErrIndexOutOfRange)
}

It would also be good to add tests for:

  • negative fill/copy size
  • near-overflow offset + size
  • size == 0
  • offset == length && size == 0
  1. The local-const/local-local fusion matrix in interp/fuse.go is too large and too manual

The commit perf(interp): flatten local-const/local-local fusions, add comparison+BR_IF fusion adds a very large amount of code across fuseLocalI32Const, fuseLocalI64Const, fuseLocalF32Const, fuseLocalF64Const, and the fuseLocalLocal* family. The optimization is useful, but the implementation repeats the same structure many times:

  • stack overflow checks
  • local address checks
  • constant decoding
  • typed binop/compare dispatch
  • IP width arithmetic
  • i64 boxing/refcount handling
  • compare+BR_IF branch width handling

The commit message mentions fixing “80 occurrences” of the wrong fused window width. That is a strong signal that this manual matrix is easy to get wrong.

I recommend simplifying this using one of the following approaches:

  • opcode metadata table + typed fusion builder
  • go generate / template-based generation
  • shared type-specific helpers combined with op tables
  • a dedicated generic helper for compare+BR_IF fusion

The current structure may be fast, but it is likely to be fragile whenever a new opcode or fusion pattern is added.

  1. ctx.pre() snapshot reuse needs an aliasing check

The JIT lowering code replaces fresh copies with a reused scratch slice:

ctx.saved = append(ctx.saved[:0], ctx.values...)
return ctx.saved

This can reduce allocations, but if the returned slice is stored or used later by sideExit, releaseRef, releaseBox, or any deferred side-exit path, a later pre() call may overwrite the same backing array and corrupt the previous snapshot.

If sideExit immediately emits code and does not store the slice, this may be safe. But the name pre() makes it look like a stable snapshot, while it is actually scratch storage.

Please consider one of the following:

  • make sideExit deep-copy any snapshot it stores
  • separate stable snapshots from scratch snapshots
  • rename this to something like preScratch() and document the lifetime
  • add a test that would catch side-exit snapshot aliasing
  1. Storing Go object pointers as long-lived uintptr in trace shape is unsafe

In perf(jit): guard heap reads with recorded shapes, trace shape stores a struct type pointer as uintptr:

type shape struct {
itab uintptr
typ uintptr
}

The code records it using something like uintptr(unsafe.Pointer(s.Typ)), then later uses it as a JIT guard.

Storing a Go object pointer as uintptr for a long lifetime is unsafe because the GC cannot see it as a live pointer. This may appear to work today because Go’s GC is non-moving, but it is still not a good invariant to rely on.

Safer options:

  • store shape.typ as *types.StructType or unsafe.Pointer so the GC can see it
  • convert to uintptr only at JIT emission time
  • preferably introduce a stable type ID/index instead of comparing pointer identity directly

Unsafe code may be acceptable in this part of the VM, but hiding a long-lived Go pointer from the GC should be avoided.

  1. guardHeap, heapValue, guardItab, and guardTag overlap in responsibility

The ARM64 lowering now has several related guard helpers:

  • heapValue
  • guardHeap
  • guardItab
  • guardTag
  • guardRC

Their responsibilities overlap across tag checking, heap cell loading, itab/type guarding, and side-exit wiring. This means callers must remember the correct combination of helpers for each path, which makes the abstraction less reliable.

A simpler design might be a single guardedHeapValue(...) path that validates a heap ref, loads the heap cell, and optionally applies shape/type guards. That would keep the invariant in one place instead of spreading it across call sites.

  1. The executable buffer batch sealing change is good, but it adds an avoidable allocation

The direction in perf(asm): batch executable buffer sealing during link is good. However, Link now builds an intermediate chunks := make([][]byte, len(codes)), and writeBatch also allocates bases := make([]unsafe.Pointer, len(codes)).

Since this PR is partly about removing allocation hotspots, the intermediate chunks slice is avoidable. writeBatch could accept []*Code directly or use an accessor callback so it can iterate over c.Bytes without building another slice.

  1. marshal.wrapFunc cached in / seen depends on a strong non-reentrant invariant

The host function wrapper now reuses the reflect argument slice and seen map across calls. This is good for allocations, but the closure is now stateful.

If the host function wrapper is guaranteed to be called sequentially within a single interpreter, and recursive VM entry from a host function is forbidden, this is probably okay. But if that invariant is ever violated, this can lead to:

  • in slice overwrite during nested/reentrant calls
  • seen map clearing at the wrong time
  • data races under concurrent calls

If this is intentionally forbidden by design, please make that constraint explicit in comments and tests. Otherwise, the scratch state should probably move to a call frame or remain per-call.

  1. Pending continuation sorting may increase trace compile time

The trace continuation path now sorts pending continuations by hit count. If the pending list is small, this is probably fine. But sorting a suffix repeatedly inside the lowering loop could add unnecessary compile-time cost.

Please include a trace compile-time benchmark or document the bound that makes this safe. If the pending limit is small and intentional, a comment near the sorting logic would help.

  1. Documentation/style: the architecture doc addition is too dense

The added paragraph in docs/architecture.md is useful, but it packs too many concepts into one long paragraph. It would be much easier to review and maintain if split into sections such as:

  • interpreter fusion
  • threaded dispatch
  • JIT guard lowering
  • trace feedback
  • RC / host cleanup
  • known trade-offs

The same concern applies to some code comments: optimization rationale and current implementation details should be separated where possible, so future performance regression analysis is easier.

Per-commit summary

  • chore: ignore .superpowers/ scratch workspace: looks fine.
  • fix(interp): remove unreachable idx<0 checks...: removing unreachable checks and fixing the host-call frame check placement looks correct.
  • feat(interp,types): remove core runtime allocation hotspots: direction is good, but wrapFunc stateful closure and Refs(dst) scratch lifetime need explicit invariants in comments/tests. The map iterator snapshot issue is fixed by the next commit, so the final state looks okay.
  • fix(types): revert map iterator...: good revert. The snapshot approach could break ref key/value lifetime.
  • perf(interp): unify host-arg cleanup...: checkBounds should be made negative-size and overflow safe.
  • test(interp): cover UPVAL_GET...: looks fine.
  • perf(interp): flatten local-const/local-local fusions...: functionally useful, but the code volume and duplication are too high. A generator/table-based approach is strongly recommended.
  • perf(jit): share ARM64 guards and cold exits: this appears to be where the JIT benchmark regression starts. Please check ctx.pre() aliasing and guard abstraction overlap.
  • perf(asm): batch executable buffer sealing...: direction is good, but the intermediate chunks allocation can be removed.
  • perf(jit): feed narrow trace facts...: the known-fact model is useful, but compile-time cost and pending sort cost should be checked.
  • perf(jit): guard heap reads with recorded shapes: long-lived uintptr storage of Go pointers should be replaced.
  • perf(jit): continue traces after boxable i64 gets: direction is good and improves over phase 11, but it does not appear to recover the earlier JIT performance level. Please re-measure the full PR against base.

Conclusion

I agree with the optimization direction, but this PR should not be merged yet without clarifying the JIT time/allocation regression and tightening the safety of a few new abstractions.

Minimum items I would like to see before merge:

  1. base vs final benchstat
  2. JIT compile time / execution time / allocation split
  3. negative-size and overflow-safe checkBounds
  4. confirmation or fix for ctx.pre() snapshot aliasing
  5. removal of long-lived Go pointer storage as uintptr in trace shape
  6. either simplify the fusion matrix with a generator/table-based approach, or create a clear follow-up issue for it

Once these are addressed, the PR’s optimization intent should be much safer to merge.

siyul-park and others added 3 commits July 5, 2026 13:39
…reter races

wrapFunc's doc comment claimed a marshaled *HostFunction is never
cached/shared across Interpreters or goroutines, so it closed over
call-scoped `in []reflect.Value` and `seen map[uintptr]bool` and reused
them across every invocation. That premise is false: a *HostFunction
returned by vm.Marshal(fn) is a types.Value, and program.WithConstants
stores it directly (New's default constant case does
`types.BoxRef(i.keep(v))`, which keeps the Go value itself, not a
copy). Two pooled Interpreters built from the same program therefore
share one *HostFunction, and concurrent calls from separate goroutines
raced on `in[idx].Set(...)` and `clear(seen)`.

Fix moves `in` and `seen` inside the returned closure so each call
gets fresh locals - no locks or sync.Pool needed.

Verification:
- go build ./... && go vet ./... clean.
- Added TestInterpreter_Marshal/marshaled_function_is_shared_and_race-safe_across_interpreters
  in interp/interp_test.go: marshals a Go func, builds two programs that
  reference the same *HostFunction via WithConstants, runs two
  Interpreters concurrently, asserts both results correct.
- Confirmed the race before the fix: `go test -race -run TestInterpreter_Marshal
  ./interp -count=5` reported a concurrent map write and a concurrent
  reflect.Value.SetInt race, and failed assertions (expected 3 got 2,
  expected 30 got 20) from cross-contaminated scratch.
- After the fix: `go test -race ./interp` passes (5/5 repeats of the
  new subtest, full package race-clean).
- Also corrected a stale host_test.go comment ("wrapFunc caches it
  across calls") that asserted the same now-removed caching behavior.

Deviation: task instructions named interp/marshal_test.go for the new
test, but no such file exists and none ever has (checked git log
--all). docs/coding-patterns.md §6.2 states Interpreter methods'
tests belong in interp_test.go even when implemented in a smaller
supporting file - marshal.go only supports Interpreter.Marshal's
public behavior - so the subtest was added to the existing
TestInterpreter_Marshal in interp_test.go instead of a new file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 31.35436% with 370 lines in your changes missing coverage. Please review.
✅ Project coverage is 46.34%. Comparing base (3ee4b45) to head (3a4829b).

Files with missing lines Patch % Lines
interp/threaded.go 17.93% 252 Missing and 50 partials ⚠️
interp/trace.go 42.50% 19 Missing and 4 partials ⚠️
asm/buffer.go 59.37% 8 Missing and 5 partials ⚠️
asm/arm64/encoder.go 50.00% 6 Missing and 6 partials ⚠️
asm/link.go 0.00% 7 Missing ⚠️
interp/interp.go 75.00% 4 Missing and 1 partial ⚠️
interp/jit.go 0.00% 4 Missing ⚠️
types/map.go 78.57% 3 Missing ⚠️
interp/marshal.go 66.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #106      +/-   ##
==========================================
- Coverage   52.57%   46.34%   -6.24%     
==========================================
  Files          77       77              
  Lines       14684    17390    +2706     
==========================================
+ Hits         7720     8059     +339     
- Misses       6125     8408    +2283     
- Partials      839      923      +84     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
interp/threaded.go (1)

5123-5127: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

interp/threaded.go:5123 — reject negative sizes in bounds

bounds still accepts negative size, so ARRAY_COPY can reach copy(dst[dstOffset:dstOffset+size], ...) with a negative slice bound and panic at runtime instead of raising ErrIndexOutOfRange. ARRAY_FILL also treats negative sizes as a no-op. Use an overflow-safe check that rejects size < 0 too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@interp/threaded.go` around lines 5123 - 5127, The bounds helper currently
only checks offset and overflow past length, so negative size values can slip
through and cause runtime slice panics or silent no-ops in callers like
ARRAY_COPY and ARRAY_FILL. Update the Interpreter.bounds check to explicitly
reject size < 0 in addition to the existing range validation, and make the
validation overflow-safe so offset+size cannot wrap. Keep the fix localized to
bounds so all callers inherit the corrected ErrIndexOutOfRange behavior.
interp/jit_arm64.go (2)

2539-2638: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Consider a shared helper for the repeated heap-guard/RC boilerplate in arrayGet/structGet/structSet.

All three functions repeat the same pre()boxsideExitguardHeap/guardItabguardIndexguardRC/retainBox sequence with only the element-read/write logic differing. The divergence found between arrayGet and structGet's retain/rc ordering (see the critical comment on structGet) is a direct consequence of this duplication. Extracting the shared guard/RC sequence into one helper would remove this class of copy-paste drift risk.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@interp/jit_arm64.go` around lines 2539 - 2638, Extract the shared lowering/GC
safety sequence used by arrayGet, structGet, and structSet into a helper so the
guard and reference-counting logic stays consistent. Move the repeated
pre()/box()/sideExit()/guardHeap()/guardItab()/guardIndex()/guardRC()/retainBox
flow behind a common function, and keep only the element load/store specifics in
the callers. Make sure the helper preserves the correct ordering for rc updates
and box retention so arrayGet and structGet cannot drift apart again.

2712-2775: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Move the ref-field retain after the RC store
structGet retains result before it decrements/stores the container RC, so a self-referential field can have its increment overwritten by the later STRR. This drops the retain and can free the object too early.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@interp/jit_arm64.go` around lines 2712 - 2775, structGet is retaining ref
fields too early, before the container’s reference count is decremented and
written back, which can overwrite the increment for self-referential values.
Update arm64Lowerer.structGet so the RC decrement/store via guardRC, SUBI, and
STRR happens before calling retainBox on the loaded result when out is
types.KindRef. Keep the existing guards and result handling intact, but move the
ref-retain to after the RC store using the same result and rcBase/addr flow.
🧹 Nitpick comments (1)
asm/buffer_test.go (1)

45-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unchecked Free error (pre-existing pattern, static-analysis flagged).

Static analysis flags defer b.Free() (Line 48) for its unchecked error return. Same pattern already exists elsewhere in the file (e.g. earlier tests), so this is consistent style rather than a new regression — flagging only because the linter surfaced it on this diff.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@asm/buffer_test.go` around lines 45 - 60, Unchecked error from Buffer.Free in
the test cleanup is being ignored. Update the deferred cleanup in the
NewBuffer-based test to capture and assert the error returned by b.Free rather
than discarding it, using the same test helper style already used around
writeBatch and patch. Keep the fix localized to this test so the buffer is still
freed while satisfying the linter.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@interp/jit_arm64.go`:
- Around line 2539-2638: Extract the shared lowering/GC safety sequence used by
arrayGet, structGet, and structSet into a helper so the guard and
reference-counting logic stays consistent. Move the repeated
pre()/box()/sideExit()/guardHeap()/guardItab()/guardIndex()/guardRC()/retainBox
flow behind a common function, and keep only the element load/store specifics in
the callers. Make sure the helper preserves the correct ordering for rc updates
and box retention so arrayGet and structGet cannot drift apart again.
- Around line 2712-2775: structGet is retaining ref fields too early, before the
container’s reference count is decremented and written back, which can overwrite
the increment for self-referential values. Update arm64Lowerer.structGet so the
RC decrement/store via guardRC, SUBI, and STRR happens before calling retainBox
on the loaded result when out is types.KindRef. Keep the existing guards and
result handling intact, but move the ref-retain to after the RC store using the
same result and rcBase/addr flow.

In `@interp/threaded.go`:
- Around line 5123-5127: The bounds helper currently only checks offset and
overflow past length, so negative size values can slip through and cause runtime
slice panics or silent no-ops in callers like ARRAY_COPY and ARRAY_FILL. Update
the Interpreter.bounds check to explicitly reject size < 0 in addition to the
existing range validation, and make the validation overflow-safe so offset+size
cannot wrap. Keep the fix localized to bounds so all callers inherit the
corrected ErrIndexOutOfRange behavior.

---

Nitpick comments:
In `@asm/buffer_test.go`:
- Around line 45-60: Unchecked error from Buffer.Free in the test cleanup is
being ignored. Update the deferred cleanup in the NewBuffer-based test to
capture and assert the error returned by b.Free rather than discarding it, using
the same test helper style already used around writeBatch and patch. Keep the
fix localized to this test so the buffer is still freed while satisfying the
linter.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4618e8bf-0535-4811-a2ea-5ea6a6a1028f

📥 Commits

Reviewing files that changed from the base of the PR and between 05a3183 and de1396c.

📒 Files selected for processing (12)
  • asm/buffer.go
  • asm/buffer_test.go
  • asm/link.go
  • docs/architecture.md
  • docs/memory-model.md
  • interp/fuse.go
  • interp/interp.go
  • interp/interp_test.go
  • interp/jit.go
  • interp/jit_arm64.go
  • interp/threaded.go
  • interp/trace.go
✅ Files skipped from review due to trivial changes (1)
  • docs/architecture.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • interp/jit.go
  • asm/buffer.go
  • interp/interp.go
  • interp/trace.go

@siyul-park
siyul-park merged commit 7655736 into main Jul 5, 2026
4 of 6 checks passed
@siyul-park
siyul-park deleted the worktree-perf-jit-fusion-8phase branch July 5, 2026 07:04
@coderabbitai coderabbitai Bot mentioned this pull request Jul 5, 2026
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.

1 participant