WasmKit 0.4.0 is now available.

WasmKit is a WebAssembly runtime written in Swift. Since Swift 6.2 its command line tool ships inside the toolchains distributed at swift.org for Linux and macOS.

This release is mostly about three things:

  1. The interpreter got roughly twice as fast
  2. The engine and its GDB stub now run on microcontrollers via Embedded Swift support
  3. Support for a few more post-MVP features (Relaxed SIMD, Extended Constant Expressions, and Threads) and WASI Preview 1 Threads.

Table of Contents

Performance

Here are the results from the wasmi benchmark suite. The chart plots execution time in version 0.4.0 relative to version 0.3.1. A value of 1.0 means no change in performance; every test case falls below 1.0, showing a consistent speedup (with geometric means of 0.506× on M4 Max, 0.503× on M2 Air, and 0.523× on Ryzen 9 5900HX).

Per-case run time on 0.4.0 divided by run time on 0.3.1, on three machines; every case is below 1.0

We also ran the CoreMark benchmark to see how WasmKit compares to other portable Wasm interpreters across different architectures:

CoreMark across Wasm interpreters on M4 Max

CoreMark across Wasm interpreters on M2 Air

CoreMark across Wasm interpreters on Ryzen 9 5900HX

Why now

WasmKit’s interpreter has been a register machine with direct-threaded dispatch for a while (I wrote up the design when it landed). Since Wasmi v1 was a register machine without direct threading, I assumed that when Robin Freyler’s Wasmi v2.0 post announced they were adding direct dispatch, they would simply be catching up to our performance.

Actually, it wasn’t the case. The benchmark suite that shipped with his post showed that on the same modules, WasmKit 0.3.1 was well behind Wasmi v2.0 across the board. Because we were now using the same fundamental dispatch technique, the huge performance gap suggested that WasmKit 0.3.1 still had plenty of room for optimization!

Optimizations

The 2x speedup consists of accumulated small optimizations. Some of the optimization ideas along the way were inspired by Wasm3 and Wasmi.

Two things are worth setting up first, because the sections below lean on both.

WasmKit does not interpret Wasm directly. The translator lowers each function into an internal register-machine instruction set (WasmKit’s own ISA) in which every operand is a slot in the current stack frame instead of a position on an operand stack. That is what reg:N means in the instruction dumps: negative indices are the frame header and the function’s parameters, non-negative ones are its locals, constants and temporaries. Each instruction consists of a 64-bit head slot followed by zero or more immediate slots. You can print these sequences yourself with wasmkit explore <module>.

Dispatch is direct-threaded, so there is no central loop to look at. The head slot holds the address of the handler implementing that instruction, and every handler ends by tail-calling the next one. The assembly below is therefore individual handlers, compiled for arm64, and a fixed set of values stays live in registers across all of them:

register holds
x0 sp, the base of the current frame (a frame slot is [x0, offset])
x1 pc, the instruction stream (immediates come from [x1, ...])
x2 md, the base of the guest’s linear memory
x3 ms, its size, for bounds checks
x4, d0 the accumulators, covered two sections down

A handler that does not use the integer accumulator leaves x4 dead, so the compiler is free to reuse it. This is why the plain handlers below keep the next handler’s address in x4, while the accumulator forms have to put it in x5.

Cheaper operand decoding

Two changes, both about how an instruction reads its own operands rather than the operation itself:

  • VReg holds a pre-shifted byte offset instead of a slot index. A 64-bit load folds the ×8 scale into its addressing mode for free, but on arm64 a 32-bit load cannot scale its index by 8, so every i32 and f32 operand used to pay an explicit lsl #3 ahead of its ldr w. Pre-shifting costs three bits of range, which a frame never needs.
  • A one-slot immediate is read as one 64-bit word and sliced apart with shifts, rather than each field being loaded separately. The next handler’s address sits in the slot right after it, so the two loads pair up: the immediates, the next handler and the program-counter bump collapse into a single post-incrementing ldp. This also makes the next handler address available from the first instruction, rather than loading it near the end.

The second one is about the shape of a handler. A binary operation occupies two slots of the instruction stream: its three immediates packed into the first 8 bytes (two operand offsets and a result offset), and the address of the next handler in the 8 bytes after that. The handler used to read the immediates field by field, and fetch the next handler at the end:

typealias Handler = @convention(c) (UnsafeMutablePointer<UInt64>, UnsafeRawPointer) -> Void

func i32Add(sp: UnsafeMutablePointer<UInt64>, pc: UnsafeRawPointer) {
    let lhs    = pc.load(fromByteOffset: 0, as: Int16.self)
    let rhs    = pc.load(fromByteOffset: 2, as: Int16.self)
    let result = pc.load(fromByteOffset: 4, as: Int32.self)

    add(sp, lhs, rhs, result)

    let next = pc.load(fromByteOffset: 8, as: Handler.self)
    next(sp, pc + 16)  // tail call to the next instruction's handler
}

Now it takes the immediates as one word, pulls the fields out with shifts, and reads the next handler right beside it:

func i32Add(sp: UnsafeMutablePointer<UInt64>, pc: UnsafeRawPointer) {
    let word = pc.load(as: UInt64.self)                     // immediates
    let next = pc.load(fromByteOffset: 8, as: Handler.self) // next handler

    let lhs    = Int16(truncatingIfNeeded: word)
    let rhs    = Int16(truncatingIfNeeded: word >> 16)
    let result = Int32(truncatingIfNeeded: word >> 32)

    add(sp, lhs, rhs, result)

    next(sp, pc + 16)
}

That is where the ldp comes from. Two 8-byte loads from adjacent offsets off the same base are exactly what an arm64 load-pair instruction does, and the post-increment form folds in the program-counter bump as well, meaning three narrow loads, one wide load, and an add become a single instruction. The three field extractions are sxth, sbfx and asr in the listing below, one instruction each and no memory involved. The field order matters too: lhs in the low bits needs no shift at all, and putting the wider result at the top lets it come out with a single asr.

Together the two changes take i32.add from twelve instructions to nine, and from seven that touch memory to four:

0.3.1                          0.4.0
ldrsw x8, [x1]                 ldp   x8, x4, [x1], #0x10   ; immediates, next
ldrsh x9, [x1, #0x4]           sxth  x9, w8                ; handler, pc bump
ldrsh x10, [x1, #0x6]          ldr   w9, [x0, x9]
lsl   x9, x9, #3               sbfx  x10, x8, #16, #16
ldr   w9, [x0, x9]             ldr   w10, [x0, x10]
lsl   x10, x10, #3             add   w9, w10, w9
ldr   w10, [x0, x10]           asr   x8, x8, #32
add   w9, w10, w9              str   x9, [x0, x8]
str   x9, [x0, x8]             br    x4
ldr   x4, [x1, #0x8]
add   x1, x1, #0x10
br    x4

#444, #446

Accumulator registers

A value that the next instruction consumes now stays in a machine register instead of round-tripping through its stack slot. There are two of them, threaded through the direct-threaded handler ABI: ireg for integers and freg for floats. Each is live only between the handler that produces a value into it and the handler right after it.

typedef SWIFT_CC(swiftasync) void (*wasmkit_tc_exec)(
    uint64_t *sp, Pc, Md, Ms, uint64_t ireg, double freg,
    SWIFT_CONTEXT void *state);

ireg came first, with 89 accumulator forms of the integer binary operations, comparisons, branches and global.get. freg followed with 39 f64 forms covering the arithmetic, sqrt, loads and stores. Loads and stores on 32-bit memories route their operands through the accumulator as well, and select takes its condition from it.

The handlers show what that buys. A plain i32.add reads both operands out of the frame and writes the result back: three slot accesses. Chain two of them and you pay six, even though the intermediate is consumed immediately and nobody else ever looks at it. As an accumulator pair they pay four:

wasmkit_tc_i32AddToAcc
ldp   x8, x5, [x1], #0x10   ; immediates, next handler, pc bump
sxth  x9, w8                ; lhs slot offset, from the low 16 bits
ldr   w9, [x0, x9]          ; lhs, out of the frame
sbfx  x8, x8, #16, #16      ; rhs slot offset, from the next 16
ldr   w8, [x0, x8]          ; rhs, out of the frame
add   w4, w8, w9            ; result stays in the accumulator (nothing stored)
br    x5                    ; next handler is in x5, because x4 is the accumulator
wasmkit_tc_i32AddFromAcc
ldp   x8, x5, [x1], #0x10   ; immediates, next handler, pc bump
sxth  x9, w8                ; the one operand that still needs a slot
ldr   w9, [x0, x9]          ; ...loaded out of the frame
add   w9, w9, w4            ; the other operand is already in the accumulator
asr   x8, x8, #32           ; result slot offset, from the high 32 bits
str   x9, [x0, x8]          ; result back to the frame
br    x5

Two loads and no store, then one load and one store. The intermediate never reaches the frame at all.

In the instruction listings the two accumulators print as acc and facc. A global.get feeding an add used to write the global’s value into a frame slot and read it straight back out; now it hands it over in ireg:

;; (i32.add (global.get $g) (local.get 0))

0.3.1                                 0.4.0
reg:4 = global.get global:0x...       acc   = global.get global:0x...
reg:4 = i32.add reg:4, reg:-4         reg:4 = i32.add acc, reg:-4

Loads do the same, and freg carries floats. An f64.load hands its result straight to the multiply that consumes it, so the loaded value never lands in a slot:

;; (f64.mul (f64.load (local.get 0)) (f64.const 2.0))

0.3.1                                 0.4.0
reg:4 = f64.load reg:-4, offset: 0    facc  = f64.load reg:-4, offset: 0
reg:4 = f64.mul reg:4, reg:0          reg:4 = f64.mul facc, reg:0

#451, #452, #453, #456, #457, #459, #462

Constants in the instruction

An i32.const used as an operand used to be materialised into the frame’s constant pool during translation and read back out as a register. An integer constant that fits in 32 bits now rides inside the instruction instead, and the pool slot disappears with it:

;; (i32.add (local.get 0) (i32.const 1))

0.3.1                                  0.4.0
 [ 0] Const 0 = 1                      (no constant slot)
0x00: reg:4 = i32.add reg:-4, reg:0    0x00: reg:4 = i32.add reg:-4, #1

There are immediate forms for the integer arithmetic, the comparisons, the fused compare and bit-test branches, and the accumulator producers. Every i32 fits; an i64 fits when it survives sign extension from 32 bits. Anything else (like a wide i64 or a float) still gets a pool slot and is read as a register.

#454

Superinstructions

33 opcodes compute (x op1 y) op2 z for the most frequent adjacent integer pairs, so a multiply-add is one dispatch rather than two:

;; (i32.add (i32.mul (local.get 0) (local.get 1)) (local.get 2))

0.3.1                                   0.4.0
reg:4 = i32.mul reg:-6, reg:-5          reg:4 = i32.mul.add reg:-6, reg:-5, reg:-4
reg:4 = i32.add reg:4, reg:-4

The f64 pairs get the same treatment, and produce into freg so the result carries on to whatever consumes it.

Two adjacent register copies fuse the same way, into one instruction that performs both. Swapping a pair of values takes four copies in 0.3.1 and two instructions now:

;; (func (param i32 i32) (result i32 i32)
;;   (local.get 1) (local.get 0))

0.3.1                        0.4.0
0x00: reg:5 = copy reg:-5    0x00: reg:5 = copy reg:-5; reg:4 = copy reg:-4
0x02: reg:4 = copy reg:-4    0x02: reg:-4 = copy reg:5; reg:-5 = copy reg:4
0x04: reg:-4 = copy reg:5    0x04: return
0x06: reg:-5 = copy reg:4
0x08: return

Comparisons, and-tests and float compares fold into the conditional branch that consumes them, which also removes the temporary slot the boolean used to live in:

;; (if (i32.lt_s (local.get 0) (i32.const 100))
;;   (then (local.set 0 (i32.mul (local.get 0) (i32.const 3)))))

0.3.1                                 0.4.0
0x00: reg:4 = i32.lt_s reg:-4, reg:0  0x00: br_if.i32.ge_s reg:-4, #100, +2 ; 0x5
0x02: br_if_not reg:4, +2 ; 0x6       0x03: reg:-4 = i32.mul reg:-4, #3
0x04: reg:-4 = i32.mul reg:-4, reg:1  0x05: reg:4 = copy reg:-4
0x06: reg:4 = copy reg:-4             0x07: reg:-4 = copy reg:4
0x08: reg:-4 = copy reg:4             0x09: return
0x0a: return

The comparison is gone: its result never reaches a slot, and the branch it fed now tests the operands itself with the predicate inverted.

#433, #445, #447, #448, #460

Fewer copies

Lowering a stack machine onto a register machine produces copies between registers.

The translator already knew how to avoid some of them. When the value a local.set stored came from the instruction just emitted, it would reach back and point that instruction at the local’s slot rather than emit the move at all. Two cases had been excluded from this out of caution, and neither exclusion turned out to be necessary:

  • local.tee and v128 producers. The value a local.tee leaves on the stack is the same alias of the local’s slot that local.get produces, so it makes no difference whether the slot was filled by a copy or by the producer writing there directly. Until this was lifted, every local.tee in a hot loop paid for a copy.
  • br_if landing pads. A conditional branch with anything to copy was lowered as brIfNot cond, +skip; <copies>; br target, and every loop (param ...) took that path even when there was nothing to copy: two dispatches on the back edge where one would do. The pad is still built, then thrown away and rewound if it comes out empty.

A counting loop written with local.tee loses one of its three instructions per iteration:

;; (loop $l
;;   (br_if $l (local.tee 1 (i32.sub (local.get 1) (i32.const 1)))))

0.3.1                                 0.4.0
0x00: reg:0 = copy reg:-4             0x00: reg:0 = copy reg:-4
0x02: reg:5 = i32.sub reg:0, reg:1    0x02: reg:0 = i32.sub reg:0, reg:1
0x04: reg:0 = copy reg:5              0x04: br_if reg:0, -4 ; 0x2
0x06: br_if reg:0, -6 ; 0x2           0x06: reg:-4 = copy reg:0
0x08: reg:-4 = copy reg:0             0x08: return
0x0a: return

#435, #439

Remove LLVM Function Merging Thunks

Of the 275 handler symbols in the 0.3.1 binary, 37 were small thunk functions containing a single unconditional branch:

wasmkit_tc_i32Load   (0.3.1)
b  _wasmkit_tc_f32Load

All 37 cases involved opcode pairs performing identical bitwise operations:

  • i32.load and f32.load both just move four bytes into a slot.
  • Reinterprets are simple raw copies.
  • Since our value slots are 64 bits wide, i64.load8_u naturally compiles to the same code as i32.load8_u.
  • Most duplicates were atomics (where i64.atomic.rmw matched its i32 counterpart).
  • i64.extend_i32_u merged with f32.reinterpret_i32 since both simply keep the low 32 bits and zero the rest.

Because the compiled bodies really were identical, LLVM’s function merging pass had folded them together. However, LLVM couldn’t simply make them the same symbol. The direct-threaded dispatch table stores the address of every handler, and LLVM will only replace a function with an alias if its address “is not significant, not named and not referenced anywhere.” Since the handler addresses are explicitly referenced in the dispatch table, they fail this test. LLVM’s fallback is to emit a thunk (a wrapper carrying the original signature that simply jumps to the canonical body) so that each opcode maintains a distinct address in memory.

This is the correct behavior for a C/C++ compiler in the general case: distinct functions are supposed to have distinct memory addresses, and the compiler has no way of knowing that our specific array of function pointers is never used for equality comparisons. But for a direct-threaded interpreter, this means every single i32.load dispatch pays the cost of an extra taken branch just to reach code it could have jumped to directly.

The fix was simply to stop generating the duplicates in the first place rather than fighting the LLVM merge pass.

#433

Turn more handlers into leaf functions

A function that calls nothing is a leaf: no prologue, no epilogue, no saved link register. That is what a handler wants to be, because a leaf handler is straight-line work between two branches and nothing else.

The memory handlers (#437) were not leaves, and the reason sat on a path that almost never runs. Their out-of-bounds case threw, and a throw out of a handler body compiles to a call into the error-allocating function. One call, on the cold path, and the fast path of every load and store pays a frame for it:

wasmkit_tc_f32Load   (0.3.1)
str   x21, [sp, #-0x20]!      ; prologue, paid on every load
stp   x29, x30, [sp, #0x10]
add   x29, sp, #0x10
mov   x22, x0
ldr   x8, [x1]
ldrsh x9, [x1, #0x8]
ldr   x9, [x0, x9, lsl #3]
add   x10, x8, #0x4           ; bounds check: index + offset + length
adds  x10, x9, x10
cset  w11, hs                 ; ...its overflow
tbnz  x3, #0x3f, <trap>       ; ...sign test on ms
tbnz  w11, #0x0, <trap>
cmp   x3, x10
b.lo  <trap>
adds  x8, x8, x9              ; second, trapping address computation
b.hs  <trap>
tbnz  x8, #0x3f, <trap>
ldrsh x9, [x1, #0xa]
ldr   w8, [x2, x8]            ; the actual load
str   x8, [x22, x9, lsl #3]
ldr   x4, [x1, #0x10]
add   x1, x1, #0x18
mov   x0, x22
ldp   x29, x30, [sp, #0x10]   ; epilogue, also paid on every load
ldr   x21, [sp], #0x20
br    x4

The fix is not to make that call cheaper but to get it out of the body. Here is the cold path of each version. In 0.3.1 it is a call, followed by an epilogue, because there is a frame to tear down:

wasmkit_tc_f32Load   (0.3.1) (the out-of-bounds path)
mov   x21, #0x0
bl    <throwOutOfBoundsMemoryAccess>   ; the call everyone pays a frame for
mov   x0, x21
mov   x1, x22
ldp   x29, x30, [sp, #0x10]
ldr   x21, [sp], #0x20
b     <wasmkit_execution_state_set_error>

In 0.4.0 a trap is just another instruction. The handler puts the address of a trap pseudo-instruction’s handler (one the translator never emits) into x4, and branches back into its own dispatch tail, so trapping is a dispatch:

wasmkit_tc_i32Load   (0.4.0) (the out-of-bounds path)
adrp  x4, ...
add   x4, x4, #0x170   ; the trap handler's address
b     <i32Load+0x30>   ; rejoin the tail: add x1, x1, #0x18 ; br x4

With no bl left on any path, the handler is a leaf and the frame goes with it:

wasmkit_tc_i32Load   (0.4.0)
ldr   x9, [x1]
ldrsh x8, [x1, #0x8]
ldr   x8, [x0, x8]
add   x8, x8, x9
subs  x10, x3, x8             ; remaining = ms - address
ccmp  x8, x9, #0x0, hs        ; address >= offset (no wrap)
ccmp  x10, #0x4, #0x0, hs     ; remaining >= length
b.lo  <trap>                  ; trap
ldr   x4, [x1, #0x10]
ldrsh x9, [x1, #0xa]
ldr   w8, [x2, x8]
str   x8, [x0, x9]
add   x1, x1, #0x18
br    x4

Two other handlers had the same shape, each with a single call keeping it off the leaf path:

  • The wasm-to-wasm return (#436) chased the frame’s instance pointer to work out whether to switch linear memory, and switching ends in a call, so every return carried a frame for a case that hardly ever happens. The call site already knows the answer, so it records it in a spare bit of the saved Pc, and _return tests that one bit. 43 instructions and two calls become 10 and none.
  • br_table (#458) called out of line. It is inlined into its handler, and the clamp reads a stored last index instead of computing count - 1. 19 instructions and one call become 11 and none.

#436, #437, #458

Embedded Swift, down to bare metal

The core engine, the WAT assembler, WASI and the GDB stub all build with Embedded Swift now, down to bare-metal RISC-V.

You can find Examples/embedded-esp32, which runs WasmKit on an ESP32-C6.

Another target is the Playdate, whose console runs a Cortex-M7. Here it is running WASM-4 cartridges (source available at kateinoigakukun/wasm4-wasmkit-playdate), with the runtime interpreting the guest modules on device:

WASM-4 games interpreted by WasmKit on a Playdate.

To achieve this, we turned capability differences into package traits like FileSystem, MultiThread, etc…

We also deleted swift-nio, swift-log and swift-system from the dependencies. GDB packet decoder is rewritten from an actor to a plain old sans-IO style decoder.

The GDB stub works over UART now:

(lldb) gdb-remote localhost:4445
Process 1 stopped
* thread #1, stop reason = trace
       frame #0: 0x4000000000000063 demo.wasm`
->  0x4000000000000063: i32.store 0
(lldb) continue
Process 1 exited with status = 0 (0x00000000)

Other changes

  • The GDB stub survives a real LLDB session now. Breakpoints no longer leak or get lost while stepping, a guest trap is reported as a stop rather than dropping the connection, the guest’s exit status comes back, and the stub answers the packets LLDB actually sends.

    Build with the WasmDebuggingSupport trait, run wasmkit run --debugger-port 4444 program.wasm, then gdb-remote localhost:4444. Almost all of it is Jonas Devlieghere’s work, driven from the LLDB side.

  • Relaxed SIMD and Extended Constant Expressions are implemented, with the proposals’ spec test suites enabled (#373, #371).
  • WASI Threads: wasmkit run --wasi-threads links wasi.thread-spawn, with --wasi-threads-max bounding live guest threads (#423, docs). It builds on shared linear memory, which works on 64-bit macOS and Linux (#380).
  • wasmkit wast runs WebAssembly spec test scripts from the command line (#415).
  • wasmkit run gained --dir host::guest mapping, --argv0, and support for an empty --env value (#392, #414); between them, enough to run CPython’s python.wasm.
  • WITExtractor was reimplemented on SwiftSyntax (#381).

Since this is still a 0.x release, there are source-breaking changes:

  • SystemPackage.FilePath and FileDescriptor no longer appear in public signatures. parseWasm(filePath: String) and parseWasm(fileHandle: CInt) take a String and a CRT file descriptor, and descriptor ownership is documented as borrowed; the caller keeps it open and closes it.
  • ImportError is gone; import resolution and module registration failures are reported as WasmKitError messages.
  • ResourceLimiter is constrained to AnyObject, because Embedded Swift supports only class-bound existentials.
  • WasmKitError carries a typed trap kind, and the core Wasm types print as wasm-flavoured text such as (i32, i32) -> (i32) instead of going through reflection.

Getting Started

The minimum supported Swift version is 6.3.

swift package add-dependency https://github.com/swiftwasm/WasmKit --up-to-next-minor-from 0.4.0
swift package add-target-dependency WasmKit <your-target> --package WasmKit

API documentation is on the Swift Package Index. If you hit a problem, please open an issue.

If you are using WasmKit and would be happy to share that use case in our documentation, please let me know!

Acknowledgements

Thanks to Robin Freyler for the wasmi v2.0 post and for wasmi-benchmarks. Publishing a careful writeup along with a suite anyone can run is a real service to everyone working on Wasm interpreters, and this release would not have happened without it.

Thanks to Jonas Devlieghere for the debugger work, Max Desiatov for Relaxed SIMD, extended-const, the WIT extractor rewrite and the command line improvements, and to Joannis Orlandos, Kenta Kubo and shopifyski for their contributions to this release.

WasmKit was originally developed by @akkyie, and is now maintained by the community.