Skip to content

JIT: Secondary frame pointer for x64 unoptimized methods - #128795

Open
AndyAyersMS wants to merge 15 commits into
dotnet:mainfrom
AndyAyersMS:SecondaryFramePointer
Open

JIT: Secondary frame pointer for x64 unoptimized methods#128795
AndyAyersMS wants to merge 15 commits into
dotnet:mainfrom
AndyAyersMS:SecondaryFramePointer

Conversation

@AndyAyersMS

@AndyAyersMS AndyAyersMS commented May 30, 2026

Copy link
Copy Markdown
Member

In un-optimized codegen on x64 only, introduce a secondary frame pointer in RBX, offset 256 bytes (configurable via DOTNET_JitSecondFramePtr, default 0x100) from either RSP or RBP, and use that to address locals in range. RBX survives most EH transitions so only needs to be set up in the prolog and re-established for filters.

Substantial code size savings from smaller displacement encodings.

Note

Description edited with GitHub Copilot to reconcile the stated offset with the shipped default (256).

AndyAyersMS and others added 4 commits May 28, 2026 17:55
On x64, addressing modes only encode a signed 8-bit displacement cheaply
(disp8, -128..+127); larger offsets require a 4-byte disp32. Methods with
large stack frames therefore emit many oversized stack-local references.

This adds an optional secondary frame/stack pointer, reserved in a
callee-saved register (RBX), offset by a configurable number of bytes from
the primary base. Stack locals that fall outside disp8 range of the primary
base but inside disp8 range of the secondary pointer are rewritten to use
the secondary pointer, shrinking those references from disp32 to disp8.

Gated behind the default-off config JitSecondFramePtr (the byte offset;
0x100 = 256) and restricted to OptimizationDisabled() (MinOpts/Tier0) on
x64 only. LSRA reserves the register; codegen sets it up in the prolog
after unwindEndProlog(); emit rewrites eligible SV refs to [rbx+disp8].

EH/funclet support: EH methods always use RBP frames on x64. The secondary
pointer is re-established (lea rbx,[rbp-offset]) only in filter funclet
prologs, since the VM's CallEHFilterFunclet restores only RBP. Catch/
finally/fault funclets need no re-establishment because CallEHFunclet
restores all nonvolatiles (including RBX) from the establisher context.

SuperPMI asmdiffs across all x64 collections (JitSecondFramePtr=0x100):
overall -7,353,695 bytes, 100% in MinOpts, FullOpts unchanged (0 diffs).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When an access is redirected through the secondary frame pointer
(REG_OPT_RSVD2/RBX) the emitted bytes use [rbx+disp8], but emitDispFrameRef
previously still printed the canonical [rbp/rsp+disp], so the listing did not
match the encoded instruction. Print the actual [rbx+disp8] operand and append
the canonical reference as a parenthesized suffix, e.g.

    mov qword ptr [rbx+0x78] (rbp-0x88), rax

Plumb the instruction through emitDispFrameRef so it can reuse
emitIsSecondFramePtrCandidate (the same decision emitOutputSV makes), keeping
display and emitted bytes in lockstep. Display-only change; no codegen impact.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When a stack access is redirected through the secondary frame pointer
(REG_OPT_RSVD2), the disassembly now prints the real [rbx+disp8] operand
and emits the canonical frame reference (e.g. rbp-0x88) as an end-of-line
';' comment, rather than an inline parenthetical that could be misread as
sitting between operands.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The LSRA-time reservation keyed only off total frame size, so ~27% of
methods reserved RBX (push/lea/pop plus unwind data) but never used it: no
local actually landed in the secondary disp8 band. The band can't be tested
at LSRA, since REGALLOC-layout offsets have no base-register flag and are
inflated by an over-estimated callee-save area.

Reserve RBX at LSRA only as a cheap candidate (out of allocation), then make
the precise band-occupancy decision in genFinalizeFrame once FINAL offsets
are known. If nothing lands in the band, cancel the reservation so no
push/lea/unwind is emitted; otherwise mark the register modified and redo the
frame layout to account for the push.

aspnet2 asmdiffs: unused-RBX setups drop from 13 to 0; size win improves from
-3864 to -3982 bytes. No replay failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 30, 2026 02:27
@github-actions github-actions Bot added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label May 30, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds an AMD64-only "secondary frame pointer" optimization (gated by JitConfig.JitSecondFramePtr, default 0x100) for unoptimized methods with large frames. RBX is reserved during LSRA, conditionally established (RBP - offset or RSP + offset) in the prolog after a profitability check on FINAL frame offsets, restored in filter funclets, and used by the xarch emitter to redirect far stack accesses from [rbp/rsp + disp32] to [rbx + disp8] (saving 3 bytes per redirected access). Disassembly is updated to show the redirected operand plus a trailing canonical-frame-reference comment, and emitDispFrameRef gains an instruction parameter on all targets.

Changes:

  • Reserve RBX (REG_OPT_RSVD2) as candidate secondary FP during LSRA when MinOpts, large frame, EH-compatible, non-OSR; finalize/cancel in genFinalizeFrame via new genSecondFramePtrIsProfitable.
  • Emit prolog lea to establish RBX from RBP/RSP, re-establish it in AMD64 filter funclets, and redirect eligible stack-var encodings (non-EVEX/SSE38/3A/crc32) in emitInsSizeSVCalcDisp / emitOutputSV.
  • Plumb instruction ins through emitDispFrameRef on all targets, and render [rbx+disp8] ; rbp-0xNN in xarch disassembly.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/coreclr/jit/targetamd64.h Define REG_OPT_RSVD2 / RBM_OPT_RSVD2 as RBX.
src/coreclr/jit/jitconfigvalues.h Add JitSecondFramePtr config (default 256).
src/coreclr/jit/codegeninterface.h Add genSecondFramePtrReg/Offset/FPbased state (AMD64).
src/coreclr/jit/codegen.h Declare genSecondFramePtrIsProfitable; fix #endif comment for TARGET_XARCH.
src/coreclr/jit/lsra.cpp Reserve RBX candidate when conditions hold (MinOpts, fixed base, large frame, EH/OSR ok).
src/coreclr/jit/codegencommon.cpp Profitability check after FINAL layout; if profitable, mark RBX modified, redo layout, emit prolog lea.
src/coreclr/jit/codegenxarch.cpp Re-establish RBX in FILTER funclet prologs.
src/coreclr/jit/emitxarch.h Declare emitIsSecondFramePtrCandidate and display state for trailing comment.
src/coreclr/jit/emitxarch.cpp Redirect candidate check in size calc/output; modrm `0x40
src/coreclr/jit/emit.h Add instruction ins = INS_none parameter to emitDispFrameRef.
src/coreclr/jit/emitarm.cpp / emitarm64.cpp / emitloongarch64.cpp / emitriscv64.cpp Update emitDispFrameRef signature on other targets (parameter unused).

The reservation gate ran lvaFrameSize(REGALLOC_FRAME_LAYOUT) eagerly for every
method -- a full layout pass even in fullopts, where the feature never engages.
Replace it with a cheap O(numLocals) estimate (outgoing-arg space plus stack-home
sizes, bailing once past the disp8 window); the precise band test in
genSecondFramePtrIsProfitable still re-checks with FINAL offsets.

Also skip the per-stack-access candidate check in the common case via a cached
emitter flag, set once in emitBegFN, instead of dereferencing codeGen on every
access.

No code-size change; recovers the throughput regressions flagged in CI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@AndyAyersMS

Copy link
Copy Markdown
Member Author

diffs

No-opt TP is a bit high, but the very high numbers are from thin collections. Realistically it's about 0.5%. Still seems more costly than I'd expect.

Test the cheap disp8 band arithmetic before the costlier instruction-class
predicates in emitIsSecondFramePtrCandidate. The band check rejects the common
in-window access first, so the EVEX/APX/SSE38-3A/crc32 checks only run for
band-matching accesses. Byte-identical codegen; cuts MinOpts throughput cost.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 31, 2026 02:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.

@AndyAyersMS

AndyAyersMS commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

Refreshed the diffs

About 12MB less code (~2%) for ~0.5% TP. Was hoping this would be substantially cheaper than #127780 but having to compute frame layout twice and the extra encoding checks are not free. This is complementary with #127780; the two together are nearly additive in code size savings.

Relies on RBX being preserved across most EH transitions (except filters).

Tier0 / minopts only, we could make it be tier0 only perhaps.

@AndyAyersMS
AndyAyersMS marked this pull request as ready for review June 12, 2026 15:02
Copilot AI review requested due to automatic review settings June 12, 2026 15:02
@AndyAyersMS

Copy link
Copy Markdown
Member Author

@dotnet/jit-contrib here's yet another idea for trying to cope with x64 displacement limits -- add in a secondary frame pointer.

Like its sibling #127780 (which sorted locals to maximize use of small displacements) this mostly reduces unoptimized code size, but it has more TP cost than I'd like.

Not sure there is any easily observable benefit either; I suspect this can help very large code bases reduce ICache/ITLB costs since last I knew all tiers allocated from the same code heap so the various tier code intermingles. I could try and measure perhaps.

Thoughts?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.

Comment thread src/coreclr/jit/lsra.cpp Outdated
Comment on lines +2621 to +2632
auto frameLikelyLargeEnough = [this]() -> bool {
unsigned size = m_compiler->lvaOutgoingArgSpaceSize;
for (unsigned lclNum = 0; lclNum < m_compiler->lvaCount; lclNum++)
{
size += m_compiler->lvaLclStackHomeSize(lclNum);
if (size > 256)
{
return true;
}
}
return false;
};
Comment thread src/coreclr/jit/lsra.cpp Outdated
Comment on lines +2634 to +2636
if ((secondFramePtrOffset != 0) && optDisabled && haveFixedBase && ehCompatible && notOsr &&
frameLikelyLargeEnough())
{
Comment thread src/coreclr/jit/emitxarch.cpp Outdated
Comment on lines +5293 to +5311
const int adjusted = EBPbased ? (dsp + codeGen->genSecondFramePtrOffset) : (dsp - codeGen->genSecondFramePtrOffset);
const bool rawFits = ((signed char)dsp == (ssize_t)dsp);
const bool adjFits = ((signed char)adjusted == (ssize_t)adjusted);

if (rawFits || !adjFits)
{
return false;
}

// Instructions with special displacement/encoding handling cannot use the plain [reg+disp8] form.
if (IsEvexEncodableInstruction(ins) || IsApxExtendedEvexInstruction(ins) || EncodedBySSE38orSSE3A(ins) ||
(ins == INS_crc32))
{
return false;
}

*pAdjustedDsp = adjusted;
return true;
}
Comment thread src/coreclr/jit/emitxarch.h
Comment thread src/coreclr/jit/codegencommon.cpp Outdated
Comment thread src/coreclr/jit/codegencommon.cpp Outdated
Comment thread src/coreclr/jit/codegencommon.cpp
Comment thread src/coreclr/jit/codegencommon.cpp
@tannergooding

Copy link
Copy Markdown
Member

Overall the changes LGTM. I don't have a strong preference on taking it or not, but did leave a few questions/comments about maybe ways to improve throughput...

Comment thread src/coreclr/jit/lsra.cpp
AndyAyersMS and others added 4 commits June 23, 2026 16:28
Commit to saving the reserved register before the single FINAL layout, so the
offsets already account for the push. If the secondary pointer turns out not
worth establishing, demote it but leave the register saved (a small wasted
push/pop) instead of redoing the frame layout. Removes the double
lvaAssignFrameOffsets(FINAL) on engaging methods, erasing the residual MinOpts
throughput cost; a few methods grow by one push/pop.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 30, 2026 14:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Comment thread src/coreclr/jit/emitxarch.h
Comment thread src/coreclr/jit/emitxarch.cpp Outdated
Comment thread src/coreclr/jit/jitconfigvalues.h
AndyAyersMS and others added 2 commits July 1, 2026 09:18
- Initialize emitSecondFramePtrActive at declaration (avoid indeterminate read).
- Use codeGen->genSecondFramePtrReg instead of hard-coded REG_OPT_RSVD2 in the
  ModRM emission and disassembly, so the emitter stays correct if the chosen
  register ever changes.
- Compute the secondary displacement in ssize_t to avoid signed int overflow (UB)
  when JitSecondFramePtr is set to a large value.
- Reject non-positive JitSecondFramePtr offsets (a negative offset would invert
  the adjustment math and prolog LEA).
- Stop double-counting the outgoing-arg-space region in the frame-size estimate
  used to gate the reservation.
- Refresh the stale genFinalizeFrame comment to match the single-layout-pass flow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An EnC remap resumes execution in the updated method without re-running its
prolog and only preserves RBM_ENC_CALLEE_SAVED (RSI|RDI on Windows, none on
SysV) across the transition. The secondary frame pointer is a callee-saved
register (REG_OPT_RSVD2/RBX) established only in the prolog and is not in that
set, so after a remap it would hold a stale value and corrupt every redirected
[rbx+disp8] local access.

Gate the reservation off when opts.compDbgEnC, and assert the invariant where
the register is committed as modified.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 1, 2026 20:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Comment thread src/coreclr/jit/codegencommon.cpp Outdated
Comment thread src/coreclr/jit/lsra.cpp Outdated
- genSecondFramePtrIsProfitable: compute the adjusted-fits-disp8 range in ssize_t
  to avoid signed int overflow (UB) for large JitSecondFramePtr values (mirrors
  the emitter fix).
- frameLikelyLargeEnough: tie the cheap frame-size threshold to the configured
  offset instead of a hard-coded 256, so the gate tracks the band actually
  targeted (default offset 256 preserves prior behavior).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 15, 2026 01:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Comment thread src/coreclr/jit/lsra.cpp
Comment on lines +2632 to +2649
unsigned size = m_compiler->lvaOutgoingArgSpaceSize;
for (unsigned lclNum = 0; lclNum < m_compiler->lvaCount; lclNum++)
{
// Skip the outgoing-arg-space var: its stack home size is lvaOutgoingArgSpaceSize, which
// is already seeded into `size` above; counting it again would double-count that region.
if (lclNum == m_compiler->lvaOutgoingArgSpaceVar)
{
continue;
}

size += m_compiler->lvaLclStackHomeSize(lclNum);
if (size > (unsigned)secondFramePtrOffset)
{
return true;
}
}
return false;
};
Comment on lines +713 to +717
// When non-zero, reserve a callee-saved register as a secondary stack base pointer, offset by this
// many bytes from the primary base, to address far locals with a disp8 displacement. 0 disables.
// 256 is canonical: it tiles the two disp8 windows contiguously for a 512-byte cheap range; other
// values overlap (less reach) or leave a gap. x64 only.
RELEASE_CONFIG_INTEGER(JitSecondFramePtr, "JitSecondFramePtr", 0x100)
@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Workflow state for the Holistic Review Orchestrator.

{
  "version": 5,
  "last_dispatched_commit": "4fb4edb6b177cfabbd79d4b7bc198a4c4c4408be",
  "last_dispatched_base_ref": "main",
  "last_dispatched_base_sha": "646ab80e65fcf3cb9f068157f644ead63761ee7c",
  "last_reviewed_commit": "4fb4edb6b177cfabbd79d4b7bc198a4c4c4408be",
  "last_reviewed_base_ref": "main",
  "last_reviewed_base_sha": "646ab80e65fcf3cb9f068157f644ead63761ee7c",
  "last_recorded_worker_run_id": "29684001643",
  "review_attempt_commit": "",
  "review_attempt_base_ref": "",
  "review_attempt_count": 0,
  "max_review_attempts": 5,
  "review_history_format": "holistic-review-disclosure-v1",
  "review_history": [
    {
      "commit": "4fb4edb6b177cfabbd79d4b7bc198a4c4c4408be",
      "review_id": 4730644779
    }
  ]
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Holistic Review

Motivation: The problem is real and well-established: x64 unoptimized (Tier0/MinOpts) frames frequently exceed the disp8 window, forcing disp32 stack-access encodings that bloat code. A secondary base pointer (RBX = primaryBase ± 256) lets far locals fold back into a cheap disp8 form, which the author's diffs show yields ~2% code-size savings for a modest TP cost.

Approach: The design is sound and carefully layered: LSRA tentatively reserves RBX as a candidate, genFinalizeFrame commits the push into the single FINAL layout and then cancels the establishment (via genSecondFramePtrIsProfitable) if no local lands in the secondary band, the prolog emits a no-unwind lea, filter funclets recompute RBX, and the emitter deterministically redirects qualifying accesses in both the size and output paths. Guards (opt-disabled only, fixed base, EH⇒RBP frame, not OSR, not EnC) are thorough and the rationale is documented inline. Overflow-safe ssize_t math and disp8-band determinism between sizing and output are handled correctly.

Summary: ⚠️ Needs Human Review. The code itself looks correct and the engineering is high quality, but two things warrant maintainer judgment: (1) the feature ships enabled by default (JitSecondFramePtr = 0x100), so it changes prologs, unwind, and EH re-establishment for essentially all x64 Tier0 methods at once — a broad blast radius that relies on the correctness of the funclet/unwind claims; and (2) there is no targeted regression test for the EH/filter re-establishment path. A human should confirm the CallEHFunclet/CallEHFilterFunclet nonvolatile-restore assumptions and decide whether default-on is intended for this PR or should bake behind an off-by-default switch first.


Detailed Findings

✅ Encoding, unwind, and EH correctness — verified

  • The redirected ModRM encoding (code | ((0x40 | (secondReg & 7)) << 8), no SIB, no REX.B) is valid because RBX < R8 and rm ≠ 4/5, and assert((unsigned)secondReg < 8) locks that in.
  • Size (emitInsSizeSVCalcDisp) and output (emitOutputSV) both call emitIsSecondFramePtrCandidate with the same post-FINAL lvaFrameAddress-derived dsp, so estimation and emission stay in lockstep. On AMD64 FEATURE_FIXED_OUT_ARGS is set, so emitCurStackLvl is 0 and RSP-based redirects use a stable displacement.
  • The RBX push carries its own unwind code (marked modified before the FINAL layout), while the establishing lea sits after unwindEndProlog and correctly needs no unwind data — mid-method unwind restores RBX to the caller value from its stack slot, which is the right semantics.
  • Redirect math is correct for both bases: FP frame RBX = RBP−offset with secondDsp = dsp+offset; RSP frame RBX = RSP+offset with secondDsp = dsp−offset.
  • localloc (which mutates RSP) forces an RBP frame, so RSP-based secondary pointers are never exposed to a shifting RSP.

⚠️ Enabled by default — large Tier0 blast radius

RELEASE_CONFIG_INTEGER(JitSecondFramePtr, "JitSecondFramePtr", 0x100) makes the feature active by default in every x64 MinOpts/Tier0 method whose frame is large enough. Because Tier0 is the first-tier code for nearly all methods, this touches prolog layout, unwind data, and EH funclet setup extremely broadly on day one. The guards look complete, but the correctness of the catch/finally/fault path depends entirely on the claim that CallEHFunclet restores RBX from the establisher CONTEXT (and that CallEHFilterFunclet restores only RBP). That claim is plausible and consistent with x64 EH, but it is load-bearing and hard to falsify by reading alone — a maintainer should confirm it, and confirm the intent to ship default-on rather than staging behind an off-by-default switch.

⚠️ Test gap — no targeted regression coverage

No test accompanies the change. The riskiest paths (filter-funclet RBX recomputation, catch/finally reliance on runtime restore, the profitability cancel-after-commit path, and the RSP-based variant) are only exercised incidentally by existing suites. Given the default-on scope, a focused test (e.g., a large-frame method with EH including a filter, plus GC/EH stress) would materially de-risk this. This is advisory rather than strictly blocking, since Tier0 codegen is heavily exercised by the existing pri1/EH/GCStress runs the author is presumably relying on.

💡 Minor observations

  • The comment-only #if defined(TARGET_AMD64) block in genFinalizeFrame (documenting that the reservation is handled later) compiles to nothing; it's harmless documentation but could just be a plain comment.
  • genSecondFramePtrIsProfitable deliberately checks "at least one redirectable access" without counting sites, so a method with only one or two redirects can still regress a few bytes. The author documents this tradeoff explicitly; noting it here only so a reviewer weighing the TP/size numbers is aware it's an intentional approximation.

Note

This review was generated by this repository's Holistic Review agentic workflow to complement the built-in Copilot review.

Generated by Holistic Review · 246.9 AIC · ⌖ 11.1 AIC · ⊞ 10K

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants