Imagine you are working on the next big thing that runs in the browser, and it requires some heavy-duty code, which need to run fast and efficient. You remember that your friend Jack told you about WebAssembly (Wasm), which supposedly runs faster than JavaScript (JS), so you decide to check it out.
The thing you are working on involves sorting large amounts of data, so you test a pure JS implementation first.
To compare the speed, the test involves the initialization of an array with 100.000 random values. That will be copied 500 times and each time stable sorted. Each test will be repeated 5 times and the average will be taken.
The tested browsers are Firefox (108.0b6), Edge (107.0.1418.56) and Chrome (107.0.5304.110) on an Intel Macbook Pro 2019.
If curious, the test can be reproduced by using the following repository: https://github.com/Ecostack/wasm-rust-go-asc
Originally, the Go version used an unstable sort as it is the default. This comparison was unfair, as Rust and AssemblyScript use a stable one by default. This has been changed, all variants use stable sorts now.
JavaScript #
function testSort() {
const length = 100_000
const arr = new Array(length)
for (let i = 0; i < arr.length; i++) {
arr[i] = Math.random()
}
const temp = new Array(length)
for (let i = 0; i < 500; i++) {
for (let j = 0; j < arr.length; j++) {
temp[j] = arr[j]
}
// stable sort
temp.sort()
}
}
function testSortTyped() {
const length = 100_000
const arr = new Int32Array(length)
for (let i = 0; i < arr.length; i++) {
arr[i] = Math.random()
}
const temp = new Int32Array(length)
for (let i = 0; i < 500; i++) {
for (let j = 0; j < arr.length; j++) {
temp[j] = arr[j]
}
// providing comparator uses sable sort
// For more info, check:
// Chrome https://bugs.chromium.org/p/v8/issues/detail?id=8567
// Firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1290554
temp.sort((a,b) => a-b)
}
}
function measureTime(times, func) {
console.log("start measuring time")
console.time("measureTime")
for (let i = 0; i < times; i++) {
func()
console.timeLog("measureTime", i)
}
console.timeEnd("measureTime")
}
measureTime(5, () => testSort())
measureTime(5, () => testSortTyped())
A test run with 5 repetition in Firefox shows, it takes around 19,273 milliseconds on average with the JavaScript solution. The Chrome version takes all the way up to 68,720 ms (+256%) In Edge, the test is not starting.
Further on, you check the typed array version, which runs considerably faster than the dynamic array variant. In Firefox, it takes around 2976 ms and Chrome is slower with 4904 ms (+65%).
With the pure JavaScript variants out of the way, you get going on the WebAssembly.
AssemblyScript #
You start with AssemblyScript, as it is most similar to TypeScript/JavaScript. Setting up the project was a breeze, with following the guide at the AssemblyScript website .
Now you got yourself the laid out project and type away the AssemblyScript version in the assembly/index.ts
.
export function testSort(): void {
const length = 100_000
const arr = new Array<i32> (length)
for (let i = 0; i < arr.length; i++) {
arr[i] = i32(Math.floor(Math.random() * 100))
}
const temp = new Array<i32> (length)
for (let i = 0; i < 500; i++) {
for (let j = 0; j < length; j++) {
temp[j] = arr[j]
}
temp.sort()
}
}
Finishing up the coding, you compile the whole thing to a wasm binary of 3.5 kb and a runtime of 1.2 kb, which totals to 4.7 kb.
asc assembly/index.ts -Ospeed --target release
After compiling and running a similar test case with 5 runs inside Firefox, the AssemblyScript version reaches an average of 6,152 milliseconds. The Chrome version runs on average with 6,405 ms (+4%) and Edge is the slowest with 6,882 ms (+11%)
It gets faster, but you wonder if there is room for improvement, so you have a look at what the other languages are offering. Up next is Rust, that seems to be quite popular these days?
Rust #
Up next is Rust, which seems to be a bit more difficult to set up. You follow
the guide for wasm-pack
and which will eventually leave you
with the src/lib.rs
where you can start implementing.
#[wasm_bindgen]
pub fn testSort() {
const length: usize = 100_000;
let mut arr: [i32; length] = [0; length];
for i in 0..arr.len() {
arr[i] = rand::random()
}
let mut temp: [i32; length] = [0; length];
for i in 0..500 {
for j in 0..length {
temp[j] = arr[j]
}
temp.sort()
}
}
After fighting with the Rust compiler, you wrap up the implementation and finally compile a Wasm binary (44 kb). It comes with two bootstrap files (16 kb, 14 kb), which all in total are 74 kb.
wasm-pack build --scope MYSCOPE
The same test case, with the Rust version in Chrome, runs on average with 2,982 ms. The same thing runs in Firefox around 20% slower with 3,582 ms and in Edge around 10% slower with 3,306 ms.
Not bad, down from 19 seconds to 3 seconds. Let’s have a look at Go.
Go (TinyGo) #
The last language in your test is Go, for which you chose the TinyGo compiler .
It produces a significant smaller binary compared to the normal Go compiler but does not support the full standard library, which does not bother you much, as you do not need the whole thing.
After following the installation guide
and
the project setup for TinyGo with Wasm
you have working project where you
can edit the main.go
.
type SortInt []int32
func (c SortInt) Len() int { return len(c) }
func (c SortInt) Swap(i, j int) { c[i], c[j] = c[j], c[i] }
func (c SortInt) Less(i, j int) bool { return c[i] < c[j] }
//export testSort
func testSort() {
length := 100_000
arr := make(SortInt, length)
for _i := range arr {
arr[_i] = int32(rand.Intn(100))
}
temp := make(SortInt, length)
for _i := 0; _i < 500; _i++ {
for j := 0; j < length; j++ {
temp[j] = arr[j]
}
sort.Stable(temp)
}
}
The compilation step is leaving you with a Wasm binary (20 kb) and the necessary runtime (17 kb), which in total are 37 kb.
tinygo build -o wasm.wasm -opt=2 -no-debug -target wasm ./main.go
You follow along with your test and discover, that the Go version runs on average 9,546 ms in Edge, 10,668 ms (+12%) in Firefox and 9,717 ms (+2%) in Chrome.
Conclusion #
With all the tests done, it looks like Rust is leading the pack, closely followed by the AssemblyScript version and then the Go version. The pure JavaScript implementation is far in the back.
The below values were obtained via the Chrome browser.
Language | File-size (kb) | Runtime (ms) | Memory (mb) |
---|---|---|---|
JavaScript (JS) | 1.3 | 68,720 | 55.7 |
JavaScript (JS) Typed | 1.3 | 4,904 | 30.5 |
AssemblyScript (AS) | 4.7 | 6,405 | 21.5 |
Rust | 74.0 | 2,982 | 21.1 |
Go | 37.0 | 9,717 | 21.5 |
Based on your observations, it seems like Rust is the safest bet for the fastest execution speed among all tested languages. If file-size is a major factor, one might consider choosing AssemblyScript, but it is around two times slower than Rust.
It is worth mentioning that the typed JavaScript version performs in Firefox equally good when compared to the Rust version. This could be attributed to native implementations of the sorting algorithm inside the JS runtime. Depending on the use case, a pure JavaScript algorithm will most likely be slower to a Rust version in most cases.
Browser / Runtime | JS (ms) | JS Typed (ms) | AS (ms) | Rust (ms) | Go (ms) |
---|---|---|---|---|---|
Firefox | 19,273 | 2,976 | 6,152 | 3,582 | 10,668 |
Edge | - | 4,986 | 6,882 | 3,306 | 9,546 |
Chrome | 68,720 | 4,904 | 6,405 | 2,982 | 9,717 |
In terms of runtimes, depending on your choice of language and based on the test results, Chrome might have the best execution speed among all Wasm runtimes.