I'm a javascript developer at my core. I like my stuff interpreted, loosely typed, and runnable in web browsers. C/C++ things like managing memory, pointers, etc, that real computer science stuff is usually too much for my squishy OOP brain.
*. Now most days, this is fine, but if you've looked at
snooze you'll see that I do a lot of array math there.
† Lately I've been getting a point where javascript just won't perform fast enough to keep the image rendering smooth.
‡ From here I had a few options: give up, rewrite everything not in javascript, or learn some language that transpiled to webassembly (or write raw webassembly).
Wait wait wait what's webassembly!?
Okay us as mentioned earlier, I'm not good at computer science, but I do know about assembly is like raw computer commands. Shit like "go to this address, read the value, compare it to this other value". It was a hard class but super rewarding and you felt like an absolute wizard when you did stuff. So when people smarter than me also decided that the browser wasn't fast enough, they invented WebAssembly,
assembly code that gets ran in the browser! WebAssembly aka WebAss aka wasm is a bunch of things, the two primary being "hard to read" and "fast".
For quite awhile the "hard to read" repulsed me more than the "fast" attracted, but then
AssemblyScript saved the day! § Which is super cool! I've been struggling through a bunch of problems and wanted to get more information about AS out there.
1: Starting Point
Original
overlay function:
Timings:
For context: snooze takes the data from 2 images, performs a merge-type operation, then renders it onto a central screen. This op gets ran 30~ times per second so performance is kind of important. What you're looking at this is the
handler function that
renderScreenContents using the overlay function.
¶ overlay written in pure js takes 15.4% of the runtime. Let's trying moving that to wasm.
2: Porting it over to AssemblyScript
My first step was to copy the function over to my configured directory with the asconfig.json, then run
asc. And bam! error
AS seems rough in some places (and I have no idea how to set up a dev env) and this was one of the gotchas: no arrow functions, just real functions.
Here's another gotchas, because I want to pass data into a function later on, I need this export. I'll talk about it more later, but tl;dr it provides a way for AS to persist an array in a way that wasm can use. I'm using a Uint8ClampedArray because that's what the getImage/putImage api expects. Time to run
asc again. We have two pieces of output now, a wasm file and a type file.
Notably, you can see all the AS types, the array id, the migrated
overlay function, more in progress code, and a bunch of things that are added when you build with the
"exportRuntime". We've got the wasm, let's look at how we run it from js.
3: Back to js-land
I actually added some comments here because uhhhh documentation is good. There's a couple major steps here:
1: Load the wasm with instantiateStreaming. Since you can't inline wasm in js (or you probably shouldn't), you'll need to load the file from another source. And loading from another source means async behavior!
# Once the wasm module is loaded we grab some of the exports,
overlay,
__getUint8ClampedArray, and
__newArray. Next we return a named function
♠ that creates a newArray using the leftArray, keyed to the id of the Uint8ClampedArray, does the same for the rightArray, and finally returns the result of wasmOverlay. Now it doesn't directly return that, it "extracts" the data from the wasm runtime with the
__getUint8ClampedArray function. Without that the return of the wasm function is just a pointer (in the form of a number) to where the array lives in memory. Let's see how it runs!
4: oh god oh christ what have i done this was a mistake
We've managed to take a function that ran at 15.4% of the runtime, apply wasm, and get it to take 33.4% of the runtime. Bad times. Before we start panicking, let's see exactly what's eating up that time.
So the cool parts are that
__newArray and
getTypedArray, the functions that pass in and pull out data from the wasm runtime are super quick. It's just my code that is slow. At this time I got real depressed and in the interest of not depressing you, I'll skip to the solution:
being reckless.
unchecked is an AS decorator-y thing that basically says "hey i know normally you'd add a block to make sure that the runtime doesn't bomb if the data is not in the correct shape, but I'm an adult and you can trust me." So pretty much the "! yeah just trust me the data will be here" in typescript. I applied the
unchecked to all the places I saved data to the array and:
it got better!
Adding those uncheckeds cut it down to 29.5% of the runtime, let's add more!
5: almost done
16.6% of the runtime is pretty cool for js calling wasm, but it's still worse than where we started. I'm pretty stubborn so I looked for some more optimizations and flipped the while and if statements
bringing it down to
11.3%! That's good enough for me! Time to merge!
6: final thoughts
Okay I'm not crazy about what I did at the end there. Maybe if I just did the while/if bit in js it would have been faster than where I'm at now. I'm not going to do that for two reasons: I'm afraid I just wasted a bunch of time and because I don't plan on stopping here. Now that I've kind of got an idea what's going on I think I might be able to optimize the wasm more and, more interestingly, move more of the entire project runtime into wasm. The interop still took 2.3% of the runtime, so if I'm able to reduce how often that needs to happen, the code will run even faster!