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))))
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))))
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.