Skip to content

Use SearchValues in Utf8JsonReader.SkipWhiteSpace - #129701

Merged
eiriktsarpalis merged 3 commits into
dotnet:mainfrom
eiriktsarpalis:eiriktsarpalis-simd-json-parsing
Jun 24, 2026
Merged

Use SearchValues in Utf8JsonReader.SkipWhiteSpace#129701
eiriktsarpalis merged 3 commits into
dotnet:mainfrom
eiriktsarpalis:eiriktsarpalis-simd-json-parsing

Conversation

@eiriktsarpalis

@eiriktsarpaliseiriktsarpalis commented Jun 22, 2026

Copy link
Copy Markdown
Member

Summary

Applies the one transferable idea from the simdjson paper (arXiv:1902.08318)vectorized scanning to the next "interesting" byte — to Utf8JsonReader's whitespace-skipping hot path, without any source or binary breaking changes.

simdjson's signature techniques (whole-buffer two-pass structural indexing, clmul quote masking, vpshufb classification, bulk UTF-8 pre-validation) are fundamentally incompatible with Utf8JsonReader's streaming, single-pass, zero-allocation, forward-only contract. But the spirit of its stage-1 classification — "advance to the next non-whitespace byte" — maps cleanly onto the runtime's portable SearchValues/IndexOfAnyExcept, which the reader already uses for its string scan.

Approach

On .NET, SkipWhiteSpace scans straight to the first non-whitespace byte with a vectorized IndexOfAnyExcept(SearchValues), reproducing the exact _lineNumber / _bytePositionInLine bookkeeping via the existing JsonReaderHelper.CountNewLines helper. SearchValues already handles short and long inputs efficiently, so there is no scalar pre-scan or threshold — the no-whitespace case (e.g. minified JSON) is handled by IndexOfAnyExcept itself at no measurable cost.

All changes are internal and gated on #if NET (netstandard keeps the pure scalar loop). No public API, no new instance fields, no ref struct layout change ⇒ source- and binary-compatible. The multi-segment reader benefits for free, since SkipWhiteSpaceMultiSegment delegates to SkipWhiteSpace.

A digit-scan vectorization (ConsumeIntegerDigits) was also prototyped but dropped: real JSON numbers are short (≤ 17–19 digits), so the scalar loop already wins and a hybrid regressed the mid-range.

Performance

Measured with BenchmarkDotNet (in-process, net11.0 host) using a faithful driver that marks each SkipWhiteSpace variant [MethodImpl(NoInlining)] (matching the real reader, which is not inlined) and walks realistic serialized JSON token-by-token. A whole-document checksum (_consumed + _lineNumber + _bytePositionInLine) is byte-identical across all variants.

DocumentBaselineThis PRRatio
minified45.4 µs45.5 µs1.00× — neutral
pretty, shallow (common)1,137 µs1,037 µs0.91× — ~10% faster
pretty, medium (common)849 µs574 µs0.68× — ~1.5× faster
pretty, deeply nested1,248 µs519 µs0.42× — ~2.4× faster

An earlier revision of this PR gated the vectorized scan behind a 16-byte scalar prefix. Benchmarks showed that gate regressed the common shallow/medium pretty cases (1.03×/1.07× slower than baseline) because of per-byte counter overhead that is rarely amortized, so it was removed in favor of the unconditional scan above. Validation on real hardware via @EgorBot / the perf lab is recommended before merge, since the win is concentrated in whitespace-heavy / deeply-indented documents.

Correctness & tests

  • Full System.Text.Json.Tests suite passes on both target frameworks (net11.0 and net481), 0 failures (52,633 / 52,395).
  • Adds two targeted tests covering long whitespace runs with embedded newlines, \r\n runs, leading tabs, and all-whitespace tails, verifying LineNumber / BytePositionInLine.
  • An independent whole-document checksum (final _consumed + _lineNumber + _bytePositionInLine) is byte-identical between the scalar baseline and the vectorized path across minified, pretty, and deeply-nested documents.

Notes

This is intentionally a small, focused, non-breaking change rather than an attempt to restructure the reader toward simdjson's architecture (which would require a whole-buffer, indexing parser and break streaming/positional contracts).

Note

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

Apply the transferable idea from the simdjson paper (arXiv:1902.08318) -- vectorized scanning to the next interesting byte -- to the reader's whitespace-skipping hot path.
SkipWhiteSpace now uses a hybrid strategy: the existing scalar loop handles the first MaxScalarWhiteSpaceScanLength (16) whitespace bytes (the common case, at no added cost since the threshold check lives inside the whitespace branch), then hands longer runs to a vectorized IndexOfAnyExcept(SearchValues) scan, reproducing the exact _lineNumber/_bytePositionInLine bookkeeping via the existing CountNewLines helper. All changes are internal and gated on #if NET (netstandard keeps the pure scalar loop), so there is no public API or ref-struct layout change -- source- and binary-compatible.
End-to-end this is neutral on minified/shallow-pretty documents and ~20% faster on deeply-nested pretty JSON; the isolated whitespace scan is 2-7x faster on long runs. Adds targeted tests covering the scalar-to-vector boundary including embedded newlines.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@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.

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 optimizes Utf8JsonReader.SkipWhiteSpace by adding a .NET-only hybrid scalar→vectorized whitespace scan for long whitespace runs, aiming to improve throughput on whitespace-heavy JSON while keeping common cases effectively unchanged.

Changes:

  • Adds a #if NET long-run fallback in Utf8JsonReader.SkipWhiteSpace that uses a vectorized “find first non-whitespace” search plus newline/byte-position bookkeeping via JsonReaderHelper.CountNewLines.
  • Introduces a SearchValues<byte>-backed IndexOfFirstNonWhiteSpace helper for .NETCoreApp builds.
  • Adds targeted reader tests for long whitespace runs and for correct LineNumber / BytePositionInLine reporting after long whitespace before an invalid token.

Reviewed changes

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

FileDescription
src/libraries/System.Text.Json/src/System/Text/Json/Reader/Utf8JsonReader.csAdds a .NET-only scalar threshold and then vectorized scan for long whitespace runs, preserving line/byte bookkeeping.
src/libraries/System.Text.Json/src/System/Text/Json/Reader/JsonReaderHelper.net8.csAdds SearchValues-based whitespace classification and IndexOfFirstNonWhiteSpace helper for vectorized scanning.
src/libraries/System.Text.Json/src/System/Text/Json/JsonConstants.csIntroduces MaxScalarWhiteSpaceScanLength constant to control the scalar→vector threshold.
src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Utf8JsonReaderTests.csAdds tests covering long whitespace runs and exception location reporting after long whitespace.

@eiriktsarpalis

Copy link
Copy Markdown
MemberAuthor

@EgorBot -amd -intel -arm64 --filter "System.Text.Json.Read"

Runs the existing dotnet/performance System.Text.Json reader benchmarks (Perf_Reader.*, ReadJson<T>.*, etc.) comparing this PR branch against main on AMD x64, Intel x64, and Apple Silicon arm64, to validate the SkipWhiteSpace vectorization.

Note

This comment was generated with the assistance of GitHub Copilot.

@eiriktsarpalis

Copy link
Copy Markdown
MemberAuthor

@EgorBot -amd -intel -arm64 --filter "System.Text.Json.DeserializeFromString"

Benchmarks showed the scalar-prefix threshold (the hybrid gate) regressed the
common shallow/medium pretty-printed shapes (~1.03x/1.07x slower than baseline)
because of per-byte counter overhead that is rarely amortized, and it only
vectorized the run after the first 16 bytes. Always handing the run to the
SearchValues-based IndexOfAnyExcept is both simpler and faster on every
whitespace-bearing shape (0.91x shallow, 0.68x medium, 0.42x deep pretty) and
neutral on minified. Removes the MaxScalarWhiteSpaceScanLength constant and the
hybrid bookkeeping.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@eiriktsarpaliseiriktsarpalis changed the title Vectorize Utf8JsonReader.SkipWhiteSpace for long whitespace runsVectorize Utf8JsonReader.SkipWhiteSpaceJun 23, 2026
@eiriktsarpalis

Copy link
Copy Markdown
MemberAuthor

@EgorBot -amd -intel -arm64 --filter "System.Text.Json.Read"

Re-running after simplifying SkipWhiteSpace to an unconditional vectorized IndexOfAnyExcept(SearchValues) scan (the earlier 16-byte scalar gate was removed because it regressed common pretty-printed documents). This supersedes the previous benchmark run, which exercised the now-replaced gated implementation.

Note

This comment was created with the assistance of GitHub Copilot.

CopilotAI review requested due to automatic review settings June 23, 2026 11:38
@eiriktsarpalis

Copy link
Copy Markdown
MemberAuthor

@EgorBot -amd -intel -arm64 --filter "System.Text.Json.DeserializeFromString"

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

@eiriktsarpalis

Copy link
Copy Markdown
MemberAuthor

Bespoke benchmark focused on Utf8JsonReader parsing of whitespace-heavy payloads (this is what SkipWhiteSpace dominates). It walks the reader token-by-token via Read() over four documents spanning the whitespace spectrum:

  • Minified — control, exercises the no-whitespace fast path (should stay neutral).
  • Pretty — standard pretty-printed object graph (2-space indent).
  • PrettyDeepNested — deeply nested pretty-printed graph → long indentation runs per token.
  • WhitespaceHeavy — synthetic doc with large mixed \n+tab whitespace runs inserted at every legal position (also stresses the newline-counting bookkeeping).

@EgorBot -amd -intel -arm64

usingSystem;usingSystem.Buffers;usingSystem.Text;usingSystem.Text.Json;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(JsonWhitespaceReadBench).Assembly).Run(args);[MemoryDiagnoser]publicclassJsonWhitespaceReadBench{privatebyte[]_payload=default!;[Params("Minified","Pretty","PrettyDeepNested","WhitespaceHeavy")]publicstringPayload="Pretty";[GlobalSetup]publicvoidSetup(){_payload=Payloadswitch{"Minified"=>BuildDoc(depth:4,breadth:4,indented:false),"Pretty"=>BuildDoc(depth:4,breadth:4,indented:true),"PrettyDeepNested"=>BuildDoc(depth:8,breadth:2,indented:true),"WhitespaceHeavy"=>BuildWhitespaceHeavy(count:2000),
_ =>thrownewArgumentOutOfRangeException(nameof(Payload)),};}[Benchmark]publiclongRead(){varreader=newUtf8JsonReader(_payload,isFinalBlock:true,state:default);longtokens=0;while(reader.Read()){tokens++;}returntokens;}privatestaticbyte[]BuildDoc(intdepth,intbreadth,boolindented){varbuffer=newArrayBufferWriter<byte>();using(varwriter=newUtf8JsonWriter(buffer,newJsonWriterOptions{Indented=indented})){WriteObject(writer,depth,breadth);}returnbuffer.WrittenSpan.ToArray();}privatestaticvoidWriteObject(Utf8JsonWriterw,intdepth,intbreadth){w.WriteStartObject();w.WriteString("name","some descriptive name value");w.WriteNumber("id",1234567);w.WriteBoolean("enabled",true);w.WriteNull("optional");w.WriteString("timestamp","2024-01-01T12:00:00Z");w.WriteStartArray("tags");for(inti=0;i<breadth;i++){w.WriteStringValue("tag-value-"+i);}w.WriteEndArray();if(depth>0){w.WriteStartArray("children");for(inti=0;i<breadth;i++){WriteObject(w,depth-1,breadth);}w.WriteEndArray();}w.WriteEndObject();}privatestaticbyte[]BuildWhitespaceHeavy(intcount){// Valid JSON array with large mixed-whitespace runs (newline + tabs) inserted// at every legal position to stress the whitespace-skipping path.conststringWs="\n\t\t\t\t\t\t\t\t";varsb=newStringBuilder();sb.Append('[');for(inti=0;i<count;i++){if(i>0){sb.Append(',');}sb.Append(Ws).Append('{');sb.Append(Ws).Append("\"id\":").Append(Ws).Append(i);sb.Append(Ws).Append(",\"name\":").Append(Ws).Append("\"item").Append(i).Append('"');sb.Append(Ws).Append(",\"active\":").Append(Ws).Append(i%2==0?"true":"false");sb.Append(Ws).Append('}');}sb.Append(Ws).Append(']');returnEncoding.UTF8.GetBytes(sb.ToString());}}

Note

This comment was created with the assistance of GitHub Copilot.

@eiriktsarpalis

Copy link
Copy Markdown
MemberAuthor

Change does not regress existing benchmarks while showing significant performance gains in a custom benchmark processing whitespace: EgorBot/Benchmarks#264 (comment)

@eiriktsarpaliseiriktsarpalis added the tenet-performance Performance related issue label Jun 23, 2026

@tannergoodingtannergooding left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

As a general nit, I wouldn't call this vectorization and I think using the term here is a bit misleading. This is rather just reusing the existing helper APIs, which happen to be vectorized, but for which that is actually an implementation detail and we'll just do whatever is "fastest" for the hardware/scenario

@MihaZupan

Copy link
Copy Markdown
Member

Neat, the " \t\r\n" set can actually make use of the even better implementation than the general ASCII one, close to that of a simple IndexOf(T) - #106900 has throughput comparisons for different hardware at the bottom of the PR description.

@eiriktsarpalis

Copy link
Copy Markdown
MemberAuthor

As a general nit, I wouldn't call this vectorization and I think using the term here is a bit misleading.

Agreed, I'll update the PR title so that at least this gets reflected in the commit. As for the rest of the PR description, I've given up on trying to rein in Copilot :-)

@eiriktsarpaliseiriktsarpalis changed the title Vectorize Utf8JsonReader.SkipWhiteSpaceUse SearchValues in Utf8JsonReader.SkipWhiteSpaceJun 24, 2026
@eiriktsarpalis
eiriktsarpalis enabled auto-merge (squash) June 24, 2026 06:19
@eiriktsarpalis

Copy link
Copy Markdown
MemberAuthor

/ba-g test failure is unrelated

@eiriktsarpalis
eiriktsarpalis merged commit 65b2508 into dotnet:mainJun 24, 2026
95 of 98 checks passed
@eiriktsarpalis
eiriktsarpalis deleted the eiriktsarpalis-simd-json-parsing branch June 24, 2026 06:25
eiriktsarpalis added a commit that referenced this pull request Jul 14, 2026
…130484)
## Summary
[#129701](#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](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:
```csharp
if (!OperatingSystem.IsBrowser())
{
// vectorized SearchValues scan — unchanged from main
return;
}
// 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`:
| Document | scalar (this PR) | `SearchValues` (`main`) |
|---|---|---|
| indented 4 KB | 16.3 | 43.2 |
| indented 40 KB | 16.4 | 43.2 |
| indented 400 KB | 16.3 | 43.0 |
| minified 40 KB | 10.7 | 27.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.
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
eiriktsarpalis added a commit that referenced this pull request Jul 15, 2026
## Summary
Applies the one transferable idea from the [simdjson paper
(arXiv:1902.08318)](https://arxiv.org/pdf/1902.08318) — *vectorized
scanning to the next "interesting" byte* — to `Utf8JsonReader`'s
whitespace-skipping hot path, **without any source or binary breaking
changes**.
simdjson's signature techniques (whole-buffer two-pass structural
indexing, `clmul` quote masking, `vpshufb` classification, bulk UTF-8
pre-validation) are fundamentally incompatible with `Utf8JsonReader`'s
streaming, single-pass, zero-allocation, forward-only contract. But the
*spirit* of its stage-1 classification — "advance to the next
non-whitespace byte" — maps cleanly onto the runtime's portable
`SearchValues`/`IndexOfAnyExcept`, which the reader already uses for its
string scan.
## Approach
On .NET, `SkipWhiteSpace` scans straight to the first non-whitespace
byte with a vectorized `IndexOfAnyExcept(SearchValues)`, reproducing the
exact `_lineNumber` / `_bytePositionInLine` bookkeeping via the existing
`JsonReaderHelper.CountNewLines` helper. `SearchValues` already handles
short and long inputs efficiently, so there is **no scalar pre-scan or
threshold** — the no-whitespace case (e.g. minified JSON) is handled by
`IndexOfAnyExcept` itself at no measurable cost.
All changes are **internal** and gated on `#if NET` (netstandard keeps
the pure scalar loop). No public API, no new instance fields, no `ref
struct` layout change ⇒ source- and binary-compatible. The multi-segment
reader benefits for free, since `SkipWhiteSpaceMultiSegment` delegates
to `SkipWhiteSpace`.
A digit-scan vectorization (`ConsumeIntegerDigits`) was also prototyped
but **dropped**: real JSON numbers are short (≤ 17–19 digits), so the
scalar loop already wins and a hybrid regressed the mid-range.
## Performance
Measured with BenchmarkDotNet (in-process, net11.0 host) using a
faithful driver that marks each `SkipWhiteSpace` variant
`[MethodImpl(NoInlining)]` (matching the real reader, which is not
inlined) and walks realistic serialized JSON token-by-token. A
whole-document checksum (`_consumed` + `_lineNumber` +
`_bytePositionInLine`) is byte-identical across all variants.
| Document | Baseline | This PR | Ratio |
|---|---:|---:|---:|
| minified | 45.4 µs | 45.5 µs | **1.00×** — neutral |
| pretty, shallow (common) | 1,137 µs | 1,037 µs | **0.91×** — ~10%
faster |
| pretty, medium (common) | 849 µs | 574 µs | **0.68×** — ~1.5× faster |
| pretty, deeply nested | 1,248 µs | 519 µs | **0.42×** — ~2.4× faster |
An earlier revision of this PR gated the vectorized scan behind a
16-byte scalar prefix. Benchmarks showed that gate *regressed* the
common shallow/medium pretty cases (1.03×/1.07× **slower** than
baseline) because of per-byte counter overhead that is rarely amortized,
so it was removed in favor of the unconditional scan above. Validation
on real hardware via @EgorBot / the perf lab is recommended before
merge, since the win is concentrated in whitespace-heavy /
deeply-indented documents.
## Correctness & tests
- Full `System.Text.Json.Tests` suite passes on **both** target
frameworks (`net11.0` and `net481`), 0 failures (52,633 / 52,395).
- Adds two targeted tests covering long whitespace runs with embedded
newlines, `\r\n` runs, leading tabs, and all-whitespace tails, verifying
`LineNumber` / `BytePositionInLine`.
- An independent whole-document checksum (final `_consumed` +
`_lineNumber` + `_bytePositionInLine`) is byte-identical between the
scalar baseline and the vectorized path across minified, pretty, and
deeply-nested documents.
## Notes
This is intentionally a small, focused, non-breaking change rather than
an attempt to restructure the reader toward simdjson's architecture
(which would require a whole-buffer, indexing parser and break
streaming/positional contracts).
> [!NOTE]
> This pull request was created with the assistance of GitHub Copilot.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 26, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@eiriktsarpalis@MihaZupan@tannergooding@PranavSenthilnathan