JavaScript's Tricky Rounding
April 24th, 2018

JavaScript rounds in a tricky way. It tricked all the engines, and even itself.

Math.round() behaves the same as C’s familiar round with one key difference: it rounds halfways (“is biased”) towards positive infinity. Here is its spec in ES 5.1. It suggests an implementation too:

The value of Math.round(x) is the same as the value of Math.floor(x+0.5)...

And so every engine did that.

Unfortunately, the implementation in the spec does not correctly implement the spec.

One bug is that adding 0.5 can result in precision loss, so that a value less than .5 may round up to 1. Mac user? Try it yourself (Safari 11.0.3):

> /System/Library/Frameworks/JavaScriptCore.framework/Versions/A/Resources/jsc
>>> 0.499999999999999944 < .5
>>> Math.round(0.499999999999999944)
One engine attempted to patch it by just checking for .5:
double JSRound(double x) {
  if (0.5 > x && x <= -0.5) return copysign(0, x);
  return floor(x + 0.5);
However this fails on the other end: when x is large enough that fractional values can no longer be represented, x + 0.5 rounds up to x + 1, so JSRounding a large integer like Math.pow(2, 52) would actually increment it.

What's a correct implementation? SpiderMonkey checks on the high end, and exploits the loss of precision on the low end:

double math_round_impl(double x) {
   if (ExponentComponent(x) >= 52) return x;
   double delta = (x >= 0 ? GetBiggestNumberLessThan(0.5) : 0.5);
   return copysign(floor(x + delta));
fish's attempt just checks high and low:
static const double kIntegerThreshold = 1LLU << 52;
double jsround(double x) {
  double absx = fabs(x);
  if (absx > kIntegerThreshold) {
    // x is already integral
    return x;
  } else if (absx < 0.5) {
    // x may suffer precision loss when adding 0.5
    // round to +/- 0
    return copysign(0, x);
  } else {
    // normal rounding.
    // ensure negative values stay negative.
    return copysign(floor(x + 0.5), x);
which produces surprisingly pleasant assembly, due to the compiler's fabs() and copysign() intrinsics.

The ES6 spec sheepishly no longer suggests an implementation, it just disavows one:

Math.round(x) may also differ from the value of Math.floor(x+0.5) because of internal rounding when computing x+0.5...

JavaScript presumably rounds this odd way to match Java, and so the only engine to get it right out of the gate is Rhino, which simply calls back to Java's Math.round. Amusingly Oracle fell into the same trap with Rhino's successor Nashorn. Round and round we go!