Fix WASM/Mono AOT regression in Utf8JsonReader.SkipWhiteSpace#130484
Fix WASM/Mono AOT regression in Utf8JsonReader.SkipWhiteSpace#130484eiriktsarpalis wants to merge 2 commits into
Conversation
PR #129701 replaced the scalar whitespace-skip loop in Utf8JsonReader.SkipWhiteSpace with an unconditional vectorized SearchValues scan. On Mono/WASM AOT the fixed per-call cost of that scan, together with the extra LastIndexOf('\n')/Count('\n') passes needed to recompute the line/byte-position bookkeeping, is not amortized over the short inter-token whitespace runs typical of indented JSON. This regressed 76 System.Text.Json benchmarks (all indented documents; zero minified). Reinstate the hybrid approach from the first commit of that PR: scan up to JsonConstants.MaxScalarWhiteSpaceScanLength (16) bytes with the scalar loop, which handles the common short-run case in a single fused pass (advance + newline count + byte offset), and only fall back to the vectorized IndexOfFirstNonWhiteSpace + CountNewLines path once the run is still whitespace after the window. This restores parity on short runs while preserving the native vectorization win for genuinely long whitespace runs. Behavior is identical to the long-shipped scalar oracle: verified with 32M+ differential cases and all published whitespace line/byte-position test vectors, which already exercise runs that cross the 16-byte boundary. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Native validation of the impacted scenarios. EgorBot runs on native hardware (not WASM), so this confirms the reinstated scalar pre-scan does not regress the native path that #129701 optimized — short/indented runs, minified input, and genuinely long whitespace runs where vectorization must be preserved. PR mode compares this branch against @EgorBot -linux_amd -linux_arm64 -osx_arm64 using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);
[MemoryDiagnoser]
public class Bench
{
private byte[] _indented = default!;
private byte[] _minified = default!;
private byte[] _deeplyIndented = default!;
private byte[] _longRuns = default!;
[GlobalSetup]
public void Setup()
{
object graph = MakeGraph(depth: 4, breadth: 4);
_indented = JsonSerializer.SerializeToUtf8Bytes(graph, new JsonSerializerOptions { WriteIndented = true });
_minified = JsonSerializer.SerializeToUtf8Bytes(graph, new JsonSerializerOptions { WriteIndented = false });
// Deep nesting => longer indentation runs (2 spaces * depth), straddling the 16-byte promotion boundary.
_deeplyIndented = JsonSerializer.SerializeToUtf8Bytes(MakeGraph(depth: 12, breadth: 2), new JsonSerializerOptions { WriteIndented = true });
// Explicit long whitespace runs => the vectorized tail path that must stay competitive on native.
_longRuns = MakeLongRunDocument(elements: 2000, runLength: 48);
}
private static object MakeGraph(int depth, int breadth)
{
if (depth == 0)
{
return "some string leaf value";
}
var node = new Dictionary<string, object>();
for (int i = 0; i < breadth; i++)
{
node["child_" + i] = MakeGraph(depth - 1, breadth);
}
node["number"] = 1234567;
node["flag"] = true;
node["nullable"] = null!;
node["array"] = new object[] { 1, 2, 3, "four", false };
return node;
}
private static byte[] MakeLongRunDocument(int elements, int runLength)
{
string ws = new string(' ', runLength - 1);
var sb = new StringBuilder();
sb.Append('[');
for (int i = 0; i < elements; i++)
{
if (i > 0)
{
sb.Append(',');
}
sb.Append('\n').Append(ws).Append(i);
}
sb.Append('\n').Append(']');
return Encoding.UTF8.GetBytes(sb.ToString());
}
private static long CountTokens(byte[] utf8)
{
var reader = new Utf8JsonReader(utf8);
long tokens = 0;
while (reader.Read())
{
tokens++;
}
return tokens;
}
[Benchmark]
public long Indented() => CountTokens(_indented);
[Benchmark]
public long Minified() => CountTokens(_minified);
[Benchmark]
public long DeeplyIndented() => CountTokens(_deeplyIndented);
[Benchmark]
public long LongWhitespaceRuns() => CountTokens(_longRuns);
}Note Benchmark authored with the assistance of GitHub Copilot. |
|
Tagging subscribers to this area: @dotnet/area-system-text-json |
There was a problem hiding this comment.
Pull request overview
This PR adjusts System.Text.Json’s Utf8JsonReader.SkipWhiteSpace implementation to use a hybrid strategy: a scalar loop for short whitespace runs and a vectorized SearchValues-based scan only after a configurable run-length threshold, aiming to avoid regressions on platforms where SIMD entry is expensive (e.g., Mono/WASM AOT) while still benefiting long runs.
Changes:
- Replaced the unconditional
IndexOfFirstNonWhiteSpacescan inSkipWhiteSpacewith a scalar pre-scan that promotes to vectorized scanning after a threshold. - Added
JsonConstants.MaxScalarWhiteSpaceScanLength(16) to centralize the promotion threshold.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/libraries/System.Text.Json/src/System/Text/Json/Reader/Utf8JsonReader.cs | Reintroduces scalar whitespace scanning and promotes to vectorized scanning after MaxScalarWhiteSpaceScanLength, while preserving line/byte position bookkeeping. |
| src/libraries/System.Text.Json/src/System/Text/Json/JsonConstants.cs | Adds MaxScalarWhiteSpaceScanLength constant and documents its purpose. |
EgorBot native benchmarks (EgorBot/Benchmarks#309) showed the unified scalar pre-scan regresses CoreCLR/native ~2x on deeply-indented and long-whitespace-run documents, since for runs longer than the 16-byte window it pays the scalar compares AND still performs the full vectorized scan on the remainder. Gate the pre-scan behind !OperatingSystem.IsBrowser(), which folds to a per-target constant: non-browser codegen is identical to main (zero native delta), while browser/WASM AOT keeps the scalar pre-scan that fixes the reported regression (dotnet/perf-autofiling-issues#75553). The vectorized tail is extracted into a shared SkipWhiteSpaceVectorized helper used by both paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Follow-up: the first EgorBot run exposed a real native regression — now gated to WASMThe first native run did not come back clean, and I don't want to paper over that. On all three native machines the unified scalar pre-scan regressed the longer-run scenarios, exactly the concern raised in review about trading native perf for a WASM-only win:
Root cause of the native regression: for whitespace runs longer than the 16-byte window, the hybrid pays up to 16 scalar compares and still performs the full vectorized scan ( Fix (pushed): gate the scalar pre-scan behind
The vectorized tail is now a shared Scope note: Re-running the same four scenarios to confirm native is now neutral (every scenario should return to ~1.00 vs @EgorBot -linux_amd -linux_arm64 -osx_arm64 using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);
[MemoryDiagnoser]
public class Bench
{
private byte[] _indented = default!;
private byte[] _minified = default!;
private byte[] _deeplyIndented = default!;
private byte[] _longRuns = default!;
[GlobalSetup]
public void Setup()
{
object graph = MakeGraph(depth: 4, breadth: 4);
_indented = JsonSerializer.SerializeToUtf8Bytes(graph, new JsonSerializerOptions { WriteIndented = true });
_minified = JsonSerializer.SerializeToUtf8Bytes(graph, new JsonSerializerOptions { WriteIndented = false });
// Deep nesting => longer indentation runs (2 spaces * depth), straddling the 16-byte promotion boundary.
_deeplyIndented = JsonSerializer.SerializeToUtf8Bytes(MakeGraph(depth: 12, breadth: 2), new JsonSerializerOptions { WriteIndented = true });
// Explicit long whitespace runs => the vectorized tail path that must stay competitive on native.
_longRuns = MakeLongRunDocument(elements: 2000, runLength: 48);
}
private static object MakeGraph(int depth, int breadth)
{
if (depth == 0)
{
return "some string leaf value";
}
var node = new Dictionary<string, object>();
for (int i = 0; i < breadth; i++)
{
node["child_" + i] = MakeGraph(depth - 1, breadth);
}
node["number"] = 1234567;
node["flag"] = true;
node["nullable"] = null!;
node["array"] = new object[] { 1, 2, 3, "four", false };
return node;
}
private static byte[] MakeLongRunDocument(int elements, int runLength)
{
string ws = new string(' ', runLength - 1);
var sb = new StringBuilder();
sb.Append('[');
for (int i = 0; i < elements; i++)
{
if (i > 0)
{
sb.Append(',');
}
sb.Append('\n').Append(ws).Append(i);
}
sb.Append('\n').Append(']');
return Encoding.UTF8.GetBytes(sb.ToString());
}
private static long CountTokens(byte[] utf8)
{
var reader = new Utf8JsonReader(utf8);
long tokens = 0;
while (reader.Read())
{
tokens++;
}
return tokens;
}
[Benchmark]
public long Indented() => CountTokens(_indented);
[Benchmark]
public long Minified() => CountTokens(_minified);
[Benchmark]
public long DeeplyIndented() => CountTokens(_deeplyIndented);
[Benchmark]
public long LongWhitespaceRuns() => CountTokens(_longRuns);
}Note This comment and benchmark were authored with the assistance of GitHub Copilot. |
| // Create local copy to avoid bounds checks. | ||
| ReadOnlySpan<byte> localBuffer = _buffer; | ||
| #if NET | ||
| // Vectorized scan to the first non-whitespace byte. The SearchValues-based | ||
| // IndexOfAnyExcept already handles short and long runs efficiently, so there is no | ||
| // need to special-case small inputs with a scalar pre-scan. | ||
| ReadOnlySpan<byte> remaining = localBuffer.Slice(_consumed); | ||
| int idx = remaining.IndexOfFirstNonWhiteSpace(); | ||
| if (idx > 0) | ||
| { | ||
| // Reproduce the scalar loop's line/byte-position bookkeeping for the skipped run. | ||
| (int newLines, int lastLineFeedIndex) = JsonReaderHelper.CountNewLines(remaining.Slice(0, idx)); | ||
| _lineNumber += newLines; | ||
| if (lastLineFeedIndex >= 0) | ||
| { | ||
| // Byte positions on the current line start after the last line feed character. | ||
| _bytePositionInLine = idx - lastLineFeedIndex - 1; | ||
| } | ||
| else | ||
| { | ||
| _bytePositionInLine += idx; | ||
| } | ||
|
|
||
| _consumed += idx; | ||
| // On most runtimes a vectorized SearchValues-based scan to the first non-whitespace byte | ||
| // is fastest across the board, so we go straight to it. On Mono/WASM AOT (browser) the |
| // On Mono/WASM AOT (browser), SkipWhiteSpace scans up to this many leading bytes with a | ||
| // scalar loop before promoting to a vectorized search, since the fixed per-call SIMD entry | ||
| // cost is high there relative to the short inter-token whitespace runs typical of indented | ||
| // JSON. Short runs (the common case) stay at scalar cost; longer runs promote to the | ||
| // vectorized path where it amortizes. Only consulted on the browser code path (see | ||
| // Utf8JsonReader.SkipWhiteSpace); other runtimes go straight to the vectorized search. | ||
| public const int MaxScalarWhiteSpaceScanLength = 16; |
Native re-run confirms the gate resolved the regressionThe re-run after gating the pre-scan behind
For comparison, the ungated hybrid (#309) was regressing the long-run scenarios hard:
Result: every native scenario is back to parity with The one mild negative — x64 WASM parity is preserved by construction: the gate only wraps the previously-validated browser hybrid in Net: the reported WASM regression (#75553) is fixed, and native is unaffected. Note This comment was authored with the assistance of GitHub Copilot. |
Supplementary: is SearchValues worth it vs. no SearchValues at all?The two runs above compare this PR against This is a self-contained three-way microbenchmark. All three @EgorBot -linux_amd -linux_arm64 -osx_arm64 using System;
using System.Buffers;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);
public class Bench
{
private const byte Space = (byte)' ';
private const byte Tab = (byte)'\t';
private const byte CR = (byte)'\r';
private const byte LF = (byte)'\n';
private const int MaxScalarWhiteSpaceScanLength = 16;
private static readonly SearchValues<byte> s_ws = SearchValues.Create(" \t\r\n"u8);
private byte[] _indented = default!;
private byte[] _minified = default!;
private byte[] _deeplyIndented = default!;
private byte[] _longRuns = default!;
public enum Doc { Indented, Minified, DeeplyIndented, LongRuns }
[Params(Doc.Indented, Doc.Minified, Doc.DeeplyIndented, Doc.LongRuns)]
public Doc Scenario;
private byte[] Current => Scenario switch
{
Doc.Indented => _indented,
Doc.Minified => _minified,
Doc.DeeplyIndented => _deeplyIndented,
_ => _longRuns,
};
[GlobalSetup]
public void Setup()
{
object graph = MakeGraph(depth: 4, breadth: 4);
_indented = JsonSerializer.SerializeToUtf8Bytes(graph, new JsonSerializerOptions { WriteIndented = true });
_minified = JsonSerializer.SerializeToUtf8Bytes(graph, new JsonSerializerOptions { WriteIndented = false });
_deeplyIndented = JsonSerializer.SerializeToUtf8Bytes(MakeGraph(depth: 12, breadth: 2), new JsonSerializerOptions { WriteIndented = true });
_longRuns = MakeLongRunDocument(elements: 2000, runLength: 48);
}
// Baseline: pure scalar loop, i.e. the pre-#129701 implementation (no SearchValues at all).
[Benchmark(Baseline = true)]
public long Scalar() => DriveScalar(Current);
// Unconditional vectorized SearchValues scan = current `main`.
[Benchmark]
public long SearchValuesOnly() => DriveSearchValues(Current);
// Scalar pre-scan promoting to SearchValues after 16 bytes = this PR's browser/WASM path.
[Benchmark]
public long Hybrid() => DriveHybrid(Current);
private static long DriveScalar(ReadOnlySpan<byte> span)
{
int pos = 0; long line = 0, bytePos = 0;
while (pos < span.Length) { pos = SkipScalar(span, pos, ref line, ref bytePos); pos = SkipToken(span, pos); }
return line + bytePos;
}
private static long DriveSearchValues(ReadOnlySpan<byte> span)
{
int pos = 0; long line = 0, bytePos = 0;
while (pos < span.Length) { pos = SkipSearchValues(span, pos, ref line, ref bytePos); pos = SkipToken(span, pos); }
return line + bytePos;
}
private static long DriveHybrid(ReadOnlySpan<byte> span)
{
int pos = 0; long line = 0, bytePos = 0;
while (pos < span.Length) { pos = SkipHybrid(span, pos, ref line, ref bytePos); pos = SkipToken(span, pos); }
return line + bytePos;
}
private static int SkipToken(ReadOnlySpan<byte> span, int pos)
{
while (pos < span.Length)
{
byte v = span[pos];
if (v is Space or Tab or CR or LF) break;
pos++;
}
return pos;
}
private static int SkipScalar(ReadOnlySpan<byte> span, int pos, ref long line, ref long bytePos)
{
for (; pos < span.Length; pos++)
{
byte val = span[pos];
if (val is not Space and not CR and not LF and not Tab) break;
if (val == LF) { line++; bytePos = 0; } else { bytePos++; }
}
return pos;
}
private static int SkipSearchValues(ReadOnlySpan<byte> span, int pos, ref long line, ref long bytePos)
{
ReadOnlySpan<byte> remaining = span.Slice(pos);
int idx = IndexOfFirstNonWhiteSpace(remaining);
if (idx > 0)
{
(int newLines, int lastLineFeedIndex) = CountNewLines(remaining.Slice(0, idx));
line += newLines;
if (lastLineFeedIndex >= 0) { bytePos = idx - lastLineFeedIndex - 1; } else { bytePos += idx; }
}
return pos + idx;
}
private static int SkipHybrid(ReadOnlySpan<byte> span, int pos, ref long line, ref long bytePos)
{
int run = 0;
for (; pos < span.Length; pos++)
{
byte val = span[pos];
if (val is not Space and not CR and not LF and not Tab) break;
if (val == LF) { line++; bytePos = 0; } else { bytePos++; }
if (++run == MaxScalarWhiteSpaceScanLength)
{
return SkipSearchValues(span, pos + 1, ref line, ref bytePos);
}
}
return pos;
}
private static int IndexOfFirstNonWhiteSpace(ReadOnlySpan<byte> span)
{
int index = span.IndexOfAnyExcept(s_ws);
return index < 0 ? span.Length : index;
}
private static (int, int) CountNewLines(ReadOnlySpan<byte> data)
{
int lastLineFeedIndex = data.LastIndexOf(LF);
int newLines = 0;
if (lastLineFeedIndex >= 0)
{
newLines = 1;
data = data.Slice(0, lastLineFeedIndex);
newLines += data.Count(LF);
}
return (newLines, lastLineFeedIndex);
}
private static object MakeGraph(int depth, int breadth)
{
if (depth == 0)
{
return "some string leaf value";
}
var node = new Dictionary<string, object>();
for (int i = 0; i < breadth; i++)
{
node["child_" + i] = MakeGraph(depth - 1, breadth);
}
node["number"] = 1234567;
node["flag"] = true;
node["nullable"] = null!;
node["array"] = new object[] { 1, 2, 3, "four", false };
return node;
}
private static byte[] MakeLongRunDocument(int elements, int runLength)
{
string ws = new string(' ', runLength - 1);
var sb = new StringBuilder();
sb.Append('[');
for (int i = 0; i < elements; i++)
{
if (i > 0)
{
sb.Append(',');
}
sb.Append('\n').Append(ws).Append(i);
}
sb.Append('\n').Append(']');
return Encoding.UTF8.GetBytes(sb.ToString());
}
}Note This comment and benchmark were authored with the assistance of GitHub Copilot. |
Summary
#129701 replaced the scalar whitespace-skip loop in
Utf8JsonReader.SkipWhiteSpacewith an unconditional vectorizedSearchValuesscan (IndexOfAnyExcept+CountNewLines). While that is neutral-to-faster on native x64, it regressed a large batch of System.Text.Json benchmarks on Mono/WASM AOT (76 reported in dotnet/perf-autofiling-issues#75553, 2026-06-24).The regression is perfectly selective: every regressed benchmark carrying indentation metadata reads an indented / whitespace-heavy document; none of the minified variants regressed. That selectivity is the tell — minified JSON enters
SkipWhiteSpacewith the next token immediately following (the scan returns after ~1 byte), while indented JSON pays the vectorized cost on every short inter-token run.Root cause
On Mono/WASM AOT the vectorized path is ~2.6× slower than the scalar loop per
SkipWhiteSpacecall, for two compounding reasons:IndexOfAnyExcept(SearchValues)probe is already ~2.56× a scalar break on WASM AOT (26.7 ns vs 10.4 ns in a microbench) — a fixed per-call cost independent of run length. Native has cheap SIMD entry; WASM AOT does not.SkipWhiteSpacemust also maintain_lineNumberand_bytePositionInLine.IndexOfAnyExceptreturns only a single index and discards the intra-run structure, so reconstructing those forcesCountNewLines=LastIndexOf('\n')+Count('\n')— up to 3 vectorized passes over each run versus 1 fused scalar pass.Typical inter-token whitespace runs in indented JSON are short (~4 bytes) — well below the length at which SIMD amortizes those costs. A run-length sweep put the scalar↔vectorized crossover at ~8 bytes on native x64 but ~24 bytes on WASM AOT.
Fix
The fix reinstates the scalar pre-scan from the first commit of #129701 (
eeea0c5, before it was simplified to the unconditional scan in6fcd59b), but gates it to the browser/WASM target only. A scalar loop handles the whitespace run and folds the line/byte bookkeeping into a single pass, promoting to the vectorizedIndexOfFirstNonWhiteSpace+CountNewLinespath only once a run exceedsJsonConstants.MaxScalarWhiteSpaceScanLength(16 bytes).OperatingSystem.IsBrowser()folds to a per-target constant, so:main⇒ no native change.The vectorized tail is extracted into a shared
SkipWhiteSpaceVectorizedhelper used by both call sites.Why gate it instead of applying the hybrid everywhere?
The initial version of this PR applied the scalar pre-scan unconditionally. An EgorBot run on native hardware (EgorBot/Benchmarks#309) showed that regressed native meaningfully — the longer-run scenarios were ~2× slower on all three machines:
Indented(depth 4)MinifiedDeeplyIndented(depth 12)LongWhitespaceRuns(48-byte)For runs longer than the 16-byte window the pre-scan pays up to 16 scalar compares and still performs the full vectorized scan on the remainder — pure overhead where SIMD entry is cheap. Native genuinely wants
main's unconditionalSearchValuesscan, so the pre-scan is scoped to the target that actually benefits.Scope note:
IsBrowser()covers browser-WASM only, not WASI/iOS/Android Mono AOT (same SIMD-entry root cause). That precisely matches what #75553 reported (browser-WASM), and there is no clean "is-Mono-AOT" gate at this layer. This can be broadened later if we want to cover mobile AOT.Validation
WASM AOT (net11 preview, node, SIMD on), ns per
SkipWhiteSpacecall (lower is better):main(SearchValues)Native — because the gate dead-code-eliminates the pre-scan on CoreCLR, native codegen is identical to
main. A fresh EgorBot run confirming the four scenarios return to ~1.00 vsmainfollows in a comment.Correctness — after the refactor, a differential harness compares both code paths (browser hybrid and non-browser pure-vectorized) against the long-shipped scalar oracle: 32.2M cases, 0 mismatches; plus 90/90 end-to-end reader assertions and the published whitespace-position vectors (
ReadLongWhitespaceAndDigitRuns,WhitespaceRunBeforeInvalidToken_ReportsLineAndBytePosition,PositionInCodeUnits), which straddle the 16-byte promotion boundary.Contributes to dotnet/perf-autofiling-issues#75553.
Note
This pull request was authored with the assistance of GitHub Copilot.