loading...

Increase Rust and WebAssembly performance πŸš€πŸš€πŸš€

sendilkumarn profile image Sendil Kumar N Updated on ・6 min read

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 ❀️

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. If you have any questions/suggestions/feel that I missed something feel free to add a comment.

You can follow me on Twitter.

If you like this article, please leave a like or a comment. ❀️

Posted on by:

sendilkumarn profile

Sendil Kumar N

@sendilkumarn

An explorer wandering in the land of programs. I am passionate about Open Source. "Docendo discimus"

Discussion

markdown guide
 

How long should it take to bundle? I've been waiting for a really long time... like four minutes, and it still says "wait until bundle finished:"

https://thepracticaldev.s3.amazonaws.com/i/n51ddfxad6dnk7wq0pgr.png

 

Okay, it took ~10 minutes, but once it got going, it was able to live-reload in a few seconds.

Here's the JS I used:

import marked from 'marked'
import("../pkg/index.js")
  .then(module => {
    const markdown = '# some markdown content'

    console.time('rust')
    console.log({ rust: module.parse(markdown) })
    console.timeEnd('rust')

    console.time('js')
    console.log({ js: marked(markdown) })
    console.timeEnd('js')
  })
  .catch(console.error)

And the result (Safari 12.11):

[Log] {rust: "<h1>some markdown content</h1>↡"}
[Debug] rust: 12.227ms
[Log] {js: "<h1 id=\"some-markdown-content\">some markdown content</h1>↡"}
[Debug] js: 3.981ms
 

Hold up, I'm going to update my rust and add the optimizations and see what sizes and times we get.

Okay, updated them and compiled with opt-level=3, and wee_alloc on. Compilation took a really long time, but I get that it's a prod feature, not a dev feature. Wasm file was 1.3Mb, though :/

I tried compiling with s and z to see the file size difference, but it failed to compile when I tried that (it deleted the pkg directory and then didn't regenerate it)

$ rustc --version
rustc 1.37.0-nightly (088b98730 2019-07-03)


$ cat Cargo.toml | sed -n '/profile.dev/,/default/p'
[profile.dev]
lto = true
opt-level = 3

[features]
# If you uncomment this line, it will enable `wee_alloc`:
default = ["wee_alloc"]

$ du -h pkg/index_bg.wasm
1.3M    pkg/index_bg.wasm

# durations:
# rust: between 1 and 3.5 ms (usually between 1 and 2)
# js:   between 2.5 and 3.5ms

Anyway, good article, thanks for writing it up :)

What was the error message when you did s and z? You should pass it as a String.

All the above tests are run on rustc 1.37.0-nightly (929b48ec9 2019-06-21)

WASM file size is around that value, this may change in the future. 🀞

Oh, that's probably what I did wrong, I just had opt-level=s, not opt-level="s"

The error wasn't interesting, it was about how it tried to import the index, but the file wasn't there. I'm 90% sure it originated in the browser and that the server was just echoing it for me to see. Other than that, it just said "build failed". Which is interesting, because when I set it to opt-level=5, it told me that the only valid values were 1-3, and s and z, so it's a bit surprising that I didn't get that error again).

 

It is installing wasm-pack and then wasm bindgen cli. If this the first time, then it might take sometime. Try installing manually and check if it fails. Check out wasm-pack docs for this. successive runs will be faster

 

So I have been tinkering with this mad project, and now I have learned that c++ is very verbose, do you find rust easier to write, manage and understand. I'm contemplating version 2 in rust with rlua.

GitHub logo acronamy / tidal-node

Node.js tidal is my experiment to transparently integrate Lua and Lua rocks (TODO) into a simple node library via WASM. *optionally* Want to require Lua scripts from Node and have it give you a table? Want to use node features in Lua?

Tidal Node

Lua was concieved as an embeded language, designed to compliment other languages, Tidal Node is a library which brings Lua to node.js through WebAssembly and c++.

Tidal Node is WIP and not production ready - however I welcome the one or two people in the world that want to use this to get those PR's coming in. You will find most of the emscripten compiler commands in npm package.json "config". There is also a installer task for Lua built in, everything is subject to change.

Requirements

compiler:

emscripten 1.38.30^

runtime:

node 10.0.0^

build tools:

  • make
  • curl

Commands

command purpose
npm start just run the project
npm run compile just compile
npm run install-lua download a copy of lua 5.3.5 and build with emscripten

What is working

  • Return flat Lua tables to node!

Highlights - "It's alive!"

  • Returning flat Lua Tables to JavaScript as Objects
  • Use module.exports and require…




 

Thats a great idea πŸ‘πŸŽ‰ Yeah Rust is very concise. When you start you have wrap your head around the ownership, lifetimes and others. But when you get the feeling for that, then it will be much more easier and simpler to write.

 

Thank you for your kind words. So there's no emscripten, sounds interesting! Okay well I'm gonna carry on getting this version done and rolled out before giving this a shot.