CRuby is a highly portable C application, and it runs on many platforms (You can see which platforms are tested on CI here). However the "portability" is only at source level, and the built executable is not portable across architectures and system call interfaces.
This project made CRuby portable at executable file level by porting it to a standalone WebAssembly with WASI ABI. Furthermore, we implemented a VFS (Virtual File System) at WASI to package Ruby scripts into a single WebAssembly binary. These improvements make it easier to distribute Ruby scripts.
This is the final report required by the 2021 Ruby Association Grant.
WebAssembly is not only for porting existing projects to the web browser, but also for providing a sandboxed portable executable. It’s not limited to the web, and is being used in a variety of situations such as edge computing, embedded environments, and interfaces for plug-in systems.
The Ruby community has already ported CRuby to WebAssembly using Emscripten, but it depends heavily on the host JavaScript environment. It makes it difficult to use for the above applications.
WASI is an effort to define a standard set of syscalls for WebAssembly modules, allowing WebAssembly modules to not only be portable across architectures but also be portable across environments implementing this standard set of system calls. WASI does not rely on JavaScript at all.
For C and C++ users, WASI SDK, which is a LLVM compiler toolchain with musl and cloudlibc based libc wasi-libc is provided.
Now the master branch of CRuby supports WASI as a build target by our several patches. The initial patch set for the support was ruby/ruby#5407 (has been merged)
This feature is tracked on "Proposal to merge WASI based WebAssembly support"
Today's WASI has limited system calls, and WebAssembly itself doesn't have context-switching mechanism. So CRuby needed some workarounds to address them. The notable changes are:
- Add emulation implementations for some missing functionalities using Asyncify:
- setjmp/longjmp for Exception
- ucontext for Fiber
- register scan for GC
- Support no thread environment
The current master branch passes basictest, bootstraptest, and ruby/spec except for Thread and process related tests. And also it supports extention libraries linked statically.
As mentioned above, the current WebAssembly has no context switching feature1, but there is an userland technique to pause and resume a WebAssembly process by binary transformation. This technique is called Asyncify.
Asyncify provides two operations to control program execution.
- Stop the current execution and unwind to the root call frame while writing out execution state and function-local registers (a.k.a Wasm locals) to memory
- Rewind to the stopped call frame while restoring saved exeuction state and registers.
You can see these emulation implementations under the wasm
directory in the ruby/ruby repository.
Thanks to these magical operations, setjmp and longjmp can be simply emulated like following procedure:
- setjmp saves the current stack pointer and exeuction state by unwinding to the main, then rewinds to the call-site of setjmp.
- longjmp unwinds to the main, but discards the collected execution state and rewinds to the call-site of setjmp saved at step 1, then restore the saved stack pointer.
However, this setjmp emulation gets slower and slower as the call stack gets deeper! In addition, setjmp is used in the core of the Ruby VM. So we mitigated the performance penalty by avoiding rewinds to the main in the core implementation2.
We've also considered using exception-handling proposal and it's 1.88 times faster than Asyncify approach in microbenchmark. However we found that it requires quite a few changes to rewrite all use of setjmp/longjmp with try-catch style at C level. We decided to use Asyncify approach for now to minimize the effects of our patches rather than performance gain.
Similar to setjmp/longjmp, Fiber on WASI exploits Asyncify. It simply switches the execution state by unwinding/rewinding and swaps stack pointers.
CRuby uses conservative garbage collection, which marks pointer-like values by scanning some value spaces to find living objects.
While running WebAssembly program, Ruby object (VALUE
) can be put in:
- Wasm Stack: Defined in Wasm spec
- Function-local Registers (Wasm Locals): Defined in Wasm spec
- C Stack: Allocated in linear memory
Unlike the normal memory space like 3. C Stack, 1. Wasm Stack and 2. Function-local Registers cannot be scanned dynamically. Fortunately, Asyncify writes Wasm Locals and Wasm Stacks out as execution states, so GC unwinds and rewinds by Asyncify and scans the execution states stored in the linear memory.
We implemented a VFS (Virtual File System) at WASI, named wasi-vfs
, to package Ruby scripts into a single WebAssembly binary. It's not only for Ruby, but also for any application using wasi-libc.
You can package directories with the same options as when running in wasmtime or wasmer.
# Without packing
$ wasmtime run ruby.wasm --mapdir /::./lib -- /irb.rb
irb(main):001:0>
...
# Packing with wasm-vfs
$ wasm-vfs pack ruby.wasm --mapdir /::./lib -o irb.wasm
$ wasmtime run irb.wasm -- /irb.rb
irb(main):001:0>
...
Note that it currently requires to link libwasi_vfs.a
because it depends on linker's symbol resolution mechanism to hook WASI system calls, and also needs to merge data sections. However this limitation will be removed after module-linking will be in place.
The wasi-vfs pack
command is a wrapper of Wizer, which is a pre-initializer of Wasm applications. See also Bytecode Alliance: Making JavaScript run fast on WebAssembly.
The initialization process in wasi-vfs scans the mapped directories, then copies them into in-memory virtual filesystem. Then Wizer take a snapshot of that state, and save it as a Wasm file.
We've also implemented a Ruby <-> JavaScript interop library as a npm package for browser and Node.js. It's already used in try.ruby-lang.org.
const vm = await RubyVM.default();
vm.eval(`
luckiness = ["Lucky", "Unlucky"].sample
JS::eval("document.body.innerText = '#{luckiness}'")
`);
Here is a performance benchmark report of Optcarrot comparing with native CRuby and other Ruby implementations. (bigger is better)
master (wasm32-wasi)
and opal
use Node.js v16.14.0 as their JS and Wasm runtime.
summary | full |
---|---|
Performance profiler reports similar trends for heaviest functions, so the bottleneck is not in CRuby implementation but Wasm runtime is slower than native. Also, it includes Asyncify overheads as we mentioned above.
- ruby.wasm: Nightly release of prebuilt Wasm target Ruby
- wasi-preset-args: A tool to preset command-line arguments to a WASI module
- This is useful to set script path passed to Ruby.
- Some platforms like Compute@Edge cannot give arguments, so this tool is essential
- optcarrot.wasm: A game emulator written in Ruby on browser powered by WebAssembly
- irb.wasm: IRB on browser powered by WebAssembly
- Snapshot an initialized VM state as an executable Wasm file
- Compress files embedded by wasi-vfs
- RDoc integration to run sample code in browser
- Extend RubyGems to allow linking extension library statically
We have shown how we ported CRuby to WebAssembly with WASI ABI, and introduced related tools. This achievement will promote the use of Ruby in more environments.
If no major issues are found, it will be released as Ruby 3.2.