fix: Use a byte-class table for the string escape scan in jwriter - #53
Merged
Conversation
kinyoklion
commented
Aug 7, 2026
| start := 0 | ||
| for i := 0; i < len(s); i++ { | ||
| aByte := s[i] | ||
| if aByte >= ' ' && aByte != '"' && aByte != '\\' { |
Member
Author
There was a problem hiding this comment.
When working on the read performance we had a similar shape and a LUT was a clear win in comparison. So I tested it out on the writer and it was also a win here.
kinyoklion
marked this pull request as ready for review
August 7, 2026 22:46
Scanning for the next byte that requires escaping now uses a 256-entry table instead of range and equality comparisons per byte, matching the technique the reader uses. The compiler emits the comparison chain as multiple compare-and-branch pairs per byte, while the table is a single always-cached load; measured on string-heavy benchmarks this is 5-7% faster, with identical output.
kinyoklion
force-pushed
the
rlamb/sdk-2888/writer-string-scan-table
branch
from
August 11, 2026 17:10
8e8f17e to
573eca8
Compare
joker23
approved these changes
Aug 11, 2026
keelerm84
approved these changes
Aug 11, 2026
Merged
kinyoklion
added a commit
that referenced
this pull request
Aug 12, 2026
**SDK-2888** — backport of #53 to v3, verbatim. Builds on the append-based writer branch (its base), since it converts the scan loop that change introduced. `writeQuotedString` scans for the next byte needing an escape with a 256-entry byte-class table instead of a range check and two equality comparisons per byte. A table lookup is a single always-L1-resident load plus one branch, which raises the throughput of the one-byte-per-iteration scan loop. Output is byte-identical. The table is deliberately not shared with jreader's: the predicates differ (this writer copies multi-byte characters through verbatim and must stop below 0x20 to escape control characters). ## Validation Full suite green under both build tags; lint clean. On v3 vs the append-based branch (interleaved, n=4): `WriteArrayOfStrings` **-4.5%**, `StreamingWriterArrayOfStrings` **-8.0%**, `WriteObjectToNoOpWriterNoAllocs` **-16.4%** (all p=0.029); real-payload whole-environment marshal a further **-3.6%** geomean. Matches the v4 measurement (-3.9% geomean). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Speeds up JSON string encoding by replacing the per-byte range and equality checks in `writeQuotedString` with a 256-entry `plainStringChars` lookup table. > > The scan still escapes only control characters, quotes, and backslashes; multi-byte characters continue to pass through verbatim. Output is byte-identical, with measurable gains on string-heavy marshal paths. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit bbd1d48. 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-2888
writeQuotedStringscans 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):
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-lintclean;BenchmarkWriteObjectToNoOpWriterNoAllocsremains 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.Note
Overview
writeQuotedStringnow decides whether a byte can be copied verbatim using a 256-entryplainStringCharstable instead of a per-byte range check plus comparisons for"and\.The table marks bytes from
0x20through0xFFas 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).Reviewed by Cursor Bugbot for commit 8e8f17e. Bugbot is set up for automated code reviews on this repo. Configure here.