Skip to content

fix(callgrind): end a call that returns with SP below its frame - #63

Draft
not-matthias wants to merge 1 commit into
masterfrom
cod-4155-remove-obj-skip-for-codspeed-node
Draft

not-matthias wants to merge 1 commit into
masterfrom
cod-4155-remove-obj-skip-for-codspeed-node

Conversation

@not-matthias

Copy link
Copy Markdown
Member

TLDR: A callee that returns with SP lower than it was called with (it builds a frame for its caller) was recorded as calling back into its caller, and the caller's work showed up under it. Callgrind now pops the call when the ret goes to its recorded return address. (amd64 tests; arm64 uses the same path but isn't covered by CI.)

  • Why it matters: V8's Sparkplug prologue (Builtins_BaselineOutOfLinePrologue) works this way. Once the node binary is no longer obj-skipped, every JS function's callees end up under the shared prologue, which ties all JS functions into one cycle and makes non-recursive functions look recursive.
  • Fix: setup_bbcc gets a third "real return" rule: SP below the top frame and target == that frame's ret_addr → pop that one frame. Equal and higher SP cases are unchanged.
  • Instruction totals unchanged: ret_below_frame gives 291,775 Ir on master and with this change.
  • Tests:
    • ret_below_frame: hand-written amd64 prologue. On master: frame_prologue -> with_prologue_a'2 -> leaf_a. Now: with_prologue_a -> leaf_a.
    • ret_below_frame_node: node --always-sparkplug; the prologue must not call into JIT code. On master: 66 such callees. Now: 0. Skipped when node isn't installed.
    • Full callgrind/tests suite: 30/30 pass locally.
  • Effect on a small Node script (4 JS functions, --separate-callers=4): false JS→JS edges 3 → 0. Without --separate-callers, cross-function false edges remain because shared V8 builtins (Call*, CompileLazy, Execution::Call) merge callers. A stacked PR will address that.

Part of COD-4155. Related: CodSpeedHQ/codspeed-node#121, CodSpeedHQ/codspeed#617

Callgrind only treated a ret as a return when SP moved back above the
frame of the top call, or stayed equal and hit the recorded return address.
A callee that returns with SP lower than it was called with, because it
builds a stack frame for its caller, was turned into a 'RET w/o CALL' jump
and then emulated as a call back into its caller. Everything the caller did
afterwards was recorded under the callee.

V8's Sparkplug prologue (Builtins_BaselineOutOfLinePrologue) works this way.
With the node binary instrumented, every JS function's callees ended up
under the shared prologue, which tied all JS functions into one cycle and
showed non-recursive functions as recursive.

A ret to the return address recorded for the top call now pops that call
even when SP is still below its frame. Instruction totals are unchanged.

Tests: ret_below_frame reproduces the pattern with a hand-written amd64
prologue, ret_below_frame_node checks that the Sparkplug prologue never
calls into JIT code under node --always-sparkplug.
@codspeed

codspeed Bot commented Nov 6, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 84 untouched benchmarks
⏩ 60 skipped benchmarks1


Comparing cod-4155-remove-obj-skip-for-codspeed-node (1b3bd12) with master (f2aa7a9)

Open in CodSpeed

Footnotes

  1. 60 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

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