Swift Embedded: The Game-Changer for Swift on WebAssembly?

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:

  1. Compile your Swift code to LLVM-IR, with Swift 6.0 Compiler.
  2. Compile/Link your LLVM-IR to WebAssembly, using Clang/Emscripten


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:

  • -O: This enables the compiler optimization.
  • -Xcc -fdeclspec: This flag passes the -fdeclspec option to the C compiler, allows __declspec keywords.
  • -target wasm32-unknown-none-wasm: This specifies the target architecture, as WASM.
  • -enable-experimental-feature Embedded: This enables the Swift Embedded.
  • -wmo: This enables Whole Module Optimization (WMO), required by Swift Embedded.
  • -disable-stack-protector: This disable the stack cookie, which reduces the amount of functions to link, in the linking phase.
  • -c: This disables the linking, so it compiles without linking.
  • -emit-ir: This specifies to generate the LLVM-IR as output.
  • -o hello.ll: This specifies the name of the output.


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

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

  • Swift (Swift Embedded) + Emascripten: 3KB
  • Swift (Swift Embedded) + Clang: 10KB
  • SwiftWasm (Swift): 7MB


Speed

More tests are required to precisely mesure the performance.

To view or add a comment, sign in

Insights from the community

Others also viewed

Explore topics