Arithmetic from the Gates Up
Booth's recoding, Karatsuba's split, and Ryū's shortest digits: the algorithms under `*`, `**`, and `toString`.
JavaScript exposes three arithmetic worlds: 32-bit integers through the bitwise operators and Math.imul, binary64 through Number, and arbitrary precision through BigInt. Each is implemented by a classical algorithm with a known cost curve, and the conversion between numbers and their decimal text is itself a hard problem that was only solved optimally in the last decade. This chapter follows a multiplication from Booth's 1951 recoding to V8's Karatsuba threshold, and a double to its shortest correctly rounded string.
In this chapter
Three arithmetics, one set of operators
The same * token dispatches to three different machines. ApplyStringOrNumericBinaryOperator converts both operands with ToNumeric and then calls either Number::multiply or BigInt::multiply, throwing if the types differ. Inside Number::multiply the specification simply says "IEEE 754-2019 binary64 multiplication with round-to-nearest, ties to even"; the entire algorithm is delegated to the standard. Inside BigInt::multiply it says "the mathematical product", and delegates the algorithm to the engine. The bitwise operators define the third world: ToInt32 on both sides, then a 32-bit operation, then reinterpretation as a signed integer. Math.imul (ES2015) is the multiplication that was missing from that world: a * b | 0 loses low bits once the exact product exceeds 2⁵³, while Math.imul(a, b) computes the low 32 bits of the exact product, which is what C's int32_t multiplication does and what a hardware multiplier produces for free.
const a = 0x7fffffff, b = 0x7fffffff; // 2^31 - 1 squared is ~4.6e18, far above 2^53
console.log("exact product :", BigInt(a) * BigInt(b));
console.log("a * b (double) :", a * b, "-> already rounded");
console.log("(a * b) | 0 :", (a * b) | 0, "(low bits of the *rounded* product: wrong)");
console.log("Math.imul :", Math.imul(a, b), "(low 32 bits of the exact product)");
console.log("BigInt.asIntN(32) :", BigInt.asIntN(32, BigInt(a) * BigInt(b)), "(same value, from the exact product)");
// Small operands agree, because the exact product fits in a double.
console.log("small case agrees :", ((1234 * 5678) | 0) === Math.imul(1234, 5678));This is why asm.js and hashing code (FNV, Murmur, xxHash ports) are written with Math.imul: it is the only 32-bit-exact multiplication in the language.
Booth's recoding: how the hardware multiplies signed integers
A schoolbook binary multiplier adds one shifted copy of the multiplicand per set bit of the multiplier: 32 partial products for a 32×32 multiply. Booth's 1951 observation was that a run of ones 0111…1 equals 1000…0 − 1, so a run of any length can be replaced by one addition and one subtraction at its ends. Recoding the multiplier into digits from {−1, 0, +1} (radix-2 Booth) or {−2, −1, 0, +1, +2} (radix-4 Booth, the modern form) means examining bits in overlapping groups of three and emitting at most one partial product per pair of bits: 16 partial products instead of 32, and, crucially, correct results for two's-complement negative operands without a separate sign-handling path. The recoded partial products are then summed with a Wallace or Dadda tree of carry-save adders and one final carry-propagate adder. This is the circuit Math.imul, Smi multiplication, and the low half of every 64-bit multiply run on; it is why integer multiplication costs three to four cycles of latency on current cores while division costs tens.
// Recode a 32-bit two's-complement multiplier into radix-4 Booth digits and
// multiply using only shifts and additions of the multiplicand, as a hardware
// multiplier does. The result is the low 32 bits, exactly like Math.imul.
function boothDigits(m) {
const digits = [];
let prev = 0; // the implicit bit below bit 0
for (let i = 0; i < 32; i += 2) {
const b0 = (m >>> i) & 1, b1 = (m >>> (i + 1)) & 1;
// digit = -2*b1 + b0 + prev, one of {-2,-1,0,1,2}
digits.push(-2 * b1 + b0 + prev);
prev = b1;
}
return digits; // 16 digits for 32 bits
}
function boothMul(a, m) {
let acc = 0;
boothDigits(m).forEach((d, i) => { acc = (acc + d * (a << (2 * i))) | 0; }); // d*a is a shift and maybe a negate
return acc | 0;
}
const digits = boothDigits(0b0111_1111_0000_0011 | 0);
console.log("multiplier 0111111100000011 recodes to", digits.slice(0, 8).join(","), "... (mostly zeros: runs collapse)");
console.log("nonzero partial products:", digits.filter(Boolean).length, "of 16, versus", (0b0111_1111_0000_0011).toString(2).split("1").length - 1, "set bits");
let ok = true;
for (let i = 0; i < 2000; i++) {
const x = (Math.random() * 2 ** 32) | 0, y = (Math.random() * 2 ** 32) | 0;
if (boothMul(x, y) !== Math.imul(x, y)) { ok = false; console.log("mismatch", x, y); break; }
}
console.log("all 2000 random products agree with Math.imul:", ok);BigInt: the cost curve of arbitrary precision
A BigInt is stored as a sign and an array of 64-bit digits (32-bit on 32-bit platforms). Addition is linear. Multiplication is where algorithm choice matters, and V8's implementation is a textbook ladder: schoolbook O(n²) for small operands; Karatsuba above roughly 34 digits, which splits each operand in half and computes three half-size products instead of four, giving O(n^1.585); Toom-Cook 3 above a few hundred digits, splitting in thirds for five products instead of nine, O(n^1.465); and an FFT-based method (Schönhage–Strassen style) for operands in the tens of thousands of digits, O(n log n log log n). Division uses Burnikel–Ziegler recursion above a threshold and Barrett reduction for large divisors. toString on a large BigInt is itself divide-and-conquer, because naive repeated division by 10 is quadratic. None of this is specified; ECMA-262 says "the mathematical value" and leaves the cost to the engine. What is specified is that BigInt operations are exact and never overflow, which is why 2n ** 100000n works and 2 ** 100000 is Infinity.
// Time n-bit × n-bit products for doubling n and estimate the exponent k in t ∝ n^k.
// Schoolbook gives k ≈ 2; Karatsuba k ≈ 1.58; Toom-3 k ≈ 1.46. Watch k fall as n grows.
function randomBig(bits) {
let x = 0n;
for (let i = 0; i < bits; i += 32) x = (x << 32n) | BigInt((Math.random() * 2 ** 32) >>> 0);
return x | (1n << BigInt(bits - 1)); // force the full width
}
function time(bits, reps) {
const a = randomBig(bits), b = randomBig(bits);
let sink = 0n;
const t0 = performance.now();
for (let i = 0; i < reps; i++) sink ^= a * b;
return (performance.now() - t0) / reps;
}
let prev = null;
for (const bits of [2048, 4096, 8192, 16384, 32768, 65536, 131072]) {
const reps = Math.max(2, Math.floor(20000000 / bits ** 1.6));
const t = time(bits, reps);
const k = prev ? Math.log2(t / prev) : NaN;
console.log(String(bits).padStart(7), "bits:", t.toFixed(4).padStart(9), "ms/mul", prev ? " k ≈ " + k.toFixed(2) : "");
prev = t;
}
console.log("k near 2 is schoolbook; a drop toward ~1.6 marks the Karatsuba threshold.");Timings are noisy at small sizes because the loop overhead dominates; the trend across the last few rows is the measurement.
Binary64 arithmetic is exact, then rounded once
IEEE 754 requires that +, −, *, /, and sqrt behave as if the exact mathematical result were computed and then rounded to the nearest representable value, ties to even. This makes floating-point arithmetic deterministic and portable: every conforming engine produces bit-identical results for those five operations. It does not make it associative: (a + b) + c and a + (b + c) each round once but at different points. Kahan's compensated summation exploits the guarantee to recover the rounding error of each addition exactly ((a + b) − a − b computes the error, not zero), which is how Math.sumPrecise (ES2025) can return the correctly rounded sum of an arbitrary list. The transcendental functions (Math.sin, Math.exp, Math.pow) are not required to be correctly rounded; the specification allows implementation-approximated results, which is why Math.pow(10, -5) differed between engines for years and why ** on Numbers is not guaranteed to match Math.pow bit-for-bit across engines.
// Two-Sum (Knuth / Møller): the error of a + b is itself representable and computable.
function twoSum(a, b) { const s = a + b; const bb = s - a; const err = (a - (s - bb)) + (b - bb); return [s, err]; }
const [s, err] = twoSum(0.1, 0.2);
console.log("0.1 + 0.2 =", s, " exact error of that addition:", err, "(as a multiple of 2^-54:", err / 2 ** -54, ")");
// Naive summation accumulates the rounding of every step.
const xs = Array(10).fill(0.1);
console.log("naive sum of 0.1 x 10 :", xs.reduce((a, b) => a + b, 0));
// Neumaier's compensated sum tracks the lost low bits.
function compensated(list) { let sum = 0, c = 0; for (const x of list) { const t = sum + x; c += Math.abs(sum) >= Math.abs(x) ? (sum - t) + x : (x - t) + sum; sum = t; } return sum + c; }
console.log("compensated :", compensated(xs));
console.log("Math.sumPrecise :", typeof Math.sumPrecise === "function" ? Math.sumPrecise(xs) : "not in this engine yet (ES2025)");
// Catastrophic case: the naive order loses a small term entirely.
const hard = [1e100, 1, -1e100];
console.log("naive [1e100, 1, -1e100]:", hard.reduce((a, b) => a + b, 0), "| compensated:", compensated(hard), "| sumPrecise:", typeof Math.sumPrecise === "function" ? Math.sumPrecise(hard) : "n/a");Number → String: the shortest digits that round-trip
Number.prototype.toString (via the abstract operation Number::toString) does not print the exact decimal expansion of a double; 0.1 would print as 0.1000000000000000055511151231257827…. It requires the shortest decimal digit string that, when parsed back, yields the same double, with ties broken toward the value closest to the exact one. Producing it correctly is the problem Steele & White posed in 1990 and solved with bignum arithmetic (the Dragon4 algorithm); Grisu (Loitsch, 2010) solved 99.5% of cases with 64-bit integer arithmetic and fell back to bignums for the rest; Ryū (Adams, 2018) solved all cases with a precomputed table of 128-bit multipliers, and its variants are now in V8, SpiderMonkey, and JavaScriptCore. The specification's requirement is what makes JSON.stringify(x) a lossless serialisation of any finite Number: the text is short and exact. toFixed, toPrecision, and toExponential are different operations: they round the exact binary value to a requested number of decimal digits, which is why (1.005).toFixed(2) is "1.00" (the double is slightly below 1.005) and not a bug.
// The exact decimal expansion of a double, via BigInt: value = mantissa × 2^exp exactly.
function exactDecimal(x) {
const f64 = new Float64Array([x]), u = new BigUint64Array(f64.buffer)[0];
const exp = Number((u >> 52n) & 0x7ffn) - 1075, frac = (u & ((1n << 52n) - 1n)) | (1n << 52n);
if (exp >= 0) return (frac << BigInt(exp)).toString();
// value = frac / 2^-exp = frac * 5^-exp / 10^-exp: multiply by 5^-exp, then place the point.
const digits = (frac * 5n ** BigInt(-exp)).toString().padStart(-exp + 1, "0");
return digits.slice(0, digits.length + exp) + "." + digits.slice(digits.length + exp);
}
console.log("exact decimal of 0.1 :", exactDecimal(0.1));
console.log("shortest (toString) :", String(0.1), "<- what the spec requires");
console.log("exact decimal of 1.005:", exactDecimal(1.005));
console.log("(1.005).toFixed(2) =", (1.005).toFixed(2), " (rounds the exact value, which is below 1.005)");
// Shortest means: no shorter string parses to the same double, and this one does.
const samples = [0.1, 1 / 3, 5e-324, 1.7976931348623157e308, 123456789.123456789, 2 ** 53 + 2];
console.log("round-trips:", samples.every((x) => Number(String(x)) === x));
console.log("digit counts:", samples.map((x) => String(x).replace(/[-.e+]/g, "").length).join(", "), "(never more than 17 significant digits)");String → Number: correctly rounded, in every engine
StringToNumber is required to round the decimal literal to the nearest double (the specification permits truncation only after the 20th significant digit, a historical allowance no modern engine uses). Doing this correctly is Clinger's 1990 problem: a decimal like 9007199254740993 sits exactly halfway between two doubles and must round to even; 2.2250738585072011e-308 famously sent PHP and Java into infinite loops in 2011 because their parsers oscillated at the subnormal boundary. Engines now use fast paths (Eisel–Lemire, 2021) for the common case and a bignum comparison for the hard ones. The consequence for JavaScript programmers is that Number("0.1"), JSON.parse("0.1"), the literal 0.1, and parseFloat("0.1") are guaranteed to produce the same double in every engine; text is a lossless interchange format for Numbers, provided the text was produced by the shortest-digits algorithm.
console.log("9007199254740993 parses to", Number("9007199254740993"), "(exact halfway -> ties to even)");
console.log("9007199254740995 parses to", Number("9007199254740995"), "(halfway the other way -> even neighbour is ...996)");
const boundary = Number("2.2250738585072011e-308");
console.log("subnormal boundary parses without hanging:", boundary > 0 && boundary < Number.MIN_VALUE * 2 ** 52 * 1.0000001);
console.log("MIN_VALUE (smallest subnormal):", Number.MIN_VALUE, "| its exact decimal has 1074 fractional digits");
console.log("parseFloat stops at the first bad char:", parseFloat("3.14abc"), "| Number() rejects it:", Number("3.14abc"));
console.log("Number('') and Number(' ') are 0 (StringToNumber of whitespace-only is 0):", Number(""), Number(" "));
console.log("hex, octal, binary literals in strings:", Number("0x1f"), Number("0o17"), Number("0b101"), "| but no sign with a radix prefix:", Number("-0x1f"));Where these ideas come from
The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.
| Mechanism | Origin | Path into JavaScript |
|---|---|---|
| Signed binary multiplication (Booth recoding) | Andrew D. Booth, A Signed Binary Multiplication Technique (1951) | Radix-4 Booth encoding halves the partial products in hardware multipliers; every `Math.imul` and every Smi multiply runs on one. |
| Sub-quadratic multiplication | Anatoly Karatsuba (published by Karatsuba & Ofman), Multiplication of Many-Digital Numbers by Automatic Computers (1962) | V8's BigInt multiplication switches from schoolbook to Karatsuba around 34 digits of 64 bits, then to Toom-Cook and FFT-based methods for very large operands. |
| Floating-point arithmetic | William Kahan and the IEEE 754 committee, IEEE Standard for Binary Floating-Point Arithmetic (IEEE 754-1985) (1985) | Number is binary64 by reference to this standard; round-to-nearest-even, signed zero, gradual underflow, and NaN propagation are all its decisions. |
| Shortest round-trip decimal conversion | Guy L. Steele Jr. & Jon L White; Ulf Adams, How to Print Floating-Point Numbers Accurately (1990); Ryū: Fast Float-to-String Conversion (2018) (2018) | Number.prototype.toString's shortest-digits requirement is Steele & White's problem; engines implement it with Grisu and Ryū. |
| Correctly rounded parsing | William D. Clinger, How to Read Floating Point Numbers Accurately (1990) | StringToNumber must round the decimal to the nearest double; Clinger's bignum fallback is what makes that exact. |
Primary sources
- ECMA-262, §6.1.6.1 The Number Type (Number::multiply, Number::toString)
- ECMA-262, §6.1.6.2 The BigInt Type
- ECMA-262, §7.1.4.1 StringToNumber
- Booth, A. D. (1951). A Signed Binary Multiplication Technique
- Karatsuba, A. & Ofman, Yu. (1962). Multiplication of Many-Digital Numbers by Automatic Computers
- Goldberg, D. (1991). What Every Computer Scientist Should Know About Floating-Point Arithmetic
- Steele, G. L. & White, J. L. (1990). How to Print Floating-Point Numbers Accurately
- Adams, U. (2018). Ryū: Fast Float-to-String Conversion
- Lemire, D. (2021). Number Parsing at a Gigabyte per Second
- V8 blog: Adding BigInts to V8 (implementation notes on algorithms and thresholds)