WebAssembly
Learning Objectives
- You know what WebAssembly is and what its key characteristics are.
- You know how to run WebAssembly modules in the browser and on the server.
For computationally intensive tasks, such as real-time data processing, physics simulations, or complex mathematical calculations, JavaScript may not be the most efficient choice.
See, for example, The Computer Language Benchmarks Game.
WebAssembly characteristics
WebAssembly is a binary instruction format designed to serve as a compilation target for high-level languages that can run in web browsers. Unlike JavaScript, which is interpreted or just-in-time compiled at runtime, WebAssembly code is compiled ahead of time into a compact binary format.
Then, when the module is loaded into the host environment, the binary format is further compiled to machine code, which can be executed directly by the host environment. This can allow faster load times and execution speeds, especially when performing heavy computational tasks.
For more information on the terms “high-level languages”, “interpreted”, “compiled”, “just-in-time”, “ahead of time” and so on, check out the course Modern and Emerging Programming Languages.
Key characteristics of WebAssembly include:
- Portability: WebAssembly is designed to run consistently across different browsers and platforms. The same module can execute on desktops, laptops, tablets, and mobile devices without modification.
- Performance: WebAssembly has a binary format that’s closer to machine code, which — in principle — allows it to reach near-native execution speeds.
- Security: WebAssembly programs are executed in a sandboxed environment. This means that even though it can be performant, it runs in a controlled environment with fewer security vulnerabilities (when used correctly).
Although WebAssembly is not directly related to cloud computing, it can be used in cloud environments to improve performance. For example, WebAssembly can be used to offload computationally intensive tasks from the server to the client, reducing the load on the server and improving the user experience.
Container providers like Docker are also exploring the use of WebAssembly to improve the performance of container images. By compiling container images to WebAssembly, the images can be loaded and executed faster, which can be beneficial in cloud environments where fast startup times are important. For more information, see Docker documentation on WASM workloads.
Creating a WebAssembly module
To create a WebAssembly module, you write the program in a language that can be compiled to WebAssembly and then compile the program to WebAsembly. With Rust, for example, creating a WebAssembly module is straightforward.
Here’s an example of a program that uses a matrix trick to calculate the nth Fibonacci number in Rust:
fn matrix_multiply(a: (u64, u64, u64, u64), b: (u64, u64, u64, u64)) -> (u64, u64, u64, u64) {
let (a11, a12, a21, a22) = a;
let (b11, b12, b21, b22) = b;
(
a11 * b11 + a12 * b21,
a11 * b12 + a12 * b22,
a21 * b11 + a22 * b21,
a21 * b12 + a22 * b22,
)
}
fn matrix_power(mut m: (u64, u64, u64, u64), mut n: u64) -> (u64, u64, u64, u64) {
let mut result = (1, 0, 0, 1);
while n > 0 {
if n % 2 == 1 {
result = matrix_multiply(result, m);
}
m = matrix_multiply(m, m);
n /= 2;
}
result
}
#[export_name = "fibonacci"]
pub fn fibonacci(n: u64) -> u64 {
if n == 0 {
return 0;
}
let matrix = (1, 1, 1, 0);
let (a, _b, _c, _d) = matrix_power(matrix, n - 1);
a
}
pub fn main() {
}
Assuming that we have the program in a Rust project with a configured release profile, we can build the project using Rust tools:
cargo build --target wasm32-unknown-unknown --release
This creates a WebAssembly module in the target/wasm32-unknown-unknown/release
directory. The module can then be loaded into a web application using JavaScript. If the project is called rust_fibo
, the WebAssembly module is typically named rust_fibo.wasm
.
The above WASM module can be downloaded by clicking here.
Loading and using WebAssembly modules
As mentioned earlier, WebAssembly is designed to run consistently across different environments. This includes directly in the browser as well as within a server environment.
WebAssembly in the browser
The WebAssembly JavaScript API provides core functionality for interacting with WebAssembly modules. The static function instantiateStreaming is the preferred approach for loading Wasm code.
The function is given a Response (e.g., result of a fetch
call) as a parameter, and it returns a promise of an object with WebAssembly Module module
and WebAssembly Instance instance
properties. The instance
property has an exports
property, which in turn has the functions that the WebAssembly module exports.
That is, to load a function fibonacci
from a WebAssembly module called rust_fibo.wasm
, we use the following code (assuming the rust_fibo.wasm
is in the same folder as the current file.
const result = await WebAssembly.instantiateStreaming(fetch('rust_fibo.wasm'));
const fibo = result.instance.exports.fibonacci;
// now, we can use the function
Concretely, in an HTML page, this would look as follows. Below, the page has two functions. One for loading the function and one for using the function. In addition, the page has an input field for entering a number, which will be passed as a parameter to the Fibonacci function.
The BigInt type is used to capture the 64 bit unsigned integers that the Rust program uses.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Fibo fun!</title>
</head>
<body>
<script>
let fiboFun = null;
const runFibo = async () => {
if (!fiboFun) {
fiboFun = await loadFibo();
}
let nth = BigInt(parseInt(document.getElementById('nth').value));
let output = document.getElementById('output');
output.textContent = fiboFun(nth);
}
const loadFibo = async () => {
const result = await WebAssembly.instantiateStreaming(fetch('rust_fibo.wasm'));
return result.instance.exports.fibonacci;
}
</script>
<button onclick="runFibo()">Run Fibo</button>
<input type="number" id="nth" value="2" />
<p id="output"></p>
</body>
</html>
WebAssembly on the server
Similarly to loading WebAssembly on the browser, loading WebAssembly on the server is straightforward. Deno provides native support for importing WebAssembly modules, which means that we can import them directly with the import
statement.
The application below shows how the rust_fibo.wasm
module could be used in a Deno application.
import { Hono } from "@hono/hono";
import { fibonacci } from "./rust_fibo.wasm";
const app = new Hono();
app.get("/api/fibonacci", (c) => {
const nth = parseInt(c.req.query("nth") ?? "0");
const result = fibonacci(BigInt(nth));
return c.json({ result: result.toString() });
});
export default app;
Like above, we use BigInt
to handle the 64 bit unsigned integers that the Rust program uses. We further stringify the result
as it is of type BigInt
that JSON.stringify
does not handle.
The above application, when run with Deno, exposes an endpoint that calculates the nth Fibonacci number.
curl "http://localhost:8000/api/fibonacci?nth=10"
{"result":"55"}%
curl "http://localhost:8000/api/fibonacci?nth=20"
{"result":"6765"}%
curl "http://localhost:8000/api/fibonacci?nth=42"
{"result":"267914296"}%
Although WebAssembly is often associated with the promise of near-native performance, the actual performance can vary depending on several factors, such as the specific workload, browser or runtime optimizations, and overheads related to sandboxing and memory management.
Modern JavaScript engines are extremely optimized for typical web tasks and excel in areas like DOM manipulation and asynchronous operations, where vanilla JavaScript is typically faster than WASM code. On the other hand, with compute-heavy operations, WASM — especially when compiled to JavaScript from e.g. efficient Rust — can be faster than equivalent JavaScript implementations.
WASM should not be picked up without extensive profiling and benchmarking. It is not a silver bullet for performance issues, and in many cases, the performance gains are not worth the added complexity. In many cases where WASM could bring benefits, one could as well setup a separate microservice for the task, implementing the microservice in a more performant language such as Rust, Go, or C++.