Skip to content

Use direct UTF-8 paths for Char parsing and short string decoding - #135309

Closed
EgorBo wants to merge 1 commit into
dotnet:mainfrom
EgorBo:perf/utf8-short-inputs
Closed

EgorBo wants to merge 1 commit into
dotnet:mainfrom
EgorBo:perf/utf8-short-inputs

Conversation

@EgorBo

@EgorBo EgorBo commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Route generic numeric UTF-8 Char parsing through the existing strict Rune-based parser. Reuse the sealed UTF-8 encoding's existing one-pass short-input decoder for span and validated array-slice GetString calls. Keep subclasses, custom encodings, and large inputs on their existing paths.

Benchmark

using System;
using System.Globalization;
using System.Numerics;
using System.Text;
using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
public class CharParsingBench
{
    private byte[] _input = null!;

    [Params("A", "\u00E9")]
    public string Text { get; set; } = null!;

    [GlobalSetup]
    public void Setup() => _input = Encoding.UTF8.GetBytes(Text);

    [Benchmark]
    public char NumericUtf8Char() => Parse<char>(_input);

    private static T Parse<T>(ReadOnlySpan<byte> input) where T : struct, INumberBase<T>
    {
        if (!T.TryParse(input, NumberStyles.Integer, CultureInfo.InvariantCulture, out T result))
        {
            throw new FormatException();
        }

        return result;
    }
}

[MemoryDiagnoser]
public class ShortUtf8Bench
{
    private byte[] _input = null!;

    [Params("Content-Length: ", "Gr\u00FC\u00DFe\u4E16\u754C")]
    public string Text { get; set; } = null!;

    [GlobalSetup]
    public void Setup() => _input = Encoding.UTF8.GetBytes(Text);

    [Benchmark]
    public string SpanGetString() => Encoding.UTF8.GetString(_input.AsSpan());

    [Benchmark]
    public string SliceGetString() => Encoding.UTF8.GetString(_input, 0, _input.Length);
}

Results from EgorBot/Benchmarks#652, comparing main against this PR. BenchmarkDotNet 0.16.0-preview.1; ARM64: Apple M4 / macOS 15.7.5; x64: AMD EPYC 9V45 / Ubuntu 24.04.4.

Method Input ARM64 main (ns) ARM64 PR (ns) Speedup x64 main (ns) x64 PR (ns) Speedup
NumericUtf8Char "A" 3.7331 0.7594 4.92x 4.9432 0.9233 5.35x
NumericUtf8Char "é" 4.5533 1.2688 3.59x 5.6743 1.3445 4.22x
SpanGetString "Content-Length: " 7.950 7.684 1.03x 9.383 8.307 1.13x
SliceGetString "Content-Length: " 8.767 8.052 1.09x 10.285 9.263 1.11x
SpanGetString "Grüße世界" 13.913 10.595 1.31x 19.876 12.137 1.64x
SliceGetString "Grüße世界" 15.180 11.453 1.33x 20.389 12.853 1.59x

Speedup is main / PR. Char parsing is 3.59-5.35x faster; short-string decoding is 1.03-1.64x faster. Allocations are unchanged on both architectures: 0 B for Char parsing, 56 B for "Content-Length: ", and 40 B for "Grüße世界".

Route generic numeric UTF-8 Char parsing through the existing strict Rune-based parser. Reuse the sealed UTF-8 encoding's existing one-pass short-input decoder for span and validated array-slice GetString calls. Keep subclasses, custom encodings, and large inputs on their existing paths.

BenchmarkDotNet 0.16.0-preview.1; Release CoreRun (.NET 12.0.0-dev), Windows x64, AMD Ryzen 9 7950X. Workstation GC (DOTNET_gcServer=0), one launch, three warmups, eight 250ms iterations. Old is the common base c0c1c33; new is the retained-candidate build measured locally.

Benchmark:

```csharp
using System;
using System.Globalization;
using System.IO;
using System.Net;
using System.Numerics;
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(CharParsingBench).Assembly).Run(args);

[MemoryDiagnoser]
public class CharParsingBench
{
    private byte[] _input = null!;

    [Params("A", "\u00E9")]
    public string Text { get; set; } = null!;

    [GlobalSetup]
    public void Setup() => _input = Encoding.UTF8.GetBytes(Text);

    [Benchmark]
    public char NumericUtf8Char() => Parse<char>(_input);

    private static T Parse<T>(ReadOnlySpan<byte> input) where T : struct, INumberBase<T>
    {
        if (!T.TryParse(input, NumberStyles.Integer, CultureInfo.InvariantCulture, out T result))
        {
            throw new FormatException();
        }

        return result;
    }
}

[MemoryDiagnoser]
public class ShortUtf8Bench
{
    private byte[] _input = null!;

    [Params("Content-Length: ", "Gr\u00FC\u00DFe\u4E16\u754C")]
    public string Text { get; set; } = null!;

    [GlobalSetup]
    public void Setup() => _input = Encoding.UTF8.GetBytes(Text);

    [Benchmark]
    public string SpanGetString() => Encoding.UTF8.GetString(_input.AsSpan());

    [Benchmark]
    public string SliceGetString() => Encoding.UTF8.GetString(_input, 0, _input.Length);
}
```

Results:

| Benchmark | Input | Old (ns/op) | New (ns/op) | Old allocation | New allocation |
|---|---|---:|---:|---:|---:|
| NumericUtf8Char | A | 5.978 | 1.03 | 0 B | 0 B |
| NumericUtf8Char | \xe9 | 6.741 | 1.399 | 0 B | 0 B |
| SliceGetString | Content-Length:  | 11.012 | 10.061 | 56 B | 56 B |
| SliceGetString | Gr\xfc\xdfe\u4e16\u754c | 19.866 | 15.155 | 40 B | 40 B |
| SpanGetString | Content-Length:  | 9.92 | 9.822 | 56 B | 56 B |
| SpanGetString | Gr\xfc\xdfe\u4e16\u754c | 18.404 | 12.724 | 40 B | 40 B |

ASCII span decoding is unchanged within noise; short non-ASCII inputs improve.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9b1b2a80-4c3e-4179-adf6-89ba090b40d5
@EgorBo

This comment was marked as resolved.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-text-encoding
See info in area-owners.md if you want to be subscribed.

@EgorBo

EgorBo commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@EgorBot -arm -amd

using System;
using System.Globalization;
using System.IO;
using System.Net;
using System.Numerics;
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]
public class CharParsingBench
{
    private byte[] _input = null!;

    [Params("A", "\u00E9")]
    public string Text { get; set; } = null!;

    [GlobalSetup]
    public void Setup() => _input = Encoding.UTF8.GetBytes(Text);

    [Benchmark]
    public char NumericUtf8Char() => Parse<char>(_input);

    private static T Parse<T>(ReadOnlySpan<byte> input) where T : struct, INumberBase<T>
    {
        if (!T.TryParse(input, NumberStyles.Integer, CultureInfo.InvariantCulture, out T result))
        {
            throw new FormatException();
        }

        return result;
    }
}

[MemoryDiagnoser]
public class ShortUtf8Bench
{
    private byte[] _input = null!;

    [Params("Content-Length: ", "Gr\u00FC\u00DFe\u4E16\u754C")]
    public string Text { get; set; } = null!;

    [GlobalSetup]
    public void Setup() => _input = Encoding.UTF8.GetBytes(Text);

    [Benchmark]
    public string SpanGetString() => Encoding.UTF8.GetString(_input.AsSpan());

    [Benchmark]
    public string SliceGetString() => Encoding.UTF8.GetString(_input, 0, _input.Length);
}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

The new paths need targeted tests, and both conditions unnecessarily type-check inputs that exceed the fast-path threshold.

Review effort: Balanced
Findings: 1 Medium severity · 2 Low severity

Open (3)
What changed in this PR

Optimizes UTF-8 character parsing and short-string decoding by reusing existing direct parsers and decoders.

Changes:

  • Routes generic char UTF-8 parsing directly through Rune.
  • Reuses the sealed UTF-8 short-input decoder for span and array-slice calls.
  • Preserves existing paths for subclasses and larger inputs.
File Description
Char.cs Adds direct generic UTF-8 char parsing.
Encoding.cs Adds the short-span UTF-8 fast path.
UTF8Encoding.cs Adds the short array-slice fast path.
UTF8Encoding.Sealed.cs Generalizes the existing short-input decoder to spans.


static bool INumberBase<char>.TryParse(ReadOnlySpan<char> s, NumberStyles style, IFormatProvider? provider, out char result) => TryParse(s, out result);

static bool INumberBase<char>.TryParse(ReadOnlySpan<byte> utf8Text, NumberStyles style, IFormatProvider? provider, out char result) => TryParseUtf8(utf8Text, out result);

public unsafe string GetString(ReadOnlySpan<byte> bytes)
{
if (this is UTF8Encoding.UTF8EncodingSealed utf8Encoding && bytes.Length <= UTF8Encoding.UTF8EncodingSealed.MaxSmallInputElementCount)
if (count == 0)
return string.Empty;

if (this is UTF8EncodingSealed utf8Encoding && count <= UTF8EncodingSealed.MaxSmallInputElementCount)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants