fix: Improve jreader throughput with a single-pass tokenizer - #52
Merged
Conversation
kinyoklion
marked this pull request as ready for review
August 7, 2026 22:49
joker23
approved these changes
Aug 11, 2026
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%.
kinyoklion
force-pushed
the
rlamb/sdk-2884/single-pass-jreader
branch
from
August 11, 2026 16:51
a451c56 to
d499ac2
Compare
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 -->
keelerm84
approved these changes
Aug 11, 2026
…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)
Merged
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>
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
The parse-side counterpart of #51. jreader's default tokenizer scanned input character by character:
readStringwalked every character throughbytes.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:
"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\uXXXXdecoded by direct indexing.bytes.Readerand per-rune appends are gone.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;putBackis a flag flip.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:
unicode.IsSpaceapplied per byte, so raw0x0B,0x0C,0x85, and0xA0bytes are accepted as whitespace.\uXXXXsurrogate code points encode as U+FFFD without pair combining, exactly as the currentappendRunedoes.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\uXXXXvalues, 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), bringingtoken_reader_default.goto 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:
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 theencoding/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
NoAllocbenchmarks remain at 0 allocs/op (CI gate).Notes for reviewers
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 in1exvs1e+x) are the subtlest part of the port; they are reproduced exactly and covered by the differential corpus.decodedStringCapacitypre-scans an escaped string's remainder once to size the decode buffer — a deliberate two-scans/one-allocation trade.strconv.ParseFloat); avoiding it would require unsafe, which this repo does not use.Note
Overview
Rewrites the default
jreadertokenizer 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 reusedtokfield 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
BenchmarkReadStringKindsfor the distinct string paths.Reviewed by Cursor Bugbot for commit 63ffa7c. Bugbot is set up for automated code reviews on this repo. Configure here.