Skip to content

fix: Improve jreader throughput with a single-pass tokenizer - #52

Merged
kinyoklion merged 3 commits into
v4from
rlamb/sdk-2884/single-pass-jreader
Aug 11, 2026
Merged

fix: Improve jreader throughput with a single-pass tokenizer#52
kinyoklion merged 3 commits into
v4from
rlamb/sdk-2884/single-pass-jreader

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Aug 7, 2026

Copy link
Copy Markdown
Member

SDK-2884

The parse-side counterpart of #51. jreader's default tokenizer scanned input character by character: readString walked every character through bytes.Reader.ReadRune (a method call plus UTF-8 decode per character, even for plain ASCII — the single hottest function when parsing a real flag payload), escaped strings were built by appending one rune at a time, and scalar reads shuttled 48-byte token structs through two call layers.

This PR rewrites the tokenizer as a single-pass scanner over the input byte slice:

  • Strings scan in place. Unescaped strings keep the existing zero-copy behavior (the returned bytes are a subslice of the input, sliced identically to before); since only " and \ end the scan, plain strings — including non-ASCII ones — are scanned without any UTF-8 decoding. Strings containing escapes decode in one forward pass into a single buffer sized up front, bulk-copying clean runs, with \uXXXX decoded by direct indexing. bytes.Reader and per-rune appends are gone.
  • Scalar reads (Bool, Number, String, PropertyName, Any) dispatch on the first non-whitespace byte and parse directly, entering the token path only for pushed-back tokens or type mismatches, so all error construction still goes through the same primitives. next() parses into a reused field on the tokenReader instead of returning token structs; putBack is a flag flip.
  • A bytes.IndexByte-based variant (find the closing quote, then scan the span) was benchmarked and not taken: ~6x faster on 400-byte strings but 15-20% slower on short ones, and on the real flag payload the two were statistically indistinguishable (p=1.000), so the simpler loop stays.

This is strictly a performance change, rebased onto the #55 revert: with the baseline deliberately kept lenient to minimize behavioral change, the rewrite preserves the current implementation's observable behavior exactly — exported API, parsed values, error types, error message strings, and error offsets are all identical. That includes the baseline's deliberate looseness, verified case by case:

  • Whitespace between tokens is unicode.IsSpace applied per byte, so raw 0x0B, 0x0C, 0x85, and 0xA0 bytes are accepted as whitespace.
  • Unescaped strings pass control characters and invalid UTF-8 through verbatim; strings containing an escape re-encode each invalid byte as U+FFFD (the existing asymmetry).
  • \uXXXX surrogate code points encode as U+FFFD without pair combining, exactly as the current appendRune does.
  • Number scanning is the same lenient scan, including its exact input positions after a malformed number (observable through RequireEOF), and integer literals beyond int64 wrap exactly as before.

Correctness validation

Beyond the existing suite (green with -race; the cross-permutation commontest matrices cover both dispatch paths), a differential harness ran the current (37b14d6) and new tokenizers in lock-step over 1,020,486 paired runs across 145,464 inputs with zero mismatches: exhaustive one- and two-byte string contents, all 65,536 \uXXXX values, surrogate grids, invalid-UTF-8 sequences at eight positions, all 256 single bytes in each inter-token position (for the per-byte whitespace rule), int64 extremes, -0.0, denormals, overflow exponents, 400-digit literals, malformed numbers, every prefix-truncation of a mixed document, and random documents with trailing junk and byte mutations. Comparisons covered decoded values (bit-exact floats, including wrapped int64 literals), string bytes and nilness, error type/fields/message/offset, RequireEOF, and failed-state stickiness. The harness's sensitivity was proven by seeded mutations: an ASCII-only whitespace table produced 204 mismatches, and surrogate pair combining produced 11,806. The edge cases the in-repo suites did not reach are now pinned by table-driven tests (exact error type, message, value, and offset), bringing token_reader_default.go to 100% statement coverage.

Benchmarks

linux/amd64, interleaved old/new binaries (3 rounds x count 2, benchstat n=6, all listed deltas significant at p<=0.002). Micro geomean -56.7% vs the current v4 baseline:

ReadBoolean                    -42.9%
ReadNumberInt                  -47.9%
ReadString                     -55.7%
ReadArrayOfStrings             -52.2%   (allocs 208 -> 158)
ReadStringKinds/shortASCII     -71%
ReadStringKinds/longASCII      -84%
ReadStringKinds/multiByte      -75.9%
ReadStringKinds/escaped        -63.8%   (allocs 101 -> 51)
ReadObjectNoAlloc              -54.0%
ReadArrayOfObjects             -51.0%

On a real 3,228-flag / 3.2 MB LaunchDarkly payload parsed through ldmodel (go-server-sdk-evaluation): direct jreader parse 38.6 ms -> 19.1 ms (-50.5%); through the encoding/json-dispatch path, 70.0 ms -> 50.2 ms (-28.2%). For reference, the v3 easyjson lexer's advantage over the old default reader on the same payload was ~19% — relevant because v4 removed the easyjson adapters, and this closes that gap with margin.

All NoAlloc benchmarks remain at 0 allocs/op (CI gate).

Notes for reviewers

  • The first-byte dispatch in any() and the pushed-back-token path (tokenToAnyValue) are parallel switches that must stay in sync; both are covered by the commontest permutation matrices and the differential corpus.
  • readNumber's consume/unread positions on malformed input (e.g. where the scan stops in 1ex vs 1e+x) are the subtlest part of the port; they are reproduced exactly and covered by the differential corpus.
  • decodedStringCapacity pre-scans an escaped string's remainder once to size the decode buffer — a deliberate two-scans/one-allocation trade.
  • Number parsing still allocates one string per float (strconv.ParseFloat); avoiding it would require unsafe, which this repo does not use.

Note

Overview
Rewrites the default jreader tokenizer as a single-pass byte scanner, roughly doubling parse throughput on real LaunchDarkly payloads while preserving observable behavior.

Strings no longer walk every character through bytes.Reader.ReadRune. Unescaped strings stay zero-copy subslices; escaped strings decode in one forward pass into a pre-sized buffer with bulk copies of plain runs. Scalar reads (Bool, Number, String, Any) dispatch on the first non-whitespace byte and parse directly, falling back to the token path only for pushed-back tokens or type mismatches. next() writes into a reused tok field instead of returning 48-byte token structs.

Adds lookup tables for whitespace and plain string bytes, new edge-case tests covering malformed numbers/strings and pushed-back tokens, and BenchmarkReadStringKinds for the distinct string paths.

Reviewed by Cursor Bugbot for commit 63ffa7c. Bugbot is set up for automated code reviews on this repo. Configure here.

@kinyoklion
kinyoklion marked this pull request as ready for review August 7, 2026 22:49
@kinyoklion
kinyoklion requested a review from a team as a code owner August 7, 2026 22:49
Comment thread jreader/token_reader_default.go Outdated
The default tokenizer previously walked every string character through
bytes.Reader.ReadRune (a method call and UTF-8 decode per character,
even for plain ASCII), decoded escaped strings by appending one rune at
a time to a growing buffer, and copied token structs by value through
every scalar read.

Behaviorally nothing changes: the exported API, decoded values, error
types, error messages, and error offsets are all identical. In
particular, the tokenizer still skips any byte that unicode.IsSpace
matches as a Latin-1 code point between tokens, passes unescaped
strings through byte-for-byte as zero-copy subslices (including
invalid UTF-8 and control characters), substitutes the replacement
character for each invalid UTF-8 byte only in strings that contain an
escape, encodes a \u-escaped surrogate code point as the replacement
character, and keeps the same lenient number grammar (including int64
wraparound for very long integer literals).

Structurally:

- Strings are scanned in place over the input byte slice; a string
  without escapes is found with a two-byte-sentinel scan (the quote
  mark and the backslash are the only bytes that cannot pass through
  verbatim) and returned as a zero-copy subslice of the input.
- A string with escapes is decoded in one forward pass into a single
  buffer sized from the string's raw length, bulk-copying each run of
  plain characters.
- \u escapes are decoded by direct indexing instead of through a
  bytes.Reader.
- next() now parses into a token field on the tokenReader instead of
  returning token structs by value, and putBack flips a flag instead
  of storing a token.
- Bool, Number, StringAsBytes, PropertyName, and Any dispatch on the
  first non-whitespace byte and parse the value directly, going
  through the token machinery only when a token has been pushed back
  or the input is not the expected type.
- Whitespace and keyword scanning index the input directly (whitespace
  through a table built from unicode.IsSpace) instead of going through
  per-byte reader method calls.

A bytes.IndexByte-based scan was also measured for the zero-copy path:
it is several times faster on long strings but 15-20% slower on short
ones, and on a real flag payload the two are statistically
indistinguishable, so the simpler loop wins.

Interleaved benchmarks (benchstat, n=6): -43% boolean reads, -48%
integer reads, -56% short string reads with a 3x-6x string-scan
throughput increase, -64% escaped strings with half the allocations,
-54% typical object parsing, -57% geomean across the jreader suite.
Parsing a real 3,228-flag LaunchDarkly payload through ldmodel
improves 50%.
Table-driven cases for the paths the permutation suites do not reach:
malformed numbers (exponent truncations, lone minus), string decoding
failures (unterminated forms, bad escapes, bad or truncated unicode
escapes) and pass-through of invalid UTF-8 after an escape, missing
colons after property names, wrong tokens where an array or object
needs a comma or end delimiter, and each entry point's interaction with
a pushed-back token. Errors are asserted exactly: type, message, value,
and offset.

Three defensive helper branches are covered directly because no input
reaches them through the entry points. token_reader_default.go is at
100% statement coverage with these tests.
kinyoklion added a commit that referenced this pull request Aug 11, 2026
**SDK-2888**

`writeQuotedString` scans for the next byte that needs escaping with a
range check and two equality comparisons per byte. The Go compiler emits
that as multiple compare-and-branch pairs per byte, which caps the
throughput of the one-byte-per-iteration scan loop; a 256-entry
byte-class table is a single always-L1-resident load plus one branch,
and is the technique the reader-side tokenizer rewrite uses. This
converts the writer's scan to the same idiom. Output is byte-identical.

The table is deliberately not shared with jreader's (in the reader-side
rewrite, #52): the predicates differ at both ends. The reader's decode
path treats control characters as plain (the lenient read side passes
them through verbatim) while this writer must escape them, and it stops
at bytes >= 0x80 (its escape-decoding path re-encodes runes) while this
writer copies multi-byte characters through verbatim. A shared-table
variant using per-package bit flags measured performance-neutral
(-0.04%), so each package keeps its own four-line generated table rather
than gaining an internal cross-package dependency. Each table's comment
notes the contrast.

## Benchmarks

go1.24.3, linux/amd64, interleaved A/B (3 rounds x count 2, benchstat
n=6):

```
                        │ comparisons │            table            │
WriteString-16            45.99n ± 3%   46.53n ± 5%  ~ (p=0.937 n=6)
WriteArrayOfStrings-16    4.309µ ± 2%   4.006µ ± 2%  -7.02% (p=0.002 n=6)
WriteObject-16            132.0n ± 3%   124.6n ± 2%  -5.61% (p=0.002 n=6)
geomean                   296.8n        285.3n       -3.88%
```

The single-short-string case is flat (fixed per-call overhead
dominates); the win appears wherever string scanning is a meaningful
share of the work. For context, the same table-vs-comparisons choice
measures much larger on the reader side (+7% to +40% for the comparison
chain), where the scan loop is a bigger fraction of total time.

## Testing

Full suite passes including with `-race`; `golangci-lint` clean;
`BenchmarkWriteObjectToNoOpWriterNoAllocs` remains 0 allocs/op. The
cross-permutation writer suite exercises the new scan against the same
expected encodings, including every escape class and multi-byte content.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> **`writeQuotedString`** now decides whether a byte can be copied
verbatim using a **256-entry `plainStringChars` table** instead of a
per-byte range check plus comparisons for `"` and `\`.
> 
> The table marks bytes from `0x20` through `0xFF` as plain except quote
and backslash, so **UTF-8 multibyte sequences pass through unchanged**
(unlike the reader’s table, which is documented as intentionally
separate). **Encoded JSON output is unchanged**; benchmarks show modest
gains when string scanning dominates (e.g. arrays of strings, objects).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8e8f17e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…ce sweep

A sweep of the JSON parsing minefield corpus confirmed every outcome
matches this reader's deliberately lenient baseline, and surfaced
behaviors no in-repo test pinned. Added:

- number leniencies that parse successfully: leading zeros, a dot with
  no fractional digits (with and without an exponent), no integer part
- the overflow split: integer literals beyond int64 wrap, while an
  exponent overflowing float64 range is rejected
- raw control characters (NUL, tab, newline) passing through strings
  verbatim on the zero-copy path
- the non-ASCII single whitespace bytes (0x0B, 0x0C, 0x85, 0xA0)
  skipped between tokens
- byte order marks rejected as unexpected characters, reported as code
  points
- deep nesting scanning in constant space (the tokenizer tracks no
  nesting state; 100k-deep brackets need no recursion)
@kinyoklion
kinyoklion merged commit a2755c9 into v4 Aug 11, 2026
13 checks passed
@kinyoklion
kinyoklion deleted the rlamb/sdk-2884/single-pass-jreader branch August 11, 2026 20:36
@github-actions github-actions Bot mentioned this pull request Aug 11, 2026
kinyoklion added a commit that referenced this pull request Aug 12, 2026
**SDK-2884** — backport of #52 to v3. Independent of the two jwriter
backports (disjoint files).

Rewrites the default tokenizer as a single-pass scanner over the input
byte slice: strings scan in place (unescaped strings keep the existing
zero-copy subslice behavior and need no UTF-8 decoding, since only the
quote and backslash end the scan), escaped strings decode in one forward
pass into a buffer sized up front, scalar reads dispatch on the first
non-whitespace byte, and tokens parse into a reused field instead of
shuttling token structs through call layers. `bytes.Reader` and per-rune
appends are gone.

Observable behavior is unchanged, including the deliberately lenient
baseline v3 and v4 share since their symmetric reverts (#54/#55):
per-byte `unicode.IsSpace` whitespace, verbatim pass-through of control
characters and invalid UTF-8 in unescaped strings, U+FFFD re-encoding on
the escaped path, no surrogate combining, lenient number scanning with
exact failure positions, and int64 wraparound. A lock-step differential
harness ran this port against the v3 baseline directly: 1,020,486 paired
runs over 145,464 inputs, zero mismatches (values bit-exact, errors
compared by type, message, value, and offset), with the harness's
sensitivity re-proven by a seeded whitespace mutation. The same harness
had proven the v4 change against the shared baseline. The table-driven
tests pinning those behaviors — including the external-conformance-sweep
cases — are part of the port. `token_reader_default.go` is at 100%
statement coverage.

## Adaptations for v3

Verbatim from v4 apart from: the default-implementation build tags and
header comment are preserved, the ported test file keeps v3's
`isEasyJSON` constant, and the benchmark's import uses the v3 module
path.

## Validation

Full suite green under both build tags, plus `-race`; lint clean
(default tags, matching CI). Benchmarks on v3 (linux/amd64, interleaved
binaries, benchstat n=4, listed deltas p=0.029; `encoding/json`
comparatives in the same runs were flat):

```
ReadString                          -59.6%
ReadNumberIntNoAlloc                -46.8%
ReadArrayOfBools                    -49.7%
ReadArrayOfStrings                  -53.4%
ReadObjectNoAlloc                   -50.6%
ReadArrayOfObjects                  -50.1%
ReadObjectWithRequiredPropsNoAlloc  -53.9%
```

All `NoAlloc` benchmarks remain at 0 allocs/op. On a real 3,228-flag /
3.2 MB LaunchDarkly payload parsed through `ldmodel`
(go-server-sdk-evaluation v3, which consumes this module natively):
direct jreader parse **28.2 ms → 15.1 ms (-46.4%)**; through the
`encoding/json`-dispatch path **53.0 ms → 36.4 ms (-31.3%)**. This also
closes the gap that previously made the easyjson build the faster read
path on this payload (~19%), with margin.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Rewrites the default (non-easyjson) JSON tokenizer for substantially
higher throughput while keeping observable parsing behavior unchanged.
> 
> The scanner now walks the input byte slice in a single pass: unescaped
strings stay zero-copy, escaped strings decode in one forward pass into
a pre-sized buffer, and scalar reads (`Bool`/`Number`/`String`)
fast-path on the first non-whitespace byte. Tokens are stored in a
reused `tok` field instead of being returned through call layers, and
`bytes.Reader` / per-rune appends are removed. Lookup tables replace
per-byte `unicode.IsSpace` and plain-ASCII string checks.
> 
> Adds table-driven edge-case tests that pin lenient number/string
handling, pushed-back token interactions, whitespace classification, and
error offsets, plus a `BenchmarkReadStringKinds` covering ASCII,
multi-byte, and escaped paths.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
3a40e1e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
kinyoklion pushed a commit that referenced this pull request Aug 13, 2026
🤖 I have created a release *beep* *boop*
---


##
[4.0.1](v4.0.0...v4.0.1)
(2026-08-11)


### Bug Fixes

* Improve jreader throughput with a single-pass tokenizer
([#52](#52))
([a2755c9](a2755c9))
* Improve jwriter throughput with append-based buffer internals
([#51](#51))
([ae1830d](ae1830d))
* Use a byte-class table for the string escape scan in jwriter
([#53](#53))
([95fa348](95fa348))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Release Please bump from **4.0.0** to **4.0.1**.
> 
> Updates `.release-please-manifest.json` and adds a `CHANGELOG.md`
entry covering three already-merged performance fixes: single-pass
`jreader` tokenization, append-based `jwriter` buffers, and a byte-class
table for string escape scanning.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6a37649. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants