About JavaScript Performance (my master's thesis)

October 01, 2020 ~ 6 min read

This post is for those who are curious about my research work at McGill University and want to learn more. I have linked to the reports and thesis if you would like delving some more.

I have been asked about my master's thesis and other research publication I had during my master's program at McGill University. I have been asked this question by a few interviewers, colleagues as well as people generally looking for improving performance of their JavaScript code. I have a few other posts on how to improve performance, if you are interested. Note that this post is about application performance and not just the rendering performance that react, sevlte and other related frameworks usually talks about.

I will start this post with the findings from our research publication about JavaScript performance, and then move to talk about my thesis. Even though this post is not exactly about how to improve JavaScript Application performance, maybe it will provide you a better understanding of how JavaScript works.

Research

There are numerous applications to doing numerical computations in client-side JavaScript, from image processing to machine learning to financial modelling to data analytics. However, developers have mostly stayed away from creating these fat client applications with a good reason. For example, if you expect the result of 0.2 + 0.1 to equal 0.3 in JavaScript, you have never done addition in JavaScript. Go ahead and open developer tools console in your browser and do this calculation and find out what the answer is, I'll wait. Also, general perception is that since JavaScript is interpreted, it isn't fast enough for numerical computations.

So our lab set out to put that assumption to test. We developed Ostrich Benchmark to gauge different types of numerical computations. We generated benchmarks for C (baseline), ASM.JS, JS (with regular arrays), JS (with typed arrays), OpenCL (parallel base) and experimental WebCL from Mozilla. WebAssembly was just a concept at the time. You can read the paper for full analysis of the benchmarks and results, but in conclusion, we found that JavaScript is not that slow for numerical computation. In fact, the ASM.JS benchmarks were just 10% slower than the baseline on average.

asm.js and Typed arrays

By now, typed arrays have gained popularity and there are a few new ways to access binary data in efficient manner. But back in 2013, there was no study done on the performance of typed arrays for numerical computation. Our benchmarks showed that typed arrays are significantly more performant than regular arrays, and if your data contain unsigned integers, you will see massive gains in the performance of your algorithms. However, there is one caveat. The typed arrays take a bit longer to initialize. When we create a typed array, the array also gets filled with zeroes. When we create a regular array, the array doesn't get filled with any value and is much faster. So if your algorithm frequently creates the arrays and destroys them (maybe to maintain immutability), then you would be better off sticking with regular arrays than typed arrays.

asm.js is a subset of JavaScript, created to be a compilation target that can run on browsers with greater speed and efficiency. Firefox pioneered asm.js and they created Ahead-of-Time compilation that detects the asm.js code and compiles it to native code before execution. Because of this compilation to native code, you'd get almost native speed on Firefox engine. In our experiments, we found that the asm.js code compiled from C code is only 10% slower than the original C code on Firefox and we believe the gap should have lowered with the newer version.

"Web Assembly" builds upon the same idea but doesn't maintain EcmaScript standards and is now more popular among the two, but those who want the browser compatibility still fall back to asm.js.

McNumJS

What I was curious about was if we could develop a library like numpy with flexible APIs and that uses asmjs like type annotations and typed arrays to get better numerical computational performance. It that is achieved, then we get good APIs while getting better performance.

This library can be used directly since the API is easy-to-use. It can also be used as a feature filling library for compilers that targets JavaScript as the target language. For example, compilers targeting JavaScript from Matlab or R can make use of this library to generate JavaScript code that almost look like original code and abstract the source language features with this library.

// constructor: Int32Array(object, shape)
const intView = new Int32Array([1,2,3,4,5,6], [2,3]);
// 2x3 Int32 Matrix default row-major
/**  1 2 3
  *  4 5 6
  *  => [1, 2, 3, 4, 5, 6]
  */
console.log(intView.size);      // 6
console.log(intView.shape);     // [2, 3]
console.log(intView.stride);    // [3, 1]
console.log(intView.get(0,1));  // 2
// index: 0*3+1*1 = 1

const intView2 = intView.clone().reshape(3,2);
/**  1 2
 *   3 4
 *   5 6
 *   => [1, 2, 3, 4, 5, 6]
 */

// mn.zeros(shape [, className]);
const z = mn.zeros([6, 6]);
// Float64Array of shape 6x6

// mn.ones(shape [, className]);
const o = mn.ones([6, 6], Int32Array);
// Int32Array of shape 6x6 filled with 1

// mn.rand(shape [, className]);
const r = mn.rand([6, 6], Int32Array);
// Int32Array of shape 6x6 filled with random numbers

// mn.linspace(start, stop [, n [, className]]);
const l = mn.linspace(0, 99);
// Float64Array of size 100 filled 0 ... 99

// mn.range(start, stop [, step, [className]]);
const rn = mn.range(10, 1);
// Float64Array of size 10 filled 10 ... 1

// mn.identity(N [, className]);
const i = mn.identity(5, Uint8Array);
// Identity matrix of type Uint8Array and shape 5x5

mn.fill(matrix, value);
// Fills the matrix with given value

mn.cos(matrix);
// Point-vise cosine of elements.

const s = mn.sum(matrix);
// Sum of all matrix elements

const t = mn.transpose(matrix);
// Transpose of the matrix. This function does not return a new 
// copy of data but changes the shape and stride of the array.

const out = mn.add(in1, in2);
// pointwise addition of in1 and in2 matrices

Performance

For the performance part, I recreated the ostrich benchmarks with this library and compared the results with JavaScript no typed arrays, with typed arrays, compiled asm.js and C versions. We can guess that the library should perform better than no typed arrays and worse than compiled asm.js or C versions. Our aim is to lower the impact of the worse part and higher the impact of the better part. Checkout the results yourself and judge yourself if we achieved it or not. If you have patience, you can read the entire thesis.

TLDR;

It is possible to get really good numerical computation performance with JavaScript Typed Arrays and asm.js like constructs. You don't need to write the asm.js code by hand, you can just know a few constructs and JIT compiler will still be able to optimize your code very efficiently.