Skip to content

Improve GC stack scanning and GcInfoDecoder performance - #135807

Open
VSadov wants to merge 23 commits into
dotnet:mainfrom
VSadov:dec001
Open

VSadov wants to merge 23 commits into
dotnet:mainfrom
VSadov:dec001

Conversation

@VSadov

@VSadov VSadov commented Oct 14, 2026

Copy link
Copy Markdown
Member

Summary

When the managed heap is small or when most marking is concurrent, stack scanning (GcScanRoots) can account for a substantial portion of GC pause duration.

Profiling and instrumenting a 500-frame deep stack walk during GC.Collect(0) showed that each frameless activation was doing redundant work:

  • 3–4 GetGCInfoToken virtual calls and 3–4 GcInfoDecoder constructions per frame (up to 4 on ReadyToRun frames and 4–5 on shared generic frames).
  • 1 full T_CONTEXT (1,232 bytes on Windows x64; 3,232 bytes on Linux x64) + T_KNONVOLATILE_CONTEXT_POINTERS (128 bytes) copy per frame due to CheckForSkippedFrames unconditionally forcing an out-of-place unwind via EnsureCallerContextIsValid even when m_crawl.pFrame is at the base of the stack (DebuggerU2MCatchHandlerFrame) or FRAME_TOP.
  • Full FP/vector state copies whenever EnsureCallerContextIsValid is needed during a GC scan (e.g., on frames with a GS cookie).
  • Bit-at-a-time ReadOneFast() loops in GcInfoDecoder::EnumerateLiveSlots when scanning partially-interruptible live slot bitmasks and couldBeLive bit-vectors.

Changes

1. Eliminate redundant per-frame GCInfo lookups and header decodes

  • 1a. R2R JitCodeToMethodInfo (codeman.cpp): Reuse the already-computed HotColdMappingLookupTable::LookupMappingForMethod result (isColdCode). When the frame is in hot code, compute m_relOffset = currentInstr - JitTokenToStartAddress(pCodeInfo->m_methodToken) directly instead of calling JitTokenToMethodRegionInfo (which decoded unwind info size, constructed a GcInfoDecoder(DECODE_CODE_LENGTH) to get hotSize, and ran a second binary search in LookupMappingForMethod). Checked builds assert equality against JitTokenToMethodRegionInfo.
  • 1b. Cache GCInfoToken in EECodeInfo (codeman.h, codeman.cpp, jitinterface.cpp, AsmOffsets.cs): Lazily cache m_gcInfoToken in EECodeInfo::GetGCInfoToken() and reset it in EECodeInfo initialization and JitCodeToMethodInfo. Reduces IJitManager::GetGCInfoToken calls from 3–4 per frame to 1 per frame.
  • 1c. Gate GetParamContextType in GcStackCrawlCallBack (gcenv.ee.common.cpp): Only query GetParamContextType when the MethodDesc requires a generic context parameter (RequiresInstMethodTableArg() || RequiresInstMethodDescArg(), with Checked-build assertions verifying that AcquiresInstMethodTableFromThis() only produces THIS/NONE and all other methods produce NONE).
  • 1d. Merge generic context decoders (eetwain.h, eetwain.cpp, codeman.cpp, gcenv.ee.common.cpp): Extend ICodeManager::GetParamContextType to optionally return the generic context token and prolog check from a single GcInfoDecoder(gcInfoToken, DECODE_GENERICS_INST_CONTEXT | DECODE_PROLOG_LENGTH), replacing separate GetParamContextType, SafeToReportGenericParamContext, and GetExactGenericsToken decoders.

2. Avoid unnecessary T_CONTEXT copies during stack walks

  • 2a. Lazy caller context in CheckForSkippedFrames (stackwalk.cpp, eetwain.cpp, exceptionhandling.cpp, threadsuspend.cpp, frameinfo.cpp, dacdbiimplstackwalk.cpp): Avoid forcing EnsureCallerContextIsValid in CheckForSkippedFrames when m_crawl.pFrame == FRAME_TOP or when m_crawl.pFrame is above currentSP and cannot belong to the current frameless activation. For frames without a GS cookie, pRD->IsCallerContextValid stays FALSE until NextRaw(), allowing Thread::VirtualUnwindCallFrame to unwind pCurrentContext in place with zero context copies. Checked builds assert after unwinding that m_crawl.pFrame was not skipped.
  • 2b. Cheaper context copy for GC root scans (threads.h, eetwain.h, gcenv.ee.cpp, stackwalk.cpp, eetwain.cpp, frames.cpp): Add GC_ROOT_SCAN / ICodeManagerFlags::GcRootScan set by ScanStackRoots. When EnsureCallerContextIsValid is called on a GC scan (e.g., for GS-cookie frames), CopyCallerContextForGCScan copies only flags, GPRs, FP, SP, and IP (144 bytes instead of 1,232 bytes on Windows x64), preserving ContextPointers and ARM64 PAC SP fields while skipping the FP/vector state.

3. Word-level bit scanning in GcInfoDecoder (gcinfodecoder.h, gcinfodecoder.cpp)

  • 4a. Word reads + TrailingZeroCount: Add BitOperations::TrailingZeroCount, BitStreamReader::ReadWord, and BitStreamReader::SkipZerosAndReadOne, replacing the bit-at-a-time ReadOneFast() loops in TGcInfoDecoder::EnumerateLiveSlots (shared across CoreCLR, DAC, and NativeAOT).

Performance Measurements

Measured on Windows x64 with a 500-frame deep stack benchmark (GC.Collect(0) in a loop of 2,000 iterations $\times$ 9 rounds after warmup, DOTNET_TieredCompilation=0) covering:

  • R2R_Small: 500 ReadyToRun frames (crossgen2-compiled)
  • JIT_Small: 500 small JIT frames (slim headers)
  • JIT_GenericShared: 500 shared generic frames (GenericClass<T>, GenericStruct<T>, GenericStatic.Recurse<T>)
  • JIT_GSCookie: 500 frames with stackalloc GS cookies (fat headers)
  • JIT_ManySlots: 500 frames with 32 tracked GC slots and sparse non-RLE live bit-vectors
  • Mixed_All: 500 frames (100 of each category above)

Per-Frame Counter Breakdown (Depth = 500)

Scenario Frames GetGCInfoToken (Before -> After) GcInfoDecoder (Before -> After) CallerCtxCopy (Before -> After) ReadOneFast Live-Slot Bits (Before -> After)
R2R_Small 511 2,040 (3.99/f) -> 511 (1.00/f) 2,040 (3.99/f) -> 1,022 (2.00/f) 511 (1.00/f) -> 1 (0.00/f) 1,398 -> 0
JIT_Small 510 1,535 (3.01/f) -> 510 (1.00/f) 1,535 (3.01/f) -> 1,020 (2.00/f) 510 (1.00/f) -> 1 (0.00/f) 1,394 -> 0
JIT_GenericShared 507 1,857 (3.66/f) -> 507 (1.00/f) 1,857 (3.66/f) -> 1,355 (2.67/f) 507 (1.00/f) -> 1 (0.00/f) 2,354 -> 0
JIT_GSCookie 510 1,535 (3.01/f) -> 510 (1.00/f) 1,535 (3.01/f) -> 1,020 (2.00/f) 510 (1.00/f full) -> 501 (GPR-only) 2,020 -> 0
JIT_ManySlots 511 1,539 (3.01/f) -> 511 (1.00/f) 1,539 (3.01/f) -> 1,022 (2.00/f) 511 (1.00/f) -> 1 (0.00/f) 15,047 -> 0
Mixed_All 521 1,738 (3.34/f) -> 521 (1.00/f) 1,738 (3.34/f) -> 1,111 (2.13/f) 521 (1.00/f) -> 101 (GPR-only) 4,499 -> 0

Workstation GC (DOTNET_gcServer=0) — Median GC.GetTotalPauseDuration() ($\mu\text{s}$/GC)

Scenario Baseline + 1a (R2R Offset) + 1b (Token Cache) + 1c (Gate Generic) + 1d (Merge Decoders) + 2a (Lazy CallerCtx) + 2b (GPR-only Copy) + 4a (Word BitScan) Total Change
R2R_Small 174.64 160.06 156.67 156.64 155.03 150.94 146.44 140.13 -34.51 $\mu\text{s}$ (-19.8%)
JIT_Small 154.95 145.14 146.03 148.23 143.74 134.89 138.28 130.81 -24.14 $\mu\text{s}$ (-15.6%)
JIT_GenericShared 209.17 191.45 188.61 202.24 180.55 174.11 172.35 169.68 -39.49 $\mu\text{s}$ (-18.9%)
JIT_GSCookie 203.32 186.78 187.26 182.42 177.60 181.05 171.92 174.15 -29.17 $\mu\text{s}$ (-14.3%)
JIT_ManySlots 328.74 317.23 325.93 331.13 320.19 321.13 328.38 317.58 -11.16 $\mu\text{s}$ (-3.4%)
Mixed_All 219.63 204.76 205.31 204.21 195.45 197.12 194.44 193.07 -26.56 $\mu\text{s}$ (-12.1%)

Server GC (DOTNET_gcServer=1) — Median GC.GetTotalPauseDuration() ($\mu\text{s}$/GC)

Scenario Baseline Final Total Change
R2R_Small 249.78 219.30 -30.48 $\mu\text{s}$ (-12.2%)
JIT_Small 228.74 211.69 -17.05 $\mu\text{s}$ (-7.5%)
JIT_GenericShared 286.12 247.11 -39.01 $\mu\text{s}$ (-13.6%)
JIT_GSCookie 278.73 252.43 -26.30 $\mu\text{s}$ (-9.4%)
JIT_ManySlots 410.27 398.15 -12.12 $\mu\text{s}$ (-3.0%)
Mixed_All 298.87 274.87 -24.00 $\mu\text{s}$ (-8.0%)

Validation

  • .\build.cmd clr+libs -rc Release -lc Release (including mscordaccore and Runtime.WorkstationGC / Runtime.ServerGC NativeAOT libraries): Passed
  • .\build.cmd clr -rc Checked: Passed
  • Library test suites against Checked CoreCLR:
    • dotnet build /t:Test src\libraries\System.Runtime.Loader\tests\System.Runtime.Loader.Tests.csproj -p:Configuration=Release: Passed
    • dotnet build /t:Test src\libraries\System.Collections\tests\System.Collections.Tests.csproj -p:Configuration=Release: Passed
    • dotnet build /t:Test src\libraries\System.Runtime.InteropServices\tests\System.Runtime.InteropServices.UnitTests\System.Runtime.InteropServices.Tests.csproj -p:Configuration=Release: Passed
  • Runtime test suites against Checked CoreCLR:
    • src\tests\build.cmd -Test JIT\Generics\JIT.Generics.csproj x64 Checked + corerun.exe JIT.Generics.dll: Passed (exit code 100)
    • src\tests\build.cmd -Test async\async.csproj x64 Checked -Priority 1 + corerun.exe async.dll: Passed (exit code 100)

Note

This pull request was authored with assistance from GitHub Copilot.

VSadov and others added 7 commits October 14, 2026 15:08
…dInfo

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

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

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

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

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

@VSadov VSadov assigned Copilot and unassigned Copilot Oct 14, 2026
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jkotas

jkotas commented Oct 15, 2026

Copy link
Copy Markdown
Member

Tests are failing

Comment thread src/coreclr/vm/exceptionhandling.cpp Outdated
Comment thread src/coreclr/vm/gcenv.ee.common.cpp Outdated
Comment thread src/coreclr/vm/gcenv.ee.common.cpp Outdated
// is shared, and thus indicates there should be an instantion argument, but the JIT
// was still allowed to optimize it away and we won't grab it below because we're not
// reporting any references from this frame.
paramContextType = pCF->GetCodeManager()->GetParamContextType(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would it be even better to do this reporting inside EnumGcRefs? We would not need to spin the extra GCInfoDecoder at all.

This comment was marked as resolved.

Comment thread src/coreclr/vm/exceptionhandling.cpp Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@VSadov

VSadov commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

I think the test failures are unrelated. (all due to linked known issues)

Comment thread src/coreclr/vm/codeman.cpp Outdated
Comment thread src/coreclr/vm/gcenv.ee.common.cpp Outdated
VSadov and others added 3 commits October 20, 2026 13:29
Reuse the decoded x86 GC header for generic-context promotion and remove the frameless special case from the GC stack callback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Preserve the branch's generic-frame cancellation tests while applying
upstream's removal of the interpreter skip.

Validation: both Checked CLR builds and targeted lifetime/dispatch tests
pass. x64 cancellation tests pass with JIT and interpreter mode 1;
x86 lifetime tests also pass with GCStress 0xC.

Known validation caveat: test135530 hangs on x86, including with runtime
defaults. A control using the preserved pre-merge native CLR also hangs,
but uses current CoreLib and is not an exact pre-merge baseline.

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

jkotas commented Oct 21, 2026

Copy link
Copy Markdown
Member

Conflicts

This comment was marked as resolved.

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.

🔵 Needs a closer look

Cross-platform GC rooting, unwinding, and exception-handling changes require maintainer review and broader runtime validation.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Comment thread src/coreclr/vm/stackwalk.cpp Outdated
Explain the conservative 64-bit scope, saved-context ownership checks, and deferred frame processing without materializing the caller context. Comments only; no executable code changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread src/coreclr/vm/stackwalk.cpp Outdated
Comment thread src/coreclr/vm/stackwalk.cpp Outdated
WRAPPER_NO_CONTRACT;
SUPPORTS_DAC;

if ((m_flags & GC_ROOT_SCAN) && m_crawl.pFrame == FRAME_TOP)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if ((m_flags & GC_ROOT_SCAN) && m_crawl.pFrame == FRAME_TOP)
if (m_crawl.pFrame == FRAME_TOP)

Can this be simpler?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This can break on non-x64 when scanning stack for debug reasons.
It would require adding platform-specific EnsureCallerContextIsValid in multiple places. I think it is better to keep the check for GC_ROOT_SCAN here instead.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What do you mean by debug reasons?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

For diagnostics (non-GC) stack scans.

For example DebuggerWalkStackProc would need

#if defined(TARGET_ARM) || defined(TARGET_ARM64) || defined(TARGET_LOONGARCH64) || defined(TARGET_RISCV64)
        // These targets use caller SP as the frame marker in the saved REGDISPLAY.
        if (pCF->IsFrameless())
        {
            pCF->EnsureCallerContextIsValid();
        }
#endif // TARGET_ARM || TARGET_ARM64 || TARGET_LOONGARCH64 || TARGET_RISCV64

@VSadov VSadov Oct 23, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I have pushed the "fix" for the failing diagnostic tests, but I think it may be better if we keep the check for GC_ROOT_SCAN when we do m_crawl.pFrame == FRAME_TOP instead

Comment thread src/coreclr/vm/stackwalk.cpp Outdated
Comment thread src/coreclr/vm/stackwalk.cpp Outdated
VSadov and others added 2 commits October 23, 2026 08:45
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
WRAPPER_NO_CONTRACT;

#ifdef ESTABLISHER_FRAME_ADDRESS_IS_CALLER_SP
TADDR sp = CallerStackFrame::FromRegDisplay(pRD).SP;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That assumes the caller context has already been prepared. It does not prepare it itself.

Previously, skipped-frame processing prepared that context as a side effect. With the unconditional empty-chain early return, that side effect is no longer guaranteed. This caused the reproduced x86 assertion.

The helper now uses:

TADDR sp = pCF->GetCallerSp();

GetCallerSp ensures the caller context exists before reading it. The crawl frame already knows the appropriate code manager, method information, and unwind flags.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

why not move if (m_crawl.pFrame == FRAME_TOP) check after the "sideeffect" ?

Moving the check after GetCallerSp would restore the old eager preparation for paths passing through this function, including the x86 exception path that failed.

The drawback is that this would also defeat the remaining empty-chain optimization for GC scans:

Compute caller SP:
    copy current context
    copy register pointers
    unwind the copy

Then discover there are no explicit frames to process.

That is the unnecessary work the empty-chain early return avoids.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@janvorli Do you have a preference?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let me get my head around the current state of this PR and respond after that.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am not sure which if (m_crawl.pFrame == FRAME_TOP) are we talking about? There isn't one in this function.

WRAPPER_NO_CONTRACT;

#ifdef ESTABLISHER_FRAME_ADDRESS_IS_CALLER_SP
TADDR sp = CallerStackFrame::FromRegDisplay(pRD).SP;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am not sure which if (m_crawl.pFrame == FRAME_TOP) are we talking about? There isn't one in this function.

Comment thread src/coreclr/vm/stackwalk.h

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants