Skip to content

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

Merged
kinyoklion merged 1 commit into
v3from
rlamb/sdk-2884/single-pass-jreader-v3
Aug 12, 2026
Merged

fix: Improve jreader throughput with a single-pass tokenizer#58
kinyoklion merged 1 commit into
v3from
rlamb/sdk-2884/single-pass-jreader-v3

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Aug 11, 2026

Copy link
Copy Markdown
Member

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.


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.

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

Backport of the v4 rewrite of the default tokenizer as a single-pass
scanner over the input byte slice: strings scan in place (unescaped
strings keep the 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. bytes.Reader and
per-rune appends are gone.

Observable behavior is unchanged, including the deliberately lenient
baseline: 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. The v4
change was validated against the previous implementation with a
lock-step differential harness (1,020,486 paired runs, zero
mismatches), and the table-driven tests pinning those behaviors are
included; the port preserves the default-implementation build tags.
@kinyoklion
kinyoklion marked this pull request as ready for review August 11, 2026 21:48
@kinyoklion
kinyoklion requested a review from a team as a code owner August 11, 2026 21:48
@kinyoklion
kinyoklion merged commit 78d0ac9 into v3 Aug 12, 2026
14 checks passed
@kinyoklion
kinyoklion deleted the rlamb/sdk-2884/single-pass-jreader-v3 branch August 12, 2026 22:50
@github-actions github-actions Bot mentioned this pull request Aug 12, 2026
kinyoklion added a commit that referenced this pull request Aug 13, 2026
…tions (#61)

v3 counterpart of #60 — the same one-line change: `AllocsPerRun(1, ...)`
becomes `AllocsPerRun(100, ...)`, with the test body otherwise
unchanged. The easyjson-conditional expectation is preserved: that
build's exactly-4-allocations-per-parse count is deterministic and holds
under the averaging (the integer division yields 400/100 = 4). Verified
under both build tags, including `-race`.

See #60 for the analysis: `AllocsPerRun` samples the process-global
malloc counter, so `runs=1` makes the assertion "nothing anywhere in the
process allocates during the window" — one stray timer or finalizer
allocation on a slow runner reads as a failure. Averaging over 100 runs
absorbs strays through the integer division, while a genuine allocation
in the code under test occurs in every run and still fails.

No interaction with the open backport PRs (#56/#57/#58) — none of them
touch this file.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> **Hardens `TestReaderSkipValueAllocations`** so flaky CI failures from
unrelated process allocations are less likely.
> 
> The test still parses the same JSON and skips the nested `b` object
while reading `a` and `c`, and still expects **0** allocs (or **4**
under the easyjson build tag). The only behavioral change is
**`testing.AllocsPerRun(1, …)` → `testing.AllocsPerRun(100, …)`**, with
comments explaining that `AllocsPerRun` uses a process-wide counter, so
a single run can fail if another goroutine allocates; averaging 100 runs
smooths stray timer/finalizer noise while real per-run allocs in the
code under test still fail the assertion.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
121ae6c. 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*
---


##
[3.1.2](v3.1.1...v3.1.2)
(2026-08-13)


### Bug Fixes

* Improve jreader throughput with a single-pass tokenizer
([#58](#58))
([78d0ac9](78d0ac9))
* Improve jwriter throughput with append-based buffer internals
([#56](#56))
([acb84f4](acb84f4))

---
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-only PR** that bumps the package version from **3.1.1** to
**3.1.2** in `.release-please-manifest.json` and adds the corresponding
**CHANGELOG** section for 2026-08-13.
> 
> The changelog records two already-merged bug fixes: **jreader**
throughput via a single-pass tokenizer ([#58]) and **jwriter**
throughput via append-based buffer internals ([#56]). No library source
changes appear in this diff.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
124c1a5. 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.

2 participants