Skip to content

Disable Utf8JsonReader whitespace-skip vectorization on browser/WASM - #130484

Merged
eiriktsarpalis merged 6 commits into
mainfrom
eiriktsarpalis-perf-regression-triage-75553
Jul 14, 2026
Merged

Disable Utf8JsonReader whitespace-skip vectorization on browser/WASM#130484
eiriktsarpalis merged 6 commits into
mainfrom
eiriktsarpalis-perf-regression-triage-75553

Conversation

@eiriktsarpalis

@eiriktsarpaliseiriktsarpalis commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

#129701 replaced the scalar whitespace-skip loop in Utf8JsonReader.SkipWhiteSpace with an unconditional vectorized SearchValues scan. That is neutral-to-faster on native, but regressed a large batch of System.Text.Json benchmarks on browser/WASM (Mono AOT) — 76 reported in dotnet/perf-autofiling-issues#75553. On WASM AOT the fixed per-call SIMD entry cost is high relative to the short inter-token whitespace runs typical of JSON, so the vectorized scan is ~2.6× slower per call there.

Fix

Disable the vectorized whitespace scan on browser/WASM and fall back to the original scalar loop; keep the vectorized path on every other target:

if(!OperatingSystem.IsBrowser()){// vectorized SearchValues scan — unchanged from mainreturn;}// browser/WASM: original scalar loop

OperatingSystem.IsBrowser() folds to a per-target constant, so native codegen is identical to main (the scalar branch is dead-code-eliminated) and only the browser build takes the scalar path. This intentionally leaves SIMD throughput on the table for long whitespace runs on WASM; real JSON inter-token runs are short (~4 bytes) and sit well below the crossover, so realistic documents only benefit.

Scope:IsBrowser() covers browser-WASM only (what #75553 reported), not WASI/iOS/Android Mono AOT; it can be broadened later if needed.

Validation

WASM AOT (net11, node, SIMD on), ns per SkipWhiteSpace call — the scalar loop this PR restores vs the SearchValues scan on main:

Documentscalar (this PR)SearchValues (main)
indented 4 KB16.343.2
indented 40 KB16.443.2
indented 400 KB16.343.0
minified 40 KB10.727.5

Native is unchanged by construction (codegen identical to main, confirmed by EgorBot runs on the earlier commits). Correctness verified against the scalar oracle (32.2M differential cases, 0 mismatches) plus the existing reader tests.

Contributes to dotnet/perf-autofiling-issues#75553.

Note

This pull request was authored with the assistance of GitHub Copilot.

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>
@eiriktsarpalis

Copy link
Copy Markdown
MemberAuthor

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 main (the current unconditional SearchValues scan). The WASM/Mono AOT win itself is covered in the PR description.

@EgorBot -linux_amd -linux_arm64 -osx_arm64

usingSystem.Collections.Generic;usingSystem.Text;usingSystem.Text.Json;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);[MemoryDiagnoser]publicclassBench{privatebyte[]_indented=default!;privatebyte[]_minified=default!;privatebyte[]_deeplyIndented=default!;privatebyte[]_longRuns=default!;[GlobalSetup]publicvoidSetup(){objectgraph=MakeGraph(depth:4,breadth:4);_indented=JsonSerializer.SerializeToUtf8Bytes(graph,newJsonSerializerOptions{WriteIndented=true});_minified=JsonSerializer.SerializeToUtf8Bytes(graph,newJsonSerializerOptions{WriteIndented=false});// Deep nesting => longer indentation runs (2 spaces * depth), straddling the 16-byte promotion boundary._deeplyIndented=JsonSerializer.SerializeToUtf8Bytes(MakeGraph(depth:12,breadth:2),newJsonSerializerOptions{WriteIndented=true});// Explicit long whitespace runs => the vectorized tail path that must stay competitive on native._longRuns=MakeLongRunDocument(elements:2000,runLength:48);}privatestaticobjectMakeGraph(intdepth,intbreadth){if(depth==0){return"some string leaf value";}varnode=newDictionary<string,object>();for(inti=0;i<breadth;i++){node["child_"+i]=MakeGraph(depth-1,breadth);}node["number"]=1234567;node["flag"]=true;node["nullable"]=null!;node["array"]=newobject[]{1,2,3,"four",false};returnnode;}privatestaticbyte[]MakeLongRunDocument(intelements,intrunLength){stringws=newstring(' ',runLength-1);varsb=newStringBuilder();sb.Append('[');for(inti=0;i<elements;i++){if(i>0){sb.Append(',');}sb.Append('\n').Append(ws).Append(i);}sb.Append('\n').Append(']');returnEncoding.UTF8.GetBytes(sb.ToString());}privatestaticlongCountTokens(byte[]utf8){varreader=newUtf8JsonReader(utf8);longtokens=0;while(reader.Read()){tokens++;}returntokens;}[Benchmark]publiclongIndented()=>CountTokens(_indented);[Benchmark]publiclongMinified()=>CountTokens(_minified);[Benchmark]publiclongDeeplyIndented()=>CountTokens(_deeplyIndented);[Benchmark]publiclongLongWhitespaceRuns()=>CountTokens(_longRuns);}

Note

Benchmark authored with the assistance of GitHub Copilot.

CopilotAI 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 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 IndexOfFirstNonWhiteSpace scan in SkipWhiteSpace with 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.

FileDescription
src/libraries/System.Text.Json/src/System/Text/Json/Reader/Utf8JsonReader.csReintroduces 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.csAdds 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>
CopilotAI review requested due to automatic review settings July 10, 2026 14:32
@eiriktsarpalis

Copy link
Copy Markdown
MemberAuthor

Follow-up: the first EgorBot run exposed a real native regression — now gated to WASM

The 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:

ScenariomacOS M4 arm64Ubuntu EPYC x64Ubuntu N2 arm64
Indented (depth 4)~16% slower~19% faster~6% slower
Minifiedneutralneutralneutral
DeeplyIndented (depth 12)~2.1x slower~45% slower~2.1x slower
LongWhitespaceRuns (48-byte)~2.8x slower~79% slower~2x slower

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 (IndexOfFirstNonWhiteSpace + CountNewLines) on the remainder. On native, where SIMD entry is cheap, that pre-scan is pure overhead — native genuinely wants main's unconditional SearchValues scan.

Fix (pushed): gate the scalar pre-scan behind !OperatingSystem.IsBrowser(). It folds to a per-target constant, so:

  • CoreCLR / native — the pre-scan branch is dead-code-eliminated; codegen is identical to mainzero native delta.
  • Browser / WASM AOT — keeps the scalar pre-scan + promotion that fixes the reported regression (dotnet/perf-autofiling-issues#75553).

The vectorized tail is now a shared SkipWhiteSpaceVectorized helper used by both call sites. Correctness re-verified after the refactor: differential harness (both paths vs. the scalar oracle, 32.2M cases, 0 mismatches) + end-to-end reader verifier (90/90).

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's no clean "is-Mono-AOT" gate at this layer. Happy to broaden if we'd rather cover mobile AOT too.

Re-running the same four scenarios to confirm native is now neutral (every scenario should return to ~1.00 vs main):

@EgorBot -linux_amd -linux_arm64 -osx_arm64

usingSystem.Collections.Generic;usingSystem.Text;usingSystem.Text.Json;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);[MemoryDiagnoser]publicclassBench{privatebyte[]_indented=default!;privatebyte[]_minified=default!;privatebyte[]_deeplyIndented=default!;privatebyte[]_longRuns=default!;[GlobalSetup]publicvoidSetup(){objectgraph=MakeGraph(depth:4,breadth:4);_indented=JsonSerializer.SerializeToUtf8Bytes(graph,newJsonSerializerOptions{WriteIndented=true});_minified=JsonSerializer.SerializeToUtf8Bytes(graph,newJsonSerializerOptions{WriteIndented=false});// Deep nesting => longer indentation runs (2 spaces * depth), straddling the 16-byte promotion boundary._deeplyIndented=JsonSerializer.SerializeToUtf8Bytes(MakeGraph(depth:12,breadth:2),newJsonSerializerOptions{WriteIndented=true});// Explicit long whitespace runs => the vectorized tail path that must stay competitive on native._longRuns=MakeLongRunDocument(elements:2000,runLength:48);}privatestaticobjectMakeGraph(intdepth,intbreadth){if(depth==0){return"some string leaf value";}varnode=newDictionary<string,object>();for(inti=0;i<breadth;i++){node["child_"+i]=MakeGraph(depth-1,breadth);}node["number"]=1234567;node["flag"]=true;node["nullable"]=null!;node["array"]=newobject[]{1,2,3,"four",false};returnnode;}privatestaticbyte[]MakeLongRunDocument(intelements,intrunLength){stringws=newstring(' ',runLength-1);varsb=newStringBuilder();sb.Append('[');for(inti=0;i<elements;i++){if(i>0){sb.Append(',');}sb.Append('\n').Append(ws).Append(i);}sb.Append('\n').Append(']');returnEncoding.UTF8.GetBytes(sb.ToString());}privatestaticlongCountTokens(byte[]utf8){varreader=newUtf8JsonReader(utf8);longtokens=0;while(reader.Read()){tokens++;}returntokens;}[Benchmark]publiclongIndented()=>CountTokens(_indented);[Benchmark]publiclongMinified()=>CountTokens(_minified);[Benchmark]publiclongDeeplyIndented()=>CountTokens(_deeplyIndented);[Benchmark]publiclongLongWhitespaceRuns()=>CountTokens(_longRuns);}

Note

This comment and benchmark were authored with the assistance of GitHub Copilot.

CopilotAI 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 2 comments.

Comment threadsrc/libraries/System.Text.Json/src/System/Text/Json/JsonConstants.cs Outdated
@eiriktsarpalis

Copy link
Copy Markdown
MemberAuthor

Native re-run confirms the gate resolved the regression

The re-run after gating the pre-scan behind !OperatingSystem.IsBrowser() is in — EgorBot/Benchmarks#313, same four scenarios, three native machines. Ratios below are main relative to this PR (PR = baseline 1.00); < 1.00 means main is faster (PR slower), > 1.00 means PR is faster.

ScenariomacOS arm64Ubuntu EPYC x64Ubuntu N2 arm64
Indented (depth 4)0.970.981.01
Minified1.000.971.00
DeeplyIndented (depth 12)1.031.020.99
LongWhitespaceRuns (48-byte)1.210.921.07

For comparison, the ungated hybrid (#309) was regressing the long-run scenarios hard:

ScenariomacOSx64ARM
DeeplyIndented0.48 (~2.1× slower)0.690.47
LongWhitespaceRuns0.36 (~2.8× slower)0.560.51

Result: every native scenario is back to parity with main. DeeplyIndented moved from ~2× slower to 0.99–1.03, and LongWhitespaceRuns from ~2–2.8× slower to 0.92–1.21. This is the expected outcome of the gate — on native OperatingSystem.IsBrowser() folds to false, the scalar pre-scan is dead-code-eliminated, and codegen is identical to main.

The one mild negative — x64 LongWhitespaceRuns at 0.92 — is measurement noise, not a systematic regression: the same benchmark is 21% and 7% faster on the other two machines, and DeeplyIndented (also long-run) is neutral across all three. Inconsistent sign on a provably codegen-identical path = run-to-run variance on cloud VMs.

WASM parity is preserved by construction: the gate only wraps the previously-validated browser hybrid in if (IsBrowser()) — it doesn't change the browser code path — so the WASM AOT numbers in the PR description (indented 16.1 ns vs main 41.0 ns) still hold. Correctness was re-verified after the helper extraction: differential harness over both paths vs. the scalar oracle (32.2M cases, 0 mismatches) + 90/90 end-to-end reader assertions.

Net: the reported WASM regression (#75553) is fixed, and native is unaffected.

Note

This comment was authored with the assistance of GitHub Copilot.

@eiriktsarpalis

Copy link
Copy Markdown
MemberAuthor

Supplementary: is SearchValues worth it vs. no SearchValues at all?

The two runs above compare this PR against main, but main already uses SearchValues, so neither answers the underlying question: what does SearchValues actually buy over the pre-#129701 pure-scalar loop — the justification for vectorizing in the first place, and the reason the PR keeps it on native and promotes to it on WASM.

This is a self-contained three-way microbenchmark. All three SkipWhiteSpace strategies are implemented in the harness itself (they're the exact algorithms the correctness harness already proved equivalent over 32.2M cases), so it does not depend on the runtime build — the PR and main toolchain columns will be identical; read either one.Scalar (no SearchValues, the pre-#129701 behavior) is the BDN baseline, so every ratio is relative to not using SearchValues.

@EgorBot -linux_amd -linux_arm64 -osx_arm64

usingSystem;usingSystem.Buffers;usingSystem.Collections.Generic;usingSystem.Text;usingSystem.Text.Json;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);publicclassBench{privateconstbyteSpace=(byte)' ';privateconstbyteTab=(byte)'\t';privateconstbyteCR=(byte)'\r';privateconstbyteLF=(byte)'\n';privateconstintMaxScalarWhiteSpaceScanLength=16;privatestaticreadonlySearchValues<byte>s_ws=SearchValues.Create(" \t\r\n"u8);privatebyte[]_indented=default!;privatebyte[]_minified=default!;privatebyte[]_deeplyIndented=default!;privatebyte[]_longRuns=default!;publicenumDoc{Indented,Minified,DeeplyIndented,LongRuns}[Params(Doc.Indented,Doc.Minified,Doc.DeeplyIndented,Doc.LongRuns)]publicDocScenario;privatebyte[]Current=>Scenarioswitch{Doc.Indented=>_indented,Doc.Minified=>_minified,Doc.DeeplyIndented=>_deeplyIndented,
_ =>_longRuns,};[GlobalSetup]publicvoidSetup(){objectgraph=MakeGraph(depth:4,breadth:4);_indented=JsonSerializer.SerializeToUtf8Bytes(graph,newJsonSerializerOptions{WriteIndented=true});_minified=JsonSerializer.SerializeToUtf8Bytes(graph,newJsonSerializerOptions{WriteIndented=false});_deeplyIndented=JsonSerializer.SerializeToUtf8Bytes(MakeGraph(depth:12,breadth:2),newJsonSerializerOptions{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)]publiclongScalar()=>DriveScalar(Current);// Unconditional vectorized SearchValues scan = current `main`.[Benchmark]publiclongSearchValuesOnly()=>DriveSearchValues(Current);// Scalar pre-scan promoting to SearchValues after 16 bytes = this PR's browser/WASM path.[Benchmark]publiclongHybrid()=>DriveHybrid(Current);privatestaticlongDriveScalar(ReadOnlySpan<byte>span){intpos=0;longline=0,bytePos=0;while(pos<span.Length){pos=SkipScalar(span,pos,refline,refbytePos);pos=SkipToken(span,pos);}returnline+bytePos;}privatestaticlongDriveSearchValues(ReadOnlySpan<byte>span){intpos=0;longline=0,bytePos=0;while(pos<span.Length){pos=SkipSearchValues(span,pos,refline,refbytePos);pos=SkipToken(span,pos);}returnline+bytePos;}privatestaticlongDriveHybrid(ReadOnlySpan<byte>span){intpos=0;longline=0,bytePos=0;while(pos<span.Length){pos=SkipHybrid(span,pos,refline,refbytePos);pos=SkipToken(span,pos);}returnline+bytePos;}privatestaticintSkipToken(ReadOnlySpan<byte>span,intpos){while(pos<span.Length){bytev=span[pos];if(visSpace or Tab or CR or LF)break;pos++;}returnpos;}privatestaticintSkipScalar(ReadOnlySpan<byte>span,intpos,reflongline,reflongbytePos){for(;pos<span.Length;pos++){byteval=span[pos];if(valis not Space and not CR and not LF and not Tab)break;if(val==LF){line++;bytePos=0;}else{bytePos++;}}returnpos;}privatestaticintSkipSearchValues(ReadOnlySpan<byte>span,intpos,reflongline,reflongbytePos){ReadOnlySpan<byte>remaining=span.Slice(pos);intidx=IndexOfFirstNonWhiteSpace(remaining);if(idx>0){(intnewLines,intlastLineFeedIndex)=CountNewLines(remaining.Slice(0,idx));line+=newLines;if(lastLineFeedIndex>=0){bytePos=idx-lastLineFeedIndex-1;}else{bytePos+=idx;}}returnpos+idx;}privatestaticintSkipHybrid(ReadOnlySpan<byte>span,intpos,reflongline,reflongbytePos){intrun=0;for(;pos<span.Length;pos++){byteval=span[pos];if(valis not Space and not CR and not LF and not Tab)break;if(val==LF){line++;bytePos=0;}else{bytePos++;}if(++run==MaxScalarWhiteSpaceScanLength){returnSkipSearchValues(span,pos+1,refline,refbytePos);}}returnpos;}privatestaticintIndexOfFirstNonWhiteSpace(ReadOnlySpan<byte>span){intindex=span.IndexOfAnyExcept(s_ws);returnindex<0?span.Length:index;}privatestatic(int,int)CountNewLines(ReadOnlySpan<byte>data){intlastLineFeedIndex=data.LastIndexOf(LF);intnewLines=0;if(lastLineFeedIndex>=0){newLines=1;data=data.Slice(0,lastLineFeedIndex);newLines+=data.Count(LF);}return(newLines,lastLineFeedIndex);}privatestaticobjectMakeGraph(intdepth,intbreadth){if(depth==0){return"some string leaf value";}varnode=newDictionary<string,object>();for(inti=0;i<breadth;i++){node["child_"+i]=MakeGraph(depth-1,breadth);}node["number"]=1234567;node["flag"]=true;node["nullable"]=null!;node["array"]=newobject[]{1,2,3,"four",false};returnnode;}privatestaticbyte[]MakeLongRunDocument(intelements,intrunLength){stringws=newstring(' ',runLength-1);varsb=newStringBuilder();sb.Append('[');for(inti=0;i<elements;i++){if(i>0){sb.Append(',');}sb.Append('\n').Append(ws).Append(i);}sb.Append('\n').Append(']');returnEncoding.UTF8.GetBytes(sb.ToString());}}

Note

This comment and benchmark were authored with the assistance of GitHub Copilot.

Replace the hybrid scalar-pre-scan + vectorized-promotion approach with a straightforward gate: on browser (Mono/WASM AOT) SkipWhiteSpace uses the plain pre-existing scalar loop; every other target keeps the unconditional vectorized SearchValues scan.
OperatingSystem.IsBrowser() folds to a per-target constant, so exactly one path survives after dead-code elimination. Native codegen stays byte-identical to main (no regression, and we forgo the browser vectorization win by design), while the reported WASM regression (#75553) is removed. Removes the SkipWhiteSpaceVectorized helper and the MaxScalarWhiteSpaceScanLength constant the hybrid required.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c9c7bcc8-88ac-491c-82d7-1a208f5a438e

CopilotAI 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 1 out of 1 changed files in this pull request and generated no new comments.

CopilotAI 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 1 out of 1 changed files in this pull request and generated 1 comment.

@eiriktsarpalis
eiriktsarpalis merged commit 6d47555 into mainJul 14, 2026
86 of 89 checks passed
@eiriktsarpalis
eiriktsarpalis deleted the eiriktsarpalis-perf-regression-triage-75553 branch July 14, 2026 16:54
@dotnet-milestone-botdotnet-milestone-botBot added this to the 11.0-preview7 milestone Jul 15, 2026
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 15, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

arch-wasmWebAssembly architecturearea-System.Text.Json

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@eiriktsarpalis@tannergooding@pavelsavara