Your sin() depends on your JDK and your instruction set

You move a Spark job to Arm instances, or upgrade the cluster to JDK 25. The job runs, the row counts match, and then the output diff lights up: a column computed with cos disagrees in the last digit. Nothing in your code changed. Both answers are correct.

Here is the smallest case we have. On a laptop with an AMD Ryzen AI 9 HX PRO 370 (Zen 5, AVX-512) and Ubuntu's OpenJDK builds, with nothing changed but the JDK, Spark's SELECT cbrt(0.22) returns 0.6036810736797685 on JDK 21 and 0.6036810736797686 on JDK 25. And SELECT cos(0.1) on x86 returns 0.9950041652780258, where Java's reference math library says 0.9950041652780257. On an Arm server, a Neoverse N2 on JDK 25, Spark's cos(0.1) returns the reference answer, 0.9950041652780257, so the same query on x86 and on Arm disagrees in the last digit.

In short

Eight of Spark's math functions can return a different last bit on a different machine. What decides it is the instruction set and the JDK version: not the chip model, not the JIT, not whole-stage codegen. If you compare results across machines, compare those columns with a tolerance; if you need the same bits everywhere, there is a way to get them.

One ulpSELECT cos(0.1): the two answers are neighbouring doubles0.9950041652780257fdlibm (StrictMath)0.9950041652780258Spark on the Ryzen AI 9 HX PRO 370 (x86), JDK 25the exact cosine0.504 ulp0.496 ulp1 ulp here is 2^-53, about 1.1e-16: the 16th significant digit. Both answersare within Java's bound for Math.cos, and here the x86 one is the nearer.

Figure 1. The two answers to cos(0.1) are neighbouring doubles, 2^-53 apart. The exact cosine of the double 0.1 lies between them, a hair nearer the x86 answer: 0.496 ulp from it and 0.504 from fdlibm's.

Two libraries behind one function list

Java has two math classes. StrictMath is fdlibm, a C library from the 1990s ported to Java and defined to the bit: it gives the same number on every machine (though not always the same bits for a NaN). Math only promises to be within one unit in the last place (an ulp) of the true result, which leaves the JVM free to use a different routine when it has one.

HotSpot has several. On x86 it swaps Math.sin, cos, tan, tanh, cbrt, exp, log, log10 and pow for hand-written machine code Intel contributed: faster than fdlibm, and rounding differently on a few percent of inputs. On Arm it swaps only sin and cos, for its own port of fdlibm that uses fused multiply-adds. Everything else falls through to StrictMath.

Which of these a query meets depends on a choice Spark made function by function. Spark computes the trigonometric functions, tanh and cbrt with Math, and its documentation says so: sin returns the sine "as if computed by java.lang.Math.sin". It computes exp, ln, log10, log1p and pow with StrictMath. So the x86 routines for exp, log and pow, which differ from fdlibm just as often, never reach a query, while the ones for the trigonometric functions, tanh and cbrt do.

Two libraries behind one function listwhich Java class each Spark math function calls, and whether HotSpotreplaces it on the Ryzen AI 9 HX PRO 370 laptop (x86, JDK 25)java.lang.Mathwhat Spark calls for thesesin, csc21,203 of 1,048,576 inputs differcos, sec21,016 of 1,048,576 inputs differtan, cot22,721 of 1,048,576 inputs differtanh130,493 of 1,048,576 inputs differcbrt86,791 of 1,048,576 inputs differasin, acos, atan, sinh,cosh, atan2, hypotMath delegates theseto StrictMath: 0 differStrictMathfdlibm in Java, on every hostexpMath.exp would differ on 51,502expm1the same either wayln, log2, logMath.log would differ on 14,001log10Math.log10 would differ on 40,041log1p, atanhthe same either waypowMath.pow would differ on 15,777red: answers that can change with the host. Spark chose StrictMath for exp, ln and pow,so their Math stubs, which differ too, never reach a query.

Figure 2. Spark's math functions by the class they call, with the count of inputs, of 1,048,576, where Math and StrictMath differ on the Ryzen laptop (x86) at JDK 25. Left, what Spark computes with Math (sin, cos, tan, sec, csc, cot, tanh, cbrt); right, with StrictMath.

One query can carry two answers. Spark folds a constant expression while it plans the query, so cos(0.1) is computed once, on the driver. cos(x) over a column is computed on the executors. Put an x86 driver in front of Arm executors and the same expression has one answer in the filter Spark folded and another in the rows it computed.

One query, two answersan x86 driver in front of Arm executors, one query:SELECT x FROM t WHERE cos(x) = cos(0.1)plans itruns itthe driver, x86 (here the Ryzen AI 9 HX PRO 370)plans the query and folds the literalthe executors, Arm (Neoverse N2)compute cos(x) for every rowcos(0.1) =cos(x) at the row x = 0.1 =0.99500416527802580.9950041652780257!=the row is filtered outfdlibm (StrictMath), the same on every host: 0.9950041652780257

Figure 3. SELECT x FROM t WHERE cos(x) = cos(0.1) with an x86 driver and Arm executors. The driver folds cos(0.1) to 0.9950041652780258 (the Ryzen laptop, JDK 25); the executors compute cos(x) at the row x = 0.1 as 0.9950041652780257 (the Neoverse N2 GitHub Arm runner, JDK 25), which at this input is also fdlibm's answer, so the row is filtered out. A drawing of the mechanism: each answer is from its own host, and we did not run a mixed cluster.

What does not change the answer

You might suspect the JIT: a method starts in the interpreter and is compiled later, so perhaps the answer moves as the query warms up. It does not. HotSpot calls the same routine from the interpreter, C1 and C2, precisely so that a function stays consistent while its caller is compiled. Whole-stage codegen, Spark's generated projections and its interpreted expressions gave the same bits on every row we ran. And the x86 chip does not matter: the Ryzen laptop (Zen 5, AVX-512) and an EPYC 7763 GitHub runner (AVX2) differ from fdlibm on the same inputs, function by function. [owed: EPYC 7763 runner, the answers compared input by input] An Intel Xeon Platinum 8573C GitHub runner, on another vendor's JDK 25 build, gave the laptop's bits for all five functions on every one of a million inputs.

What does move it is the architecture and the JDK. Arm replaces only sin and cos, so on tan, tanh and cbrt Arm gives fdlibm's answer and x86 does not, and on sin and cos each has its own. Input by input, the laptop and an Arm runner disagree on about 6% of inputs for sin and 4% for cos, more than either disagrees with fdlibm, and for the other three on exactly the inputs where x86 leaves fdlibm. On x86, the tanh routine arrived in JDK 24 and the cbrt one in JDK 25: upgrading from 21 changes tanh on about one input in eight and cbrt on about one in twelve.

Which routine answersJDK 25: what HotSpot runs for Math's five, and on how many of 262,144 inputsit differs from fdlibm (StrictMath)Ryzen AI 9 HX PRO 370x86, Zen 5, AVX-512 (laptop)Intel's stub: 8,707 differIntel's stub: 8,726 differIntel's stub: 9,549 differIntel's stub: 4,772 differIntel's stub: 21,962 differEPYC 7763x86, AVX2 (GitHub runner)Intel's stub: 8,707 differIntel's stub: 8,726 differIntel's stub: 9,549 differIntel's stub: 4,772 differIntel's stub: 21,962 differNeoverse N2Arm (GitHub Arm runner)Arm's FMA port: 19,081 differArm's FMA port: 17,697 differfdlibm: the samefdlibm: the samefdlibm: the sameMath.sinMath.cosMath.tanMath.tanhMath.cbrtthe two x86 chips: the same count for every functionthe Ryzen laptop against the Arm runner, input by input (the probe's 1,048,576 inputs):sin66,122cos43,725tan22,721tanh130,493cbrt86,791

Figure 4. The routine behind Math.sin, cos, tan, tanh and cbrt on JDK 25 on the Ryzen laptop, an EPYC 7763 GitHub runner and a Neoverse N2 GitHub Arm runner, with the count of 262,144 positive inputs (an earlier probe's control pass, a different input set from Figure 6's) where Math differs from fdlibm. The two x86 chips: 8,707 (sin) to 21,962 (cbrt), the same count for every function. Arm: 19,081 for sin, 17,697 for cos, none for the other three. Below, the Ryzen laptop against the Arm runner input by input over Figure 6's 1,048,576 inputs: 66,122 for sin and 43,725 for cos, by 1 ulp (125 of each only in a NaN's bits), and for tan, tanh and cbrt the laptop's own counts against fdlibm, 22,721, 130,493 and 86,791.

Where it bites

One ulp of a double near 1 is about 1e-16, the sixteenth significant digit. No dashboard shows it. It shows wherever doubles are compared exactly:

It also shows in text. Java prints a double as the shortest decimal that tells it from its neighbours, so the two answers to cos(0.1) print with different last digits, and a CSV export diffs.

Where it bitesthe same query exported to CSV twice, then diffedJDK 21 to 25, on the Ryzen laptop--- jdk21.csv +++ jdk25.csv fn,x,value cos,0.1,0.9950041652780258-cbrt,0.22,0.6036810736797685+cbrt,0.22,0.6036810736797686the Ryzen laptop to Arm, JDK 25--- ryzen.csv +++ arm.csv fn,x,value-cos,0.1,0.9950041652780258+cos,0.1,0.9950041652780257-cbrt,0.22,0.6036810736797686+cbrt,0.22,0.6036810736797685

Figure 5. A two-row CSV export, diffed. Left, the Ryzen laptop on JDK 21 and on JDK 25: cos(0.1) prints 0.9950041652780258 on both, cbrt(0.22) 0.6036810736797685 and then 0.6036810736797686. Right, the Ryzen laptop against the Neoverse N2 Arm runner, both on JDK 25: Arm prints 0.9950041652780257 and 0.6036810736797685, fdlibm's answers to both.

How often depends on the function and the host. On x86, about 2% of inputs for sin, cos and tan, about 8% for cbrt, about 12% for tanh; on Arm, about 5% for sin and 3% for cos. Almost always by one ulp; tanh reaches two on about one difference in a hundred, and so do Spark's sec, csc and cot, which divide one by a cosine, sine or tangent that was already one ulp off.

How often, how farshare of 1,048,576 inputs where Math differs from fdlibm (StrictMath)sinRyzen AI 9, JDK 252.0%Ryzen AI 9, JDK 212.0%Neoverse N2, JDK 255.2%cosRyzen AI 9, JDK 252.0%Ryzen AI 9, JDK 212.0%Neoverse N2, JDK 252.8%tanRyzen AI 9, JDK 252.2%Ryzen AI 9, JDK 212.2%Neoverse N2, JDK 250: the same as fdlibmtanhRyzen AI 9, JDK 2512.4% (1,563 by 2 ulps)Ryzen AI 9, JDK 210: the same as fdlibmNeoverse N2, JDK 250: the same as fdlibmcbrtRyzen AI 9, JDK 258.3%Ryzen AI 9, JDK 210: the same as fdlibmNeoverse N2, JDK 250: the same as fdlibm0%2%4%6%8%10%12%14%1 ulp2 ulps

Figure 6. The share of 1,048,576 inputs where Math differs from fdlibm, by one ulp and by two, on the Ryzen laptop at JDK 25 and 21 and on the Neoverse N2 Arm runner at JDK 25, where 54,794 differ for sin, 29,538 for cos and none for the other three. Values only: an answer that differs only in a NaN's bits is not counted.

Spark's own queryrows of 262,144 where a function's answer differs from fdlibm's (StrictMath)sin(x)Ryzen AI 9 HX PRO 3705,170, up to 1 ulpEPYC 77635,170, up to 1 ulpNeoverse N213,569, up to 1 ulpcos(x)Ryzen AI 9 HX PRO 3705,344, up to 1 ulpEPYC 77635,344, up to 1 ulpNeoverse N27,389, up to 1 ulptan(x)Ryzen AI 9 HX PRO 3705,708, up to 1 ulpEPYC 77635,708, up to 1 ulpNeoverse N20sec(x)Ryzen AI 9 HX PRO 3704,296, up to 2 ulpsEPYC 77634,296, up to 2 ulpsNeoverse N24,418, up to 2 ulpscsc(x)Ryzen AI 9 HX PRO 3704,166, up to 2 ulpsEPYC 77634,166, up to 2 ulpsNeoverse N27,954, up to 2 ulpscot(x)Ryzen AI 9 HX PRO 3704,852, up to 2 ulpsEPYC 77634,852, up to 2 ulpsNeoverse N20tanh(x)Ryzen AI 9 HX PRO 37032,396, up to 2 ulpsEPYC 776332,396, up to 2 ulpsNeoverse N20cbrt(x)Ryzen AI 9 HX PRO 37021,640, up to 1 ulpEPYC 776321,640, up to 1 ulpNeoverse N20010,00020,00030,000asin, atan2, exp, ln, log10, pow: 0 rows on every hostwhole-stage codegen, generated projection, interpreted: one digest per column

Figure 7. Spark's own query over 262,144 rows read from Parquet: the rows where each function differs from the same function over StrictMath, on the Ryzen laptop, an EPYC 7763 GitHub runner and the Neoverse N2 Arm runner, all at JDK 25. On the laptop from 4,166 (csc) to 32,396 (tanh), by up to 2 ulps for sec, csc, cot and tanh; on the EPYC 7763 the same count for every function and the same digest for every column, so the same bits in every row; on Arm 13,569 for sin, 7,389 for cos, 4,418 for sec and 7,954 for csc, by up to 2 ulps for sec and csc, and none for tan, cot, tanh and cbrt; on all three, none for asin, atan2, exp, ln, log10 and pow. Whole-stage codegen, the generated projection and the interpreted path gave one digest per column on each host.

What to do

We build Varka, a vectorized engine for Spark SQL. It matches Spark bit for bit on the same host, so it calls the same Math methods and inherits the same dependence.

How these were measured

On a laptop with an AMD Ryzen AI 9 HX PRO 370 (Zen 5, AVX-512) running Linux, with Ubuntu's OpenJDK builds 25.0.4.1, 21.0.12.1 and 17.0.20.1 (HotSpot): a probe compares Math and StrictMath over 1,048,576 inputs per function, cold and hot and in each JIT tier, and a Spark query over a 262,144-row Parquet table runs the same functions with and without codegen. The same probe ran on a Neoverse N2 GitHub Arm runner and an Intel Xeon Platinum 8573C GitHub runner, and the Spark query on the Neoverse N2 and on an EPYC 7763 GitHub runner, all with Azul's Zulu 25.0.5 build. On the EPYC 7763 the Spark query gave the laptop's results: the same rows differing from fdlibm for every function, the same digest for every column. Figure 4's counts come from the control pass of an earlier probe on GitHub's EPYC 7763 and Neoverse N2 runners; the HotSpot facts are from the JDK 25 source. The record is VARKA-587.md, with the Ryzen laptop's output in VARKA-587-math-host-laptop.txt and the Arm runner's in VARKA-587-math-host-arm.txt and VARKA-587-spark-arm.txt, and the EPYC 7763 runner's Spark query in VARKA-587-spark-epyc7763.txt.