fix: Improve jreader throughput with a single-pass tokenizer - #58
Merged
Conversation
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
marked this pull request as ready for review
August 11, 2026 21:48
keelerm84
approved these changes
Aug 12, 2026
Merged
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Readerand 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.IsSpacewhitespace, 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.gois 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
isEasyJSONconstant, 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/jsoncomparatives in the same runs were flat):All
NoAllocbenchmarks remain at 0 allocs/op. On a real 3,228-flag / 3.2 MB LaunchDarkly payload parsed throughldmodel(go-server-sdk-evaluation v3, which consumes this module natively): direct jreader parse 28.2 ms → 15.1 ms (-46.4%); through theencoding/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 reusedtokfield instead of being returned through call layers, andbytes.Reader/ per-rune appends are removed. Lookup tables replace per-byteunicode.IsSpaceand 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
BenchmarkReadStringKindscovering 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.