From 0df7ccaa61e0d1aefbe06e938506984fe299884c Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sat, 8 Aug 2026 22:37:39 +1000 Subject: [PATCH] Make MemoryAllocator limits configurable and extensible The allocation limits on MemoryAllocator were internal with no public way to raise the 1 GB single-buffer default, and ApplyOptions was not callable from external subclasses, which blocked custom allocators. - Expose MemoryGroupAllocationLimitBytes, SingleBufferAllocationLimitBytes, and AccumulativeAllocationLimitBytes with validated protected setters. - Make ApplyOptions protected so derived allocators can accept options. - Add MemoryAllocatorOptions.SingleBufferAllocationLimitMegabytes to raise the contiguous buffer cap up to 2047 MB without a custom allocator. - Add public-api tests that implement a complete external allocator. - Add behavior tests for the new option and its validation. --- .../Memory/Allocators/MemoryAllocator.cs | 69 +++++- .../Allocators/MemoryAllocatorOptions.cs | 36 +++ .../MemoryAllocatorExtensibilityTests.cs | 210 ++++++++++++++++++ ...niformUnmanagedPoolMemoryAllocatorTests.cs | 36 +++ 4 files changed, 341 insertions(+), 10 deletions(-) create mode 100644 tests/ImageSharp.PublicApi.Tests/MemoryAllocatorExtensibilityTests.cs diff --git a/src/ImageSharp/Memory/Allocators/MemoryAllocator.cs b/src/ImageSharp/Memory/Allocators/MemoryAllocator.cs index 1ca0df1c9..591c3b9dd 100644 --- a/src/ImageSharp/Memory/Allocators/MemoryAllocator.cs +++ b/src/ImageSharp/Memory/Allocators/MemoryAllocator.cs @@ -13,6 +13,9 @@ public abstract class MemoryAllocator { private const int OneGigabyte = 1 << 30; private long accumulativeAllocatedBytes; + private long memoryGroupAllocationLimitBytes = Environment.Is64BitProcess ? 4L * OneGigabyte : OneGigabyte; + private long accumulativeAllocationLimitBytes = long.MaxValue; + private int singleBufferAllocationLimitBytes = OneGigabyte; /// /// Gets the default platform-specific global instance that @@ -25,16 +28,26 @@ public abstract class MemoryAllocator public static MemoryAllocator Default { get; } = Create(); /// - /// Gets the maximum number of bytes that can be allocated by a memory group. + /// Gets or sets the maximum number of bytes that can be allocated by a memory group. + /// A memory group backs the pixel buffer of a single image, so this limits the total image size. /// /// - /// The allocation limit is determined by the process architecture: 4 GB for 64-bit processes and - /// 1 GB for 32-bit processes. + /// The default limit is determined by the process architecture: 4 GB for 64-bit processes and + /// 1 GB for 32-bit processes. The setter is available to derived allocators and requires a positive value. /// - internal long MemoryGroupAllocationLimitBytes { get; private protected set; } = Environment.Is64BitProcess ? 4L * OneGigabyte : OneGigabyte; + /// The value is not greater than zero. + public long MemoryGroupAllocationLimitBytes + { + get => this.memoryGroupAllocationLimitBytes; + protected set + { + Guard.MustBeGreaterThan(value, 0, nameof(this.MemoryGroupAllocationLimitBytes)); + this.memoryGroupAllocationLimitBytes = value; + } + } /// - /// Gets the maximum accumulative size, in bytes, of all active allocations made through this allocator instance. + /// Gets or sets the maximum accumulative size, in bytes, of all active allocations made through this allocator instance. /// /// /// Defaults to , effectively imposing no limit on the accumulative total. @@ -42,16 +55,40 @@ public abstract class MemoryAllocator /// outstanding allocations issued by this instance.
/// When the accumulative size of active allocations exceeds this limit, an will be thrown to /// prevent further allocations and signal that the limit has been breached. + /// The setter is available to derived allocators and requires a positive value. ///
- internal long AccumulativeAllocationLimitBytes { get; private protected set; } = long.MaxValue; + /// The value is not greater than zero. + public long AccumulativeAllocationLimitBytes + { + get => this.accumulativeAllocationLimitBytes; + protected set + { + Guard.MustBeGreaterThan(value, 0, nameof(this.AccumulativeAllocationLimitBytes)); + this.accumulativeAllocationLimitBytes = value; + } + } /// - /// Gets the maximum size, in bytes, that can be allocated for a single buffer. + /// Gets or sets the maximum size, in bytes, that can be allocated for a single contiguous buffer. + /// This limit applies to and to contiguous image buffers + /// requested through . /// /// /// The single buffer allocation limit is set to 1 GB by default. + /// A single contiguous buffer can never exceed bytes; larger images are + /// backed by discontiguous memory groups limited by . + /// The setter is available to derived allocators and requires a positive value. /// - internal int SingleBufferAllocationLimitBytes { get; private protected set; } = OneGigabyte; + /// The value is not greater than zero. + public int SingleBufferAllocationLimitBytes + { + get => this.singleBufferAllocationLimitBytes; + protected set + { + Guard.MustBeGreaterThan(value, 0, nameof(this.SingleBufferAllocationLimitBytes)); + this.singleBufferAllocationLimitBytes = value; + } + } /// /// Gets the length of the largest contiguous buffer that can be handled by this allocator instance in bytes. @@ -79,16 +116,28 @@ public abstract class MemoryAllocator /// /// Applies the supplied to this instance. + /// Derived allocators can call this from their constructors to accept user configuration. /// /// The options to apply. Properties left as are ignored. - private protected void ApplyOptions(MemoryAllocatorOptions options) + /// + /// The applied single buffer limit is capped to , + /// because a single contiguous buffer can never be larger than the total allocation limit. + /// + protected void ApplyOptions(MemoryAllocatorOptions options) { if (options.AllocationLimitMegabytes.HasValue) { this.MemoryGroupAllocationLimitBytes = options.AllocationLimitMegabytes.Value * 1024L * 1024L; - this.SingleBufferAllocationLimitBytes = (int)Math.Min(this.SingleBufferAllocationLimitBytes, this.MemoryGroupAllocationLimitBytes); } + if (options.SingleBufferAllocationLimitMegabytes.HasValue) + { + // The option setter caps the value at 2047 MB, so converting to bytes cannot overflow. + this.SingleBufferAllocationLimitBytes = (int)(options.SingleBufferAllocationLimitMegabytes.Value * 1024L * 1024L); + } + + this.SingleBufferAllocationLimitBytes = (int)Math.Min(this.SingleBufferAllocationLimitBytes, this.MemoryGroupAllocationLimitBytes); + if (options.AccumulativeAllocationLimitMegabytes.HasValue) { this.AccumulativeAllocationLimitBytes = options.AccumulativeAllocationLimitMegabytes.Value * 1024L * 1024L; diff --git a/src/ImageSharp/Memory/Allocators/MemoryAllocatorOptions.cs b/src/ImageSharp/Memory/Allocators/MemoryAllocatorOptions.cs index 41a5ea5ee..0578fd64d 100644 --- a/src/ImageSharp/Memory/Allocators/MemoryAllocatorOptions.cs +++ b/src/ImageSharp/Memory/Allocators/MemoryAllocatorOptions.cs @@ -8,9 +8,15 @@ namespace SixLabors.ImageSharp.Memory; /// public struct MemoryAllocatorOptions { + /// + /// The largest single-buffer limit, in Megabytes, that still fits bytes. + /// + private const int MaxSingleBufferAllocationLimitMegabytes = 2047; + private int? maximumPoolSizeMegabytes; private int? allocationLimitMegabytes; private int? accumulativeAllocationLimitMegabytes; + private int? singleBufferAllocationLimitMegabytes; /// /// Gets or sets a value defining the maximum size of the 's internal memory pool @@ -55,6 +61,36 @@ public struct MemoryAllocatorOptions } } + /// + /// Gets or sets a value defining the maximum size, in Megabytes, of a single contiguous buffer + /// that the created can allocate. + /// means the default of 1 GB. + /// + /// + /// This limit applies to contiguous buffers, including image buffers requested through + /// . A single contiguous buffer can never exceed + /// bytes, so the largest accepted value is 2047. The applied limit is also + /// capped to because a single buffer can never be larger + /// than the total allocation limit. + /// + public int? SingleBufferAllocationLimitMegabytes + { + readonly get => this.singleBufferAllocationLimitMegabytes; + set + { + if (value.HasValue) + { + Guard.MustBeGreaterThan(value.Value, 0, nameof(this.SingleBufferAllocationLimitMegabytes)); + Guard.MustBeLessThanOrEqualTo( + value.Value, + MaxSingleBufferAllocationLimitMegabytes, + nameof(this.SingleBufferAllocationLimitMegabytes)); + } + + this.singleBufferAllocationLimitMegabytes = value; + } + } + /// /// Gets or sets a value defining the maximum accumulative size, in Megabytes, of all active allocations made /// through the created instance. diff --git a/tests/ImageSharp.PublicApi.Tests/MemoryAllocatorExtensibilityTests.cs b/tests/ImageSharp.PublicApi.Tests/MemoryAllocatorExtensibilityTests.cs new file mode 100644 index 000000000..766e07341 --- /dev/null +++ b/tests/ImageSharp.PublicApi.Tests/MemoryAllocatorExtensibilityTests.cs @@ -0,0 +1,210 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers; +using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.PublicApi.Tests; + +/// +/// Verifies that a fully functional can be implemented outside the ImageSharp assembly. +/// +public class MemoryAllocatorExtensibilityTests +{ + private const int OneMegabyte = 1 << 20; + + /// + /// Verifies that an external allocator can apply from its constructor + /// and that the applied limits are readable through the public properties. + /// + [Fact] + public void ExternalAllocatorCanApplyOptionsFromConstructor() + { + ExternalArrayMemoryAllocator allocator = new(new MemoryAllocatorOptions + { + AllocationLimitMegabytes = 8, + SingleBufferAllocationLimitMegabytes = 2, + AccumulativeAllocationLimitMegabytes = 8 + }); + + Assert.Equal(8L * OneMegabyte, allocator.MemoryGroupAllocationLimitBytes); + Assert.Equal(2 * OneMegabyte, allocator.SingleBufferAllocationLimitBytes); + Assert.Equal(8L * OneMegabyte, allocator.AccumulativeAllocationLimitBytes); + } + + /// + /// Verifies that the applied single buffer limit is capped to the group allocation limit. + /// + [Fact] + public void ExternalAllocatorAppliedSingleBufferLimitIsCappedToGroupLimit() + { + ExternalArrayMemoryAllocator allocator = new(new MemoryAllocatorOptions + { + AllocationLimitMegabytes = 4, + SingleBufferAllocationLimitMegabytes = 8 + }); + + Assert.Equal(4 * OneMegabyte, allocator.SingleBufferAllocationLimitBytes); + } + + /// + /// Verifies that an external allocator can set the limit properties directly + /// and that the base class validates allocations against the configured values. + /// + [Fact] + public void ExternalAllocatorCanSetSingleBufferLimit() + { + ExternalArrayMemoryAllocator allocator = new(); + allocator.SetLimits( + memoryGroupAllocationLimitBytes: 4096, + singleBufferAllocationLimitBytes: 1024, + accumulativeAllocationLimitBytes: 4096); + + allocator.Allocate(1024).Dispose(); + Assert.Throws(() => allocator.Allocate(1025)); + } + + /// + /// Verifies that owners produced by an external allocator participate in accumulative allocation tracking. + /// + [Fact] + public void ExternalAllocatorTracksAccumulativeAllocations() + { + ExternalArrayMemoryAllocator allocator = new(); + allocator.SetLimits( + memoryGroupAllocationLimitBytes: 4096, + singleBufferAllocationLimitBytes: 4096, + accumulativeAllocationLimitBytes: 4096); + + IMemoryOwner owner = allocator.Allocate(4096); + + // The full accumulative budget is reserved while the owner is live. + Assert.Throws(() => allocator.Allocate(1)); + + // Disposing the owner releases the reservation. + owner.Dispose(); + allocator.Allocate(4096).Dispose(); + } + + /// + /// Verifies that an external allocator can back image creation through , + /// for both discontiguous and contiguous buffer preferences, and that disposal reaches the external owners. + /// + /// The contiguous buffer preference to apply. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ExternalAllocatorBacksImageCreation(bool preferContiguousImageBuffers) + { + ExternalArrayMemoryAllocator allocator = new(); + Configuration configuration = Configuration.Default.Clone(); + configuration.MemoryAllocator = allocator; + configuration.PreferContiguousImageBuffers = preferContiguousImageBuffers; + + using (Image image = new(configuration, 16, 16, Color.Red.ToPixel())) + { + Assert.True(allocator.CreatedOwners > 0); + Assert.Equal(Color.Red.ToPixel(), image[8, 8]); + } + + Assert.Equal(0, allocator.LiveOwners); + } + + /// + /// A implemented with only the public API surface, backed by managed arrays. + /// + private sealed class ExternalArrayMemoryAllocator : MemoryAllocator + { + /// + /// Initializes a new instance of the class with default limits. + /// + public ExternalArrayMemoryAllocator() + { + } + + /// + /// Initializes a new instance of the class with custom limits. + /// + /// The to apply. + public ExternalArrayMemoryAllocator(MemoryAllocatorOptions options) => this.ApplyOptions(options); + + /// + /// Gets the total number of owners created by this allocator. + /// + public int CreatedOwners { get; private set; } + + /// + /// Gets the number of owners created by this allocator that are not yet disposed. + /// + public int LiveOwners { get; private set; } + + /// + /// Sets the protected limit properties directly, as a derived allocator can. + /// + /// The group allocation limit, in bytes. + /// The single buffer allocation limit, in bytes. + /// The accumulative allocation limit, in bytes. + public void SetLimits( + long memoryGroupAllocationLimitBytes, + int singleBufferAllocationLimitBytes, + long accumulativeAllocationLimitBytes) + { + this.MemoryGroupAllocationLimitBytes = memoryGroupAllocationLimitBytes; + this.SingleBufferAllocationLimitBytes = singleBufferAllocationLimitBytes; + this.AccumulativeAllocationLimitBytes = accumulativeAllocationLimitBytes; + } + + /// + protected override int GetBufferCapacityInBytes() => int.MaxValue; + + /// + protected override AllocationTrackedMemoryManager AllocateCore(int length, AllocationOptions options = AllocationOptions.None) + { + this.CreatedOwners++; + this.LiveOwners++; + return new ExternalArrayMemoryManager(new T[length], this); + } + + /// + /// Records the disposal of an owner created by this allocator. + /// + internal void OnOwnerDisposed() => this.LiveOwners--; + } + + /// + /// An implemented with only the public API surface. + /// + /// The element type. + private sealed class ExternalArrayMemoryManager : AllocationTrackedMemoryManager + where T : struct + { + private readonly T[] array; + private readonly ExternalArrayMemoryAllocator allocator; + + /// + /// Initializes a new instance of the class. + /// + /// The array that backs this owner. + /// The allocator that created this owner. + public ExternalArrayMemoryManager(T[] array, ExternalArrayMemoryAllocator allocator) + { + this.array = array; + this.allocator = allocator; + } + + /// + public override Span GetSpan() => this.array; + + /// + public override MemoryHandle Pin(int elementIndex = 0) => throw new NotSupportedException("Pinning is not required by these tests."); + + /// + public override void Unpin() + { + } + + /// + protected override void DisposeCore(bool disposing) => this.allocator.OnOwnerDisposed(); + } +} diff --git a/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedPoolMemoryAllocatorTests.cs b/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedPoolMemoryAllocatorTests.cs index b4593d4d0..b02956666 100644 --- a/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedPoolMemoryAllocatorTests.cs +++ b/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedPoolMemoryAllocatorTests.cs @@ -483,6 +483,42 @@ public class UniformUnmanagedPoolMemoryAllocatorTests Assert.Throws(() => allocator.AllocateGroup(5 * oneMb, 1024)); } + [Fact] + public void Allocate_OverSingleBufferLimit_ThrowsInvalidMemoryOperationException() + { + MemoryAllocator allocator = MemoryAllocator.Create(new MemoryAllocatorOptions + { + SingleBufferAllocationLimitMegabytes = 2 + }); + const int oneMb = 1 << 20; + allocator.Allocate(2 * oneMb).Dispose(); // Should work + Assert.Throws(() => allocator.Allocate(3 * oneMb)); + + // The group limit is unchanged, so the same size still allocates as a discontiguous group. + allocator.AllocateGroup(3 * oneMb, 1024).Dispose(); + } + + [ConditionalFact(typeof(Environment), nameof(Environment.Is64BitProcess))] + public void MemoryAllocator_Create_RaisesSingleBufferLimit() + { + MemoryAllocator allocator = MemoryAllocator.Create(new MemoryAllocatorOptions + { + SingleBufferAllocationLimitMegabytes = 2047 + }); + + Assert.Equal(2047L * 1024 * 1024, (long)allocator.SingleBufferAllocationLimitBytes); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(2048)] + public void MemoryAllocatorOptions_InvalidSingleBufferLimit_Throws(int value) + { + MemoryAllocatorOptions options = default; + Assert.Throws(() => options.SingleBufferAllocationLimitMegabytes = value); + } + [Fact] public void Allocate_AccumulativeLimit_ReleasesOnOwnerDispose() {