Skip to content

Avoid array-pool traffic for short encoded StringPool inputs - #1213

Closed
Garume wants to merge 2 commits into
CommunityToolkit:mainfrom
Garume:perf/stringpool-short-byte-input
Closed

Garume wants to merge 2 commits into
CommunityToolkit:mainfrom
Garume:perf/stringpool-short-byte-input

Conversation

@Garume

@Garume Garume commented Sep 22, 2026 •

Copy link
Copy Markdown

Related to #699.

Maintainer feedback requested: I could not find recorded maintainer approval on the issue. Please confirm whether the short-input gains and the documented empty/long-input tradeoffs are acceptable before this is considered ready to merge.

For #699, use a bounded 64-character stack buffer when Encoding.GetMaxCharCount fits, avoiding the array-pool rent/return pair for short encoded inputs. Larger inputs retain the existing pooled path. The pointer overload of Encoding.GetChars, its capacity argument, and the actual decoded length remain unchanged. A fixed nonempty buffer also preserves a non-null destination for custom encodings reporting zero characters.

This is a performance tradeoff, not a universal throughput improvement or a warmed-allocation reduction. Both versions allocate 0 B for warmed cache hits. On Windows x64/.NET 10.0.1, selected short-input cases improved, while empty input and some pooled cases regressed. The table below includes every measured scenario. ARM64, other runtimes, cache misses, and contention have not been benchmarked.

Validation:

  • All 661 HighPerformance tests pass on .NET 10.
  • All 39 StringPool tests pass on .NET Framework 4.7.2.
  • Library builds for netstandard2.0, netstandard2.1, and net8.0.
  • Added coverage for both sides of the threshold, Unicode, UTF-8 fallback/throwing behavior, empty/null behavior, interned identity, and custom encoding capacity/pointer behavior.
  • An ArrayPool EventListener audit observes 1,000 rent/return pairs before and zero after for 1,000 warmed eligible calls. Large inputs retain 1,000 pairs.

Benchmarks compare compiled baseline and candidate libraries; the baseline assembly was renamed to allow aliases in the same harness. These are single-machine screening measurements, with one benchmark process per case, three warmups, and eight measured iterations. Maintainer feedback on the short-input/empty-input tradeoff is welcome.


BenchmarkDotNet v0.16.0-nightly.20260801.595, Windows 11 (10.0.26200.9457/25H2/2025Update/HudsonValley2)
AMD Ryzen AI 9 HX 370 w/ Radeon 890M 2.00GHz, 1 CPU, 24 logical and 12 physical cores
Memory: 63.12 GB Total, 36.52 GB Available
.NET SDK 10.0.101
  [Host]     : .NET 10.0.1 (10.0.1, 10.0.125.57005), X64 RyuJIT x86-64-v4
  Job-RTBDTB : .NET 10.0.1 (10.0.1, 10.0.125.57005), X64 RyuJIT x86-64-v4

IterationCount=8  IterationTime=150ms  WarmupCount=3  

Method Scenario Mean Error StdDev Ratio RatioSD Allocated Alloc Ratio
Before Empty 1.474 ns 0.0833 ns 0.0297 ns 1.00 0.00 - NA
After Empty 2.424 ns 0.0388 ns 0.0203 ns 1.65 0.03 - NA
Before Unicode 36.817 ns 0.2646 ns 0.1384 ns 1.00 0.00 - NA
After Unicode 34.164 ns 0.1499 ns 0.0784 ns 0.93 0.00 - NA
Before Utf16_16 28.776 ns 0.2278 ns 0.1191 ns 1.00 0.00 - NA
After Utf16_16 24.211 ns 0.0988 ns 0.0517 ns 0.84 0.00 - NA
Before Utf8_1024 115.664 ns 1.5392 ns 0.6834 ns 1.00 0.00 - NA
After Utf8_1024 118.840 ns 0.4153 ns 0.2172 ns 1.03 0.01 - NA
Before Utf8_126 63.372 ns 0.4188 ns 0.1860 ns 1.00 0.00 - NA
After Utf8_126 66.063 ns 0.5296 ns 0.2770 ns 1.04 0.01 - NA
Before Utf8_127 64.699 ns 0.2023 ns 0.0898 ns 1.00 0.00 - NA
After Utf8_127 65.135 ns 0.1958 ns 0.0869 ns 1.01 0.00 - NA
Before Utf8_128 46.265 ns 0.1916 ns 0.0851 ns 1.00 0.00 - NA
After Utf8_128 43.146 ns 0.2842 ns 0.1262 ns 0.93 0.00 - NA
Before Utf8_62 44.268 ns 0.3175 ns 0.1661 ns 1.00 0.00 - NA
After Utf8_62 40.494 ns 0.2192 ns 0.1146 ns 0.91 0.00 - NA
Before Utf8_63 47.320 ns 0.2697 ns 0.1197 ns 1.00 0.00 - NA
After Utf8_63 40.136 ns 0.2929 ns 0.1301 ns 0.85 0.00 - NA
Before Utf8_64 39.877 ns 0.2050 ns 0.1072 ns 1.00 0.00 - NA
After Utf8_64 43.532 ns 0.2453 ns 0.1283 ns 1.09 0.00 - NA
Before Utf8_8 27.135 ns 0.2062 ns 0.1079 ns 1.00 0.00 - NA
After Utf8_8 26.944 ns 0.1497 ns 0.0783 ns 0.99 0.00 - NA

Developed with AI assistance; changes and validation were reviewed before submission.

PR Checklist

  • Created a feature branch in my fork.
  • Based on the latest main (b135626dd54d33b8f05f2ff31591592c004aa848).
  • Contains no merge commits.
  • Tested with the repository's .NET SDK 10.0.101.
  • Added tests for the changed behavior and compatibility cases.
  • Contains no public API or intentional breaking behavior changes.
  • Existing file/style conventions followed; no new production APIs or source files.
Benchmark harness and reproduction layout

Build the full-history baseline checkout with -p:AssemblyName=CommunityToolkit.HighPerformance.Baseline, then build this PR's checkout normally. The projects below use aliases to compare both compiled libraries. The measurements above used baseline b135626dd54d33b8f05f2ff31591592c004aa848 and candidate 879500f891a2dbd8b526938dc7d434394ec6bd3a.

Directory layout:

communitytoolkit-perf-first/   # library checkout
  src/CommunityToolkit.HighPerformance/bin/Release/net8.0/CommunityToolkit.HighPerformance.dll
toolkit-first-evidence/
  baseline/CommunityToolkit.HighPerformance.Baseline.dll
  benchmark/                 # Program.cs and Benchmark.csproj below

From the common parent directory, first restore and build the baseline checkout:

git -C communitytoolkit-perf-first checkout b135626dd54d33b8f05f2ff31591592c004aa848
dotnet restore communitytoolkit-perf-first/src/CommunityToolkit.HighPerformance/CommunityToolkit.HighPerformance.csproj
dotnet build communitytoolkit-perf-first/src/CommunityToolkit.HighPerformance/CommunityToolkit.HighPerformance.csproj -c Release -f net8.0 --no-restore -p:AssemblyName=CommunityToolkit.HighPerformance.Baseline -o toolkit-first-evidence/baseline
# Fetch this PR's branch first if using an upstream-only clone.
git -C communitytoolkit-perf-first checkout 879500f891a2dbd8b526938dc7d434394ec6bd3a
dotnet build communitytoolkit-perf-first/src/CommunityToolkit.HighPerformance/CommunityToolkit.HighPerformance.csproj -c Release -f net8.0 --no-restore

Place the following files in toolkit-first-evidence/benchmark. The pinned prerelease BenchmarkDotNet package is available from the public https://pkgs.dev.azure.com/dnceng/public/_packaging/benchmark-dotnet-prerelease/nuget/v3/index.json feed; include nuget.org for its dependencies. Run dotnet run -c Release -- --filter '*' --artifacts results from that directory. Use an isolated NuGet.Config if unrelated machine-wide package sources interfere.

Benchmark.csproj:

<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup><ItemGroup><PackageReference Include="BenchmarkDotNet" Version="0.16.0-nightly.20260801.595"/><Reference Include="CommunityToolkit.HighPerformance.Baseline"><HintPath>../baseline/CommunityToolkit.HighPerformance.Baseline.dll</HintPath><Aliases>baseline</Aliases></Reference><Reference Include="CommunityToolkit.HighPerformance"><HintPath>../../communitytoolkit-perf-first/src/CommunityToolkit.HighPerformance/bin/Release/net8.0/CommunityToolkit.HighPerformance.dll</HintPath><Aliases>candidate</Aliases></Reference></ItemGroup></Project>

Program.cs:

extern alias baseline;
extern alias candidate;
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Exporters.Json;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using Perfolizer.Horology;
using OldPool = baseline::CommunityToolkit.HighPerformance.Buffers.StringPool;
using NewPool = candidate::CommunityToolkit.HighPerformance.Buffers.StringPool;

var config = ManualConfig.Create(DefaultConfig.Instance)
    .AddJob(Job.Default.WithWarmupCount(3).WithIterationCount(8).WithIterationTime(TimeInterval.FromMilliseconds(150)))
    .AddExporter(JsonExporter.Full);
var reports = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config).ToArray();
return reports.Length == 0 || reports.Any(s => s.Reports.Any(r => !r.Success)) ? 1 : 0;

[MemoryDiagnoser]
public class StringPoolBenchmarks
{
    [Params("Empty", "Utf8_8", "Utf8_62", "Utf8_63", "Utf8_64", "Utf8_126", "Utf8_127", "Utf8_128", "Utf8_1024", "Utf16_16", "Unicode")]
    public string Scenario { get; set; } = "";
    private byte[] input = null!;
    private Encoding encoding = null!;
    private OldPool oldPool = null!;
    private NewPool newPool = null!;

    [GlobalSetup]
    public void Setup()
    {
        encoding = Scenario.StartsWith("Utf16") ? Encoding.Unicode : Encoding.UTF8;
        string text = Scenario == "Empty" ? "" : Scenario == "Unicode" ? "日本語😀の文字列" : new string('x', int.Parse(Scenario.Split('_')[1]));
        input = encoding.GetBytes(text);
        oldPool = new OldPool(); newPool = new NewPool();
        oldPool.Add(text); newPool.Add(text);
        if (oldPool.GetOrAdd(input, encoding) != text || newPool.GetOrAdd(input, encoding) != text) throw new Exception("Decode mismatch");
    }

    [Benchmark(Baseline = true)] public string Before() => oldPool.GetOrAdd(input, encoding);
    [Benchmark] public string After() => newPool.GetOrAdd(input, encoding);
}

@Garume

Garume commented Sep 22, 2026

Copy link
Copy Markdown
Author

@dotnet-policy-service agree

@Garume
Garume marked this pull request as ready for review September 22, 2026 16:43
@Garume Garume closed this Sep 24, 2026
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.

1 participant