fix: Improve jreader throughput with a single-pass tokenizer - #52
Open
kinyoklion wants to merge 1 commit into
Open
fix: Improve jreader throughput with a single-pass tokenizer#52kinyoklion wants to merge 1 commit into
kinyoklion wants to merge 1 commit into
Conversation
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, including the encoding/json-compatible substitution of the replacement character for invalid UTF-8 bytes and the handling of lone and paired \u surrogate escapes. Structurally: - Strings are scanned in place over the input byte slice, with a 256-entry table classifying the characters that can pass through verbatim. Strings without escapes or invalid UTF-8 are still returned as zero-copy subslices of the input. - A string with escapes or invalid UTF-8 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, including surrogate pairs, 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 instead of going through per-byte reader method calls. An IndexByte-based string scan was also measured and was 12-25% slower than the table-driven loop across string shapes, because the validation pass still has to visit every byte, so finding the quote first is pure extra work. Interleaved benchmarks (benchstat, n=6): -36% boolean reads, -35% integer reads, -54% short strings with a 3x-6x string-scan throughput increase, -66% escaped strings with half the allocations, -43% typical object parsing, -52% geomean across the jreader suite. Parsing a real 3,228-flag LaunchDarkly payload through ldmodel improves 46%.
kinyoklion
marked this pull request as ready for review
August 7, 2026 22:49
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. Parse throughput was ~110 MB/s against the rewritten writer's ~400 MB/s.This PR rewrites the tokenizer as a single-pass scanner over the input byte slice:
\uXXXXescapes and surrogate pairs decode 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 validate the span) was benchmarked and rejected: 12-25% slower than the table loop across all string shapes, since validation must visit every byte anyway.Observable behavior is unchanged: exported API, parsed values, error types, error message strings, error offsets,
Offset()positions, andRawValue()byte ranges are all identical to the previous implementation.Correctness validation
Beyond the existing suite (green with
-race; the cross-permutation commontest matrices cover both dispatch paths), a differential harness ran the old and new tokenizers in lock-step over 1,013,258 paired cases with zero mismatches: exhaustive one- and two-byte string contents, all 65,536\uXXXXvalues, surrogate-boundary grids plus random and handpicked pairs (truncated pairs,\uD800𐀀), invalid-UTF-8 sequences at eight positions, int64 extremes,-0.0, denormals, overflow exponents, 400-digit literals, malformed numbers, every prefix-truncation of a mixed document, and 8,000 random documents with trailing junk and byte mutations. Comparisons covered decoded values (bit-exact floats), string/raw bytes and nilness, error type/fields/message,Offset()after every operation, and failed-state stickiness. The harness's sensitivity was proven by seeded mutations (an off-by-one offset and a disabled surrogate-pairing branch were both detected).One behavior worth naming because it is preserved, not changed: since the RFC 8259 work, invalid UTF-8 in strings is replaced with U+FFFD even when no escape sequence is present (each invalid byte forces the string off the zero-copy path). The new scanner matches that exactly, including the case where an unpairable surrogate emits U+FFFD and the following characters are re-read normally.
Benchmarks
go1.24, linux/amd64, interleaved old/new binaries (3 rounds x count 2, benchstat n=6, all listed deltas p=0.002). Micro geomean -52%:
On a real 3,228-flag / 3.2 MB LaunchDarkly payload parsed through
ldmodel(go-server-sdk-evaluation): direct jreader parse 34.2 ms -> 18.5 ms (-45.9%); through theencoding/json-dispatch path, 62.1 ms -> 48.0 ms (-22.7%). 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.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 jreader’s default
tokenReaderto scan the input[]bytein one pass instead of walking strings throughbytes.Readerand shuttling token structs throughnext().String parsing now uses a
plainStringCharsbyte-class table to bulk-scan unescaped ASCII (still zero-copy subslices), with a separatedecodeStringpath for escapes,\u/ surrogate pairs, and invalid UTF-8 (replacingbytes.Readerand per-rune appends). Scalar reads (Bool,Number,String,Any, etc.) peek the first non-whitespace byte and parse inline when possible;next()writes into a reusedr.tokandputBack()is a flag. Whitespace and keyword scanning use tighter index loops.Adds
BenchmarkReadStringKindsto exercise short/medium/long ASCII, multi-byte, and escaped string shapes viaStringAsBytes.Reviewed by Cursor Bugbot for commit 856c9de. Bugbot is set up for automated code reviews on this repo. Configure here.