runtime: AppDomain.MonitoringSurvivedMemorySize incorrect in .NET 5.0
While trying to add a new feature to BDN to measure survived memory (https://github.com/dotnet/BenchmarkDotNet/pull/1596), I found that .NET Core 3.1 and .NET 5.0 were reporting unexpected values. I made a separate application to test whether the measurements I’m making are even accurate separate from all other BDN code. I found that it seems to be accurate in .NET Core 3.1, but it’s off in .NET 5.0.
Console prints:
Survived bytes: 0
Survived bytes: 24
Survived bytes: 24
With this code:
using System;
using System.Runtime.CompilerServices;
namespace MemoryTest
{
class Program
{
static int n;
static void Main(string[] args)
{
n = 1_000_000;
Measure(); // Run once for GC monitor to make its allocations.
Console.WriteLine($"Survived bytes: {Measure()}");
Console.WriteLine($"Survived bytes: {Measure()}");
Console.WriteLine($"Survived bytes: {Measure()}");
}
static long Measure()
{
long beforeBytes = GetTotalBytes();
NonAllocatingMethod();
long afterBytes = GetTotalBytes();
return afterBytes - beforeBytes;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void NonAllocatingMethod()
{
for (int i = 0; i < n; i++) { }
}
static long GetTotalBytes()
{
AppDomain.MonitoringIsEnabled = true;
// Enforce GC.Collect here to make sure we get accurate results.
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
return AppDomain.CurrentDomain.MonitoringSurvivedMemorySize;
}
}
}
[Edit] This was ran with Visual Studio 16.8.2 on Windows 7 SP1 on AMD Phenom II x6.
Also, the results above were from running in DEBUG mode directly in Visual Studio. After building in RELEASE configuration and running it separately, I got these results (different, but still wrong):
Survived bytes: 0
Survived bytes: 24
Survived bytes: 0
About this issue
- Original URL
- State: closed
- Created 4 years ago
- Comments: 21 (11 by maintainers)
@cshung Thank you for the detailed information. I have tested again on the latest .Net 7 preview and can confirm I no longer see abnormal survived bytes.
I am writing this to do these three things:
(1) Correct my previous mistake (2) Summarize the allocation and survival patterns within the second repro running on both .NET 6 and 7, and (3) Conclude that we are fine
(1) Earlier, when I said this
This is not entirely accurate, allocations does not necessarily mean survival. I need to make sure the allocation did survive before I conclude earlier.
(2) Between the time we printed “Searching for abnormal survived memory…” and “Abnormal survived memory found in {abnormalCount} out of {iterations} iterations.”, we did a lot of allocations.
Here is a call stack for the important allocations that does survive only on .NET 6 or below:
I understand the stack is cryptic, so here is a hopefully simple explanation of what is going on. At the bottom of the stack,
TieredCompilationManager
is working on a thread trying to optimize some methods. When the compilation happens, the JIT decided that it is a good idea to perform some inlining. In order for the profiler to be able to rewrite the IL for a particular method, it needs to know that the IL for a certain method is embedded in the compilation of some other methods so that it can invalidate the compiled code. As an implementation detail, that data is recorded as some objects on the GC heap - thus you see the allocation and survival.In .NET 7, #67160 changed the JIT inlining tracking map so that the tracking data is no longer allocated on the GC heap, so this allocation on the GC heap is not happening anymore.
(3)
The contract of the method
AppDomain.MonitoringSurvivedMemorySize
has always been this according to the documentation.It says nothing about whether or not the bytes are allocated by the user code or not. So there isn’t a contract violation per se.
That being said, I understand it can be inconvenient for benchmark.net to consume this API. Fortunately, the survival is gone in .NET 7. So I would recommend using this API for .NET 7+. @timcassell, if you observe the value still changes after #67160, feel free to let us know and we will deal with that on a case-by-case basis depending on what else is allocated on the GC heap.
(*)
For the record, here are some unimportant allocations that does not survive.
MonitoringSurvivedMemorySize
is callingMonitoringSurvivedProcessMemorySize
is callingGetGCMemoryInfo
, in which we need to allocate the object for output.Printing out the interpolated string leads to some allocation due to
DefaultInterpolatedStringHandler
and its use forArrayPool
.When we perform a
GC.Collect
, the ArrayPool wants to trim itself on the finalizer thread and iterates over aConditionalWeakTable
. The iteration requires the construction of anEnumerator
which is also an allocation.Since none of these survives, so they are fine and does not contribute to the
abnormalCount
value. It is included here just so we know we need to ignore them if we ever want to reproduce the debugging.