Jul 2, 2019

Increase Rust and WebAssembly performance

The dream of running native code in the browser is not something new. There were many failed attempts. They all taught us a lesson. Those learnings made WebAssembly possible today.

WebAssembly makes it possible to run languages like C, C++, Rust and other languages in the browser.

But what is WebAssembly? Check out this presentation here or this awesome post from Lin Clark.

TL;DR:

  • Rust's toolchain makes it easy write WebAssembly application.
  • If you want better performance then use opt-level=3.
  • If you want a smaller sized bundle then use opt-level="s".

What are we gonna do?

Create a WebAssembly application that takes a string in markdown format and converts that into HTML.

Lets get started

So far, Rust has the best tooling for the WebAssembly. It is well integrated with the language. This makes Rust the best choice for doing WebAssembly.

We will need to install Rust before getting started. To install Rust checkout the installation guide here.

Once you have the Rust installed. Let's start creating the application.

Create Application

Create a WebAssembly application with all the necessary toolchain:

npm init rust-webpack markdown-rust

This creates a new Rust + JavaScript based application with Webpack.

Go inside the directory

cd markdown-rust

It has both Cargo.toml and package.json.

The Rust source files are present in the src directory and the JavaScript files are available in js directory. We also have webpack configured for running the application easy and fast.

The Cargo.toml contains the following:

[package]
# Some package information.

Then it declares the project will build a dynamic library with the following command.

[lib]
crate-type = ["cdylib"]

We have also declared the release profile should optimize the release using lto flag.

[profile.release]
lto = true

Finally added some [features] and [depdencies].

Now all We have to do is add the markdown library for the Rust that compiles the Markdown (string) into HTML string.

[dependencies]
# some comments ......
wasm-bindgen = "0.2.45"
comrak = "0.6"

Remove all the contents from src/lib.rs and replace that with the following.

Load the comrak functions and wasm_bindgen that we will be using.

use comrak::{markdown_to_html, ComrakOptions};
use wasm_bindgen::prelude::*;

So what is wasm_bindgen?

WebAssembly does not have any bindings to call the JavaScript or Document APIs. In fact, we can only pass numbers between JavaScript and WebAssembly. But that is not always desirable right, we need to pass JS objects, Strings, classes, closures and others between them.

How can we achieve that?

We can create a binding file or glue file that helps to translate the above objects into numbers. For example, in case of the string rather than sending each character as a character code.

We can put that string in a linear memory array and then pass the start-index (of where it is in memory) and its length to the other world (or JavaScript). The other world should have access to this linear memory array and fetches the information from there.

But doing this for every value that we pass between JavaScript and WebAssembly is time-consuming and error-prone. The wasm_bindgen tool helps you to build the binding file automatically and also removes the boilerplate code with a single #[wasm_bindgen] annotation.

But we need to be very careful about how many times we cross the boundary between JavaScript and WebAssembly module. More we cross slower the performance will be.

Now we will create a function called parse that actually takes the markdown input and returns the HTML.

#[wasm_bindgen]
pub fn parse(input: &str) -> String {
    markdown_to_html(&input.to_string(), &ComrakOptions::default())
}

The #[wasm_bindgen] annotation does all the boilerplate of converting the string into two numbers, one for the pointer to the start of the string in the linear memory and the other for the length of the string. The #[wasm_bindgen] also generates the binding file in JavaScript.

Time for some JavaScript :heart:

Now we have the WebAssembly Module ready. It is time for some JavaScript.

We will remove all the lines from the js/index.js and replace that with the following contents.

We will first import the WebAssembly module generated. Since we are using Webpack, Webpack will take care of bootstrapping wasm_pack that will, in turn, use the wasm_bindgen to convert Rust into WebAssembly module and then generate the necessary binding files.

The wasm_pack is a tool that helps to build and pack the Rust and WebAssembly applications. More about Wasm-pack here.

This means we have to just import the pkg/index.js file. This is where wasm_pack will generate the output.

const rust = import('../pkg/index.js')

The dynamic import will create promise which when resolved gives the result of the WebAssembly modules. We can call the function parse defined inside the Rust file like below.

rust.then((module) => {
  console.log(module.parse('#some markdown content'))
})

We will also calculate the time it took to parse the contents using the WebAssembly module.

rust.then((module) => {
  console.log(module.parse('#some markdown content'))

  const startWasm = performance.now()
  module.parse('#Heading 1')
  const endWasm = performance.now()

  console.log(`It took ${endWasm - startWasm} to do this in WebAssembly`)
})

For comparison, we will also calculate the time it took to do it with JavaScript.

Install the markdown library for the JavaScript.

npm install --save marked

Once installed, let us write our JavaScript code that takes in a Markdown text and returns the HTML.

// js/index.js
import marked from 'marked'
// some content goes here;

const markdown = '#Heading'

const startJs = performance.now()
console.log(marked(markdown))
const endJs = performance.now()

console.log(`It took ${endJs - startJs} to do this in JavaScript`)

Let us run the application using npm run start. This will kick start the Webpack dev server and serve the content from the local.

It is quite an interesting performance statistics to look at.

In Chrome and Safari, the JavaScript performance is way better than the WebAssembly. But in Firefox the JavaScript version is 50% slower than the WebAssembly.

This is mainly because WebAssembly linking and bootstrapping is very very fast in Firefox than compared with any other browser.

If you take a look at the bundle size, the WebAssembly file is mammoth 7475 KB than compared with the JavaScript variant 1009 KB.

If you are booing for WebAssembly now, then wait.

We did not add any optimizations yet. Let us add some optimizations and check the performance.

Open the Cargo.toml file and add the following segment above the [features] section.

[profile.dev]
lto = true
opt-level = 3

The opt-level is nothing but optimization level for compiling the project.

The lto here refers to link-time-optimization.

Note: This optimization level and lto should be added to the profile.release while working on the real application.

Additionally, enable the wee_alloc which does a much smaller memory allocation.

Uncomment the following in the Cargo.toml

[features]
default = ["wee_alloc"]

Add the wee_alloc memory allocation inside the src/lib.rs file.

#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

Now let us restart the server.

We can now see the real performance benefits of the WebAssembly. In Chrome the WebAssembly version is 4 times faster than the JavaScript version.

In Safari, the JavaScript variant is still between 2-3 ms but the WebAssembly variant is between 0-2ms.

Firefox too saw almost 50% faster WebAssembly code when using the optimizations than without optimizations.

Now the all-important bundle size is 1280 KB for WebAssembly and 1009 KB for JavaScript.

We can also ask Rust compiler to optimize for size rather than speed. To specify that change the opt-level to s

opt-level = "s"

WebAssembly still is a clear winner, but the Chrome registers slightly increased WebAssembly times but still lesser than the JavaScript variant. Both Safari and Firefox provide higher performance for the WebAssembly.

The bundle size is reduced further for WebAssembly at around 1220 and 1009 KB for JavaScript.

Rust compiler also supports opt-level = "z" which reduces the file size even further.

opt-level = "z"

The bundle size is reduced further for WebAssembly at around 1161KB and 1009 KB for JavaScript.

The performance of the WebAssembly module in Chrome is fluctuating a lot when using opt-level='z' between 41 and 140 ms.

IE Canary for Mac has (~)almost the same performance as of Chrome.

Use opt-level="z" if you are more concerned about your bundle size but the performance is not reliable in v8 now.

I hope this gives you a motivation to kick start your awesome WebAssembly journey.


Up Next