Skip to content

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

Open
kinyoklion wants to merge 1 commit into
v4from
rlamb/sdk-2888/writer-string-scan-table
Open

fix: Use a byte-class table for the string escape scan in jwriter#53
kinyoklion wants to merge 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: the two predicates differ (the reader must stop at bytes >= 0x80 to perform UTF-8 validation, while this writer copies multi-byte characters through verbatim), and 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.

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

1 participant