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)

Most upvoted comments

@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

Suffice to say that we did allocate, and therefore we observe the survived bytes changes.

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:

coreclr!AllocateSzArray+0x304  
coreclr!AllocatePrimitiveArray+0x36a  
coreclr!NoRemoveDefaultCrossLoaderAllocatorHashTraits<MethodDesc *,MethodDesc *>::AddToValuesInHeapMemory+0x147  
coreclr!CrossLoaderAllocatorHash<InliningInfoTrackerHashTraits>::Add+0x221  
coreclr!JITInlineTrackingMap::AddInliningDontTakeLock+0x5e8  
coreclr!JITInlineTrackingMap::AddInlining+0x9ec  
coreclr!Module::AddInlining+0xa09  
coreclr!CEEInfo::reportInliningDecision+0x115c  
clrjit!InlineResult::Report+0x89  
clrjit!InlineResult::{dtor}+0xa  
clrjit!Compiler::fgInline+0x2cf  
clrjit!Phase::Run+0x1e  
clrjit!DoPhase+0x50  
clrjit!Compiler::compCompile+0x3d8  
clrjit!Compiler::compCompileHelper+0x291  
clrjit!Compiler::compCompile+0x24a  
clrjit!jitNativeCode+0x262  
clrjit!CILJit::compileMethod+0x83  
coreclr!invokeCompileMethodHelper+0x86  
coreclr!invokeCompileMethod+0xc5  
coreclr!UnsafeJitFunction+0x7f1  
coreclr!MethodDesc::JitCompileCodeLocked+0x1f1  
coreclr!MethodDesc::JitCompileCodeLockedEventWrapper+0x466  
coreclr!MethodDesc::JitCompileCode+0x2a9  
coreclr!MethodDesc::PrepareILBasedCode+0x66  
coreclr!MethodDesc::PrepareCode+0x10  
coreclr!TieredCompilationManager::CompileCodeVersion+0xce  
coreclr!TieredCompilationManager::OptimizeMethod+0x22  
coreclr!TieredCompilationManager::DoBackgroundWork+0x125  
coreclr!TieredCompilationManager::BackgroundWorkerStart+0xc8  
coreclr!TieredCompilationManager::BackgroundWorkerBootstrapper1+0x5c  
coreclr!ManagedThreadBase_DispatchInner+0xd  
coreclr!ManagedThreadBase_DispatchMiddle+0x85  
coreclr!ManagedThreadBase_DispatchOuter+0xae  
coreclr!ManagedThreadBase_FullTransition+0x24  
coreclr!ManagedThreadBase::KickOff+0x24  
coreclr!TieredCompilationManager::BackgroundWorkerBootstrapper0+0x3a  
KERNEL32!BaseThreadInitThunk+0x10  
ntdll!RtlUserThreadStart+0x2b

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.

There are some other unimportant allocations too, see (*) at the end of the message for reference.

(3)

The contract of the method AppDomain.MonitoringSurvivedMemorySize has always been this according to the documentation.

Gets the number of bytes that survived the last collection and that are known to be referenced by the current application domain.

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 calling MonitoringSurvivedProcessMemorySize is calling GetGCMemoryInfo, 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 for ArrayPool.

When we perform a GC.Collect, the ArrayPool wants to trim itself on the finalizer thread and iterates over a ConditionalWeakTable. The iteration requires the construction of an Enumerator 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.