Replace fast_float C++ library with pure C implementation#14661
Conversation
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.
|
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. |
Do note that the 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. |
|
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. details on benchmarks on arm-aws-m8g.metal-24xl:
details on benchmarks on x86-aws-m7i.metal-24xl:
|
sundb
left a comment
There was a problem hiding this comment.
we can remove g++ from ci.yml and daily.yml now.
|
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
left a comment
There was a problem hiding this comment.
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).
| double fast_float_strtod(const char *nptr, char **endptr) { | ||
| double result = 0.0; | ||
| const char *p = nptr; | ||
| const char *pend = nptr + strlen(nptr); |
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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.
| /* Handle overflow in mantissa: if we have too many digits, | ||
| * we need to reparse more carefully */ | ||
| if (digit_count > MAX_DIGITS) { |
There was a problem hiding this comment.
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 ? 🤔
There was a problem hiding this comment.
Yes, seems sensible to me.
|
hi, if possible can you elaborate some of the test case and test data you have on hand? |
|
Hello, I see this is still blocked. Could I ask what I can do in order to put this forward? Thanks. |
|
Is the intention to retain the MIT license? Now that it's pure C, it might be worth pulling into hiredis and phpredis 😄 |
|
Any update on this? |
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 |
@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;
} |
|
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 |
|
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 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 |
|
@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). However in all the call sites a larger buffer is used right now, but still. Thanks |
Before #11884 we used strtod, but fastfloat doesn't support hex literals, so in fact it's a break change introduced since that. |
good catch. |
…o avoid break change This reverts commit d6c93bd.
Co-authored-by: Moti Cohen <moti.cohen@redis.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Reviewed by Cursor Bugbot for commit f5fc37d. Configure here.
| * - 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); |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit f5fc37d. Configure here.
|
Maybe we also need to update the README.md file in redis repo. Line 225 in 3bcfbbe Like this, there is no need to install g++. |
no, it's for BUILD_WITH_MODULES |
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.
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.
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.
|
While reviewing the recent Issue Found Redis' current Examples: This is not the normal floating-point limitation where a decimal value cannot be represented exactly as binary64. The Reason The fallback only runs when the fast path rejects an input. These inputs are accepted by the widened fast path, so the There are two problematic paths:
Because Redis mixes a custom fast path with a runtime libc strtod() fallback, every accepted fast-path input must be bit- 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 |
This issue originated from the changes in PR #15061 |
…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.


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
Changes
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_floatC++ 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 tostrtodfor 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, andDEBUG SLEEP), tightensstring2dto reject partial parses, and adds a dedicatedfastfloatunit test plus a SORT regression test.Simplifies builds by removing
fast_floatfrom dependency targets, dropping-lstdc++and thedeps/fast_floatstatic library, cleaning up.gitignore/depsMakefile, and adjusting CI workflows to no longer installg++/gcc-c++.Reviewed by Cursor Bugbot for commit 605c2a2. Bugbot is set up for automated code reviews on this repo. Configure here.