Skip to content

Commit d1299d4

Browse files
pockeclaude
authored andcommitted
Avoid re-decoding an incomplete number on every ResumableParser chunk
`JSON::ResumableParser` re-decodes an incomplete number on every chunk it receives, so a long number delivered in small chunks is disproportionately slow to parse -- the work is quadratic (a bit worse in practice) in the number's length. ## Background When the parser reads a number that reaches the end of the buffer, it cannot tell whether more digits will arrive in a later chunk, so it rewinds the cursor and returns `false` to wait for more input. Until now it fully decoded the number before that check -- and then discarded the result. For a long run of digits that decode builds a bignum, whose cost grows faster than linearly with the digit count, and it was repeated on every chunk that extended the number. The total cost is therefore quadratic (a bit worse in practice) in the length of the number. The same "rewind and re-process" happens for incomplete strings and comments, but those only re-scan (no value construction), so only numbers pay this decode cost. ## Benchmark Feeding an incomplete token in 128-byte chunks, one iteration being one full chunked feed (`benchmark-ips`; macOS 15.6 arm64; ruby 4.0.0): ```ruby require "json" require "benchmark/ips" def feed_incomplete(token, chunk_size) parser = JSON::ResumableParser.new i = 0 while i < token.bytesize parser << token.byteslice(i, chunk_size) i += chunk_size parser.parse end end CHUNK = 128 sizes = [100, 16_000, 32_000, 64_000, 128_000] Benchmark.ips do |x| x.config(time: 3, warmup: 1) sizes.each do |n| number = "1" * n string = '"' + ("a" * n) x.report("number #{n} digits") { feed_incomplete(number, CHUNK) } x.report("string #{n} chars") { feed_incomplete(string, CHUNK) } end end ``` Per-iteration time, before vs. after this change: | Input (128-byte chunks) | Before | After | Change | |-------------------------|---------:|---------:|-----------------| | number, 100 digits | 949 ns | 414 ns | negligible | | number, 16,000 digits | 16.4 ms | 0.138 ms | ~119x faster | | number, 32,000 digits | 94.8 ms | 0.531 ms | ~178x faster | | number, 64,000 digits | 540.8 ms | 2.05 ms | ~264x faster | | number, 128,000 digits | 3.07 s | 8.13 ms | ~378x faster | | string, 100 chars | 411 ns | 391 ns | ~1x (unchanged) | | string, 16,000 chars | 49.4 μs | 47.7 μs | ~1x (unchanged) | | string, 32,000 chars | 169.7 μs | 165.8 μs | ~1x (unchanged) | | string, 64,000 chars | 621 μs | 616 μs | ~1x (unchanged) | | string, 128,000 chars | 2.38 ms | 2.35 ms | ~1x (unchanged) | The string rows are unchanged, confirming the change is number-specific. ## Fix Defer the decode. `json_parse_number` still scans the digits (cheap, and enough to advance the cursor so the caller can detect an incomplete number), but when the scan reaches the end of the buffer in resumable mode it returns `Qundef` without decoding. The caller already rewinds and returns `false` in exactly that case, so the decoded value was never used there. The number is decoded once, when a following byte proves it complete. Non-resumable `JSON.parse` is unaffected: it passes `resumable = false`, so the early return is never taken and the number is decoded as before. A test checks that large integers and floats split across feeds still decode to the correct value. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpeQSEFkF1X1Uuzjx9BsYe
1 parent cc010c4 commit d1299d4

2 files changed

Lines changed: 30 additions & 3 deletions

File tree

ext/json/ext/parser/parser.c

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,7 +1457,7 @@ static inline int json_parse_digits(JSON_ParserState *state, uint64_t *accumulat
14571457
return (int)(state->cursor - start);
14581458
}
14591459

1460-
static inline VALUE json_parse_number(JSON_ParserState *state, JSON_ParserConfig *config, bool negative, const char *start)
1460+
static inline VALUE json_parse_number(JSON_ParserState *state, JSON_ParserConfig *config, bool negative, const char *start, bool resumable)
14611461
{
14621462
bool integer = true;
14631463
const char first_digit = *state->cursor;
@@ -1514,6 +1514,16 @@ static inline VALUE json_parse_number(JSON_ParserState *state, JSON_ParserConfig
15141514
}
15151515
}
15161516

1517+
// A number touching the end of the buffer may still grow in a later chunk,
1518+
// so the caller will rewind and wait. Decoding it now would build a value
1519+
// -- for a long run of digits, an expensive bignum -- only to discard it,
1520+
// and repeating that on every resumed chunk is quadratic in the number's
1521+
// length. The digit scan above already advanced the cursor, which is all
1522+
// the caller needs to detect the incomplete number.
1523+
if (RB_UNLIKELY(resumable && eos(state))) {
1524+
return Qundef;
1525+
}
1526+
15171527
if (integer) {
15181528
return json_decode_integer(mantissa, mantissa_digits, negative, start, state->cursor);
15191529
}
@@ -1638,7 +1648,7 @@ ALWAYS_INLINE(static) bool json_parse_any(JSON_ParserState *state, JSON_ParserCo
16381648
case '-': {
16391649
state->cursor++;
16401650

1641-
value = json_parse_number(state, config, true, value_start);
1651+
value = json_parse_number(state, config, true, value_start, resumable);
16421652

16431653
if (RB_UNLIKELY(UNDEF_P(value) && config->allow_nan && peek(state) == 'I')) {
16441654
state->cursor = value_start;
@@ -1661,7 +1671,7 @@ ALWAYS_INLINE(static) bool json_parse_any(JSON_ParserState *state, JSON_ParserCo
16611671
}
16621672

16631673
case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': {
1664-
value = json_parse_number(state, config, false, value_start);
1674+
value = json_parse_number(state, config, false, value_start, resumable);
16651675

16661676
// Top level numbers are ambiguous when parsing streams, we can't
16671677
// know if we parsed all the digits if we hit EOS.

test/json/resumable_parser_test.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,23 @@ def test_parse_byte_by_byte_numbers
175175
assert_resumed_parsing('123 ', trailing_bytes: 1)
176176
end
177177

178+
def test_large_numbers_split_across_feeds_are_decoded_correctly
179+
{
180+
'12345678901234567890123456789012345678901234567890 ' => 12345678901234567890123456789012345678901234567890,
181+
'-98765432109876543210987654321 ' => -98765432109876543210987654321,
182+
'3.14159265358979323846264338327950288 ' => 3.14159265358979323846264338327950288,
183+
'-1.5e-300 ' => -1.5e-300,
184+
}.each do |doc, expected|
185+
parser = new_parser
186+
value = nil
187+
doc.each_char do |char|
188+
parser << char
189+
value = parser.value if parser.parse
190+
end
191+
assert_equal expected, value, doc.inspect
192+
end
193+
end
194+
178195
def test_nul_byte_is_a_syntax_error
179196
# A NUL byte in a structural position must raise, not stall forever waiting for more input
180197
# (peek() returns 0 both at EOS and for a literal NUL byte).

0 commit comments

Comments
 (0)