Skip to content

fix: Use a byte-class table for the string escape scan in jwriter - #53

Merged
kinyoklion merged 1 commit into
v4from
rlamb/sdk-2888/writer-string-scan-table
Aug 11, 2026
Merged

fix: Use a byte-class table for the string escape scan in jwriter#53
kinyoklion merged 1 commit into
v4from
rlamb/sdk-2888/writer-string-scan-table

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Aug 7, 2026

Copy link
Copy Markdown
Member

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.


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).

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

start := 0
for i := 0; i < len(s); i++ {
aByte := s[i]
if aByte >= ' ' && aByte != '"' && aByte != '\\' {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
kinyoklion marked this pull request as ready for review August 7, 2026 22:46
@kinyoklion
kinyoklion requested a review from a team as a code owner 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
kinyoklion force-pushed the rlamb/sdk-2888/writer-string-scan-table branch from 8e8f17e to 573eca8 Compare August 11, 2026 17:10
@kinyoklion
kinyoklion merged commit 95fa348 into v4 Aug 11, 2026
13 checks passed
@kinyoklion
kinyoklion deleted the rlamb/sdk-2888/writer-string-scan-table branch August 11, 2026 20:28
@github-actions github-actions Bot mentioned this pull request Aug 11, 2026
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>
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.

3 participants