Faster float / string conversion (Ryu)

From my understanding, ryu and dragonbox achieve their performance while honoring that. Paraphrasing from the dragonbox abstract:

criteria:

  1. Information preservation
  2. Minimum-length output
  3. Correct rounding

ryu and dragonbox (among others) satisfy these

You seem to feel strongly about this. Maybe you are interested in submitting a PR?

1 Like

That could just mean that str is also slow.

Given that json took 79 seconds with floats and 18 seconds with ints, but orjson took only 5 seconds with either, it looks like a mix of reasons, with float indeed contributing by far the most to the slowness.

Just for avoidance of doubt, it’s worth pointing out that these proposals would not do away with the existing dtoa.c code, which handles more than just the shortest-string float-to-string conversions: dtoa.c also provides support for {:.e} and {:.f} formatting, and is used for the current round implementation (including the corner case where the second argument to round is negative - e.g., rounding to the nearest hundred / thousand / million), as well as for correctly-rounded conversions in the other direction, from strings to floats. So complete dtoa.c replacement would be a substantially bigger project than introducing Ryu (or dragonbox, or whatever the latest state-of-the-art code is) for faster shortest string float->str conversion.

My guess - on the basis that most data should be read more often that it’s written - is that to see significant improvements we’d also need a faster correctly-rounded str->float conversion.

A point made in the dragonbox abstract is that floating-point I/O is largely asymmetric. Going from float binary to decimal string is more complex, since there is a large space of candidate outputs to chose from (trailing zero’s, varying exponent, etc.). So an algorithm might handle that slowly (dtoa).

“Most data should be read more often that it’s written” is reasonable, but is a view centered on data-storage, and specifically the client’s view. For example, a server publishing read-only records over the network in json, or some other text format, is doing much more number-to-string than string-to-number.

Anyway, I wouldn’t suggest taking an effort like this on (replacing the float repr implementation) unless there was evidence showing that a significant portion of real-world applications would benefit.

Interestingly, the frepr package, cited in this thread, monkey-patches PyFloat_Type.tp_repr to achieve its speedup. While it’s using a much slower algorithm (the cited 8x speedup is more like 2x for small-exponent values), and this approach doesn’t work for PyPy, it gave me a few ideas:

  • update this package to use ryu or dragonbox
  • add an instrument-only mode. Wrap the existing repr(), but collect stats on call count and time spent in the function. Then anyone can determine exactly how a 15 or 20x speedup of this function would affect their app, without correctness concerns.
1 Like

we’d also need a faster correctly-rounded str->float conversion.

If you talk about string → float conversion too … could such be of help?

didn’t try but sounds promising
[ edit ] probably it’s already in or will come shortly:
It is part of the standard C++ library under Linux (as of GCC 12);
( Daniel Lemire, Computer Science Professor ), [ /edit ]

1 Like

For people interested in this field, yet another faster algorithm/library has appeared, following the dragon-theme: GitHub - vitaut/zmij: A double-to-string conversion algorithm based on Schubfach and yy

3 Likes

Russ Cox’s Floating-Point Printing and Parsing Can Be Simple And Fast may be of interest here.

Thanks for the reference! Żmij is the very newest kid on the block here, and according to the author, substantially faster than even uscale:

More relevant context:

After Żmij came up in this thread, I put together a small CPython C-extension PoC to evaluate it in a stdlib-json-compatible setting:

https://github.com/mp0rta/zmij-fastjson

It exposes fastjson.dumps() with a strict drop-in contract: for any given *args, **kwargs, it aims to be byte-for-byte identical to json.dumps(*args, **kwargs), including matching exception type/message. Whenever exact equivalence can’t be guaranteed, it falls back to stdlib json.dumps to preserve behavior.

For finite floats, the native path uses Żmij for formatting and implements the JSON-specific “append .0 if the result contains none of ‘.’, ‘e’, ‘E’” rule (similar to CPython’s Py_DTSF_ADD_DOT_0 behavior), and preserves -0.0.

I’m not proposing a wholesale replacement of dtoa but I’d be interested in pointers / insights:

  1. Where would a CPython integration realistically hook in (e.g. PyOS_double_to_string vs the json encoder’s float path)?

  2. What benchmark evidence would you consider convincing here (pyperf methodology, representative workloads like large list[float], comparisons, acceptable variance)?

2 Likes

To determine what we can improve upton the current implemetation in dtoa.c I implemented float-to-string and string-to-float conversions with modern libraries. For string-to-float, the options I looked at are ffc.h (a C99 port of Lemire’s fast_float) and Wuffs. Both can drop into the string-to-float path currently provided by _Py_dg_strtod in dtoa.c. There is no significant performance gain (or loss), but both packages provide a modern and well-tested routine.

For float-to-string the situation is a bit more complex. _Py_dg_dtoa has three different modes:

dtoa mode Used by Description
Mode 0 repr(), str() Shortest round-trip representation
Mode 2 %e, %g, f-string e/g N significant digits
Mode 3 (ndigits ≥ 0) %f, f-string f N digits after the decimal point
Mode 3 (ndigits < 0) float.__round__ with negative ndigits Rounding to a power of 10

Of the C libraries available, Ryu (used by Rust, Swift, Julia, and Zig) provides most of the required functionality. Using Ryu for Mode 0 and Mode 3 with ndigits ≥ 0 gives a clear performance improvement:

Benchmark main ns/op Ryu ns/op Speedup
Mode 0 — shortest repr
repr(x), fixed set 178.1 102.6 1.74×
repr(x), 100 random floats 410.8 101.3 4.06×
Mode 3 — fixed-point (positive ndigits)
f'{x:.2f}', fixed set 144.8 151.2 0.96×
f'{x:.10f}', fixed set 168.5 168.1 1.00×
f'{x:.10f}', 100 random floats 339.1 176.2 1.92×

(Release build, gcc 14.2 on Linux x86_64, best ns/op over 7 samples.)

The fixed set is 0.0, 1.0, -1.0, 0.1, 0.5, 1.5, 3.14, 2.718281828, 1/7, 1/3, 12345.6789, -98.76, 9.99, 100.0, 0.001, 1e-5, -2.5, 42.42, 0.25, 987.654. Note that for the fixed set (“easy” values where Gay’s dtoa hits a fast path) performance is very different from the random floats.

A branch implementing the above is at eendebakpt:ryu_float_partial. When disregarding the vendored Ryu and added tests, the changes to cpython itself are small (last two commits).

In an ideal scenario a replacement for dtoa.c would give us all
three of:

  1. Significant performance improvement
  2. A modern, well-tested library with minimal interface to cpython
  3. Full replacement of dtoa.c

Achieving all three together is not easy. The string-to-float side is relatively easy. The float-to-string side has challenges: many of the available libraries are C++ (Dragonbox, Google double-conversion, others). The only C option with competitive performance I am aware of is Ryu.

  • Mode 0 and Mode 3 with ndigits ≥ 0: Ryu works well out of the box.
  • Mode 2 (%e / %g): Ryu’s d2exp has a slight regression versus Gay’s dtoa. With some additional changes on ryu this can be fixed.
    Mode 3 with ndigits < 0: is not implemented in Ryu. I have a prototype (based on ideas from Gay’s algorithm). It works, but it means diverging from the Ryu implementation.

That leaves us with roughly two options:

i) Use Ryu only for mode 0 and mode 3 with ndigits ≥ 0. Solid performance gain, but keeps dtoa.c around as the fallback for unsupported modes.

ii) Use Ryu for all modes. Larger performance gain and lets us delete dtoa.c, but requires more work on the Ryu adapter (mode-2 fast path, mode-3-negative extension).

Any feedback is welcome. If you are interested in working together on a potential PR, send me a DM.

9 Likes

Should language constraints our choice? I assume we rather to vendor some unmodified version of the Ryu (or whatever else, see mentioned in the discussion thread projects) with some configure option to use system’s library.

Have you tried other variants wrt available feature set, maybe some library fits our needs better?

I believe this should be the end of story: no local modifications in the vendored code (Ryu or something else) and removal of the dtoa.c.

But I think you can introduce bindings to the new library in several steps, assuming you (a) understand which parts are missing and (b) upstream is positive on changing the library in the way you like.

It looks like mode-3-negative extension is missing from this branch. Could you show that part? (BTW, I suggest you using PRs against your fork. This offers you possibility to delete old branches eventually: you always be able to restore closed PR.)