Browse Source

Merge pull request #3165 from SixLabors/js/configurable-allocator-limits

Make MemoryAllocator limits configurable and extensible
main
James Jackson-South 6 days ago
committed by GitHub
parent
commit
3e1dd486cd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      src/ImageSharp/Advanced/AotCompilerTools.cs
  2. 69
      src/ImageSharp/Memory/Allocators/MemoryAllocator.cs
  3. 36
      src/ImageSharp/Memory/Allocators/MemoryAllocatorOptions.cs
  4. 210
      tests/ImageSharp.PublicApi.Tests/MemoryAllocatorExtensibilityTests.cs
  5. 36
      tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedPoolMemoryAllocatorTests.cs

7
src/ImageSharp/Advanced/AotCompilerTools.cs

@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using System.Runtime.CompilerServices;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Ani;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Cur;
using SixLabors.ImageSharp.Formats.Exr;
@ -235,7 +236,7 @@ internal static class AotCompilerTools
PixelOperations<TPixel> operations = PixelOperations<TPixel>.Instance;
_ = operations.GetPixelTypeInfo();
_ = operations.GetPixelBlender(default(GraphicsOptions));
_ = operations.GetPixelBlender(default);
_ = operations.GetPixelBlender(default, default);
operations.FromVector4Destructive(default, default, default);
operations.FromVector4Destructive(default, default, default, default);
@ -289,7 +290,7 @@ internal static class AotCompilerTools
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
[Preserve]
private static unsafe void AotCompileImage<TPixel>()
private static void AotCompileImage<TPixel>()
where TPixel : unmanaged, IPixel<TPixel>
{
Image<TPixel> img = default;
@ -354,6 +355,7 @@ internal static class AotCompilerTools
private static void AotCompileImageEncoderInternals<TPixel>()
where TPixel : unmanaged, IPixel<TPixel>
{
default(AniEncoderCore).Encode<TPixel>(default, default, default);
default(BmpEncoderCore).Encode<TPixel>(default, default, default);
default(CurEncoderCore).Encode<TPixel>(default, default, default);
default(ExrEncoderCore).Encode<TPixel>(default, default, default);
@ -376,6 +378,7 @@ internal static class AotCompilerTools
private static void AotCompileImageDecoderInternals<TPixel>()
where TPixel : unmanaged, IPixel<TPixel>
{
default(AniDecoderCore).Decode<TPixel>(default, default, default);
default(BmpDecoderCore).Decode<TPixel>(default, default, default);
default(CurDecoderCore).Decode<TPixel>(default, default, default);
default(ExrDecoderCore).Decode<TPixel>(default, default, default);

69
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;
/// <summary>
/// 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();
/// <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>
/// <remarks>
/// 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.
/// </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>
/// 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>
/// <remarks>
/// 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/>
/// 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.
/// The setter is available to derived allocators and requires a positive value.
/// </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>
/// 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>
/// <remarks>
/// 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>
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>
/// 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>
/// Applies the supplied <see cref="MemoryAllocatorOptions"/> to this instance.
/// Derived allocators can call this from their constructors to accept user configuration.
/// </summary>
/// <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)
{
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;

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

@ -8,9 +8,15 @@ namespace SixLabors.ImageSharp.Memory;
/// </summary>
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? allocationLimitMegabytes;
private int? accumulativeAllocationLimitMegabytes;
private int? singleBufferAllocationLimitMegabytes;
/// <summary>
/// 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>
/// Gets or sets a value defining the maximum accumulative size, in Megabytes, of all active allocations made
/// 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));
}
[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]
public void Allocate_AccumulativeLimit_ReleasesOnOwnerDispose()
{

Loading…
Cancel
Save