Are We Wasm Yet ? - Part 1

Are We Wasm Yet ? - Part 1

Thoughts from a Curious Gopher

TL;DR - Yes we’re Wasm, but we can do more!

Webassembly, commonly referred to as Wasm, is a binary instruction format designed for a stack based virtual machine. Webassembly was designed to be a portable compilation target for programming languages that enables deployments on the web for client and server applications.

While Wasm was originally intended to be targeted towards web portability, there has been a myriad of non browser use cases being developed that take advantage of Wasm’s portability and sandbox nature. However, not every programming language’s support for Wasm is equal. With the differences in language support it is easy to overlook some of the key benefits Wasm has to offer.

In this two part series we will explore the support for web assembly in the Golang ecosystem by developing two simple web applications, highlight a few tips/tricks, unpack some of the limitations that exists and discuss future solutions that Wasm may enable.

Let’s dive in!

Wasm Basics

Golang first added Wasm support in v1.11 and continues make improvements. At the time of this writing the latest release is v1.18. However, we will be using v1.17.7 since v1.18 was recently release and a few tools used in this post are still rolling out support.

Compiling to `Wasm is as simple as targeting the JS operating system type.

GOOS=js GOARCH=wasm go build -o main.wasm

The GOOS variable tells the Go compiler what kind of host environment to target. In this case, we will be targeting the JavaScript virtual machine. The GOARCH variable instructs the go compile to target a specific architecture, here we are targeting the Wasm architecture.

Before we instantiate our Wasm file, we will also need to copy Golang’s type wrapper which can be found in the Golang installation directory.

cp "$(shell go env GOROOT)/misc/wasm/wasm_exec.js" .

After compiling our Golang code and coping the wasm_exec.js file we can now instantiate our application. Below is a simple example demonstrating how you can load your Wasm file into a web environment.

First, create an index.html. This is where we will create our JavaScript snippet to load in our Wasm binary.


<html>
    <head>
        <meta charset="utf-8"/>
        <script src="wasm_exec.js"></script>
        <script>
            if (!WebAssembly.instantiateStreaming) {
                WebAssembly.instantiateStreaming = async (resp, importObject) => {
                    const source = await (await resp).arrayBuffer();
                    return await WebAssembly.instantiate(source, importObject);
                };
            }
            const go = new Go();
            WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject)
                .then((result) => {
                    go.run(result.instance);
                })
        </script>
    </head>
</html>

For node.js, please note that instantiateStreaming is not supported. More information can be found here github.com/nodejs/node/issues/21130

Wasm Example Applications

Now that we know the basics of compiling and loading a Wasm Application, let’s explore a few real Golang examples.

We will be using a simple Golang server to serve our application. However, any file server could be used to host our application.

Below is a simple example http server that will be used to serve up our applications.

package main

import (
    "flag"
    "log"
    "net/http"
)

func main() {
    port := flag.String("p", "80", "port to serve on")
    dir := flag.String("d", ".", "the directory of static file to host")
    flag.Parse()

    http.Handle("/", http.FileServer(http.Dir(*dir)))
    log.Printf("Serving UI %s on HTTP port: %s\n", *dir, *port)
    log.Fatal(http.ListenAndServe(":"+*port, nil))
}

In our first example, we will explore a simple sha256 hashing application. We will use Golang's built in crypto package to hash a raw user import string.

First lets create a main_js.go file.

package main

import (
    "crypto/sha256"
    "fmt"
    "syscall/js"
)

func sha256Hash(this js.Value, args []js.Value) interface{} {
    h := sha256.New()
    h.Write([]byte(args[0].JSValue().String()))
    return fmt.Sprintf("%x", h.Sum(nil))
}

func main() {
    c := make(chan struct{})
    js.Global().Set("Sha256Hash", js.FuncOf(sha256Hash))
    <-c
}

There are a few nuances worth calling out:

  1. Our code must reside in the main package.

  2. We need to keep our application alive after we have instantiate the Wasm module. We can achieve this by simply creating a channel and reading from it which will block our application from exiting.

Once again, we can compile our code with the following command:

GOOS=js GOARCH=wasm go build -o sha256.wasm

Let's inspect the Wasm binary a little closer before stepping into how we will interact with our module.

We can see that from our command above we will output a file call sha256.wasm. Taking a closer look at the file we can see that the size of the binary is ~2.0mb! When using Wasm outside of the browser, this may be an acceptable size. However, this is a fairly large file to be loading when using a Wasm in the browser.

Size is this first limitation that we will focus on.

Don't Panic! There are plenty of things we can do to improve the file size.

Leveraging additional build flags is a natural first step in reducing the overall size of the binary. Luckily, we can use Golang's linker flags or -ldflags to modify the output.

Using the ldflags options we can pass two additional flags -w and -s. The -w option turns off DWARF debugging information. The -s turns off generation of the Go symbol table. Pairing these two ldflag options will result in a smaller binary.

Below is the command to build with the -ldflag:

GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o ./web/sha256.wasm ./wasm/main_js.go

After running this command we can see that our binary went from ~2.0mb down to ~1.9mb!

Okay, that might not be as exciting as you would have hoped for but it is a start!

Before going any further, let's take a deeper dive into the binary to see exactly what is taking up the most space.

For this we will leverage a tool called twiggy. Twiggy is a code size profiler for WASM. You can find more information on Twiggy here.

We can see our code size profile by running the following command:

twiggy top -n 10 sha256.wasm

This will show us the to 10 functions ordered by size. Below are the results.

 Shallow Bytes  Shallow %  Item
──────┼───────────┼─────────
        100051      4.91%  data[21564]
         55952      2.75%  unicode.init
         40729      2.00%  data[1054]
         38785      1.90%  data[21561]
         31858      1.56%  data[1021]
         31509      1.55%  function names subsection
         23267      1.14%  fmt.__pp_.printValue
         14990      0.74%  runtime.gentraceback
         10673      0.52%  internal_fmtsort.compare
         10229      0.50%  fmt.__pp_.doPrintf
       1678348     82.42%  ... and 25814 more.
       2036391    100.00%  Σ [25824 Total Rows]

Some of these names may seem daunting but one that stands out that we can evaluate is fmt. In our code we took a short cut by using fmt.Sprintf("%x",h.Sum(nil)). Let's instead use the std lib's hex package to return our string.

Our new code looks like this:

package main

import (
    "crypto/sha256"
    "encoding/hex"
    "syscall/js"
)

func sha256Hash(this js.Value, args []js.Value) interface{} {
    h := sha256.New()
    h.Write([]byte(args[0].String()))
    return hex.EncodeToString(h.Sum(nil))
}

func main() {
    c := make(chan struct{})
    js.Global().Set("Sha256Hash", js.FuncOf(sha256Hash))
    <-c
}

Recompiling with our earlier optimization we can see that our binary is now 1.5mb! Let's take another look at our twiggy output to confirm we are no longer seeing the fmt functions.

 Shallow Bytes  Shallow %  Item
──────┼───────────┼─────────────
         83336      5.16%  data[18294]
         55706      3.45%  code[975]
         35953      2.23%  data[884]
         33141      2.05%  data[18292]
         28210      1.75%  data[872]
         14990      0.93%  code[759]
          8926      0.55%  code[679]
          8553      0.53%  code[795]
          8119      0.50%  data[880]
          7530      0.47%  code[313]
       1329141     82.37%  ... and 22080 more.
       1613605    100.00%  Σ [22090 Total Rows]

This is awesome, we can see that fmt is no longer being leveraged!

While we are trending in the right direction regarding reducing size, we are still looking at a 1.5mb file to load. Luckily, we have a few more tricks we can pull out.

Next, we will explore another fantastic Wasm tool call wasm-opt. The wasm-opt program is a tool in the binaryen toolkit which is a wasm-to-wasm transformation that optimizes the input WASM module. There are dozens of configurations that can be leveraged with wasm-opt, but for our use case we will leverage the -0z flag. This option will execute default optimization passes, super-focusing on code size.

Running the following command will produce a new size optimized binary:

wasm-opt -Oz -o ./web/sha256.wasm.opt ./web/sha256.wasm

Taking a closer look at the binary, we can see that the size went from 1613605 bytes ( ~1.5mb ) to 1564792 bytes ( ~1.5mb ). This isn't as drastic as our last optimizations, but it is once again an easy optimization that could be made to reduce our footprint.

Running twiggy one last time produces the following results:

 Shallow Bytes  Shallow %  Item
────────┼───────────┼──────────
         83336      5.33%  data[18294]
         55227      3.53%  code[968]
         35953      2.30%  data[884]
         33141      2.12%  data[18292]
         28210      1.80%  data[872]
         14610      0.93%  code[759]
          8712      0.56%  code[679]
          8119      0.52%  data[880]
          7224      0.46%  code[795]
          7161      0.46%  code[313]
       1283099     82.00%  ... and 22072 more.
       1564792    100.00%  Σ [22082 Total Rows]

We can see that wasm-opt was able to reduce the size of a few of our largest sections in our Wasm file.

We will dive deeper into wasm-opt and additional trade offs that can be made in future posts but for now we have one final optimization to discuss, TinyGo !

TinyGo describes itself as a Go Compiler for small places. At first glance this sounds exactly like what we are looking for. Let's jump in.

TinyGo implements its own wasm_exec.js wrapper. After installing TinyGo, we can run the following command to copy the file into our server directory.

cp "$(shell tinygo env TINYGOROOT)/targets/wasm_exec.js" .

Next, we can compile our existing Go application. Below, is the command to compile our application with TinyGo.

tinygo build -target wasm -o sha256.wasm main_js.go

This produces a Wasm file that is only 178Kb! Let's take a look at what twiggy has to say about the overall size profile.

 Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼───
         3893821.31% ┊ custom section '.debug_info'
         3017816.52% ┊ custom section '.debug_line'
         2172611.89% ┊ custom section '.debug_str'
         2023511.07% ┊ custom section '.debug_pubnames'
         148918.15% ┊ custom section '.debug_loc'
          39092.14% ┊ syscall/js.handleEvent
          34561.89% ┊ custom section '.debug_ranges'
          34221.87% ┊ runtime.run$1
          30821.69% ┊ custom section '.debug_pubtypes'
          29591.62% ┊ runtime.printitf
         3992721.85% ┊ ... and 180 more.
        182723 ┊   100.00% ┊ Σ [190 Total Rows]

This is obviously drastically different than the Wasm file that was generated with the native Golang compiler. The main call out is the overall amount of functions found within the module.

Here we can see we have 190 total rows compared to the 22082 rows from the module produced from Golang. In a future post, we may look closer into how this is achieved, but for now let's see if we can reduce the size of our tiny Wasm module using wasm-opt as we did before.

Using the same wasm-opt command we used earlier, we see that it produces a new Wasm file that is down to 150Kb!

Whew .... Mission accomplished!

Now that we have a size optimized Wasm file, let's use it in our web app!

We will keep things easy. Below is a simple webpage that has a single user input field. On submit, we will execute our WASM module.

<html>
<head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
    <script src="wasm_exec.js"></script>
    <script>
        // polyfill
        if (!WebAssembly.instantiateStreaming) {
            WebAssembly.instantiateStreaming = async (resp, importObject) => {
                const source = await (await resp).arrayBuffer();
                return await WebAssembly.instantiate(source, importObject);
            };
        }
        const go = new Go();
        WebAssembly.instantiateStreaming(fetch("sha256.wasm"), go.importObject)
            .then((result) => {
                go.run(result.instance);
            })
    </script>
</head>
<body>

    <h3>sha256 hash input text</h3>
    <input type="text" id="textinput" value="foo bar">
    <button onclick="getInput()">Hash me</button>
    <p id="demo"></p>
    <script>
    function getInput() {
      var x = document.getElementById("textinput").value;
      var hashed = Sha256Hash(x)
      document.getElementById("demo").innerHTML = hashed;
    }
    </script>
    </body>
</html>

This above code should be familiar, but it can be broken down into two sections.

  1. Loading our Wasm modules. This is taking place in the head of our html file.
  2. Our javascript function that calls our Wasm function Sha256Hash.

Here is what the application looks like in action:

Screen Shot 2022-03-20 at 1.28.20 PM.png

Summary

In this article, we focused on size optimization for a very basic Wasm module that exposes a sha256 hashing function.

We highlighted a few amazing tools; wasm-opt, Twiggy and TinyGo. I want to thank all of the amazing contributors of these projects for making these available to the Wasm community!

In part 2, we will build a simple client and server application written in Go. We will compile the client implementation to Wasm and use it in the browser to send requests to our server.

We will then discuss the limitations we have run into in more detail and run through a few features that would improve Golang's holistic Wasm support.

All of the code for this blog is available on Github here

I hope you enjoyed the first part of this series and stay tuned for part 2!

Did you find this article valuable?

Support Ethan M Lewis by becoming a sponsor. Any amount is appreciated!