perf(dtoa,quickjs): speed up string<->number conversions - #1664
Open
zzjjob wants to merge 6 commits into
Open
Conversation
added 6 commits
August 13, 2026 21:38
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.
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.
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 MSVC,which keeps the previous exact behavior (no speedup there, no regression).
dtoa.hgains two internal helpers.