Browse Source

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.
pull/3165/head
James Jackson-South 6 days ago
parent
commit
0df7ccaa61
  1. 69
      src/ImageSharp/Memory/Allocators/MemoryAllocator.cs
  2. 36
      src/ImageSharp/Memory/Allocators/MemoryAllocatorOptions.cs
  3. 210
      tests/ImageSharp.PublicApi.Tests/MemoryAllocatorExtensibilityTests.cs
  4. 36
      tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedPoolMemoryAllocatorTests.cs

69
src/ImageSharp/Memory/Allocators/MemoryAllocator.cs

@ -13,6 +13,9 @@ public abstract class MemoryAllocator
{ {
private const int OneGigabyte = 1 << 30; private const int OneGigabyte = 1 << 30;
private long accumulativeAllocatedBytes; private long accumulativeAllocatedBytes;
private long memoryGroupAllocationLimitBytes = Environment.Is64BitProcess ? 4L * OneGigabyte : OneGigabyte;
private long accumulativeAllocationLimitBytes = long.MaxValue;
private int singleBufferAllocationLimitBytes = OneGigabyte;
/// <summary> /// <summary>
/// Gets the default platform-specific global <see cref="MemoryAllocator"/> instance that /// Gets the default platform-specific global <see cref="MemoryAllocator"/> instance that
@ -25,16 +28,26 @@ public abstract class MemoryAllocator
public static MemoryAllocator Default { get; } = Create(); public static MemoryAllocator Default { get; } = Create();
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The allocation limit is determined by the process architecture: 4 GB for 64-bit processes and /// The default limit is determined by the process architecture: 4 GB for 64-bit processes and
/// 1 GB for 32-bit processes. /// 1 GB for 32-bit processes. The setter is available to derived allocators and requires a positive value.
/// </remarks> /// </remarks>
internal long MemoryGroupAllocationLimitBytes { get; private protected set; } = Environment.Is64BitProcess ? 4L * OneGigabyte : OneGigabyte; /// <exception cref="ArgumentOutOfRangeException">The value is not greater than zero.</exception>
public long MemoryGroupAllocationLimitBytes
{
get => this.memoryGroupAllocationLimitBytes;
protected set
{
Guard.MustBeGreaterThan(value, 0, nameof(this.MemoryGroupAllocationLimitBytes));
this.memoryGroupAllocationLimitBytes = value;
}
}
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Defaults to <see cref="long.MaxValue"/>, effectively imposing no limit on the accumulative total. /// Defaults to <see cref="long.MaxValue"/>, effectively imposing no limit on the accumulative total.
@ -42,16 +55,40 @@ public abstract class MemoryAllocator
/// outstanding allocations issued by this instance.<br/> /// outstanding allocations issued by this instance.<br/>
/// When the accumulative size of active allocations exceeds this limit, an <see cref="InvalidMemoryOperationException"/> will be thrown to /// When the accumulative size of active allocations exceeds this limit, an <see cref="InvalidMemoryOperationException"/> will be thrown to
/// prevent further allocations and signal that the limit has been breached. /// prevent further allocations and signal that the limit has been breached.
/// The setter is available to derived allocators and requires a positive value.
/// </remarks> /// </remarks>
internal long AccumulativeAllocationLimitBytes { get; private protected set; } = long.MaxValue; /// <exception cref="ArgumentOutOfRangeException">The value is not greater than zero.</exception>
public long AccumulativeAllocationLimitBytes
{
get => this.accumulativeAllocationLimitBytes;
protected set
{
Guard.MustBeGreaterThan(value, 0, nameof(this.AccumulativeAllocationLimitBytes));
this.accumulativeAllocationLimitBytes = value;
}
}
/// <summary> /// <summary>
/// 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 <see cref="Allocate{T}(int, AllocationOptions)"/> and to contiguous image buffers
/// requested through <see cref="Configuration.PreferContiguousImageBuffers"/>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The single buffer allocation limit is set to 1 GB by default. /// The single buffer allocation limit is set to 1 GB by default.
/// A single contiguous buffer can never exceed <see cref="int.MaxValue"/> bytes; larger images are
/// backed by discontiguous memory groups limited by <see cref="MemoryGroupAllocationLimitBytes"/>.
/// The setter is available to derived allocators and requires a positive value.
/// </remarks> /// </remarks>
internal int SingleBufferAllocationLimitBytes { get; private protected set; } = OneGigabyte; /// <exception cref="ArgumentOutOfRangeException">The value is not greater than zero.</exception>
public int SingleBufferAllocationLimitBytes
{
get => this.singleBufferAllocationLimitBytes;
protected set
{
Guard.MustBeGreaterThan(value, 0, nameof(this.SingleBufferAllocationLimitBytes));
this.singleBufferAllocationLimitBytes = value;
}
}
/// <summary> /// <summary>
/// Gets the length of the largest contiguous buffer that can be handled by this allocator instance in bytes. /// 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
/// <summary> /// <summary>
/// Applies the supplied <see cref="MemoryAllocatorOptions"/> to this instance. /// Applies the supplied <see cref="MemoryAllocatorOptions"/> to this instance.
/// Derived allocators can call this from their constructors to accept user configuration.
/// </summary> /// </summary>
/// <param name="options">The options to apply. Properties left as <see langword="null"/> are ignored.</param> /// <param name="options">The options to apply. Properties left as <see langword="null"/> are ignored.</param>
private protected void ApplyOptions(MemoryAllocatorOptions options) /// <remarks>
/// The applied single buffer limit is capped to <see cref="MemoryGroupAllocationLimitBytes"/>,
/// because a single contiguous buffer can never be larger than the total allocation limit.
/// </remarks>
protected void ApplyOptions(MemoryAllocatorOptions options)
{ {
if (options.AllocationLimitMegabytes.HasValue) if (options.AllocationLimitMegabytes.HasValue)
{ {
this.MemoryGroupAllocationLimitBytes = options.AllocationLimitMegabytes.Value * 1024L * 1024L; 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) if (options.AccumulativeAllocationLimitMegabytes.HasValue)
{ {
this.AccumulativeAllocationLimitBytes = options.AccumulativeAllocationLimitMegabytes.Value * 1024L * 1024L; this.AccumulativeAllocationLimitBytes = options.AccumulativeAllocationLimitMegabytes.Value * 1024L * 1024L;

36
src/ImageSharp/Memory/Allocators/MemoryAllocatorOptions.cs

@ -8,9 +8,15 @@ namespace SixLabors.ImageSharp.Memory;
/// </summary> /// </summary>
public struct MemoryAllocatorOptions public struct MemoryAllocatorOptions
{ {
/// <summary>
/// The largest single-buffer limit, in Megabytes, that still fits <see cref="int.MaxValue"/> bytes.
/// </summary>
private const int MaxSingleBufferAllocationLimitMegabytes = 2047;
private int? maximumPoolSizeMegabytes; private int? maximumPoolSizeMegabytes;
private int? allocationLimitMegabytes; private int? allocationLimitMegabytes;
private int? accumulativeAllocationLimitMegabytes; private int? accumulativeAllocationLimitMegabytes;
private int? singleBufferAllocationLimitMegabytes;
/// <summary> /// <summary>
/// Gets or sets a value defining the maximum size of the <see cref="MemoryAllocator"/>'s internal memory pool /// Gets or sets a value defining the maximum size of the <see cref="MemoryAllocator"/>'s internal memory pool
@ -55,6 +61,36 @@ public struct MemoryAllocatorOptions
} }
} }
/// <summary>
/// Gets or sets a value defining the maximum size, in Megabytes, of a single contiguous buffer
/// that the created <see cref="MemoryAllocator"/> can allocate.
/// <see langword="null"/> means the default of 1 GB.
/// </summary>
/// <remarks>
/// This limit applies to contiguous buffers, including image buffers requested through
/// <see cref="Configuration.PreferContiguousImageBuffers"/>. A single contiguous buffer can never exceed
/// <see cref="int.MaxValue"/> bytes, so the largest accepted value is 2047. The applied limit is also
/// capped to <see cref="AllocationLimitMegabytes"/> because a single buffer can never be larger
/// than the total allocation limit.
/// </remarks>
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;
}
}
/// <summary> /// <summary>
/// Gets or sets a value defining the maximum accumulative size, in Megabytes, of all active allocations made /// Gets or sets a value defining the maximum accumulative size, in Megabytes, of all active allocations made
/// through the created <see cref="MemoryAllocator"/> instance. /// through the created <see cref="MemoryAllocator"/> instance.

210
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;
/// <summary>
/// Verifies that a fully functional <see cref="MemoryAllocator"/> can be implemented outside the ImageSharp assembly.
/// </summary>
public class MemoryAllocatorExtensibilityTests
{
private const int OneMegabyte = 1 << 20;
/// <summary>
/// Verifies that an external allocator can apply <see cref="MemoryAllocatorOptions"/> from its constructor
/// and that the applied limits are readable through the public properties.
/// </summary>
[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);
}
/// <summary>
/// Verifies that the applied single buffer limit is capped to the group allocation limit.
/// </summary>
[Fact]
public void ExternalAllocatorAppliedSingleBufferLimitIsCappedToGroupLimit()
{
ExternalArrayMemoryAllocator allocator = new(new MemoryAllocatorOptions
{
AllocationLimitMegabytes = 4,
SingleBufferAllocationLimitMegabytes = 8
});
Assert.Equal(4 * OneMegabyte, allocator.SingleBufferAllocationLimitBytes);
}
/// <summary>
/// Verifies that an external allocator can set the limit properties directly
/// and that the base class validates allocations against the configured values.
/// </summary>
[Fact]
public void ExternalAllocatorCanSetSingleBufferLimit()
{
ExternalArrayMemoryAllocator allocator = new();
allocator.SetLimits(
memoryGroupAllocationLimitBytes: 4096,
singleBufferAllocationLimitBytes: 1024,
accumulativeAllocationLimitBytes: 4096);
allocator.Allocate<byte>(1024).Dispose();
Assert.Throws<InvalidMemoryOperationException>(() => allocator.Allocate<byte>(1025));
}
/// <summary>
/// Verifies that owners produced by an external allocator participate in accumulative allocation tracking.
/// </summary>
[Fact]
public void ExternalAllocatorTracksAccumulativeAllocations()
{
ExternalArrayMemoryAllocator allocator = new();
allocator.SetLimits(
memoryGroupAllocationLimitBytes: 4096,
singleBufferAllocationLimitBytes: 4096,
accumulativeAllocationLimitBytes: 4096);
IMemoryOwner<byte> owner = allocator.Allocate<byte>(4096);
// The full accumulative budget is reserved while the owner is live.
Assert.Throws<InvalidMemoryOperationException>(() => allocator.Allocate<byte>(1));
// Disposing the owner releases the reservation.
owner.Dispose();
allocator.Allocate<byte>(4096).Dispose();
}
/// <summary>
/// Verifies that an external allocator can back image creation through <see cref="Configuration"/>,
/// for both discontiguous and contiguous buffer preferences, and that disposal reaches the external owners.
/// </summary>
/// <param name="preferContiguousImageBuffers">The contiguous buffer preference to apply.</param>
[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<Rgba32> image = new(configuration, 16, 16, Color.Red.ToPixel<Rgba32>()))
{
Assert.True(allocator.CreatedOwners > 0);
Assert.Equal(Color.Red.ToPixel<Rgba32>(), image[8, 8]);
}
Assert.Equal(0, allocator.LiveOwners);
}
/// <summary>
/// A <see cref="MemoryAllocator"/> implemented with only the public API surface, backed by managed arrays.
/// </summary>
private sealed class ExternalArrayMemoryAllocator : MemoryAllocator
{
/// <summary>
/// Initializes a new instance of the <see cref="ExternalArrayMemoryAllocator"/> class with default limits.
/// </summary>
public ExternalArrayMemoryAllocator()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ExternalArrayMemoryAllocator"/> class with custom limits.
/// </summary>
/// <param name="options">The <see cref="MemoryAllocatorOptions"/> to apply.</param>
public ExternalArrayMemoryAllocator(MemoryAllocatorOptions options) => this.ApplyOptions(options);
/// <summary>
/// Gets the total number of owners created by this allocator.
/// </summary>
public int CreatedOwners { get; private set; }
/// <summary>
/// Gets the number of owners created by this allocator that are not yet disposed.
/// </summary>
public int LiveOwners { get; private set; }
/// <summary>
/// Sets the protected limit properties directly, as a derived allocator can.
/// </summary>
/// <param name="memoryGroupAllocationLimitBytes">The group allocation limit, in bytes.</param>
/// <param name="singleBufferAllocationLimitBytes">The single buffer allocation limit, in bytes.</param>
/// <param name="accumulativeAllocationLimitBytes">The accumulative allocation limit, in bytes.</param>
public void SetLimits(
long memoryGroupAllocationLimitBytes,
int singleBufferAllocationLimitBytes,
long accumulativeAllocationLimitBytes)
{
this.MemoryGroupAllocationLimitBytes = memoryGroupAllocationLimitBytes;
this.SingleBufferAllocationLimitBytes = singleBufferAllocationLimitBytes;
this.AccumulativeAllocationLimitBytes = accumulativeAllocationLimitBytes;
}
/// <inheritdoc />
protected override int GetBufferCapacityInBytes() => int.MaxValue;
/// <inheritdoc />
protected override AllocationTrackedMemoryManager<T> AllocateCore<T>(int length, AllocationOptions options = AllocationOptions.None)
{
this.CreatedOwners++;
this.LiveOwners++;
return new ExternalArrayMemoryManager<T>(new T[length], this);
}
/// <summary>
/// Records the disposal of an owner created by this allocator.
/// </summary>
internal void OnOwnerDisposed() => this.LiveOwners--;
}
/// <summary>
/// An <see cref="AllocationTrackedMemoryManager{T}"/> implemented with only the public API surface.
/// </summary>
/// <typeparam name="T">The element type.</typeparam>
private sealed class ExternalArrayMemoryManager<T> : AllocationTrackedMemoryManager<T>
where T : struct
{
private readonly T[] array;
private readonly ExternalArrayMemoryAllocator allocator;
/// <summary>
/// Initializes a new instance of the <see cref="ExternalArrayMemoryManager{T}"/> class.
/// </summary>
/// <param name="array">The array that backs this owner.</param>
/// <param name="allocator">The allocator that created this owner.</param>
public ExternalArrayMemoryManager(T[] array, ExternalArrayMemoryAllocator allocator)
{
this.array = array;
this.allocator = allocator;
}
/// <inheritdoc />
public override Span<T> GetSpan() => this.array;
/// <inheritdoc />
public override MemoryHandle Pin(int elementIndex = 0) => throw new NotSupportedException("Pinning is not required by these tests.");
/// <inheritdoc />
public override void Unpin()
{
}
/// <inheritdoc />
protected override void DisposeCore(bool disposing) => this.allocator.OnOwnerDisposed();
}
}

36
tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedPoolMemoryAllocatorTests.cs

@ -483,6 +483,42 @@ public class UniformUnmanagedPoolMemoryAllocatorTests
Assert.Throws<InvalidMemoryOperationException>(() => allocator.AllocateGroup<byte>(5 * oneMb, 1024)); Assert.Throws<InvalidMemoryOperationException>(() => allocator.AllocateGroup<byte>(5 * oneMb, 1024));
} }
[Fact]
public void Allocate_OverSingleBufferLimit_ThrowsInvalidMemoryOperationException()
{
MemoryAllocator allocator = MemoryAllocator.Create(new MemoryAllocatorOptions
{
SingleBufferAllocationLimitMegabytes = 2
});
const int oneMb = 1 << 20;
allocator.Allocate<byte>(2 * oneMb).Dispose(); // Should work
Assert.Throws<InvalidMemoryOperationException>(() => allocator.Allocate<byte>(3 * oneMb));
// The group limit is unchanged, so the same size still allocates as a discontiguous group.
allocator.AllocateGroup<byte>(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<ArgumentOutOfRangeException>(() => options.SingleBufferAllocationLimitMegabytes = value);
}
[Fact] [Fact]
public void Allocate_AccumulativeLimit_ReleasesOnOwnerDispose() public void Allocate_AccumulativeLimit_ReleasesOnOwnerDispose()
{ {

Loading…
Cancel
Save