Chapter 4

Numbers

WebAssembly has four number types: i32, i64, f32 and f64. (A fifth, v128, holds SIMD vectors and has a chapter of its own.) That is a short list for a language that wants to compile C, Rust, or your language, with its signed and unsigned integers of four sizes, its booleans, characters and enums. This chapter shows how the short list covers the long one, and the exact rules for overflow, division, conversions and floating point, which are the kind of detail a compiler has to get exactly right.

#Integers have no sign4.1

An i32 is 32 bits. It is not signed and it is not unsigned. The instructions decide how to interpret the bits, and where the interpretation matters there are two versions of the instruction, with _s or _u on the end:

(module
  (func (export "div") (param i32 i32) (result i32)
    (i32.div_s (local.get 0) (local.get 1)))
  (func (export "divu") (param i32 i32) (result i32)
    (i32.div_u (local.get 0) (local.get 1))))
called as div(-7, 2) and divu(-7, 2)
-3
2147483644

The same bits, 0xfffffff9, are −7 to div_s and 4,294,967,289 to div_u. The host prints results as signed, which is why the unsigned quotient, 2,147,483,644, happens to look sensible.

Where the interpretation does not matter there is only one instruction. Addition, subtraction and multiplication give the same bits whether you think of the operands as signed or unsigned (that is the beauty of two's complement), so there is one i32.add, one i32.sub and one i32.mul. Equality is the same, so there is one i32.eq.

Here is every i32 instruction, grouped by what it does. i64 has exactly the same set.

Kind Instructions
arithmetic add sub mul div_s div_u rem_s rem_u
bitwise and or xor shl shr_s shr_u rotl rotr
counting clz ctz popcnt
tests eqz
comparison eq ne lt_s lt_u gt_s gt_u le_s le_u ge_s ge_u
sign extension extend8_s extend16_s (and i64.extend32_s)

shr_s is the arithmetic right shift, which copies the sign bit in; shr_u is the logical one, which shifts in zeros. clz and ctz count leading and trailing zero bits, and popcnt counts the bits that are set. Every one of these exists on modern hardware as a single instruction, and engines use it.

#Wrapping and trapping4.2

Integer arithmetic wraps around. There is no overflow flag and no undefined behaviour:

(module
  (import "env" "print_i32" (func $print (param i32)))
  (func (export "main")
    (call $print (i32.add (i32.const 0x7fffffff) (i32.const 1)))
    (call $print (i32.mul (i32.const 65536) (i32.const 65536)))
    (call $print (i32.sub (i32.const 0) (i32.const 1)))))
-2147483648
0
-1

Shift counts are taken modulo the width, so shifting an i32 by 33 is shifting it by 1. That matches x86, but it does not match C, where it is undefined, or Rust, where it panics in debug builds. If your language has a rule for oversized shifts, you have to implement it yourself:

(module
  (import "env" "print_i32" (func $print (param i32)))
  (func (export "main")
    (call $print (i32.shl (i32.const 1) (i32.const 33)))
    (call $print (i32.shr_s (i32.const -64) (i32.const 2)))
    (call $print (i32.shr_u (i32.const -64) (i32.const 28)))))
2
-16
15

Only two integer operations can fail, and both trap: dividing by zero, and dividing the most negative number by −1, whose result does not fit:

(module
  (func (export "main") (result i32)
    (i32.div_s (i32.const 1) (i32.const 0))))
RuntimeError: divide by zero
(module
  (func (export "main") (result i32)
    (i32.div_s (i32.const -2147483648) (i32.const -1))))
RuntimeError: divide result unrepresentable

A trap stops the WebAssembly code immediately and throws a WebAssembly.RuntimeError in the JavaScript that called it. There is no way to catch a trap from inside WebAssembly. Chapter 17 covers traps in detail.

The remainder, rem_s, does not trap on the most negative number divided by −1: the remainder is 0, which fits. It still traps on zero. Its sign follows the dividend, as in C, Rust and Go:

(module
  (import "env" "print_i32" (func $print (param i32)))
  (func (export "main")
    (call $print (i32.rem_s (i32.const -7) (i32.const 2)))
    (call $print (i32.rem_s (i32.const 7) (i32.const -2)))
    (call $print (i32.rem_s (i32.const -2147483648) (i32.const -1)))))
-1
1
0

If your language's % is a modulus whose sign follows the divisor, as in Python, you need a fix-up after rem_s.

#Small integers4.3

Since there are only 32- and 64-bit integers, a compiler has to represent an 8- or 16-bit value in an i32, and decide what the upper bits hold. There are two common approaches.

The first keeps every small value normalised: the upper bits always hold the sign extension (for signed types) or zeros (for unsigned types). After every operation that could disturb them, the compiler normalises again. For unsigned types that is an and with a mask. For signed types it is extend8_s or extend16_s, which take the low 8 or 16 bits and copy their top bit upwards:

(module
  (import "env" "print_i32" (func $print (param i32)))
  (func (export "main")
    ;; a u8 holding 200, plus 100: 300 must wrap to 44
    (call $print (i32.and (i32.add (i32.const 200) (i32.const 100)) (i32.const 0xff)))
    ;; an i8 holding 100, plus 100: 200 must wrap to -56
    (call $print (i32.extend8_s (i32.add (i32.const 100) (i32.const 100))))))
44
-56

The second approach lets the upper bits hold garbage, and normalises only where it matters: before a comparison, a division, a right shift, a conversion, or a store that will be read back as a wider type. Addition, subtraction, multiplication, and the bitwise operations never let garbage in the upper bits affect the lower ones, so they need no fix-up.

The second approach emits less code, and is what LLVM does. The first is simpler to get right. Either is fine, as long as you pick one and test the edges.

Booleans become i32s holding 0 or 1, which is exactly what the comparison instructions produce. i32.eqz is logical not. A char is an i32 holding a code point. Enums are i32s, or i64s if their values need it.

#64-bit integers4.4

i64 has the same instructions as i32. Mixing the two needs explicit conversions:

Instruction Does
i32.wrap_i64 keeps the low 32 bits
i64.extend_i32_s sign-extends to 64 bits
i64.extend_i32_u zero-extends to 64 bits
(module
  (import "env" "print_i64" (func $print (param i64)))
  (func (export "main")
    (call $print (i64.extend_i32_s (i32.const -1)))
    (call $print (i64.extend_i32_u (i32.const -1)))
    (call $print (i64.mul (i64.const 4294967296) (i64.const 4294967296)))))
-1
4294967295
0

In JavaScript, an i64 crosses the boundary as a BigInt, since a JavaScript number cannot hold every 64-bit integer exactly. That is why calling an i64 function from JavaScript needs arguments like 5n rather than 5:

(module
  (func (export "square") (param i64) (result i64)
    (i64.mul (local.get 0) (local.get 0))))
called as square(3000000000n) and square(4000000000n)
9000000000000000000
-2446744073709551616

The first square fits in 63 bits. The second, 1.6 × 10¹⁹, does not, so it wrapped to 1.6 × 10¹⁹ − 2⁶⁴. When you write tests for wrapping arithmetic, compute the expected values with a language that has arbitrary-precision integers rather than by hand. In Python:

x = 4_000_000_000 ** 2
print((x + 2**63) % 2**64 - 2**63)
-2446744073709551616

#Floating point4.5

f32 and f64 are IEEE 754 single and double precision, with round to nearest, ties to even. The instructions are the ones you would expect:

Kind Instructions
arithmetic add sub mul div sqrt
rounding ceil floor trunc nearest
sign abs neg copysign
comparison eq ne lt gt le ge
other min max

nearest rounds to the nearest integer, ties to even, unlike C's round, which rounds ties away from zero. Comparisons follow IEEE: every comparison with a NaN is false except ne. min and max return NaN if either operand is NaN, and treat −0 as less than +0. Those are not the rules of C's fmin or of x86's minss, which is why f32.min usually compiles to several machine instructions rather than one.

(module
  (import "env" "print_f64" (func $print (param f64)))
  (func (export "main")
    (call $print (f64.div (f64.const 1) (f64.const 3)))
    (call $print (f64.nearest (f64.const 2.5)))
    (call $print (f64.nearest (f64.const 3.5)))
    (call $print (f64.min (f64.const -0) (f64.const 0)))
    (call $print (f64.div (f64.const 1) (f64.const 0)))
    (call $print (f64.sqrt (f64.const -1)))))
0.3333333333333333
2
4
-0
Infinity
NaN

Floating-point division by zero does not trap. It gives an infinity or a NaN, as IEEE says.

f32 values are rounded to single precision after every operation, so they behave exactly like C's float with strict semantics. When an f32 crosses into JavaScript it becomes a double holding the same value, which is why it prints with so many digits:

(module
  (import "env" "print_f32" (func $print (param f32)))
  (func (export "main")
    (call $print (f32.const 0.1))
    (call $print (f32.add (f32.const 16777216) (f32.const 1)))))
0.10000000149011612
16777216

The literal syntax for floats is rich, since a compiler writing tests needs to be able to write any value exactly: decimal (1.5, 1e-3), hexadecimal (0x1.8p1, which is 3), inf, -inf, nan, and NaNs with a chosen payload (nan:0x200000).

#Conversions4.6

Converting between integers and floats has a large family of instructions, with names that say both types:

Instruction From → to Notes
f64.convert_i32_s i32 → f64 also _u, and every int/float pair
i32.trunc_f64_s f64 → i32 traps on NaN or out of range
i32.trunc_sat_f64_s f64 → i32 saturates instead: NaN gives 0
f32.demote_f64 f64 → f32 rounds to nearest
f64.promote_f32 f32 → f64 exact
i32.reinterpret_f32 f32 → i32 the same bits, a different type
f32.reinterpret_i32 i32 → f32

The two kinds of float-to-integer conversion need a closer look. The plain trunc instructions trap if the value is NaN or does not fit:

(module
  (func (export "main") (result i32)
    (i32.trunc_f64_s (f64.const 3e9))))
RuntimeError: float unrepresentable in integer range

The trunc_sat instructions, added in WebAssembly 2.0, never trap. Values too large become the largest integer, too small the smallest, and NaN becomes 0:

(module
  (import "env" "print_i32" (func $print (param i32)))
  (func (export "main")
    (call $print (i32.trunc_sat_f64_s (f64.const 3e9)))
    (call $print (i32.trunc_sat_f64_s (f64.const -3e9)))
    (call $print (i32.trunc_sat_f64_s (f64.const nan)))
    (call $print (i32.trunc_sat_f64_s (f64.const -2.9)))))
2147483647
-2147483648
0
-2

Pick the one that matches your language. Rust's as saturates, so a Rust compiler emits trunc_sat. C leaves out-of-range conversions undefined, so either will do, and trunc_sat avoids a trap. Java's conversions also saturate. If your language promises an error on overflow, use the trapping form, or test the range yourself first and raise your own error.

The reinterpret instructions are free: they change only the type, not the bits. They are how you implement bit tricks on floats, or a union of a float and an integer, without going through memory:

(module
  (import "env" "print_u32" (func $print (param i32)))
  (func (export "main")
    (call $print (i32.reinterpret_f32 (f32.const 1.0)))
    (call $print (i32.reinterpret_f32 (f32.const -0.0)))))
1065353216
2147483648

That is 0x3f800000 and 0x80000000.

#What you have seen4.7

Four number types cover every scalar in a source language. Integers are bits, and the instruction says whether they are signed. Arithmetic wraps, shifts take their count modulo the width, and only division can trap. Floats are IEEE, deterministic except for NaN bits. Conversions are explicit, and float-to-int comes in a trapping and a saturating form.

The next chapter puts instructions into functions and calls them.