Floating Point in the Browser, Part 1: Impossible Expectations

A few years ago I did a lot of thinking and writing about floating-point math. It was good fun, and I learned a lot in the process, but sometimes I go a long time without actually using that hard-earned knowledge. So, I am always inordinately pleased when I end up working on a bug which requires some of that specialized knowledge. Here then is the first of (at least) three tales of floating-point bugs that I have investigated in Chromium (part two is here, part three is here). This is a short one.

Apparently the official JSON logo?The title of the bug was “JSON Parses 64-bit Integers Incorrectly”, which doesn’t immediately sound like a floating-point or browser issue, but it was filed in crbug.com and I was asked to take a look. The simplest version of the repro is to open the Chrome developer tools (F12 or Ctrl+Shift+I) and paste this code into the developer console:

json = JSON.parse(‘{“x”: 2940078943461317278}’); alert(json[‘x’]);

Pasting unknown code into the console window is a good way to get pwned but this code was simple enough that I could tell that it wasn’t malicious. The bug report was nice enough to have included the author’s expectations and actual results:

What is the expected behavior?
The integer 2940078943461317278 should be returned.
What went wrong?
The integer 2940078943461317000 is returned instead.

The “bug” was actually reported on Linux and I work on Chrome for Windows but the behavior is cross-platform and I have some floating-point expertise so I investigated.

The reason that this behavior of integers is a potential “floating-point” bug is because JavaScript doesn’t actually have an integer type. That is also the reason that this isn’t actually a bug.

Speaking of big things, this is a pyramid!The input number is quite big. It is about 2.9e18. And that is the problem. Since JavaScript doesn’t have an integer type it uses IEEE-754 floating-point double-precision for its numbers. This binary floating-point format has a sign bit, an 11-bit exponent, and a 53-bit mantissa (yes, that’s 65 bits, the hidden implied one is magic). This double type works well enough for storing integers that many JavaScript programmers never notice that there isn’t an integer type, but very large numbers break the illusion.

A JavaScript number can exactly store any integer up to 2^53. After that it can hold all even integers up to 2^54. And after that it can hold all multiples of four up to 2^55, and so on.

The problematic number expressed in base-2 scientific notation, it is about 1.275 * 2^61. At that range very few integers can be expressed – the gap between representable numbers is 512. Here are three relevant numbers:

  • 2,940,078,943,461,317,278 – the number the bug filer wanted to store
  • 2,940,078,943,461,317,120 – the closest double to that number (smaller)
  • 2,940,078,943,461,317,632 – the next closest double to that number (larger)

The number in question is bracketed by two doubles and the JSON module (like JavaScript itself, or any other correctly implemented text-to-double conversion function) did the best that it could and returned the closest double. To be clear, the number that the bug filer wanted to store cannot be stored in the built-in JavaScript numeric type.

So far so good. If you push the limits of the language you need to know more about how it works. But there is one remaining mystery. The bug report said that the number returned was actually this one:

2,940,078,943,461,317,000

That is peculiar because it is not the input number, it is not the closest double and, in fact, it is not even a number that is representable as a double!

Mystery Bay, NSW, AustraliaThis mystery is also explained by the JavaScript specification. The spec says that when printing a number the implementation should print enough digits to uniquely identify it, and then no more. This is handy when printing numbers like 0.1 which cannot be exactly represented as a double. For instance, if JavaScript mandated that 0.1 should be printed as the value stored then it would have to print:

0.1000000000000000055511151231257827021181583404541015625

This would be accurate but it would just confuse people without adding any value. The exact rules can be found here (search for “ToString Applied to the Number Type”). I don’t think they actually require the trailing zeroes, but they certainly allow them.

So, the JavaScript runtime prints 2,940,078,943,461,317,000 because:

  • The value of the original number was lost when it was stored as a JavaScript number
  • The printed number is close enough to the stored value to uniquely identify it
  • The printed number is the simplest number that uniquely identifies the stored value

Working-as-intended, not a bug, closed as WontFix. The original bug can be found here.

Reddit discussion is here, twitter announcement here.

About brucedawson

I'm a programmer, working for Google, focusing on optimization and reliability. Nothing's more fun than making code run 10x as fast. Unless it's eliminating large numbers of bugs. I also unicycle. And play (ice) hockey. And sled hockey. And juggle. And worry about whether this blog should have been called randomutf-8. 2010s in review tells more: https://twitter.com/BruceDawson0xB/status/1212101533015298048
This entry was posted in Chromium, Computers and Internet, Floating Point and tagged , . Bookmark the permalink.

21 Responses to Floating Point in the Browser, Part 1: Impossible Expectations

  1. asdf says:

    Part of me thinks JS needs proper integer types, but the other part thinks this will make everything explode with type confusion bugs, no matter what the specification would look like.

  2. Yes, this is surprising. Note that if the output precision were fixed to allow round-tripping (i.e. getting the initial value when re-reading the printed one), one could also have undesirable output, like in zsh: while “echo $((1.0))” and “echo $((1.2))” output “1.” and “1.2” respectively, “echo $((1.1))” outputs “1.1000000000000001”. This would be less disturbing if the trailing zeros were kept for 1.0 and 1.2 (thus making the fixed output precision explicit).

    Concerning JavaScript, I would have preferred a rule giving either “2940078943461317120” or “2.940078943461317e+18”. I think that this would make more sense for the end user, though in the former case, the number of “significant” digits (i.e. ignoring the trailing zeros) would not be minimum, and in the latter case, the string length would not be minimum. So I suspect that the rule was chosen to get both a minimum string length for the input number (I have not checked that, though) and a minimum of significant digits in the output.

  3. Jonathan says:

    Typo? The “the next closest double (larger)” is even smaller and farther away.

  4. Doug Moen says:

    Instead of closing the bug as WontFix, I think the appropriate response is to print integers with a magnitude > 2^53 using scientific notation. Given the expression 2940078943461317278, it is misleading to print the result as 2940078943461317000, because this looks like an exact integer. We should print the value as 2.940078943461317e18 instead. (This bug also existed in my Curv programming language, and that’s how I fixed it.)

    • brucedawson says:

      The spec is a bit inscrutable but I think it does not allow scientific notation to be used in this case. That means that making the suggested change would require changing the spec, with all the associated backwards compatibility concerns.

      • That’s case 6 of the spec: If k ≤ n ≤ 21, return the string-concatenation of:
        • the code units of the k digits of the decimal representation of s (in order, with no leading zeroes)
        • n − k occurrences of the code unit 0x0030 (DIGIT ZERO)

        So, you can have trailing zeros that do not correspond to “significant” digits. Scientific notation will be used only with 22 digits or more.

      • Doug Moen says:

        Just to clarify, the approach that I favour is (1) to print integers whose magnitude is <= 2^53 using integer notation, and (2) to print "large integers" outside this range using scientific notation.

        Javascript conforms to part (1), but it doesn't conform to part (2). "Large integers" are sometimes printed in integer notation, and sometimes printed in scientific notation. As Vincent says, the threshold for switching from integer to scientific notation happens when you need 22 digits, even though 64 bit floats have a 53 bit mantissa and only 16 significant digits.

        • brucedawson says:

          Right, and I’m not saying that you’re _wrong_, I’m just saying that changing the JavaScript spec at this point was rather more than I was willing to take on, and would likely cause myriad compatibility problems. That ship has sailed through the barn doors so closing them now would be like burning the bridge after you cross it before you get to it.

  5. Pingback: Floating Point in the Browser, Part 1: Impossible Expectations | Hacker News | AnotherFN.com - Another FN

  6. Pingback: Floating Point in the Browser, Part 1: Impossible Expectations - Techie Stuff PR

  7. Steven Don says:

    Not just browsers that have these problems. I had a very similar problem with an embedded Javascript interpreter in a C++ game engine years ago. Using Date.now() for timing completely killed it. After about an hour of debugging and getting really strange results, I learned that Direct3D set the floating point control word so that the “double” datatype has lower precision. All the timestamps got rounded, making Date.now() always return the same timestamp.

    The fix was to add the D3DCREATE_FPU_PRESERVE flag when creating the Direct3D device.

    • brucedawson says:

      Well, anything that uses IEEE floating-point math (or, really, any general purpose floating-point format for computers) is going to have these problems. The D3D habit of changing the precision to float precision was particularly vile. Luckily that has gone away – SSE math doesn’t have a global precision setting.

      • AFAIK, the dynamic precision is specific to x87. Under Linux, it is used by default on 32-bit x86, but not on x86_64 (except for the “long double” type in C), where SSE is always available. The fact that D3D changed the precision to single precision yielded a security issue in the JavaScript engine of Firefox in the past (CVE-2006-6499): https://bugzilla.mozilla.org/show_bug.cgi?id=358569

        • brucedawson says:

          Yep, a precision setting is an x87 specific feature. Well, I think the 68000 floating-point coprocessor had it as well, but that’s been gone for a long time.
          On Windows the x87 registers are still used to return floating-point results (that’s part of the ABI and cannot be changed easily) but otherwise tend to be mostly unused. Mostly. 64-bit processes generally don’t use x87 at all (although Fractal eXtreme is one exception).

  8. Hi,

    I ran into the very same issue when programming a smart contract in Solidity for a blockchain. My intent was to convert the fragments of an Ether (ethereum cryptocurrency) into Euro equivalents. I got several errors that the javascript engine of nodejs (V8) or the browser couldn‘t hold the number when I based my calculations on the Ether‘s Wei.

    Since Solidity doesn‘t have support for floating point numbers per se, and I was dealing with floating numbers across the board (it was about caclulating the energy consumption of a living quartier, as well as the amount of renewable energy being produced or to be bought), I had to get rid of fractions…

    What a pain that was. Eventually, it worked, but this is the first time in 20 years that I really hit the barriers of the standard number resolution in a programming language.

    A programming language which is the standard for frontend-development and thus should be quite flexible by nature…

  9. Kirill Dmitrenko says:

    Parsing integers larger than 2^53 to BigInt may be another solution:)

  10. CdrJameson says:

    Scientific notation can have its own problems, one of which I ran into this week when a sending function used it with larger numbers, but a receiving function didn’t understand it.
    1.4324e12 suddenly becomes 1.4324 and oops!
    Whenever a value went over the e threshold it dropped like a stone.
    Even worse, the next number in the input then became 12.

  11. Logi Bones says:

    Very informative. Thanks for sharing this.

  12. Эрнест Хлынов says:

    If JavaScript can not represent number it uses closest double. But what if the number is equally far from two doubles? For example, 18014398509481990 is at the distance of 2 from both 18014398509481988 and 18014398509481992, but JS falls back to 18014398509481992, using 18014398509481990 as representative value of whole group (thanks to last zero).

    ——— number ——————— JS ——————– double———–
    18014398509481987
    18014398509481988 18014398509481988 18014398509481988
    18014398509481989

    18014398509481990 18014398509481990
    18014398509481991
    18014398509481992 —————————- 18014398509481992
    18014398509481993
    18014398509481994

    It seems that JS prefer 18014398509481992 over 18014398509481988 because its mantissa have less significant digits in binary format:
    18014398509481992 – 50 zeros 1 0
    18014398509481988 – 50 zeros 0 1

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.