Skip to content

perf(dtoa,quickjs): speed up string<->number conversions - #1664

Open
zzjjob wants to merge 6 commits into
quickjs-ng:masterfrom
zzjjob:perf/string-number-conversions
Open

perf(dtoa,quickjs): speed up string<->number conversions#1664
zzjjob wants to merge 6 commits into
quickjs-ng:masterfrom
zzjjob:perf/string-number-conversions

Conversation

@zzjjob

@zzjjob zzjjob commented Aug 14, 2026

Copy link
Copy Markdown

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:

  • string -> number: Number(str), +str, "123" | 0, arithmetic coercions
  • number -> string: String(d), d.toString(), d + "" (free format)

Changes

  1. js_atod 128-bit fast path (f7ec6ef): plain base-10 strings with up to
    19 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.
  2. Ryū shortest-representation printing (4f4c51b): the free-format
    js_dtoa path now uses the Ryū algorithm instead of the bignum
    successive-tries search. A portable 32-bit-split umul128 keeps the fast
    path working on MSVC (no __int128 needed). Lookup tables come from
    ulfjack/ryu (Apache-2.0 or BSL-1.0, attribution included).
  3. Single-scan runtime coercion (32bcb2a, 7a46c13):
    JS_ToNumberHintFree calls the exported js_atod_fast10_parse/round
    directly, skipping the general js_atof scanner and its duplicated digit
    scan; plain integers stay on the fast path without a double round-trip.
  4. Review fixes (1192c85, 10bb178): fix clz64(0) UB in the division
    rounding 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 0x0031f57e09648c83 printed
9.990000000000001e-307 instead of the shortest 9.99e-308. Ryū produces the
spec-compliant shortest form.

Benchmark

tests/microbench.js, Apple Silicon, clang -O2, ns/iteration (lower is
better):

benchmark before after delta
string_to_int 72.6 36.2 -50%
string_to_float 84.0 35.9 -57%
float_to_string 159.0 73.7 -54%
float_toString 163.5 79.7 -51%
full suite total 4852.7 4612.3 -5%

toFixed/toPrecision/toExponential and radix != 10 keep the bignum path
and are unchanged.

Testing

  • Differential fuzz, 546,666 string inputs: bit-identical to the pre-change
    build (random, structured boundaries and exhaustive small integers).
  • Differential fuzz, 2,162,888 double bit patterns: byte-identical to V8 as an
    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.
  • ASan + UBSan clean on both fuzz corpora and tests.conf (the UB in Don't build with Atomics support by default #4 was
    found this way).
  • tests.conf: 0/92 errors; test262-fast: 52 errors, identical to the
    committed baseline (test262_errors.txt), no new failures.
  • Build matrix checked: default, -DJS_ATOD_NO_FAST_PATH,
    -U__SIZEOF_INT128__ (MSVC-style portable path), and the amalgamated build.
  • New regression tests: tests/atod-fast-path.js (bit-exact rounding vectors)
    and tests/dtoa-shortest.js (shortest round-trip vectors incl. the
    denormals fixed above).

Scope notes

  • The js_atod fast path needs 128-bit integers and is compiled out on MSVC,
    which keeps the previous exact behavior (no speedup there, no regression).
  • The Ryū path runs on all compilers including MSVC via the portable multiply.
  • No API changes; dtoa.h gains two internal helpers.

zhangzhijian01 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.
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