Skip to content

Render nested scalars without encoding/json - #527

Open
vuanhphung wants to merge 3 commits into
mainfrom
perf/json-scalar-fastpath
Open

vuanhphung wants to merge 3 commits into
mainfrom
perf/json-scalar-fastpath

Conversation

@vuanhphung

@vuanhphung vuanhphung commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Go 1.27 enables the jsonv2 experiment by default, so encoding/json.Marshal now runs on the json/v2 engine and costs more per call. The nested ARRAY / MAP / STRUCT renderers call it once per scalar element, which made complex-type results 13–27% slower on Go 1.27 than on Go 1.26. Since v1.18.0 requires Go 1.27.3, every user upgrading hits this; it showed up as the Extended Types jump on the Go nightly benchmark when its build image moved from Go 1.26 to 1.27.

This adds internal/jsonfmt, which formats bools, integers, finite floats, and strings (including valid UTF-8) directly, byte-identical to json.Marshal, and falls back to json.Marshal for everything else (strings needing escapes or with invalid UTF-8, []byte, NaN/Inf, other types). Both the Thrift (arrowbased) and kernel (arrowscan) renderers use it, appending into stack scratch buffers, so cross-backend parity is unchanged.

Offline CloudFetch harness, Go 1.27.3, single-column results drained through database/sql:

main this PR
map 2.49s 1.10s (-56%)
array 2.00s 0.93s (-54%)
struct 1.63s 0.95s (-42%)
array<double> 2.70s 1.53s (-43%)
array<string> 2.41s 1.14s (-53%)
map, non-ASCII values 2.93s 1.44s (-51%)

This is also faster than main built with GOEXPERIMENT=nojsonv2 or with Go 1.26. BenchmarkRenderNested (internal/rows/arrowbased) reproduces this per backend in-repo: 1.7–2.9x faster on both, except binary leaves, which still go through json.Marshal and are unchanged. The remaining ~10% Go 1.27 slowdown on primitive columns comes from database/sql's new Rows lock and isn't addressable in the driver.

Testing: new tests check byte-for-byte parity with json.Marshal (edge cases, every single-byte string, every rune, random Unicode mixes, random float bit patterns), under both the default json/v2 backend and GOEXPERIMENT=nojsonv2. The Thrift/kernel parity test gains nested DOUBLE and UTF-8/HTML string cases. go test -short ./... and golangci-lint pass.

This pull request and its description were written by Isaac.

Go 1.27 enables the jsonv2 experiment by default, so encoding/json.Marshal
runs on the json/v2 engine and costs noticeably more per call. The nested
ARRAY/MAP/STRUCT renderers call it once per scalar element, which made
complex-type results 13-27% slower on Go 1.27 than on Go 1.26.

Add internal/jsonfmt, which formats bools, integers, finite floats, and
strings that need no escaping directly, byte-identical to json.Marshal, and
falls back to json.Marshal for everything else. Use it from both the Thrift
(arrowbased) and kernel (arrowscan) renderers.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No issues identified by the review bot.

- Look up escape-free bytes in a table: the per-byte comparisons made long
  ASCII strings slower than json.Marshal (8 KiB: 7.6 vs 5.5 us; now 3.2 us).
- Return json.Marshal's slice directly when dst is empty instead of copying it.
- Silence gosec G404 for the seeded test RNG.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: 1 Low

Looks good. appendFloat matches encoding/json's float encoder exactly, the type switch only fast-paths exact built-in types (named types and json.Marshaler types fall back), and both renderers keep the same output as before. One low note: strings containing any non-ASCII byte always take the slower json.Marshal fallback.

Comment thread internal/jsonfmt/jsonfmt.go Outdated
// plainASCII marks the bytes json.Marshal copies verbatim. A table lookup keeps
// the scan faster than json.Marshal's own on long strings.
var plainASCII = func() (t [256]bool) {
for c := 0x20; c < 0x7f; c++ {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Low — plainASCII only marks 0x20–0x7e as safe, so a string with any non-ASCII byte (accented Latin, CJK, emoji) skips the fast path and goes through the full json.Marshal fallback. The output is still correct. The cost is that STRING elements in non-English data get none of the speedup this PR is after, and the CloudFetch benchmark (likely ASCII alpha0-style data) won't show it.

If you want to cover that case: encoding/json copies valid multi-byte UTF-8 through unchanged except U+2028/U+2029 (and it replaces invalid sequences with �). You could validate with utf8.DecodeRuneInString and fall back only when a sequence is invalid or is U+2028/U+2029. The existing every-single-byte and héllo/日本語/\xff\xfe parity tests would guard that change. This is optional; the current behaviour is safe.

- Copy valid multi-byte UTF-8 (and 0x7f) verbatim, as both json engines do;
  only invalid UTF-8 and U+2028/U+2029 still fall back to json.Marshal.
- Thrift containers and kernel map keys append scalars into a stack scratch
  buffer instead of allocating per value.
- Tests: every-rune and random Unicode parity, error-text parity, nested
  DOUBLE and UTF-8/HTML string cases in the backend parity test.
- Add BenchmarkRenderNested for both backends; report allocs and cover
  fallback values in BenchmarkMarshalScalars.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No issues identified by the review bot.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant