#tech
#webassembly
#overview
#benchmarks
14 min read

AssemblyScript: Introduction to WebAssembly for TypeScript Developers

Andriy Obrizan

WebAssembly is a low-level language with a compact binary format that runs with near-native performance in the browser. Multiple languages, like C/C++, C#, and Rust, have compilation targets for WebAssembly.

AssemblyScript is a strict variant of TypeScript specially created for WebAssembly. The syntax, easy installation, and integration with the existing JavaScript ecosystem provide a shallow learning curve for all web developers, especially if they had some experience with strictly typed languages like C++, C#, or Java.

Benefits of WebAssembly

JavaScript’s initial goal was to bring some interactivity to static HTML pages of the time. It’s not the best programming language in the world and has many quirks and strange behavior.

WebAssembly is an open standard created inside the W3C WebAssembly Community Group specifically for modern web applications. Its primary goals are to be fast, efficient, and portable. WebAssembly is designed to work side-by-side with other web technologies and respect the web’s existing security principles. It also runs in a sandbox execution environment, making it safe for the users. The bytecode is much smaller than minified JavaScript, and it runs at nearly native speed across different platforms.

Those benefits enable new types of web applications in the browser:

  • Games
  • CAD Applications
  • Scientific simulations
  • Fast image and video processing applications

But WebAssembly it’s limited to the web and browser. Being an open portable bytecode format, it can compete with Microsoft’s MSIL and Java bytecode on their field.

Let’s summarize the benefits:

  • It’s a W3C open standard.
  • People can choose multiple languages for their web applications.
  • WebAssembly provides better runtime performance.
  • The code is small and faster to download.
  • Faster parsing and compilation because it’s much closer to machine code.
  • Many performance optimizations are possible during compile-time.
  • It brings new types of applications to the web.
  • All major browsers support WebAssembly.
  • High security is part of the design.
  • Modules are one of the key concepts.
  • JavaScript interoperability.

The standard is evolving, and some significant features aren’t there yet:

  • DOM and WebAPIs aren’t available out of the box yet.
  • No garbage collector.
  • Multithreading isn’t available in all browsers yet.
  • SIMD instructions aren’t there yet.

WebAssembly has clear high-level goals and a roadmap to achieve them.

What is AssemblyScript

AssemblyScript is a language made specifically for WebAssembly based on a strict subset of TypeScript, a language that adds types to JavaScript.

Maintaining predictable performance while keeping small binaries were crucial for AssemblyScript. Those are the main reason to use WebAssembly in most cases. They had to avoid the dynamic nature of JavaScript and keep everything strictly typed for compatibility with the binary format without including additional JIT and heavy runtime dependencies in every application.

AssemblyScript compiler is based on the official WebAssembly’s Binaryen compiler toolchain. No heavy toolchains are required, as it integrates well with existing web ecosystem. Just npm install it, and you’re ready to go.

AssemblyScript vs TypeScript

AssemblyScript is not just TypeScript to WebAssembly compiler. Most of the existing TypeScript code won’t work, as there are some substantial limitations.

The basic types are quite different from TypeScript, as it directly exposes all integer and floating-point types available in WebAssembly. Those types more accurately represent the registries in the CPU, thus, are much faster. JavaScript JIT tries to figure out which type to use for number type based on values it contains, possibly recompiling the code multiple times. With AssemblyScript, developers have full control here and must specify the ideal types in advance.

Union types like string | boolean are also not supported as WebAssembly has a fixed memory model. Optional arguments and properties like firstName?: string and any aren’t available either. You can still use default values a: int32 = 0 to specify optional arguments, though. All objects are also statically typed and don’t allow to change their properties dynamically.

Modules don’t have access to DOM and other external APIs out-of-the-box. You have to call JavaScript and vice-versa with import & export. Interoperability is limited to fundamental numeric values for now, and objects cannot flow in and out of WebAssembly yet. The workaround is to write custom wrappers to access pointers in the module’s memory manually. AssemblyScript’s loader provides some basic functionality to help you with that.

The JavaScript’s == and === operators don’t have much sense in strictly typed WebAssembly where you just can’t compare values of different types. In AssemblyScript == acts as JavaScript’s ===, while === performs an identity comparison returning true only if both operands are exactly the same object. It can be very confusing for someone with a JavaScript or TypeScript background, where using === is a common practice for the vast majority of cases.

Exceptions aren’t supported in WebAssembly yet, so you can’t use them in AssemblyScript. Closures aren’t available either, but you can always rewrite the code to avoid using them. Since they are considered a critical language feature, the team is working to implement them ahead of the WebAssembly support. At the time of writing, it’s still in beta and limited to read-only captures. You can try it out with

npm install assemblyscript-closures-beta

Hello World Example With AssemblyScript

Playing around with AssemblyScript is pretty straightforward. You should have a recent version of Node.JS that supports WebAssembly installed.

Create an empty directory and initialize a new node module:

mkdir as-hello

cd as-hello

npm init

Install both the loader and the compiler:

npm install --save @assemblyscript/loader

npm install --save-dev assemblyscript

The compiler provides convenient utility to set up a new AssemblyScript project quickly. Just run:

npx asinit .

Now you can build the project:

npm run asbuild

And run the tests:

npm test

Let’s examine the project we’ve just created:

asconfig.json contains the configuration for the AssemblyScript compiler. It defines debug and release targets with different optimization levels:

{
  "targets": {
    "debug": {
      "binaryFile": "build/untouched.wasm",
      "textFile": "build/untouched.wat",
      "sourceMap": true,
      "debug": true
    },
    "release": {
      "binaryFile": "build/optimized.wasm",
      "textFile": "build/optimized.wat",
      "sourceMap": true,
      "optimizeLevel": 3,
      "shrinkLevel": 1,
      "converge": false,
      "noAssert": false
    }
  },
  "options": {}
}

The entry point for web assembly code is assembly/index.ts. It exports a single add function:

export function add(a: i32, b: i32): i32 {
  return a + b;
}

Notice that it’s AssemblyScript, so both parameters and results are i32 (32-bit integer) instead of TypeScript number.

index.js loads web assembly and exposes it’s for JavaScript. You can also add javascript imports that will be available to AssemblyScript code here:

const fs = require("fs");
const loader = require("@assemblyscript/loader");

const imports = { /* imports go here */ };
const wasmModule = loader.instantiateSync(
  fs.readFileSync(__dirname + "/build/optimized.wasm"), 
  imports);

module.exports = wasmModule.exports;

There’s a small test in tests/index.js that imports the module and invokes the web assembly function:

const assert = require("assert");
const myModule = require("..");
assert.equal(myModule.add(1, 2), 3);
console.log("ok");

In the package.json we can find the scripts that we’ve executed after scaffolding the project. There are two scripts to build each target and asbuild script that runs both of them:

"scripts": {
    "test": "node tests",
    "asbuild:untouched": "asc assembly/index.ts --target debug",
    "asbuild:optimized": "asc assembly/index.ts --target release",
    "asbuild": "npm run asbuild:untouched && npm run asbuild:optimized"
 }

AssemblyScript Performance Benchmarks

Venkatram, Nischay, “Benchmarking AssemblyScript for Faster Web Applications” (2020). Creative Components. 558

The main reason to use web assembly in your application is the performance benefits it brings.

Nischay Venkatram tested different algorithms in AssemblyScript and JavaScript, running in Chrome and Firefox. His scientific study is available here. Some of the conclusions:

This study showed that Assemblyscript does in fact provide performance benefits in most cases. It also showed that idiomatic Typescript code may not always be fast in Assemblyscript. But these are mostly limitations due to lack of maturity. As Webassembly starts to support features like SIMD, multi-threading, and Garbage Collection, and tooling infrastructure like Binayen improves, Assemblyscript will only become more sophisticated and easy to use. Aside from performance benefits, this paper showed that Assemblyscript (or Webassembly) is more predictable in its performance since it doesn’t fall off the fast path frequently and get deoptimized by the browser engine like Javascript.

Venkatram, Nischay, “Benchmarking AssemblyScript for Faster Web Applications” (2020). Creative Components. 558

When evaluating AssemblyScript versus TypeScript for a client project, we also found that WebAssembly performance was much more consistent and predictable. Still, the difference in real-world scenarios wasn’t that huge. Let’s write some benchmarks to understand what causes this difference. We’ll be using Node.js and the benchmark library to get statistically significant results.

Add Benchmark

Let’s test the performance of calling the add function from the example:

export function add(a: i32, b: i32): i32 {
  return a + b;
}

It’s a simple test, so the difference is the cost of crossing JavaScript - WASM boundary and calling a function there:

Benchmarking add:
AssemblyScript x 125,765,014 ops/sec ±0.37% (96 runs sampled)
JavaScript x 697,262,505 ops/sec ±0.05% (99 runs sampled)
JavaScript is faster

Calling WASM functions is pretty expensive, and JavaScript is almost six times faster here.

Factorial Benchmark

How about adding some computations? We’ll test simple recursive factorial function that does a bit more CPU-intensive work inside WASM:

export function factorial(i: i32): i32 {
  return i == 0 ? 1 : i * factorial(i - 1);
}

WASM achieves near-native performance with computations, and the results are as expected:

Benchmarking factorial:
AssemblyScript x 14,204,553 ops/sec ±0.50% (97 runs sampled)
JavaScript x 6,478,385 ops/sec ±0.27% (94 runs sampled)
AssemblyScript is faster

Even lightweight computations in AssemblyScript are two times faster than JavaScript.

Squaring Array Benchmark

Maybe we can leverage WASM for all computations in the application? Real-world applications usually map-reduce large amounts of data, so let’s test the data transfer performance by simply squaring all elements of the array:

export function squareArray(arr: Int32Array): Int32Array {
  const len = arr.length;
  const result = new Int32Array(len);
  for (let i = 0; i < len; ++i) {
    const e = unchecked(arr[i]);
    unchecked(result[i] = e * e);
  }
  return result;
}

Note that we’re using typed Int32Array instead of i32[], which directly maps the memory and is a bit faster. We’re also using unchecked() to eliminate array boundary checking that will slightly decrease the performance. The results compared to JavaScript are somewhat disappointing:

Benchmarking squareArray:
AssemblyScript x 432,270 ops/sec ±1.32% (87 runs sampled)
JavaScript x 822,068 ops/sec ±2.31% (85 runs sampled)
JavaScript is faster

Javascript is almost two times faster in this test. The explanation is simple - you can’t easily pass JavaScript arrays to WASM and back. The WebAssembly module has its dedicated memory with a different representation of an array. You have to create an array in the module’s memory, copy all the input data there, then call the function, which will return a pointer to the result array in WASM memory that you have to convert back to JavaScript array. The wrapper function that does all of these using helper methods from the AssemblyScript loader looks like this:

function squareArrayWrap(array) {
  const {
    __newArray,
    __getInt32Array,

    Int32Array_ID,
    squareArray,
  } = wasmModule.exports;

  const arr = __newArray(Int32Array_ID, array);

  const result = __getInt32Array(squareArray(arr));

  return result;
}

The __getInt32Array function copies the AssemblyScript Int32Array array to the new JavaScript array.

There’s also a faster way to access the WASM array without copying it. The __getInt32ArrayView just returns the view of it in the WASM memory, but you have to __pin it before use and __unpin afterward so that GC won’t move it in memory while JS code might still access it.

The loader exposes specific functions for typed arrays along with generic __getArray and __getArrayView for regular type[] arrays.

The __newArray helper function creates an array in the module’s memory and initializes it with the data from JavaScript’s array-like parameter. The first argument is the type of array. You have to export it from AssemblyScript like this:

export const Int32Array_ID = idof<Int32Array>();

For this to work, you also have to export runtime while building the module. You can do this either with a command-line argument --exportRuntime or "exportRuntime": true in the asconfig.json file. Without this flag, __newArray will fail with weird and confusing errors like the type is not an array.

Sinus Lookup Table Benchmark

Let’s combine data transfers with a bit of computation. This function generates the sinus function lookup table for fast approximations:

export function calcSinLookup(): Float64Array {
  const max = 6283;
  const result = new Float64Array(max);

  for (let i = 0; i < max; ++i) {
    unchecked(result[i] = Math.sin(i * 0.001));
  }

  return result;
}

Here are the benchmark results of calculating an array of 6283 sinuses:

Benchmarking calcSinLookup:
AssemblyScript x 11,450 ops/sec ±0.68% (94 runs sampled)
JavaScript x 11,417 ops/sec ±1.35% (96 runs sampled)
AssemblyScript,JavaScript is faster

Performance is pretty much the same, and that’s with copying all the results to a new array in the AssemblyScript test. Computations are indeed much quicker in WASM.

JavaScript Callbacks Benchmark

Now let’s test how expensive in terms of performance is calling JavaScript functions from AssemblyScript. WASM doesn’t provide Web Apis or DOM access out of the box, so you’ll probably have to manually import some of them into the module in real applications. Our test function will just calculate the sum of N integers, invoking the callback function for adding each number:

declare namespace test {
  @external("test", "importCallback")
  export function importCallback(a: i32, b: i32): i32;
}

export function testImport(n: i32): i32 {
  let result: i32 = 0;

  for (let i = 1; i <= n; ++i) {
    result = test.importCallback(result, i);
  }

  return result;
}

In index.js we have to provide the imports:

function importCallback(a, b) {
  return a + b;
}

const imports = {
  test: {
    importCallback,
  },
};

The results are shocking:

Benchmarking importCallback:
AssemblyScript x 24,366 ops/sec ±0.10% (96 runs sampled)
JavaScript x 351,545 ops/sec ±0.12% (95 runs sampled)
JavaScript is faster

Invoking a simple JavaScript callback from AssemblyScript is almost 15 times slower than plain JavaScript.

Conclusion

Computations in WebAssembly are indeed much faster than in JavaScript, achieving near-native performance levels. More importantly, the performance is stable and predictable, while in JavaScript, it may fluctuate a lot. AssemblyScript provides a much lower entry barrier to WASM for JavaScript and especially TypeScript developers.

Keep in mind that AssemblyScript is not exactly TypeScript. Some important language features, like closures, are not implemented yet. Passing objects, arrays, and strings involves copying data and writing some low-level code to do that. The loader is evolving, becoming more mature, and automatically handling some complex cases while providing low-level methods to do everything else. AssemblyScript already has multiple implementations of garbage collection that’s still lacking in WebAssembly standards, greatly simplifying memory management for programmers. WASM also has more precise number types than JavaScript’s number.

When considering using WebAssembly in your application, you have to ensure that it will bring more benefits than harm. Our tests showed that passing large amounts of data back and forth is slow and may demolish the performance bonus of WASM computations in many cases. The calculations have to be quite heavy to justify the memory copying. On top of that, WebAssembly adds a lot of complexity to the project. Additional build step, wrapper functions, passing the data, lack of multithreading and asynchronous code, interoperability, etc.

We believe that WebAssembly is the future of the web. We love AssemblyScript for the seamless experience it provides when developing the modules.

It’s just not there yet.

For some projects involving image/video processing, 3d graphics, physics, or scientific calculations, the time has probably come. Our client is building an analytics platform with lots of math on the front end, and after evaluating the pros and cons of WebAssembly, we decided to stick with TypeScript for the engine.

Maybe your project is different, and AssemblyScript will be a perfect fit. Tell us more using the form below, and we’ll gladly help you.

P. S. The source code of the benchmarks is available on GitHub.