Skip to content

Azure Blob storage: pooled reads, streaming serializer, buffered writes#9879

Open
egil wants to merge 2 commits intodotnet:mainfrom
egil:pooled-azure-blob-storage-read
Open

Azure Blob storage: pooled reads, streaming serializer, buffered writes#9879
egil wants to merge 2 commits intodotnet:mainfrom
egil:pooled-azure-blob-storage-read

Conversation

@egil
Copy link
Copy Markdown
Contributor

@egil egil commented Jan 13, 2026

Summary

  • Add IGrainStorageStreamingSerializer plus stream overloads for OrleansJsonSerializer; Orleans and Newtonsoft.Json grain storage serializers implement it.
  • Azure Blob storage can use pooled read buffers via DownloadStreamingAsync, and supports a buffered stream write mode using pooled segments; default write path remains BinaryData.
  • Add logging when pooled reads fall back for payloads > int.MaxValue.

Tests/Benchmarks

  • New Azure Blob storage tests covering pooled reads and streaming serializer behavior (default binary writes vs buffered stream writes).
  • Added focused grain storage benchmarks (read/write, binary vs streaming) across Orleans, Newtonsoft.Json, and STJ, with LOH-sized payloads. Note: results are from Azurite on a local machine, so CPU timing is noisy but allocation/GC deltas are consistent.

Experiments/Decisions

  • Tried OpenWriteAsync for fully streaming uploads; rejected due to an ETag/concurrency race when GetPropertiesAsync is needed post-upload.
  • Explored IBufferWriter/ReadOnlySequence serializer paths; dropped to keep the API surface smaller and because Blob SDK paths are stream-first.
  • Considered size hints and rented byte[] for writes; not reliable with unknown payload sizes in Orleans. Buffered stream uploads were kept as the best allocation/compatibility tradeoff while keeping BinaryData as the default for throughput.

Ill post measurements from my laptop in comments below.

@egil egil force-pushed the pooled-azure-blob-storage-read branch from 7a23245 to 7ec8b60 Compare January 14, 2026 16:49
@egil egil marked this pull request as ready for review January 14, 2026 16:51
Copilot AI review requested due to automatic review settings January 14, 2026 16:51
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces an opt-in performance optimization for Azure Blob Storage reads by using pooled buffers to reduce memory pressure on the Large Object Heap (LOH) and minimize Gen2 garbage collection. The change adds a new UsePooledBufferForReads option to AzureBlobStorageOptions that, when enabled, switches from DownloadContentAsync to DownloadStreamingAsync with ArrayPool-rented buffers.

Changes:

  • Added UsePooledBufferForReads option to AzureBlobStorageOptions for opt-in buffer pooling
  • Modified ReadStateAsync to use streaming download with pooled buffers when the option is enabled
  • Added comprehensive test coverage with PersistenceGrainTests_AzureBlobStore_PooledReads
  • Added performance benchmark AzureBlobReadStateBenchmark demonstrating 56-74% memory allocation reduction for large payloads

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/Azure/Orleans.Persistence.AzureStorage/Providers/Storage/AzureBlobStorageOptions.cs Adds new UsePooledBufferForReads boolean option with documentation warning about buffer retention
src/Azure/Orleans.Persistence.AzureStorage/Providers/Storage/AzureBlobStorage.cs Implements pooled buffer logic using DownloadStreamingAsync and ArrayPool<byte>.Shared when option is enabled
test/Extensions/TesterAzureUtils/Persistence/PersistenceGrainTests_AzureBlobStore_PooledReads.cs New test class verifying functionality with pooled reads enabled
test/Benchmarks/GrainStorage/AzureBlobReadStateBenchmark.cs New benchmark comparing pooled vs non-pooled read performance
test/Benchmarks/Program.cs Adds benchmark runner entry for the new Azure Blob read state benchmark
test/Benchmarks/run_test.cmd Updates script to run the new benchmark
test/Benchmarks/Properties/launchSettings.json Removes hardcoded launch profile

@egil egil force-pushed the pooled-azure-blob-storage-read branch from 7ec8b60 to 99a2f67 Compare January 14, 2026 17:04
@ReubenBond
Copy link
Copy Markdown
Member

I assume most of the remaining allocations are from the actual state object being loaded. Does that sound right? I also see an eTag string + the BinaryData object + the async state machine, but those are not worth going after at this point.

@egil
Copy link
Copy Markdown
Contributor Author

egil commented Jan 14, 2026

I assume most of the remaining allocations are from the actual state object being loaded. Does that sound right?

That is my assumption too.

I also see an eTag string + the BinaryData object + the async state machine, but those are not worth going after at this point.

Agreed. First stab at this is to reduce GC churn. Similar pattern may be applicable to other storage providers though, but I do feel like blob storage is more likely to have larger payloads than e.g. a SQL server.

Long term it would be great to do away with byte[] entirely and pass a stream or similar abstraction directly to the grain storage serializer, but that requires changing API surface.

@egil egil force-pushed the pooled-azure-blob-storage-read branch from 99a2f67 to b8fb3bc Compare January 14, 2026 18:04
@egil egil requested review from ReubenBond and Copilot January 14, 2026 23:57
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 6 comments.

@egil egil marked this pull request as draft January 15, 2026 11:00
@egil egil force-pushed the pooled-azure-blob-storage-read branch 2 times, most recently from e4321a9 to 370139e Compare January 15, 2026 22:26
@egil egil changed the title Azure blob reads: add optional pooled buffers for streaming downloads Azure Blob storage: pooled reads, streaming serializer, buffered writes Jan 15, 2026
@egil egil force-pushed the pooled-azure-blob-storage-read branch 3 times, most recently from cee520a to 7793d64 Compare January 15, 2026 23:36
@egil
Copy link
Copy Markdown
Contributor Author

egil commented Jan 15, 2026


BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.7623)
13th Gen Intel Core i7-13800H 2.90GHz, 1 CPU, 20 logical and 14 physical cores
.NET SDK 10.0.102
  [Host]     : .NET 10.0.2 (10.0.2, 10.0.225.61305), X64 RyuJIT x86-64-v3
  Job-WRIKEU : .NET 10.0.2 (10.0.2, 10.0.225.61305), X64 RyuJIT x86-64-v3

IterationCount=5  LaunchCount=1  WarmupCount=2  

Method PayloadSize SerializerKind Mean Error StdDev Ratio RatioSD Gen0 Gen1 Gen2 Allocated Alloc Ratio
ReadStateNonPooledAsync 4096 Orleans 1.255 ms 0.1456 ms 0.0378 ms 1.00 0.04 - - - 20.3 KB 1.00
ReadStatePooledAsync 4096 Orleans 1.204 ms 0.1723 ms 0.0447 ms 0.96 0.04 - - - 19.17 KB 0.94
ReadStateNonPooledAsync 4096 NewtonsoftJson 1.675 ms 1.0016 ms 0.1550 ms 1.01 0.12 - - - 44.66 KB 1.00
ReadStatePooledAsync 4096 NewtonsoftJson 1.729 ms 0.3535 ms 0.0547 ms 1.04 0.10 - - - 41.96 KB 0.94
ReadStateNonPooledAsync 4096 SystemTextJson 1.293 ms 0.5266 ms 0.0815 ms 1.00 0.08 - - - 24.88 KB 1.00
ReadStatePooledAsync 4096 SystemTextJson 1.308 ms 0.5689 ms 0.1478 ms 1.01 0.12 - - - 22.25 KB 0.89
ReadStateNonPooledAsync 65536 Orleans 2.637 ms 1.1559 ms 0.3002 ms 1.01 0.15 - - - 46.33 KB 1.00
ReadStatePooledAsync 65536 Orleans 1.378 ms 0.5652 ms 0.1468 ms 0.53 0.08 - - - 34.39 KB 0.74
ReadStateNonPooledAsync 65536 NewtonsoftJson 1.736 ms 0.2337 ms 0.0607 ms 1.00 0.04 10.0000 3.3333 3.3333 440.02 KB 1.00
ReadStatePooledAsync 65536 NewtonsoftJson 1.606 ms 0.3397 ms 0.0526 ms 0.93 0.04 6.6667 3.3333 3.3333 402.3 KB 0.91
ReadStateNonPooledAsync 65536 SystemTextJson 1.539 ms 0.1674 ms 0.0435 ms 1.00 0.04 - - - 119.34 KB 1.00
ReadStatePooledAsync 65536 SystemTextJson 1.992 ms 1.2706 ms 0.1966 ms 1.30 0.12 - - - 82.46 KB 0.69
ReadStateNonPooledAsync 131072 Orleans 1.438 ms 0.3246 ms 0.0843 ms 1.00 0.08 - - - 70.33 KB 1.00
ReadStatePooledAsync 131072 Orleans 1.460 ms 0.6371 ms 0.1655 ms 1.02 0.12 - - - 50.39 KB 0.72
ReadStateNonPooledAsync 131072 NewtonsoftJson 2.191 ms 0.6722 ms 0.1746 ms 1.01 0.10 16.0000 12.0000 12.0000 902.13 KB 1.00
ReadStatePooledAsync 131072 NewtonsoftJson 4.126 ms 5.3489 ms 1.3891 ms 1.89 0.60 15.0000 10.0000 10.0000 786.67 KB 0.87
ReadStateNonPooledAsync 131072 SystemTextJson 1.866 ms 0.8013 ms 0.2081 ms 1.01 0.15 3.3333 - - 271.33 KB 1.00
ReadStatePooledAsync 131072 SystemTextJson 2.052 ms 0.7261 ms 0.1886 ms 1.11 0.15 - - - 146.73 KB 0.54

@egil
Copy link
Copy Markdown
Contributor Author

egil commented Jan 15, 2026


BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.7623)
13th Gen Intel Core i7-13800H 2.90GHz, 1 CPU, 20 logical and 14 physical cores
.NET SDK 10.0.102
  [Host]     : .NET 10.0.2 (10.0.2, 10.0.225.61305), X64 RyuJIT x86-64-v3
  Job-WRIKEU : .NET 10.0.2 (10.0.2, 10.0.225.61305), X64 RyuJIT x86-64-v3

IterationCount=5  LaunchCount=1  WarmupCount=2  

Method PayloadSize SerializerKind Mean Error StdDev Median Ratio RatioSD Gen0 Gen1 Gen2 Allocated Alloc Ratio
ReadStateBinaryAsync 65536 Orleans 1.434 ms 0.8179 ms 0.2124 ms 1.402 ms 1.02 0.19 - - - 46.33 KB 1.00
ReadStateStreamAsync 65536 Orleans 1.370 ms 0.2190 ms 0.0569 ms 1.360 ms 0.97 0.13 - - - 34.85 KB 0.75
ReadStateBinaryAsync 65536 NewtonsoftJson 2.658 ms 3.7338 ms 0.9697 ms 2.411 ms 1.11 0.53 5.0000 - - 448.97 KB 1.00
ReadStateStreamAsync 65536 NewtonsoftJson 2.281 ms 3.0020 ms 0.7796 ms 1.808 ms 0.96 0.44 4.0000 - - 340.7 KB 0.76
ReadStateBinaryAsync 65536 SystemTextJson 2.520 ms 3.6914 ms 0.9586 ms 2.320 ms 1.12 0.54 - - - 122.21 KB 1.00
ReadStateStreamAsync 65536 SystemTextJson 1.826 ms 1.6279 ms 0.2519 ms 1.927 ms 0.81 0.28 - - - 83.08 KB 0.68
ReadStateBinaryAsync 131072 Orleans 1.467 ms 0.4653 ms 0.1208 ms 1.479 ms 1.01 0.11 - - - 70.33 KB 1.00
ReadStateStreamAsync 131072 Orleans 2.084 ms 3.2107 ms 0.8338 ms 1.637 ms 1.43 0.53 - - - 50.85 KB 0.72
ReadStateBinaryAsync 131072 NewtonsoftJson 2.390 ms 1.0338 ms 0.2685 ms 2.491 ms 1.01 0.15 16.0000 12.0000 12.0000 906.9 KB 1.00
ReadStateStreamAsync 131072 NewtonsoftJson 2.045 ms 0.4543 ms 0.1180 ms 2.086 ms 0.87 0.11 16.0000 8.0000 8.0000 2041.11 KB 2.25
ReadStateBinaryAsync 131072 SystemTextJson 1.724 ms 0.8376 ms 0.2175 ms 1.717 ms 1.01 0.16 3.3333 - - 278.56 KB 1.00
ReadStateStreamAsync 131072 SystemTextJson 1.712 ms 0.5361 ms 0.1392 ms 1.713 ms 1.01 0.14 - - - 147.42 KB 0.53

@egil
Copy link
Copy Markdown
Contributor Author

egil commented Jan 15, 2026


BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.7623)
13th Gen Intel Core i7-13800H 2.90GHz, 1 CPU, 20 logical and 14 physical cores
.NET SDK 10.0.102
  [Host]     : .NET 10.0.2 (10.0.2, 10.0.225.61305), X64 RyuJIT x86-64-v3
  Job-WRIKEU : .NET 10.0.2 (10.0.2, 10.0.225.61305), X64 RyuJIT x86-64-v3

IterationCount=5  LaunchCount=1  WarmupCount=2  

Method PayloadSize SerializerKind Mean Error StdDev Ratio RatioSD Allocated Alloc Ratio
WriteStateBinaryAsync 65536 Orleans 3.265 ms 0.9563 ms 0.2484 ms 1.00 0.10 43.99 KB 1.00
WriteStateStreamAsync 65536 Orleans 3.303 ms 1.2792 ms 0.1980 ms 1.02 0.09 20.53 KB 0.47
WriteStateBinaryAsync 65536 NewtonsoftJson 5.659 ms 1.1809 ms 0.1827 ms 1.00 0.04 197 KB 1.00
WriteStateStreamAsync 65536 NewtonsoftJson 3.688 ms 0.5972 ms 0.1551 ms 0.65 0.03 27.68 KB 0.14
WriteStateBinaryAsync 65536 SystemTextJson 3.670 ms 1.1722 ms 0.1814 ms 1.00 0.06 51.52 KB 1.00
WriteStateStreamAsync 65536 SystemTextJson 3.744 ms 0.7988 ms 0.2075 ms 1.02 0.07 21.64 KB 0.42
WriteStateBinaryAsync 131072 Orleans 3.621 ms 1.4803 ms 0.3844 ms 1.01 0.14 67.96 KB 1.00
WriteStateStreamAsync 131072 Orleans 3.891 ms 1.2095 ms 0.1872 ms 1.08 0.11 20.54 KB 0.30
WriteStateBinaryAsync 131072 NewtonsoftJson 7.757 ms 6.4783 ms 1.0025 ms 1.01 0.17 361.02 KB 1.00
WriteStateStreamAsync 131072 NewtonsoftJson 6.566 ms 1.5394 ms 0.2382 ms 0.86 0.11 27.67 KB 0.08
WriteStateBinaryAsync 131072 SystemTextJson 5.252 ms 4.9974 ms 0.7734 ms 1.02 0.21 83.52 KB 1.00
WriteStateStreamAsync 131072 SystemTextJson 6.462 ms 5.5679 ms 1.4460 ms 1.25 0.32 22.11 KB 0.26

@egil egil marked this pull request as ready for review January 15, 2026 23:40
@egil egil requested a review from Copilot January 15, 2026 23:40
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 7 comments.

@egil egil force-pushed the pooled-azure-blob-storage-read branch from 7793d64 to 59a6eac Compare January 16, 2026 09:16
@egil
Copy link
Copy Markdown
Contributor Author

egil commented Jan 16, 2026

Not sure why this test is failing, and if it has anything to do with the changes on this PR:

  Failed UnitTests.General.AllowCallChainReentrancyTests.CallChainReentrancy_WithSuppression [2 m]
  Error Message:
   System.OperationCanceledException : The operation was canceled.
  Stack Trace:
     at System.Threading.Channels.AsyncOperation`1.GetResult(Int16 token)
   at UnitTests.General.CallChainReentrancyTestHelper.CallChainObserver.WaitForOperationAsync(CallChainOperation operationType, String grain, Int32 callIndex, CancellationToken cancellationToken) in /_/test/TesterInternal/MessageScheduling/CallChainReentrancyTestHelper.cs:line 208
   at UnitTests.General.CallChainReentrancyTestHelper.CallChainReentrancy_WithSuppression() in /_/test/TesterInternal/MessageScheduling/CallChainReentrancyTestHelper.cs:line 175
   at UnitTests.General.AllowCallChainReentrancyTests.CallChainReentrancy_WithSuppression() in /_/test/TesterInternal/MessageScheduling/AllowCallChainReentrancyTests.cs:line 75
--- End of stack trace from previous location ---

/// <summary>
/// Optional stream-based serializer for grain state.
/// </summary>
public interface IGrainStorageStreamingSerializer
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@ReubenBond said on Discord he prefers something like this for the abstraction.

/// <summary>
/// Optional stream-based serializer for grain state.
/// </summary>
public interface IGrainStorageStreamingSerializer
{
    /// <summary>
    /// Serializes the object input to a stream.
    /// </summary>
    /// <param name="input">The object to serialize.</param>
    /// <param name="destination">The destination buffer writer.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <typeparam name="T">The input type.</typeparam>
    ValueTask SerializeAsync<T>(T input, IBufferWriter<byte> destination, CancellationToken cancellationToken = default);

    /// <summary>
    /// Deserializes the provided data from a stream.
    /// </summary>
    /// <param name="input">The input byte sequence.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <typeparam name="T">The output type.</typeparam>
    /// <returns>The deserialized object, or null.</returns>
    ValueTask<T?> DeserializeAsync<T>(ReadOnlySequence<byte> input, CancellationToken cancellationToken = default);
}

For the data providers where Stream is native, we can then include helper extensions method that map to/from IBufferWriter<byte> and ReadOnlySequence<byte>

Copy link
Copy Markdown
Contributor Author

@egil egil Jan 17, 2026

Choose a reason for hiding this comment

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

For serialization, we can use ready build adapters in Orleans:

public static class GrainStorageStreamingSerializerExtensions
{
    /// <summary>
    /// Serializes the object input to a stream.
    /// </summary>
    /// <param name="input">The object to serialize.</param>
    /// <param name="destination">The destination stream.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <typeparam name="T">The input type.</typeparam>
    public static ValueTask SerializeAsync<T>(this IGrainStorageStreamingSerializer serializer, T input, Stream destination, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(serializer);
        ArgumentNullException.ThrowIfNull(destination);

        if (destination is MemoryStream memoryStream)
        {
            return serializer.SerializeAsync(input, new MemoryStreamBufferWriter(memoryStream), cancellationToken);
        }
        else
        {
            return serializer.SerializeAsync(input, new ArrayStreamBufferWriter(destination), cancellationToken);
        }
    }
}

For DeserializeAsync, I would love a suggestion for how to adapt a Stream to ReadOnlySequence<byte> without loading all data into memory first. Suggestions?

Copy link
Copy Markdown

@SilentBlueD666 SilentBlueD666 Jan 17, 2026

Choose a reason for hiding this comment

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

The naming feels off, now you've switched from stream, maybe change to IGrainStateBufferSerializer or something.

My gut is telling me, drop the ValueTask, by this point all IO should be done, and Task/Await is just overhead 99% of the time, unless I am missing why you would need...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The naming feels off, now you've switched from stream, maybe change to IGrainStateBufferSerializer or something.

My gut is telling to drop the ValueTask, by this point all IO should be done, and Task/Await is just overhead 99% of the time, unless I am missing why you would need...

I hope IO is not done at this point, no, since that means data has been loaded into memory, which was the main problem I was trying to avoid. With large datasets, e.g., blobs, that leads to more GC churn. So there need to be support for streaming data from blob storage to the serializer and then to objects.

Calling the interface IGrainStateBufferSerializer is fine though, no objections there.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I was thinking about the IO and buffering in the serializer implementations (JsonGrainStorageSerializer, OrleansGrainStorageSerializer) and how it flows into AzureBlobGrainStorage.

Note: overhead here is tiny — pure micro-optimization territory!

The overall solution I was getting at earlier on Discord before I dropped off 😅 has been implemented at the calling level (in the provider) rather than inside the serializer.

// In AzureBlobGrainStorage.UploadSerializedStateBufferedAsync<T>
var bufferStream = PooledBufferStream.Rent();
try
{
    // Serialize: sync write to the pooled stream (no real await needed inside most impls)
    await streamSerializer.SerializeAsync(value, bufferStream).ConfigureAwait(false);
    
    bufferStream.Position = 0;
    
    // Actual IO: upload from the pooled stream
    return await blob.UploadAsync(bufferStream, options).ConfigureAwait(false);
}
finally
{
    PooledBufferStream.Return(bufferStream);
}

In JsonGrainStorageSerializer.SerializeAsync (similar for Orleans serializer):

public ValueTask SerializeAsync<T>(T value, Stream destination, CancellationToken ct = default)
{
    ct.ThrowIfCancellationRequested();
    _orleansJsonSerializer.Serialize(value, typeof(T), destination);  // sync write
    return ValueTask.CompletedTask;  // no suspension
}

The await on serialize is still basically zero-cost (completed ValueTask), but unnecessary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ahh I see. If there is no need for asynchrony in the serializer implementations, then dropping ValueTask is fine. Looks like there are no asynchronous methods on ReadOnlySequence nor on IBufferWriter, so probably a good indicator ValueTask is not needed?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Yep 😁

public bool UsePooledBufferForReads { get; set; } = true;

/// <summary>
/// Gets or sets the write path to use when a stream serializer is available.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

write path -> write mode

egil added 2 commits January 22, 2026 10:18
Introduce IGrainStorageStreamingSerializer and stream overloads for OrleansJsonSerializer plus Orleans/Json grain storage serializers. Azure blob storage now supports pooled read buffers, a buffered stream write mode, and logs a warning when large payloads force a fallback to DownloadContentAsync.

Add Azure Blob storage tests for pooled reads and streaming serializer behavior, and add focused grain storage benchmarks (binary vs streaming) across Orleans, Newtonsoft.Json, and STJ.

Alternatives considered: full streaming OpenWriteAsync with separate ETag readback (rejected due to concurrency race), and IBufferWriter/ReadOnlySequence buffer paths (explored, but didnt improve performance or allocation in a meaningful way, so but dropped for now). Buffered stream uploads and pooled reads were kept as the best allocation/compatibility tradeoff while keeping BinaryData writes as the default.
@egil egil force-pushed the pooled-azure-blob-storage-read branch from 77c0d03 to 485d518 Compare January 22, 2026 10:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants