Skip to content

Conversation

@simi
Copy link
Contributor

@simi simi commented Oct 26, 2025

As promised @byroot, opening initial draft of porting Ryu raw C float parser.

Original Ryu does 2 passes. Since JSON parser already does 1 pass to explore the data type, first Ryu pass was merged into that one to save one whole additional pass. Once mantissa, exponent and sign are known, it can be passed directly to second pass in ryu_s2d_from_parts function returning double and casted to Ruby VALUE.

I did also some simple fuzzing and found out, there is imprecision on some edge cases. I have decided to fix it in the code (that's addition to Ryu, see the additional branch in json_ryu_parse_float) and fall back in this case to slow parsing. I'm not 100% sure it is actually needed. All original tests are also passing without, not sure this kind of precision is required.

I have added also some tests for those edge cases, to make it clear those all works (even it is not simple/asserted to find out in tests which parsing was used).

I have left comments in all files, which were mostly for me, since I'm not well skilled C coder and IEEE 754 expert. Those can be removed, as they doesn't fit the rest of the codebase styling. I have tried to resolve also licensing differences and added my attribution to the parser extraction to make it clear, it was modified. I'm happy to remove that if not needed.

For now this ported only C parser, but there's also JAVA one in Ryu repository if needed.


"fuzzying" results

First 5 errors:

  Error 1:
    batch: 979996
    index: 705
    original: -3.2652630314355e-310
    parsed: -3.26526303143548e-310
    relative_error: 1.5130960081462042e-14
    type: precision_error

  Error 2:
    batch: 1007092
    index: 737
    original: 3.9701623107025e-310
    parsed: 3.97016231070248e-310
    relative_error: 1.244446970113474e-14
    type: precision_error

  Error 3:
    batch: 1804227
    index: 234
    original: -3.6607772435415e-310
    parsed: -3.66077724354148e-310
    relative_error: 1.3496195287842083e-14
    type: precision_error

  Error 4:
    batch: 3641183
    index: 518
    original: 2.9714076801985e-310
    parsed: 2.97140768019848e-310
    relative_error: 1.6627326136824052e-14
    type: precision_error
================================================================================
[8607.06s] Batch 5929403 ✓ | 5929403000 numbers | 688900/sec | Errors: 4

@simi
Copy link
Contributor Author

simi commented Oct 26, 2025

ahh, I forgot the main, benchmark

[retro@retro2  json (ryu-float-parser *=)]❤ BENCHMARK=1 rb x ruby benchmark/parser.rb 
== Parsing float parsing (2251051 bytes)
ruby 3.4.5 (2025-07-16 revision 20cda200d3) +PRISM [x86_64-linux]
Warming up --------------------------------------
                json    10.000 i/100ms
          json_coder    14.000 i/100ms
                  oj     2.000 i/100ms
          Oj::Parser     2.000 i/100ms
           rapidjson    11.000 i/100ms
Calculating -------------------------------------
                json    127.953 (±14.8%) i/s    (7.82 ms/i) -    630.000 in   5.043387s
          json_coder    111.526 (±10.8%) i/s    (8.97 ms/i) -    560.000 in   5.076677s
                  oj     20.446 (±19.6%) i/s   (48.91 ms/i) -    100.000 in   5.067769s
          Oj::Parser     19.776 (±20.2%) i/s   (50.57 ms/i) -     98.000 in   5.114057s
           rapidjson    125.694 (±12.7%) i/s    (7.96 ms/i) -    627.000 in   5.065287s

Comparison:
                json:      128.0 i/s
           rapidjson:      125.7 i/s - same-ish: difference falls within error
          json_coder:      111.5 i/s - same-ish: difference falls within error
                  oj:       20.4 i/s - 6.26x  slower
          Oj::Parser:       19.8 i/s - 6.47x  slower

@simi simi force-pushed the ryu-float-parser branch 4 times, most recently from 82d04ba to ad8803a Compare October 26, 2025 23:11
#include <stdbool.h>
#include <string.h>

// Detect __builtin_clzll availability (for floor_log2)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have seen simd.h file, but per my poor understanding, __builtin_clzll is not SIMD. I decided to keep it aside from simd.h, but copy the detection pattern. This should be compatible with anything modern (the fast way), except MSVC. It is possible to provide also fast MSVC implementation, but conisdering MSVC Ruby built is rarely used (over other Windows build alternatives), IMHO it is worth of time to maintain.

@byroot
Copy link
Member

byroot commented Oct 27, 2025

== Parsing float parsing (2251051 bytes)
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
               after    20.000 i/100ms
Calculating -------------------------------------
               after    207.448 (± 1.9%) i/s    (4.82 ms/i) -      1.040k in   5.015059s

Comparison:
              before:       34.4 i/s
               after:      207.4 i/s - 6.03x  faster

The gains are indeed pretty significant.

The branch needs a bit of a cleanup, unfortunately your fork doesn't allow pushes:

ERROR: Permission to RubyElders/json.git denied to byroot.
fatal: Could not read from remote repository.

So I'll push my cleanup in another branch: master...byroot:json:ryu-float-parser

@simi simi marked this pull request as ready for review October 27, 2025 08:26
@simi
Copy link
Contributor Author

simi commented Oct 27, 2025

No idea how to do it :-o, but I have added you to my fork directly @byroot. Feel free to push there. Just please ping me for final review.

@byroot
Copy link
Member

byroot commented Oct 27, 2025

No idea how to do it

On the right side of the pull request page you should have a: " Allow edits and access to secrets by maintainers" check box:

Capture d’écran 2025-10-27 à 10 12 57

@simi
Copy link
Contributor Author

simi commented Oct 27, 2025

No idea how to do it

On the right side of the pull request page you should have a: " Allow edits and access to secrets by maintainers" check box:

Capture d’écran 2025-10-27 à 10 12 57

I was looking for this, but I'm blind or it is missing.

image

@byroot
Copy link
Member

byroot commented Oct 27, 2025

I was looking for this, but I'm blind or it is missing.

It's probably because your fork is in an organization. I know organizations can disable this entirely (is the case of Shopify and it's annoying).


if (state->cursor == state->end || *state->cursor < '0' || *state->cursor > '9') {
if (state->cursor == state->end || !rb_isdigit(*state->cursor)) {
raise_parse_error("invalid number: %s", state);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use raise_parse_error_at here, so the error message contain the entire number, not just what is past the exponent. (I'm taking care of it)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@byroot
Copy link
Member

byroot commented Oct 27, 2025

The gains are indeed pretty significant.

After the last few tweeks, we're now closing on 7x speedup:

== Parsing float parsing (2251051 bytes)
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
               after    22.000 i/100ms
Calculating -------------------------------------
               after    238.850 (± 0.0%) i/s    (4.19 ms/i) -      1.210k in   5.065963s

Comparison:
              before:       35.1 i/s
               after:      238.8 i/s - 6.80x  faster

We're still slightly behind rapidjson on my machine, but it's close enough and a huge improvements:

== Parsing float parsing (2251051 bytes)
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
                json    23.000 i/100ms
          json_coder    23.000 i/100ms
                  oj     3.000 i/100ms
          Oj::Parser     4.000 i/100ms
           rapidjson    26.000 i/100ms
Calculating -------------------------------------
                json    232.059 (± 0.0%) i/s    (4.31 ms/i) -      1.173k in   5.054770s
          json_coder    217.893 (± 0.5%) i/s    (4.59 ms/i) -      1.104k in   5.066776s
                  oj     37.768 (± 2.6%) i/s   (26.48 ms/i) -    189.000 in   5.011577s
          Oj::Parser     43.306 (± 2.3%) i/s   (23.09 ms/i) -    220.000 in   5.080949s
           rapidjson    265.167 (± 0.4%) i/s    (3.77 ms/i) -      1.352k in   5.098758s

Comparison:
                json:      232.1 i/s
           rapidjson:      265.2 i/s - 1.14x  faster
          json_coder:      217.9 i/s - 1.07x  slower
          Oj::Parser:       43.3 i/s - 5.36x  slower
                  oj:       37.8 i/s - 6.14x  slower

The code LGTM at this point, but I've been meaning to add some fuzzing for a while, and that may be worth it here.
I reached out to @kddnewton to see if he has any insights on how to do it.

I also need to dig a bit deeper in the parsing differences for very large numbers (all the assert_in_delta tests). It's likely not a big deal, but I want to better understand the subtleties of it.

@simi
Copy link
Contributor Author

simi commented Oct 27, 2025

@byroot this is my simple naive fuzzer - https://gist.github.com/simi/d5b4800e9eb050dbefb34f218c8e49b4.

Co-Authored-By: Jean Boussier <[email protected]>
@byroot byroot merged commit 8f5c7f9 into ruby:master Oct 30, 2025
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants