Skip to content

Pixel processing breaking changes & API discussion #1739

@antonfirsov

Description

@antonfirsov

Why breaking changes again?

ImageSharp 1.0 suffers from serious memory management issues under high parallel load.

#1730 is here to deal with this, but we realized we cannot guarantee the safety of .GetPixelRowSpan(y) API-s after switching to unmanaged memory.

Although well written written user code is not at risk, missing or buggy Image<T> lifetime management can lead to memory corruption under certain circumstances. Consider the following code:

var image = new Image<Rgba32>(w, h); // missing using statement
Span<Rgba32> span = image.GetPixelRowSpan(0); // last use of image, GC is free to collect it

// GC happens here and finalizers have enough time to finish
// and potentially return image's underlying memory to the OS

span[0] = default;  // memory corruption

We cannot let a silent package update to turn performance issues and minor bugs into serious security problems, and we don't want to maintain safe-looking API-s which are in fact inherently dangerous, therefore we have to replace existing Span<TPixel> methods with delegate-based variants, which can be implemented safely.

We are about to deliver tons of bugfixes and performance improvements in our next NuGet release. We believe this will justify the breaking changes, so we decided to bump our major version number.

ImageSharp 2.0 is coming!

Proposed API changes

Processing pixel rows using Span<T>

The following methods are about to be deleted:

public class Image<TPixel>
{
-	public Span<TPixel> GetPixelRowSpan(int rowIndex);
}

public class ImageFrame<TPixel>
{
-	public Span<TPixel> GetPixelRowSpan(int rowIndex);	
}

public class Buffer2D<TPixel>
{
-	public Span<TPixel> GetRowSpan(int rowIndex);	
}

... and replaced by delegate based variants:

public readonly ref struct PixelAccessor<TPixel> where TPixel : unmanaged, IPixel<TPixel>
{
    // We expose the dimensions of the backing image
    // to make it easy to use PixelAccessor<TPixel> with static delegates.
    public int Width { get; }
    public int Height { get; }
    public Span<TPixel> GetRowSpan(int rowIndex);
}

public delegate void PixelAccessorAction<TPixel>(PixelAccessor<TPixel> pixelAccessor) where TPixel : unmanaged, IPixel<TPixel>;

public class Image<TPixel> where TPixel : unmanaged, IPixel<TPixel>
{
	public void ProcessPixelRows(PixelAccessorAction<TPixel> processPixels);
}

public class ImageFrame<TPixel> where TPixel : unmanaged, IPixel<TPixel>
{
	public void ProcessPixelRows(PixelAccessorAction<TPixel> processPixels);
}

public class Buffer2D<T> where T : unmanaged
{
	// This is an advanced API, mostly used internally.
	// We don't expect many users to interact with it.
	// Regardless, adding the Dangerous prefix seems reasonable.
 	public Span<T> DangerousGetRowSpan(int rowIndex);	
}

The delegate is responsible to make sure, that necessary bookkeeping is invoked around pixel processing (increasing SafeHandle ref counts). PixelAccessor<TPixel> has to be a ref struct to make sure that no-one saves it to the heap escaping the bookkeeping mechanisms.

Sample code

using var image = new Image<Rgb24>(256, 256);
image.ProcessPixelRows(static pixelAccessor =>
{
    for (int y = 0; y < pixelAccessor.Height; y++)
    {
        Span<Rgb24> row = pixelAccessor.GetRowSpan(y);

        // Using row.Length helps JIT to eliminate bounds checks when accessing row[x].
        for (int x = 0; x < row.Length; x++)
        {
            row[x] = new Rgb24((byte)x, (byte)y, 0);
        }
    }
});

Processing multiple images simultaneously

Things get tricky with use cases like #1666. The following code using nested delegates won't work, because PixelAccessor<TPixel> is a ref struct and can not be captured by a delegate:

- static Image<Rgba32> Extract(Image<Rgba32> sourceImage, Rectangle sourceArea)
- {
-     Image<Rgba32> targetImage = new (sourceArea.Width, sourceArea.Height);
-     int height = sourceArea.Height;
-     sourceImage.ProcessPixelRows(sourceAccessor =>
-     {
-         targetImage.ProcessPixelRows(targetAccessor =>
-         {
-             for (int i = 0; i < height; i++)
-             {
-                 // [CS1628] Cannot use ref, out, or in parameter 'sourceAccessor' inside an anonymous method, lambda expression, query expression, or local function
-                 Span<Rgba32> sourceRow = sourceAccessor.GetRowSpan(sourceArea.Y + i);
-                 Span<Rgba32> targetRow = targetAccessor.GetRowSpan(i);
- 
-                 sourceRow.Slice(sourceArea.X, sourceArea.Width).CopyTo(targetRow);
-             }
-         });
-     });
- 
-     return targetImage;
- }

To deal with this, we need to expose additional delegates and ProcessPixelRows overloads that work with multiple PixelProcessors<TPixel>-s simultaneously.

public delegate void PixelAccessorAction<TPixel1, TPixel2>(PixelAccessor<TPixel1> pixelAccessor1, PixelAccessor<TPixel2> pixelAccessor2)
        where TPixel1 : unmanaged, IPixel<TPixel1>
        where TPixel2 : unmanaged, IPixel<TPixel2>;

public delegate void PixelAccessorAction<TPixel1, TPixel2, TPixel3>(PixelAccessor<TPixel1> pixelAccessor1, PixelAccessor<TPixel2> pixelAccessor2, PixelAccessor<TPixel2> pixelAccessor3)
        where TPixel1 : unmanaged, IPixel<TPixel1>
        where TPixel2 : unmanaged, IPixel<TPixel2>
        where TPixel3 : unmanaged, IPixel<TPixel3>;

public class Image<TPixel> where TPixel : unmanaged, IPixel<TPixel>
{
	public void ProcessPixelRows<TPixel2>(Image<TPixel2> image2,
		PixelAccessorAction<TPixel> processPixels1, PixelAccessorAction<TPixel> processPixels2)
		where TPixel2 : unmanaged, IPixel<TPixel2>;

	public void ProcessPixelRows<TPixel2, TPixel3>(Image<TPixel2> image2, Image<TPixel3> image3,
		PixelAccessorAction<TPixel> processPixels1, PixelAccessorAction<TPixel> processPixels2, PixelAccessorAction<TPixel> processPixels3)
		where TPixel2 : unmanaged, IPixel<TPixel2>
	    where TPixel3 : unmanaged, IPixel<TPixel3>;	
}

// Also expose the same overloads on ImageFrame<TPixel>

Now we can implement the Extract method:

private static Image<Rgba32> Extract(Image<Rgba32> sourceImage, Rectangle sourceArea)
{
    Image<Rgba32> targetImage = new (sourceArea.Width, sourceArea.Height);
    int height = sourceArea.Height;
    sourceImage.ProcessPixelRows(targetImage, (sourceAccessor, targetAccessor) =>
    {
        for (int i = 0; i < height; i++)
        {
            Span<Rgba32> sourceRow = sourceAccessor.GetRowSpan(sourceArea.Y + i);
            Span<Rgba32> targetRow = targetAccessor.GetRowSpan(i);

            sourceRow.Slice(sourceArea.X, sourceArea.Width).CopyTo(targetRow);
        }
    });

    return targetImage;
}

Processing pixel rows using Memory<T>

There are some less-known overloads in SixLabors.ImageSharp.Advanced.AdvancedImageExtensions that allow accessing image rows through Memory<T>. We should consider adding the Dangerous prefix to them because accessing Span<TPixel> through image.GetPixelRowMemory(y).Span suffers from the same issues as image.GetPixelRowSpan(y).

public static class AdvancedImageExtensions
{
-	public static Memory<TPixel> GetPixelRowMemory<TPixel>(this Image<TPixel> source, int rowIndex);
-	public static Memory<TPixel> GetPixelRowMemory<TPixel>(this ImageFrame<TPixel> source, int rowIndex)
+	public static Memory<TPixel> DangerousGetPixelRowMemory<TPixel>(this Image<TPixel> source, int rowIndex);
+	public static Memory<TPixel> DangerousGetPixelRowMemory<TPixel>(this ImageFrame<TPixel> source, int rowIndex)
}

Although these methods are alredy hidden behind the *.Advanced namespace, I'm in favor of this change, since users can abuse them to acquire a pixel row span with image.GetPixelRowMemory(y).Span instead of the ProcessPixelRows delegate API-s.

Getting a single pixel span to Image<TPixel>

The method Image.TryGetSinglePixelSpan(out Span<TPixel>) is mostly used for interop scenarios, to acquire a Span<TPixel> that could be pinned so users can pass the image's pointer to the GPU or other image processing libraries. Not surprisingly, we have to replace this method:

public class Image<TPixel>
{
-	public bool TryGetSinglePixelSpan(out Span<TPixel> span)
+   public bool DangerousTryGetSinglePixelMemory(out Memory<TPixel> memory);
}

API usage

using var image = new Image<Rgba32>(4096, 4096);

if (!image.DangerousTryGetSinglePixelMemory(out Memory<Rgba32> memory))
{
    throw new Exception("Make sure to initialize MemoryAllocator.Default!");
}

using (MemoryHandle imageMemoryHandle = memory.Pin())
{
    IntPtr resourceHandle = Interop.AllocateSomeUnmanagedResourceToWorkWithPixelData(
        (IntPtr)imageMemoryHandle.Pointer,
        image.Width * image.Height);
    Interop.WorkWithUnmanagedResource(resourceHandle);
    Interop.FreeUnmanagedResource(resourceHandle);
}

Initializing MemoryAllocator

Update: The proposal to guarantee contiguous memory at alloactor level has been replaced by Configuration.PreferContiguousImageBuffers: #1739 (comment)

After #1730, an image of 4096 x 4096 pixels will no longer fit into a single contiguous memory buffer by default.
The following initialization code has to be executed once during startup, to make sure that an image of this size is contiguous:

Limiting pool size
MemoryAllocator.Default = MemoryAllocator.Create(
	new MemoryAllocatorOptions()
    {
        MaximumPoolSizeMegabytes= 8
    });

Feedback is welcome!

/cc @saucecontrol @Sergio0694 @br3aker @alistaircarscadden @ThomasMiz @Veriara @DaZombieKiller @TanukiSharp @ptasev

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions