Skip to content

prelude: vendor Ryu, make String.toFloat correctly rounded (fix WASM round-trip + TOML float parsing)#1287

Open
mbouaziz wants to merge 10 commits into
SkipLabs:mainfrom
mbouaziz:ryu-string-to-float
Open

prelude: vendor Ryu, make String.toFloat correctly rounded (fix WASM round-trip + TOML float parsing)#1287
mbouaziz wants to merge 10 commits into
SkipLabs:mainfrom
mbouaziz:ryu-string-to-float

Conversation

@mbouaziz

@mbouaziz mbouaziz commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What

Two pre-existing float-parsing bugs share one root cause — reconstructing a
double from its decimal digits with repeated * 0.1, which is not correctly
rounded:

  1. WASM String.toFloat (SKIP_String__toFloat_raw's in-tree atof on
    SKIP32): "0.3".toFloat() yielded 0.30000000000000004, and round-trip
    (toString().toFloat() == value) failed for ~85% of sampled doubles.
  2. TOML parseFloat: "3.14" parsed to 3.1400000000000006 instead of the
    nearest double.

Both were masked while Float.toString produced only 9 significant digits; they
surface once toString becomes exact (a follow-up).

How

  • Vendor the Ryu library (ryu/d2s.c double→string and ryu/s2d.c
    string→double) from ulfjack/ryu @ 4c0618b0, with #ifdef SKIP32 guards so it
    builds under -nostdlibinc. (d2s is used by the follow-up Float.toString
    change; s2d is used here.)
  • SKIP32 String.toFloat uses Ryu s2d (correctly rounded) for the inputs it
    supports (≤17 significant digits, standard formats), falling back to the
    existing atof for the rest. SKIP64 keeps libc strtod, already correct.
  • TOML parseFloat keeps its validation but delegates the value to
    String.toFloat instead of reconstructing it by hand.

Verification

  • Runtime builds for both SKIP64 and SKIP32; the SKIP32 wasm library links.
  • s2d round-trips all shortest-representation toString outputs — 0 failures
    over 7053 sampled doubles (including subnormals and DBL_MAX), where the old
    WASM atof failed 5982.
  • All 331 toml tests pass.

🤖 Generated with Claude Code

mbouaziz and others added 9 commits July 7, 2026 09:12
Vendored unmodified from https://github.com/ulfjack/ryu at commit
4c0618b0e44f7ef027ebae05d2cc7812048f7c8f (2026-02-09).

Files copied from ryu/ directory:
  ryu.h, d2s.c, common.h, d2s_full_table.h, d2s_intrinsics.h, digit_table.h

License files copied from repository root:
  LICENSE-Apache2, LICENSE-Boost

Ryu converts IEEE 754 doubles to their shortest decimal string
representation. It is self-contained C, dual-licensed under
Apache License 2.0 and Boost Software License 1.0.

No modifications applied — files are byte-identical to upstream.

## How to reproduce

  COMMIT=4c0618b0e44f7ef027ebae05d2cc7812048f7c8f
  DST=skiplang/prelude/runtime/ryu
  mkdir -p "$DST"
  for f in ryu.h d2s.c common.h d2s_full_table.h d2s_intrinsics.h digit_table.h; do
    curl -sL "https://raw.githubusercontent.com/ulfjack/ryu/$COMMIT/ryu/$f" -o "$DST/$f"
  done
  for f in LICENSE-Apache2 LICENSE-Boost; do
    curl -sL "https://raw.githubusercontent.com/ulfjack/ryu/$COMMIT/$f" -o "$DST/$f"
  done

## How to verify

Compare vendored files against upstream checksums (sha256):

  common.h         0bbd71d26da6193e678d0776cf418f43f287c73d6fd6725353df0aadf70f2a19
  d2s.c            d24323c7eb77d63f1e52c415212b50060b776f0883525d907d04954fcd48cf64
  d2s_full_table.h 2618f6e5fae6c4443899b184efe3d08295dd267dc9f1a994c983c7caca59ebe6
  d2s_intrinsics.h 1d05702f2edacce428223d4b43dd3095c1bd1f3ad30128ce1761d84356dddc7d
  digit_table.h    8b782573abc0b8554d74163ae6c02f0beb5c30d1e63eaebdb0ddf2c98d817e01
  ryu.h            b7feab0ba1df5e9ef3d602f386592ad152491405e49b408fb21c0e0e9e6bfb16
  LICENSE-Apache2  c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4
  LICENSE-Boost    c9bff75738922193e67fa726fa225535870d2aa1059f91452c411736284ad566

Run: sha256sum skiplang/prelude/runtime/ryu/*

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add Ryu-specific clang-format settings to minimize diff against
upstream. Based on LLVM style with no column limit, cast spacing,
and short-if-on-single-line to match Ryu's conventions.

Add clang-format off/on guards around lookup tables to preserve
upstream's aligned two-entries-per-line layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Guard libc headers behind #ifdef SKIP32 since WASM is compiled with
-nostdlibinc (no libc available):

- d2s.c: replace <assert.h>, <stdlib.h>, <string.h> with SKIP32 stubs
  (memcpy declaration + assert no-op); guard d2s() which uses malloc
- common.h: replace <assert.h>, <string.h> with SKIP32 stubs
- d2s_intrinsics.h: guard <assert.h> with #ifndef SKIP32
- ryu.h: guard <inttypes.h> with #ifndef SKIP32

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add ryu/d2s.c to both the Makefile and build.sk so it's compiled for
SKIP64 and SKIP32 targets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Vendored from https://github.com/ulfjack/ryu at commit
4c0618b0e44f7ef027ebae05d2cc7812048f7c8f (the same commit as the d2s
files), then reformatted with the vendored ryu/.clang-format.

Files copied from the ryu/ directory:
  s2d.c, ryu_parse.h

Ryu's s2d converts a decimal string to the nearest IEEE 754 double
(correctly rounded). Upstream documents it as experimental: it supports
up to 17 significant digits and not all input formats, returning a
non-SUCCESS Status otherwise (callers fall back for those cases). s2d
reuses the shared d2s tables/intrinsics already vendored; no new tables.

Upstream sha256 (pre-format):
  s2d.c        9485e78f329ec1cf931b737333ef7bd85195864dfb9dd096413f0d336a7a46d3
  ryu_parse.h  da8ad9764b2701627cb75926b9d14b540fdfd7be693d671da9594eb16dabd27a

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same treatment as d2s.c: guard libc headers behind #ifdef SKIP32 since
WASM is compiled with -nostdlibinc (no libc):

- replace <assert.h>, <stdlib.h>, <string.h> with SKIP32 stubs
  (memcpy declaration + assert no-op)
- guard the s2d() convenience wrapper, which uses strlen; callers on
  SKIP32 use s2d_n() with an explicit length instead

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Compile ryu/s2d.c for both SKIP64 and SKIP32 (added to runtime/Makefile
CFILES and prelude/build.sk srcs, alongside ryu/d2s.c).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On SKIP32 (WASM) there is no libc strtod, so SKIP_String__toFloat_raw
fell back to the in-tree atof, which reconstructs the value with repeated
`* 0.1` and is not correctly rounded. This breaks round-trip: e.g.
"0.3".toFloat() yielded 0.30000000000000004, and toString().toFloat()
did not recover the original for most values.

Use Ryu's s2d (correctly rounded) on SKIP32 for the inputs it supports
(<=17 significant digits, standard formats), falling back to the existing
atof for the rest (>17 digits, leading '+', etc.). SKIP64 keeps using
libc atof, which is already correctly rounded.

Verified: s2d round-trips all shortest-representation toString outputs
(0 failures over 7053 sampled doubles, incl. subnormals and DBL_MAX),
where the old atof failed ~85%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TOML's parseFloat reconstructed the value by hand with repeated `* 0.1`,
which is not correctly rounded: e.g. "3.14" parsed to 3.1400000000000006
rather than the nearest double. The old 9-digit Float.toString masked
this, but it breaks round-tripping and shows up once toString becomes
exact (shortest-representation).

Keep the existing validation walk (so malformed input is still rejected
and the TOML "invalid" cases still fail), but delegate the actual value
to String.toFloat (Ryu s2d on WASM, libc strtod on native), after
stripping digit-group underscores and a leading '+'.

All 331 toml tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wasm32 defines __SIZEOF_INT128__, so Ryu's `HAS_UINT128` path used
__uint128_t. Those 128-bit ops lower to compiler-rt calls (__multi3,
__lshrti3) that are not available under -nostdlibinc, so the resulting
wasm module failed to instantiate:

  LinkError: WebAssembly.instantiate(): Import "env" "__multi3":
  function import requires a callable

Define Ryu's `RYU_ONLY_64_BIT_OPS` under `#ifdef SKIP32` in
d2s_intrinsics.h to force the portable 64-bit path (used by both d2s and
s2d). Verified: no 128-bit libcalls remain in either wasm object, and
s2d still round-trips all 7053 sampled toString outputs. SKIP64 is
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant