Swift Embedded: The Game-Changer for Swift on WebAssembly?
Almost two years ago, I tested many languages and compilers targeting WebAssembly. That test included languages such as C, Zig, Go, AssemblyScript, Odin, C#, and Swift. The test concluded that .NET and Swift are terrible for WebAssembly.
I defined "terrible" based on speed and size. Both languages/compilers generate giant bytecode and are more than 10 times slower than comparable C code. By contrast, AssemblyScript and Go (using TinyGo) are still almost 4 times and 2 times slower, respectively. There are many reasons for this: .NET doesn't support AOT and interprets everything in WASM (VM-in-VM), and Swift lacks features such as DCE and heavily relies on dynamic dispatching, without specialised code paths. Furthermore, the latest Swift version at the time of testing (Swift 5.6) didn't support unaligned reads. However, with the introduction of Swift 5.7, this feature was added, resulting in a slight performance improvement.
However, one month ago, Apple announced Swift Embedded. I don't use Swift, so I was completely unaware of Swift Embedded. While I don't plan to run Swift on my Raspberry Pi Pico, how about WASM?
What is Swift Embedded?
Well, that is a subset of Swift. It doesn't support all Swift features, specially excluding support for Objective-C interoperability, Swift Runtime and also removing support for Reflection. It still using some VTable, for dynamic dispatching and still using ReferenceCounting, with support for atomic (if hardware supports that). Due to the removal of Reflection, you can't use `Any` as a type, You can find more information on https://github.com/swiftlang/swift/blob/1401b31a095b49c486c814d4a01ee128f46b594b/docs/EmbeddedSwift/EmbeddedSwiftStatus.md (That link is specific for the current version, might not be the most accurate).
Also, Swift Embedded doesn't use the Swift Runtime. That is the most important feature, because the generated code only requires only a few functions from LibC, such as posix_memalign, free, putchar, memset and memcpy. Those functions are provided by LibC, which can be linked leveraging existing WASM compilers such as Emscripten and WASI-LIBC.
How do I use it?
It's just a strip-down version of Swift, so you just one compiler flag. Well, it's not that trivial, you need with few more steps and a bunch of compiler flags.
Download
First, you need to either wait for Swift 6.0 or get the pre-release versions, it's free https://www.swift.org/download/.
Swift 6.0 doesn't support WASI and doesn't link LibC functions, that makes impossible to allocate/deallocate memory, specially initialise classes. But, you can use Clang (with wasi-libc) or Emscripten to fix that limitation. You need to use either wasi-sdk (https://github.com/WebAssembly/wasi-sdk) or Emscripten (https://emscripten.org/docs/getting_started/downloads.html).
Of course, you also need LLVM and Clang.
Compiling
Now, you need to compile your code, that follows some few steps:
First Step: Compiling
First, compiles your Swift code to LLVM-IR, without solve any LibC linking:
xcrun --toolchain swift swiftc \
-O \
-Xcc -fdeclspec \
-target wasm32-unknown-none-wasm \
-enable-experimental-feature Embedded \
-wmo \
-disable-stack-protector \
-emit-executable \
-c \
-emit-ir \
-o hello.ll
hello.swift
That will compile hello.swift to hello.ll. That is what each flag does:
Second Step: Linking
Now, you need to use your Emscripten or Clang to transform hello.ll to hello.wasm, and you need to link the LibC functions. The size of emcc seems to be smaller, even with both using -O3 (and not -Os).
Recommended by LinkedIn
Using Emscripten:
emcc \
-sALLOW_MEMORY_GROWTH=1 \
-sSTANDALONE_WASM=1 \
-O3 \
-flto \
-o hello.wasm \
hello.ll
That will generate hello.wasm file, which can be executed on WebAssembly runtimes, assuming that the host expose some Emscripten required functions (such as emscripten_notify_memory_growth).
If you want to run on any WASI runtimes, you can add -sPURE_WASI=1 flag. However, combining ALLOW_MEMORY_GROWTH with PURE_WASI seems to not work, and the memory.grow instruction isn't present on the resulting WASM bytecode. I sent one patch to fix that issue (https://github.com/emscripten-core/emscripten/pull/22217). If you want to use EMCC, you need to patch your standalone.c or use my fork.
Using Clang:
clang \
-target wasm32-wasi \
--sysroot=/path/to/your/wasi-libc/sysroot \
-O3 \
-flto \
-Wl,--gc-sections,--strip-all \
-o hello.wasm \
hello.ll
That will also generate hello.wasm file, but can be execute on any WebAssembly runtime that is compatible with WASI (such as wasmtime, wazero or wasmer).
Run!
Now, you can run your hello.wasm, so assuming you have:
var _limiter: Int = 0
@_expose(wasm, "limit")
@_cdecl("limit")
public func limit(_ i: Int) {
_limiter = i
}
@_expose(wasm, "run")
@_cdecl("run")
public func run() {
if (_limiter == 0) {
limit(10)
}
for _ in 0..._limiter {
print()
}
}
class Printer {
var value: String
init(value: String) {
self.value = value
}
public func printIt() {
print(self.value)
}
}
public func print() {
let printer = Printer(value: "Hello World")
printer.printIt()
}
This code seems to be "weird hello world", but contains allocations from objects, destructions, WASI-calls and non-inline loop. You can run it as:
wasmtime --invoke run hello.wasm
It will output as:
Hello World
Hello World
Hello World
Hello World
Hello World
Hello World
Hello World
Hello World
Hello World
Hello World
Hello World
Comparing against Swift (with SwiftWasm)
The usual way to compile Swift for WASM is using SwiftWasm. That is usually better than the standard Swift Compiler, because it supports WASI and handles linking. However, I couldn't get it to work with Swift Embedded, and it's not required.
We can take the same source-code and compile it using SwiftWasm, which handles WASI, in that case:
xcrun --toolchain swiftwasm swiftc \
-O \
-Xcc -fdeclspec \
-target wasm32-wasi \
-sdk /Library/Developer/Toolchains/swift-wasm-DEVELOPMENT-SNAPSHOT-2024-07-08-a.xctoolchain/usr/share/wasi-sysroot \
-L /Library/Developer/Toolchains/swift-DEVELOPMENT-6.0-SNAPSHOT-2024-07-08-a.xctoolchain/usr/lib \
-wmo \
-disable-stack-protector \
-emit-executable \
-o hello.wasm \
hello.swift
That will generate one hello.wasm directly from hello.swift using SwiftWasm, without any external compiler/linker.
Sizes
Speed
More tests are required to precisely mesure the performance.