From 0b12398ff1c3de85e11c20478fcf18ee10f6b27c Mon Sep 17 00:00:00 2001 From: Masataka Pocke Kuwabara Date: Thu, 9 Jul 2026 15:35:14 +0900 Subject: [PATCH] Avoid re-decoding an incomplete number on every ResumableParser chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01PpeQSEFkF1X1Uuzjx9BsYe --- ext/json/ext/parser/parser.c | 16 +++++++++++++--- test/json/resumable_parser_test.rb | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/ext/json/ext/parser/parser.c b/ext/json/ext/parser/parser.c index 9b7b4be3..d1c90ff8 100644 --- a/ext/json/ext/parser/parser.c +++ b/ext/json/ext/parser/parser.c @@ -1457,7 +1457,7 @@ static inline int json_parse_digits(JSON_ParserState *state, uint64_t *accumulat return (int)(state->cursor - start); } -static inline VALUE json_parse_number(JSON_ParserState *state, JSON_ParserConfig *config, bool negative, const char *start) +static inline VALUE json_parse_number(JSON_ParserState *state, JSON_ParserConfig *config, bool negative, const char *start, bool resumable) { bool integer = true; const char first_digit = *state->cursor; @@ -1514,6 +1514,16 @@ static inline VALUE json_parse_number(JSON_ParserState *state, JSON_ParserConfig } } + // A number touching the end of the buffer may still grow in a later chunk, + // so the caller will rewind and wait. Decoding it now would build a value + // -- for a long run of digits, an expensive bignum -- only to discard it, + // and repeating that on every resumed chunk is quadratic in the number's + // length. The digit scan above already advanced the cursor, which is all + // the caller needs to detect the incomplete number. + if (RB_UNLIKELY(resumable && eos(state))) { + return Qundef; + } + if (integer) { return json_decode_integer(mantissa, mantissa_digits, negative, start, state->cursor); } @@ -1638,7 +1648,7 @@ ALWAYS_INLINE(static) bool json_parse_any(JSON_ParserState *state, JSON_ParserCo case '-': { state->cursor++; - value = json_parse_number(state, config, true, value_start); + value = json_parse_number(state, config, true, value_start, resumable); if (RB_UNLIKELY(UNDEF_P(value) && config->allow_nan && peek(state) == 'I')) { state->cursor = value_start; @@ -1661,7 +1671,7 @@ ALWAYS_INLINE(static) bool json_parse_any(JSON_ParserState *state, JSON_ParserCo } case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': { - value = json_parse_number(state, config, false, value_start); + value = json_parse_number(state, config, false, value_start, resumable); // Top level numbers are ambiguous when parsing streams, we can't // know if we parsed all the digits if we hit EOS. diff --git a/test/json/resumable_parser_test.rb b/test/json/resumable_parser_test.rb index 5d7bf38b..b3c82c16 100644 --- a/test/json/resumable_parser_test.rb +++ b/test/json/resumable_parser_test.rb @@ -175,6 +175,23 @@ def test_parse_byte_by_byte_numbers assert_resumed_parsing('123 ', trailing_bytes: 1) end + def test_large_numbers_split_across_feeds_are_decoded_correctly + { + '12345678901234567890123456789012345678901234567890 ' => 12345678901234567890123456789012345678901234567890, + '-98765432109876543210987654321 ' => -98765432109876543210987654321, + '3.14159265358979323846264338327950288 ' => 3.14159265358979323846264338327950288, + '-1.5e-300 ' => -1.5e-300, + }.each do |doc, expected| + parser = new_parser + value = nil + doc.each_char do |char| + parser << char + value = parser.value if parser.parse + end + assert_equal expected, value, doc.inspect + end + end + def test_nul_byte_is_a_syntax_error # A NUL byte in a structural position must raise, not stall forever waiting for more input # (peek() returns 0 both at EOS and for a literal NUL byte).