Skip to content

Fix GC.GetTotalMemory returning negative under regions GC - #130888

Merged
janvorli merged 7 commits into
dotnet:mainfrom
kkokosa:fix/gc-gettotalmemory-negative-106712
Jul 27, 2026
Merged

Fix GC.GetTotalMemory returning negative under regions GC#130888
janvorli merged 7 commits into
dotnet:mainfrom
kkokosa:fix/gc-gettotalmemory-negative-106712

Conversation

@kkokosa

@kkokosa kkokosa commented Jul 16, 2026

Copy link
Copy Markdown
Member

Fixes #106712.

Summary

GC.GetTotalMemory(false) intermittently returns a negative value under the regions GC (the default since .NET 7). The value originates in GCHeap::ApproxTotalBytesInUse, which estimates gen0 live bytes as gen0_size - gen0_frag using unsigned size_t arithmetic. gen0_frag (free-list + free-object space) is a per-generation total that spans every gen0 region, but gen0_size only 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 in gen0_frag while its span is dropped from gen0_size, so gen0_frag > gen0_size, the subtraction underflows, and the public API casts the near-2^64 size_t to a negative long.

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 GetTotalMemory silently under-report gen0 whenever the dropped span is smaller than gen0_frag (positive but too low) - observed returning ~0.4 MB where the correct value was ~16.0 MB.

Root cause

size_t gen0_frag = generation_free_list_space(gen0) + generation_free_obj_space(gen0);
// gen0_size summed region spans, but stopped at the ephemeral region:
//   if (gen0_seg == current_eph_seg) break;
totsize = gen0_size - gen0_frag;   // size_t underflow when gen0_frag > gen0_size

Two mechanisms, neither a GC race:

  1. Multi-region gen0 span miscount (dominant). Any gen0 region linked after the ephemeral one had its span dropped from gen0_size while its fragmentation stayed in gen0_frag.
  2. Discard-branch transient (secondary and minor). In a_fit_free_list_p's free-list discard path, free_obj_space is incremented before free_list_space is decremented, so a lock-free reader could briefly observe both - a transient over-count.

Why a forced GC.GetTotalMemory(true) (and continued allocation) self-heals

The 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.
  • Continued allocation also recovers it without a GC. As the mutator allocates, alloc_allocated advances in the ephemeral region, so the counted gen0_size grows; once it climbs back above gen0_frag the 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 computing gen0_size (removing the early break at the ephemeral region), so the counted span matches the per-generation fragmentation total. This mirrors how generation_size() in plan_phase.cpp walks the whole region chain
  • defensive clamp - compute totsize = (gen0_size > gen0_frag) ? (gen0_size - gen0_frag) : 0 instead of an unguarded size_t subtraction. 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 from GC.GetTotalMemory
  • (cancelled) src/coreclr/gc/allocation.cpp - reorder the discard-branch bookkeeping (unlinkfree_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; standalone clrgcexp.dll (regions) → NEG −12,158,984 @ 96 ms; standalone clrgc.dll (segments) → 0 negatives over 13.5 M probes. Matches the field report that DOTNET_GCName=clrgc.dll doesn't repro.

Reproduction

Two repros, both run by swapping only coreclr.dll between 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.GetTotalMemory on 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:

    @kg repro (unmodified logic) Unfixed net11 Fixed net11
    Multithreaded (8 threads churning strings) ❌ NEG −2,883,184 after 140,958 probes @ 703 ms — "fails instantly", as reported 9.3 M probes / 25 s, no negative
    Single-threaded (rare) did not fire in our windows (Debug 356 K / 900 s, Release 5.3 M / 900 s, min ≥ 0) — matches "takes longer to reproduce" ✅ clean

    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] TestEntryPoint idiom. 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.dll fixed↔unfixed), on both Debug and full Release:

Build Runtime Result
Debug Unfixed ❌ exit 101 — negative -11,997,832 after 100,285 probes
Debug Fixed ✅ exit 100, 3/3 runs, ~2.8–2.9 M probes each, min ≈ +566,768
Release Unfixed ❌ exit 101 — negative -11,970,520 after only 83,187 probes
Release Fixed ✅ exit 100, 40.7–43.6 M probes, min ≈ +571,472

The 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 -S on the guard block returns only PR #59283 plus mechanical gc.cpp → interface.cpp split commits — no .NET 8/9/10 change to the gen0 loop or subtraction). Against installed retail runtimes:

Repro variant net 8.0.27 net 9.0.16 net 10.0.8 net 11 (unfixed)
pinned, single-threaded NEG −12,141,504 @ 18 ms NEG −11,967,528 @ 49 ms NEG −12,103,120 @ 15 ms NEG −12,189,472 @ 147 ms
no-pin, multithreaded NEG −417,696 @ 50 ms NEG −2,083,192 @ 10 ms NEG −741,272 @ 25 ms (regions repro)
no-pin, single-threaded 0 neg / 60 s 0 neg / 120 s 0 neg / 120 s 0 neg / 60 s

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

ApproxTotalBytesInUse is reached only via GCHeap::GetTotalBytesInUse, whose only callers are the System.GC.GetTotalMemory(bool) QCall (CoreCLR) and RhpGetTotalBytesInUse (NativeAOT). The one in-box managed consumer is RuntimeEventSource's gc-heap-size PollingCounter (GC.GetTotalMemory(false) / 1e6), which surfaces the negative through EventCounters / dotnet-counters / EventPipe / APM telemetry.

  • No public API surface change — same signature and semantics, just a corrected number. Also repairs the forceFullCollection: true stabilization loop in GetTotalMemory(bool), whose diff = (newSize − size) / size convergence test was meaningless while size was underflowed.
  • Risk is confined to accounting — the change alters nothing about what the GC collects, promotes, or decommits. interface.cpp adds a bounded region walk (gen0 region count, single digits) plus a clamped subtraction, both under the already-held gc_lock.

Performance impact

GetTotalMemory is a diagnostic API (the gc-heap-size counter polls it ~1×/sec), not a hot path. Local A/B (same build, only coreclr.dll swapped; no allocation in the timed section; batched quantum-free mean; workstation GC, x64):

Release (shipping configuration — authoritative):

Scenario Unfixed Fixed Delta
Common case (pins=0, single gen0 region) ~45.4 ns ~43.7 ns ≈0 ns (identical result 626,712)
Fragmented (pins=40000, several regions) ~48.8 ns (under-counts: 0.4 M) ~47.0 ns (correct: 16.0 M) ≈0 ns (within noise)

Debug (checked; absolute numbers and delta both inflated by contract checks):

Scenario Unfixed Fixed Delta
Common case (pins=0) ~535.7 ns ~537.8 ns +~2 ns (≈0 %)
Fragmented (pins=40000) ~558.5 ns (under-counts: 0.4 M) ~611.2 ns (correct: 16.0 M) +~53 ns (+~9 %)

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.

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
Copilot AI review requested due to automatic review settings July 16, 2026 17:17
@azure-pipelines

Copy link
Copy Markdown
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.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @anicka-net, @dotnet/gc
See info in area-owners.md if you want to be subscribed.

@kkokosa

kkokosa commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

@dotnet-policy-service agree company="Microsoft"

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

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 thread src/tests/GC/API/GC/GetTotalMemoryConcurrent.cs Outdated
Comment thread src/coreclr/gc/allocation.cpp Outdated
Comment thread src/coreclr/gc/allocation.cpp Outdated
Copilot AI review requested due to automatic review settings July 17, 2026 09:47

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 4 out of 4 changed files in this pull request and generated no new comments.

Comment thread src/coreclr/gc/interface.cpp Outdated
… 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
Copilot AI review requested due to automatic review settings July 20, 2026 10:44

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 3 out of 3 changed files in this pull request and generated 2 comments.

Comment thread src/coreclr/gc/interface.cpp
Comment thread src/coreclr/gc/interface.cpp
@kkokosa

kkokosa commented Jul 20, 2026

Copy link
Copy Markdown
Member Author
  • I've removed reordering of the updates fix - it was minor side-correction and not root cause, also does give guarantee as discussed
  • comment removed in the code

@kkokosa
kkokosa marked this pull request as ready for review July 20, 2026 10:50
@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.

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

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
    }
  ]
}

@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: 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
Copilot AI review requested due to automatic review settings July 24, 2026 13:08

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 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.cpp bookkeeping reorder, but the current diff adds a clamp in ApproxTotalBytesInUse and does not include any allocation.cpp changes. 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;

Comment thread src/coreclr/gc/interface.cpp Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 24, 2026 13:18

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 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/tests/GC/API/GC/GetTotalMemoryConcurrent.cs
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
Copilot AI review requested due to automatic review settings July 24, 2026 13:30

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 3 out of 3 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 24, 2026 13:49

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 3 out of 3 changed files in this pull request and generated no new comments.

@janvorli
janvorli merged commit 1103cab into dotnet:main Jul 27, 2026
116 checks passed
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 11.0-rc1 milestone Jul 28, 2026
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.

GC.GetTotalMemory occasionaly returns negative values

5 participants