Skip to content

fast_float_strtod: fix ±1 ULP rounding mismatch in widened fast path#15111

Merged
sundb merged 7 commits into
redis:unstablefrom
filipecosta90:fast-float-eisel-lemire
May 13, 2026
Merged

fast_float_strtod: fix ±1 ULP rounding mismatch in widened fast path#15111
sundb merged 7 commits into
redis:unstablefrom
filipecosta90:fast-float-eisel-lemire

Conversation

@fcostaoliveira

@fcostaoliveira fcostaoliveira commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator

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:

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.

Testing

./src/redis-server test fastfloat — 140 assertions, all pass.

Layer What it verifies
Existing decimal_ok cases Clinger path + previously widened cases all still pass
New decimal_ok cases Subnormal boundaries (5e-324, smallest/largest subnormals), round-half-to-even ties, near-DBL_MAX overflow, repunit mantissas, wide-exponent cases reachable via Eisel-Lemire
Reproducer cases The exact two inputs @vitahlin reported (9007199255094284e-19 and 2489830482329185244e1) — both now match strtod() exactly
Differential block Bit-exact memcmp between fast_float_strtod() and strtod() on hard cases (2^53 boundaries, DBL_MAX, smallest-subnormal, Mushtak-Lemire stress range, scientific constants)

Performance — preliminary, no regression detected

ZSET-heavy benchmarks on redis/redis:unstable baseline vs this branch, oss-standalone topology, AWS bare-metal runners (Intel SPR m7i.metal-24xl and ARM Graviton4 m8g.metal-24xl), n=3 dps per cell on the PR side:

Test Intel m7i (PR n=3, σ) ARM m8g (PR n=3, σ) Δ vs unstable Intel Δ vs unstable ARM
1Mkeys-load-zset-listpack-with-100-elements-double-score (#15061's headline) 9629 (σ=0.1%) 9343 (σ=0.2%) −2.45 % +7.59 %
1key-zset-1K-elements-zrange-all-elements 9021 (σ=4.2%) 10628 (σ=0.2%) −0.10 % +0.35 %
1key-zset-600K-elements-zrangestore-1K-elements 5906 (σ=0.9%) 5233 (σ=0.5%) flat −0.15 %
1key-zset-1M-elements-zremrangebyscore-pipeline-10 (running) (running)

No regression beyond fleet baseline noise (~1.5 %) on any test on either arch. PR #15061's +73-84 % win on load-zset-listpack-with-100-elements-double-score is preserved (Intel flat at −2.45 %, well within fleet drift; ARM improves +7.59 %, likely from Eisel-Lemire's wider exponent coverage hitting the fast path on more input shapes than the previous branch). Final cell + same-day baseline pinning will be added in a follow-up comment once the runs complete.

Commits

The branch is structured as five atomic commits to ease review:

  1. 5e16ef626 — Add the 128-bit power-of-five table (data only, no behaviour change)
  2. 56b981261 — Port compute_float + helpers (dead code at this point, verifies build)
  3. 31fb41cbd — Wire Eisel-Lemire into parse_number_string, remove the buggy widened branch
  4. adabcc0ec — Extend REDIS_TEST block with reproducers + boundary cases + differential vs strtod()
  5. 53c0aaaa8 — Pack the table at 8 entries per line (whitespace-only)

Each commit builds cleanly and the existing test suite passes after each one.

Credits

Test plan

  • make test passes
  • make REDIS_CFLAGS=-DREDIS_TEST -j && ./src/redis-server test fastfloat — 140/140 pass
  • @vitahlin's two reproducer inputs now bit-exact with strtod()
  • Cross-arch performance benchmarks on x86 + ARM, n=3 dps — no regression on the ZSET tests against current unstable baseline (full table in PR body; same-day baseline pinning to follow)
  • Reviewer-driven: run any additional inputs you'd like added to the differential block

Note

Medium Risk
Changes core decimal-to-double conversion logic and adds a large precomputed table; mistakes could cause subtle numeric rounding differences or regress parsing behavior/perf across platforms.

Overview
Fixes fast_float_strtod() correctness for cases where mantissas exceed 2^53 by replacing the prior 128-bit “widened” conversion shortcut with a port of the Eisel–Lemire decimal-to-binary algorithm, including IEEE-754 packing (am_to_double) and required helpers.

Adds the upstream 5^q 128-bit lookup table (covering q in [-342, 308]) and updates parse_number_string() to choose between the existing Clinger fast path and the new Eisel–Lemire path, falling back to libc strtod() when out of range.

Expands the REDIS_TEST suite with reproducers for the reported ±1 ULP issues plus additional boundary and differential tests to ensure bit-for-bit parity with strtod() across subnormals, ties-to-even, near-overflow, and wide exponents.

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

Imports the precomputed extended-precision powers of five from upstream
fast_float (fast_table.h, MIT-licensed). Covers the full range needed
to handle any 64-bit decimal mantissa producing a finite double:
5^-342 ... 5^308, 651 entries, 1302 uint64_t values total.

This is data-only; the next commit wires it into compute_float() to
replace the current __SIZEOF_INT128__ widened branch which has known
1-ULP rounding mismatches vs strtod() on 17-19 digit mantissas (e.g.
9007199255094284e-19 and 2489830482329185244e1).

Source: https://github.com/fastfloat/fast_float
        include/fast_float/fast_table.h
Adds the algorithmic body of the Eisel-Lemire decimal-to-binary
conversion as a double-only specialisation. Components:

  - value128 / adjusted_mantissa structs (port of upstream's typedefs)
  - leading_zeroes_u64 (__builtin_clzll wrapper)
  - full_multiplication (64x64 -> 128, __uint128_t fast path with a
    32-bit-target fallback so this remains correct on platforms
    without 128-bit ints)
  - eisel_lemire_power, compute_product_approximation_d
  - compute_float_d — the main algorithm, returns (mantissa, power2)
  - am_to_double — packs the result into IEEE-754 binary64 layout

Mathematical guarantees: for inputs with ≤19 mantissa digits and the
exponent ranges produced by parse_number_string, the result is provably
bit-exact with strtod (Mushtak & Lemire, 'Fast Number Parsing Without
Fallback'). compute_float_d's negative-power2 branch is therefore
unreachable from our parser, but is preserved in the API so the next
commit can wire a defensive strtod fallback for any future caller.

Still dead code at this stage; the next commit replaces the buggy
__SIZEOF_INT128__ widened branch in parse_number_string with a call
into compute_float_d + am_to_double.

Source: https://github.com/fastfloat/fast_float
        include/fast_float/{decimal_to_binary,float_common}.h
The previous __SIZEOF_INT128__ widened branch used a homebrew
'(double)hi * 2^64 + (double)lo' conversion that double-rounded the
128-bit product, producing ±1 ULP mismatches vs strtod() on inputs
where the high half exceeded 2^53 or where the integer division
truncated the remainder. Two reported reproducers:

  9007199255094284e-19  ->  -1 ULP
  2489830482329185244e1 ->  +1 ULP

Replaces that branch with a call into the Eisel-Lemire core
(compute_float_d + am_to_double) added in the previous commit.
For inputs the Clinger fast path can handle (mantissa <= 2^53,
|exp| <= 22) we keep Clinger — it's a single double multiply
and stays the cheapest path. Everything else now goes through
Eisel-Lemire, which is correctly-rounded by construction
(Mushtak & Lemire, 'Fast Number Parsing Without Fallback') and
covers the full double exponent range, not just |exp| <= 19.

Existing fastfloat unit tests pass (98/98). Both reproducer
inputs above now match strtod() bit-exactly.
…cases

Adds 22 new ff_testcase entries (subnormal boundaries, round-half-to-
even tie cases, near-DBL_MAX overflow, wide-exponent inputs now reachable
via Eisel-Lemire) plus an explicit differential block that bit-compares
fast_float_strtod() against libc strtod() on 22 hard inputs covering:

  - 2^53 boundary mantissas (9007199254740992..996)
  - DBL_MAX / smallest-normal / smallest-subnormal limits
  - The two reproducer inputs the previous widened branch missed
    (9007199255094284e-19 and 2489830482329185244e1)
  - 19-digit Mushtak-Lemire stress mantissas
  - Common scientific constants

Test count: 98 -> 140. All pass.

Reported-by: vitahlin
Suggested-by: vitahlin (rounding-boundary cases on PR redis#14661)
@fcostaoliveira fcostaoliveira requested a review from sundb April 25, 2026 22:06
Whitespace-only change. The 651-entry table previously occupied 651
lines (one entry per line, matching upstream fast_float's per-entry
formatting). For a flat C uint64_t array this is wasteful; pack 8
entries (16 hex literals, ~280 chars) per line instead. Cuts the
table body from 651 to 82 lines.

No behaviour change. All 140 fastfloat tests still pass.
@augmentcode

augmentcode Bot commented Apr 25, 2026

Copy link
Copy Markdown
🤖 Augment PR Summary

Summary: This PR fixes a ±1 ULP mismatch between Redis’ fast_float_strtod() fast path and libc strtod() for certain 17–19 digit mantissas.

Changes:

  • Replaces the previously widened (mantissa > 2^53) shortcut conversion with a port of the upstream Eisel–Lemire algorithm from fast_float.
  • Adds a large precomputed 128-bit power-of-five table (5^-342 … 5^308) used by the algorithm to avoid iterative rounding.
  • Introduces double-specialized helpers (full_multiplication, compute_product_approximation, compute_float_d, am_to_double) to compute correctly rounded binary64 results.
  • Keeps the existing Clinger fast path unchanged for small mantissas and |exp| ≤ 22 (single multiply/divide).
  • Updates parse_number_string() to select Clinger vs Eisel–Lemire vs fallback-to-libc based on mantissa/exponent ranges.

Tests: Expands the REDIS_TEST suite with the reported reproducers, subnormal/tie-boundary cases, and a differential bitwise comparison loop against libc strtod().

🤖 Was this summary useful? React with 👍 or 👎

@augmentcode augmentcode 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.

Review completed. 2 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

Comment thread src/fast_float_strtod.c
value128 firstproduct = full_multiplication(w, power_of_five_128[index]);
/* For double, bit_precision = mantissa_explicit_bits (52) + 3 = 55. */
const uint64_t precision_mask =
(uint64_t)0xFFFFFFFFFFFFFFFFULL >> 55;

@augmentcode augmentcode Bot Apr 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

src/fast_float_strtod.c:926precision_mask looks computed with the wrong shift: UINT64_MAX >> 55 leaves only 9 one-bits, but the comment indicates bit_precision is 55 so this mask likely needs 55 one-bits. If this mask is wrong, the “rounding-boundary fixup” path in compute_product_approximation_d() may rarely trigger (or trigger incorrectly), reintroducing hard-to-reproduce rounding mismatches vs strtod().

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

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.

When these 9 bits are all 1 (i.e., firstproduct.high & 0x1FF == 0x1FF), it indicates that the value is very close to the round-half-to-even boundary, so the extension table (power_of_five_128[index+1]) needs to be triggered for a second-pass correction.
so this is correct.

Comment thread src/fast_float_strtod.c
};
run_ff_tests(decimal_ok, COUNTOF(decimal_ok), 0);

/* Differential cross-check: every accepted input must produce the

@augmentcode augmentcode Bot Apr 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

src/fast_float_strtod.c:1410 — The differential block compares fast_float_strtod() vs libc strtod(), but it would still pass if these inputs silently fall back to libc (e.g., if fast_float_try_fast() rejects them). Since this PR is specifically about correctness/perf of the widened fast path, it may be worth asserting that the key reproducers (and a couple of representative hard cases) are actually accepted by fast_float_try_fast() so a future regression doesn’t get masked.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Whitespace-only change. The previous 8-entries-per-line layout produced
~280-character lines, which hurt diff/code-review readability. 4 entries
(8 hex literals) per line keeps lines around 140 characters.

No behaviour change. All 140 fastfloat tests still pass.
Comment thread src/fast_float_strtod.c Outdated
Comment thread src/fast_float_strtod.c Outdated
Comment thread src/fast_float_strtod.c Outdated
@sundb

sundb commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

I just realized that libc also use Eisel-Lemire algorithm. In fact, the main expense at present lies in the need to copy the memory. But I'm thinking that if we know for sure that the input is terminated with '\0', then we can directly fall back to using strtod().

[EDIT] ignore the above comment, tested, and it's not good enough.

@sundb sundb added this to Redis 8.8 May 9, 2026
@github-project-automation github-project-automation Bot moved this to Todo in Redis 8.8 May 9, 2026
Reformatting only, no behavior change:
- compute_float_d: collapse power2 normal-path computation onto a
  single line.
- compute_float_d: collapse the subnormal→normal post-rounding power2
  ternary onto a single line.
- parse_number_string: keep the EISEL_LEMIRE bounds check condition on
  one line and place `return 0;` on its own indented line.

vitahlin's two reproducers still match libc strtod() bit-exactly
(0 ULP delta), and unit/scripting passes.
@fcostaoliveira

Copy link
Copy Markdown
Collaborator Author

Hi @sundb — addressed your review in aeec6af:

  • Collapsed the normal-path answer.power2 computation onto a single line (thread).
  • Collapsed the subnormal→normal post-rounding power2 ternary onto a single line (thread).
  • Kept the EISEL_LEMIRE bounds condition on one line and put return 0; on its own indented line (thread).

Reformatting only — no behavior change. @vitahlin's two reproducers still match libc strtod() bit-exactly (0 ULP delta) and unit/scripting passes. Ready for another look when you have a moment.

@fcostaoliveira fcostaoliveira requested a review from sundb May 13, 2026 10:09
@sundb sundb merged commit 3ab7fe0 into redis:unstable May 13, 2026
18 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in Redis 8.8 May 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants