.NET ArrayPool and MemoryPool

Introduction

In this post, I am going to explain what MemoryPool<T> and ArrayPool<T> are, their differences and how to use them. I will also introduce a slight variation of ArrayPool<T> that provides better maintenance with some real-world examples that you can use in your hot paths in the production environment.

ArrayPool and MemoryPool

Both MemoryPool<T> and ArrayPool<T> provide reusable instances of memory buffers that you can use and later return when you are done with them. Reusing memory buffers reduces memory pressure on the garbage collector and thus can boost the performance of your app. Make sure to always benchmark and collect performance metrics after any changes to your app.

ArrayPool<T> is an abstract class on its own from which one can create their own implementation. The .NET framework provides a default implementation called System.Buffers.ArrayPool<T>.Shared which is ready for use. For example,

    byte[] buffer = System.Buffers.ArrayPool<byte>.Shared.Rent(1_000);
    try
    {
        // Logic that uses buffer goes here 
        for (int i = 0; i < 1_000; i++)
        {
            buffer[i] = (byte)(i % 256);
        }
    }
    finally
    {
        System.Buffers.ArrayPool<byte>.Shared.Return(buffer);
    }

The above example borrows an array of at least 1,000 bytes for some logic and then returns it. I say at least because the returned array length may be larger than the requested length. The data type is not restricted to byte as in here; it could be technically anything (eg char). The key is to return the buffer after use which can be safely done in a finally block should an exception occur. Also, the buffer should not be used once it has been returned to the pool. It seems more convenient to use the buffer for local scope though technically the buffer can be passed around (just be careful when doing so).

Similarly, for MemoryPool<T>

    using IMemoryOwner<byte> buffer = System.Buffers.MemoryPool<byte>.Shared.Rent(1_000);
    var memory = buffer.Memory.Span;

    for (int i = 0; i < 1_000; i++)
    {
        memory[i] = (byte)(i % 256);
    }

MemoryPool<T> as of this writing is simply a wrapper around ArrayPool<T>. Notice the difference in the returned data type. While ArrayPool<T> returns arrays, MemoryPool<T> returns IMemoryOwner<T> which defines the owner responsible for the lifetime management of its Memory<T> buffer. This makes the data returned by MemoryPool<T> easier to pass around your components where another component becomes the owner and returns the buffer. Additionally, lots of the .NET framework methods have been refactored to accept Memory<T> directly in their overloads which can be seen as an added benefit. Note it is possible to obtain a Memory<T> from an array by simply calling the allocation-free method AsMemory().

The buffer from MemoryPool<T> is returned by simply disposing of it instead of maunally returning it as in the case of ArrayPool<T>. Under the hood, disposing the buffer returns its memory to the underlying ArrayPool<T>. If you want to to transfer ownership of the buffer, make sure to not dispose of it before doing the transfer. Ownership here means who is responsible for calling Dispose to return the buffer.

From a performance standpoint, since MemoryPool<T> is a wrapper, we expect some small penalty in performance. Hence I ran the following benchmarks

[Benchmark(Baseline = true)]
public void ArrayPool()
{
    var buffer = System.Buffers.ArrayPool<byte>.Shared.Rent(1_000);
    try
    {
        for (int i = 0; i < 1_000; i++)
        {
            buffer[i] = (byte)(i % 256);
        }
    }
    finally
    {
        System.Buffers.ArrayPool<byte>.Shared.Return(buffer);
    }
}

[Benchmark]
public void MemoryPool()
{
    using var buffer = MemoryPool<byte>.Shared.Rent(1_000);
    var span = buffer.Memory.Span;
    for (int i = 0; i < 1_000; i++)
    {
        span[i] = (byte)(i % 256);
    }	
}
Method Mean Error StdDev Ratio RatioSD Gen0 Gen1 Allocated Alloc Ratio
ArrayPool 662.0 ns 2.80 ns 5.97 ns 1.00 0.00 - - - NA
MemoryPool 786.9 ns 13.92 ns 13.02 ns 1.19 0.02 0.0019 0.0010 24 B NA

BenchmarkDotNet=v0.13.2, OS=Windows 11 (10.0.22621.1848) .NET SDK=7.0.100

As we can see, there is small overhead in using MemoryPool. It is 19% slower and has higher memory footprint, but the overall difference is not bad.

Alternative

As you start using ArrayPool you will notice that the manual invocation of the Return method can clutter the source code quickly especially if one is using multiple buffers in the same code block. It would be nice if one could use Dispose to return the buffer just like MemoryPool but without the performance overhead. The following wrapper achieves that:

public static class ArrayPoolHelper
{
	public static SharedObject<T> Rent<T>(int minimumLength)
	{
		return new SharedObject<T>(minimumLength);
	}
		
	public struct SharedObject<T> : IDisposable
	{		
		private readonly T[] _value;

		public SharedObject(int minimumLength)
		{
			_value = ArrayPool<T>.Shared.Rent(minimumLength);
		}

		public T[] Value
		{
			get {				
				return _value;
			}
		}
		
		public void Dispose()
		{			
			ArrayPool<T>.Shared.Return(Value);			
		}
	}
}

which can be used like the following

[Benchmark]
public void ArrayPoolHelper()
{
    using var buffer = ArrayPoolHelper.Rent<byte>(1_000);
    for(int i=0; i<1_000;i++) {
        buffer.Value[i]=(byte) (i % 256);
    }
}

The following benchmark shows how all three rank together:

Method Mean Error StdDev Ratio RatioSD Gen0 Gen1 Allocated Alloc Ratio
ArrayPoolHelper 647.8 ns 6.48 ns 5.41 ns 0.98 0.01 - - - NA
ArrayPool 658.7 ns 6.45 ns 6.03 ns 1.00 0.00 - - - NA
MemoryPool 779.8 ns 13.08 ns 12.23 ns 1.18 0.03 0.0019 0.0010 24 B NA

As we can see above, ArrayPoolHelper is as fast as ArrayPool and without any allocation penalties.

Examples

It is common in apps nowadays to use Base64 encoding to transfer binary data as text. Since Base64 is heavy on using byte arrays, we could put the code above to use and see how it performs.

The following method decodes text from Base64 to byte[], which is the equivalent of Convert.FromBase64String

public static SharedObject<byte> FromBase64String(string value, out int bytesWritten)
{
    using var buffer = Rent<byte>(Encoding.UTF8.GetMaxByteCount(value.Length));
    int bufferSize = Encoding.UTF8.GetBytes(value, buffer.Value);
    var decodedBuffer = Rent<byte>(Base64.GetMaxDecodedFromUtf8Length(value.Length));
    try {
        Base64.DecodeFromUtf8(buffer.Value.AsSpan(0, bufferSize), decodedBuffer.Value, out int _, out bytesWritten);
        if (bytesWritten == 0) {
            throw new InvalidOperationException("Error writing to buffer.");
        }
    }
    catch {
        decodedBuffer.Dispose();
        throw;
    }
    return decodedBuffer;
}

and the benchmarks

// 2048 bytes encoded in base64.
const string base64Text = "9uGC8l4jWOD+...";

[Benchmark]
public void ArrayPoolHelperBase64()
{    
    using var bytes = UserQuery.ArrayPoolHelper.FromBase64String(base64Text, out int size);       
}

[Benchmark(Baseline = true)]
public void ConvertFromBase64()
{
    byte[] bytes = Convert.FromBase64String(base64Text);
}
Method Mean Error StdDev Ratio Gen0 Allocated Alloc Ratio
ArrayPoolHelperBase64 224.5 ns 2.54 ns 2.38 ns 0.11 - - 0.00
ConvertFromBase64 2,003.1 ns 29.91 ns 27.98 ns 1.00 0.1640 2072 B 1.00

ArrayPoolHelperBase64 is clearly the winner being 90% faster than Convert.FromBase64String and having no allocations.

If you are using Convert.FromBase64String in one of your hot paths, your app will most likely see benefits from using the above method.

For completion, below is the Convert.ToBase64String implementation

public static string ToBase64String(ReadOnlySpan<byte> value)
{
    using var encodedBuffer = Rent<byte>(Base64.GetMaxEncodedToUtf8Length(value.Length));
    Base64.EncodeToUtf8(value, encodedBuffer.Value, out int _, out int bytesWritten);
    if (bytesWritten == 0)
    {
        throw new InvalidOperationException("Error writing to buffer.");
    }

    // Convert bytes to string            
    return Encoding.UTF8.GetString(encodedBuffer.Value.AsSpan(0, bytesWritten));
}
Comments