prelude: Float.toString via Ryu (round-trip-safe, shortest representation)#1288
Open
mbouaziz wants to merge 13 commits into
Open
prelude: Float.toString via Ryu (round-trip-safe, shortest representation)#1288mbouaziz wants to merge 13 commits into
mbouaziz wants to merge 13 commits into
Conversation
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>
Let the runtime handle Float.toString() instead of folding at compile time, avoiding precision differences between host and target compilers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the pure-Skip splitFloat/normalizeFloat implementation (which used 1e9 scaling and could only produce 9 significant digits) with a C helper using Ryu's d2s_buffered_n() for shortest-representation formatting. This guarantees IEEE 754 double round-trip: toString().toFloat() == original for all finite values, while producing clean output (e.g., "0.1" not "0.10000000000000001"). No libc dependency, works in both SKIP64 and SKIP32. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Update expected values to match Ryu's shortest-representation output: - "0.1" instead of "0.10000000000000001" - "5.0e-324" instead of "4.9406564584124654e-324" - "1.0e-05" instead of "1.0000000000000001e-05" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
43c2ae1 to
ed97067
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #1287 (vendors Ryu + correctly-rounded
String.toFloat). Until thatmerges, this PR's diff also shows the #1287 commits.
What
Replace the pure-Skip
Float.toString(which capped at 9 significant digits andcould not round-trip) with a C helper built on Ryu
d2s, producing the shortestdecimal string that round-trips through the IEEE-754 double:
toString().toFloat() == valuefor all finite values (e.g.0.1prints"0.1",not
"0.10000000000000001").SKIP_Float_format17instring.cformats the Ryu shortest representation inSkip's number conventions (fixed vs scientific,
.0for integers,e±NN).FloatToStringpeephole constant-folding, so folding no longerdiverges from the runtime (and doesn't fold the now-native toString).
tests/Float.skexpectations and the one affected golden(
toml/tests/valid/float/long.json) to the shortest representation.CI note
The
prelude-testsjob runs with the image's bootstrap compiler, which stillconstant-folds
Float.toString()to the old 9-digit form, so the newconstant-float assertions fail there until the bootstrap is bumped and the image
rebuilt. The
compilerjob (which builds from source) passes.Verification
🤖 Generated with Claude Code