Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Limit all memory allocations in the MemoryAllocator layer #2706

Merged
merged 7 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/ImageSharp/Formats/Png/PngDecoderCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1979,6 +1979,9 @@ private IMemoryOwner<byte> ReadChunkData(int length)
}

// We rent the buffer here to return it afterwards in Decode()
// We don't want to throw a degenerated memory exception here as we want to allow partial decoding
// so limit the length.
length = (int)Math.Min(length, this.currentStream.Length - this.currentStream.Position);
IMemoryOwner<byte> buffer = this.configuration.MemoryAllocator.Allocate<byte>(length, AllocationOptions.Clean);

this.currentStream.Read(buffer.GetSpan(), 0, length);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace SixLabors.ImageSharp.Memory.Internals;
internal struct UnmanagedMemoryHandle : IEquatable<UnmanagedMemoryHandle>
{
// Number of allocation re-attempts when detecting OutOfMemoryException.
private const int MaxAllocationAttempts = 1000;
private const int MaxAllocationAttempts = 10;

// Track allocations for testing purposes:
private static int totalOutstandingHandles;
Expand Down
46 changes: 40 additions & 6 deletions src/ImageSharp/Memory/Allocators/MemoryAllocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the Six Labors Split License.

using System.Buffers;
using System.Runtime.CompilerServices;

namespace SixLabors.ImageSharp.Memory;

Expand All @@ -10,6 +11,8 @@ namespace SixLabors.ImageSharp.Memory;
/// </summary>
public abstract class MemoryAllocator
{
private const int OneGigabyte = 1 << 30;

/// <summary>
/// Gets the default platform-specific global <see cref="MemoryAllocator"/> instance that
/// serves as the default value for <see cref="Configuration.MemoryAllocator"/>.
Expand All @@ -20,6 +23,10 @@ public abstract class MemoryAllocator
/// </summary>
public static MemoryAllocator Default { get; } = Create();

internal long MemoryGroupAllocationLimitBytes { get; private set; } = Environment.Is64BitProcess ? 4L * OneGigabyte : OneGigabyte;

internal int SingleBufferAllocationLimitBytes { get; private set; } = OneGigabyte;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, there is no good reason to set this to 1GB. Contiguous buffers have a natural limit of int.MaxValue ~ 2GB so I think we should set the contiguous limit to min(int.MaxValue, MemoryAllocatorOptions.AllocationLimitMegabytes * 1MB). That would get rid of all obscure logic and make the semantics of MemoryAllocatorOptions.AllocationLimitMegabytes very obvious.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. I like that. The only risk we have with this stuff is in our decoders which can be easily mitigated with some care (PNG tweaks)


/// <summary>
/// Gets the length of the largest contiguous buffer that can be handled by this allocator instance in bytes.
/// </summary>
Expand All @@ -30,16 +37,24 @@ public abstract class MemoryAllocator
/// Creates a default instance of a <see cref="MemoryAllocator"/> optimized for the executing platform.
/// </summary>
/// <returns>The <see cref="MemoryAllocator"/>.</returns>
public static MemoryAllocator Create() =>
new UniformUnmanagedMemoryPoolMemoryAllocator(null);
public static MemoryAllocator Create() => Create(default);

/// <summary>
/// Creates the default <see cref="MemoryAllocator"/> using the provided options.
/// </summary>
/// <param name="options">The <see cref="MemoryAllocatorOptions"/>.</param>
/// <returns>The <see cref="MemoryAllocator"/>.</returns>
public static MemoryAllocator Create(MemoryAllocatorOptions options) =>
new UniformUnmanagedMemoryPoolMemoryAllocator(options.MaximumPoolSizeMegabytes);
public static MemoryAllocator Create(MemoryAllocatorOptions options)
{
UniformUnmanagedMemoryPoolMemoryAllocator allocator = new(options.MaximumPoolSizeMegabytes);
if (options.AllocationLimitMegabytes.HasValue)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For V4 I think we should introduce UniformUnmanagedMemoryPoolOptions which has the standard options as a property.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, AllocatorOptions is specific to UniformUnmanagedMemoryAllocator (which is intantiated with MemoryAllocator.Create(), the type itself is not public). I don't think anyone ever would need another allocator IRL. The design keeps allocators pluggable just in case + SimpleGcMemoryAllocator can be useful for diagnostic purposes since managed allocations are easy to track with standard .NET tools. But SimpleGcMemoryAllocator should not be used in production.

To secure users who still use it (mostly legacy code designed to workaround 1.* issues), I applied the default limits, but you cannot configure SimpleGcMemoryAllocator with MemoryAllocatorOptions.

{
allocator.MemoryGroupAllocationLimitBytes = options.AllocationLimitMegabytes.Value * 1024 * 1024;
allocator.SingleBufferAllocationLimitBytes = (int)Math.Min(allocator.SingleBufferAllocationLimitBytes, allocator.MemoryGroupAllocationLimitBytes);
}

return allocator;
}

/// <summary>
/// Allocates an <see cref="IMemoryOwner{T}" />, holding a <see cref="Memory{T}"/> of length <paramref name="length"/>.
Expand All @@ -64,15 +79,34 @@ public virtual void ReleaseRetainedResources()
/// <summary>
/// Allocates a <see cref="MemoryGroup{T}"/>.
/// </summary>
/// <typeparam name="T">The type of element to allocate.</typeparam>
/// <param name="totalLength">The total length of the buffer.</param>
/// <param name="bufferAlignment">The expected alignment (eg. to make sure image rows fit into single buffers).</param>
/// <param name="options">The <see cref="AllocationOptions"/>.</param>
/// <returns>A new <see cref="MemoryGroup{T}"/>.</returns>
/// <exception cref="InvalidMemoryOperationException">Thrown when 'blockAlignment' converted to bytes is greater than the buffer capacity of the allocator.</exception>
internal virtual MemoryGroup<T> AllocateGroup<T>(
internal MemoryGroup<T> AllocateGroup<T>(
long totalLength,
int bufferAlignment,
AllocationOptions options = AllocationOptions.None)
where T : struct
=> MemoryGroup<T>.Allocate(this, totalLength, bufferAlignment, options);
{
if (totalLength < 0)
{
InvalidMemoryOperationException.ThrowNegativeAllocationException(totalLength);
}

ulong totalLengthInBytes = (ulong)totalLength * (ulong)Unsafe.SizeOf<T>();
if (totalLengthInBytes > (ulong)this.MemoryGroupAllocationLimitBytes)
{
InvalidMemoryOperationException.ThrowAllocationOverLimitException(totalLengthInBytes, this.MemoryGroupAllocationLimitBytes);
}

// Cast to long is safe because we already checked that the total length is within the limit.
return this.AllocateGroupCore<T>(totalLength, (long)totalLengthInBytes, bufferAlignment, options);
}

internal virtual MemoryGroup<T> AllocateGroupCore<T>(long totalLengthInElements, long totalLengthInBytes, int bufferAlignment, AllocationOptions options)
where T : struct
=> MemoryGroup<T>.Allocate(this, totalLengthInElements, bufferAlignment, options);
}
21 changes: 20 additions & 1 deletion src/ImageSharp/Memory/Allocators/MemoryAllocatorOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Six Labors.
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.

namespace SixLabors.ImageSharp.Memory;
Expand All @@ -9,6 +9,7 @@ namespace SixLabors.ImageSharp.Memory;
public struct MemoryAllocatorOptions
{
private int? maximumPoolSizeMegabytes;
private int? allocationLimitMegabytes;

/// <summary>
/// Gets or sets a value defining the maximum size of the <see cref="MemoryAllocator"/>'s internal memory pool
Expand All @@ -27,4 +28,22 @@ public struct MemoryAllocatorOptions
this.maximumPoolSizeMegabytes = value;
}
}

/// <summary>
/// Gets or sets a value defining the maximum (discontiguous) buffer size that can be allocated by the allocator in Megabytes.
/// <see langword="null"/> means platform default: 1GB on 32-bit processes, 4GB on 64-bit processes.
/// </summary>
public int? AllocationLimitMegabytes
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You got it in the options! Nice!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this consistent with how we talk about memory across the public api? do we talk about megabytes anywhere else in the public api or is it all bytes?

we want to make sure we are being consistent on our units of measure in the public api surface.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other property on MemoryAllocatiorOptions is MaximumPoolSizeMegabytes. When I designed the MemoryAllocator configuration API it seemed like Megabytes are easier to reason about when talking about limits.

get => this.allocationLimitMegabytes;
set
{
if (value.HasValue)
{
Guard.MustBeGreaterThan(value.Value, 0, nameof(this.AllocationLimitMegabytes));
}

this.allocationLimitMegabytes = value;
}
}
}
14 changes: 12 additions & 2 deletions src/ImageSharp/Memory/Allocators/SimpleGcMemoryAllocator.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Copyright (c) Six Labors.
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.

using System.Buffers;
using System.Runtime.CompilerServices;
using SixLabors.ImageSharp.Memory.Internals;

namespace SixLabors.ImageSharp.Memory;
Expand All @@ -17,7 +18,16 @@ public sealed class SimpleGcMemoryAllocator : MemoryAllocator
/// <inheritdoc />
public override IMemoryOwner<T> Allocate<T>(int length, AllocationOptions options = AllocationOptions.None)
{
Guard.MustBeGreaterThanOrEqualTo(length, 0, nameof(length));
if (length < 0)
{
InvalidMemoryOperationException.ThrowNegativeAllocationException(length);
}

ulong lengthInBytes = (ulong)length * (ulong)Unsafe.SizeOf<T>();
if (lengthInBytes > (ulong)this.SingleBufferAllocationLimitBytes)
{
InvalidMemoryOperationException.ThrowAllocationOverLimitException(lengthInBytes, this.SingleBufferAllocationLimitBytes);
}

return new BasicArrayBuffer<T>(new T[length]);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,18 @@ public UniformUnmanagedMemoryPoolMemoryAllocator(int? maxPoolSizeMegabytes)
int length,
AllocationOptions options = AllocationOptions.None)
{
Guard.MustBeGreaterThanOrEqualTo(length, 0, nameof(length));
int lengthInBytes = length * Unsafe.SizeOf<T>();
if (length < 0)
{
InvalidMemoryOperationException.ThrowNegativeAllocationException(length);
}

ulong lengthInBytes = (ulong)length * (ulong)Unsafe.SizeOf<T>();
if (lengthInBytes > (ulong)this.SingleBufferAllocationLimitBytes)
{
InvalidMemoryOperationException.ThrowAllocationOverLimitException(lengthInBytes, this.SingleBufferAllocationLimitBytes);
}

if (lengthInBytes <= this.sharedArrayPoolThresholdInBytes)
if (lengthInBytes <= (ulong)this.sharedArrayPoolThresholdInBytes)
{
var buffer = new SharedArrayPoolBuffer<T>(length);
if (options.Has(AllocationOptions.Clean))
Expand All @@ -97,7 +105,7 @@ public UniformUnmanagedMemoryPoolMemoryAllocator(int? maxPoolSizeMegabytes)
return buffer;
}

if (lengthInBytes <= this.poolBufferSizeInBytes)
if (lengthInBytes <= (ulong)this.poolBufferSizeInBytes)
{
UnmanagedMemoryHandle mem = this.pool.Rent();
if (mem.IsValid)
Expand All @@ -111,20 +119,15 @@ public UniformUnmanagedMemoryPoolMemoryAllocator(int? maxPoolSizeMegabytes)
}

/// <inheritdoc />
internal override MemoryGroup<T> AllocateGroup<T>(
long totalLength,
internal override MemoryGroup<T> AllocateGroupCore<T>(
long totalLengthInElements,
long totalLengthInBytes,
int bufferAlignment,
AllocationOptions options = AllocationOptions.None)
{
long totalLengthInBytes = totalLength * Unsafe.SizeOf<T>();
if (totalLengthInBytes < 0)
{
throw new InvalidMemoryOperationException("Attempted to allocate a MemoryGroup of a size that is not representable.");
}

if (totalLengthInBytes <= this.sharedArrayPoolThresholdInBytes)
{
var buffer = new SharedArrayPoolBuffer<T>((int)totalLength);
var buffer = new SharedArrayPoolBuffer<T>((int)totalLengthInElements);
return MemoryGroup<T>.CreateContiguous(buffer, options.Has(AllocationOptions.Clean));
}

Expand All @@ -134,18 +137,18 @@ public UniformUnmanagedMemoryPoolMemoryAllocator(int? maxPoolSizeMegabytes)
UnmanagedMemoryHandle mem = this.pool.Rent();
if (mem.IsValid)
{
UnmanagedBuffer<T> buffer = this.pool.CreateGuardedBuffer<T>(mem, (int)totalLength, options.Has(AllocationOptions.Clean));
UnmanagedBuffer<T> buffer = this.pool.CreateGuardedBuffer<T>(mem, (int)totalLengthInElements, options.Has(AllocationOptions.Clean));
return MemoryGroup<T>.CreateContiguous(buffer, options.Has(AllocationOptions.Clean));
}
}

// Attempt to rent the whole group from the pool, allocate a group of unmanaged buffers if the attempt fails:
if (MemoryGroup<T>.TryAllocate(this.pool, totalLength, bufferAlignment, options, out MemoryGroup<T>? poolGroup))
if (MemoryGroup<T>.TryAllocate(this.pool, totalLengthInElements, bufferAlignment, options, out MemoryGroup<T>? poolGroup))
{
return poolGroup;
}

return MemoryGroup<T>.Allocate(this.nonPoolAllocator, totalLength, bufferAlignment, options);
return MemoryGroup<T>.Allocate(this.nonPoolAllocator, totalLengthInElements, bufferAlignment, options);
}

public override void ReleaseRetainedResources() => this.pool.Release();
Expand Down
13 changes: 7 additions & 6 deletions src/ImageSharp/Memory/DiscontiguousBuffers/MemoryGroup{T}.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,16 @@ IEnumerator<Memory<T>> IEnumerable<Memory<T>>.GetEnumerator()
{
int bufferCapacityInBytes = allocator.GetBufferCapacityInBytes();
Guard.NotNull(allocator, nameof(allocator));
Guard.MustBeGreaterThanOrEqualTo(totalLengthInElements, 0, nameof(totalLengthInElements));
Guard.MustBeGreaterThanOrEqualTo(bufferAlignmentInElements, 0, nameof(bufferAlignmentInElements));

int blockCapacityInElements = bufferCapacityInBytes / ElementSize;
if (totalLengthInElements < 0)
{
InvalidMemoryOperationException.ThrowNegativeAllocationException(totalLengthInElements);
}

if (bufferAlignmentInElements > blockCapacityInElements)
int blockCapacityInElements = bufferCapacityInBytes / ElementSize;
if (bufferAlignmentInElements < 0 || bufferAlignmentInElements > blockCapacityInElements)
{
throw new InvalidMemoryOperationException(
$"The buffer capacity of the provided MemoryAllocator is insufficient for the requested buffer alignment: {bufferAlignmentInElements}.");
InvalidMemoryOperationException.ThrowInvalidAlignmentException(bufferAlignmentInElements);
}

if (totalLengthInElements == 0)
Expand Down
15 changes: 15 additions & 0 deletions src/ImageSharp/Memory/InvalidMemoryOperationException.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.

using System.Diagnostics.CodeAnalysis;

namespace SixLabors.ImageSharp.Memory;

/// <summary>
Expand All @@ -24,4 +26,17 @@ public InvalidMemoryOperationException(string message)
public InvalidMemoryOperationException()
{
}

[DoesNotReturn]
internal static void ThrowNegativeAllocationException(long length) =>
throw new InvalidMemoryOperationException($"Attempted to allocate a buffer of negative length={length}.");

[DoesNotReturn]
internal static void ThrowInvalidAlignmentException(long alignment) =>
throw new InvalidMemoryOperationException(
$"The buffer capacity of the provided MemoryAllocator is insufficient for the requested buffer alignment: {alignment}.");

[DoesNotReturn]
internal static void ThrowAllocationOverLimitException(ulong length, long limit) =>
throw new InvalidMemoryOperationException($"Attempted to allocate a buffer of length={length} that exceeded the limit {limit}.");
}
12 changes: 12 additions & 0 deletions tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -558,4 +558,16 @@ public void BmpDecoder_CanDecode_Os2BitmapArray<TPixel>(TestImageProvider<TPixel
// Compare to reference output instead.
image.CompareToReferenceOutput(provider, extension: "png");
}

[Theory]
[WithFile(Issue2696, PixelTypes.Rgba32)]
public void BmpDecoder_ThrowsException_Issue2696<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
InvalidImageContentException ex = Assert.Throws<InvalidImageContentException>(() =>
{
using Image<TPixel> image = provider.GetImage(BmpDecoder.Instance);
});
Assert.IsType<InvalidMemoryOperationException>(ex.InnerException);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This made me find that the file wasn't the actual file from #2696 here, leading to an exception in header parsing instead of an allocation failure. Fixed in d1cc651.

}
}
18 changes: 4 additions & 14 deletions tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -338,21 +338,11 @@ public void Issue2564_DecodeWorks<TPixel>(TestImageProvider<TPixel> provider)
}

[Theory]
[WithFile(TestImages.Jpeg.Issues.HangBadScan, PixelTypes.L8)]
public void DecodeHang<TPixel>(TestImageProvider<TPixel> provider)
[WithFile(TestImages.Jpeg.Issues.HangBadScan, PixelTypes.Rgb24)]
public void DecodeHang_ThrowsException<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
if (TestEnvironment.IsWindows &&
TestEnvironment.RunsOnCI)
{
// Windows CI runs consistently fail with OOM.
return;
}

using Image<TPixel> image = provider.GetImage(JpegDecoder.Instance);
Assert.Equal(65503, image.Width);
Assert.Equal(65503, image.Height);
}
=> Assert.Throws<InvalidImageContentException>(
() => { using Image<TPixel> image = provider.GetImage(JpegDecoder.Instance); });

// https://github.com/SixLabors/ImageSharp/issues/2517
[Theory]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,17 @@ public BufferTests()

protected SimpleGcMemoryAllocator MemoryAllocator { get; } = new SimpleGcMemoryAllocator();

[Theory]
[InlineData(-1)]
public void Allocate_IncorrectAmount_ThrowsCorrect_ArgumentOutOfRangeException(int length)
public static TheoryData<int> InvalidLengths { get; set; } = new()
{
ArgumentOutOfRangeException ex = Assert.Throws<ArgumentOutOfRangeException>(() => this.MemoryAllocator.Allocate<BigStruct>(length));
Assert.Equal("length", ex.ParamName);
}
{ -1 },
{ (1 << 30) + 1 }
};

[Theory]
[MemberData(nameof(InvalidLengths))]
public void Allocate_IncorrectAmount_ThrowsCorrect_InvalidMemoryOperationException(int length)
=> Assert.Throws<InvalidMemoryOperationException>(
() => this.MemoryAllocator.Allocate<BigStruct>(length));

[Fact]
public unsafe void Allocate_MemoryIsPinnableMultipleTimes()
Expand Down