perf(dtoa,quickjs): speed up string<->number conversions - #1664
Conversation
Add a 128-bit integer fast path to js_atod() for base-10 strings with up to 19 significant digits and a decimal exponent in [-19, 19]. It rounds exactly once at the end (round-half-to-even) and is therefore bit-identical to the bignum parser; anything else falls back. Speeds up the common runtime coercion cases (Number(str), +str, str | 0) by ~30-40% in tests/microbench.js. The path needs __int128 (gcc/clang) and is disabled on MSVC, which keeps the exact slow parser. Add a bit-exact regression test generated from the bignum-only build and verified by a 546k-case differential fuzz.
Replace the bignum successive-tries search in js_dtoa() with the Ryu algorithm for the common free-format case (radix 10, automatic exponent: Number.prototype.toString, String(), d + ""). The digits and exponent feed the same ECMAScript formatting code, so output is byte-identical to the bignum path. Uses __int128 when available and a portable 32-bit-split umul128 on MSVC, so the fast path runs everywhere. Speeds up float_to_string / float_toString in tests/microbench.js by about 2.1x (159ns -> 74ns). Lookup tables come from ulfjack/ryu (Apache-2.0/BSL-1.0) in dtoa-ryu-table.h and are registered in amalgam.js. Also fixes the pre-existing bignum printer emitting a non-shortest 17-digit form for some denormals (e.g. 0x0031f57e09648c83 printed 9.990000000000001e-307 instead of 9.99e-307); verified against V8 over 2.1M bit patterns and by the new dtoa-shortest.js test.
Add a fast path in JS_ToNumberHintFree for flat 8-bit strings of the form [ ]*[+-]?[0-9]+[ ]* whose value is below 2^53, where integer parsing is exact and equals the general parser. This is the hot case for +str, "123" | 0 and numeric arithmetic coercions. The hook goes in ToNumber rather than ToInt32 because the bitwise ops route operands through JS_ToNumericFree first, so a ToInt32 hook never fires there. Only ASCII spaces are trimmed; every other input (wider whitespace, hex, exponent, fractions, values >= 2^53) falls back to the exact parser, keeping StringToNumber semantics bit-identical (verified by a 546k-input differential run against the previous build). string_to_int in tests/microbench.js: 72.6ns -> 34.5ns (-52%), string_to_float regresses ~8% from the extra failed scan, net positive.
Extend the dtoa fast parser with js_atod_fast10_parse/round so JS_ToNumberHintFree parses plain decimal strings exactly once and skips the general js_atof scanner, instead of the failed integer-only pre-scan plus a second full parse. Pure integers stay on the fast path without a double round-trip, and fraction/exponent inputs now avoid the duplicate digit scan (string_to_float 84ns -> 36ns). Also fixes js_atod accepting strings without any digit (bare ".", "+." now NaN) which the fast path used to misparse as 0 when called directly; verified bit-identical on a 546k-input differential run against the previous build.
round_m_div_d_to_double() called clz64(0) when m * 2^(64-b) < d, i.e.
the 64-bit numerator was smaller than the divisor (mantissa in
[2^63, 10^19) divided by 10^19, e.g. Number("-.46877437956235577410")).
clz(0) is undefined behavior; the result happened to be correct in
non-sanitized builds but aborts the UBSan CI config.
Fall back to a full 128-bit numerator in the zero-quotient case, where
the quotient always has at least 63 bits and the normal rounding path
applies. The common case keeps the 64-bit division, so string_to_float
in microbench.js is unchanged (~36ns). Verified bit-identical on a
546k-input differential run and clean under ASan+UBSan.
js_atod_fast10_parse/round are only compiled when __SIZEOF_INT128__ is defined and JS_ATOD_NO_FAST_PATH is not, but dtoa.h declared them unconditionally. Guard the declarations identically so a future call site can never link against a missing definition. Verified the regular, -DJS_ATOD_NO_FAST_PATH and -U__SIZEOF_INT128__ builds all link and pass tests/atod-fast-path.js.
The 128-bit fast path uses unsigned 128-bit division and a u128->double conversion. Clang targeting MSVC on Windows lowers both to compiler-rt libcalls (__udivti3, __floatuntidf) that lld-link does not provide, breaking windows-clang, windows-clang-cl and the meson clang-cl jobs with undefined symbols. Unix toolchains link compiler-rt automatically, and MinGW/Cygwin link their runtime, which is why only MSVC-target clang failed. Gate the fast path on !(_WIN32 && __clang__ && !__MINGW32__) so those toolchains fall back to the exact bignum parser (the same behavior MSVC already had) while MinGW/Cygwin keep the speedup. The Ryu number->string path is unaffected everywhere because it only needs 128-bit multiply and shift. Also convert through uint64_t in the small-n branch to avoid the u128->double libcall on other toolchains.
415a631 to
ffdc551
Compare
|
Fixed the Windows clang/clang-cl failures. The new The fast path is now guarded off for The new CI runs are queued as |
saghul
left a comment
There was a problem hiding this comment.
Left some comments, PTAL.
I cannot judge the Ryu implementation, so I'll take it at face value :-)
General question: is there a reason not to enable this always? Did you add those defines to disable the feature a way to test it but nobody would likely ever want to disable it?
| @@ -0,0 +1,364 @@ | |||
| /* Ryu double->shortest-decimal lookup tables. | |||
|
|
|||
| * Generated by PrintDoubleLookupTable in the ryu project | |||
There was a problem hiding this comment.
Can you add a script to this repo that would regenerate the table? We have such a script for the unicode table for example.
| if (m == 0) { | ||
| /* keep -0 per StringToNumber */ | ||
| return neg ? js_float64(-0.0) : js_int32(0); | ||
| } else if (e10 == 0 && |
There was a problem hiding this comment.
This is kinda weird to read, can you please do 2 branches here?
| JSATODTempMem *tmp_mem); | ||
| /* fast parser for plain base-10 strings; guarded exactly like the | ||
| definitions in dtoa.c so callers don't link against a stub */ | ||
| #if !defined(JS_ATOD_NO_FAST_PATH) && defined(__SIZEOF_INT128__) && \ |
There was a problem hiding this comment.
Since we use this define on both places, you can compute it here and define some JS_ATOD_USE_FAST_PATH when supported.
| * (__udivti3, __floatuntidf) that lld does not link. MinGW and Cygwin | ||
| * link the runtime library and keep the fast path. Also disabled with | ||
| * JS_ATOD_NO_FAST_PATH for differential testing. */ | ||
| #if !defined(JS_ATOD_NO_FAST_PATH) && defined(__SIZEOF_INT128__) && \ |
There was a problem hiding this comment.
same comment as before, let's have a specific define for this.
| } | ||
| #endif | ||
|
|
||
| #if !defined(JS_DTOA_NO_RYU) |
There was a problem hiding this comment.
Why use this define and not JS_ATOD_NO_FAST_PATH ?
Summary
Speeds up the string<->number conversion paths, the two hottest remaining
microbenchmark gaps against other engines, with exactly-rounded fast paths and
the bignum implementations kept as the exact fallback.
Targets:
Number(str),+str,"123" | 0, arithmetic coercionsString(d),d.toString(),d + ""(free format)Changes
js_atod128-bit fast path (f7ec6ef): plain base-10 strings with up to19 significant digits and a decimal exponent in [-19, 19] are parsed with
128-bit integer arithmetic and rounded exactly once (round-half-to-even),
bit-identical to the bignum parser. Anything else falls back.
4f4c51b): the free-formatjs_dtoapath now uses the Ryū algorithm instead of the bignumsuccessive-tries search. A portable 32-bit-split
umul128keeps the fastpath working on MSVC (no
__int128needed). Lookup tables come fromulfjack/ryu (Apache-2.0 or BSL-1.0, attribution included).
32bcb2a,7a46c13):JS_ToNumberHintFreecalls the exportedjs_atod_fast10_parse/rounddirectly, skipping the general
js_atofscanner and its duplicated digitscan; plain integers stay on the fast path without a double round-trip.
1192c85,10bb178): fixclz64(0)UB in the divisionrounding helper when the 64-bit quotient is zero, and guard the fast-parser
declarations exactly like their definitions.
Also fixes a pre-existing bug: the bignum printer emitted a non-shortest
17-digit form for some denormals, e.g. the double
0x0031f57e09648c83printed9.990000000000001e-307instead of the shortest9.99e-308. Ryū produces thespec-compliant shortest form.
Benchmark
tests/microbench.js, Apple Silicon, clang-O2, ns/iteration (lower isbetter):
toFixed/toPrecision/toExponentialand radix != 10 keep the bignum pathand are unchanged.
Testing
build (random, structured boundaries and exhaustive small integers).
oracle (0 mismatches); the 216 divergences vs the old bignum printer are all
the non-shortest-denormal bug above, where Ryū is shorter and round-trips.
tests.conf(the UB in Don't build with Atomics support by default #4 wasfound this way).
tests.conf: 0/92 errors;test262-fast: 52 errors, identical to thecommitted baseline (
test262_errors.txt), no new failures.-DJS_ATOD_NO_FAST_PATH,-U__SIZEOF_INT128__(MSVC-style portable path), and the amalgamated build.tests/atod-fast-path.js(bit-exact rounding vectors)and
tests/dtoa-shortest.js(shortest round-trip vectors incl. thedenormals fixed above).
Scope notes
js_atodfast path needs 128-bit integers and is compiled out on MSVCand on clang targeting MSVC on Windows (compiler-rt libcalls for 128-bit
division are not linked by lld), keeping the previous exact behavior there;
MinGW and Cygwin keep the fast path. The Ryū number->string path runs on
every toolchain: it only needs 128-bit multiply and shift.
dtoa.hgains two internal helpers.