Skip to content

Commit 8da286e

Browse files
authored
Merge pull request #607 from SciSharp/memory_leak
[Bug] Add GC memory pressure tracking for native allocations to avoid memory leaking
2 parents f53e1a9 + de4c1ad commit 8da286e

2 files changed

Lines changed: 54 additions & 15 deletions

File tree

docs/website-src/docs/buffering.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ This page explains how to create arrays from existing buffers without copying, h
1212

1313
**Predictable Layout.** Managed arrays can be moved by the garbage collector at any time. Unmanaged memory stays put, which is essential when passing pointers to native libraries or GPU drivers.
1414

15-
**No GC Pauses.** Large managed arrays cause GC pressure. A 1GB NDArray in unmanaged memory doesn't affect GC at all.
15+
**Reduced GC Overhead.** Large managed arrays cause GC pressure and can trigger expensive collections. Unmanaged memory avoids this—though NumSharp still informs the GC about allocation sizes so it can schedule collections appropriately.
1616

1717
**Interop Efficiency.** When calling into native code (BLAS, CUDA, image processing libraries), unmanaged memory can be passed directly without marshaling.
1818

@@ -50,6 +50,14 @@ User Code
5050

5151
**Internal Infrastructure** handles the low-level details: pinning managed arrays so the GC won't move them, tracking ownership so memory gets freed at the right time, and managing the raw pointers. You don't need to interact with these directly—the external APIs handle it for you.
5252

53+
### GC Pressure Tracking
54+
55+
Although NumSharp uses unmanaged memory, the .NET garbage collector still needs to know about it. Otherwise, the GC sees only the small managed wrappers (~100 bytes each) and doesn't realize there's megabytes of unmanaged data attached. This can cause memory to grow unbounded before the GC kicks in.
56+
57+
NumSharp solves this by calling `GC.AddMemoryPressure()` when allocating native memory and `GC.RemoveMemoryPressure()` when freeing it. This applies to arrays created with `np.array()`, `np.zeros()`, `np.empty()`, and similar functions.
58+
59+
For external memory (via `np.frombuffer()` with a dispose callback), the caller is responsible for pressure tracking since NumSharp doesn't know how the memory was allocated.
60+
5361
---
5462

5563
## Creating Arrays from Buffers
@@ -163,17 +171,21 @@ This is appropriate when you're borrowing memory temporarily. You must ensure th
163171

164172
```csharp
165173
// We allocate native memory
166-
IntPtr ptr = Marshal.AllocHGlobal(1024 * sizeof(float));
174+
int bytes = 1024 * sizeof(float);
175+
IntPtr ptr = Marshal.AllocHGlobal(bytes);
176+
GC.AddMemoryPressure(bytes); // Tell GC about this allocation
167177
168178
// Transfer ownership to NumSharp
169-
var arr = np.frombuffer(ptr, 1024 * sizeof(float), typeof(float),
170-
dispose: () => Marshal.FreeHGlobal(ptr));
179+
var arr = np.frombuffer(ptr, bytes, typeof(float),
180+
dispose: () => {
181+
Marshal.FreeHGlobal(ptr);
182+
GC.RemoveMemoryPressure(bytes);
183+
});
171184

172185
// When arr is garbage collected, the dispose action runs
173-
// No manual free needed
174186
```
175187

176-
The `dispose` parameter takes an action that NumSharp calls when the array is no longer needed. This is cleaner for memory you've allocated, but be careful: if you free the memory yourself AND provide a dispose action, you'll double-free.
188+
The `dispose` parameter takes an action that NumSharp calls when the array is no longer needed. For large allocations, pair `GC.AddMemoryPressure()` with `GC.RemoveMemoryPressure()` so the GC knows about your memory. Be careful: if you free the memory yourself AND provide a dispose action, you'll double-free.
177189

178190
### From .NET Buffer Types
179191

src/NumSharp.Core/Backends/Unmanaged/UnmanagedMemoryBlock`1.cs

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public UnmanagedMemoryBlock(long count)
2929
{
3030
var bytes = BytesCount = count * InfoOf<T>.Size;
3131
var ptr = (IntPtr)NativeMemory.Alloc((nuint)bytes);
32-
_disposer = new Disposer(ptr);
32+
_disposer = new Disposer(ptr, bytes);
3333
Address = (T*)ptr;
3434
Count = count;
3535
}
@@ -52,16 +52,19 @@ public UnmanagedMemoryBlock(T* ptr, long count)
5252
/// <summary>
5353
/// Construct with externally allocated memory and a custom <paramref name="dispose"/> function.
5454
/// </summary>
55-
/// <param name="start"></param>
55+
/// <param name="start">Pointer to externally allocated unmanaged memory.</param>
5656
/// <param name="count">The length in objects of <typeparamref name="T"/> and not in bytes.</param>
57-
/// <param name="dispose"></param>
58-
/// <remarks>Does claim ownership.</remarks>
57+
/// <param name="dispose">Cleanup action called when memory is released.</param>
58+
/// <remarks>
59+
/// Claims ownership of the memory. Caller is responsible for GC.AddMemoryPressure
60+
/// if the memory is unmanaged and large enough to warrant it.
61+
/// </remarks>
5962
[MethodImpl(OptimizeAndInline)]
6063
public UnmanagedMemoryBlock(T* start, long count, Action dispose)
6164
{
6265
Count = count;
6366
BytesCount = InfoOf<T>.Size * count;
64-
_disposer = new Disposer(dispose);
67+
_disposer = new Disposer(dispose); // Caller tracks pressure for their allocation
6568
Address = start;
6669
}
6770

@@ -984,16 +987,24 @@ private enum AllocationType
984987
private readonly IntPtr Address;
985988
private readonly GCHandle _gcHandle;
986989
private readonly Action _dispose;
990+
private readonly long _bytesCount;
987991

988992

989993
/// <summary>
990994
/// Construct a AllocationType.Native (NativeMemory.Alloc)
991995
/// </summary>
992-
/// <param name="address"></param>
993-
public Disposer(IntPtr address)
996+
/// <param name="address">The address of the allocated memory.</param>
997+
/// <param name="bytesCount">The size in bytes of the allocation (for GC memory pressure tracking).</param>
998+
public Disposer(IntPtr address, long bytesCount)
994999
{
9951000
Address = address;
1001+
_bytesCount = bytesCount;
9961002
_type = AllocationType.Native;
1003+
1004+
// Inform the GC about unmanaged memory allocation so it can
1005+
// schedule collections appropriately (fixes GitHub issue #501)
1006+
if (bytesCount > 0)
1007+
GC.AddMemoryPressure(bytesCount);
9971008
}
9981009

9991010
/// <summary>
@@ -1009,11 +1020,21 @@ public Disposer(GCHandle gcHandle)
10091020
/// <summary>
10101021
/// Construct a AllocationType.External
10111022
/// </summary>
1012-
/// <param name="dispose"></param>
1013-
public Disposer(Action dispose)
1023+
/// <param name="dispose">The cleanup action to invoke on disposal.</param>
1024+
/// <param name="bytesCount">
1025+
/// Optional: Size in bytes for GC memory pressure tracking.
1026+
/// Pass 0 for managed memory (GCHandle) or when caller manages pressure.
1027+
/// Pass actual bytes for unmanaged memory to inform GC.
1028+
/// </param>
1029+
public Disposer(Action dispose, long bytesCount = 0)
10141030
{
10151031
_dispose = dispose;
1032+
_bytesCount = bytesCount;
10161033
_type = AllocationType.External;
1034+
1035+
// Track memory pressure for external unmanaged memory
1036+
if (bytesCount > 0)
1037+
GC.AddMemoryPressure(bytesCount);
10171038
}
10181039

10191040
/// <summary>
@@ -1036,11 +1057,17 @@ private void ReleaseUnmanagedResources()
10361057
{
10371058
case AllocationType.Native:
10381059
NativeMemory.Free((void*)Address);
1060+
// Remove GC memory pressure that was added during allocation
1061+
if (_bytesCount > 0)
1062+
GC.RemoveMemoryPressure(_bytesCount);
10391063
return;
10401064
case AllocationType.Wrap:
10411065
return;
10421066
case AllocationType.External:
10431067
_dispose();
1068+
// Remove GC memory pressure if it was added for external unmanaged memory
1069+
if (_bytesCount > 0)
1070+
GC.RemoveMemoryPressure(_bytesCount);
10441071
return;
10451072
case AllocationType.GCHandle:
10461073
_gcHandle.Free();

0 commit comments

Comments
 (0)