perf(json): kill lex_number's per-call heap allocations#3633
Open
mizchi wants to merge 1 commit into
Open
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR refactors JSON number lexing to avoid per-number tuple allocations by returning only Double and using a ParseContext side channel (last_number_repr) to carry the optional original string representation when needed (e.g., infinity fallbacks).
Changes:
- Replace
(Double, StringView?)returns across number-lexing functions withDoubleand write the optional repr intoctx.last_number_repr. - Update
lex_valueto read + clearlast_number_reprwhen constructingNumbertokens. - Add
last_number_reprstate toParseContextand initialize it inParseContext::make.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| json/lex_number.mbt | Switch number lexing functions to return Double and set ctx.last_number_repr instead of returning tuples. |
| json/lex_main.mbt | Adapt number-token construction to read and clear ctx.last_number_repr. |
| json/internal_types.mbt | Add mut last_number_repr : StringView? to ParseContext and initialize it. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+52
to
55
| let n = ctx.lex_zero(start=ctx.offset - 2) | ||
| let repr = ctx.last_number_repr | ||
| ctx.last_number_repr = None | ||
| return Number(n, repr.map(repr => repr.to_owned())) |
Comment on lines
+59
to
62
| let n = ctx.lex_decimal_integer(start=ctx.offset - 2) | ||
| let repr = ctx.last_number_repr | ||
| ctx.last_number_repr = None | ||
| return Number(n, repr.map(repr => repr.to_owned())) |
Comment on lines
+69
to
78
| let n = ctx.lex_zero(start=ctx.offset - 1) | ||
| let repr = ctx.last_number_repr | ||
| ctx.last_number_repr = None | ||
| return Number(n, repr.map(repr => repr.to_owned())) | ||
| } | ||
| Some('1'..='9') => { | ||
| let (n, repr) = ctx.lex_decimal_integer(start=ctx.offset - 1) | ||
| let n = ctx.lex_decimal_integer(start=ctx.offset - 1) | ||
| let repr = ctx.last_number_repr | ||
| ctx.last_number_repr = None | ||
| return Number(n, repr.map(repr => repr.to_owned())) |
Comment on lines
+52
to
55
| let n = ctx.lex_zero(start=ctx.offset - 2) | ||
| let repr = ctx.last_number_repr | ||
| ctx.last_number_repr = None | ||
| return Number(n, repr.map(repr => repr.to_owned())) |
3 tasks
e7c449b to
c69ee72
Compare
Three related allocation removals along the JSON number lex chain, all stemming from the same observation: the native target boxes every returned struct / tuple / enum payload by default. ## 1. Drop the `(Double, StringView?)` tuple The number-lex chain (`lex_zero` / `lex_decimal_*` / `lex_number_end` / `lex_integer_end`) returned `(Double, StringView?)`, where the second component is the source-text view that's only ever `Some(_)` on the infinity-overflow path. Native boxes every tuple, so every parsed JSON number cost one heap allocation just to carry a value that was structurally `None`. Side-channel the optional view through a new private `ParseContext.last_number_repr` field. Each leaf return writes it (usually `None`); `lex_main.mbt` reads + clears when constructing the `Number` token. ## 2. Stack-allocate `JsonNumberScan` via `#valtype` `scan_json_number` builds + returns a five-field `JsonNumberScan` struct on every JSON number, used immediately by `lex_number_end` and discarded. Without `#valtype` the native target boxes that struct as a ~32-byte heap object per call. ## 3. NaN sentinel for `try_fast_double` `JsonNumberScan::try_fast_double` returned `Double?` — `Some(d)` on the fast path, `None` to fall back to strconv. The fast path can only produce 0 or a finite `Double` (the `checked_mul` guard rules out infinity), so `NaN` is a free sentinel. Returning a plain `Double` (with NaN meaning "not handled") skips the boxed `Option<Double>` allocation on every JSON number that hits this path. ## Numbers Measured on a native-target alloc profiler over mizchi/pprof-mbt's bench suite (`--sample-rate 100`): | bench | metric | before | after | Δ | |---|---|---|---|---| | `json_numbers` (10 k integers × 30) | allocs | 1 200 200 | 600 200 | **−50 %** | | | bytes | 18.31 MB | 9.16 MB | **−50 %** | | `json_parse` (1 000-obj × 50) | allocs | 3 250 200 | 2 300 200 | **−29 %** | | | bytes | 58.56 MB | 46.93 MB | **−20 %** | (`moonbitlang#3` only fires for non-integer / non-overflow numbers, so it adds 100 k allocs / 681 kB on top of #1+moonbitlang#2 on `json_parse` and nothing on `json_numbers`.) Tests: `moon test --target native -p json` and `moon test --target wasm-gc -p json` both pass (171 / 171 each). `moon fmt --check` clean.
c69ee72 to
01266c0
Compare
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three related allocation removals along the JSON number lex chain,
all stemming from the same observation: the native target boxes every
returned struct / tuple / enum payload by default, so any per-number
intermediate value lives on the heap until the next GC sweep / refcount
drop.
1. Drop the
(Double, StringView?)tupleThe number-lex chain (
lex_zero/lex_decimal_*/lex_number_end/lex_integer_end) returned(Double, StringView?), where the secondcomponent is the source-text view that's only ever
Some(_)on theinfinity-overflow path. Native boxes every tuple, so every parsed
JSON number cost one heap allocation per call just to carry a
value that was structurally
None.Side-channel the optional view through a new private
ParseContext.last_number_reprfield. Each leaf return writes it(usually
None);lex_main.mbtreads + clears when constructingthe
Numbertoken. No public API change —ParseContextand thesehelpers are all
priv.2. Stack-allocate
JsonNumberScanvia#valtypescan_json_numberbuilds + returns a five-fieldJsonNumberScanstruct on every JSON number, used immediately by
lex_number_endand discarded. Without
#valtypethe native target boxes thatstruct as a ~32-byte heap object per call.
The annotation is the only change — same struct, same call sites.
3. NaN sentinel for
try_fast_doubleJsonNumberScan::try_fast_doublereturnedDouble?—Some(d)onthe fast path,
Noneto fall back to strconv. The fast path canonly produce 0 or a finite
Double(thechecked_mulguard rulesout infinity), so
NaNis a free sentinel. Returning a plainDouble(with NaN meaning "not handled by the fast path") skipsthe boxed
Option<Double>allocation on every JSON number thathits this path.
Numbers
Measured on a native-target alloc profiler (mizchi/pprof-mbt's
memprofile-native) over the bench suite (--sample-rate 100):json_numbers(10 k integers × 30)json_parse(1 000-obj × 50)#3adds the last 100 k allocs / 681 kB onjson_parseon top of#1+#2(those handle the integer-only path;try_fast_doublefires for the mixed-content numbers).
After all three fixes,
json_numbers's remaining allocations allland in
parse_value2(theNumber(_, _)token construction), whichis fundamental. The dominant
lex_integer_end/scan_json_number/try_fast_doubleattribution that previously stacked up to >18 MB onthat bench is gone.
Why this is safe
Tokensurfaceproduced by
lex_mainis identical (stillNumber(Double, String?)with the same string content on the overflow path).
#valtypeis the language's existing knob for opting aprivstruct out of boxing — same semantics, just no heap roundtrip.
try_fast_doublenever legitimatelyproducing NaN (it only returns 0, a finite scaled mantissa, or the
sentinel itself).
checked_mulrejects multiplications that couldgo through Infinity, and
pow10_fast_pathonly indexes pre-computedfinite doubles. Verified by the existing
jsontest suite.Test plan
moon test --target native -p json— 171 / 171 passmoon test --target wasm-gc -p json— 171 / 171 passmoon fmt --checkcleanIndependent of #3632 (json lex_skip_whitespace) and #3634 (primitive
Hash::hashoverrides). Same workflow each PR: native-target allocprofile a real bench → attack the top site.