TIL: Why ARM Has a JavaScript Instruction
ARM has this very specific instruction, FJCVTZS, doing "Floating-point Javascript Convert
to Signed fixed-point, rounding toward Zero". For example, it converts
Float64(42.99) to
Int32(42).
JavaScript's Special Treatment
JavaScript uses double-precision floating-point (Float64) for all numbers
(i.e., of type number). Yet many operations require 32-bit
integers: bitwise operations, array indexing, typed array access. The
language runtime must constantly convert Float64 to Int32 during normal
execution flow of a typical JavaScript program.
Such a conversion cannot be performed with simple casting like this:
int32_t convert(double x) {
return (int32_t)x;
}
Compiled ARM-64 assembly:
fcvtzs w0, d0
ret
This compiles to FCVTZS (without the J), which
is the "standard" and expected instruction to perform such conversion.
But JavaScript cannot use it as is. Casting a double to int32 has
undefined behavior for edge cases such as:
NaN, Infinity, and values larger than 231-1. It can return anything it likes, and it does:
#include <stdio.h>
#include <stdint.h>
#include <math.h>
int32_t convert(double x) {
return (int32_t)x;
}
int main() {
const double overflow = 293923492094823.43;
printf("Cast 42.99: %d\n", convert(42.99));
printf("Cast NaN: %d\n", convert(NAN));
printf("Cast Infinity: %d\n", convert(INFINITY));
printf("Cast %f: %d\n", overflow, convert(overflow));
}
Output:
Cast 42.99: 42 // OK
Cast NaN: 42 // garbage
Cast Infinity: 42 // garbage
Cast 293923492094823.437500: -241844128 // garbage
The ECMAScript specification defines a
ToInt32
operation with explicit steps to get a 32-bit integer of a 64-bit
floating-point
number. Since not all Float64 values fit in 32 bits, the
specification is explicit about how to handle the special edge cases:
NaN→ 0±Infinity→ 0- Out-of-range values wrap modulo 232
ARM's FJCVTZS performs the conversion according to
ECMAScript's ToInt32 definition. It has a defined behavior
for all inputs.
Before ARMv8.3-A introduced it, JavaScript engines implemented the semantic in software, checking for special cases and then fixing up the result. Since conversions from Float64 to Int32 are extremely common in typical JavaScript programs, ARM added dedicated silicon for it.
x86-64 has no equivalent. JavaScript engines have to use CVTTSD2SI and handle the special cases JavaScript requires in
software.
Note: JavaScript runtimes can use 32-bit integers (or other representations) internally to avoid costly conversions, provided ECMAScript-compliant behavior is preserved. That's what V8 does with "Small Integers" (SMI), and other state-of-the-art JavaScript engines like JavaScriptCore have similar techniques.
Such techniques are certainly a far bigger win than a special conversion
instruction. FJCVTZS helps when converting a genuine Float64
that couldn't be represented as a 32-bit integer (or SMI) in the first
place.
Using FJCVTZS From C
It's possible to use FJCVTZS from C, so we can compare the
results.
#include <arm_acle.h>
int32_t convert_js(double x) {
return __jcvt(x);
}
int main() {
const double overflow = 293923492094823.43;
// Using FCVTZS
printf("Cast 42.99: %d\n", convert(42.99));
printf("Cast NaN: %d\n", convert(NAN));
printf("Cast Infinity: %d\n", convert(INFINITY));
printf("Cast %f: %d\n", overflow, convert(overflow));
printf("\n");
// Using FJCVTZS
printf("FJCVTZS 42.99: %d\n", convert_js(42.99));
printf("FJCVTZS NaN: %d\n", convert_js(NAN));
printf("FJCVTZS Infinity: %d\n", convert_js(INFINITY));
printf("FJCVTZS %f: %d\n", overflow, convert_js(overflow));
}
Cast 42.99: 42
Cast NaN: 42
Cast Infinity: 42
Cast 293923492094823.437500: -241844128
FJCVTZS 42.99: 42
FJCVTZS NaN: 0
FJCVTZS Infinity: 0
FJCVTZS 293923492094823.437500: 1700160359
We can verify this in JavaScript directly. Double bitwise-not (~~) forces ECMAScript ToInt32:
~~42.99 // = 42
~~NaN // = 0
~~Infinity // = 0
~~293923492094823.43 // = 1700160359