WasmKit 0.4.0 - 2x Faster, and Running on Microcontrollers
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:
- The interpreter got roughly twice as fast
- The engine and its GDB stub now run on microcontrollers via Embedded Swift support
- Support for a few more post-MVP features (Relaxed SIMD, Extended Constant Expressions, and Threads) and WASI Preview 1 Threads.
Table of Contents
- Table of Contents
- Performance
- Embedded Swift, down to bare metal
- Other changes
- Getting Started
- Acknowledgements
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).
We also ran the CoreMark benchmark to see how WasmKit compares to other portable Wasm interpreters across different architectures:
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:
VRegholds 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 everyi32andf32operand used to pay an explicitlsl #3ahead of itsldr 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
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.
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.
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.teeandv128producers. The value alocal.teeleaves on the stack is the same alias of the local’s slot thatlocal.getproduces, so it makes no difference whether the slot was filled by a copy or by the producer writing there directly. Until this was lifted, everylocal.teein a hot loop paid for a copy.br_iflanding pads. A conditional branch with anything to copy was lowered asbrIfNot cond, +skip; <copies>; br target, and everyloop (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
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.loadandf32.loadboth just move four bytes into a slot.- Reinterprets are simple raw copies.
- Since our value slots are 64 bits wide,
i64.load8_unaturally compiles to the same code asi32.load8_u. - Most duplicates were atomics (where
i64.atomic.rmwmatched itsi32counterpart). i64.extend_i32_umerged withf32.reinterpret_i32since 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.
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_returntests 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 computingcount - 1. 19 instructions and one call become 11 and none.
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:
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
WasmDebuggingSupporttrait, runwasmkit run --debugger-port 4444 program.wasm, thengdb-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-threadslinkswasi.thread-spawn, with--wasi-threads-maxbounding live guest threads (#423, docs). It builds on shared linear memory, which works on 64-bit macOS and Linux (#380). wasmkit wastruns WebAssembly spec test scripts from the command line (#415).wasmkit rungained--dir host::guestmapping,--argv0, and support for an empty--envvalue (#392, #414); between them, enough to run CPython’spython.wasm.WITExtractorwas reimplemented on SwiftSyntax (#381).
Since this is still a 0.x release, there are source-breaking changes:
SystemPackage.FilePathandFileDescriptorno longer appear in public signatures.parseWasm(filePath: String)andparseWasm(fileHandle: CInt)take aStringand a CRT file descriptor, and descriptor ownership is documented as borrowed; the caller keeps it open and closes it.ImportErroris gone; import resolution and module registration failures are reported asWasmKitErrormessages.ResourceLimiteris constrained toAnyObject, because Embedded Swift supports only class-bound existentials.WasmKitErrorcarries a typedtrapkind, 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.