Skip to content

Fix WASM/Mono AOT regression in Utf8JsonReader.SkipWhiteSpace#130484

Open
eiriktsarpalis wants to merge 2 commits into
mainfrom
eiriktsarpalis-perf-regression-triage-75553
Open

Fix WASM/Mono AOT regression in Utf8JsonReader.SkipWhiteSpace#130484
eiriktsarpalis wants to merge 2 commits into
mainfrom
eiriktsarpalis-perf-regression-triage-75553

Conversation

@eiriktsarpalis

@eiriktsarpalis eiriktsarpalis 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 (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 SkipWhiteSpace with 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 SkipWhiteSpace call, for two compounding reasons:

  1. Fixed SIMD-entry cost. A bare 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.
  2. Multi-pass bookkeeping. SkipWhiteSpace must also maintain _lineNumber and _bytePositionInLine. IndexOfAnyExcept returns only a single index and discards the intra-run structure, so reconstructing those forces CountNewLines = 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 in 6fcd59b), 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 vectorized IndexOfFirstNonWhiteSpace + CountNewLines path only once a run exceeds JsonConstants.MaxScalarWhiteSpaceScanLength (16 bytes).

if (!OperatingSystem.IsBrowser())
{
    SkipWhiteSpaceVectorized(_consumed);   // native: identical to main
    return;
}
// browser/WASM: scalar pre-scan, promote to SkipWhiteSpaceVectorized on long runs

OperatingSystem.IsBrowser() folds to a per-target constant, so:

  • CoreCLR / native — the pre-scan branch is dead-code-eliminated; codegen is identical to mainno native change.
  • Browser / WASM AOT — keeps the scalar pre-scan + promotion that removes the regression.

The vectorized tail is extracted into a shared SkipWhiteSpaceVectorized helper 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:

Scenario macOS M4 arm64 Ubuntu EPYC x64 Ubuntu N2 arm64
Indented (depth 4) ~16% slower ~19% faster ~6% slower
Minified neutral neutral neutral
DeeplyIndented (depth 12) ~2.1× slower ~45% slower ~2.1× slower
LongWhitespaceRuns (48-byte) ~2.8× slower ~79% slower ~2× slower

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 unconditional SearchValues scan, 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 SkipWhiteSpace call (lower is better):

Document pre-#129701 scalar main (SearchValues) this PR
indented 4 KB 15.8 41.0 16.1
indented 40 KB 15.8 41.1 16.2
indented 400 KB 15.9 41.3 16.2
minified 40 KB 10.4 26.7 11.4

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 vs main follows 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.

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
Member Author

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

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.

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

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.

Comment thread src/libraries/System.Text.Json/src/System/Text/Json/Reader/Utf8JsonReader.cs Outdated
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>
Copilot AI review requested due to automatic review settings July 10, 2026 14:32
@eiriktsarpalis

Copy link
Copy Markdown
Member Author

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:

Scenario macOS M4 arm64 Ubuntu EPYC x64 Ubuntu N2 arm64
Indented (depth 4) ~16% slower ~19% faster ~6% slower
Minified neutral neutral neutral
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

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.

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

Comment on lines 1009 to +1013
// 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
Comment on lines +48 to +54
// 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;
@eiriktsarpalis

Copy link
Copy Markdown
Member Author

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.

Scenario macOS arm64 Ubuntu EPYC x64 Ubuntu N2 arm64
Indented (depth 4) 0.97 0.98 1.01
Minified 1.00 0.97 1.00
DeeplyIndented (depth 12) 1.03 1.02 0.99
LongWhitespaceRuns (48-byte) 1.21 0.92 1.07

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

Scenario macOS x64 ARM
DeeplyIndented 0.48 (~2.1× slower) 0.69 0.47
LongWhitespaceRuns 0.36 (~2.8× slower) 0.56 0.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
Member Author

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants