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:

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

Closing Thoughts

I don't really know what to think of this instruction. It's neat, but given that modern engines already avoid most of these conversions, I'm not sure how much it matters in practice. x86_64 never bothered adding an equivalent, and JavaScript runs just fine on it.

Still, I find it amusing that JavaScript is quirky enough to have its own CPU instruction.