Skip to content

Optimize JSON string escaping - #3782

Merged
bobzhang merged 7 commits into
moonbitlang:mainfrom
mizchi:perf/json-escape-fast-path
Aug 20, 2026
Merged

Optimize JSON string escaping#3782
bobzhang merged 7 commits into
moonbitlang:mainfrom
mizchi:perf/json-escape-fast-path

Conversation

@mizchi

@mizchi mizchi commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add a no-escape fast path for JSON string escaping
  • rewrite escaping over UTF-16 code units so escaped strings are handled in one pass
  • add focused Json::stringify benchmarks for escaped and non-escaped string arrays

Context

This follows the dependency hotspot analysis:
https://gist.github.com/mizchi/28f382dd0d9c7c1cb0ef42b202f7f24e

json is one of the higher-priority non-builtin packages by dependency fan-out, and Json::stringify sits on top of StringBuilder/string output paths. The previous escape helper allocated and copied through a StringBuilder even when a string contained no escapable characters. This is common for JSON keys and ordinary ASCII values.

Benchmarks

Measured with native release benches. Before is upstream/main@607c2745; after is this PR head 69942b73.

Benchmark Before After Change
Json::stringify strings no escape n=2048 250.96 us 68.57 us 3.66x faster (-72.7%)
Json::stringify strings slash no escape n=2048 242.26 us 69.79 us 3.47x faster (-71.2%)
Json::stringify strings slash escaped n=2048 309.00 us 300.71 us 1.03x faster (-2.7%)
Json::stringify strings quotes controls n=2048 313.91 us 309.18 us 1.02x faster (-1.5%)

Validation

  • moon fmt
  • moon info
  • moon test -p moonbitlang/core/json --target all
  • moon check --target all
  • moon bench -p moonbitlang/core/json -f stringify_escape_bench_test.mbt --target native --release

@mizchi
mizchi marked this pull request as ready for review July 3, 2026 06:37
Copilot AI review requested due to automatic review settings July 3, 2026 06:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Optimizes Json::stringify string escaping in the json package by adding a no-escape fast path and switching escaping to operate on UTF-16 code units to avoid unnecessary allocations in common cases.

Changes:

  • Add a pre-scan fast path in JSON string escaping to return the original string when no escaping is needed.
  • Rewrite escaping logic to iterate UTF-16 code units (single pass over code units when escaping is required).
  • Add dedicated Json::stringify benchmarks to measure escaped vs non-escaped string array performance.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
json/json.mbt Updates JSON string escaping implementation with a no-escape fast path and UTF-16 code-unit based escaping.
json/stringify_escape_bench_test.mbt Adds benchmarks covering stringify performance for strings requiring no escaping vs various escaping scenarios.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread json/json.mbt Outdated
Comment thread json/json.mbt Outdated
@mizchi
mizchi force-pushed the perf/json-escape-fast-path branch 2 times, most recently from 41ea1dd to 538c916 Compare July 7, 2026 12:35
@Yu-zh

Yu-zh commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

@mizchi thanks for the optimization. Following #3816, I added an eight-wide UTF-16 SIMD need_escape scan for native/wasm in c79c665, with a scalar fallback and scalar tail.

In native release benchmarks, the escape-free cases improved from 77.84 µs to 60.26 µs and from 78.46 µs to 59.05 µs (about 23–25% faster); inputs that actually require escaping were effectively unchanged. The pushed commit includes block/tail and surrogate-boundary coverage, and the PR checks can now validate it across the repository.

@mizchi
mizchi force-pushed the perf/json-escape-fast-path branch 2 times, most recently from 2abfb2a to bdd315a Compare July 30, 2026 09:03
mizchi and others added 6 commits August 20, 2026 21:53
Add quickcheck properties pinning the SIMD need_escape against the
scalar reference, escape against a per-code-unit model and the real
parser, and an exhaustive sweep of escapable code units across SIMD
block boundaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bobzhang
bobzhang force-pushed the perf/json-escape-fast-path branch from bdd315a to 2ab0bc6 Compare August 20, 2026 14:05
@bobzhang

Copy link
Copy Markdown
Contributor

Rebased onto latest main and resolved the conflicts.

Conflict resolution: main now ships its own portable @v128.v128_load_i16x8 (no #cfg, works on every backend, with its own test), so this PR's copies of v128/simd_memory.mbt / v128/v128_test.mbt additions were dropped in favor of main's versions. The json changes are unchanged; the branch now only touches the json package.

More rigorous testing: added json/escape_quickcheck_wbtest.mbt with property-based tests:

  • quickcheck: SIMD need_escape agrees with need_escape_scalar on adversarial code-unit strings (escapables, the 0x1F/0x20 boundary, lone surrogate halves — these have the high bit set, so they specifically probe the unsigned i16x8_lt_u compare) and on arbitrary Unicode strings, for both escape_slash values.
  • quickcheck: escape matches a straightforward per-code-unit model, and the no-copy fast path fires exactly when nothing is escapable.
  • quickcheck: stringify/parse roundtrip on adversarial strings (the parser's string lexer is an independent implementation of the same grammar).
  • exhaustive sweep: every escapable code unit at every position for all lengths 0..=24, covering SIMD block starts, interiors, and the scalar tail.

Fresh benchmarks after the rebase (Apple Silicon, moon bench -p moonbitlang/core/json -f stringify_escape_bench_test.mbt --target native --release; before = main@8d6e0305 with the same bench file):

Benchmark main this PR Change
strings no escape n=2048 234.63 µs 34.72 µs 6.76x faster
strings slash no escape n=2048 231.50 µs 33.86 µs 6.84x faster
strings slash escaped n=2048 293.22 µs 270.88 µs 1.08x faster
strings quotes controls n=2048 287.14 µs 269.61 µs 1.06x faster

The fast-path win grew from ~3.7x to ~6.8x because main's v128_load_i16x8 is itself faster now; the escaped paths show no regression.

Validation: moon fmt, moon info (no drift), moon check --target all, moon test -p moonbitlang/core/json --target all (213/213 on wasm, wasm-gc, js, native), plus native --release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bobzhang

Copy link
Copy Markdown
Contributor

Follow-up: dropped the 17 inferable (_ : UInt16) ascriptions in json/json.mbt (1d9f2e38) — the match arms and i16x8_splat calls now use bare '"' / 0x0C literals. moon check --target all and all 213 json tests pass on wasm, wasm-gc, js, and native.

Re-reviewed the full branch with Codex CLI (codex exec review --base origin/main, model_reasoning_effort="ultra"). It cross-checked the SIMD/scalar paths and probed stringify behavior empirically on both js and native backends (emoji, control chars, escape_slash). Its signoff, no findings:

The SIMD precheck is bounds-safe and matches the scalar escape criteria, with correct scalar fallbacks and tail handling. The escaping behavior remains intact, and the full multi-target test suite passes.

@bobzhang
bobzhang merged commit f5e7dd6 into moonbitlang:main Aug 20, 2026
15 of 16 checks passed
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.

4 participants