Skip to content

Add APIs to BlobBuilder for customizing the underlying byte array et al.#115294

Open
teo-tsirpanis wants to merge 32 commits intodotnet:mainfrom
teo-tsirpanis:srm-buffers
Open

Add APIs to BlobBuilder for customizing the underlying byte array et al.#115294
teo-tsirpanis wants to merge 32 commits intodotnet:mainfrom
teo-tsirpanis:srm-buffers

Conversation

@teo-tsirpanis
Copy link
Contributor

@teo-tsirpanis teo-tsirpanis commented May 5, 2025

Fixes #99244
Fixes #100418

This PR builds on top of @jaredpar's branch to add APIs for customizing the underlying buffer of a BlobBuilder. The chunking logic of BlobBuilder was updated to allocate multiple additional chunks with a user-customizable maximum size each. As part of this, we use APIs from System.Text.Unicode.Utf8 to encode UTF-8 strings, which increases performance and safety, and reduces duplicate code.

Copilot AI review requested due to automatic review settings May 5, 2025 02:04
@ghost
Copy link

ghost commented May 5, 2025

Note regarding the new-api-needs-documentation label:

This serves as a reminder for when your PR is modifying a ref *.cs file and adding/modifying public APIs, please make sure the API implementation in the src *.cs file is documented with triple slash comments, so the PR reviewers can sign off that change.

1 similar comment
@ghost
Copy link

ghost commented May 5, 2025

Note regarding the new-api-needs-documentation label:

This serves as a reminder for when your PR is modifying a ref *.cs file and adding/modifying public APIs, please make sure the API implementation in the src *.cs file is documented with triple slash comments, so the PR reviewers can sign off that change.

@teo-tsirpanis teo-tsirpanis marked this pull request as draft May 5, 2025 02:04
@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label May 5, 2025
Copy link
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 adds new APIs to BlobBuilder for customizing the underlying byte array and updates related encoding and buffer-handling logic across metadata and core libraries. Key changes include replacing legacy UTF-8 encoding code with calls to the new System.Text.Unicode.Utf8 APIs, updating BlobBuilder’s API surface (including new constructors and properties), and adding NET-specific intrinsics support across several core modules.

Reviewed Changes

Copilot reviewed 31 out of 36 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
System/Reflection/Internal/Utilities/StreamExtensions.cs Removed obsolete TryReadAll overload for Span to rely on newer API paths.
System/Reflection/Internal/Utilities/BlobUtilities.cs Rewrote WriteUtf8 to use Utf8.FromUtf16 for encoding, replacing manual UTF-8 encoding logic.
System/Reflection/Metadata.cs Added new BlobBuilder constructors, properties, and APIs including ReadOnlySpan/WriteBytes overloads.
System.Private.CoreLib (various files) Updated intrinsics and preprocessor conditions (#if NET, #if SYSTEM_PRIVATE_CORELIB) for newer vectorized and ASCII helper routines.
Microsoft.Bcl.Memory (PACKAGE.md and others) Updated documentation and type forwarding to include UTF-8 APIs for NET platforms.
Files not reviewed (5)
  • src/libraries/Microsoft.Bcl.Memory/src/Microsoft.Bcl.Memory.csproj: Language not supported
  • src/libraries/Microsoft.Bcl.Memory/tests/Microsoft.Bcl.Memory.Tests.csproj: Language not supported
  • src/libraries/System.Reflection.Metadata/System.Reflection.Metadata.sln: Language not supported
  • src/libraries/System.Reflection.Metadata/src/Resources/Strings.resx: Language not supported
  • src/libraries/System.Reflection.Metadata/src/System.Reflection.Metadata.csproj: Language not supported

@jkotas
Copy link
Member

jkotas commented May 5, 2025

As part of this, we use APIs from System.Text.Unicode.Utf8 to encode UTF-8 strings, which increases performance and safety, and reduces duplicate code.

Is this needed to introduce the new APIs?

System.Text.Unicode.Utf8 change introduces a new dependency for System.Reflection.Metadata on .NET Framework that will be an extra work to push through the system. It would be better to avoid bundling the two changes together in a single PR.

@am11
Copy link
Member

am11 commented May 5, 2025

As part of this, we use APIs from System.Text.Unicode.Utf8 to encode UTF-8 strings, which increases performance and safety, and reduces duplicate code.

Is this needed to introduce the new APIs?

The other PR is approved and ready to merge #111292. After the merge and rebase this branch against main, those commits will disappear.

@jkotas
Copy link
Member

jkotas commented May 5, 2025

The other PR is approved and ready to merge #111292. After the merge and rebase this branch against main, those commits will disappear.

The change that introduces System.Reflection.Metadata dependency on Microsoft.Bcl.Memory won't disappear.

@teo-tsirpanis
Copy link
Contributor Author

Switched back to the old WriteUTF8 implementation for downlevel frameworks, which was rewritten to use spans and remove unsafe code. On modern .NET we still use System.Text.Unicode.Utf8.

Tests pass locally. This is ready for review.

@teo-tsirpanis teo-tsirpanis marked this pull request as ready for review May 10, 2025 11:39
@teo-tsirpanis teo-tsirpanis requested a review from steveharter May 10, 2025 11:39
@jkotas
Copy link
Member

jkotas commented May 10, 2025

WriteUTF8 implementation for downlevel frameworks, which was rewritten to use spans and remove unsafe code

What''s the performance regression introduced by this rewrite on .NET Framework?

Our primary interest in removing unsafe code is on latest .NET. It is fine to keep unsafe code for .NET Framework if it is required for decent performance.

@teo-tsirpanis
Copy link
Contributor Author

I updated the function to use unsafe code and wrote a benchmark to compare it with my initial safe edition. We cannot compare it with the existing unsafe implementation since the functions don't have the same signature and semantics. The numbers look promising so I switched to the unsafe edition:


BenchmarkDotNet v0.14.0, Windows 10 (10.0.19045.5796/22H2/2022Update)
Intel Core i7-7700HQ CPU 2.80GHz (Kaby Lake), 1 CPU, 8 logical and 4 physical cores
  [Host]     : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256
  DefaultJob : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256


Method N Mean Error StdDev Ratio RatioSD
Safe 16 276.9 ns 5.38 ns 4.77 ns 1.00 0.02
Unsafe 16 173.1 ns 3.05 ns 2.85 ns 0.63 0.01
Safe 128 1,946.7 ns 35.89 ns 31.81 ns 1.00 0.02
Unsafe 128 1,210.2 ns 17.84 ns 15.81 ns 0.62 0.01
Benchmark code
// See https://aka.ms/new-console-template for more information
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.InteropServices;
using System.Text;

BenchmarkRunner.Run<Utf8Bench>();

public class Utf8Bench
{
    public string TestString = null!;

    public byte[] TestBytes = new byte[2048];

    [Params(16, 128)]
    public int N { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        var sb = new StringBuilder();
        for (int i = 0; i < N; i++)
        {
            sb.Append('a');
        }
        for (int i = 0; i < N; i++)
        {
            sb.Append('Θ');
        }
        for (int i = 0; i < N; i++)
        {
            sb.Append("😂");
        }
        TestString = sb.ToString();
        TestBytes = new byte[2048];
    }

    [Benchmark(Baseline = true)]
    public int Safe()
    {
        WriteUtf8Safe(TestString.AsSpan(), TestBytes, out int charsRead, out int bytesWritten, true);
        return charsRead + bytesWritten;
    }

    [Benchmark]
    public int Unsafe()
    {
        WriteUtf8Unsafe(TestString.AsSpan(), TestBytes, out int charsRead, out int bytesWritten, true);
        return charsRead + bytesWritten;
    }

    public static void WriteUtf8Safe(ReadOnlySpan<char> source, Span<byte> destination, out int charsRead, out int bytesWritten, bool allowUnpairedSurrogates)
    {
        // Copy from PR
    }

    public static unsafe void WriteUtf8Unsafe(ReadOnlySpan<char> source, Span<byte> destination, out int charsRead, out int bytesWritten, bool allowUnpairedSurrogates)
    {
        // Copy from PR
    }
}

Comment on lines +205 to +214
/// <summary>
/// Changes the size of the byte array underpinning the <see cref="BlobBuilder"/>.
/// Derived types can override this method to control the allocation strategy.
/// </summary>
/// <param name="capacity">The array's new size.</param>
/// <seealso cref="Capacity"/>
protected virtual void SetCapacity(int capacity)
{
Array.Resize(ref _buffer, Math.Max(MinChunkSize, capacity));
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't understand how subclasses are supposed to override this. They will reassign the Buffer property, but according to a comment in #100418, reassigning Buffer clears the head chunk.

What are the semantics? Should Capacity's setter have additional logic?

Copy link
Member

Choose a reason for hiding this comment

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

cc: @jaredpar

@jeffhandley
Copy link
Member

We are trying to minimize our use of unsafe throughout, so I expect we'd want to avoid bringing that in here. @jkotas @AaronRobinsonMSFT can you advise on this PR for whether we should pursue getting it into approvable state for .NET 11?

Copy link
Member

@AaronRobinsonMSFT AaronRobinsonMSFT left a comment

Choose a reason for hiding this comment

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

Nothing jumps out to me as being the reason for the slowdown. @teo-tsirpanis Have you tried running this under a profiler and seeing where the hot spots are?

bytesWritten = destinationLength - destination.Length;
}
#else
public static void WriteUtf8(ReadOnlySpan<char> source, Span<byte> destination, out int charsRead, out int bytesWritten, bool allowUnpairedSurrogates)
Copy link
Member

Choose a reason for hiding this comment

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

Does this need to change at all? If this is for .NET Framework, I would just leave it as-is. There is little benefit to changing anything due to possibility of regressions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. The method's old signature was harder to work with; it uses pointers, does not clearly state which buffer is read-only, and requires pre-computing the UTF-8 byte count.

I had to refactor SRM's UTF-8 encoder1 in order to improve code reusability and memory safety in modern frameworks, and take advantage of the System.Text.Unicode.Utf8 APIs. Originally, I tried adding a reference to the Microsoft.Bcl.Memory package — which has a polyfill to Utf8, and this method had a single implementation. Adding the extra dependency to SRM however is not going to be simple, hence the custom code's re-introduction.

Another idea if we want to avoid the extra package dependency, is to vendor the sources of Utf8 to SRM.

Footnotes

  1. which has unique requirements, because it is essentially a WTF-8 decoder

@jkotas jkotas added the tenet-performance Performance related issue label Sep 2, 2025
@jkotas
Copy link
Member

jkotas commented Sep 2, 2025

We are trying to minimize our use of unsafe throughout, so I expect we'd want to avoid bringing that in here.

The unsafe code is used in .NET Framework polyfills for the most part. There is not much we can do about that without significantly regressing performance of this library on .NET Framework.

These APIs are expected to improve Roslyn performance once Roslyn switches over to use them. Like with any performance related change, I would like to see some numbers that show (1) the refactoring required by these APIs is not regressing Roslyn performance and (2) switching Roslyn to use these APIs is improving Roslyn performance.

@jeffhandley
Copy link
Member

@teo-tsirpanis This PR is awaiting action for collecting performance benchmarks and comparing the results.

@jeffhandley jeffhandley added the needs-author-action An issue or pull request that requires more info or actions from the author. label Jan 26, 2026
@teo-tsirpanis
Copy link
Contributor Author

(1) the refactoring required by these APIs is not regressing Roslyn performance

The most significant refactoring is the change to the UTF-8 encoder implementation. For modern .NET, I expect it to be faster, since it's using inbox APIs that are vectorized. For .NET Framework, if we end up with the rewritten unsafe implementation, I don't expect performance to differ very much, since the implementations are quite similar (https://www.diffchecker.com/VMVUZR1g/). It should also be noted that Roslyn in Visual Studio no longer runs on .NET Framework by default (dotnet/roslyn#77914).

(2) switching Roslyn to use these APIs is improving Roslyn performance

In #100418, @jaredpar said:

Using the [new APIs] I've been able to significantly improve the allocation profile of VBCSCompiler. For building a solution the scale of compilers.slnf (~500 compilation events, large, small and medium projects) I've been able to remove ~200MB of LOH for byte[] and reduce GC pause time by 1.5%.

@dotnet-policy-service dotnet-policy-service bot removed the needs-author-action An issue or pull request that requires more info or actions from the author. label Jan 26, 2026
@jkotas
Copy link
Member

jkotas commented Jan 26, 2026

I would like to see the numbers for the implementation in this PR and including walk clock time.

@agocke
Copy link
Member

agocke commented Jan 29, 2026

It sounds like there's an assumption that we need to bring this new API surface to the .NET Framework polyfill. Why is that the case?

@jkotas
Copy link
Member

jkotas commented Jan 29, 2026

It sounds like there's an assumption that we need to bring this new API surface to the .NET Framework polyfill. Why is that the case?

This is a question for Roslyn (@jaredpar). These APIs are designed to optimize Roslyn. If Roslyn is fine with missing these APIs on netfx, we can certainly drop them there.

@teo-tsirpanis
Copy link
Contributor Author

I would like to see the numbers for the implementation in this PR and including walk clock time.

I wrote a benchmark of BlobBuilder.WriteUTF8 similar to my previous one, comparing between SRM from NuGet and a local build of this branch, running on .NET Framework and .NET 10. I also included writing ASCII-only strings, which is what the overwhelming majority of metadata identifiers are going to be in a compilation.

.NET Framework


BenchmarkDotNet v0.15.8, Windows 10 (10.0.19045.6456/22H2/2022Update)
Intel Core i7-7700HQ CPU 2.80GHz (Max: 2.81GHz) (Kaby Lake), 1 CPU, 8 logical and 4 physical cores
  [Host]     : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256
  DefaultJob : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256


Method AsciiOnly N Mean Error StdDev
Write_Before False 16 222.5 ns 3.81 ns 3.38 ns
Write_Before True 16 164.9 ns 0.71 ns 0.67 ns
Write_Before False 128 1,474.0 ns 7.12 ns 6.31 ns
Write_Before True 128 993.3 ns 5.79 ns 5.13 ns
Write_After False 16 219.7 ns 1.62 ns 1.26 ns
Write_After True 16 148.4 ns 1.09 ns 0.91 ns
Write_After False 128 1,291.5 ns 6.24 ns 5.53 ns
Write_After True 128 698.3 ns 4.26 ns 3.99 ns

.NET 10


BenchmarkDotNet v0.15.8, Windows 10 (10.0.19045.6456/22H2/2022Update)
Intel Core i7-7700HQ CPU 2.80GHz (Max: 2.81GHz) (Kaby Lake), 1 CPU, 8 logical and 4 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=InProcess  Toolchain=InProcessEmitToolchain  

Method AsciiOnly N Mean Error StdDev
Write_Before False 16 154.05 ns 1.515 ns 1.265 ns
Write_Before True 16 65.35 ns 0.233 ns 0.206 ns
Write_Before False 128 1,159.08 ns 7.941 ns 7.428 ns
Write_Before True 128 467.17 ns 2.684 ns 2.241 ns
Write_After False 16 85.51 ns 1.687 ns 2.675 ns
Write_After True 16 19.53 ns 0.146 ns 0.114 ns
Write_After False 128 532.97 ns 7.474 ns 6.991 ns
Write_After True 128 28.57 ns 0.308 ns 0.257 ns

Sources are attached. I manually changed the TFM, the UseStockSrm property, and the path to the local SRM assembly, and concatenated the results into the tables above.

Copilot AI review requested due to automatic review settings February 1, 2026 19:36
Copy link
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 6 comments.

Comment on lines +122 to +136
set
{
if (value is null)
{
Throw.ArgumentNull(nameof(value));
}

if (!IsHead)
{
Throw.InvalidOperationBuilderAlreadyLinked();
}

_buffer = value;
_length = 0;
}
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

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

The Buffer property setter doesn't validate that the new buffer is at least MinChunkSize (16 bytes) long. This validation is performed in the constructor that accepts a buffer parameter, but not in the property setter. If a derived type sets Buffer to an array smaller than 16 bytes, subsequent operations may fail or cause undefined behavior.

Copilot uses AI. Check for mistakes.
/// </summary>
/// <remarks>
/// This can only be called on the head of a chain of <see cref="BlobBuilder"/> instances.
/// Calling the setter will reset the <see cref="Length"/> to zero.
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

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

The XML documentation comment states "Calling the setter will reset the to zero" but the setter actually resets _length directly, not Length. While this is correct behavior (since Length is a property that derives its value from _length), the documentation would be clearer if it said "Calling the setter will reset the builder's length to zero" to avoid confusion about which property is being modified.

Suggested change
/// Calling the setter will reset the <see cref="Length"/> to zero.
/// Calling the setter will reset the builder's length to zero.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I prefer it this way.

Comment on lines +164 to +166
if (value < Length)
{
Throw.ArgumentOutOfRange(nameof(value));
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

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

The Capacity setter checks if the new value is less than Length (the current chunk's length) rather than Count (the total length including all previous chunks). This allows setting Capacity to a value that's greater than or equal to Length but less than Count, which would lose data from previous chunks. The check should be if (value < Count) instead of if (value < Length) to properly validate that the new capacity can hold all existing data.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 16, 2026 00:53
Copy link
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 1 comment.

@@ -789,7 +934,8 @@ public void WriteBytes(byte[] buffer, int start, int byteCount)
WriteBytes(buffer.AsSpan(start, byteCount));
}

Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

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

The newly added public WriteBytes(ReadOnlySpan) method is missing XML documentation. All other public WriteBytes overloads in this file have summary documentation. For consistency and to help API consumers understand the method's purpose and behavior, add a summary comment similar to the one on WriteBytes(byte[]) but clarifying that this method writes the contents of a ReadOnlySpan<byte>.

Suggested change
/// <summary>
/// Writes the contents of the specified read-only span of bytes to the builder.
/// </summary>
/// <param name="buffer">The read-only span whose contents to write.</param>

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Reflection.Metadata community-contribution Indicates that the PR has been added by a community member new-api-needs-documentation tenet-performance Performance related issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[API Proposal]: Extend BlobBuilder so consumers can better control allocations BlobBuilder.ReserveBytes needs to zero the memory

9 participants