From 8ae297a9ba3580c2ce6f22094ec9c8e364b91be1 Mon Sep 17 00:00:00 2001 From: Konrad Kokosa Date: Mon, 20 Jul 2026 10:50:12 +0200 Subject: [PATCH 1/8] Fix non-monotonic GC.GetTotalAllocatedBytes under DATAS (Server GC) GCHeap::GetTotalAllocatedBytes summed per-heap total_alloc_bytes over the currently-active heaps [0, n_heaps). Under DATAS the active heap count shrinks and grows at runtime; decommissioned heaps keep their (real, never-reset) total_alloc_bytes, and recommission/change_heap_count never carry them over. As a result the returned value could drop when heaps were decommissioned (or jump when re-added), making GC.GetTotalAllocatedBytes(precise: true) non-monotonic. BenchmarkDotNet's MemoryDiagnoser surfaced this as random 0 B or hugely inflated allocation numbers. Sum over all heaps that ever existed [0, n_max_heaps) instead. All n_max_heaps heaps are created at startup and remain valid; allocation only occurs on active heaps, so this yields the true cumulative total and stays monotonic regardless of DATAS heap-count changes. When DATAS is off, n_heaps == n_max_heaps, so behavior is unchanged. Fixes #118826 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6875c76e-3ab7-4000-98dc-f7233d09c6ce --- src/coreclr/gc/interface.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/coreclr/gc/interface.cpp b/src/coreclr/gc/interface.cpp index 00d5f77f1a5e3f..aecd3abb80d6e0 100644 --- a/src/coreclr/gc/interface.cpp +++ b/src/coreclr/gc/interface.cpp @@ -2023,7 +2023,7 @@ uint64_t GCHeap::GetTotalAllocatedBytes() { #ifdef MULTIPLE_HEAPS uint64_t total_alloc_bytes = 0; - for (int i = 0; i < gc_heap::n_heaps; i++) + for (int i = 0; i < gc_heap::n_max_heaps; i++) { gc_heap* hp = gc_heap::g_heaps[i]; total_alloc_bytes += hp->total_alloc_bytes_soh; From 9d7d71acdc5825d1bbb9ec2668a4058c5c8b3a5f Mon Sep 17 00:00:00 2001 From: Konrad Kokosa Date: Mon, 20 Jul 2026 12:11:30 +0200 Subject: [PATCH 2/8] Add regression test for GetTotalAllocatedBytes under Server GC + DATAS Drives an oscillating allocation load with frequent forced blocking gen2 GCs so DATAS decommissions/recommissions heaps, and asserts the precise cumulative allocated-bytes total never decreases. Reproduces the non-monotonic drop from issue #118826 on unfixed builds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6875c76e-3ab7-4000-98dc-f7233d09c6ce --- .../API/GC/GetTotalAllocatedBytesServerGC.cs | 127 ++++++++++++++++++ .../GC/GetTotalAllocatedBytesServerGC.csproj | 22 +++ 2 files changed, 149 insertions(+) create mode 100644 src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs create mode 100644 src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.csproj diff --git a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs new file mode 100644 index 00000000000000..47e47a334e1c04 --- /dev/null +++ b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs @@ -0,0 +1,127 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Regression test for a Server GC accounting bug in GC.GetTotalAllocatedBytes(). +// +// GCHeap::GetTotalAllocatedBytes() summed each heap's cumulative total_alloc_bytes +// over the *currently-active* heaps [0, n_heaps). Under DATAS (Dynamic Adaptation To +// Application Sizes, default-on for Server GC) the active heap count grows and shrinks +// at run time as load changes. Because a decommissioned heap keeps its (real, frozen) +// total_alloc_bytes, excluding it from the sum made the returned value DROP whenever the +// heap count shrank -- i.e. GC.GetTotalAllocatedBytes() became non-monotonic. The +// precise overload has no smoothing, so it exposed the drop directly (surfacing as random +// zero / hallucinated allocation deltas in tools that difference two reads). +// +// This test drives Server GC with an oscillating allocation load (bursts grow the heap +// count, quiet periods let DATAS shrink it) while forcing frequent blocking gen2 GCs -- +// the points at which DATAS re-evaluates and decommissions heaps -- and repeatedly reads +// the precise cumulative total. Because the value is a cumulative counter, it must never +// decrease. A shrink-induced drop equals an entire heap's cumulative allocation, which is +// far larger than the small, by-design fluctuation of the precise value (per-thread unused +// allocation-context bytes); the tolerance below absorbs the latter while catching the bug. +// +// Only meaningful under Server GC (the .csproj sets DOTNET_gcServer=1). Under Workstation +// GC there is a single heap and no heap-count oscillation, so the check is a trivial pass. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using Xunit; + +public class GetTotalAllocatedBytesServerGC +{ + private static volatile object s_sink; + + [Fact] + public static void TestEntryPoint() + { + TimeSpan budget = TimeSpan.FromSeconds(20); + + // The precise total legitimately fluctuates by a small amount (it subtracts each + // thread's currently-unused allocation-context bytes). The bug drops a whole heap's + // cumulative counter, which is orders of magnitude larger. Allow generous slack for + // the benign fluctuation so the test cannot false-fail on a correct runtime. + const long Tolerance = 64L * 1024 * 1024; + + int burstThreads = Math.Max(2, Environment.ProcessorCount); + using var cts = new CancellationTokenSource(); + CancellationToken token = cts.Token; + + // Load controller: alternate a heavy BURST (many allocators -> DATAS grows the heap + // count) with a QUIET period (near-zero allocation -> DATAS shrinks it). + var controller = new Thread(() => + { + while (!token.IsCancellationRequested) + { + var burst = new List(); + using var burstCts = new CancellationTokenSource(); + for (int i = 0; i < burstThreads; i++) + { + var t = new Thread(() => + { + var rnd = new Random(Environment.CurrentManagedThreadId); + object local = null; + while (!burstCts.IsCancellationRequested) + { + for (int j = 0; j < 2000; j++) + local = new byte[rnd.Next(16, 4096)]; + } + GC.KeepAlive(local); + }) + { IsBackground = true }; + t.Start(); + burst.Add(t); + } + + if (token.WaitHandle.WaitOne(1500)) { burstCts.Cancel(); break; } + burstCts.Cancel(); + foreach (var t in burst) + t.Join(2000); + + // QUIET: near-zero allocation so DATAS decides to shrink the heap count. + if (token.WaitHandle.WaitOne(3000)) + break; + } + }) + { IsBackground = true, Name = "load-controller" }; + controller.Start(); + + long maxDrop = 0; + long prevInitial = 0, prevFinal = 0; + long prev = GC.GetTotalAllocatedBytes(precise: true); + var sw = Stopwatch.StartNew(); + try + { + while (sw.Elapsed < budget) + { + // A blocking gen2 GC is where DATAS re-evaluates (and can decommission) heaps. + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + long cur = GC.GetTotalAllocatedBytes(precise: true); + if (cur < prev) + { + long drop = prev - cur; + if (drop > maxDrop) + { + maxDrop = drop; + prevInitial = prev; + prevFinal = cur; + } + } + prev = cur; + } + } + finally + { + cts.Cancel(); + controller.Join(3000); + } + + Assert.True( + maxDrop <= Tolerance, + $"GC.GetTotalAllocatedBytes(precise: true) is a cumulative counter and must not " + + $"decrease, but it dropped by {maxDrop:N0} bytes (from {prevInitial:N0} to {prevFinal:N0}), " + + $"exceeding the {Tolerance:N0} byte tolerance. Server GC = {System.Runtime.GCSettings.IsServerGC}, " + + $"processors = {Environment.ProcessorCount}."); + } +} diff --git a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.csproj b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.csproj new file mode 100644 index 00000000000000..3de0661534aef7 --- /dev/null +++ b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.csproj @@ -0,0 +1,22 @@ + + + + true + 1 + + true + + + PdbOnly + + true + + + + + + + + + + From ca2cf687e7d6fe50f04b231422e79aa3463b894a Mon Sep 17 00:00:00 2001 From: Konrad Kokosa Date: Mon, 20 Jul 2026 14:33:23 +0200 Subject: [PATCH 3/8] Remove unused s_sink field in GetTotalAllocatedBytesServerGC test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ad1ce451-e247-42ec-accc-c4a688fcd555 --- src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs index 47e47a334e1c04..650737be7d84b5 100644 --- a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs +++ b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs @@ -31,8 +31,6 @@ public class GetTotalAllocatedBytesServerGC { - private static volatile object s_sink; - [Fact] public static void TestEntryPoint() { From 3c5e6871bffbba8846ea395603b042e984d9ef66 Mon Sep 17 00:00:00 2001 From: Konrad Kokosa Date: Tue, 21 Jul 2026 19:55:23 +0200 Subject: [PATCH 4/8] Join burst threads before QUIET period in GetTotalAllocatedBytes test Always cancel the burst CTS and join the burst threads before checking for cancellation, so no allocator thread outlives the BURST phase. This guarantees the following QUIET period is truly allocation-free and DATAS can decide to shrink the heap count deterministically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ad1ce451-e247-42ec-accc-c4a688fcd555 --- src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs index 650737be7d84b5..e17757c3e16582 100644 --- a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs +++ b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs @@ -72,11 +72,17 @@ public static void TestEntryPoint() burst.Add(t); } - if (token.WaitHandle.WaitOne(1500)) { burstCts.Cancel(); break; } + bool cancelled = token.WaitHandle.WaitOne(1500); + + // Always cancel and join the burst threads so no allocator outlives the + // BURST -- this guarantees the following QUIET period is truly allocation-free. burstCts.Cancel(); foreach (var t in burst) t.Join(2000); + if (cancelled) + break; + // QUIET: near-zero allocation so DATAS decides to shrink the heap count. if (token.WaitHandle.WaitOne(3000)) break; From 91262690c351a2eef0964e9eff008bf584bd9186 Mon Sep 17 00:00:00 2001 From: Konrad Kokosa Date: Mon, 27 Jul 2026 17:11:37 +0200 Subject: [PATCH 5/8] Improve test assertion Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs index e17757c3e16582..06fee4074e72e4 100644 --- a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs +++ b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs @@ -34,6 +34,11 @@ public class GetTotalAllocatedBytesServerGC [Fact] public static void TestEntryPoint() { + if (!System.Runtime.GCSettings.IsServerGC) + { + throw new Exception("ERROR: Requires server GC."); + } + TimeSpan budget = TimeSpan.FromSeconds(20); // The precise total legitimately fluctuates by a small amount (it subtracts each From 7f9596395b405c55c805a9978185c3ddcd853085 Mon Sep 17 00:00:00 2001 From: Konrad Kokosa Date: Mon, 27 Jul 2026 23:53:36 +0200 Subject: [PATCH 6/8] Improving test for failing fast Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs index 06fee4074e72e4..cc3ba5bb25a805 100644 --- a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs +++ b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs @@ -116,6 +116,11 @@ public static void TestEntryPoint() prevInitial = prev; prevFinal = cur; } + + if (maxDrop > Tolerance) + { + break; + } } prev = cur; } From 3f199b01d4b26f6d879ebcbc6a4d5faf691c9b2d Mon Sep 17 00:00:00 2001 From: Konrad Kokosa Date: Tue, 28 Jul 2026 17:30:06 +0200 Subject: [PATCH 7/8] Small test cleanup Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs index cc3ba5bb25a805..0ce48a9293f738 100644 --- a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs +++ b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs @@ -59,13 +59,14 @@ public static void TestEntryPoint() { var burst = new List(); using var burstCts = new CancellationTokenSource(); + CancellationToken burstToken = burstCts.Token; for (int i = 0; i < burstThreads; i++) { var t = new Thread(() => { var rnd = new Random(Environment.CurrentManagedThreadId); object local = null; - while (!burstCts.IsCancellationRequested) + while (!burstToken.IsCancellationRequested) { for (int j = 0; j < 2000; j++) local = new byte[rnd.Next(16, 4096)]; From 444eb72bfdea3ca9663c8de759c2cf56628e9f19 Mon Sep 17 00:00:00 2001 From: Konrad Kokosa Date: Tue, 28 Jul 2026 19:12:56 +0200 Subject: [PATCH 8/8] Cap burst allocator thread count in GetTotalAllocatedBytes test Clamp burstThreads to [2, 16] instead of scaling 1:1 with ProcessorCount, avoiding hundreds of allocator threads (oversubscription/flakiness) on high-core machines while still driving DATAS heap-count oscillation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs index 0ce48a9293f738..6168e2071bd91d 100644 --- a/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs +++ b/src/tests/GC/API/GC/GetTotalAllocatedBytesServerGC.cs @@ -47,7 +47,11 @@ public static void TestEntryPoint() // the benign fluctuation so the test cannot false-fail on a correct runtime. const long Tolerance = 64L * 1024 * 1024; - int burstThreads = Math.Max(2, Environment.ProcessorCount); + // Cap the allocator count: we only need enough parallel allocators to drive DATAS + // heap-count oscillation, not one per core. Scaling 1:1 with ProcessorCount would spawn + // hundreds of threads on high-core machines, oversubscribing the box and making the test + // needlessly heavy and flaky without improving repro reliability. + int burstThreads = Math.Clamp(Environment.ProcessorCount, 2, 16); using var cts = new CancellationTokenSource(); CancellationToken token = cts.Token;