Hello, I hitting a super weird wall in Kotlin/JS (...
# javascript
l
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:
Copy code
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
Are you saying the problem is the string representation? Because those are all the same number.
l
Well, the computations that I have afterwards drift further, leading to a test that fails on some platforms
j
But there is no drift in those numbers. They are the same.
l
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
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.
👀 1
l
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
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
Ok, found where the sin drift starts:
sin(0.8314090181354269)
What do you get on your machine on Kotlin/JVM?
c
Kotlin playground: sin(0.8314090181354269)
l
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
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:
Copy code
import kotlin.math.*

fun main() {
    println(sin(0.8314090181354269).toRawBits())
}
l
I get
4604830472895931656
locally
c
Playground has:
4604830472895931655
l
Yup, one bit off
c
yep
l
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
oh let’s not get into comparing weird things, not enough 🥃 for that. What is your local machine / JVM?
l
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 🙏🏼
👍 1
c
On my MacBook M1, aarch64, Corretto-17.0.6.10.1:
4604830472895931656
l
So same as me
c
think that’s the same as what you had locally, yea.
l
I'm going to test on my phone
My phone and my laptop agree
Android 13 and macOS
c
that’s positive. Is it just the playground that is off, or other situations?
l
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
Is this an accurate summary so far:
Copy code
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:
Copy code
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
You can just edit your previous message and remove the other one instead of pasting the same thing just to add one line.
c
looks like godbolt is Linux in Intel.
l
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
^^^ that’s not the same test, sin(…).
l
That's a different representation, but we've seen it's not the same number in both cases.
c
sure, but trying to keep the test consistent and identify which platforms are different, This is what we have so far:
Copy code
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
Please, don't make me read that list again with all those digits
c
it’s just the platform and the results… ???
l
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
yes, updated with information as we test.
it shows that the JVM on MacOS is different than all the rest.
l
Nope, Android agrees with macOS
c
Do we know if that Android is on an arm chipset?
l
Yes it is
c
yea. so it looks like the JVM/ARM combo is different somehow.
l
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
yea. so arm/jvm is different … somehow … but how to take that further.
haven’t had any luck searching on this yet.
l
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
Here’s golang on my Macbook / aarch64:
4604830472895931655
. Same as JVM/Intel etc.
l
Might look into this "tomorrow"
c
So not really the CPU - the JVM/CPU combination.
l
Still kinda is
But there are other factors as well
Interesting nonetheless
c
well, the CPU can get the right answer, as shown with golang. But the JVM on the same CPU doesn’t.
l
Not just the JVM
ART as well
I guess it's more… artsy?
c
sorry, what is ART?
l
Android RunTime
It's not an actual JVM
c
yea
l
A bunch of things are different in ART, but not this kind of things, usually
c
e
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
yep, looks that way. even between JVM revisions as they may change the internals to use different opcodes which may adjust the precision.
e
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)
👍🏻 2
👍 1
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
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
where do you see that? it definitely goes to
Math.sin
from what I can see
c
Clicked-thru from Kotlin math.sin to
Copy code
@SinceKotlin("1.2")
@InlineOnly
public actual inline fun sin(x: Double): Double = nativeMath.sin(x)
to:
Copy code
@IntrinsicCandidate
    public static double sin(double a) {
        return StrictMath.sin(a); // default impl. delegates to StrictMath
    }
Kotlin 1.8.10.
Copy code
import java.lang.Math as nativeMath

public actual inline fun sin(x: Double): Double = nativeMath.sin(x)
c
wowsers. yes that is right. let me see if the click-thru lies…
no, it’s right. Math.sin -> StringMath.sin
*StrictMath.sin
e
are you looking in java.lang.Math? that is outside of kotlin-stdlib and may be implemented differently on different JVMs
c
that’s right. it is documented as being non-exact so precision will vary.
e
kotlin-stdlib doesn't even promise 1.ulp
c
nor are there cross-platform consistency promises.
e
oh if you're looking at OpenJDK:
Copy code
@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
💯 . Even with @Strictfp the results vary between platforms.
e
strictfp is orthogonal to this (and doesn't even do anything on modern JVMs)
c
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
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
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
well that's unsurprising since they use completely different implementations
(
sin
isn't an instruction in ASM, it's library code)
c
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
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
👍 1
some more floating-point fun (just in plain multiply and add, no trig): https://www.kdab.com/fma-woes/
🤦 1
c
lol,
stubs.go
has the directive
Copy code
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
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
exactly.
e
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).
2
👍🏻 1
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.
👍🏻 1
e
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
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.
🙄 1
blob open mouth 2
@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
yes, and I posted links to the implementations previously
e
Right. And the spec only requires them to be correct up to the last bit.
e
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
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)
😮 1
e
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
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)
😮 1
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.