iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🧮

How Accurate Are Numerical Calculations in JavaScript?

に公開
1

JavaScript is a programming language used for various purposes and comes equipped with a full set of features to handle them. Among these features is the capability for numerical calculations.

In numerical calculations, especially involving decimals, the accuracy of calculation results often becomes an issue. In programming, errors occur in results due to various factors. As an example, in the case of floating-point numbers, because the number of bits available to represent a value is finite, the calculation result may differ from the true value (the mathematically correct result)—this is known as rounding error. For example, JavaScript numbers are IEEE 754 double-precision floating-point numbers (commonly called double), but a double cannot accurately represent the result of 1 / 10 (0.1). The resulting floating-point number (written out in decimal) is 0.100000000000000005551115123125782702118158340454101562, which is not perfectly 0.1. However, this is the limit of what can be expressed in a double, and since it is impossible to represent 0.1 more accurately in the world of doubles, it is unavoidable.

Now, how accurate are the results of various numerical calculations in the JavaScript programming language? In this article, we will examine the ECMAScript Specification, the document that defines the JavaScript language specification, to investigate what level of accuracy is guaranteed in JavaScript numerical calculations.

Arithmetic Operations

First, let's look into the basic arithmetic operations. How the expression a + b is evaluated in JavaScript is defined in 12.8.3.1 Runtime Semantics: Evaluation. Since this section also includes string concatenation using +, we proceed to find the part dealing with numbers, which leads us to 6.1.6.1.7 Number::add.

When examining normal cases, excluding the handling of NaN, Infinity, and 0, it states the following:

In the remaining cases, where neither an infinity, nor a zero, nor NaN is involved, and the operands have the same sign or have different magnitudes, the sum is computed and rounded to the nearest representable value using IEEE 754-2019 roundTiesToEven mode. If the magnitude is too large to represent, the operation overflows and the result is then an infinity of appropriate sign. The ECMAScript language requires support of gradual underflow as defined by IEEE 754-2019.

Thus, for addition, it appears that the most accurate result possible within the representable range of a double is obtained.

In the case of subtraction (6.1.6.1.8 Number::subtract), it is specified that the result of x - y is equal to the result of x + (-y). The calculation of (-y) (6.1.6.1.1 Number::unaryMinus) simply changes the sign, so no error occurs there.

For multiplication (6.1.6.1.4 Number::multiply), division (6.1.6.1.5 Number::divide), and remainder (6.1.6.1.6 Number::remainder), the results are also defined to be rounded to the nearest representable value using roundTiesToEven.

All of these arithmetic operations are specified to produce results exactly as defined by IEEE 754-2019.

Exponentiation

Exponentiation is represented by x ** y, an operation that calculates x to the power of y.

Quoting from 6.1.6.1.3 Number::exponentiate:

It returns an implementation-approximated value representing the result of raising base to the power exponent, subject to the following requirements:

Unlike the previous sections, the concept of an implementation-approximated value has appeared. Note that the "following requirements" part defines behavior regarding special values such as 0, Infinity, and NaN.

Implementation-approximated is defined as follows:

An implementation-approximated facility is one that defers its definition to an external source while recommending an ideal behaviour. While conforming implementations are free to choose any behaviour within the constraints put forth by this specification, they are encouraged to strive to approximate the ideal. Some mathematical operations, such as Math.exp, are implementation-approximated.

Roughly speaking, it means that the engine should aim to produce the most accurate result possible, but the specification does not guarantee the accuracy of the result.

In the case of exponentiation, the constraints defined by the specification refer to the behavior regarding 0, Infinity, NaN, etc., mentioned in the document; for normal results, it is only defined as "an implementation-approximated value representing the result of the power calculation."

In other words, taken to an extreme, even if an implementation decides that "calculating the mantissa is a hassle, so 3 ** 2 is 8," it would not be a violation of the ECMAScript specification. In reality, such extreme implementations probably don't exist, but practically speaking, you should not expect results to be accurate down to the last bit.

Math Functions

In JavaScript, various mathematical calculation functions are provided as standard through the Math object. Let's see how accurate these are.

Math.abs

This function returns the absolute value of a given number (20.3.2.1 Math.abs). Since it only handles the sign, the result is accurate.

Math.acos, Math.acosh, Math.asin, Math.asinh, Math.atan, Math.atanh, Math.atan2

These are inverse trigonometric functions. Math.atan2 is particularly useful. All of these results are implementation-approximated.

However, for Math.acosh, Math.asinh, and Math.atanh, it is specified that if the absolute value of the input is greater than 1, the return value becomes NaN. It seems they at least care about the domain of the function. Additionally, cases where the result becomes 0 (such as Math.acos(1), Math.asin(0), Math.atan(0), etc.) are specifically defined. This is likely because there are two types of 0: +0 and -0. For example, Math.asin(-0) becomes -0 instead of 0.

In other words, an implementation that returns \frac{\pi}{4} for all "awkward angles" because it's too much trouble to calculate would not be a violation of the specification.

Math.cbrt, Math.sqrt

These are the cube root and square root functions. Except for cases where the result is 0, NaN, or Infinity, these are also implementation-approximated.

This means that a processing engine where "calculating decimals is hard, so Math.sqrt(5) is 2" would still be spec-compliant.

Math.ceil, Math.floor, Math.round, Math.trunc

These are functions that convert decimals to integers. ceil rounds up, floor rounds down, Math.round rounds to the nearest integer, and Math.trunc rounds toward zero.

Quoting from the specification for Math.ceil (20.3.2.10 Math.ceil), for an input n, it is defined as follows. This means the most accurate value (within the range representable by JavaScript numbers) is returned. That's a relief. The other three functions are defined similarly.

Return the smallest (closest to -∞) integral Number value that is not less than n.

Math.clz32

This calculation treats the given numeric value as an unsigned 32-bit integer and counts the number of leading zero bits. Since it is a bitwise operation, the result is accurate.

Math.cos, Math.cosh, Math.sin, Math.sinh, Math.tan, Math.tanh

These are trigonometric functions. As with the inverse trigonometric functions, all results are implementation-approximated values.

In other words, a processing engine that says "the result of Math.cos is between 0 and 1 anyway, so let's just return 0.5 for everything" would still be spec-compliant. Furthermore, since there is no definition stating that the results of Math.cos or Math.sin must fall within the range of [-1, 1], it would not be a violation of the specification even if Math.cos suddenly returned 10.

Math.exp, Math.expm1, Math.pow

Math.exp(x) returns e^x, and Math.expm1(x) returns e^x - 1 (where e is the base of natural logarithms). Math.pow(x, y) is x^y. All results are implementation-approximated. Regarding Math.expm1, the specification (20.3.2.14 Math.exp) states:

The result is computed in a way that is accurate even when the value of x is close to 0.

This means that Math.expm1(x) can be expected to provide a more accurate result than calculating Math.exp(x) - 1. However, since both are implementation-approximated, there are ultimately no guarantees in the specification.

Math.fround

This converts the given numeric value to a float (IEEE-754 single-precision floating-point number). This conversion is defined to use rounding to the nearest even value, resulting in the most accurate representable result. While JavaScript does not have a native float type, this value is obtained by converting from float back to double, during which no error occurs.

Math.hypot

This function takes several arguments x_1, \dots, x_n and returns \sqrt{x_1^2 + \dots + x_n^2}.

The specification states the following:

Implementations should take care to avoid the loss of precision from overflows and underflows that are prone to occur in naive implementations when this function is called with two or more arguments.

In other words, you can expect less error in the calculation by using Math.hypot rather than implementing it yourself as Math.sqrt(x1 ** 2 + ... + xn ** 2).

That being said, the result is still implementation-approximated, so there is no guarantee in the specification.

Math.imul

This function treats the two given arguments as unsigned 32-bit integers and returns the lower 32 bits of their product, interpreted as a signed 32-bit integer. Since it is an integer operation, the result is accurate.

To explain in words, it is hard to understand what this function is intended to do, but MDN says the following:

The Math.imul() function returns the result of the C-like 32-bit multiplication of the two parameters.

In other words, this behavior mimics the behavior of 32-bit integer multiplication in languages like C.

Math.log, Math.log1p, Math.log10, Math.log2

These are logarithmic calculation functions. Math.log is the natural logarithm, and Math.log1p returns the natural logarithm of the given value plus 1. Math.log1p is in a similar situation as Math.expm1.

Again, all results are implementation-approximated, so there are no guarantees for the results.

An implementation where "calculating the natural logarithm is a hassle, so let's just calculate Math.log(8) with base 2 and return 3" would still be spec-compliant.

Math.max, Math.min

These functions return the maximum or minimum value among the given arguments. Since these are not floating-point calculations, there are no errors.

Math.random()

While not a numerical calculation per se, this function returns a random numerical value between 0 and 1.

Quoting the definition from the specification (20.3.2.27 Math.random):

Returns a Number value with positive sign, greater than or equal to +0𝔽 but strictly less than 1𝔽, chosen randomly or pseudo randomly with approximately uniform distribution over that range, using an implementation-defined algorithm or strategy. This function takes no arguments.

The algorithm used for random number generation is considered implementation-defined. In other words, the engine is free to generate random numbers using any method it chooses.

Furthermore, there are no specific definitions regarding the properties of the generated random numbers.

This means that a Math.random that always returns 0 would still be spec-compliant—or so one might think, but that is not quite the case. The specification includes one more sentence, imposing the following constraint:

Each Math.random function created for distinct realms must produce a distinct sequence of values from successive calls.

Think of the term "realm" used here as the execution environment. To give an example in a browser, the inside and outside of an iframe are different execution environments.

In other words, Math.random must produce a different sequence of random numbers each time. A Math.random that always returns 0 would violate this, and thus would not be spec-compliant. That's a bit of a relief.

Math.sign

This function returns a numeric value corresponding to the sign of the given number. Specifically, it returns 1 for a positive number, +0 for +0, -0 for -0, and -1 for a negative number. Since this is all it does, there are naturally no errors.

We have now touched upon all the Math functions.

Math Constants

The Math object also provides several constants. Specifically, these are Math.E, Math.LN10, Math.LN2, Math.LOG10E, Math.LOG2E, Math.PI, Math.SQRT1_2, and Math.SQRT2.

As an example, let's quote the specification for Math.SQRT2 (20.3.1.8 Math.SQRT2):

The Number value for the square root of 2, which is approximately 1.4142135623730951.

At first glance, "approximately 1.4142135623730951" might look like the definition, but it is not. Actually, "The Number value for the square root of 2" is the definition. According to 6.1.6.1 The Number Type, the phrase "The Number value for [real number]" is defined to mean "the floating-point number closest to that real number (using roundTiesToEven)." Therefore, this definition guarantees that Math.SQRT2 represents the most accurate value possible within the representable range.

Bonus: Accuracy of Numerical Calculations in WebAssembly

In recent years, WebAssembly has been making inroads, particularly in the frontend domain. Since WebAssembly also includes instructions for numerical calculations, I investigated how accurate they are. The WebAssembly Specification states, "Floating-point arithmetic follows the IEEE 754-2019 standard." Additionally, the rounding mode is always roundTiesToEven.

The definitions of specific arithmetic instructions such as fsub and fmul state that the result is "rounded to the nearest representable value," showing that the same guarantees as in JavaScript are provided.

The most complex calculation built into WebAssembly is likely fsqrt, but for this, it simply says, "return the square root of 𝑧," and nothing is written regarding precision (unlike implementation-approximated, it doesn't explicitly state that precision is not guaranteed either).

This is a guess based on secondary information as I haven't read the IEEE 754 specification (if you have accurate information, please let me know!), but since square root is included in the basic operations of IEEE 754, it is expected that the square root in WebAssembly possesses maximum accuracy. If you need an accurate square root, it might be better to calculate it in WebAssembly rather than JavaScript.

Summary

In this article, I explained the accuracy of numerical calculations in JavaScript based on the language specification. In JavaScript, almost all calculations more complex than the four basic arithmetic operations are implementation-approximated, and there are no guarantees regarding precision. We are programming in a world where Math.sin might always return 0.5.

  • Q. I understand the specification, but what about actual implementations?
  • A. I'm not very interested in that, so I'll leave it as an exercise for the reader.
GitHubで編集を提案

Discussion

petamorikenpetamoriken

IEEE 754仕様は読んでいないので二次情報からの推測ですが(正確な情報をお持ちの方はぜひ教えてください!)平方根はIEEE 754における基本演算に含まれていることから、WebAssemblyにおける平方根は最大限の正確性を持つことが期待されます。正確な平方根が欲しい場合はJavaScriptではなくWebAssemblyで計算すべきかもしれません。

こちら記事に書いたのですが IEEE 754-2019 に平方根についての命令が記載されています。また WebAssembly 同様 JavaScript でも正確な平方根を返す仕様となりました。

https://zenn.dev/pixiv/articles/407e91e63c089e