Learning Webassembly
Along with Rust and C, I've been diving deeper into using WebAssembly. This is just a summary of a few key points in my learnings.
This write-up by Surma is the best, low-level Rust to WebAssembly tutorial I've found.
The MDN WebAssembly Pages are excellent to understand WebAssembly independently of the source language.
My goal is to understand the lower level workings of Rust and WASM, so I'm not going to be using wasm-bindgen or wasm-pack at this time.
Memory Allocation
My journey started when I was working on a Rust project that I thought would be a good candidate for both a system application and a web application. So I added #[no_std]
at the top of my lib.rs
file, meaning it shouldn't use Rust's standard library. Later I switched to using #![cfg_attr(not(feature = "std"), no_std)]
which means, only leave out the standard library if the "std" feature flag is absent in Cargo.toml. This just makes it easier to quickly switch between no_std
and std
for testing and development purposes by toggling the feature flags in Cargo.toml.
[features]
default=[] // Temporarily add "std" to the
std=[] // array if you want to develop
// with the standard library
As I worked without the standard library, I realized how much I take Vec
and String
for granted. Returning lists of items is common when programming and it's challenging without those constructs.
With no_std, these are your viable options for storage:
- Plan out where you'll store things ahead of time. Rely on static allocation.
- If you can't do #1, you'll need to setup an allocator.
What's an allocator? Think of it like someone trying to fit a bunch of items in a box. You have a limited amount of memory space, and the program asks the allocator, "Hey do you have room for this a u32"? And then the allocator looks in the box and finds a good place for it and gives you a pointer to that memory location. "Hey do you have a spot for this vector? I'm not sure how big it will be." And then the allocator finds a spot in the box for a vector that can expand and gives you the pointer to that location. This goes on and on, the allocator finding places for what you want to store, clearing out old memory, and generally managing where everything is.
If you need an allocator, I can't speak in-depth about how you'd reliably set that up, but here's a few tips for future reference.
I know that wee_alloc is no longer a good choice (source).
I know that dlmalloc is the default allocator wasm32-unknown-unknown and seems like a good choice. Here's a minimal example of how I got that working, and I will probably use this method in the future.
static mut GLOBAL_VEC_TEST: Vec<u32> = Vec::new();
extern crate alloc;
use alloc::vec::Vec;
use dlmalloc::GlobalDlmalloc;
#[global_allocator]
static GLOBAL: GlobalDlmalloc = GlobalDlmalloc;
#[no_mangle]
pub extern "C" fn push_number_to_vec(number: u32) -> u32 {
unsafe {
GLOBAL_VEC_TEST.push(number);
GLOBAL_VEC_TEST.len() as u32
}
}
I also briefly used the talc crate before I switched to dlmalloc. It worked, but I ran into some problems that I was unable to solve. This is how I used talc with standard (non-nightly rust).
use core::ptr::addr_of;
use talc::*;
static mut ARENA: [u8; 10000] = [0; 10000];
#[global_allocator]
static ALLOCATOR: Talck<spin::Mutex<()>, ClaimOnOom> =
Talc::new(unsafe { ClaimOnOom::new(Span::from_array(addr_of!(ARENA) as *mut [u8; 10000])) })
.lock();
If you'd like to see how to build your own allocator, see the write-up by Surma for a non-production bare-bones example.
Basic Rust to WASM Project Setup
Some notes on how to setup a very basic Rust to WASM project.
File/folder setup:
.cargo
|---- config.toml
src
|---- lib.rs
Cargo.toml
build_dev.sh
build_prod.sh
Create .cargo/config.toml
file. This is where you'll specify that you're compiling to wasm.
.cargo/config.toml
[build]
target = "wasm32-unknown-unknown"
Add some fields to Cargo.toml. Key point is you're using cdylib
which uses the C ABI. You're also adding some flags that will reduce the size of the production .wasm file.
Cargo.toml
[lib]
crate-type = ["cdylib"]
[features]
default=[]
std=[]
[profile.release]
# In some cases you might
# want panic = "abort"?
# size optimization for .wasm file
strip = true
# size optimization for .wasm file
lto = true
build_dev.sh
#/bin/bash
cargo build
cp ./target/wasm32-unknown-unknown/debug/crate.wasm ./public/crate.wasm
build_prod.sh
#/bin/bash
cargo build --release
cp ./target/wasm32-unknown-unknown/release/crate.wasm ./public/crate.wasm
How do you "Do Something" in WASM?
Generally, there is a "host" that will initialize a WASM module. For example the web browser or a server.
When I think about "doing something" in a programming language, I think about inputs and outputs. In WASM, the core types are i32, f32, i64, f64, and funcref (for referencing functions) and externref which can reference something in the host environment.
When a WASM module is initialized, the host system can provide functions that get imported into the wasm module. These functions are limited to accepting and returning the core types.
When a WASM module is initialized it can also return functions that can be used by the host system. Again, these functions are limited to accepting and returning the core types.
Compared to other languages, WASM itself is very limited and requires a shift in thinking. To "do something" in WASM...
- You can create/call a WASM function that returns one of the core types.
- You can create a function on the host and pass/call it in WASM. Again, you're restricted to the core types.
Given #1 and #2, there's basically only three ways to do something.
- You can do mathematical calculations and return a single value.
- You can return a number that represents a location in memory.
- You can manipulate other areas of memory.
Given that, if you wanted to create a function to generate an image of random noise...
- You'd import a "random" function into your WASM module
- Assuming you have static memory, you'd pre-allocate the memory needed for the image and initialize all bytes to 0.
- You would have a predefined schema of the memory's structure. For image data, the memory could be linear series of red, green, blue, alpha bytes.
- You would create a WASM function that accepts the width and height of the image, and then loops over the memory, and sets each red, green, blue, alpha to a random value.
- After looping, the function would return the memory location of the start of the image data.
- The host system would then have the memory location and can read the memory from start to (start + width * height * 4) bytes.
- The host system can then render the image using the known width, height, and RGBA data.
Here's a small demo using that method...
rust code
const IMAGE_MAX_MEM: usize = 1440000;
static mut IMAGE_MEM: [u8; IMAGE_MAX_MEM] = [0; IMAGE_MAX_MEM];
#[no_mangle]
pub extern "C" fn draw_noise(w: u32, h: u32) -> *const [u8; IMAGE_MAX_MEM] {
for i in (0..(w * h * 4)).step_by(4) {
unsafe {
IMAGE_MEM[i as usize] = (math::random() * 255.0) as u8;
IMAGE_MEM[(i + 1) as usize] = (math::random() * 255.0) as u8;
IMAGE_MEM[(i + 2) as usize] = (math::random() * 255.0) as u8;
IMAGE_MEM[(i + 3) as usize] = (math::random() * 255.0) as u8;
}
}
unsafe { core::ptr::addr_of!(IMAGE_MEM) }
}
mod math {
pub fn random() -> f64 {
unsafe { math_js::random() }
}
mod math_js {
#[link(wasm_import_module = "Math")]
extern "C" {
pub fn random() -> f64;
}
}
}
index.html script
let exports, lib;
const importObject = {
Math: {
random: () => Math.random()
}
};
WebAssembly.instantiateStreaming(fetch("crate.wasm"), importObject).then(
(results) => {
lib = results;
exports = lib.instance.exports;
requestAnimationFrame(draw_noise);
}
);
function draw_noise() {
// Get the memory address of the image
const imageAddress = exports.draw_noise(width, height);
// Get WASM memory buffer
const memoryBuffer = exports.memory.buffer;
// Read the image data into an array
const data = new Uint8ClampedArray(
memoryBuffer,
imageAddress,
width * height * 4
);
// Put the array into ImageData
const imageData = new ImageData(
data,
width,
height
);
// Draw the image on a canvas
ctx.putImageData(imageData, 0, 0);
requestAnimationFrame(draw_noise);
}
Note that draw_noise
returns a raw pointer to the memory location of IMAGE_MEM
. It uses the core::ptr::addr_of!
macro as opposed to returning &IMAGE_MEM
. There is an important difference between the two, but at this time I can't explain that.
I then moved on to reading The Rustonomicon to better understand unsafe
, raw pointers, memory layout, etc.
Alignment and Size
The definitions of alignment and size are technical (an are not a rust-specific concept), but to say it in simple terms, any value stored in memory will have a size (the amount of bytes needed to store it), and that value will have an "alignment" which indicates the numeric memory positions where the value can be stored. Alignment is measured in bytes, and must be at least 1, and always a power of 2. This video gives a good explanation of it in the context of rust.
This short example gave me a better feel for how memory is laid out using the C representation.
#[repr(C)]
struct MyStruct {
a: bool,
b: f64,
c: u8,
}
pub fn main() {
let ms = MyStruct {
a: false,
b: 3.16,
c: 3,
};
println!(
"size_of_val MyStruct (bytes): {}",
core::mem::size_of_val(&ms)
);
println!(
"align_of_val MyStruct (bytes): {}",
core::mem::align_of_val(&ms)
);
println!(
"size_of MyStruct (bytes): {}",
core::mem::size_of::<MyStruct>()
);
println!(
"align_of MyStruct (bytes): {}",
core::mem::align_of::<MyStruct>()
);
println!("size_of_val struct.a: {}", core::mem::size_of_val(&ms.a));
println!("align_of_val struct.a: {}", core::mem::align_of_val(&ms.a));
println!("size_of_val struct.b: {}", core::mem::size_of_val(&ms.b));
println!("align_of_val struct.b: {}", core::mem::align_of_val(&ms.b));
println!("size_of_val struct.c: {}", core::mem::size_of_val(&ms.c));
println!("align_of_val struct.c: {}", core::mem::align_of_val(&ms.c));
println!(
"offset_of! struct.a: {}",
core::mem::offset_of!(MyStruct, a)
);
println!(
"offset_of! struct.b: {}",
core::mem::offset_of!(MyStruct, b)
);
println!(
"offset_of! struct.c: {}",
core::mem::offset_of!(MyStruct, c)
);
let my_struct = core::ptr::addr_of!(ms) as *const u8;
let a = core::ptr::addr_of!(ms.a) as *const u8;
let b = core::ptr::addr_of!(ms.b) as *const u8;
let c = core::ptr::addr_of!(ms.c) as *const u8;
inspect_bytes(my_struct, core::mem::size_of_val(&ms));
inspect_bytes(a, core::mem::size_of_val(&ms.a));
inspect_bytes(b, core::mem::size_of_val(&ms.b));
inspect_bytes(c, core::mem::size_of_val(&ms.c));
}
fn inspect_bytes(ptr: *const u8, num_elements: usize) {
unsafe {
let slice = core::slice::from_raw_parts(ptr, num_elements);
println!("Bytes at {:p} ({} bytes): {:?}", ptr, num_elements, slice);
}
}
output
size_of_val MyStruct (bytes): 24
align_of_val MyStruct (bytes): 8
size_of MyStruct (bytes): 24
align_of MyStruct (bytes): 8
size_of_val struct.a: 1
align_of_val struct.a: 1
size_of_val struct.b: 8
align_of_val struct.b: 8
size_of_val struct.c: 1
align_of_val struct.c: 1
offset_of! struct.a: 0
offset_of! struct.b: 8
offset_of! struct.c: 16
Bytes at 0x6c502ff4d0 (24 bytes): [0, 244, 47, 80, 108, 0, 0, 0, 72, 225, 122, 20, 174, 71, 9, 64, 3, 248, 47, 80, 108, 0, 0, 0]
Bytes at 0x6c502ff4d0 (1 bytes): [0]
Bytes at 0x6c502ff4d8 (8 bytes): [72, 225, 122, 20, 174, 71, 9, 64]
Bytes at 0x6c502ff4e0 (1 bytes): [3]