Memory Model in WebAssembly
For JavaScript to execute, the JavaScript engine should download the resources. The JavaScript engine wait until the resources are downloaded. Once downloaded, the JavaScript engine parses. The parser converts the source code to byte code that JavaScript interpreter runs.
When a function is called multiple times. The baseline compiler
(in v8) compiles the code. The compilation happens in the main thread. The compiler spends time for compilation. But the compiled code runs faster than the interpreted code. The compiled code is optimised by the optimising compiler
.
When function is called a lot more. The compiler marks the function and tries to optimise further. During this re-optimisation
, compiler assumes and produces even more optimised code. This optimisation takes a bit of time but the generated code is much more faster.
The function is executed. Finally, the code is garbage collected.
WebAssembly is fast. 🚀
The JavaScript engine download the WebAssembly module. Once downloaded the WebAssembly module is decoded.
Decoding is faster than parsing.
Once decoded, the WebAssembly module is compiled and optimised. This step is fast because the module has already been compiled and optimised.
The module is finally executed.
Note: there is no separate garbage collection step. The WebAssembly module takes care of allocating and de-allocating the memory.
In the quest of speeding up WebAssembly execution, the browser vendors implemented streaming compilation. Streaming compilation enables JavaScript engines to compile and optimise the module while the WebAssembly module is still downloading. Unlike JavaScript, where the engines should wait until the file is completely downloaded. This speeds up the process.
JavaScript and WebAssembly are two different things at the browser level. Calling WebAssembly from JavaScript or vice versa is slow. (This holds good for calls between any two languages). This is because crossing boundaries has a cost attached to it.
The browser vendors (especially Firefox) are trying to reduce the cost of boundary crossing. In fact, in Firefox the JavaScript to WebAssembly call is much faster than the non-inlined JavaScript to JavaScript calls.
But still proper care should be given to the boundary crossing while designing your application. They can be a major performance bottleneck for the application. In those cases it is important to understand the memory model of the WebAssembly module.
Memory model in WebAssembly
The memory section
of the WebAssembly module is a vector of linear memories.
Linear Memory Model
A linear memory model is a memory addressing technique in which the memory is organized in a single contagious address space. It is also known as Flat memory model.
While the linear memory model makes it easier to understand, program, and represent the memory.
They have huge disadvantages like
- high execution time for rearranging elements
- wastes a lot of memory area
The memory is a vector of raw bytes of uninterpreted data. They use resizable array buffers to hold the raw bytes of memory. JavaScript and WebAssembly can synchronously read and write into the memory.
We can allocate the memory using WebAssembly.memory()
constructor from JavaScript.
Write some code ✍️
Passing from WebAssembly to JavaScript
Let us first see how to pass values through memory from WebAssembly Module (written with Rust) to JavaScript.
Create a new project using cargo
.
$ cargo new --lib memory_world
Once the project is successfully created. Open the project in your favourite editor. Let us edit the src/lib.rs
with the following contents
#![no_std]
use core::panic::PanicInfo;
use core::slice::from_raw_parts_mut;
#[no_mangle]
fn memory_to_js() {
let obj: &mut [u8];
unsafe {
obj = from_raw_parts_mut::<u8>(0 as *mut u8, 1);
}
obj[0] = 13;
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> !{
loop{}
}
What is there?
The rust file starts with #![no_std]
. The #![no_std]
attribute instructs the rust compiler to fallback to core crate instead of std crate. The core crate is platform agnostic. The core crate is a smaller sub set of the std crate. This reduce the binary size dramatically.
The function memory_to_js
is annotated with #[no_mangle]
. This function does not return any value, because it changes the value in the shared memory.
We define a mutable slice of type u8
and name it as obj
. Then we use from_raw_parts_mut
to create a u8
using a pointer and length. By default the memory starts at 0
and we just take 1
element.
We are accessing the raw memory so we wrap the calls inside the unsafe
block. The generated slice from from_raw_parts_mut
is mutable.
Finally we assign 13
in the first index.
unsafe {
obj = from_raw_parts_mut::<u8>(0 as *mut u8, 1);
}
obj[0] = 13;
We have also defined a panic_handler
to capture any panics and ignore it for now (do not do this in your production application).
Note that we are not using
wasm_bindgen
here.
In JavaScript, we load the WebAssembly module and access the memory straightaway from the module.
First fetch and instantiate the WebAssembly module.
const bytes = await fetch(
'target/wasm32-unknown-unknown/debug/memory_world.wasm'
)
const response = await bytes.arrayBuffer()
const result = await WebAssembly.instantiate(response, {})
The result object is the WebAssembly object that contains all the imported and exported functions. We call the exported memory_to_js
function from the result.exports
.
result.exports.memory_to_js()
This calls the WebAssembly module's memory_to_js
function and assigns the value in the shared memory.
The shared memory is exported by result.exports.memory.buffer
object.
const memObj = new UInt8Array(result.exports.memory.buffer, 0).slice(0, 1)
console.log(memObj[0]) // 13
The memory is accessed via load
and store
binary instructions. These binary instructions are accessed with the offset
and the alignment
. The alignment
is in base 2 logarithmic representation.
Note: WebAssembly currently provides only
32-bit
address ranges. In future, WebAssembly may provide64-bit
address range.
Passing from JavaScript to WebAssembly
We have seen how to share memory between JavaScript and WebAssembly, by creating the memory in Rust. Now it is time to create memory in JavaScript and use it inside Rust.
The memory in the JavaScript land has no way to tell the WebAssembly land what to allocate and when to free them. Being type, WebAssembly expects explicit type information. We need to tell the WebAssembly land how to allocate the memory and then how to free them.
To create the memory via JavaScript, use the WebAssembly.Memory()
constructor.
The memory constructor takes in an object to set the defaults. They are
- initial - The initial size of the memory
- maximum - The maximum size of the memory (Optional)
- shared - to denote whether to use the shared memory
The unit for initial and maximum is (WebAssembly) pages. Each page hold upto 64KB.
Write some code ✍️
Initialise the memory,
const memory = new WebAssembly.Memory({ initial: 10, maximum: 100 })
The memory is initialized with WebAssembly.Memory()
constructor with an initial value of 10 pages
and a maximum value of 100 pages
. This translates to 640KB and 6.4MB initial and maximum respectively.
const bytes = await fetch(
'target/wasm32-unknown-unknown/debug/memory_world.wasm'
)
const response = await bytes.arrayBuffer()
const instance = await WebAssembly.instantiate(response, {
js: { mem: memory },
})
We fetch the WebAssembly Module and instantiate them. But while instantiating we pass in the memory object.
const s = new Set([1, 2, 3])
let jsArr = Uint8Array.from(s)
We create a typedArray
(UInt8Array
) with values 1, 2, and 3.
const len = jsArr.length
let wasmArrPtr = instance.exports.malloc(len)
WebAssembly modules will not have any clue about the objects size that are created in the memory. WebAssembly needs to allocate memory. We have to manually write the allocation and freeing of memory. In this step, we send the length of the array and allocate that memory. This will give us a pointer to the location of the memory.
let wasmArr = new Uint8Array(instance.exports.memory.buffer, wasmArrPtr, len)
We then create a new typedArray with the buffer (total available memory), memory offset (wasmAttrPtr), and the length of the memory.
wasmArr.set(jsArr)
We finally set the locally created typedArray (jsArr
) into the typedArray wasmArrPtr
.
const sum = instance.exports.accumulate(wasmArrPtr, len) // -> 7
console.log(sum)
We are sending the pointer
(to memory) and length
to WebAssembly module. In the WebAssembly module we fetch the value from the memory and use them.
In the Rust, the malloc
and accumulate
functions are as follows:
use std::alloc::{alloc, dealloc, Layout};
use std::mem;
#[no_mangle]
fn malloc(size: usize) -> *mut u8 {
let align = std::mem::align_of::<usize>();
if let Ok(layout) = Layout::from_size_align(size, align) {
unsafe {
if layout.size() > 0 {
let ptr = alloc(layout);
if !ptr.is_null() {
return ptr
}
} else {
return align as *mut u8
}
}
}
std::process::abort
}
Given the size, the malloc function allocates a block of memory.
#[no_mangle]
fn accumulate(data: *mut u8, len: usize) -> i32 {
let y = unsafe { std::slice::from_raw_parts(data as *const u8, len) };
let mut sum = 0;
for i in 0..len {
sum = sum + y[i];
}
sum as i32
}
The accumulate
function takes in the shared array and the size (len
). It then recovers the data
from the shared memory. Then runs through the data
and returns the sum of all the elemnts passed in the data.
Interested to explore further
WebAssembly Memory using JavaScript API at here
Memory access in the WebAssembly is safer check at here
Check out more about from_raw_parts_mut
at here
Check out more about TypedArray here