EDIT: For the latest of GopherJS/Wasm comparison, see Wasm benchmark result
Hi all!
This article describes about my experiment of the new WebAssembly port of Go. WebAssembly port is now available on the master branch of Go, and you'd need to compile Go yourself.
tl;dr
- I have created GopherWasm, an agnostic WebAssembly wrapper that works both on GopherJS and WebAssembly port.
- Performance of GopherJS and WebAssembly depends on browsers. GopherJS is faster than WebAssembly on some environments, and slower on other environments. For Ebiten 'sprite' example,
(GopherJS on Chrome) > (WebAssembly on Firefox) > (GopherJS on Firefox) > (WebAssembly on Chrome)
with 5000 sprites.
Go on browsers
Running Go applications on web browsers must be awesome. Needless to say, Go is an awesome language. I don't discuss how good Go is in this article :-)
There is a transpiler from Go to JavaScript - GopherJS by Richard Musiol. This also enables Go programs to run both on browsers and Node.js. You can use all the features of Go. The compilation result is reasonably readable JavaScript. The performance is so-so due to some overhead. To emulate Go behaviors precisely, GopherJS adds some overhead like boundary check of index access to slices. Instead of emulating Go behavior by JavaScript, executing binaries like WebAssembly on browsers seems much more efficient.
WebAssembly is a performance-wise format compared to JavaScript. WebAssembly is supported by most of modern browsers. WebAssembly is a low-level language as the name says, and it is expected that WebAssembly binary is generated from other languages. Actually C, C++ and Rust already support WebAssembly port.
The latest Go version 1.11 supports WebAssembly port by Richard Musiol, the same author of GopherJS. Now Go 1.11 is on the way releasing, but you can test WebAssembly APIs with the latest Go by compiling yourself. Your compiled program for WebAssembly is available both on browsers and Node.js. You can use full features of Go including goroutines. You can call any JavaScript functions from Go, and you can pass Go function as a JavaScript callback. The API is defined at syscall/js
package. The environment variables for WebAssembly are GOOS=js
and GOARCH=wasm
. As WebAssembly is performance-wise format, this should be faster than GopherJS, right? Unfortunately, this was not true. I'll describe this later.
Ebiten
Ebiten is a dead simple 2D game library by me. This is basically an OpenGL wrapper. This works on browsers with WebGL by GopherJS, and actually you can see some examples work on the website and the jsgo playground by Dave Brophy. Recently (actually today!) I fixed Ebiten (master branch) to accept WebAssembly compilation of the latest Go compiler except for the audio part. Thus, Ebiten now works both on GopherJS and WebAssembly!
Port GopherJS library to WebAssembly
As I said, Ebiten can already work with GopherJS. GopherJS's API is similar to WebAssembly, but different. For example, the counterpart of js.Object
of GopherJS is js.Value
of syscall/js
.
Then, how can I write libraries to accept both GopherJS and WebAssembly? Of course it is easily possible to write similar duplicated code, but isn't there a more elegant way?
I've created GopherWasm, an agnostic WebAssembly wrapper. If you use GopherWasm, your library automatically works both on GopherJS and WebAssembly port! GopherWasm API is almost same as syscall/js
. The only one difference is js.ValueOf
accepts []float32
or other slices in GopherWasm, not in syscall/js
. I have already filed to fix syscall/js.ValueOf
to accept such slices, so the situation might change in near future.
Performance comparison
I've compared the performances between GopherJS and WebAssembly port with my Ebiten example 'sprites'.
By pressing left or right arrow keys, you can change the number of sprites and see how FPS (frames per second) changes.
On my MacBook Pro 2014, I took very rough measurements by showing 5000 sprites:
GopherJS on Chrome: 55-60 FPS
GopherJS on Firefox: 20-25 FPS
WebAssembly on Chrome: 15-20 FPS
WebAssembly on Firefox: 40-45 FPS
- Chrome: Version 67.0.3396.87 (Official Build) (64-bit)
- Firefox: 60.0.2 (64-bit)
- Ebiten: 460c47a9ebaa21bcce730a460a7f87fa6cbe56ed
- Go: 534ddf741f6a5fc38fb0bb3e3547d3231c51a7be
This is a very interesting result. Before this experiment, I thought WebAssembly should always be faster than GopherJS. However, the result depended on browsers. For 5000 sprites, the result was (GopherJS on Chrome) > (WebAssembly on Firefox) > (GopherJS on Firefox) > (WebAssembly on Chrome)
. I guess optimization way is different among browsers.
I took rough profile and it looks like allocation (runtime.mallocgc
) was the heaviest task on WebAssembly. This is different tendency from GopherJS. I'm not sure the details how objects are allocated on WebAssembly, but at least WebAssembly requires different optimization from GopherJS.
I plan to do optimization to keep 60 FPS as much as possible. Stay tuned!
Binary size comparison
-rw-r--r-- 1 hajimehoshi staff 7310436 Jun 16 05:23 sprites.js
-rw-r--r-- 1 hajimehoshi staff 278394 Jun 16 05:23 sprites.js.map
-rwxr-xr-x 1 hajimehoshi staff 8303883 Jun 16 04:03 sprites.wasm
It looks like WebAssembly binary is slightly bigger.
Appendix - How to do experiments
Install Ebiten and other libraries
go get -u github.com/hajimehoshi/ebiten/...
go get -u github.com/hajimehoshi/gopherwasm
go get -u github.com/gopherjs/gopherjs
Get the latest Go and compile it
cd
git clone https://go.googlesource.com/go go-code
cd go-code/src
# Compile Go. ./all.bash is also fine if you want to run tests.
./make.bash
Compile an Ebiten example for WebAssembly
cd /path/to/your/wasm/project
# Compile 'sprites' example for WebAssembly
GOOS=js GOARCH=wasm ~/go-code/bin/go build -tags=example -o sprites.wasm github.com/hajimehoshi/ebiten/examples/sprites
# Copy wasm_exec.js
cp ~/go-code/misc/wasm/wasm_exec.js .
Prepare an HTML file to run the wasm file. This file is based on ~/go-code/misc/wasm/index.html
.
<!DOCTYPE html>
<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("sprites.wasm"), go.importObject).then(result => {
go.run(result.instance);
});
</script>
Run an HTTP server as you like.
Run GopherJS server
gopherjs serve --tags=example
Then access http://localhost:8080/github.com/hajimehoshi/ebiten/examples/sprites/
to see the example.
Top comments (5)
Can you try replacing setTimeout in L307 of wasm_exec.js (github.com/golang/go/blob/master/m...) with requestAnimationFrame and see if that gives any boost in performance ?
Thanks, I'll try. I'm worried that this might conflict with rAF on the game side.
My worry was right: FPS is now up to 30 when the number of sprites are small (e.g. 100 or so), while FPS was 60 before the fix.
Got it. Thanks for giving it a shot.
This was really interesting to read! I also expected WebAssembly to be faster. I'm definitely going to look into GopherJS and Ebiten.