Skip to content

fix: Improve jwriter throughput with append-based buffer internals - #56

Merged
kinyoklion merged 2 commits into
v3from
rlamb/sdk-2878/append-based-jwriter-v3
Aug 12, 2026
Merged

fix: Improve jwriter throughput with append-based buffer internals#56
kinyoklion merged 2 commits into
v3from
rlamb/sdk-2878/append-based-jwriter-v3

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Aug 11, 2026

Copy link
Copy Markdown
Member

SDK-2878 — backport of #51 to v3.

Rewrites the default token writer's internals around an append-based byte slice, the same design encoding/json adopted in Go 1.19–1.24: streamableBuffer holds a plain []byte written with inlinable appends, the string escape scan appends clean segments in a single pass, numbers append via strconv directly into the buffer, and the streaming chunk-size check runs once per token instead of once per fragment. Buffer growth at least doubles capacity so reallocations stay logarithmic; Flush returns and records destination errors, detects short writes, and never retains undelivered data; MarshalJSONWithWriter starts from a pre-sized buffer. Public API and encoded output are unchanged (chunk boundaries in streaming mode move to token granularity; total output is identical).

Adaptations for v3

The change is otherwise verbatim from v4; three things differ because v3 still ships the easyjson build-tag variant:

  • The default-implementation build tags and header comment are preserved on token_writer_default.go.
  • The two buffer-internals tests (growth amortization, marshal allocations) live in a new default-tagged file, because v3 compiles the shared test files under both tags and they reference default-only internals.
  • token_writer_easyjson.go gains the equivalent pre-sized constructor (EnsureSpace) so the shared marshal entry point compiles under both tags.

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

WriteString                    -27.1%
WriteArrayOfBools              -21.3%
WriteArrayOfStrings            -46.9%
WriteObject                    -31.6%
StreamingWriterArrayOfStrings  -33.3%

On a real 3,228-flag / 3.2 MB LaunchDarkly payload marshaled through ldmodel (go-server-sdk-evaluation v3, which consumes this module natively): in-memory whole-environment marshal 10.2 ms → 8.4 ms (-17.6%), streaming with an 8 KiB chunk size 10.3 ms → 6.9 ms (-33.5%).

The byte-class-table scan backport (#57) builds on this branch and merges after it.


Note

Overview
Rewrites the default jwriter token path around an append-based []byte streamableBuffer instead of bytes.Buffer, with at-least-doubling growth, pre-sized writers (64-byte default, 1000 for MarshalJSONWithWriter), and direct strconv appends for numbers.

String escaping uses a plainStringChars lookup table and segment appends (no per-rune UTF-8 walk); streaming mode flushes at chunk boundaries while scanning so escape-heavy strings do not grow the buffer with input length.

Streaming reliability: Flush detects short writes (io.ErrShortWrite), sticks destination errors, drops undelivered buffered data, and Writer.Flush records those errors on the writer; chunk flush timing aligns with completed tokens (total output unchanged).

v3 easyjson build: adds matching newTokenWriterWithCapacity / EnsureSpace; buffer-internals tests live in default-tagged files only.

Tests cover growth panics, reallocation bounds, marshal allocation budget, destination failures, short writes, parity across chunk sizes, and escape-heavy streaming bounds.

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

Backport of the v4 change to the default token writer: streamableBuffer
holds a plain byte slice written with inlinable appends, the string
escape scan appends clean segments in a single pass, numbers append via
strconv directly into the buffer, and the streaming chunk-size check
runs once per token instead of once per fragment. Buffer growth at
least doubles capacity so reallocations stay logarithmic; Flush returns
and records destination errors, detects short writes, and never retains
undelivered data; MarshalJSONWithWriter starts from a pre-sized buffer.

Adaptations for v3: the default-implementation build tags and header
comment are preserved; the buffer-internals tests live in a
default-tagged file because the easyjson variant compiles the shared
test files too; and token_writer_easyjson.go gains the equivalent
pre-sized constructor so the shared marshal entry point compiles under
both tags (EnsureSpace pre-sizes the easyjson buffer).
@kinyoklion
kinyoklion marked this pull request as ready for review August 11, 2026 21:48
@kinyoklion
kinyoklion requested a review from a team as a code owner August 11, 2026 21:48
**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
kinyoklion merged commit acb84f4 into v3 Aug 12, 2026
12 of 13 checks passed
@kinyoklion
kinyoklion deleted the rlamb/sdk-2878/append-based-jwriter-v3 branch August 12, 2026 22:49
@github-actions github-actions Bot mentioned this pull request Aug 12, 2026
kinyoklion added a commit that referenced this pull request Aug 13, 2026
…tions (#61)

v3 counterpart of #60 — the same one-line change: `AllocsPerRun(1, ...)`
becomes `AllocsPerRun(100, ...)`, with the test body otherwise
unchanged. The easyjson-conditional expectation is preserved: that
build's exactly-4-allocations-per-parse count is deterministic and holds
under the averaging (the integer division yields 400/100 = 4). Verified
under both build tags, including `-race`.

See #60 for the analysis: `AllocsPerRun` samples the process-global
malloc counter, so `runs=1` makes the assertion "nothing anywhere in the
process allocates during the window" — one stray timer or finalizer
allocation on a slow runner reads as a failure. Averaging over 100 runs
absorbs strays through the integer division, while a genuine allocation
in the code under test occurs in every run and still fails.

No interaction with the open backport PRs (#56/#57/#58) — none of them
touch this file.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> **Hardens `TestReaderSkipValueAllocations`** so flaky CI failures from
unrelated process allocations are less likely.
> 
> The test still parses the same JSON and skips the nested `b` object
while reading `a` and `c`, and still expects **0** allocs (or **4**
under the easyjson build tag). The only behavioral change is
**`testing.AllocsPerRun(1, …)` → `testing.AllocsPerRun(100, …)`**, with
comments explaining that `AllocsPerRun` uses a process-wide counter, so
a single run can fail if another goroutine allocates; averaging 100 runs
smooths stray timer/finalizer noise while real per-run allocs in the
code under test still fail the assertion.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
121ae6c. 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*
---


##
[3.1.2](v3.1.1...v3.1.2)
(2026-08-13)


### Bug Fixes

* Improve jreader throughput with a single-pass tokenizer
([#58](#58))
([78d0ac9](78d0ac9))
* Improve jwriter throughput with append-based buffer internals
([#56](#56))
([acb84f4](acb84f4))

---
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-only PR** that bumps the package version from **3.1.1** to
**3.1.2** in `.release-please-manifest.json` and adds the corresponding
**CHANGELOG** section for 2026-08-13.
> 
> The changelog records two already-merged bug fixes: **jreader**
throughput via a single-pass tokenizer ([#58]) and **jwriter**
throughput via append-based buffer internals ([#56]). No library source
changes appear in this diff.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
124c1a5. 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.

2 participants