-
Notifications
You must be signed in to change notification settings - Fork 18.4k
Description
Introduction
It is truly remarkable what can be accomplished by compiling Go to WebAssembly (wasm
), but virtually all such applications are being held back by the combination of two factors:
- Every function call to JS that passes arguments makes multiple, unavoidable Go heap allocations
- The Go garbage collector then blocks the main, and only, thread for a large fraction of a second
As the title of this proposal suggests, the performance problems can be solved if the js.Wrapper
type is removed. Removal of existing features from the standard library should not be taken lightly, but this particular removal is both allowable (syscall/js
is exempt from Go's compatibility promise) and necessary given the data.
Why Wrapper
matters
At the core of every call to JS, syscall/js
uses its ValueOf
function to convert Go arguments to their JS equivalents. This conversion is implemented as a switch statement:
func ValueOf(x interface{}) Value {
switch x := x.(type) { // some cases omitted
case Value:
return x
case Wrapper:
return x.JSValue() // the problem
case nil:
return valueNull
case bool:
if x {
return valueTrue
} else {
return valueFalse
}
case int:
return floatValue(float64(x))
case unsafe.Pointer:
return floatValue(float64(uintptr(x)))
case float64:
return floatValue(x)
case string:
return makeValue(stringVal(x))
default:
panic("ValueOf: invalid value")
}
}
One of the cases is the Wrapper
interface. This was intended to allow any type implementing the JSValue
method to be easily passed as an argument to a JS method. However, as JSValue
may consist of an arbitrary pointer receiver, the compiler must assume the worst:
type BadWrapper struct {
Value js.Value
}
var escapeRoute *BadWrapper
// Implements js.Wrapper
func (this *BadWrapper) JSValue() js.Value {
escapeRoute = this // escape to heap
return this.Value
}
This means that the argument to ValueOf
, which includes any argument passed to any JS function, is guaranteed to escape to the heap.
Data
Data is reported as number of heap allocations per unit of work.
Optimizations (cumulative) | Frame of game (best case) | Frame of game (worst case) | Typical canvas/WebGL call | []byte copy to JS |
string manipulation |
Garbage collector effect on gameplay |
---|---|---|---|---|---|---|
Control (no optimizations) | 858 | 40000 | 3 | 1 | 3 | Frequent stutters |
//go:noescape |
829 | untested | 3 | 0 | 3 | Frequent stutters |
makeArgs slices on stack (See #39740) |
383 | untested | 1 | 0 | 3 | Less-frequent stutters |
Unexport Wrapper.JSValue() |
383 | untested | 1 | 0 | 3 | No change |
Remove Wrapper type |
62* | 300* | 0 | 0 | 2 | Not bad, especially after other allocations are reduced |
*on the order of the number of allocations that happen unrelated to syscall/js
Data collection method
- Debug output from my online game, implemented in Go
- Micro-benchmarks: main.go
- Entire patch to
syscall/js/js.go
: js.go.patch
Context
Preliminary discussion of this proposal has has already taken place in #39740. The consensus there was that removing Wrapper
is the only way to sustainably call JS functions in a performance-critical setting. Given that syscall/js
is a low level package, it make sense to prioritize performance over the ease of use afforded by Wrapper
.
More context on my actual use case
- My game's client is written in
Go
, compiled toWebAssembly
, usesWebGL 2.0
APIs, and is intended to run at 60 frames per second. - The WebGL API requires many function calls, each mainly taking integer and floating point parameters.
- I have observed to Go garbage collector running every 3-5 seconds under normal circumstances.
- I have observed the Go garbage collector running over 10 times in a single frame in a worst-case-scenario
- I have observed the Go garbage collector taking 300+ milliseconds to run