https://kotlinlang.org logo
Title
l

louiscad

03/03/2023, 2:17 AM
Hello, I hitting a super weird wall in Kotlin/JS (legacy) with this subtraction:
1.6896646614334472 - 1.6893290421702434
sometimes returns
3.356192632038013E-4
like in the JVM, but at other times, I get
0.0003356192632038013
.
I searched deep, but I still have no clue as to why I'm getting this, anyone has an idea? I'm completely lost. Pure JS in Google Chrome console gives
0.0003356192632038013
which is not what IEEE 754 should give I think. Kotlin code with JDK 18 gives me
3.356192632038013E-4
, which is using Java's
double
, which is supposed to be IEEE 754 too!
It gets weirder. Here's a snippet I'm running in multiple modes on play.kotl.in with Kotlin 1.8.10:
fun main() {
    val a = 1.6896646614334472
    val b = 1.6893290421702434
    val c = (a - b)
    println("$a - $b = $c")
}
Here are the results:on Kotlin/JS IR:
0.0003356192632038013
on Kotlin/JS (legacy):
3.356192632038013E-4
on Kotlin/JVM:
3.356192632038013E-4
EDIT: This is the same number, "just" represented differently. The actual problem is that
sin(0.8314090181354269)
gives different results on JS compared to Android and SOME JVM environments: the last digit can be
7
or
8
, and we still don't know why.
j

jw

03/03/2023, 2:29 AM
Are you saying the problem is the string representation? Because those are all the same number.
l

louiscad

03/03/2023, 2:30 AM
Well, the computations that I have afterwards drift further, leading to a test that fails on some platforms
j

jw

03/03/2023, 2:30 AM
But there is no drift in those numbers. They are the same.
l

louiscad

03/03/2023, 2:31 AM
You're right, now I see it, so I need to look further
I guess Kotlin/JS IR is only being more consistent now by always showing it the JS way
j

jw

03/03/2023, 2:33 AM
I suspect the string representation is perhaps influenced by the bit representation. So perhaps it can be exactly represented in two bit forms and some quirk of the codepath results in one bit pattern over the other. There's probably some normalization thing in the floating point spec.
l

louiscad

03/03/2023, 2:34 AM
Other operations I have further down are all using
Double
(at least from a Kotlin standpoint), and I have
*
,
+
,
cos
, and
sin
being used
j

jw

03/03/2023, 2:35 AM
nevermind seems like the bit pattern is always normalized since there's an implicit 1 in the integer part that isn't encoded but is assumed
l

louiscad

03/03/2023, 2:50 AM
Ok, found where the sin drift starts:
sin(0.8314090181354269)
What do you get on your machine on Kotlin/JVM?
c

Chris Lee

03/03/2023, 2:52 AM
Kotlin playground: sin(0.8314090181354269)
l

louiscad

03/03/2023, 2:53 AM
On play.kotl.in, I consistently get
0.7388815504611167
for Kotlin 1.8.10, be it the JVM, JS (legacy) or JS IR. On machine though, on the JVM, I get
0.7388815504611168
(notice the last digit). My JDK is
64-Bit Server VM Zulu18.30+11-CA
Your link (Jake) gives me the same result as play.kotl.in. I don't get why at other times I get the last digit changed in Kotlin/JS legacy and on my JVM :blob-thinking-upside-down:
c

Chris Lee

03/03/2023, 2:55 AM
the challenge is in printing it out - need to find a way to dump/compare the internals as the string representation is just that - an alternate representation.
This may help:
import kotlin.math.*

fun main() {
    println(sin(0.8314090181354269).toRawBits())
}
l

louiscad

03/03/2023, 2:57 AM
I get
4604830472895931656
locally
c

Chris Lee

03/03/2023, 2:57 AM
Playground has:
4604830472895931655
l

louiscad

03/03/2023, 2:57 AM
Yup, one bit off
c

Chris Lee

03/03/2023, 2:57 AM
yep
l

louiscad

03/03/2023, 2:58 AM
:heads-down: 🔫
I'm glad it's a water gun
Google search seems empty of results for that problem, which doesn't even seem to be a Kotlin/JS problem now. I think this is the weirdest thing I've ever seen as a developer.
c

Chris Lee

03/03/2023, 3:05 AM
oh let’s not get into comparing weird things, not enough 🥃 for that. What is your local machine / JVM?
l

louiscad

03/03/2023, 3:05 AM
aarch64
JDK is
64-Bit Server VM Zulu18.30+11-CA
as stated before in the thread
Regardless, thank you very much to both of you for your help so far, I already learned a few things, and discovered kotlin.godbolt.org as well 🙏🏼
c

Chris Lee

03/03/2023, 3:07 AM
On my MacBook M1, aarch64, Corretto-17.0.6.10.1:
4604830472895931656
l

louiscad

03/03/2023, 3:08 AM
So same as me
c

Chris Lee

03/03/2023, 3:08 AM
think that’s the same as what you had locally, yea.
l

louiscad

03/03/2023, 3:09 AM
I'm going to test on my phone
My phone and my laptop agree
Android 13 and macOS
c

Chris Lee

03/03/2023, 3:13 AM
that’s positive. Is it just the playground that is off, or other situations?
l

louiscad

03/03/2023, 3:14 AM
Hum, I think it's worse than that
Chrome on my laptop agrees with Kotlin Playground, and JDK 17 on GitHub Actions on Linux also agrees with that
And godbolt
So depending on the JDK or the platform, we get something different™
c

Chris Lee

03/03/2023, 3:16 AM
Is this an accurate summary so far:
MacBook M1, aarch64, Corretto-17.0.6.10.1: 4604830472895931656
aarch64, 64-Bit Server VM Zulu18.30+11-CA: 4604830472895931656
Playground:								   4604830472895931655
Chrome on aarch64:						   4604830472895931655
godbolt:								   4604830472895931655
With github:
MacBook M1, aarch64, Corretto-17.0.6.10.1: 4604830472895931656
aarch64, 64-Bit Server VM Zulu18.30+11-CA: 4604830472895931656
Playground:								   4604830472895931655
Chrome on aarch64:						   4604830472895931655
godbolt:								   4604830472895931655
Github Actions (linux)					   4604830472895931655
l

louiscad

03/03/2023, 3:20 AM
You can just edit your previous message and remove the other one instead of pasting the same thing just to add one line.
c

Chris Lee

03/03/2023, 3:24 AM
looks like godbolt is Linux in Intel.
l

louiscad

03/03/2023, 3:25 AM
My concise summary (only last digit differs): • JVM on macOS (aarch64), Android 13 (aarch64):
0.7388815504611168
• JS (legacy & IR), JVM on Linux (x64):
0.7388815504611167
c

Chris Lee

03/03/2023, 3:25 AM
^^^ that’s not the same test, sin(…).
l

louiscad

03/03/2023, 3:26 AM
That's a different representation, but we've seen it's not the same number in both cases.
c

Chris Lee

03/03/2023, 3:27 AM
sure, but trying to keep the test consistent and identify which platforms are different, This is what we have so far:
println(sin(0.8314090181354269).toRawBits())

MacOS aarch64, Corretto-17.0.6.10.1: 	   4604830472895931656
MacOS aarch64, Zulu18.30+11-CA: 		   4604830472895931656
Playground:								   4604830472895931655
Chrome on aarch64:						   4604830472895931655
godbolt (Linux/Intel):					   4604830472895931655
Github Actions (linux):					   4604830472895931655
l

louiscad

03/03/2023, 3:27 AM
Please, don't make me read that list again with all those digits
c

Chris Lee

03/03/2023, 3:28 AM
it’s just the platform and the results… ???
l

louiscad

03/03/2023, 3:28 AM
Anyway, I don't know what I'm going to do with that and the d*mn test, but it's 4:26AM, I'll go where I belong
This is the 3rd time you're copying this.
c

Chris Lee

03/03/2023, 3:28 AM
yes, updated with information as we test.
it shows that the JVM on MacOS is different than all the rest.
l

louiscad

03/03/2023, 3:36 AM
Nope, Android agrees with macOS
c

Chris Lee

03/03/2023, 3:37 AM
Do we know if that Android is on an arm chipset?
l

louiscad

03/03/2023, 3:37 AM
Yes it is
c

Chris Lee

03/03/2023, 3:37 AM
yea. so it looks like the JVM/ARM combo is different somehow.
l

louiscad

03/03/2023, 3:38 AM
I said it's my phone earlier, who sports a non ARM phone these days?
Interesting, Intel Mac gives the same result as Linux x64
(JVM again)
What a cluster***k
Seems to be CPU dependent.
c

Chris Lee

03/03/2023, 3:41 AM
yea. so arm/jvm is different … somehow … but how to take that further.
haven’t had any luck searching on this yet.
l

louiscad

03/03/2023, 3:42 AM
I'm wondering if ARM 32 bits CPUs would give the same difference. Need to test with an old phone someday.
Or easier: a watch, because I've got no ARM 32 bits phone around
c

Chris Lee

03/03/2023, 3:43 AM
Here’s golang on my Macbook / aarch64:
4604830472895931655
. Same as JVM/Intel etc.
l

louiscad

03/03/2023, 3:43 AM
Might look into this "tomorrow"
c

Chris Lee

03/03/2023, 3:43 AM
So not really the CPU - the JVM/CPU combination.
l

louiscad

03/03/2023, 3:43 AM
Still kinda is
But there are other factors as well
Interesting nonetheless
c

Chris Lee

03/03/2023, 3:43 AM
well, the CPU can get the right answer, as shown with golang. But the JVM on the same CPU doesn’t.
l

louiscad

03/03/2023, 3:44 AM
Not just the JVM
ART as well
I guess it's more… artsy?
c

Chris Lee

03/03/2023, 3:44 AM
sorry, what is ART?
l

louiscad

03/03/2023, 3:45 AM
Android RunTime
It's not an actual JVM
c

Chris Lee

03/03/2023, 3:45 AM
yea
l

louiscad

03/03/2023, 3:46 AM
A bunch of things are different in ART, but not this kind of things, usually
c

Chris Lee

03/03/2023, 3:48 AM
e

ephemient

03/03/2023, 3:49 AM
funny you mention it… just recently, I encountered https://kotlinlang.slack.com/archives/C09222272/p1675431328423709
but basically you shouldn't expect the exact same bit patterns from floating point operations on different platforms
c

Chris Lee

03/03/2023, 3:51 AM
yep, looks that way. even between JVM revisions as they may change the internals to use different opcodes which may adjust the precision.
e

ephemient

03/03/2023, 3:51 AM
Java has StrictMath.sin that is supposed to be using the "same" implementation across platforms (as opposed to Math.sin which is supposed to be fast and close enough)
earlier versions of the JVM had a
strictfp
modifier, preventing using the intermediate 80-bit x87 FPU values. (that's gone now; SSE works better on x86-64 and isn't an oddball size)
but still, there's optimizations like using FMA which can result in slightly different results
c

Chris Lee

03/03/2023, 3:55 AM
Kotlin
math.sin
boils down to
StrictMath.sin
on the JVM. It is documented as being non-exact:
The computed result must be within 1 ulp of the exact result.
e

ephemient

03/03/2023, 3:56 AM
where do you see that? it definitely goes to
Math.sin
from what I can see
c

Chris Lee

03/03/2023, 3:57 AM
Clicked-thru from Kotlin math.sin to
@SinceKotlin("1.2")
@InlineOnly
public actual inline fun sin(x: Double): Double = nativeMath.sin(x)
to:
@IntrinsicCandidate
    public static double sin(double a) {
        return StrictMath.sin(a); // default impl. delegates to StrictMath
    }
Kotlin 1.8.10.
import java.lang.Math as nativeMath

public actual inline fun sin(x: Double): Double = nativeMath.sin(x)
c

Chris Lee

03/03/2023, 4:00 AM
wowsers. yes that is right. let me see if the click-thru lies…
no, it’s right. Math.sin -> StringMath.sin
*StrictMath.sin
e

ephemient

03/03/2023, 4:01 AM
are you looking in java.lang.Math? that is outside of kotlin-stdlib and may be implemented differently on different JVMs
c

Chris Lee

03/03/2023, 4:02 AM
that’s right. it is documented as being non-exact so precision will vary.
e

ephemient

03/03/2023, 4:03 AM
kotlin-stdlib doesn't even promise 1.ulp
c

Chris Lee

03/03/2023, 4:04 AM
nor are there cross-platform consistency promises.
e

ephemient

03/03/2023, 4:13 AM
oh if you're looking at OpenJDK:
@IntrinsicCandidate
    public static double sin(double a) {
that means that the JIT may produce some different native code instead of running that function normally
and this in fact does happen: e.g. https://bugs.openjdk.org/browse/JDK-8189105
so don't believe the implementation
c

Chris Lee

03/03/2023, 4:14 AM
💯 . Even with @Strictfp the results vary between platforms.
e

ephemient

03/03/2023, 4:15 AM
strictfp is orthogonal to this (and doesn't even do anything on modern JVMs)
c

Chris Lee

03/03/2023, 4:15 AM
It is, however, consistent: running it in a loop (such that Hotstop will compile to native code) doesn’t change the results, and the results are the same with/without -Xint to disable compilation.
(-Xint is noticeably slower)
e

ephemient

03/03/2023, 4:17 AM
the JVM tries to avoid the same "pure" function having different values at runtime
although there's definitely been bugs in the past where constant folding could produce different results, depending on the platform
c

Chris Lee

03/03/2023, 4:20 AM
interestingly: the same sin calculation in golang / aarch64 returns a different result than JVM/aarch64. Would have to wade through the opcodes to figure that out - likely differences in intrinsics etc.
e

ephemient

03/03/2023, 4:30 AM
well that's unsurprising since they use completely different implementations
(
sin
isn't an instruction in ASM, it's library code)
c

Chris Lee

03/03/2023, 4:34 AM
that’s right. go does have an architecture-specific sin (
archSin
internal function), but of course the implementations will differ, as will the compiler steps etc.
the results are close - off by just the last bit - which is fine & expected.
it’s all a curiosity now 😉
e

ephemient

03/03/2023, 4:37 AM
I didn't think Go had a ARM64 variant of sin, but maybe it does and I missed it. in any case, yeah the details don't specifically matter to us; we just have to keep in mind that the results should be close but may not be exactly equal
some more floating-point fun (just in plain multiply and add, no trig): https://www.kdab.com/fma-woes/
c

Chris Lee

03/03/2023, 4:43 AM
lol,
stubs.go
has the directive
go:build !s390x
indicating that most of the trig operations don’t have assembly/intrinsic implementations outside of s390x platform.
so that’s the difference - the JVM uses intrinsics for sin, golang doesn’t. Well, at least one of the differences, there could be algorithmic or other implementation details.
e

ephemient

03/03/2023, 4:47 AM
Hotspot implements those intrinsics, it's not like it's something magical. you can see the code it produces in the link I pasted earlier (plus some data tables in a neighboring file)
c

Chris Lee

03/03/2023, 4:48 AM
exactly.
e

elizarov

03/03/2023, 8:25 AM
It has nothing to do with FMA and stuff.
sin
just does not (and pragmatically can not) guarantee that it always produces the same result. The actual implementation of
sin
uses an approximate algorithm and it only guarantees that its error does not exceed 1 ULP. That is, it can be off from the correct answer in the last bit. This especially happens when the true answer is almost half way in between neighboring double values. In this case, different implementations of
sin
function can get different values of the last bit. This is totally inevitable. The actual background here is way more complicated. For example, old versions of libc tried to provide “correctly rounded result” in all the cases. That meant that sometimes, when approximation is close to the half of the last bit, it has to use arbitrary-precision arithmetics to figure out more digits to round the result correctly. It ended up causing all sorts of performance problems in games, where sometimes, if you are unlucky, sin computations will be very slow. Modern libc does not do this anymore. Nobody does, nor should be doing this. It is perfectly fine for a
sin
value to have different value in the last bit on different implementations (runtimes, versions, archs).
TL;DR: It is easy to mathematically define “the correctly rounded” value that
sin
should be returning, so that it is always the same no matter which runtime/platform you compute it with, yet a fast algorithm to perform such computation does not exist. So a pragmatic tradeoff is to require that the
sin
returns the correct value “up to the last bit”. For that purpose, multiple fast algorithms exist, so different runtimes use different such algos, producing different results in the last bit, just as expected.
e

ephemient

03/03/2023, 8:38 AM
right, I wasn't trying to say FMA had anything to do with this. just that there's multiple reasons not to expect exactly equal floating-point results in general
I don't know if the JVM will perform that optimization either, but LLVM seems happy to
e

elizarov

03/03/2023, 8:40 AM
In fact, the story with JS runtimes is way more funnier. For example, the initial implementation of V8 by Google wanted to claim the title of “the fastest JS runtime”. Back then, the certain benchmark that was popular to measure “which JS runtime is the best” basically measured how fast
sin
/`cos` are working. So V8 provided very fast yet very inaccurate implementation for `sin`/`cos` with error not just in the last bit (as customary) but really being very far off. Of course, they had to fix it when people stared using V8 for real stuff, but the title of the fastest JS engine was already won, so it was not a problem anymore.
@ephemient They just simply use totally different algorithms. E.g. JVM runtime has its own sin/cos code, JS runtimes use something else, Kotlin/Native, for example, just uses the implementation from libc.
e

ephemient

03/03/2023, 8:41 AM
yes, and I posted links to the implementations previously
e

elizarov

03/03/2023, 8:42 AM
Right. And the spec only requires them to be correct up to the last bit.
e

ephemient

03/03/2023, 8:43 AM
I am not actually sure about JS. the latest spec I can find simply says that
sin
and
cos
are "implementation-approximated", so there doesn't seem to be a guarantee about the number of bits of accuracy
e

elizarov

03/03/2023, 8:44 AM
However, this is not to be confused with regular arithmetics (addition, multiplication, division). These operations must produce the correctly rounded result, so it will be really the same all the time (and for chains of such operations whether FMA is used in the optimized code actually matters)
Yes, JS spec is still loose, yet, in practice, they’ll have to provide “at most 1 ulp of error” guarantee or it’ll be impossible to run any serious physics computation, for example. (that’s how people discovered the original V8 performance trick)
e

ephemient

03/03/2023, 8:48 AM
chains of regular arithmetic had observable non-
strictfp
differences across platforms at some point in the past, didn't they? not anymore on JVM, but LLVM is happy to optimize those chains to produce different results depending on optimization level and target
e

elizarov

03/03/2023, 8:51 AM
Yes, LLVM is basically designed as a backend for C/C++, which allows for a lot of optimization freedom (including judicious use of FMA, reordering, etc). Even without FMA, floating point arithmetics is simply not associative, so (a+b)+c != a+(b+c) in general case. Java spec was always way stricter in what kind of optimizations are allowed (thus floating point code on JVM is usually much slower than optimized floating point code on LLVM)
That is, Java spec is strict even in non-strictfp mode. So, they’ve actually deprecated strictfp, because nowadays (with SSE instructions available everywhere) it does not matter anymore.