Skip to content

[pull] master from ruby:master#1196

Merged
pull[bot] merged 8 commits into
turkdevops:masterfrom
ruby:master
Jul 9, 2026
Merged

[pull] master from ruby:master#1196
pull[bot] merged 8 commits into
turkdevops:masterfrom
ruby:master

Conversation

@pull

@pull pull Bot commented Jul 9, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

BurdetteLamar and others added 8 commits July 9, 2026 02:51
PushInlineFrame keeps caller stack slots lazy. When an inlined callee
side-exits, install a caller JITFrame with a stack map on the exit path
so rb_zjit_materialize_frames can reconstruct older frames without
eagerly spilling at frame push.

Co-authored-by: Alan Wu <alanwu@ruby-lang.org>
Co-authored-by: Kevin Menard <kevin@nirvdrum.com>
…Parser 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

ruby/json@d1299d42ac
@pull pull Bot locked and limited conversation to collaborators Jul 9, 2026
@pull pull Bot added the ⤵️ pull label Jul 9, 2026
@pull pull Bot merged commit 835ad5c into turkdevops:master Jul 9, 2026
1 of 3 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants