0

I am trying to write a test for some code I wrote recently which marks memory as pinned, by allocating a GCHandle (for persistent use across interop with C++).

I am testing with a very large allocation in a single object that contains some float and int arrays. I want to check that once I've freed the GCHandle for each of the arrays, the memory is able to be garbage collected.

Currently I can see the memory allocated increasing with a call to GC.GetTotalAllocatedBytes(true); or currentProcess.WorkingSet64; and can confirm the increase is about the same size as my rough estimate of the object being created (just adding up the size of the large arrays in the object).

However, I can't seem to get the number to shrink after removing the object, even without pinning the arrays above. I've tried

GC.Collect();            
GC.WaitForPendingFinalizers();

to force collection across all generations, but my memory use never drops. I understand that the GC may be holding memory back for reuse, but I hoped there was a way to deallocate this large chunk I've created for this singular purpose..

Am I looking at the wrong metric, or is this just something that's out of my hands?

Example:

using System.Diagnostics;
using System.Runtime.InteropServices;

namespace TestApp
{
    public class MemUser
    {        
        private float[] data;
        private GCHandle dataHandle;
        public float[] Data { get { return data; } set { data = value; } }
        public MemUser(int numData)
        {
            data = new float[numData];
        }
        public long RoughSize()
        {
            return data.Length * sizeof(float);
        }
        public void GetPinnedHandles()
        {
            if (dataHandle.IsAllocated)
                dataHandle.Free();
            dataHandle = GCHandle.Alloc(dataHandle, GCHandleType.Pinned);
        }

        public void FreePinnedHandles()
        {
            if (dataHandle.IsAllocated)
                dataHandle.Free();
        }
        public void Clear()
        {
            FreePinnedHandles();
            data = null;
        }
    }

    public class Program
    {

        public static void Main(string[] args)
        {
            const int numFloats = 500 * 1000 * 1000;
            long beforeCreation = GetProcessMemory();
            // Create the object using a large chunk of memory 
            MemUser testMemory = new MemUser(numFloats);
            long roughMemSize = testMemory.RoughSize();
            // in practice, will pin the memory, but testing without for now
            //testMemory.GetPinnedHandles();

            //confirm memory in use jumps
            long afterCreation = GetProcessMemory();            
            long difference = afterCreation - beforeCreation;
            TestResult(difference, roughMemSize);

            // get rid of the object
            testMemory.Clear();
            testMemory = null;
           
            // this test fails, memory may have even gone up
            long afterDeletion = GetProcessMemory();
            difference = afterCreation - afterDeletion;
            TestResult(difference, roughMemSize);
        }


        public static long GetProcessMemory()
        {
            Process currentProcess = System.Diagnostics.Process.GetCurrentProcess();
            GC.Collect();
            GC.WaitForPendingFinalizers();
            long totalBytesOfMemoryUsed = GC.GetTotalAllocatedBytes(true);
            //long totalBytesOfMemoryUsed = currentProcess.WorkingSet64;
            return totalBytesOfMemoryUsed;
        }
        public static void TestResult(long difference, long expected)
        {
            
            if (difference >= expected)
                Console.Write($"PASS, difference ({difference}) >= {expected}\n");
            else
                Console.Write($"FAIL, difference ({difference}) < {expected}\n");
        }
    }
}
mike
  • 1,192
  • 9
  • 32
  • How do you test it? Using a console app or is this behavior observed in production app? Because for former case there could be some caveats - [check out this answer](https://stackoverflow.com/a/76121746/2501279) – Guru Stron May 02 '23 at 10:33
  • I'm trying to write an `Xunit` test – mike May 02 '23 at 10:34
  • 1
    it can be subjected to the same caveats (in theory). Can you please add a [mre]? – Guru Stron May 02 '23 at 10:36
  • sure, will set one up now – mike May 02 '23 at 10:40
  • 1
    if you are running in Debug mode then you need to be *outside* the function that holds the reference when you call `GC.Collect`, otherwise the lifetime is prolonged until the end of the function. Also you need to do `GC.Collect` again after calling `WaitForPendingFinalizers` – Charlieface May 02 '23 at 11:04
  • @Charlieface - that sounds promising. I'm just adding an example now that illustrates the problem but doesn't take that into account yet – mike May 02 '23 at 11:07
  • 1
    So put the lines `MemUser testMemory = new MemUser(numFloats); long roughMemSize = testMemory.RoughSize(); testMemory.Clear();` into a separate function – Charlieface May 02 '23 at 11:10
  • apologies for the dumb question but won't I need to retain a reference to `testMemory` to pass to the function to clear it? Is it OK if that reference is held in the Program class? – mike May 02 '23 at 11:12
  • 1
    @mike the provided repro is susceptible to exactly the same problem which I referenced in the first comment. Even if run in Release mode. – Guru Stron May 02 '23 at 11:26
  • 1
    you need to put the allocation of MemUser into an extra method and prevent inlining of that method to ensure that no stale stack references to your class are alive on the stack in release builds. During allocation the address is copied several times and as long as the stack frame is alive GC will not free memory of objects which are still reachable although the value is never used. – Alois Kraus May 02 '23 at 14:20

2 Answers2

1

You should probably be using GC.GetTotalMemory(true) to get the current memory usage. See also How to get the amount of memory used by an application.

GC.GetTotalAllocatedBytes(true);

From the documentation this looks like it counts the total number of allocations, not the current memory.

currentProcess.WorkingSet64;

Since this is process memory I would assume it includes memory that is managed by the garbage collector but not currently used. When memory is collected the GC will not immediately return it to the OS, since it is likely needed soon again.

But there are other problems. When using GCHandle.Alloc you voluntary opt out from the garbage collection. So it is very important that each alloc call is matched to a free call, at least for any long running process.

I would probably recommend using the fixed statement if possible. This should both be safer, and allow the memory to be moved when not actively used by native code. Pinning small objects for a long time can cause memory fragmentation.

JonasH
  • 28,608
  • 2
  • 10
  • 23
  • using this does get me better looking differences, very close to what I expect but for some reason the difference is slightly less than expected after clearing the object (explained below as `likely related to string interning within the process and memory used by the console object`, which I don't fully understand – mike May 02 '23 at 13:56
  • 1
    I have created a bad example above, the `GCHandle` use is commented out while I try to figure out the behaviour when not using it. In practice I do call free on the allocated handles. – mike May 02 '23 at 14:22
  • @mike. Since you're trying to deduce the memory used by the process, you need to realize that every string literal is going to be interned automatically. So when we write an example like we have, the process size increases each time we use a string literal. If you use my example verbatim, then comment the console output of the many '=' symbols and run it, you'll get a wholly different number for memory usage because the first call to ```GetTotalMemory``` does not use Console or know of any string content yet, which is why I put that line there - to get a more representative result. – majixin May 02 '23 at 14:24
  • @mike I would not expect the numbers to match exactly, there is probably some memory that is used for framework internal stuff that is counted. If you want a breakdown of what the memory is used for I would recommend using a memory profiler. – JonasH May 02 '23 at 14:24
  • thanks both, I'll try to write my test to allow a little leeway for the process size increasing during runtime – mike May 02 '23 at 14:27
1

The main issue is the code shown doesn't call dataHandle.Free() in MemUser's Clear() method.

You cannot rely on garbage collection to free GCHandle because it's a struct and not created on the heap.

I reworked your code to the following, which shows the memory is being cleared for the array, albeit without a few small additional number of bytes, which are likely related to string interning within the process and memory used by the console object.

As you can see if you run this, GC.Collect() and GC.WaitForPendingFinalizers() is not needed.

        public class MemUser
        {
            private float[] data;
            private GCHandle dataHandle;
            public MemUser(int numData)
            {
                data = new float[numData];
                dataHandle = GCHandle.Alloc(data, GCHandleType.Pinned);
            }
            public long RoughSize()
            {
                return data.Length * sizeof(float);
            }

            public void Clear()
            {
                dataHandle.Free();
                data = null;
            }
        }

        public static void Main(string[] args)
        {
            Console.WriteLine("====================================================================================================");
            const int numFloats = 500 * 1000 * 1000;
            long beforeCreation = GC.GetTotalMemory(true);
            Console.WriteLine("Memory used by process: " + beforeCreation);

            // Create the object using a large chunk of memory 
            MemUser testMemory = new MemUser(numFloats);
            long afterCreation = GC.GetTotalMemory(true);
            Console.WriteLine("Memory used by process after creation of large array pinned with GCHandle: " + afterCreation);

            //deduce the delta
            long delta = afterCreation - beforeCreation;
            Console.WriteLine("Additional memory used by process for the array and GCHandle: " + delta);
            GC.AddMemoryPressure(delta);

            // free the memory
            testMemory.Clear();
            testMemory = null;

            // did we reclaim all the memory? new delta should be close to zero once we free memory
            long afterDeletion = GC.GetTotalMemory(true);
            long newDelta = afterDeletion - beforeCreation;
            Console.WriteLine("Memory after deletion: " + afterDeletion);
            Console.WriteLine("Memory delta relative to before creation: " + newDelta);
            Console.ReadLine();
        }
majixin
  • 186
  • 9
  • you're right, that sample code never called `dataHandle.Free` but it also never called `GetPinnedHandles`, it was commented out. – mike May 02 '23 at 13:50
  • the main difference in behaviour is due to using `GC.GetTotalMemory`, I have similar results by only changing that. – mike May 02 '23 at 14:08
  • 1
    @mike. Indeed. Your question was about GCHandle, so I just showed an example where memory gets freed. In your real-world use-case using unmanaged-memory, the memory area would need to be shared and not belong exclusively to a different process. If it does belong to a different process, you'd need to use a copy instead. That said, if I comment ```dataHandle.Free()``` in my example, the memory is not freed - so it is critical. – majixin May 02 '23 at 14:13
  • 1
    I'm going to mark this as an answer as it includes the `GetTotalMemory` which helps a lot and also illustrates the difference that freeing `GCHandle` makes – mike May 02 '23 at 14:42