Files
scriptc/packages/runtime/test/gen-fixed-cases.mjs
Malte Ubl e7eef59ec1 Fast paths for number<->string conversion (Number/parseFloat, toFixed, two-decimal String)
String to number: Number(str), unary +, and parseFloat now convert short
decimal spellings with Clinger's exact fast path (scr_decimal_scan in
scr_number.c): significant digits m <= 2^53 and a decimal exponent within
+-22 (plus the folded range up to 22 + 15) need one correctly rounded
multiply or divide, bit-identical to strtod. ToNumber validates and
accumulates in the same pass; everything else still goes through the copied
span and strtod. Whitespace trimming in Number(), parseInt, and parseFloat
skips the UTF-8 decode for ASCII non-whitespace.

toFixed: the exact-binary-value digit generation moves from scr_lib.c into
scr_number.c (scr_f64_to_fixed). For f <= 22, mantissa * 5^f fits a
64x64->128 multiply, so the spec's n is one shift plus the dropped-half bit
whenever it fits 64 bits. Digits render two at a time instead of through
snprintf. The 16-limb bignum remains the fallback.

Number to string (H44): a non-integral x below 2^46 that is the double
nearest to m/100 prints as m/100 directly instead of running Ryu. Below 2^46
ulp(x) < 0.01, so m/100 is the unique shortest round-tripping decimal. The
correctly rounded division (double)m / 100 == x is the proof. This covers
JSON.stringify, templates, String(), join, and console output.

Tests: Node oracles for every path. number-cases.txt gains money-like values,
the 2^46 cutoff, and one-ulp neighbors. tonumber-cases.txt gains fast-path
boundaries plus 20k seeded short decimals. The new parsefloat-cases.txt pins
scr_parse_float, and the new fixed-cases.txt/test_fixed.c pin toFixed,
including the 2^64 fast-path limit and f up to 100. Fuzz modes cover all four
paths. Existing case lines are unchanged. Corpus 3170 pins the paths
end-to-end against Node.

Linux x64 sandbox, A/B against perf/stack/06-h23-layout-noise,
--layouts=4 --runs=16 (run 3 of 3; geomean -6.0% / -6.7% / -6.3%):

| workload | baseline ms | candidate ms | change | 95% CI | verdict | size | node ms | candidate/node |
| --- | ---: | ---: | ---: | --- | --- | ---: | ---: | ---: |
| json-records | 236.2 | 204.2 | -13.5% | -19.8% .. +2.1% | neutral | +0.0% | 488.62 | 0.42x |
| regex-logs | 176.2 | 168.8 | -4.2% | -10.1% .. +0.3% | neutral | +0.0% | 281.23 | 0.60x |
| ast-interp | 280.0 | 273.1 | -2.5% | -5.6% .. +1.4% | neutral | +2.8% | 273.05 | 1.00x |
| records-sort | 952.4 | 939.4 | -1.4% | -5.0% .. +5.6% | neutral | +0.0% | 289.87 | 3.24x |
| template-render | 78.9 | 77.3 | -2.1% | -6.2% .. +1.2% | neutral | +0.0% | 246.71 | 0.31x |
| functional-pipeline | 338.7 | 348.8 | +3.0% | -6.5% .. +24.0% | neutral | +0.1% | 339.84 | 1.03x |
| async-pipeline | 207.3 | 207.8 | +0.2% | -5.3% .. +3.8% | neutral | +0.0% | 105.57 | 1.97x |
| word-graph | 302.0 | 300.1 | -0.6% | -5.1% .. +12.9% | neutral | +3.0% | 600.48 | 0.50x |
| alloc-trees | 336.3 | 332.4 | -1.1% | -6.2% .. +2.6% | neutral | +0.1% | 144.73 | 2.30x |
| numeric-kernels | 49.7 | 49.8 | +0.1% | -3.4% .. +6.5% | neutral | +4.0% | 143.73 | 0.35x |
| validate-errors | 179.1 | 182.9 | +2.1% | -5.8% .. +9.1% | neutral | +0.0% | 727.78 | 0.25x |
| csv-numbers | 349.0 | 233.8 | -33.0% | -37.5% .. -26.5% | faster | +0.1% | 541.77 | 0.43x |
| log-summary | 171.6 | 135.7 | -20.9% | -23.3% .. -17.7% | faster | +1.8% | 313.2 | 0.43x |
| inventory-report | 411.9 | 384.7 | -6.6% | -18.1% .. +5.5% | neutral | +2.7% | 521.27 | 0.74x |

Size: +64..+136 bytes per executable. The +1.8..+4.0% rows are one 4 KiB
page crossing (two for inventory-report).
2026-10-08 15:49:53 -05:00

160 lines
4.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* oxlint-disable no-loss-of-precision -- Oracle inputs intentionally exercise numeric literal rounding. */
// Generates Number.prototype.toFixed test cases with Node as the oracle.
// Each line: <16 hex digits of the double's bit pattern>\t<fractionDigits>\t<x.toFixed(f)>
// Only finite |x| < 1e21 (the scr_f64_to_fixed domain; larger values and
// non-finite receivers fall back to ToString before digit generation).
//
// node gen-fixed-cases.mjs cases # curated + seeded cases → stdout
// node gen-fixed-cases.mjs fuzz <count> # <count> random cases → stdout
import { argv, stdout } from "node:process";
const f64 = new Float64Array(1);
const u64 = new BigUint64Array(f64.buffer);
function line(x, f) {
f64[0] = x;
return `${u64[0].toString(16).padStart(16, "0")}\t${f}\t${x.toFixed(f)}\n`;
}
function* curated() {
const values = [
0,
-0,
1,
-1,
0.5,
-0.5,
1.5,
2.5,
-2.5,
0.05,
0.005,
0.0005,
1.005,
1.015,
1.025,
1.045,
2.675,
8.345,
0.285,
10.235,
0.1 + 0.2,
123.456,
99.995,
999.995,
0.000001,
1e-7,
5e-324,
-5e-324,
2.2250738585072014e-308,
Number.EPSILON,
2 ** 52,
2 ** 52 + 0.5,
2 ** 53,
2 ** 53 + 2,
2 ** 63,
2 ** 64,
2 ** 64 - 2048,
1.8446744073709552e19,
1e17,
1e18,
1e19,
1e20,
999999999999999900000,
123456789.987654321,
Math.PI,
Math.E,
-Math.PI,
1 / 3,
2 / 3,
];
const digits = [0, 1, 2, 3, 5, 10, 15, 17, 20, 21, 22, 23, 25, 30, 50, 99, 100];
for (const x of values) for (const f of digits) yield [x, f];
// Where the 64-bit fast path stops: n = |x| × 10^f just below / above 2^64.
for (let f = 0; f <= 23; f++) {
for (const scale of [0.5, 0.9, 1, 1.1, 2]) {
const x = (2 ** 64 / 10 ** f) * scale;
if (x < 1e21) {
yield [x, f];
yield [-x, f];
}
}
}
// Money-like values and exact halves at small digit counts.
for (let m = 0; m <= 2000; m++) {
yield [m / 100, 2];
yield [m / 1000, 2];
if (m % 7 === 0) yield [-m / 100, 1];
if (m % 5 === 0) yield [m / 8, 2];
}
}
// xorshift128 — deterministic so the committed file is reproducible
function makeRng(seed) {
let s0 = seed ^ 0x9e3779b97f4a7c15n,
s1 = 0x2545f4914f6cdd1dn;
return () => {
let x = s0;
const y = s1;
s0 = y;
x ^= (x << 23n) & 0xffffffffffffffffn;
s1 = x ^ y ^ (x >> 17n) ^ (y >> 26n);
return (s1 + y) & 0xffffffffffffffffn;
};
}
function* random(count, seed) {
const next = makeRng(seed);
const int = (n) => Number(next() % BigInt(n));
let emitted = 0;
while (emitted < count) {
const shape = int(4);
let x;
let f;
if (shape === 0) {
// Raw random bit patterns (mostly tiny or huge) at any precision.
u64[0] = next();
x = f64[0];
f = int(101);
} else if (shape === 1) {
// Random magnitudes across the fast-path range.
x = (int(2 ** 30) / 2 ** 30) * 10 ** (int(30) - 8);
f = int(26);
} else if (shape === 2) {
// Money-like m / 100 and m / 1000 with typical digit counts.
x = int(10 ** (1 + int(14))) / (int(2) ? 100 : 1000);
f = int(5);
} else {
// Arithmetic results (revenue-style products) with small f.
x = (int(100000) / 100) * int(50) * (1 - int(300) / 1000);
f = int(4);
}
if (int(3) === 0) x = -x;
if (!Number.isFinite(x) || Math.abs(x) >= 1e21) continue;
yield [x, f];
emitted++;
}
}
const mode = argv[2] ?? "cases";
let chunk = "";
function emit([x, f]) {
chunk += line(x, f);
if (chunk.length > 1 << 16) {
stdout.write(chunk);
chunk = "";
}
}
if (mode === "cases") {
for (const c of curated()) emit(c);
for (const c of random(30_000, 0xf1c3dn)) emit(c);
} else if (mode === "fuzz") {
const count = Number(argv[3] ?? 1_000_000);
const seed = BigInt(Date.now());
for (const c of random(count, seed)) emit(c);
} else {
throw new Error(`unknown mode: ${mode}`);
}
stdout.write(chunk);