New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
syscall/js: increase performance of Call, Invoke, and New by not allowing new slices to escape onto the heap #39740
Comments
@cherrymui Would this be a good case for |
…calling js functions (see golang#39740)
I've formulated 3 fully complete solutions to the problem:
My suggestion is for you to:
For what it's worth, I have validated all of the above solutions in my WebAssembly game to get an idea of their performance, and observed no crashing/panicking/issues. |
I've been looking for a solution to this ever since I filed the issue, and ended up with a patch that solves the problem (both the slice problem and the interface escaping to heap problem) for me. Specifically, heap allocations per frame went from ~1500-40,000 (almost all related to patch -u $GOROOT/src/syscall/js/js.go -i see_below.patch --- js.go.dist 2021-01-23 15:50:00.931644132 -0800
+++ js.go 2021-01-23 17:31:54.938784167 -0800
@@ -145,6 +145,11 @@
return valueGlobal
}
+func noescape(foo interface{}) interface{} {
+ bar := uintptr(unsafe.Pointer(&foo))
+ return *((*interface{})(unsafe.Pointer(bar ^ 0)))
+}
+
// ValueOf returns x as a JavaScript value:
//
// | Go | JavaScript |
@@ -159,7 +164,8 @@
// | map[string]interface{} | new object |
//
// Panics if x is not one of the expected types.
-func ValueOf(x interface{}) Value {
+func ValueOf(a interface{}) Value {
+ x := noescape(a)
switch x := x.(type) {
case Value: // should precede Wrapper to avoid a loop
return x
@@ -215,11 +221,12 @@
o.Set(k, v)
}
return o
- default:
- panic("ValueOf: invalid value")
}
+ runtime.KeepAlive(a)
+ panic("ValueOf: invalid value")
}
+//go:noescape
func stringVal(x string) ref
// Type represents the JavaScript type of a Value.
@@ -303,6 +310,7 @@
return r
}
+//go:noescape
func valueGet(v ref, p string) ref
// Set sets the JavaScript property p of value v to ValueOf(x).
@@ -317,6 +325,7 @@
runtime.KeepAlive(xv)
}
+//go:noescape
func valueSet(v ref, p string, x ref)
// Delete deletes the JavaScript property p of value v.
@@ -329,6 +338,7 @@
runtime.KeepAlive(v)
}
+//go:noescape
func valueDelete(v ref, p string)
// Index returns JavaScript index i of value v.
@@ -342,6 +352,7 @@
return r
}
+//go:noescape
func valueIndex(v ref, i int) ref
// SetIndex sets the JavaScript index i of value v to ValueOf(x).
@@ -356,11 +367,24 @@
runtime.KeepAlive(xv)
}
+//go:noescape
func valueSetIndex(v ref, i int, x ref)
-func makeArgs(args []interface{}) ([]Value, []ref) {
- argVals := make([]Value, len(args))
- argRefs := make([]ref, len(args))
+var (
+ argValsSlice []Value
+ argRefsSlice []ref
+)
+
+func makeArgs(args []interface{}) (argVals []Value, argRefs []ref) {
+ for i, _ := range argValsSlice {
+ argValsSlice[i] = Value{}
+ }
+ if len(args) > cap(argValsSlice) {
+ argValsSlice = make([]Value, 0, len(args))
+ argRefsSlice = make([]ref, 0, len(args))
+ }
+ argVals = argValsSlice[:len(args)]
+ argRefs = argRefsSlice[:len(args)]
for i, arg := range args {
v := ValueOf(arg)
argVals[i] = v
@@ -380,6 +404,7 @@
return r
}
+//go:noescape
func valueLength(v ref) int
// Call does a JavaScript call to the method m of value v with the given arguments.
@@ -402,6 +427,7 @@
return makeValue(res)
}
+//go:noescape
func valueCall(v ref, m string, args []ref) (ref, bool)
// Invoke does a JavaScript call of the value v with the given arguments.
@@ -421,6 +447,7 @@
return makeValue(res)
}
+//go:noescape
func valueInvoke(v ref, args []ref) (ref, bool)
// New uses JavaScript's "new" operator with value v as constructor and the given arguments.
@@ -440,6 +467,7 @@
return makeValue(res)
}
+//go:noescape
func valueNew(v ref, args []ref) (ref, bool)
func (v Value) isNumber() bool {
@@ -539,8 +567,10 @@
return string(b)
}
+//go:noescape
func valuePrepareString(v ref) (ref, int)
+//go:noescape
func valueLoadString(v ref, b []byte)
// InstanceOf reports whether v is an instance of type t according to JavaScript's instanceof operator.
@@ -551,6 +581,7 @@
return r
}
+//go:noescape
func valueInstanceOf(v ref, t ref) bool
// A ValueError occurs when a Value method is invoked on
@@ -577,6 +608,7 @@
return n
}
+//go:noescape
func copyBytesToGo(dst []byte, src ref) (int, bool)
// CopyBytesToJS copies bytes from src to dst.
@@ -591,4 +623,5 @@
return n
}
+//go:noescape
func copyBytesToJS(dst ref, src []byte) (int, bool) |
I'm excited about this suggestion and change, @finnbear! |
I believe so too: the argument slice is used only when a callback is invoked from the Go side, and this invoking should never happen recursively. @neelance what do you think? I don't have any insights about |
The documentation of
If I understand this correctly, then For a case like It might also apply to I don't understand what's going on with |
I was still wondering if we really need |
Me too!
Yes, at the minimum for
You're 100% right. I just added it to all functions on the basis that none should cause pointers to escape, but it would have no effect when there are no pointers involved. It should be applied in the minimal number of cases though, so that one should be removed.
To elaborate on this, I think it is safe even if the call is re-entrant. The chronology of events would look like this:
I might be missing something but I don't think there is an issue with the global slice approach (as long as
Yes, and yes.
Yeah, I can see why that looks bad. What it does: The How it works: XOR-ing a pointer type with 0 doesn't change the pointer, but Go can't track that the pointer is the same. Why it's needed: First, we need a command that shows us when and why things escape in Basic 'does it escape' check: Running this, it can be determined that too many variables called Advanced 'why does it escape' check: Running this, it can be determined that there are two ways things escape.
How it could be made better/more "safe":
There are two types of heap allocations and slice(s) are only one of them. In my original proposal, I didn't propose a way to eliminate the second type of allocations: interfaces. As long as the slices contain pointers related to interfaces, and the I'd be happy to investigate more (how the patch could be made better, safer, and more broadly applicable) and answer other questions, as I want to get something like this into the standard library so that something as basic as a function call doesn't allocate. Also, I've deployed the patch to my game and will continue to monitor it (so far no issues, bearing in mind I know not to call the new Thanks @hajimehoshi and @neelance for developing |
Okay, I think I slowly manage to wrap my head around this... The You have mentioned the three problematic cases:
|
You're not wrong 😉
I see what you mean. However, I have read
I did not consider that. Sounds good, since
You're right that the interface's pointer is immutable. However, the relevant issue is where that pointer points to (heap vs off heap). GC doesn't really know if the variant of type BadWrapper struct {
Value js.Value
}
var escapeRoute *BadWrapper
// Implements js.Wrapper
func (this *BadWrapper) JSValue() js.Value {
escapeRoute = this
return this.Value
} so it assumes the worst. This is why a solution specific to Side note: As far as I know, the only type I use that implements
I doubt this will work (I think GC will see right through it) but I can test it. |
Right, it won't work. I thought the problematic pointer was the interface itself, but it is the value that the interface contains. |
The only option I see is to get rid of I would be okay with the tradeoff. |
@dennwc What's your take on this? You contributed http://golang.org/cl/141644 which we would need to revert. |
I'm all for performance here, so feel free to revert. That's unfortunate though, since it was quite useful to allow custom objects to be passed to native Is there any way we can solve this? E.g. make a private interface method on |
Let's test it! As a baseline, with no I'll start by applying Here's the optimization output before your idea: ./js.go:162:14: parameter x leaks to {heap} with derefs=0:
./js.go:162:14: flow: {temp} = x:
./js.go:162:14: flow: x = {temp}:
./js.go:162:14: from case Wrapper: return x.JSValue() (switch case) at ./js.go:166:2
./js.go:162:14: flow: {heap} = x:
./js.go:162:14: from x.JSValue() (call parameter) at ./js.go:167:19 And here is it after simply un-exporting the ./js.go:162:14: parameter x leaks to ~r1 with derefs=1:
./js.go:162:14: flow: {temp} = x:
./js.go:162:14: flow: x = *{temp}:
./js.go:162:14: from case Value: return x (switch case) at ./js.go:164:2
./js.go:162:14: flow: ~r1 = x:
./js.go:162:14: from return x (return) at ./js.go:165:3 // case Value: return x Under my understanding, I'll continue testing by applying my Now I'll ./js.go:447:20: parameter args leaks to {heap} with derefs=1:
./js.go:447:20: flow: args = args:
./js.go:447:20: from makeArgs(args) (call parameter) at ./js.go:448:30
./js.go:447:20: flow: {temp} = args:
./js.go:447:20: flow: arg = *{temp}:
./js.go:447:20: from for loop (range-deref) at ./js.go:380:16
./js.go:447:20: flow: x = arg:
./js.go:447:20: from ValueOf(arg) (call parameter) at ./js.go:381:15
./js.go:447:20: flow: {temp} = x:
./js.go:447:20: flow: x = {temp}:
./js.go:447:20: from case Wrapper: return x.jsValue() (switch case) at ./js.go:166:2
./js.go:447:20: flow: {heap} = x:
./js.go:447:20: from x.jsValue() (call parameter) at ./js.go:167:19 So maybe wrapper is a problem after all...let's try removing it entirely and putting the concrete type Unfortunately, unless there is something I'm missing or you are willing to use the hack method, getting rid of Here are the patches that ended up eliminating the Edit: There is some weirdness going on. Although allocations are definitively at 59 per frame, the optimization output from ./js.go:436:20: parameter args leaks to {heap} with derefs=2:
./js.go:436:20: flow: args = args:
./js.go:436:20: from makeArgs(args) (call parameter) at ./js.go:437:30
./js.go:436:20: flow: {temp} = args:
./js.go:436:20: flow: arg = *{temp}:
./js.go:436:20: from for loop (range-deref) at ./js.go:369:16
./js.go:436:20: flow: x = arg:
./js.go:436:20: from ValueOf(arg) (call parameter) at ./js.go:370:15
./js.go:436:20: flow: {temp} = x:
./js.go:436:20: flow: x = *{temp}:
./js.go:436:20: from case Func: return x.Value (switch case) at ./js.go:155:2
./js.go:436:20: flow: ~r1 = x:
./js.go:436:20: from x.Value (dot) at ./js.go:156:11
./js.go:436:20: from return x.Value (return) at ./js.go:156:3
./js.go:436:20: flow: v = ~r1:
./js.go:436:20: from v := ValueOf(arg) (assign) at ./js.go:370:5
./js.go:436:20: flow: {heap} = v:
./js.go:436:20: from argVals[i] = v (assign) at ./js.go:371:14 And ./js.go:416:23: parameter args leaks to {heap} with derefs=2:
./js.go:416:23: flow: {heap} = **args:
./js.go:416:23: from makeArgs(args) (call parameter) at ./js.go:417:30 Looks like they both "escape" via my single slice optimization in There are a few possibilities:
|
The "hack" is not a proper solution for upstream, right? Because as far as I can see, the hack is not a workaround to a shortcoming of the compiler. The complier is actually right that Removing |
If we un-exported it, and implemented it in a way that it can't, that might not be the case. However, from what I have tried so far, even though it doesn't escape to heap, the allocations still happen somewhere in the call stack.
Not as written, no. It might be able to be adapted into a proper solution though.
I would be happy to (after I spend about 30 more minutes trying different combinations of ways to keep Wrapper and eliminate allocations, just to make sure removing it is the only way)! |
@neelance proposal submitted: #44006 Note that, as part of the proposal, I propose yet another way to solve this very issue. It intentionally makes no effort to make the argument/ref slices global in any way. Here's a snippet:
func storeArgs(args []interface{}, argValsDst []Value, argRefsDst []ref) {
for i, arg := range args {
v := ValueOf(arg)
argValsDst[i] = v
argRefsDst[i] = v.ref
}
}
// This function must be inlined
func makeArgs(args []interface{}) (argVals []Value, argRefs []ref) {
const maxStackArgs = 16
if len(args) <= maxStackArgs {
// Allocated on the stack
argVals = make([]Value, len(args), maxStackArgs)
argRefs = make([]ref, len(args), maxStackArgs)
} else {
// Allocated on the heap
argVals = make([]Value, len(args))
argRefs = make([]ref, len(args))
}
return
} Calls to argVals, argRefs := makeArgs(args)
storeArgs(args, argVals, argRefs) |
The existence of the Wrapper interface causes unavoidable heap allocations in syscall/js.ValueOf. See the linked issue for a full explanation. Fixes golang#44006.
@finnbear I just looked into your patch some more. The Do the |
Which branch is taken is dynamic, but there is no dispatch (calling a function) here. All the code there is known statically and, crucially, inlined into the caller. There is no rule that says a given variable (e.g. Side note: The reason it is safe is that Go can trace the flow of the
That's not what I'm seeing. It's possible that the Go compiler version matters, though, so I include my version info. func makeArgs(args []interface{}) (argVals []Value, argRefs []ref) {
const maxStackArgs = 16
if len(args) <= maxStackArgs {
// Allocated on the stack
argVals = make([]Value, len(args), maxStackArgs)
argRefs = make([]ref, len(args), maxStackArgs)
} else {
// Allocated on the heap
argVals = make([]Value, len(args))
argRefs = make([]ref, len(args))
}
return
}
func (v Value) Call(m string, args ...interface{}) Value {
argVals, argRefs := makeArgs(args) // 394
storeArgs(args, argVals, argRefs)
res, ok := valueCall(v.ref, m, argRefs)
...
} $ go version
go version go1.16.2 linux/amd64
$ cd go1.16beta1/src/syscall/js # this just happens to be where I developed the patch
$ GOOS=js GOARCH=wasm go build -gcflags "-m -m"
...
./js.go:394:30: inlining call to makeArgs <---- IMPORTANT
...
./js.go:394:30: make([]Value, len(args), maxStackArgs) does not escape <------ HERE
./js.go:394:30: make([]ref, len(args), maxStackArgs) does not escape
./js.go:394:30: make([]Value, len(args)) escapes to heap
./js.go:394:30: make([]ref, len(args)) escapes to heap
...
Here, we actually see dynamic dispatch in action (Normally, the term dynamic dispatch is used in the context of polymorphism, but I just mean calling a function known only at runtime). f, ok := funcs[id]
...
result := f(this, args) As far as the Go compiler is concerned, |
I just want to leave a comment that my 'global stack' suggestion might not work with multiple goroutines (w/o mutex). |
OK I'll double-check this today... Thanks! (Sorry for my terribly late comment) |
Unfortunately, with Go 1.17.2 (darwin), these values seem to escape. EDIT: |
With
|
I'd like to investigate this, but just so we are on the same page, can you ZIP and attach your entire |
I'm using |
By inlining |
I downloaded
As you can see below, the first two lines of output are what you observed, but the remaining lines tell the full story. ~/sdk/go1.17.2/src/syscall/js$ GOOS=js GOARCH=wasm ~/go/bin/go1.17.2 build -gcflags "-m -m"
...
# line 412: argVals = make([]Value, len(args), maxStackArgs) in makeArgs
./js.go:412:17: make([]Value, len(args), maxStackArgs) escapes to heap
# line 413: argRefs = make([]ref, len(args), maxStackArgs) in makeArgs
./js.go:413:17: make([]ref, len(args), maxStackArgs) escapes to heap
# line 416 argVals = make([]Value, len(args)) in makeArgs
./js.go:416:17: make([]Value, len(args)) escapes to heap
# line 417 argRefs = make([]ref, len(args)) in makeArgs
./js.go:417:17: make([]ref, len(args)) escapes to heap
...
# line 434: argVals, argRefs := makeArgs(args) in Call
./js.go:434:30: make([]Value, len(args), maxStackArgs) does not escape
./js.go:434:30: make([]ref, len(args), maxStackArgs) does not escape
./js.go:434:30: make([]Value, len(args)) escapes to heap
./js.go:434:30: make([]ref, len(args)) escapes to heap
...
# line 459: argVals, argRefs := makeArgs(args) in Invoke
./js.go:459:30: make([]Value, len(args), maxStackArgs) does not escape
./js.go:459:30: make([]ref, len(args), maxStackArgs) does not escape
./js.go:459:30: make([]Value, len(args)) escapes to heap
./js.go:459:30: make([]ref, len(args)) escapes to heap
...
# line 481: argVals, argRefs := makeArgs(args) in New
./js.go:481:30: make([]Value, len(args), maxStackArgs) does not escape
./js.go:481:30: make([]ref, len(args), maxStackArgs) does not escape
./js.go:481:30: make([]Value, len(args)) escapes to heap
./js.go:481:30: make([]ref, len(args)) escapes to heap
Manually inlining will disambiguate the optimizer output, but is not necessary, as the automatically-inlined versions (In |
I've confirmed this and so on. Thanks! I think that'd be very nice to have this change in the official |
Excellent!
I didn't before, but now that you mention the possibility, I'll read this and submit a PR at some point in the next few days. (I will make an attempt to only include |
Sure, I'm happy to review your PR!
That'll be a great first step, and very easy to review! |
@finnbear Any updates? The release freeze has come (today?), we cannot make it for Go 1.18 unfortunately. |
Yep, PR submitted: #49799
I was under the impression that the release freeze starts November 1, that the PR would have been too late even 5 days ago when I said I would do it, and therefore that there was no rush. If I had known today was a deadline, I would have prioritized this differently. Apparently, it is possible to get a freeze exception, especially for low risk, high reward changes, but whether this would apply is subjective and I'll leave that up to maintainers to decide. |
Change https://golang.org/cl/367045 mentions this issue: |
Oh sorry, I was misunderstanding. Thank you for the PR! |
I'm not sure if this is the correct place to raise this issue, forgive me if it's not. I can understand the reasoning for removing the The package a
import (
"syscall/js"
)
func DoStuffInJavascriptLand(obj js.Wrapper) {
js.Global().Call("some_js_function", obj.JSValue())
} package b
import (
"syscall/js"
)
type Thingie struct {
js.Value
}
type AnotherThingie struct {
js.Value
}
func NewThingie() *Thingie {
return &Thingie{Value: js.Global.New("something")}
}
func NewAnotherThingie() *Thingie {
return &AnotherThingie{Value: js.Global.New("something")}
}
package main
import (
"a"
"b"
)
func main() {
t1 := b.NewThingie()
t2 := b.NewAnotherThingie()
a.DoStuffInJavascriptLand(t1)
a.DoStuffInJavascriptLand(t2)
} I would imagine we will see a lot of Did I miss something? |
The reason is not a technical limitation, because as you point out, as long as the case is removed from I think it has more to do with how confusing it would have been to keep the Also, code using
You might be right, and if this happens, you could write a proposal to re-instate However, if you briefly assume that |
Maybe. But just for the record:
Code implicitly using
I wish I knew how :-) I now realize it's worse than I imagined. The
I'm uncertain. It fitted a purpose - recognizing types backed by a My suggestion as of now would be to write a small wrapper for TLDR: |
The good news is that submitting a proposal is as simple as creating a GitHub issue with a title similar to You make some good points, so there is a chance your proposal would be accepted :) |
Any updates on this and #49799 ? |
AFAIK the PR happened to coincide with a release freeze. I haven't touched Go since submitting it, and It looks like a fairly trivial merge conflict materialized ( If anyone wants to help move the PR forward, I'd be happy to grant access to my fork. |
Sure, we're currently in a release freeze right now but I'd be happy to help push it forward after |
Cool, I've invited you to be a collaborator on my go fork, which should probably allow you to edit the PR (https://github.com/finnbear/go/tree/makeargs-stackslice). Feel free to pursue other options like making your own fork/PR with the same changes if it is easier. |
I am hitting this same problem. I am happy to see that someone has reported it already, though sad to see that it lingers for nearly 3 years 😢 In my case, I am making a large number of WebGL calls and the JS heap jumps from 2MB to 4MB in the timespan of 200ms-400ms. It gets GCed and then all over again, resulting in a jagged heap chart. This, combined with #54444 , causes the whole game to stutter and the CPU to spike. In contrast, the native implementation of the same game has a steady heap profile. I tried to follow the thread above and as far as I can tell the #49799 MR should help fix this. I am not sure I understood how the Is there any chance that with #57968 this would get more traction soon? I would be happy to lend my help, though I fear my understandings in the low-level inner workings of the Go runtime might not be up to the task yet. EDIT: After a bit more troubleshooting, it appears that while this issue is related, the majority of the allocations might actually be occuring inside the |
What version of Go are you using (
go version
)?Does this issue reproduce with the latest release?
Yes, in go1.15beta1
What did you do?
I used
js.Call
,js.New
, andjs.Invoke
with multiple arguments, hundreds of times per frame, 60 frames per second in order to implement my WebGL game.What did you expect to see?
The above functions are absolutely essential for getting work done in js/wasm, as they are analogous to a simple function call. They should be optimized as such. No excess go heap memory should be allocated, no extra go garbage collection. After implementing a hack to fix the issue I will go on to describe (see below), here is a CPU profile of memory allocation:

Importantly, memory allocation is 8% of the total time and does not include
makeArgs
allocating any slices that end up on the heapWhat did you see instead?
2+ new slices allocated on the heap per call to one of the above three functions, as the first few lines of the makeArgs function...
go/src/syscall/js/js.go
Lines 361 to 363 in 60f7876
...possibly are assumed to escape to the heap via...
go/src/syscall/js/js.go
Lines 405 to 406 in 60f7876
...or
valueNew
/valueInvoke
, or if go's escape analysis optimization doesn't work on the caller's stack.A CPU profile shows the significant penalty associated with allocating too many slices (mallocgc now accounts for over 20%), and that doesn't even include the extra garbage collector load that claims a few FPS.

I thought of adding
//go:noescape
before each of the above and potentially other functions implemented in javascript, but I didn't get any improvement right away. Only my hack tomakeArgs
worked consistently:Another potential solution, building on the above hack, would be to make
makeArgs
take slices, to put the values/refs in, as arguments (instead of returning new slices). The caller could deal with efficiently handling the memory. Maybe this could help go's escape analysis, in conjunction with//go:noescape
.Side note: Ideally, a solution would address the heap allocation of an interface for certain arguments. Hundreds of thousands get allocated before the garbage collector runs, causing the garbage collector to take 12-20ms.
The text was updated successfully, but these errors were encountered: