-
Notifications
You must be signed in to change notification settings - Fork 18k
syscall/js: increase performance of Call, Invoke, and New by not allowing new slices to escape onto the heap #39740
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
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 |
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 |
@mokiat @unitoftime AFAIK, someone just needs to revive this PR which unfortunately landed during a release freeze. I've since migrated to Rust and don't necessarily have the Go toolchains installed or the relevant details in mind (e.g. the meaning of |
Cool. Thanks for the info and initial research! I'll take a more in depth look next chance I get. |
The existing implementation causes unecessary heap allocations for javascript syscalls: Call, Invoke, and New. The new change seeks to hint the Go compiler to allocate arg slices with length <=16 to the stack. Fixes golang#39740
The existing implementation causes unnecessary heap allocations for javascript syscalls: Call, Invoke, and New. The new change seeks to hint the Go compiler to allocate arg slices with length <=16 to the stack. Fixes golang#39740
The existing implementation causes unnecessary heap allocations for javascript syscalls: Call, Invoke, and New. The new change seeks to hint the Go compiler to allocate arg slices with length <=16 to the stack. Fixes golang#39740
The existing implementation causes unnecessary heap allocations for javascript syscalls: Call, Invoke, and New. The new change seeks to hint the Go compiler to allocate arg slices with length <=16 to the stack. Fixes golang#39740
The existing implementation causes unnecessary heap allocations for javascript syscalls: Call, Invoke, and New. The new change seeks to hint the Go compiler to allocate arg slices with length <=16 to the stack. Fixes golang#39740
Change https://go.dev/cl/576575 mentions this issue: |
The existing implementation causes unnecessary heap allocations for javascript syscalls: Call, Invoke, and New. The new change seeks to hint the Go compiler to allocate arg slices with length <=16 to the stack. Original Work: CL 367045 - Calling a JavaScript function with 16 arguments or fewer will not induce two additional heap allocations, at least with the current Go compiler. - Using syscall/js features with slices and strings of statically-known length will not cause them to be escaped to the heap, at least with the current Go compiler. - The reduction in allocations has the additional benefit that the garbage collector runs less often, blocking WebAssembly's one and only thread less often. Fixes golang#39740
The existing implementation causes unnecessary heap allocations for javascript syscalls: Call, Invoke, and New. The new change seeks to hint the Go compiler to allocate arg slices with length <=16 to the stack. Original Work: CL 367045 - Calling a JavaScript function with 16 arguments or fewer will not induce two additional heap allocations, at least with the current Go compiler. - Using syscall/js features with slices and strings of statically-known length will not cause them to be escaped to the heap, at least with the current Go compiler. - The reduction in allocations has the additional benefit that the garbage collector runs less often, blocking WebAssembly's one and only thread less often. Fixes golang#39740
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: