Skip to content

perf(interp): defer ref operand ownership to backing slots in the ARM64 JIT#151

Merged
siyul-park merged 3 commits into
mainfrom
claude/benchmark-bottleneck-analysis-fc3428
Jul 16, 2026
Merged

perf(interp): defer ref operand ownership to backing slots in the ARM64 JIT#151
siyul-park merged 3 commits into
mainfrom
claude/benchmark-bottleneck-analysis-fc3428

Conversation

@siyul-park

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

Copy link
Copy Markdown
Owner

Problem

docs/benchmarks.md's worst competitive row was Sieve(256): 5,052 ns adaptive vs wazero 645 ns (7.8x) — the representative pattern of a runtime-allocated array held in a local. A control experiment isolated the cause: per array access, TypedArraySum(256) (constant array) costs 2.28 ns while Sieve costs 6.89 ns — 3x for the same element type and JIT. The delta is pure refcount churn: every ref LOCAL_GET emitted a retain and the consuming ARRAY_GET/ARRAY_SET emitted a matching guardRC + release — two serialized read-modify-writes on the same rc cell, per element, net zero.

Change

The JIT fusion layer already had a deferral concept for compile-time constant ref markers (raw refs: no retain at push, box() materializes it, sideExit.retain balances exits). This PR unifies that into a single ownership model and extends it to every slot-backed producer:

  • value gains owner (ownerStack/ownerConst/ownerLocal/ownerGlobal/ownerUpval) and home. Only ownerStack carries its own retain; every other owner borrows from backing storage.
  • ProducersLOCAL_GET/GLOBAL_GET/UPVAL_GET of a ref, const markers, and DUP of a deferred ref push deferred with no retain.
  • ConsumersARRAY_GET/SET, STRUCT_GET/SET, ARRAY_LEN, REF_IS_NULL, DROP, and coroutine/error/string reads borrow the container and elide its guardRC/release when deferred (element/payload retains are unchanged).
  • Materialization points take exactly one retain wherever the value reaches interpreter-visible state:
    • own — slot stores, call-argument transfer, entry-frame returns
    • settle — backing slot overwritten (*_SET) or frame dying (stitch, tail dispatch)
    • materializeExits — guard exit stubs reload each deferred operand from its flushed VM stack home and retain on the cold path only
    • redeem — stub-less deopts (trap fallback, module completion)
    • directCall/selfCall own every live deferred operand before the BL (a callee trap adopts the caller's flushed stack)
  • A committing (loop back-edge) flush rejects live deferred refs — a loop-carried deferral keeps the trace threaded rather than leaking one retain per iteration.
  • The consume() peephole and sideExit.retain scalar field are subsumed and removed.

Results (Apple M4 Pro, darwin/arm64, Go 1.26.2, median of 3 × 300ms, serial)

Workload before after
Sieve(256) default 5,052 ns 4,722 ns
Sieve(256) jit 5,017 ns 4,719 ns

Other rows moved within noise or from earlier branch commits; docs/benchmarks.md refreshed as one consistent measurement set (July 16, 2026).

Safety

The RC invariant (exact and symmetric on every path, including deopts) is preserved by construction: each deferred value is retained exactly once on any path that hands it to the interpreter. TestARM64_DeferredRefElision adds a JIT-vs-threaded oracle asserting popped results and every heap refcount agree across 48 lockstep runs, covering: local/global/upval backers, DUP fan-out, backer overwrite (settle), terminal-op trap (redeem), module completion, call-argument transfer, and self tail-call forwarding. Each regression test was verified to fail with its fix reverted.

Test plan

  • go test -race ./... — all 16 packages green
  • benchmarks correctness suite across default/threaded/jit modes
  • go vet, make lint clean
  • Full -tags=compare external comparison re-measured serially; docs/benchmarks.md, docs/jit-internals.md (new Reference Ownership section), docs/memory-model.md, AGENTS.md, docs/architecture.md updated

Known follow-ups (out of scope)

  • Loop-invariant hoisting of heap-cell/itab/slice-header reloads — the remaining Sieve gap to wazero.
  • A pre-existing (baseline-reproducible) JIT miscompile when a ref lingers on the operand stack across a loop header — unrelated to this change, found while writing coverage; deserves its own investigation.

Summary by CodeRabbit

  • Bug Fixes
    • Improved ARM64 JIT correctness for deferred ref operands, ensuring retains are properly owned/redeemed across calls, branches, exits, trap fallback, and module completion, and preventing deferred refs from persisting across committing loop back-edges.
  • Tests
    • Added an ARM64 regression test covering deferred ref elision scenarios and verifying exact JIT vs threaded interpreter parity, including heap refcount stability.
  • Documentation
    • Updated JIT, architecture, and memory-model docs to clarify deferred ref ownership/flush rules; refreshed benchmark results with new measurement data.

…64 JIT

A ref pushed by LOCAL_GET/GLOBAL_GET/UPVAL_GET took a retain that its
container consumer immediately released through guardRC — two dependent
read-modify-writes on the same rc cell per element, making Sieve(256)
3x slower per array access than the constant-array TypedArraySum(256).

Unify the existing raw const-marker deferral into a single owner model:
a ref value is ownerStack (owns its retain) or defers it to backing
storage (ownerConst/Local/Global/Upval). Producers push deferred with no
retain; container consumers (ARRAY/STRUCT GET/SET, ARRAY_LEN,
REF_IS_NULL, DROP, coro/error/string reads) borrow and elide the
matching release. The retain materializes only where the value reaches
interpreter-visible state: own (slot stores, call args, entry returns),
settle (backing slot overwritten or frame dying), guard exit stubs,
redeem (trap fallback and module completion), and before real calls.
A committing back-edge flush rejects live deferred refs, keeping
loop-carried deferrals threaded. The consume() peephole is subsumed.

Sieve(256) default 5,052 -> 4,722 ns/op (-7%, jit 4,719); refcount
exactness verified against a threaded oracle across deopt, call,
tail-call, and completion paths.
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 87aae1f1-0be2-4460-a66e-207ad62dc617

📥 Commits

Reviewing files that changed from the base of the PR and between 1165e33 and edf6d68.

📒 Files selected for processing (4)
  • docs/jit-internals.md
  • interp/jit.go
  • interp/jit_arm64.go
  • interp/jit_arm64_test.go

📝 Walkthrough

Walkthrough

The ARM64 JIT now tracks deferred reference ownership explicitly, borrows retains during native execution, and materializes or redeems them at interpreter-visible boundaries. Lowering, calls, exits, flushes, documentation, benchmarks, and regression tests were updated.

Changes

Deferred reference ownership

Layer / File(s) Summary
Ownership metadata and contracts
interp/jit.go, docs/architecture.md, docs/jit-internals.md, docs/memory-model.md, AGENTS.md
Values track reference backing and slots; documentation defines deferred ownership and committing-flush constraints.
Deferred operand lowering
interp/jit_arm64.go
Reference loads, stores, constants, snapshots, cold exits, and ownership helpers use explicit backing states.
Heap and container reference operations
interp/jit_arm64.go
Reference predicates, container accesses, error reads, and coroutine operations conditionally retain or release based on ownership.
Call, exit, frame handoffs, and validation
interp/jit_arm64.go, interp/jit_arm64_test.go, docs/benchmarks.md
Calls, tail transitions, traps, completion, and flushes enforce retain ownership; ARM64 tests compare values and refcounts, and benchmark results are refreshed.

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

Sequence Diagram(s)

sequenceDiagram
  participant CompiledJIT
  participant Flush
  participant Redeem
  participant Interpreter
  CompiledJIT->>Flush: flush deferred operand stack
  Flush->>Redeem: redeem deferred retains
  Redeem->>Interpreter: transfer retained references
  Interpreter->>CompiledJIT: return or trap
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main ARM64 JIT ref-ownership change.
Description check ✅ Passed The description is detailed and covers the changes, results, safety, tests, and follow-ups, though it doesn't use the template headings exactly.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/benchmark-bottleneck-analysis-fc3428

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

@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 29.57%. Comparing base (b472a30) to head (1165e33).

Files with missing lines Patch % Lines
interp/jit.go 0.00% 7 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #151   +/-   ##
=======================================
  Coverage   29.57%   29.57%           
=======================================
  Files          86       86           
  Lines       60181    60185    +4     
=======================================
+ Hits        17796    17798    +2     
- Misses      41118    41121    +3     
+ Partials     1267     1266    -1     

☔ 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.

Actionable comments posted: 5

🧹 Nitpick comments (1)
interp/jit_arm64_test.go (1)

1313-1340: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Inline the lockstep harness into each subtest.

balanced hides setup, execution, and assertions for four cases. Inline this harness per t.Run, including the native-entry assertion.

As per coding guidelines, “Inline test setup, execution, and assertions unless the testing documentation explicitly allows a helper.”

🤖 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_test.go` around lines 1313 - 1340, Remove the shared
balanced helper and inline its JIT/reference setup, 48-iteration lockstep
execution, result comparison, refcount comparison, and reset logic directly into
each of the four t.Run subtests. Preserve the existing assertions and add each
case’s native-entry assertion within its respective subtest.

Source: Coding guidelines

🤖 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/jit_arm64_test.go`:
- Around line 1092-1098: Extend the refcount assertions in the upvalue and
overwrite sub-cases of DeferredRefElision to compare rc[1:] with the threaded
execution state before each Reset(). Apply the same full-state comparison to the
additional cases around the referenced ranges, while preserving their existing
result and replacement-array assertions.
- Around line 1141-1155: Update the JIT regression tests around each listed
instance creation, including the test containing New(prog, WithTick(1),
WithThreshold(0)), to attach a profiler to every JIT instance and assert that
the vm_jit_native_entries_total metric is greater than zero after execution.
Preserve the existing assertions while ensuring each test proves native code was
entered rather than only fallback execution.

In `@interp/jit_arm64.go`:
- Around line 2484-2495: Remove the unreachable ctx.values ownership scan at
interp/jit_arm64.go lines 2484-2495; locals already owns transferred arguments,
so retain the surrounding control flow without this loop. Remove the equivalent
scan at interp/jit_arm64.go lines 2527-2541; locals handles arguments before
frame replacement, and no replacement logic is needed there.
- Around line 3228-3232: Materialize deferred ref ownership before transferring
refs into interpreter-visible storage: in interp/jit_arm64.go lines 3228-3232,
update the array mutation path to call own on the ref value before capturing pre
and storing the array element; apply the same ordering in lines 3481-3485 for
the struct-field mutation path. Use the existing ownership helper and preserve
the current mutation behavior.
- Around line 4094-4127: Update flush to pre-scan ctx.values at function entry
when commit is true, rejecting any ownerConst or other deferred owner before
emitting ARM64 instructions or mutating symbolic state. Preserve ownerStack
validation and the existing non-commit emission behavior, then perform the
current write/clear logic only after the commit validation succeeds.

---

Nitpick comments:
In `@interp/jit_arm64_test.go`:
- Around line 1313-1340: Remove the shared balanced helper and inline its
JIT/reference setup, 48-iteration lockstep execution, result comparison,
refcount comparison, and reset logic directly into each of the four t.Run
subtests. Preserve the existing assertions and add each case’s native-entry
assertion within its respective subtest.
🪄 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: 1dbca822-b529-4ed7-9410-dc3cea6d5285

📥 Commits

Reviewing files that changed from the base of the PR and between b472a30 and df796b2.

📒 Files selected for processing (8)
  • AGENTS.md
  • docs/architecture.md
  • docs/benchmarks.md
  • docs/jit-internals.md
  • docs/memory-model.md
  • interp/jit.go
  • interp/jit_arm64.go
  • interp/jit_arm64_test.go

Comment thread interp/jit_arm64_test.go
Comment thread interp/jit_arm64_test.go Outdated
Comment thread interp/jit_arm64.go Outdated
Comment thread interp/jit_arm64.go Outdated
Comment thread interp/jit_arm64.go Outdated
@siyul-park
siyul-park merged commit 6acdf68 into main Jul 16, 2026
1 of 2 checks passed
@siyul-park
siyul-park deleted the claude/benchmark-bottleneck-analysis-fc3428 branch July 16, 2026 13:31
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