Fix GC.GetTotalMemory returning negative under regions GC - #130888
Conversation
GCHeap::ApproxTotalBytesInUse computes gen0's live size as gen0_size - gen0_frag using unsigned arithmetic. gen0_frag (free_list_space + free_obj_space) is a per-generation total spanning every gen0 region, but gen0_size only summed region spans up to the ephemeral region, dropping any gen0 region linked after it (for example a pinned region swept in place). When the dropped regions' fragmentation exceeded their omitted span, gen0_frag exceeded gen0_size and the unsigned subtraction underflowed, surfacing as a negative value from GC.GetTotalMemory. Walk every gen0 region so the counted span matches the fragmentation total. Also reorder the free-list discard bookkeeping in a_fit_free_list_p so a lock-free reader observes a harmless under-count instead of a transient over-count. Segments are unaffected: the single contiguous ephemeral segment already bounds all gen0 fragmentation within the counted span. Adds a regression test that drives concurrent allocation with a large, continuously-refreshed pinned-object ring while probing GC.GetTotalMemory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 975e6875-e7d4-4172-8d1f-5ed514017a4d
|
Azure Pipelines: Successfully started running 3 pipeline(s). 12 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Tagging subscribers to this area: @anicka-net, @dotnet/gc |
|
@dotnet-policy-service agree company="Microsoft" |
There was a problem hiding this comment.
Pull request overview
This PR fixes a correctness bug in CoreCLR GC accounting where GC.GetTotalMemory(false) could intermittently surface a negative long under regions GC, by ensuring gen0 span accounting covers all gen0 regions and by adjusting related free-space bookkeeping. It also adds a regression test intended to reproduce the negative-value symptom under concurrent allocation/pinning.
Changes:
- CoreCLR GC: In regions builds, compute gen0 size by walking all gen0 regions instead of stopping at the ephemeral region (
GCHeap::ApproxTotalBytesInUse). - CoreCLR GC allocation: Reorder discard-path counter updates to reduce transient fragmentation over-count exposure to concurrent readers.
- Tests: Add a new priority-1, process-isolated GC API regression test exercising concurrent pin churn +
GC.GetTotalMemory(false)probing.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/coreclr/gc/interface.cpp | Fix gen0 size accounting under regions by walking the full gen0 region chain. |
| src/coreclr/gc/allocation.cpp | Reorder discard-path free-space counter updates to reduce transient over-count windows. |
| src/tests/GC/API/GC/GetTotalMemoryConcurrent.cs | New regression test that probes for negative GC.GetTotalMemory(false) while inducing retained gen0 regions via pin churn. |
| src/tests/GC/API/GC/GetTotalMemoryConcurrent.csproj | New test project configuration (process isolation, priority 1, GCStress incompatible). |
… comment Per reviewer feedback (jkotas, janvorli, mangod9): the discard-path counter reordering in allocation.cpp only reduces the frequency of the transient without proper memory barriers, and the accounting is best-effort by design, so revert it to the original ordering. Also remove the verbose gen0-region comment in interface.cpp. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da4f5bf-503e-4a71-a1df-f73e8aedbfe2
|
|
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. |
|
Workflow state for the Holistic Review Orchestrator. {
"version": 5,
"last_dispatched_commit": "a22ce85aa9edd8bcf0e8685f8687f391161b8a66",
"last_dispatched_base_ref": "main",
"last_dispatched_base_sha": "9221c0834e459ed2505b8abb690c48cd81949e25",
"last_reviewed_commit": "a22ce85aa9edd8bcf0e8685f8687f391161b8a66",
"last_reviewed_base_ref": "main",
"last_reviewed_base_sha": "9221c0834e459ed2505b8abb690c48cd81949e25",
"last_recorded_worker_run_id": "29737578224",
"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": "a22ce85aa9edd8bcf0e8685f8687f391161b8a66",
"review_id": 4734457208
}
]
} |
There was a problem hiding this comment.
Holistic Review
Motivation: GC.GetTotalMemory(false) can return a negative (or under-reported) value under the default regions GC. In GCHeap::ApproxTotalBytesInUse, gen0 live bytes are computed as gen0_size - gen0_frag in unsigned size_t arithmetic. gen0_frag is a per-generation total spanning every gen0 region, but gen0_size stopped summing region spans at the ephemeral region (via an early break). When gen0 retains a region past the ephemeral one (e.g. a region pinned in place and swept rather than compacted), that region's fragmentation stays in gen0_frag while its span is dropped from gen0_size, so the subtraction can underflow and the QCall casts the near-2^64 value to a negative long. This surfaces through RuntimeEventSource's gc-heap-size counter and any GetTotalMemory consumer. Issue #106712.
Approach: The fix walks every gen0 region when computing gen0_size (removing the break at the ephemeral region) so the counted span matches the per-generation fragmentation total, mirroring how the whole region chain is walked elsewhere. The current_eph_seg local is moved into the #else (useregions/redacted) (segments) branch, which is now its only consumer, avoiding an unused-variable warning in the regions build. A priority-1, process-isolated, GCStressIncompatible regression test drives a churning ring of pinned objects plus concurrent GetTotalMemory probing to deterministically reproduce the retained-region layout, asserting no probe is negative. Note: the PR description references an allocation.cpp discard-branch reordering, but the final commit intentionally dropped that; the actual change is confined to interface.cpp and the new test.
Summary: The change is correct, minimal, and well-scoped. Under USE_REGIONS, the loop now uses in_range_for_segment(current_alloc_allocated, gen0_seg) to cap the ephemeral region at the current allocation pointer and heap_segment_allocated for all other regions, correctly accumulating the full gen0 span so it is consistent with gen0_frag. The added region walk is bounded by the (small) gen0 region count and runs under the already-held GC lock, with no change to what the GC collects or promotes. The regression test follows existing GC API test conventions (flat .cs/.csproj, [Fact] TestEntryPoint), is single-file, and is claimed to reliably fail pre-fix and pass post-fix on both Debug and Release. I have no actionable findings. Verdict: LGTM.
Note
This review was generated by this repository's Holistic Review agentic workflow to complement the built-in Copilot review.
Generated by Holistic Review · 69.4 AIC · ⌖ 10.6 AIC · ⊞ 10K
The region-walk fix already removes the structural underflow at its source. Add a defensive clamp so the residual lock-free transient (gen0_frag updated by the allocator under more_space_lock_soh, not the gc_lock held by the reader) can never surface as a negative from GC.GetTotalMemory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ad1ce451-e247-42ec-accc-c4a688fcd555
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/coreclr/gc/interface.cpp:2094
- The PR description no longer matches the code changes here: it states there is "No clamping" and also describes an
allocation.cppbookkeeping reorder, but the current diff adds a clamp inApproxTotalBytesInUseand does not include anyallocation.cppchanges. Please update the PR description to reflect the current implementation so future readers understand what actually shipped.
// Defense-in-depth clamp: gen0_frag is updated by the allocator under a different lock, so a lock-free read must never underflow to a negative.
totsize = (gen0_size > gen0_frag) ? (gen0_size - gen0_frag) : 0;
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Thread.Join(TimeSpan) returns false on timeout; the result was ignored, so a worker that never observed s_stop could be silently skipped and the test could pass while masking a cleanup/liveness issue. Capture the join result and assert all workers stopped, after the primary non-negative assertion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ad1ce451-e247-42ec-accc-c4a688fcd555
Fixes #106712.
Summary
GC.GetTotalMemory(false)intermittently returns a negative value under the regions GC (the default since .NET 7). The value originates inGCHeap::ApproxTotalBytesInUse, which estimates gen0 live bytes asgen0_size - gen0_fragusing unsignedsize_tarithmetic.gen0_frag(free-list + free-object space) is a per-generation total that spans every gen0 region, butgen0_sizeonly summed region spans up to the ephemeral region. When gen0 retains another region (for example one pinned in place and swept rather than compacted), that region's fragmentation stays ingen0_fragwhile its span is dropped fromgen0_size, sogen0_frag > gen0_size, the subtraction underflows, and the public API casts the near-2^64size_tto a negativelong.In plain words: gen0 live bytes are "space minus holes." With segments, gen0 is a single contiguous block, so the holes are always a subset of the space and the subtraction can't go negative. With regions, gen0 is a linked list of blocks, and the code summed the holes over the whole list but measured the space over only part of it - so the holes could exceed the space and the unsigned subtraction wrapped.
The negative is only the visible symptom. The same miscount also makes
GetTotalMemorysilently under-report gen0 whenever the dropped span is smaller thangen0_frag(positive but too low) - observed returning ~0.4 MB where the correct value was ~16.0 MB.Root cause
Two mechanisms, neither a GC race:
gen0_sizewhile its fragmentation stayed ingen0_frag.a_fit_free_list_p's free-list discard path,free_obj_spaceis incremented beforefree_list_spaceis decremented, so a lock-free reader could briefly observe both - a transient over-count.Why a forced
GC.GetTotalMemory(true)(and continued allocation) self-healsThe reported observation that a forced
GetTotalMemory(true)returns a sane value and stops the negatives - and that continued allocation walks the value back positive - follows directly from Mechanism #1. The bytes are never wrong; only the lock-free measurement is.GetTotalMemory(true)forces blocking, compacting GC(s). Without pinning, gen0 is fully compacted: survivors are relocated, the swept-in-place regions linked after the ephemeral one are reclaimed, and gen0's free-space counters reset. Gen0 collapses to a clean layout where the counted span again covers all its fragmentation (gen0_frag ≤ gen0_size), so the next computation is correct — the bad layout is repaired.alloc_allocatedadvances in the ephemeral region, so the countedgen0_sizegrows; once it climbs back abovegen0_fragthe subtraction stops underflowing. The value flip-flops as allocation and GCs change which snapshot a read catches.The fix
src/coreclr/gc/interface.cpp- walk every gen0 region when computinggen0_size(removing the earlybreakat the ephemeral region), so the counted span matches the per-generation fragmentation total. This mirrors howgeneration_size()inplan_phase.cppwalks the whole region chaintotsize = (gen0_size > gen0_frag) ? (gen0_size - gen0_frag) : 0instead of an unguardedsize_tsubtraction. The first fix removes the reproducible underflow at its source; the clamp guarantees the residual lock-free transient described below can never surface as a negative fromGC.GetTotalMemorysrc/coreclr/gc/allocation.cpp- reorder the discard-branch bookkeeping (unlink→free_list_space -=→free_obj_space +=) so the only transient a concurrent reader can observe is a harmless under-count instead of an over-count that could underflow. The final state is identical; this flips the transient's direction rather than removing it. After discussion this was not taken, as also inaccurate and misleading.Why this is regions-only
With segments, gen0 lives inside a single contiguous ephemeral segment; its fragmentation is by construction a subset of the counted span, so the subtraction can never underflow. The bug requires gen0 to span multiple regions with one retained past the ephemeral one — only possible under regions. Empirically, with the same source and workload: built-in
coreclr.dll(regions) → NEG −12,194,016 @ 97 ms; standaloneclrgcexp.dll(regions) → NEG −12,158,984 @ 96 ms; standaloneclrgc.dll(segments) → 0 negatives over 13.5 M probes. Matches the field report thatDOTNET_GCName=clrgc.dlldoesn't repro.Reproduction
Two repros, both run by swapping only
coreclr.dllbetween fixed and unfixed builds:Amplifier (reliable, used by the regression test). A console app keeps a large, continuously-refreshed ring of pinned tiny objects (forcing retained gen0 regions) while flooding gen0 with garbage and probing
GC.GetTotalMemoryon another thread. Fails in well under a second (~60–70 K probes, e.g. NEG −12,183,896), and fires even single-threaded (threads=1) — confirming a structural miscount, not a race.The reporter's exact repros from the issue (only change: a wall-clock cap), which use no pinning:
Takeaway: pinning is a reliable amplifier, not a prerequisite — heavy multithreaded churn triggers frequent GCs that transiently reshuffle the gen0 region list into the same buggy layout.
Testing
New regression test
src/tests/GC/API/GC/GetTotalMemoryConcurrent.cs(+.csproj), priority 1, process-isolated,GCStressIncompatible,[Fact] TestEntryPointidiom. It runs the pinned + concurrent-probe workload and fails if any probe is negative. Pinning converts the rare transient into a deterministic, structural repro that fires in milliseconds.Proven fail → pass (swapping only
coreclr.dllfixed↔unfixed), on both Debug and full Release:-11,997,832after 100,285 probes-11,970,520after only 83,187 probesThe Release run proves the fix holds on an optimized runtime; the unfixed Release binary reproduces the negative even faster than Debug.
Existing suite (no regressions)
GC/API/GC/GetTotalMemory,TotalMemory,TotalMemory2,GetGCMemoryInfo,GetTotalAllocatedBytes,GetAllocatedBytesForCurrentThread— all exit 100 against the fixed runtime (default regions GC).The ".NET 10 fixed the single-threaded repro" claim — disproved
The issue notes the single-threaded (no-pin) repro was "fixed on .NET 10." Testing shows this is a measurement artifact, not a code fix: the accounting site is byte-identical since regions shipped in .NET 7 (
git log -Son the guard block returns only PR #59283 plus mechanicalgc.cpp → interface.cppsplit commits — no .NET 8/9/10 change to the gen0 loop or subtraction). Against installed retail runtimes:The structural underflow is single-thread-reproducible on every shipping version (15–49 ms with pinning). The no-pin single-threaded case is simply a rare transient on all versions (identical .NET 9 vs 10 behavior) — the perceived ".NET 10 fix" is timing noise, not a behavioral change. This PR is the first actual correction of the root cause.
Impact & blast radius
ApproxTotalBytesInUseis reached only viaGCHeap::GetTotalBytesInUse, whose only callers are theSystem.GC.GetTotalMemory(bool)QCall (CoreCLR) andRhpGetTotalBytesInUse(NativeAOT). The one in-box managed consumer isRuntimeEventSource'sgc-heap-sizePollingCounter(GC.GetTotalMemory(false) / 1e6), which surfaces the negative through EventCounters /dotnet-counters/ EventPipe / APM telemetry.forceFullCollection: truestabilization loop inGetTotalMemory(bool), whosediff = (newSize − size) / sizeconvergence test was meaningless whilesizewas underflowed.interface.cppadds a bounded region walk (gen0 region count, single digits) plus a clamped subtraction, both under the already-heldgc_lock.Performance impact
GetTotalMemoryis a diagnostic API (thegc-heap-sizecounter polls it ~1×/sec), not a hot path. Local A/B (same build, onlycoreclr.dllswapped; no allocation in the timed section; batched quantum-free mean; workstation GC, x64):Release (shipping configuration — authoritative):
pins=0, single gen0 region)pins=40000, several regions)Debug (checked; absolute numbers and delta both inflated by contract checks):
pins=0)pins=40000)The common case is free on either config (a single gen0 region means the fix walks the same one region). The Debug fragmented +53 ns is Debug-accessor overhead (contract checks on
heap_segment_next/_mem/_allocated,in_range_for_segment), not a real cost — Release shows ≈0 in both scenarios. Cost is bounded by the (inherently small) gen0 region count.Note
This pull request was prepared with the assistance of AI (GitHub Copilot). The root-cause analysis, fix, and regression test were reviewed by me before submitting.