d65b0ec0ef (master): [ruby/json] Avoid re-decoding an incomplete number on every ResumableParser chunk

0 views
Skip to first unread message

Masataka Pocke Kuwabara

unread,
5:43 AM (15 hours ago) 5:43 AM
to ruby...@g.ruby-lang.org
Masataka Pocke Kuwabara 2026-07-09 06:35:14 +0000 (Thu, 09 Jul 2026)

New Revision: d65b0ec0ef

https://github.com/ruby/ruby/commit/d65b0ec0ef

Log:
[ruby/json] 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 <nor...@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpeQSEFkF1X1Uuzjx9BsYe

https://github.com/ruby/json/commit/d1299d42ac

Modified files:
ext/json/parser/parser.c
test/json/resumable_parser_test.rb
Reply all
Reply to author
Forward
0 new messages