ECMA-262 still contains a sentence recommending that engines compute Math with fdlibm, the freely distributable math library from Sun Microsystems. V8 15.6, shipping in the Node 27 alpha, no longer does. Seventeen functions in src/base/ieee754.cc now forward straight into LLVM's libm, and the release note covering that is eleven words long.
The two clauses in one release-note line
Node shipped 27.0.0-alpha.0.0.0 on 29 October 2026. Inside the V8 15.6 section of the release notes, two clauses sit on one line: "Math methods now use LLVM's libm and may differ in the last bit;" and "Math.random() sequences change for a given --random-seed;". They share a single credit line (Joyee Cheung, nodejs/node #65161, #66537, #66601) and name no function, no magnitude, no mechanism.
They are also unrelated changes. One swaps the approximation kernels under the transcendental functions. The other renumbers a seeded pseudorandom stream. They break different tests and need different fixes, and bundling them behind one sentence is why I expect most teams to read this as a single rounding footnote.
Node 26 went LTS on 28 October, one day before the alpha, so the version nearly everyone is standardising on right now still runs the V8 14.6 fdlibm port, and node:lts moved to it without a commit from you. Node 27 stable is not due until April 2027. That gap is the cheap window: a nightly lane on the alpha turns all of this into ordinary test failures now, instead of a surprise riding along with an LTS migration.
What ieee754.cc actually forwards
src/base/ieee754.cc on main opens with #include "third_party/llvm-libc/src/shared/math.h" and then:
#define LIBC_MATH (LIBC_MATH_NO_ERRNO | LIBC_MATH_NO_EXCEPT | \
LIBC_MATH_ASSUME_ROUND_NEAREST_ONLY)
Below it, seventeen functions collapse into one-line forwards of the shape double exp(double x) { return LIBC_NAMESPACE::shared::exp(x); }: acos, asin, atan, atan2, cos, exp, log, log1p, log2, log10, expm1, cbrt, sin, tan, cosh, sinh, tanh. legacy::pow(x, y) forwards to shared::pow(x, y).
What I read in the tree: V8's third_party/llvm-libc/README.v8 records Name: llvm-libc, Revision: DEPS, Security Critical: yes, Shipped: yes, and the vendored directory holds only a BUILD.gn and two READMEs. The implementation arrives through DEPS rather than living in V8's repository, so grepping V8 for the kernels finds nothing and tells you nothing.
LLVM's libm exists to be more accurate than what it replaces. A last-bit move therefore usually means the Node 26 value carried V8's fdlibm rounding error and the Node 27 value sits closer to the exact result. Your golden file was pinning the error. Teams reach for a compatibility flag at this point, and the correct response is to loosen the assertion.
The three functions that kept fdlibm and changed anyway
acosh, asinh and atanh still carry their fdlibm-derived bodies in the same file, complete with the original method comments and / Get two 32 bit ints from a double. /. Those bodies call log, log1p, sqrt and fabs, and log and log1p now route into llvm-libc. The old algorithm is wrapped around a new kernel, so their output can shift through composition while the function itself looks untouched in the diff.
No changelog mentions this, and it has a practical consequence: their error behaviour matches neither engine's published reference values for those three functions. If your numerics suite uses atanh as a reference point, widen its tolerance further than the delegated set.
Math.pow has a second route. ieee754.h annotates the legacy entry point with "This function should not be used directly. Instead, use v8::internal::math::pow." ECMA-262 defines Math.pow through Number::exponentiate, so the specified special cases apply before any libm call and only the general path can move. Which libm a given build reaches for pow is worth confirming per platform rather than assuming.
Why sqrt is safe and tan never was
The useful split is not "float math is unreliable". ECMA-262 §21.3.2.33 defines Math.sqrt step 4 as "Return 𝔽(the square root of ℝ(n))", an exact value with no approximation latitude, and the same exactness holds for + - * /, Math.abs, floor, ceil, round, trunc and fround. The §21.3.2 NOTE names the other set explicitly: acos, acosh, asin, asinh, atan, atanh, atan2, cbrt, cos, cosh, exp, expm1, hypot, log, log1p, log2, log10, pow, random, sin, sinh, tan, tanh.
| Function | §21.3.2 latitude | Node 27 vs Node 26 | Assertion style |
|---|---|---|---|
sqrt, abs, floor, ceil, round, trunc, fround, + - * / | none, exact result specified | cannot change | exact equality |
exp, log, log1p, log2, log10, expm1, cbrt, sin, cos, tan, sinh, cosh, tanh, asin, acos, atan, atan2 | implementation-approximated | one-line forward to llvm-libc | relative, a few ULP |
acosh, asinh, atanh | implementation-approximated | fdlibm body over llvm-libc kernels | relative, wider |
pow | special cases specified, general path approximated | general path only | relative, a few ULP |
random | no cross-version guarantee | sequence renumbers per seed | own PRNG |
fmod, hypot | hypot in the NOTE list | neither declared in ieee754.h | confirm per build |
Because the spec grants the latitude, a ticket claiming Node 27 computes Math.tan incorrectly has nowhere to go. Say that internally before someone spends a week writing it up, and budget for the migration instead.
The seeded sequence has four moving parts
src/numbers/math-random.cc reads the flag as if (v8_flags.random_seed != 0) { seed = v8_flags.random_seed; }, derives state.s0 = MurmurHash3(seed) and state.s1 = MurmurHash3(~seed) under CHECK(state.s0 != 0 || state.s1 != 0), then refills with for (int i = kCacheSize - 1; i >= 0; i--) over XorShift128(&state.s0, &state.s1). math-random.h sets static const int kCacheSize = 64;.
The value your Nth call sees depends on the seed derivation, the xorshift step, the double conversion and the cache geometry. Any one of those moving renumbers everything after it. The same file also carries const bool kUseRefillCache = kSystemPointerSize == 4; with the comment "Use RefillCache on 32-bit architecture since CSA/Torque can't generate code operating with 64-bit values", so cross-architecture sequence identity was never a property of this code in the first place.
Here is the cell I could not close. The release note says the seeded sequence changed, the source shows the full chain, and I could not establish which link moved between 14.6 and 15.6. Against a V8 checkout this settles it: git log --oneline branch-heads/14.6..branch-heads/15.6 -- src/numbers/math-random.cc src/numbers/math-random.h src/base/utils/random-number-generator.h. Everything else above comes from a page I read.
Round-to-nearest became a compile-time assumption
That LIBC_MATH define waives three things: errno, FP exceptions, and any rounding mode other than nearest. A native addon or a vendored numeric library that calls fesetround() leaves the process in a state the math kernels were compiled to assume away. llvm-libc's own libc.llvm.org/math page returned an empty body on every fetch I tried, so I have not read upstream's description of LIBC_MATH_ASSUME_ROUND_NEAREST_ONLY and will not guess at its exact guarantee. Inside a Node 27 source tree, grep -rn "LIBC_MATH_ASSUME_ROUND_NEAREST_ONLY" deps/v8/third_party/llvm-libc deps/v8/src/base/ieee754.cc is where to look. Addons are getting rebuilt for Node 27 regardless, and npm rebuild fixes only half of a NODE_MODULE_VERSION mismatch, so audit rounding-mode calls while that work is already open.
FAQ
Does this affect JSON.stringify output of computed floats? Only through the value. Double to string conversion is specified exactly; if Math.log returns a different double, the printed digits differ because the number differs.
Will a --js-flags switch bring back fdlibm? Nothing in ieee754.cc is conditional on a flag. The forwards are unconditional one-liners.
Does pinning a Node patch version protect recorded v8.serialize() fixtures? No. The same release notes state that v8.cachedDataVersionTag() now changes on every Node patch bump of V8, and that v8.serialize(), structuredClone() and postMessage() use a format older Node releases cannot read.
Is Math.random without a seed affected? The seeded case is what the note calls out. Unseeded output was never reproducible, so no test should have depended on it.
What to put in CI before Node 27 stable
- Add a nightly lane on
27.0.0-alpha.0.0.0. Stable is April 2027, so you have roughly five months to fix failures one at a time. - Ship a bit-level probe rather than reading a version table. Print raw bits for
sin(1),tan(1e22),exp(1),log(7),cbrt(7),atanh(0.5),acosh(1.5)andpow(1.0000000000000002, 1e3), withsqrt(2)as the control that must not move, then diff the hex between 26 and 27. Distro backports and vendoreddeps/make release strings unreliable in both directions, which is the same reason the Node Brotli version string lies.
const b = Buffer.alloc(8);
b.writeDoubleLE(Math.tan(1e22));
console.log(b.readBigUInt64LE().toString(16));
- Confirm the alpha you downloaded matches what you read on main:
grep -c "LIBC_NAMESPACE::shared" deps/v8/src/base/ieee754.cc. Node 27 alpha pins V8 15.6.75.13 (up from 14.6 in Node 26, release commit826df076a3, nodejs/node #66601). - Split assertions by spec class using the table above. Grep the test tree for
toBe(,toEqual(andstrictEqual(whose argument is aMath.call, keep exact equality for the exact set, and move the transcendental set to a few ULP of relative tolerance. - Replace every seeded
Math.randomreplay with a userland xorshift128+ you seed explicitly. Fuzz corpora, simulation runs and recorded shuffles all qualify. - Audit transcendentals on identity paths. A
Math.logorMath.powfeeding a cache key, shard index, dedup hash or idempotency token splits a mixed-version fleet silently. Round to a fixed number of decimals before hashing, or hash the integer inputs. - Grep deploy manifests for
--experimental-wasm-. Those flags are renamed--wasm-<name>in this release, and a pinned one in a Dockerfile or systemd unit becomes an unknown-flag start failure rather than a warning.
Comments
Be the first to comment.