Skip to content

Commit c5624b5

Browse files
Merge pull request #3056 from SixLabors/js/accumulative-memory-limit
Add accumulative allocation tracking to allocators
2 parents b3f3793 + ceb656d commit c5624b5

12 files changed

Lines changed: 464 additions & 45 deletions

src/ImageSharp/Memory/Allocators/MemoryAllocator.cs

Lines changed: 176 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ namespace SixLabors.ImageSharp.Memory;
1212
public abstract class MemoryAllocator
1313
{
1414
private const int OneGigabyte = 1 << 30;
15+
private long accumulativeAllocatedBytes;
16+
private int trackingSuppressionCount;
1517

1618
/// <summary>
1719
/// Gets the default platform-specific global <see cref="MemoryAllocator"/> instance that
@@ -23,9 +25,44 @@ public abstract class MemoryAllocator
2325
/// </summary>
2426
public static MemoryAllocator Default { get; } = Create();
2527

26-
internal long MemoryGroupAllocationLimitBytes { get; private set; } = Environment.Is64BitProcess ? 4L * OneGigabyte : OneGigabyte;
28+
/// <summary>
29+
/// Gets the maximum number of bytes that can be allocated by a memory group.
30+
/// </summary>
31+
/// <remarks>
32+
/// The allocation limit is determined by the process architecture: 4 GB for 64-bit processes and
33+
/// 1 GB for 32-bit processes.
34+
/// </remarks>
35+
internal long MemoryGroupAllocationLimitBytes { get; private protected set; } = Environment.Is64BitProcess ? 4L * OneGigabyte : OneGigabyte;
36+
37+
/// <summary>
38+
/// Gets the maximum allowed total allocation size, in bytes, for the current process.
39+
/// </summary>
40+
/// <remarks>
41+
/// Defaults to <see cref="long.MaxValue"/>, effectively imposing no limit on total allocations.
42+
/// This property can be set to enforce a cap on total memory usage across all allocations made through this allocator instance, providing
43+
/// a safeguard against excessive memory consumption.<br/>
44+
/// When the cumulative size of active allocations exceeds this limit, an <see cref="InvalidMemoryOperationException"/> will be thrown to
45+
/// prevent further allocations and signal that the limit has been breached.
46+
/// </remarks>
47+
internal long AccumulativeAllocationLimitBytes { get; private protected set; } = long.MaxValue;
2748

28-
internal int SingleBufferAllocationLimitBytes { get; private set; } = OneGigabyte;
49+
/// <summary>
50+
/// Gets the maximum size, in bytes, that can be allocated for a single buffer.
51+
/// </summary>
52+
/// <remarks>
53+
/// The single buffer allocation limit is set to 1 GB by default.
54+
/// </remarks>
55+
internal int SingleBufferAllocationLimitBytes { get; private protected set; } = OneGigabyte;
56+
57+
/// <summary>
58+
/// Gets a value indicating whether change tracking is currently suppressed for this instance.
59+
/// </summary>
60+
/// <remarks>
61+
/// When change tracking is suppressed, modifications to the object will not be recorded or
62+
/// trigger change notifications. This property is used internally to temporarily disable tracking during
63+
/// batch updates or initialization.
64+
/// </remarks>
65+
private bool IsTrackingSuppressed => Volatile.Read(ref this.trackingSuppressionCount) > 0;
2966

3067
/// <summary>
3168
/// Gets the length of the largest contiguous buffer that can be handled by this allocator instance in bytes.
@@ -53,6 +90,11 @@ public static MemoryAllocator Create(MemoryAllocatorOptions options)
5390
allocator.SingleBufferAllocationLimitBytes = (int)Math.Min(allocator.SingleBufferAllocationLimitBytes, allocator.MemoryGroupAllocationLimitBytes);
5491
}
5592

93+
if (options.AccumulativeAllocationLimitMegabytes.HasValue)
94+
{
95+
allocator.AccumulativeAllocationLimitBytes = options.AccumulativeAllocationLimitMegabytes.Value * 1024L * 1024L;
96+
}
97+
5698
return allocator;
5799
}
58100

@@ -72,6 +114,10 @@ public abstract IMemoryOwner<T> Allocate<T>(int length, AllocationOptions option
72114
/// Releases all retained resources not being in use.
73115
/// Eg: by resetting array pools and letting GC to free the arrays.
74116
/// </summary>
117+
/// <remarks>
118+
/// This does not dispose active allocations; callers are responsible for disposing all
119+
/// <see cref="IMemoryOwner{T}"/> instances to release memory.
120+
/// </remarks>
75121
public virtual void ReleaseRetainedResources()
76122
{
77123
}
@@ -102,11 +148,137 @@ internal MemoryGroup<T> AllocateGroup<T>(
102148
InvalidMemoryOperationException.ThrowAllocationOverLimitException(totalLengthInBytes, this.MemoryGroupAllocationLimitBytes);
103149
}
104150

105-
// Cast to long is safe because we already checked that the total length is within the limit.
106-
return this.AllocateGroupCore<T>(totalLength, (long)totalLengthInBytes, bufferAlignment, options);
151+
long totalLengthInBytesLong = (long)totalLengthInBytes;
152+
this.ReserveAllocation(totalLengthInBytesLong);
153+
154+
using (this.SuppressTracking())
155+
{
156+
try
157+
{
158+
MemoryGroup<T> group = this.AllocateGroupCore<T>(totalLength, totalLengthInBytesLong, bufferAlignment, options);
159+
group.SetAllocationTracking(this, totalLengthInBytesLong);
160+
return group;
161+
}
162+
catch
163+
{
164+
this.ReleaseAccumulatedBytes(totalLengthInBytesLong);
165+
throw;
166+
}
167+
}
107168
}
108169

109170
internal virtual MemoryGroup<T> AllocateGroupCore<T>(long totalLengthInElements, long totalLengthInBytes, int bufferAlignment, AllocationOptions options)
110171
where T : struct
111172
=> MemoryGroup<T>.Allocate(this, totalLengthInElements, bufferAlignment, options);
173+
174+
/// <summary>
175+
/// Tracks the allocation of an <see cref="IMemoryOwner{T}" /> instance after reserving bytes.
176+
/// </summary>
177+
/// <typeparam name="T">Type of the data stored in the buffer.</typeparam>
178+
/// <param name="owner">The allocation to track.</param>
179+
/// <param name="lengthInBytes">The allocation size in bytes.</param>
180+
/// <returns>The tracked allocation.</returns>
181+
protected IMemoryOwner<T> TrackAllocation<T>(IMemoryOwner<T> owner, ulong lengthInBytes)
182+
where T : struct
183+
{
184+
if (this.IsTrackingSuppressed || lengthInBytes == 0)
185+
{
186+
return owner;
187+
}
188+
189+
return new TrackingMemoryOwner<T>(owner, this, (long)lengthInBytes);
190+
}
191+
192+
/// <summary>
193+
/// Reserves accumulative allocation bytes before creating the underlying buffer.
194+
/// </summary>
195+
/// <param name="lengthInBytes">The number of bytes to reserve.</param>
196+
protected void ReserveAllocation(long lengthInBytes)
197+
{
198+
if (this.IsTrackingSuppressed || lengthInBytes <= 0)
199+
{
200+
return;
201+
}
202+
203+
long total = Interlocked.Add(ref this.accumulativeAllocatedBytes, lengthInBytes);
204+
if (total > this.AccumulativeAllocationLimitBytes)
205+
{
206+
_ = Interlocked.Add(ref this.accumulativeAllocatedBytes, -lengthInBytes);
207+
InvalidMemoryOperationException.ThrowAllocationOverLimitException((ulong)lengthInBytes, this.AccumulativeAllocationLimitBytes);
208+
}
209+
}
210+
211+
/// <summary>
212+
/// Releases accumulative allocation bytes previously tracked by this allocator.
213+
/// </summary>
214+
/// <param name="lengthInBytes">The number of bytes to release.</param>
215+
internal void ReleaseAccumulatedBytes(long lengthInBytes)
216+
{
217+
if (lengthInBytes <= 0)
218+
{
219+
return;
220+
}
221+
222+
_ = Interlocked.Add(ref this.accumulativeAllocatedBytes, -lengthInBytes);
223+
}
224+
225+
/// <summary>
226+
/// Suppresses accumulative allocation tracking for the lifetime of the returned scope.
227+
/// </summary>
228+
/// <returns>An <see cref="IDisposable"/> that restores tracking when disposed.</returns>
229+
internal IDisposable SuppressTracking() => new TrackingSuppressionScope(this);
230+
231+
/// <summary>
232+
/// Temporarily suppresses accumulative allocation tracking within a scope.
233+
/// </summary>
234+
private sealed class TrackingSuppressionScope : IDisposable
235+
{
236+
private MemoryAllocator? allocator;
237+
238+
public TrackingSuppressionScope(MemoryAllocator allocator)
239+
{
240+
this.allocator = allocator;
241+
_ = Interlocked.Increment(ref allocator.trackingSuppressionCount);
242+
}
243+
244+
public void Dispose()
245+
{
246+
if (this.allocator != null)
247+
{
248+
_ = Interlocked.Decrement(ref this.allocator.trackingSuppressionCount);
249+
this.allocator = null;
250+
}
251+
}
252+
}
253+
254+
/// <summary>
255+
/// Wraps an <see cref="IMemoryOwner{T}"/> to release accumulative tracking on dispose.
256+
/// </summary>
257+
private sealed class TrackingMemoryOwner<T> : IMemoryOwner<T>
258+
where T : struct
259+
{
260+
private IMemoryOwner<T>? owner;
261+
private readonly MemoryAllocator allocator;
262+
private readonly long lengthInBytes;
263+
264+
public TrackingMemoryOwner(IMemoryOwner<T> owner, MemoryAllocator allocator, long lengthInBytes)
265+
{
266+
this.owner = owner;
267+
this.allocator = allocator;
268+
this.lengthInBytes = lengthInBytes;
269+
}
270+
271+
public Memory<T> Memory => this.owner?.Memory ?? Memory<T>.Empty;
272+
273+
public void Dispose()
274+
{
275+
// Ensure only one caller disposes the inner owner and releases the reservation.
276+
IMemoryOwner<T>? inner = Interlocked.Exchange(ref this.owner, null);
277+
if (inner != null)
278+
{
279+
inner.Dispose();
280+
this.allocator.ReleaseAccumulatedBytes(this.lengthInBytes);
281+
}
282+
}
283+
}
112284
}

src/ImageSharp/Memory/Allocators/MemoryAllocatorOptions.cs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ public struct MemoryAllocatorOptions
1010
{
1111
private int? maximumPoolSizeMegabytes;
1212
private int? allocationLimitMegabytes;
13+
private int? accumulativeAllocationLimitMegabytes;
1314

1415
/// <summary>
1516
/// Gets or sets a value defining the maximum size of the <see cref="MemoryAllocator"/>'s internal memory pool
1617
/// in Megabytes. <see langword="null"/> means platform default.
1718
/// </summary>
1819
public int? MaximumPoolSizeMegabytes
1920
{
20-
get => this.maximumPoolSizeMegabytes;
21+
readonly get => this.maximumPoolSizeMegabytes;
2122
set
2223
{
2324
if (value.HasValue)
@@ -35,7 +36,7 @@ public int? MaximumPoolSizeMegabytes
3536
/// </summary>
3637
public int? AllocationLimitMegabytes
3738
{
38-
get => this.allocationLimitMegabytes;
39+
readonly get => this.allocationLimitMegabytes;
3940
set
4041
{
4142
if (value.HasValue)
@@ -46,4 +47,29 @@ public int? AllocationLimitMegabytes
4647
this.allocationLimitMegabytes = value;
4748
}
4849
}
50+
51+
/// <summary>
52+
/// Gets or sets a value defining the maximum total size that can be allocated by the allocator in Megabytes.
53+
/// <see langword="null"/> means platform default: 2GB on 32-bit processes, 8GB on 64-bit processes.
54+
/// </summary>
55+
public int? AccumulativeAllocationLimitMegabytes
56+
{
57+
readonly get => this.accumulativeAllocationLimitMegabytes;
58+
set
59+
{
60+
if (value.HasValue)
61+
{
62+
Guard.MustBeGreaterThan(value.Value, 0, nameof(this.AccumulativeAllocationLimitMegabytes));
63+
if (this.AllocationLimitMegabytes.HasValue)
64+
{
65+
Guard.MustBeGreaterThanOrEqualTo(
66+
value.Value,
67+
this.AllocationLimitMegabytes.Value,
68+
nameof(this.AccumulativeAllocationLimitMegabytes));
69+
}
70+
}
71+
72+
this.accumulativeAllocationLimitMegabytes = value;
73+
}
74+
}
4975
}

src/ImageSharp/Memory/Allocators/SimpleGcMemoryAllocator.cs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,32 @@ namespace SixLabors.ImageSharp.Memory;
1212
/// </summary>
1313
public sealed class SimpleGcMemoryAllocator : MemoryAllocator
1414
{
15+
/// <summary>
16+
/// Initializes a new instance of the <see cref="SimpleGcMemoryAllocator"/> class with default limits.
17+
/// </summary>
18+
public SimpleGcMemoryAllocator()
19+
: this(default)
20+
{
21+
}
22+
23+
/// <summary>
24+
/// Initializes a new instance of the <see cref="SimpleGcMemoryAllocator"/> class with custom limits.
25+
/// </summary>
26+
/// <param name="options">The <see cref="MemoryAllocatorOptions"/> to apply.</param>
27+
public SimpleGcMemoryAllocator(MemoryAllocatorOptions options)
28+
{
29+
if (options.AllocationLimitMegabytes.HasValue)
30+
{
31+
this.MemoryGroupAllocationLimitBytes = options.AllocationLimitMegabytes.Value * 1024L * 1024L;
32+
this.SingleBufferAllocationLimitBytes = (int)Math.Min(this.SingleBufferAllocationLimitBytes, this.MemoryGroupAllocationLimitBytes);
33+
}
34+
35+
if (options.AccumulativeAllocationLimitMegabytes.HasValue)
36+
{
37+
this.AccumulativeAllocationLimitBytes = options.AccumulativeAllocationLimitMegabytes.Value * 1024L * 1024L;
38+
}
39+
}
40+
1541
/// <inheritdoc />
1642
protected internal override int GetBufferCapacityInBytes() => int.MaxValue;
1743

@@ -29,6 +55,18 @@ public override IMemoryOwner<T> Allocate<T>(int length, AllocationOptions option
2955
InvalidMemoryOperationException.ThrowAllocationOverLimitException(lengthInBytes, this.SingleBufferAllocationLimitBytes);
3056
}
3157

32-
return new BasicArrayBuffer<T>(new T[length]);
58+
long lengthInBytesLong = (long)lengthInBytes;
59+
this.ReserveAllocation(lengthInBytesLong);
60+
61+
try
62+
{
63+
IMemoryOwner<T> buffer = new BasicArrayBuffer<T>(new T[length]);
64+
return this.TrackAllocation(buffer, lengthInBytes);
65+
}
66+
catch
67+
{
68+
this.ReleaseAccumulatedBytes(lengthInBytesLong);
69+
throw;
70+
}
3371
}
3472
}

0 commit comments

Comments
 (0)