Skip to content

Use SearchValues to scan for characters to escape with default Json options#129781

Merged
MihaZupan merged 4 commits into
dotnet:mainfrom
MihaZupan:searchvalues-jsonEscape
Jun 25, 2026
Merged

Use SearchValues to scan for characters to escape with default Json options#129781
MihaZupan merged 4 commits into
dotnet:mainfrom
MihaZupan:searchvalues-jsonEscape

Conversation

@MihaZupan

@MihaZupan MihaZupan commented Jun 23, 2026

Copy link
Copy Markdown
Member

Helps avoid some indirection in the common case.
A couple small improvements: EgorBot/Benchmarks#268 (comment), #129781 (comment)

@MihaZupan MihaZupan added this to the 11.0.0 milestone Jun 23, 2026
@MihaZupan MihaZupan self-assigned this Jun 23, 2026
Copilot AI review requested due to automatic review settings June 23, 2026 23:15
@MihaZupan

This comment was marked as outdated.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

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.

Pull request overview

This PR optimizes the “needs escaping?” scan in System.Text.Json’s writer helpers for the default encoding scenario by using SearchValues<T> + IndexOfAnyExcept (under #if NET) when no custom JavaScriptEncoder is provided.

Changes:

  • Introduces SearchValues<byte> / SearchValues<char> instances representing the AllowList-permitted ASCII set.
  • Uses IndexOfAnyExcept(...) to find the first character/byte needing escaping when encoder is null (NET builds), otherwise falls back to JavaScriptEncoder.FindFirstCharacterToEncode*.

Note: I did not build or run tests as part of this review.

@MihaZupan

Copy link
Copy Markdown
Member Author

@EgorBot

using System;
using System.Buffers;
using System.Collections.Generic;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

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

// Targets JsonWriterHelper.NeedsEscaping(ReadOnlySpan<char|byte>, encoder), which this change routes
// through SearchValues for the default-escaping case. The fast path triggers when the encoder is null
// (default options) OR is the JavaScriptEncoder.Default singleton (ReferenceEquals). ExplicitDefaultEncoder
// toggles between those two so both branches are measured.
[MemoryDiagnoser]
public class JsonWriteStrings
{
    public enum Escape { NoneEscaped, OneEscaped, AllEscaped }

    [Params(Escape.NoneEscaped, Escape.OneEscaped, Escape.AllEscaped)]
    public Escape Escaped;

    [Params(false, true)]
    public bool ExplicitDefaultEncoder;

    private const int Count = 10_000;
    private ArrayBufferWriter<byte> _buffer;
    private JsonWriterOptions _options;
    private string[] _utf16;
    private byte[][] _utf8;

    [GlobalSetup]
    public void Setup()
    {
        _buffer = new ArrayBufferWriter<byte>();
        _options = new JsonWriterOptions { Encoder = ExplicitDefaultEncoder ? JavaScriptEncoder.Default : null };

        var rng = new Random(42);
        _utf16 = new string[Count];
        _utf8 = new byte[Count][];
        for (int i = 0; i < Count; i++)
        {
            _utf16[i] = MakeString(rng, 5, 100, Escaped);
            _utf8[i] = Encoding.UTF8.GetBytes(_utf16[i]);
        }
    }

    private static string MakeString(Random rng, int min, int max, Escape escape)
    {
        int len = rng.Next(min, max);
        var arr = new char[len];
        if (escape == Escape.AllEscaped)
        {
            Array.Fill(arr, '"');
            return new string(arr);
        }
        for (int i = 0; i < len; i++)
        {
            arr[i] = (char)rng.Next('a', 'z' + 1);
        }
        if (escape == Escape.OneEscaped)
        {
            arr[rng.Next(0, len)] = '"';
        }
        return new string(arr);
    }

    // UTF-16 (char) path -> NeedsEscaping(ReadOnlySpan<char>, encoder)
    [Benchmark]
    public void WriteStringValues_Utf16()
    {
        _buffer.Clear();
        using var writer = new Utf8JsonWriter(_buffer, _options);
        writer.WriteStartArray();
        for (int i = 0; i < Count; i++)
        {
            writer.WriteStringValue(_utf16[i]);
        }
        writer.WriteEndArray();
        writer.Flush();
    }

    // UTF-8 (byte) path -> NeedsEscaping(ReadOnlySpan<byte>, encoder)
    [Benchmark]
    public void WriteStringValues_Utf8()
    {
        _buffer.Clear();
        using var writer = new Utf8JsonWriter(_buffer, _options);
        writer.WriteStartArray();
        for (int i = 0; i < Count; i++)
        {
            writer.WriteStringValue(_utf8[i]);
        }
        writer.WriteEndArray();
        writer.Flush();
    }
}

// End-to-end serialization (property names + ASCII string values), default encoder vs explicit Default.
[MemoryDiagnoser]
public class JsonSerializeStringHeavy
{
    [Params(false, true)]
    public bool ExplicitDefaultEncoder;

    private JsonSerializerOptions _options;
    private Dictionary<string, string> _dictionary;
    private LoginViewModel _login;

    [GlobalSetup]
    public void Setup()
    {
        _options = new JsonSerializerOptions { Encoder = ExplicitDefaultEncoder ? JavaScriptEncoder.Default : null };

        _dictionary = new Dictionary<string, string>();
        for (int i = 0; i < 100; i++)
        {
            _dictionary["property_name_" + i] = "some string value number " + i;
        }

        _login = new LoginViewModel
        {
            Email = "name.familyname@not.com",
            Password = "abcdefgh123456!@#$%^&*()",
            RememberMe = true,
        };
    }

    [Benchmark]
    public byte[] Serialize_Dictionary() => JsonSerializer.SerializeToUtf8Bytes(_dictionary, _options);

    [Benchmark]
    public byte[] Serialize_Login() => JsonSerializer.SerializeToUtf8Bytes(_login, _options);
}

public class LoginViewModel
{
    public string Email { get; set; }
    public string Password { get; set; }
    public bool RememberMe { get; set; }
}

Note

This benchmark comment was generated with assistance from GitHub Copilot.

@MihaZupan

Copy link
Copy Markdown
Member Author

@MihuBot benchmark Perf_Strings -medium

@MihuBot

MihuBot commented Jun 24, 2026

Copy link
Copy Markdown
System.Text.Json.Tests.Perf_Strings
BenchmarkDotNet v0.16.0-nightly.20260518.1249, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 4.79 GB Available
MediumRun : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job=MediumRun  OutlierMode=DontRemove  IterationCount=15
LaunchCount=2  MemoryRandomization=True  WarmupCount=10
Method Toolchain Formatted SkipValidation Escaped Mean Error Ratio Allocated Alloc Ratio
WriteStringsUtf8 Main False False AllEscaped 30.791 ms 1.0580 ms 1.00 2176.13 KB 1.00
WriteStringsUtf8 PR False False AllEscaped 30.561 ms 0.5371 ms 0.99 2176.13 KB 1.00
WriteStringsUtf16 Main False False AllEscaped 29.643 ms 0.2897 ms 1.00 2176.23 KB 1.00
WriteStringsUtf16 PR False False AllEscaped 29.492 ms 0.1748 ms 1.00 5356.68 KB 2.46
WriteStringsUtf8 Main False False OneEscaped 5.459 ms 0.0175 ms 1.00 136.12 KB 1.00
WriteStringsUtf8 PR False False OneEscaped 5.387 ms 0.0145 ms 0.99 136.12 KB 1.00
WriteStringsUtf16 Main False False OneEscaped 7.163 ms 0.0626 ms 1.00 136.13 KB 1.00
WriteStringsUtf16 PR False False OneEscaped 7.035 ms 0.0216 ms 0.98 136.13 KB 1.00
WriteStringsUtf8 Main False False NoneEscaped 1.866 ms 0.0102 ms 1.00 34.13 KB 1.00
WriteStringsUtf8 PR False False NoneEscaped 1.810 ms 0.0101 ms 0.97 34.13 KB 1.00
WriteStringsUtf16 Main False False NoneEscaped 3.309 ms 0.0135 ms 1.00 68.12 KB 1.00
WriteStringsUtf16 PR False False NoneEscaped 3.284 ms 0.0109 ms 0.99 68.12 KB 1.00
WriteStringsUtf8 Main False True AllEscaped 30.692 ms 0.9648 ms 1.00 2176.13 KB 1.00
WriteStringsUtf8 PR False True AllEscaped 30.184 ms 0.0843 ms 0.99 2176.05 KB 1.00
WriteStringsUtf16 Main False True AllEscaped 29.478 ms 0.1221 ms 1.00 2176.04 KB 1.00
WriteStringsUtf16 PR False True AllEscaped 28.988 ms 0.0759 ms 0.98 2176.04 KB 1.00
WriteStringsUtf8 Main False True OneEscaped 5.397 ms 0.0132 ms 1.00 136.1 KB 1.00
WriteStringsUtf8 PR False True OneEscaped 5.387 ms 0.0161 ms 1.00 136.1 KB 1.00
WriteStringsUtf16 Main False True OneEscaped 7.084 ms 0.0159 ms 1.00 136.1 KB 1.00
WriteStringsUtf16 PR False True OneEscaped 7.075 ms 0.0448 ms 1.00 136.1 KB 1.00
WriteStringsUtf8 Main False True NoneEscaped 1.833 ms 0.0085 ms 1.00 34.13 KB 1.00
WriteStringsUtf8 PR False True NoneEscaped 1.799 ms 0.0112 ms 0.98 34.13 KB 1.00
WriteStringsUtf16 Main False True NoneEscaped 3.301 ms 0.0129 ms 1.00 68.12 KB 1.00
WriteStringsUtf16 PR False True NoneEscaped 3.278 ms 0.0214 ms 0.99 68.12 KB 1.00
WriteStringsUtf8 Main True False AllEscaped 30.615 ms 0.1010 ms 1.00 2176.05 KB 1.00
WriteStringsUtf8 PR True False AllEscaped 30.503 ms 0.0468 ms 1.00 2176.05 KB 1.00
WriteStringsUtf16 Main True False AllEscaped 30.508 ms 0.7668 ms 1.00 2176.04 KB 1.00
WriteStringsUtf16 PR True False AllEscaped 29.111 ms 0.0568 ms 0.96 2176.04 KB 1.00
WriteStringsUtf8 Main True False OneEscaped 5.743 ms 0.0086 ms 1.00 136.1 KB 1.00
WriteStringsUtf8 PR True False OneEscaped 5.648 ms 0.0301 ms 0.98 136.1 KB 1.00
WriteStringsUtf16 Main True False OneEscaped 7.399 ms 0.0508 ms 1.00 136.1 KB 1.00
WriteStringsUtf16 PR True False OneEscaped 7.231 ms 0.0133 ms 0.98 136.1 KB 1.00
WriteStringsUtf8 Main True False NoneEscaped 2.367 ms 0.1196 ms 1.00 68.12 KB 1.00
WriteStringsUtf8 PR True False NoneEscaped 2.062 ms 0.0079 ms 0.87 68.12 KB 1.00
WriteStringsUtf16 Main True False NoneEscaped 3.496 ms 0.0184 ms 1.00 68.12 KB 1.00
WriteStringsUtf16 PR True False NoneEscaped 3.512 ms 0.0087 ms 1.00 68.12 KB 1.00
WriteStringsUtf8 Main True True AllEscaped 30.619 ms 0.0497 ms 1.00 2176.05 KB 1.00
WriteStringsUtf8 PR True True AllEscaped 30.508 ms 0.0548 ms 1.00 2176.05 KB 1.00
WriteStringsUtf16 Main True True AllEscaped 30.288 ms 0.3253 ms 1.00 2176.04 KB 1.00
WriteStringsUtf16 PR True True AllEscaped 29.077 ms 0.0439 ms 0.96 2176.04 KB 1.00
WriteStringsUtf8 Main True True OneEscaped 5.720 ms 0.0135 ms 1.00 136.1 KB 1.00
WriteStringsUtf8 PR True True OneEscaped 5.604 ms 0.0096 ms 0.98 136.1 KB 1.00
WriteStringsUtf16 Main True True OneEscaped 7.424 ms 0.2119 ms 1.00 136.1 KB 1.00
WriteStringsUtf16 PR True True OneEscaped 7.196 ms 0.0117 ms 0.97 136.1 KB 1.00
WriteStringsUtf8 Main True True NoneEscaped 2.286 ms 0.0133 ms 1.00 68.12 KB 1.00
WriteStringsUtf8 PR True True NoneEscaped 2.025 ms 0.0077 ms 0.89 68.12 KB 1.00
WriteStringsUtf16 Main True True NoneEscaped 3.439 ms 0.0095 ms 1.00 68.12 KB 1.00
WriteStringsUtf16 PR True True NoneEscaped 3.457 ms 0.0072 ms 1.01 68.12 KB 1.00

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Copilot AI review requested due to automatic review settings June 24, 2026 15:28

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@MihaZupan MihaZupan enabled auto-merge (squash) June 24, 2026 16:41
@MihaZupan

Copy link
Copy Markdown
Member Author

Review from eiriktsarpalis is stale because it was submitted before the most recent code changes.

@eiriktsarpalis mind taking another look please given the new restrictions?

@MihaZupan MihaZupan merged commit 69417eb into dotnet:main Jun 25, 2026
85 of 88 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants