Skip to content

Replace fast_float C++ library with pure C implementation#14661

Merged
sundb merged 50 commits into
redis:unstablefrom
antirez:nocpp
Apr 15, 2026
Merged

Replace fast_float C++ library with pure C implementation#14661
sundb merged 50 commits into
redis:unstablefrom
antirez:nocpp

Conversation

@antirez

@antirez antirez commented Jan 5, 2026

Copy link
Copy Markdown
Contributor

The fast_float dependency required C++ (libstdc++) to build Redis. This commit replaces the 3800-line C++ template library with a minimal pure C implementation (~360 lines) that provides the same functionality needed by Redis.

This is very important because Redis build process would fail without g++ installed, a common situation in Linux distributions even after installing the basic build tools: we want the build process of Redis to be the simplest possible. Also Redis sometimes is compiled in embedded systems lacking the g++ toolchain. There is no reason to depend on C++ in a project written in C.

The C implementation uses

  1. Fast path (Clinger's algorithm) for numbers with mantissa <= 2^53 and exponent in [-22, 22], covering ~99% of real-world cases.
  2. Fallback to strtod() for complex cases to ensure correctly-rounded results.

Changes

  • Move new fast_float_strtod.c(C implementation) from deps into Redis core since it is now a single file and no longer needs a separate directory.
  • Remove all c++ dependencies

The implementation was tested against both strtod and the original C++ implementation with 10,000+ test cases including edge cases, special values (inf/nan), and random inputs.


Note

Medium Risk
Replaces core floating-point parsing and build/link settings to remove C++/libstdc++ dependencies; parsing correctness and edge-case behavior could affect command semantics and tests across platforms.

Overview
Replaces the bundled deps/fast_float C++ library with a new in-tree C implementation (src/fast_float_strtod.c/.h) that parses common decimal floats via a fast path and falls back to strtod for complex cases.

Updates all call sites to use the new length-based fast_float_strtod(const char*, size_t, ...) API (including RESP double parsing, zset score parsing, and DEBUG SLEEP), tightens string2d to reject partial parses, and adds a dedicated fastfloat unit test plus a SORT regression test.

Simplifies builds by removing fast_float from dependency targets, dropping -lstdc++ and the deps/fast_float static library, cleaning up .gitignore/deps Makefile, and adjusting CI workflows to no longer install g++/gcc-c++.

Reviewed by Cursor Bugbot for commit 605c2a2. Bugbot is set up for automated code reviews on this repo. Configure here.

The fast_float dependency required C++ (libstdc++) to build Redis.
This commit replaces the 3800-line C++ template library with a minimal
pure C implementation (~260 lines) that provides the same functionality
needed by Redis.

The C implementation uses:
1. Fast path (Clinger's algorithm) for numbers with mantissa <= 2^53
   and exponent in [-22, 22], covering ~99% of real-world cases.
2. Fallback to strtod() for complex cases to ensure correctly-rounded
   results.

Changes:
- New deps/fast_float/fast_float_strtod.c (C implementation)
- Updated deps/fast_float/Makefile (compile C instead of C++)
- Removed deps/fast_float/fast_float.h (C++ library)
- Removed deps/fast_float/fast_float_strtod.cpp (C++ wrapper)
- Removed -lstdc++ from src/Makefile FINAL_LIBS

The implementation was tested against both strtod and the original C++
implementation with 10,000+ test cases including edge cases, special
values (inf/nan), and random inputs.
@antirez

antirez commented Jan 5, 2026

Copy link
Copy Markdown
Contributor Author

Ping @fcostaoliveira that I believe was the original commit author that introduced the fast path for floats parsing. Would love a review from him. Thanks.

@lemire

lemire commented Jan 5, 2026

Copy link
Copy Markdown

@antirez

Fallback to standard strtod() for complex cases to ensure correctly-rounded results

Do note that the fast_float library provides correctly-rounded results always.

So the C port should not need a fallback.

@lemire lemire mentioned this pull request Jan 5, 2026
6 tasks
@antirez

antirez commented Jan 5, 2026

Copy link
Copy Markdown
Contributor Author

@antirez

Fallback to standard strtod() for complex cases to ensure correctly-rounded results

Do note that the fast_float library provides correctly-rounded results always.

So the C port should not need a fallback.

The C implementation is not a port of the C++ code, this is why you can see this huge difference in the lines of code needed for the implementation. This code was written with the mandatory specification of just incapsulating the fast path and not caring about the complex edge cases to handle, which are rare, complicated, and that strtod() can handle very well without we having any speed regression.

@fcostaoliveira fcostaoliveira self-requested a review January 6, 2026 15:02
@fcostaoliveira

Copy link
Copy Markdown
Collaborator

checking the original benchmarks used to approve fast_float on redis (#11884 (comment)) we see pretty much the same performance between this branch and unstable, making it "safe" performance wise to merge.
Haven't reviewed the code yet, just performance. Will do that until EOD (both me and @paulorsousa )

details on benchmarks on arm-aws-m8g.metal-24xl:

Test Case Baseline /redis unstable (median obs. +- std.dev) Comparison antirez/redis nocpp (median obs. +- std.dev) % change (higher-better) Note
memtier_benchmark-1key-zset-100-elements-zrange-all-elements 56079 +- 0.9% (7 datapoints) 57839 3.1% IMPROVEMENT
memtier_benchmark-1Mkeys-load-zset-with-10-elements-double-score 150193 +- 0.3% (7 datapoints) 153518 2.2% No Change

details on benchmarks on x86-aws-m7i.metal-24xl:

Test Case Baseline /redis unstable (median obs. +- std.dev) Comparison antirez/redis nocpp (median obs. +- std.dev) % change (higher-better) Note
memtier_benchmark-1key-zset-100-elements-zrange-all-elements 47326 +- 0.3% (7 datapoints) 47385 0.1% No Change
memtier_benchmark-1Mkeys-load-zset-with-10-elements-double-score 128550 +- 2.9% (7 datapoints) 126653 -1.5% No Change

@sundb sundb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we can remove g++ from ci.yml and daily.yml now.

Comment thread deps/fast_float/Makefile Outdated
@antirez

antirez commented Jan 13, 2026

Copy link
Copy Markdown
Contributor Author

Thank you! It is great that the brach is still as fast as before. The new C code only covers the subset that "matters", so it was important to be sure that there were no regression speed. Appreciated!

@Sanix-Darker Sanix-Darker left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hi there,
I really do like this simple C approach, and just wanted to participate by pointing out some of my thoughts, IMHO, these three fixes collectively can improve parsing speed for common cases (especially for strings with long non-numeric suffixes).

Comment thread deps/fast_float/fast_float_strtod.c Outdated
double fast_float_strtod(const char *nptr, char **endptr) {
double result = 0.0;
const char *p = nptr;
const char *pend = nptr + strlen(nptr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

strlen() forces O(n) scan of entire input string, even for short numeric prefixes like '123.45'. For strings like '3.14\n...1MB_of_text...', this wastes CPU cycles scanning irrelevant data.

Maybe it could be better to parse incrementally until first non-numeric character instead ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

sometimes strlen() is so massively optimized in the C library that processes N words in parallel. But also what you said is intuitively totally correct. I'm not sure without benchmarking. We could go in one way or the other.

Comment thread src/fast_float_strtod.c
Comment on lines +214 to +216
/* Handle overflow in mantissa: if we have too many digits,
* we need to reparse more carefully */
if (digit_count > MAX_DIGITS) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If am not wrong, this reparses the same digits twice when digit_count > 19 (from #define MAX_DIGITS 19 ).
This wastes cycles and complicates exponent tracking, maybe we could track truncated mantissa during initial parse using a separate variable to avoid re-parsing ? 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, seems sensible to me.

Comment thread src/fast_float_strtod.c Outdated
@lechibang-1512

Copy link
Copy Markdown

hi, if possible can you elaborate some of the test case and test data you have on hand?

@sundb sundb added this to Redis 8.8 Jan 26, 2026
@sundb sundb moved this from Todo to In Review in Redis 8.8 Jan 26, 2026
@antirez

antirez commented Feb 1, 2026

Copy link
Copy Markdown
Contributor Author

Hello, I see this is still blocked. Could I ask what I can do in order to put this forward? Thanks.

@michael-grunder

Copy link
Copy Markdown
Contributor

Is the intention to retain the MIT license? Now that it's pure C, it might be worth pulling into hiredis and phpredis 😄

@district10

Copy link
Copy Markdown

Any update on this?

Comment thread deps/fast_float/fast_float_strtod.c Outdated
@lemire

lemire commented Feb 24, 2026

Copy link
Copy Markdown

The fast_float dependency required C++ (libstdc++) to build Redis.

Thankfully, it does not. You do need at C++ compiler at compile time but you do not need to introduce a dependency on the C++ standard library.

See

#14812

@sundb

sundb commented Mar 3, 2026

Copy link
Copy Markdown
Collaborator

@antirez

Fallback to standard strtod() for complex cases to ensure correctly-rounded results

Do note that the fast_float library provides correctly-rounded results always.
So the C port should not need a fallback.

The C implementation is not a port of the C++ code, this is why you can see this huge difference in the lines of code needed for the implementation. This code was written with the mandatory specification of just incapsulating the fast path and not caring about the complex edge cases to handle, which are rare, complicated, and that strtod() can handle very well without we having any speed regression.

@antirez note that in the current redis code, if fast_float_strtod() fails, it will fallback to strtod(). However, in this PR, there is already a fallback, which means that if it is an illegal double string, we will definitely fall back twice.

e.g.

int string2d(const char *s, size_t slen, double *dp) {
    ...
    *dp = fast_float_strtod(s, &eptr);
    // fallback to strtod
    if (unlikely((size_t)(eptr - (char*)s) != slen)) {
        char *fallback_eptr;
        *dp = strtod(s, &fallback_eptr);
        if ((size_t)(fallback_eptr - (char*)s) != slen) return 0;
    }
    if (unlikely(errno == EINVAL ||
        (errno == ERANGE &&
            (*dp == HUGE_VAL || *dp == -HUGE_VAL || fpclassify(*dp) == FP_ZERO)) ||
        isnan(*dp)))
        return 0;
    return 1;
}

@kolemannix

kolemannix commented Mar 4, 2026

Copy link
Copy Markdown

Funny timing, I recently performed a by-hand port of fast_float to plain C99. It lives here:

https://github.com/kolemannix/ffc.h?tab=readme-ov-file#benchmarks

It passes the 'exhaustive' test suite for all 32-bit bit patterns roundtripped to float and double, and is currently benchmarking at (slightly above) the level of the original cpp.

This could be a viable alternative to this PR. I believe my port does not suffer from a few of the issues raised in the comments in this PR (strlen, reparse)

*Edit: we haven't tested the specialized 32-bit architecture code yet; if this is of interest for the redis project, we can focus on that asap

*Edit 2: tests are passing under x64 as of kolemannix/ffc.h#5

@kolemannix

Copy link
Copy Markdown

We have an initial release of ffc.h published, I'll work a variant of this branch that uses ffc.h instead. I understand if its more than you want, the fallback bigint stuff, if falling back to strtod is satisfactory.

But if that is the main concern, I am happy to add a macro feature that removes the bigint fallback code and either delegates to strdo(d|f) or returns a specific outcome code for that case.

@antirez

antirez commented Mar 16, 2026

Copy link
Copy Markdown
Contributor Author

@sundb @fcostaoliveira please could you handle this from this point on? Feel free to do the thing that you believe are more appropriate. I vote for using the implementation I provided that is stripped from everything we don't need. However note that there is an error:

The actual maximum output of the conversion bug is 26 bytes: (18 significant digits + 7 trailing zeros + 1 for - sign).
So the declaration should be:

  // fpconv_dtoa.h
  int fpconv_dtoa(double fp, char dest[26]);

However in all the call sites a larger buffer is used right now, but still.

Thanks

Comment thread deps/fast_float/fast_float_strtod.c Outdated
Comment thread deps/fast_float/fast_float_strtod.c Outdated
Comment thread deps/fast_float/fast_float_strtod.c Outdated
Comment thread deps/fast_float/fast_float_strtod.c Outdated
@sundb

sundb commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator

This changes semantics, not just implementation. The new fast_float_strtod() fallback now accepts hex literals (0x1, 0x1p0) for direct callers that previously rejected them, which affects commands like ZCOUNT / ZRANGEBYSCORE / ZREMRANGEBYSCORE / SORT BY. I don’t think Redis generally treats hex numeric input as interchangeable with decimal input, so this looks like an unintended grammar expansion. Can we keep the previous decimal-only behavior here, or explicitly document and test the new syntax if it’s intentional?

Before #11884 we used strtod, but fastfloat doesn't support hex literals, so in fact it's a break change introduced since that.
Now we bring it back.

@sundb

sundb commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator

SORT BY now fails on valid tiny finite doubles near underflow, while old code passed.

RPUSH s a b
SET w_a 2.2250738585072012e-308
SET w_b 2.2250738585072014e-308
SORT s BY w_*
(error) ERR One or more scores can't be converted into double

good catch.

Comment thread src/t_zset.c Outdated
sundb and others added 2 commits April 13, 2026 09:32
Comment thread src/sort.c
Comment thread src/util.c
Comment thread src/fast_float_strtod.c

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Reviewed by Cursor Bugbot for commit f5fc37d. Configure here.

Comment thread src/fast_float_strtod.c
* - Very large or very small exponents
* - Too many digits (need precise rounding)
* This ensures we get correctly-rounded results for edge cases. */
return fast_float_strtod_fallback(nptr, len, endptr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hex strings now accepted via strtod fallback path

Medium Severity

fast_float_strtod falls back to strtod() for any input the fast path can't handle. Since strtod() accepts hex literals like 0x1 and 0x1p0, and string2d no longer has its own separate rejection logic (the old explicit strtod fallback in string2d was removed and replaced with a simple "reject if not all consumed" check), hex inputs are now accepted through string2d. This changes behavior for commands like ZCOUNT, ZRANGEBYSCORE, ZREMRANGEBYSCORE, and SORT BY, which previously rejected hex input. This was flagged in the PR discussion by @moticless as an unintended grammar expansion.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f5fc37d. Configure here.

@sundb sundb merged commit 670993a into redis:unstable Apr 15, 2026
18 checks passed
@github-project-automation github-project-automation Bot moved this from In Review to Done in Redis 8.8 Apr 15, 2026
@vitahlin

Copy link
Copy Markdown
Contributor

Maybe we also need to update the README.md file in redis repo.

redis/README.md

Line 225 in 3bcfbbe

sudo apt-get install -y --no-install-recommends ca-certificates wget dpkg-dev gcc g++ libc6-dev libssl-dev make git python3 python3-pip python3-venv python3-dev unzip rsync clang automake autoconf gcc-10 g++-10 libtool

Like this, there is no need to install g++.

@sundb

sundb commented Apr 15, 2026

Copy link
Copy Markdown
Collaborator

Maybe we also need to update the README.md file in redis repo.

redis/README.md

Line 225 in 3bcfbbe

sudo apt-get install -y --no-install-recommends ca-certificates wget dpkg-dev gcc g++ libc6-dev libssl-dev make git python3 python3-pip python3-venv python3-dev unzip rsync clang automake autoconf gcc-10 g++-10 libtool

Like this, there is no need to install g++.

no, it's for BUILD_WITH_MODULES

fcostaoliveira added a commit to filipecosta90/redis that referenced this pull request Apr 16, 2026
Recover the redis#14661 regression on float-parsing-heavy sorted-set listpack
workloads (bisect on 1Mkeys-load-zset-listpack-with-100-elements-double-score:
-5.7% x86 / -27.4% ARM vs 8.6 shipped in 8.8-M02).

Root cause: ~50% of random double scores have 17-19 significant digits,
which exceed MAX_MANTISSA_FAST_PATH (2^53). These fell through to the
strtod() fallback (memcpy + null-term + glibc strtod), which on ARM is
especially slow and is the entire regression vs 8.6.

Fix: compute (mantissa * 10^exp) in 128-bit integer arithmetic and
convert to double via a single IEEE round-to-nearest-even cast. Covered
for |exp| in [0, 19] where 10^|exp| fits in uint64. Cases outside that
range (or truly corrupt inputs) still fall through to strtod.

Bit-exact with strtod: verified on every added unit test (98 cases
total, up from 77) + TCL round-trip via ZADD/ZSCORE/DEBUG RELOAD on
both listpack and skiplist encodings.

Code delta: ~50 LOC in fast_float_strtod.c, ~25 LOC of unit tests,
~30 LOC of TCL integration test. No new dependencies; pure C.
fcostaoliveira added a commit to filipecosta90/redis that referenced this pull request Apr 16, 2026
Recovers the regression on float-parsing-heavy sorted-set listpack
workloads introduced when the C++ fast_float library was replaced by
the pure-C implementation. Bisect on
`memtier_benchmark-1Mkeys-load-zset-listpack-with-100-elements-double-score`
(x86-aws-m7i.metal-24xl / arm-aws-m8g.metal-24xl, n=3 datapoints each):

| Commit                               | x86 ops/sec | ARM ops/sec |
|--------------------------------------|------------:|------------:|
| 8.6 branch (a176d12)                 |       5,633 |       6,502 |
| Right before the rewrite             |       5,617 |         TBD |
| Current unstable (with the rewrite)  |       5,310 |       4,721 |
| **After this patch (x86 measured)**  |   **9,788** |         TBD |

x86 result: +73.8% vs 8.6, +84.3% vs unstable. ARM measurement pending.

## Root cause

~50% of random double scores have 17-19 significant digits, which
exceed MAX_MANTISSA_FAST_PATH (2^53). These fell through to the
strtod() fallback (memcpy + null-term + glibc strtod), which on ARM
is significantly slower than the fast path and is essentially the
entire regression vs 8.6.

## Fix

Compute (mantissa * 10^exp) in 128-bit integer arithmetic via
`__uint128_t` and convert to double with a single IEEE round-to-
nearest-even cast. Supported for |exp| in [0, 19] where 10^|exp|
fits in uint64. Cases outside that range (or truly corrupt inputs)
still fall through to strtod().

## Correctness

Bit-exact with strtod on every tested case:

- `src/fast_float_strtod.c` unit tests: 98 passed (77 original + 21
  new), 0 failed. New cases exercise positive/negative exponents,
  mantissa exactly at 2^53 boundary, ties-to-even rounding, 19-digit
  mantissas, cases that fall back to strtod.
- `tests/unit/type/zset.tcl` integration tests: round-trip ZADD →
  DEBUG RELOAD → ZSCORE on listpack + skiplist encodings with 17-19
  significant digit scores.

## Code delta

~50 LOC in fast_float_strtod.c, ~25 LOC of unit tests, ~30 LOC of
TCL integration test. No new dependencies; pure C; no libstdc++
reintroduced.

## References

The fast-path concept is Clinger's, the pure-C impl being extended
is antirez/sundb/mpozniak95/moticless's, the broader algorithmic
context is fast_float (Lemire & Magalhães). None of those directly
cover the narrow 'widen the Clinger fast path via __uint128_t
one-shot' trick this patch uses — that was written for this patch.

- Clinger, 'How to Read Floating Point Numbers Accurately' (PLDI
  1990) — the existing pure-C fast path is a direct application of
  Clinger; this patch only widens its precondition.
- Pure-C fast_float_strtod.c being extended: redis#14661 by
  @antirez, @sundb, @mpozniak95, @moticless. The existing Clinger
  fast path (mantissa <= 2^53) is unchanged.
- Related reading: Lemire & Magalhães, 'Number Parsing at a Gigabyte
  per Second' (2021). A full port via the Eisel-Lemire algorithm
  (see github.com/kolemannix/ffc.h, ~3.5k LOC) achieves correct
  rounding over the full exponent range. This patch takes the
  narrower path that only needs ~50 LOC to cover the Redis use case.
fcostaoliveira added a commit to filipecosta90/redis that referenced this pull request Apr 17, 2026
Recovers the regression on float-parsing-heavy sorted-set listpack
workloads introduced when the C++ fast_float library was replaced by
the pure-C implementation. Bisect on
`memtier_benchmark-1Mkeys-load-zset-listpack-with-100-elements-double-score`
(x86-aws-m7i.metal-24xl / arm-aws-m8g.metal-24xl, n=3 datapoints each):

| Commit                               | x86 ops/sec | ARM ops/sec |
|--------------------------------------|------------:|------------:|
| 8.6 branch (a176d12)                 |       5,633 |       6,502 |
| Right before the rewrite             |       5,617 |         TBD |
| Current unstable (with the rewrite)  |       5,310 |       4,721 |
| **After this patch (x86 measured)**  |   **9,788** |         TBD |

x86 result: +73.8% vs 8.6, +84.3% vs unstable. ARM measurement pending.

## Root cause

~50% of random double scores have 17-19 significant digits, which
exceed MAX_MANTISSA_FAST_PATH (2^53). These fell through to the
strtod() fallback (memcpy + null-term + glibc strtod), which on ARM
is significantly slower than the fast path and is essentially the
entire regression vs 8.6.

## Fix

Compute (mantissa * 10^exp) in 128-bit integer arithmetic via
`__uint128_t` and convert to double with a single IEEE round-to-
nearest-even cast. Supported for |exp| in [0, 19] where 10^|exp|
fits in uint64. Cases outside that range (or truly corrupt inputs)
still fall through to strtod().

## Correctness

Bit-exact with strtod on every tested case:

- `src/fast_float_strtod.c` unit tests: 98 passed (77 original + 21
  new), 0 failed. New cases exercise positive/negative exponents,
  mantissa exactly at 2^53 boundary, ties-to-even rounding, 19-digit
  mantissas, cases that fall back to strtod.
- `tests/unit/type/zset.tcl` integration tests: round-trip ZADD →
  DEBUG RELOAD → ZSCORE on listpack + skiplist encodings with 17-19
  significant digit scores.

## Code delta

~50 LOC in fast_float_strtod.c, ~25 LOC of unit tests, ~30 LOC of
TCL integration test. No new dependencies; pure C; no libstdc++
reintroduced.

## References

The fast-path concept is Clinger's, the pure-C impl being extended
is antirez/sundb/mpozniak95/moticless's, the broader algorithmic
context is fast_float (Lemire & Magalhães). None of those directly
cover the narrow 'widen the Clinger fast path via __uint128_t
one-shot' trick this patch uses — that was written for this patch.

- Clinger, 'How to Read Floating Point Numbers Accurately' (PLDI
  1990) — the existing pure-C fast path is a direct application of
  Clinger; this patch only widens its precondition.
- Pure-C fast_float_strtod.c being extended: redis#14661 by
  @antirez, @sundb, @mpozniak95, @moticless. The existing Clinger
  fast path (mantissa <= 2^53) is unchanged.
- Related reading: Lemire & Magalhães, 'Number Parsing at a Gigabyte
  per Second' (2021). A full port via the Eisel-Lemire algorithm
  (see github.com/kolemannix/ffc.h, ~3.5k LOC) achieves correct
  rounding over the full exponent range. This patch takes the
  narrower path that only needs ~50 LOC to cover the Redis use case.
@daemyung-lablup

daemyung-lablup commented Apr 25, 2026

Copy link
Copy Markdown

While reviewing the recent fast_float_strtod() changes, I noticed a potential correctness issue in the widened fast path
for 17-19 digit mantissas. This is not about the usual fact that decimal values are often not exactly representable as
binary floating-point values. The concern is that Redis' fast path and its libc strtod() fallback can choose different
adjacent double values for the same valid decimal input.

Issue Found

Redis' current fast_float_strtod() widened fast path can return a different double than libc strtod() for some valid
decimal inputs. The difference is 1 ULP, but it is still observable because Redis uses libc strtod() as the fallback path
and expects the fast path to be bit-exact with it.

Examples:

input: 9007199255094284e-19
fast_float_strtod: 0x3f4d83c94fbbcb8a
libc strtod      : 0x3f4d83c94fbbcb8b
delta            : -1 ULP

input: 2489830482329185244e1
fast_float_strtod: 0x43f59888c51e5b4c
libc strtod      : 0x43f59888c51e5b4b
delta            : +1 ULP

This is not the normal floating-point limitation where a decimal value cannot be represented exactly as binary64. The
expected behavior is still to choose the correctly-rounded nearest representable double. In these cases, the widened fast
path chooses the adjacent double instead.

Reason

The fallback only runs when the fast path rejects an input. These inputs are accepted by the widened fast path, so the
function returns the fast-path result directly and never calls the strtod() fallback.

There are two problematic paths:

  • For negative exponents, the widened path computes (mantissa << 64) / divisor using integer division. This truncates the
    remainder, so values near a rounding boundary can be rounded down incorrectly.
  • For positive exponents, the widened path splits a __uint128_t product into high/low 64-bit parts, converts each part to
    double, and adds them. This can introduce double rounding and choose the neighboring double.

Because Redis mixes a custom fast path with a runtime libc strtod() fallback, every accepted fast-path input must be bit-
exact with strtod(). The original Clinger fast path had a safe precondition for that. The widened 17-19 digit mantissa path
does not appear to preserve that guarantee for all inputs.

below code is reproduced case.

/*
* Standalone reproducer for Redis fast_float_strtod rounding mismatches.
*
* Build from the repository root:
*
*   cc -std=c11 -O2 -I src tests/helpers/fast_float_strtod_rounding_repro.c -o /tmp/ff_repro
*   /tmp/ff_repro
*
* The program intentionally returns non-zero when it finds a mismatch between
* the current Redis fast_float_strtod implementation and libc strtod().
*/

#include <errno.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Pull in the current Redis implementation so the repro follows the real path. */
#include "../../src/fast_float_strtod.c"

/* Minimal allocator stubs used only by fast_float_strtod_fallback(). */
void *zmalloc(size_t size) {
   return malloc(size);
}

void zfree(void *ptr) {
   free(ptr);
}

static uint64_t double_bits(double value) {
   uint64_t bits;
   memcpy(&bits, &value, sizeof(bits));
   return bits;
}

static int check_one(const char *input) {
   char *fast_end = NULL;
   char *libc_end = NULL;
   size_t len = strlen(input);

   errno = 0;
   double fast = fast_float_strtod(input, len, &fast_end);
   int fast_errno = errno;

   errno = 0;
   double libc = strtod(input, &libc_end);
   int libc_errno = errno;

   uint64_t fast_bits = double_bits(fast);
   uint64_t libc_bits = double_bits(libc);
   int64_t ulp_delta = (fast_bits >= libc_bits) ?
       (int64_t)(fast_bits - libc_bits) : -(int64_t)(libc_bits - fast_bits);

   printf("input: %s\n", input);
   printf("  fast_float_strtod: %.17g bits=0x%016" PRIx64 " errno=%d end=%td/%zu\n",
          fast, fast_bits, fast_errno, fast_end - input, len);
   printf("  libc strtod      : %.17g bits=0x%016" PRIx64 " errno=%d end=%td/%zu\n",
          libc, libc_bits, libc_errno, libc_end - input, len);
   printf("  ulp delta        : %" PRId64 "\n\n", ulp_delta);

   return fast_bits != libc_bits || fast_end != input + len || libc_end != input + len;
}

int main(void) {
   const char *cases[] = {
       /* Negative exponent widened path truncates the scaled integer quotient. */
       "9007199255094284e-19",

       /* Positive exponent widened path double-rounds through hi/lo doubles. */
       "2489830482329185244e1",
   };
   int mismatches = 0;

   for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
       mismatches += check_one(cases[i]);
   }

   printf("%d mismatch(es) found\n", mismatches);
   return mismatches ? 1 : 0;
}

Test Result

input: 9007199255094284e-19
  fast_float_strtod: 0.00090071992550942834 bits=0x3f4d83c94fbbcb8a errno=0 end=20/20
  libc strtod      : 0.00090071992550942845 bits=0x3f4d83c94fbbcb8b errno=0 end=20/20
  ulp delta        : -1

input: 2489830482329185244e1
  fast_float_strtod: 2.4898304823291855e+19 bits=0x43f59888c51e5b4c errno=0 end=21/21
  libc strtod      : 2.4898304823291851e+19 bits=0x43f59888c51e5b4b errno=0 end=21/21
  ulp delta        : 1

2 mismatch(es) found

@vitahlin

Copy link
Copy Markdown
Contributor

While reviewing the recent fast_float_strtod() changes, I noticed a potential correctness issue in the widened fast path for 17-19 digit mantissas. This is not about the usual fact that decimal values are often not exactly representable as binary floating-point values. The concern is that Redis' fast path and its libc strtod() fallback can choose different adjacent double values for the same valid decimal input.

Issue Found

Redis' current fast_float_strtod() widened fast path can return a different double than libc strtod() for some valid decimal inputs. The difference is 1 ULP, but it is still observable because Redis uses libc strtod() as the fallback path and expects the fast path to be bit-exact with it.

Examples:

input: 9007199255094284e-19
fast_float_strtod: 0x3f4d83c94fbbcb8a
libc strtod      : 0x3f4d83c94fbbcb8b
delta            : -1 ULP

input: 2489830482329185244e1
fast_float_strtod: 0x43f59888c51e5b4c
libc strtod      : 0x43f59888c51e5b4b
delta            : +1 ULP

This is not the normal floating-point limitation where a decimal value cannot be represented exactly as binary64. The expected behavior is still to choose the correctly-rounded nearest representable double. In these cases, the widened fast path chooses the adjacent double instead.

Reason

The fallback only runs when the fast path rejects an input. These inputs are accepted by the widened fast path, so the function returns the fast-path result directly and never calls the strtod() fallback.

There are two problematic paths:

  • For negative exponents, the widened path computes (mantissa << 64) / divisor using integer division. This truncates the
    remainder, so values near a rounding boundary can be rounded down incorrectly.
  • For positive exponents, the widened path splits a __uint128_t product into high/low 64-bit parts, converts each part to
    double, and adds them. This can introduce double rounding and choose the neighboring double.

Because Redis mixes a custom fast path with a runtime libc strtod() fallback, every accepted fast-path input must be bit- exact with strtod(). The original Clinger fast path had a safe precondition for that. The widened 17-19 digit mantissa path does not appear to preserve that guarantee for all inputs.

below code is reproduced case.

/*
* Standalone reproducer for Redis fast_float_strtod rounding mismatches.
*
* Build from the repository root:
*
*   cc -std=c11 -O2 -I src tests/helpers/fast_float_strtod_rounding_repro.c -o /tmp/ff_repro
*   /tmp/ff_repro
*
* The program intentionally returns non-zero when it finds a mismatch between
* the current Redis fast_float_strtod implementation and libc strtod().
*/

#include <errno.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Pull in the current Redis implementation so the repro follows the real path. */
#include "../../src/fast_float_strtod.c"

/* Minimal allocator stubs used only by fast_float_strtod_fallback(). */
void *zmalloc(size_t size) {
   return malloc(size);
}

void zfree(void *ptr) {
   free(ptr);
}

static uint64_t double_bits(double value) {
   uint64_t bits;
   memcpy(&bits, &value, sizeof(bits));
   return bits;
}

static int check_one(const char *input) {
   char *fast_end = NULL;
   char *libc_end = NULL;
   size_t len = strlen(input);

   errno = 0;
   double fast = fast_float_strtod(input, len, &fast_end);
   int fast_errno = errno;

   errno = 0;
   double libc = strtod(input, &libc_end);
   int libc_errno = errno;

   uint64_t fast_bits = double_bits(fast);
   uint64_t libc_bits = double_bits(libc);
   int64_t ulp_delta = (fast_bits >= libc_bits) ?
       (int64_t)(fast_bits - libc_bits) : -(int64_t)(libc_bits - fast_bits);

   printf("input: %s\n", input);
   printf("  fast_float_strtod: %.17g bits=0x%016" PRIx64 " errno=%d end=%td/%zu\n",
          fast, fast_bits, fast_errno, fast_end - input, len);
   printf("  libc strtod      : %.17g bits=0x%016" PRIx64 " errno=%d end=%td/%zu\n",
          libc, libc_bits, libc_errno, libc_end - input, len);
   printf("  ulp delta        : %" PRId64 "\n\n", ulp_delta);

   return fast_bits != libc_bits || fast_end != input + len || libc_end != input + len;
}

int main(void) {
   const char *cases[] = {
       /* Negative exponent widened path truncates the scaled integer quotient. */
       "9007199255094284e-19",

       /* Positive exponent widened path double-rounds through hi/lo doubles. */
       "2489830482329185244e1",
   };
   int mismatches = 0;

   for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
       mismatches += check_one(cases[i]);
   }

   printf("%d mismatch(es) found\n", mismatches);
   return mismatches ? 1 : 0;
}

Test Result

input: 9007199255094284e-19
  fast_float_strtod: 0.00090071992550942834 bits=0x3f4d83c94fbbcb8a errno=0 end=20/20
  libc strtod      : 0.00090071992550942845 bits=0x3f4d83c94fbbcb8b errno=0 end=20/20
  ulp delta        : -1

input: 2489830482329185244e1
  fast_float_strtod: 2.4898304823291855e+19 bits=0x43f59888c51e5b4c errno=0 end=21/21
  libc strtod      : 2.4898304823291851e+19 bits=0x43f59888c51e5b4b errno=0 end=21/21
  ulp delta        : 1

2 mismatch(es) found

This issue originated from the changes in PR #15061

sundb pushed a commit that referenced this pull request May 13, 2026
…15111)

## Summary

Fixes a ±1 ULP rounding mismatch between `fast_float_strtod()` and libc
`strtod()` in the widened (mantissa > 2^53) fast path introduced by
#15061. Reported by @vitahlin in
#14661 (comment) with
two minimal reproducers:

```
input: 9007199255094284e-19
  fast_float_strtod: 0x3f4d83c94fbbcb8a
  libc strtod      : 0x3f4d83c94fbbcb8b   delta -1 ULP

input: 2489830482329185244e1
  fast_float_strtod: 0x43f59888c51e5b4c
  libc strtod      : 0x43f59888c51e5b4b   delta +1 ULP
```

Redis treats `strtod()` as the fallback for `fast_float_strtod()`, so
every fast-path-accepted input is contractually expected to be bit-exact
with `strtod()`. The two cases above are accepted by the widened branch
but produce a different IEEE-754 representation, breaking the contract.

## Root cause

The widened branch added in #15061 used a homebrew shortcut to convert a
128-bit integer product to a double:

```c
value = (double)hi * 18446744073709551616.0 + (double)lo;
```

This is **not** a single-rounding operation — `(double)hi` rounds when
`hi > 2^53`, and the subsequent `+ (double)lo` rounds again. For inputs
near the round-half-to-even boundary, the two roundings can compose into
the wrong direction.

The negative-exponent branch is doubly affected: integer division
`scaled / divisor` truncates the remainder before the conversion, so
even a hypothetical correct `hi*2^64+lo` step would round down on inputs
that should round up.

## Fix

Replace the homebrew widened branch with the **Eisel-Lemire** algorithm
from upstream `fast_float`. This is the same algorithm that
`fast_float`'s own widened path uses; bit-exact-with-strtod for inputs
with ≤19-digit mantissa is proved in:

> Noble Mushtak and Daniel Lemire, "Fast Number Parsing Without
Fallback".

Pieces ported (MIT-licensed, from
`fast_float/include/fast_float/fast_table.h`, `decimal_to_binary.h`,
`float_common.h`):

- 128-bit precomputed extended-precision powers of five (`5^-342 ...
5^308`, 651 entries) — pure data.
- `compute_product_approximation` — 128-bit multiply with high-half
rounding-boundary fixup.
- `compute_float` — the main algorithm; returns `(mantissa, power2)`
ready to be packed.
- `am_to_double` — IEEE-754 binary64 bit-pack.
- `__builtin_clzll` and `__uint128_t` wrappers (with a 32-bit fallback
for portability).

Clinger's strict fast path (`mantissa ≤ 2^53` and `|exp| ≤ 22`) is
**kept** unchanged — it's a single double multiply/divide and is faster
than Eisel-Lemire on its domain. Only the buggy widened branch is
replaced.

The port stays minimal:
- **double-only** (no `float`, no `long double`)
- No bigint slow path. The rare "indeterminate" inputs that upstream
resolves with `digit_comp` are unreachable from `parse_number_string`'s
≤19-digit mantissa per the Mushtak-Lemire proof, but a defensive
`am.power2 < 0` check is preserved that falls back to libc `strtod()` if
any future caller widens the input domain.

## Why not just revert #15061?

Considered. Reverting restores correctness at the cost of the +73-84 %
zset listpack-load wins #15061 measured on 17-19 digit double scores.
Eisel-Lemire is *the* algorithm that gives both correctness *and* the
wider mantissa range — preserving #15061's wins while fixing the
rounding regression.

A "tightened admission filter" (only accept widened-path inputs where
the conversion happens to be single-rounding) was also considered. The
math shows the filter conditions are essentially unsatisfied for typical
inputs (`lo == 0` requires the 128-bit product be divisible by 2^64;
only ~1 in 10^13 random inputs qualify), making it equivalent to a
revert with extra dead code. Eisel-Lemire is the only widened-path
solution that preserves perf on the typical case.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-notes indication that this issue needs to be mentioned in the release notes

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.