Skip to content

fix(interp): let a committing flush pass through a live ref operand#149

Merged
siyul-park merged 2 commits into
mainfrom
claude/indirect-recursive-fib-jit-d79f7b
Jul 15, 2026
Merged

fix(interp): let a committing flush pass through a live ref operand#149
siyul-park merged 2 commits into
mainfrom
claude/indirect-recursive-fib-jit-d79f7b

Conversation

@siyul-park

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

Copy link
Copy Markdown
Owner

Summary

BenchmarkCall_IndirectRecursiveFib/default drops from 598,302 → 80,548 ns/op (7.4x). It previously ran slower than threaded; it now beats it by 6.9x.

The cause was not per-call overhead — nothing compiled at all. The profiler recorded exactly one attempt, outcome=rejected, reason=lowering-rejected, and zero native entries.

arm64Lowerer.flush refused to commit whenever any live operand was a KindRef:

if v.kind == types.KindRef && commit {
    return false
}

selfCall is the only caller that reaches that loop with live values (tailLoop pops all args and bails unless the operand stack is empty). indirectRecursiveFib passes its own callee as a ref argument, so a KindRef was always live there and the whole compile was rejected. observe() never retries a rejected anchor, so the function stayed interpreted for the process lifetime. recursiveFib's args are i32-only, which is the entire difference between 38 us and 598 us.

Why this is refcount-safe

The real hazard is only the raw marker: box() materializes and retains it because the interpreter frame being rebuilt will release it — and a commit rebuilds no frame, so nothing balances that retain. A non-raw ref already carries the retain taken when it was pushed (localGet retains before ctx.push), and box() returns it with no extra retain. Committing therefore transfers that single ownership edge to the stack — precisely what the ordinary inlined call path already does when it stores arguments and truncates ctx.values without releasing.

So the check is simply over-broad. Rejecting only raw refs is both necessary and sufficient: raw markers are produced only fused with their immediate consumer and cannot survive as an unrelated live operand at a self-call.

Test plan

  • New TestARM64_SelfCallWithRefArg — differential-tests the JIT result against a WithThreshold(-1) threaded reference, and asserts a trace is actually installed (entryFunction, vm_jit_native_entries_total > 0).
  • Verified the test guards the fix: reverting the one-line change makes compiles natively fail (compiled.module nil). The correctness subtest alone passes either way via interpreted fallback, which is why the install assertion carries the regression.
  • go test -race ./... green (both modules); make fuzz clean; gofmt/go vet clean.
  • Benchmarks re-measured on this branch at canonical benchtime=1s -count=5; docs/benchmarks.md updated with those medians.

Eager jit (threshold 0) is unchanged at ~575 us — it can compile before representative traces are learned, already documented in the threshold-zero note. Only adaptive default benefits.

Notes

docs/jit-internals.md gains the committing-flush ownership contract.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Fixed ARM64 native execution for self-recursive functions that forward reference parameters.
    • Added stricter validation to reject unsupported “raw” reference states during commit behavior.
  • Performance
    • Refreshed benchmark results for IndirectRecursiveFib(20) across execution modes.
  • Documentation
    • Updated JIT internals guidance on correctness for committing flush operations and reference handling.
    • Updated benchmark documentation with revised narrative and table values.
  • Tests
    • Added an ARM64-only regression test covering self-call with a reference argument.

arm64Lowerer.flush refused to commit whenever any live operand was a
KindRef. selfCall is the only caller that reaches that loop with live
values, so every self-recursive function forwarding a ref argument to
itself failed to lower, rejecting the whole compile. observe() never
retries a rejected anchor, so the function stayed interpreted for the
process lifetime.

The hazard is only the raw marker: box() materializes and retains it for
an interpreter frame to release, and a commit rebuilds no frame. A
non-raw ref already carries the retain taken when it was pushed, so
committing transfers that edge to the stack, exactly as the inlined call
path does when it stores arguments and drops them from the operand
stack. Reject only raw refs.

BenchmarkCall_IndirectRecursiveFib/default: 598,302 -> 80,548 ns/op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The ARM64 JIT now rejects raw references during committing flushes. A new test validates self-recursive reference forwarding and native profiler entries, while benchmark and JIT-internals documentation are updated.

Changes

ARM64 self-call reference forwarding

Layer / File(s) Summary
Commit reference ownership rule
interp/jit_arm64.go, docs/jit-internals.md
Committing flushes reject raw reference markers while accepting live non-raw references transferred to the VM stack; the ownership rule is documented.
Self-call validation and benchmark evidence
interp/jit_arm64_test.go, docs/benchmarks.md
The ARM64 test validates recursive reference arguments and native profiler entries, while IndirectRecursiveFib(20) measurements are updated.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main fix: allowing committing flushes to carry live ref operands in the interp JIT.
Description check ✅ Passed The description covers the change, rationale, and testing, and is mostly complete despite not using the template headings exactly.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/indirect-recursive-fib-jit-d79f7b

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

@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: 2

🤖 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 1107-1152: The “compiles natively” subtest manually invokes
capture, compilation, and installation instead of exercising the runtime path.
Remove that manual setup and attach the existing profiler to the repeated-run
test, then assert that vm_jit_native_entries_total is greater than zero after
normal execution through observe/compile gating; avoid assertions about private
trace or module structure.

In `@interp/jit_arm64.go`:
- Around line 3892-3898: Move the raw-reference preflight loop in the lowering
function containing ctx.values before any store emission or localDirty clearing.
Ensure commit with a raw KindRef returns false before mutating IR, stack,
parameters, facts, or labels, while preserving the existing lowering behavior
for supported references.
🪄 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: e0d9f235-fddc-40cf-84a4-86f04b66215c

📥 Commits

Reviewing files that changed from the base of the PR and between 7be9930 and 6682590.

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

Comment thread interp/jit_arm64_test.go Outdated
Comment thread interp/jit_arm64.go
Comment on lines +3892 to 3898
// A non-raw ref carries the retain taken when it was pushed, so committing it
// transfers that edge to the stack. A raw marker instead retains inside box()
// for an interpreter frame to release; a commit rebuilds no frame, so nothing
// would balance it.
for j, v := range ctx.values {
if v.kind == types.KindRef && commit {
if v.kind == types.KindRef && commit && v.raw {
return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preflight raw references before mutating lowering state.

Line 3897 can return false after stores were emitted and localDirty flags cleared. Move this check before any flush mutation.

Proposed fix
 func (l arm64Lowerer) flush(ctx *lowering, commit bool) bool {
+	if commit {
+		for _, v := range ctx.values {
+			if v.kind == types.KindRef && v.raw {
+				return false
+			}
+		}
+	}
 	a := ctx.assembler
 	...
 	for j, v := range ctx.values {
-		if v.kind == types.KindRef && commit && v.raw {
-			return false
-		}
 		boxed, ok := l.boxHome(ctx, v)

As per coding guidelines, unsupported JIT lowering must return false without mutating IR, stack, parameters, facts, or labels.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// A non-raw ref carries the retain taken when it was pushed, so committing it
// transfers that edge to the stack. A raw marker instead retains inside box()
// for an interpreter frame to release; a commit rebuilds no frame, so nothing
// would balance it.
for j, v := range ctx.values {
if v.kind == types.KindRef && commit {
if v.kind == types.KindRef && commit && v.raw {
return false
func (l arm64Lowerer) flush(ctx *lowering, commit bool) bool {
if commit {
for _, v := range ctx.values {
if v.kind == types.KindRef && v.raw {
return false
}
}
}
// A non-raw ref carries the retain taken when it was pushed, so committing it
// transfers that edge to the stack. A raw marker instead retains inside box()
// for an interpreter frame to release; a commit rebuilds no frame, so nothing
// would balance it.
for j, v := range ctx.values {
boxed, ok := l.boxHome(ctx, v)
🤖 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 3892 - 3898, Move the raw-reference
preflight loop in the lowering function containing ctx.values before any store
emission or localDirty clearing. Ensure commit with a raw KindRef returns false
before mutating IR, stack, parameters, facts, or labels, while preserving the
existing lowering behavior for supported references.

Source: Coding guidelines

The test used a fixture-builder closure and drove compilation by hand
through private state, against docs/coding-patterns.md 6.1, 6.4 and 6.8.

Inline the program, drop the grouping subtests, and observe native entry
through Interpreter plus the profiler instead. Metrics publish on Close,
so close before reading them. The default policy is what emits an entry
here: WithThreshold(1) compiles before a representative trace exists and
emits nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@siyul-park
siyul-park merged commit b472a30 into main Jul 15, 2026
3 of 4 checks passed
@siyul-park
siyul-park deleted the claude/indirect-recursive-fib-jit-d79f7b branch July 15, 2026 15:14

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

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

1071-1090: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Use t.Cleanup to guarantee i.Close() runs even on assertion failure.

i.Close() is only reached after the loop completes; if any require call inside the loop (lines 1075-1079) fails, the test aborts via t.FailNow() before Close() runs, leaking the compiler's native code buffer/cache for the remainder of the test binary.

♻️ Proposed fix
 	profile := prof.New()
 	i := New(prog, WithProfiler(profile))
+	t.Cleanup(func() { _ = i.Close() })
 
 	for range 64 {
 		require.NoError(t, i.Run(context.Background()))
 		value, err := i.PopBoxed()
 		require.NoError(t, err)
 		require.Equal(t, types.BoxI32(6765), value)
 		i.Reset()
 	}
-	require.NoError(t, i.Close())
🤖 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 1071 - 1090, Register i.Close with
t.Cleanup immediately after creating the interpreter so it runs even when a
require assertion aborts the test. Remove the later explicit i.Close call while
preserving the existing profiler metrics assertions.
🤖 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.

Nitpick comments:
In `@interp/jit_arm64_test.go`:
- Around line 1071-1090: Register i.Close with t.Cleanup immediately after
creating the interpreter so it runs even when a require assertion aborts the
test. Remove the later explicit i.Close call while preserving the existing
profiler metrics assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4ba3c22b-7dd4-47d8-be94-0cb2e3e6a178

📥 Commits

Reviewing files that changed from the base of the PR and between 6682590 and 4eec634.

📒 Files selected for processing (1)
  • interp/jit_arm64_test.go

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