Here is a simple program in two packages, checked into rsc.io/tmp/escbug.
package p
import (
"runtime"
"rsc.io/tmp/escbug/q"
)
func P() {
var buf [24]byte
runtime.KeepAlive(q.Q(buf[:0], 1))
}
package q
func nop(x int) int { return x }
func Q(dst []byte, x int64) []byte {
return q(dst, x)
}
func q[T int32 | int64](dst []byte, x T) []byte {
return append(dst, byte(nop(nop(nop(nop(nop(nop(nop(nop(nop(nop(nop(nop(nop(nop(nop(nop(nop(nop(nop(nop(nop(int(x))))))))))))))))))))))))
}
p.P calls q.Q with a stack buffer, which the compiler incorrectly promotes to a heap allocation.
q.Q calls q.q, which returns the result of appending to the buffer. The result of the analysis should be that the argument dst leaks to the result but not to the heap. And in fact if you build this, you do get that result when analyzing q.q and q.Q:
% go build -gcflags=./p=-m=2 -gcflags=./q=-m=2 ./p
...
q/q.go:9:25: parameter dst leaks to ~r0 for q[go.shape.int64] with derefs=0:
q/q.go:9:25: flow: {temp} ← dst:
q/q.go:9:25: from append(dst, byte(~r0)) (call parameter) at q/q.go:10:15
q/q.go:9:25: flow: ~r0 ← {temp}:
q/q.go:9:25: from return append(dst, byte(~r0)) (return) at q/q.go:10:2
...
q/q.go:5:8: parameter dst leaks to ~r0 for Q with derefs=0:
q/q.go:5:8: flow: {temp} ← dst:
q/q.go:5:8: from q[go.shape.int64](&.dict.q[int64], dst, x) (call parameter) at q/q.go:6:10
q/q.go:5:8: flow: ~r0 ← {temp}:
q/q.go:5:8: from return q[go.shape.int64](&.dict.q[int64], dst, x) (return) at q/q.go:6:2
q/q.go:5:8: leaking param: dst to result ~r0 level=0
...
q/q.go:9:25: parameter dst leaks to ~r0 for q[int64] with derefs=0:
q/q.go:9:25: flow: {temp} ← dst:
q/q.go:9:25: from q[go.shape.int64](&.dict.q[int64], dst, x) (call parameter) at q/q.go:9:6
q/q.go:9:25: flow: ~r0 ← {temp}:
q/q.go:9:25: from return q[go.shape.int64](&.dict.q[int64], dst, x) (return) at q/q.go:9:6
...
I made this change to the compiler to print the export data related to escape analysis, and assuming I got all the possible places that need prints, the only export data printed in q is for q.Q and q.nop. Note that there is no escape info for q.q in any form (the above analysis considered both q[go.shape.int64] and q[int64]).
q/q.go:5:6: escape export data: Q: dst:"esc:\x00\x01\x00\x01" x:""
q/q.go:3:6: escape export data: nop: x:""
Now let's look at the output from building p, which I elided above. This is the full output related to p and its calls:
...
q/q.go:3:6: can inline nop with cost 2 as: func(int) int { return x }
q/q.go:9:6: cannot inline q[go.shape.int64]: function too complex: cost 89 exceeds budget 80
q/q.go:5:6: can inline Q with cost 64 as: func([]byte, int64) []byte { return q[go.shape.int64](&.dict.q[int64], dst, x) }
q/q.go:9:6: can inline q[int64] with cost 64 as: func([]byte, int64) []byte { return q[go.shape.int64](&.dict.q[int64], dst, x) }
...
p/p.go:9:6: can inline P with cost 78 as: func() { buf = <nil>; runtime.KeepAlive(q.Q(buf[:0], int64(1))) }
p/p.go:11:23: inlining call to q.Q
p/p.go:10:6: buf escapes to heap in P:
p/p.go:10:6: flow: q.dst ← &buf:
p/p.go:10:6: from buf (address-of) at p/p.go:11:27
p/p.go:10:6: from buf[:0] (slice) at p/p.go:11:27
p/p.go:10:6: from q.dst, q.x := buf[:0], int64(1) (assign-pair) at p/p.go:11:23
p/p.go:10:6: flow: {heap} ← q.dst:
p/p.go:10:6: from q.q[go.shape.int64](&q..dict.q[int64], q.dst, q.x) (call parameter) at p/p.go:11:23
p/p.go:10:6: moved to heap: buf
p/p.go:11:23: ~r0 does not escape
p/p.go:9:6: escape export data: P:
As I understand it, p.P calls q.Q, which on the page calls q.q, which the compiler treats as a direct call to q.q[go.shape.int64]. The first call is inlinable, so that the actual code being compiled is p.P calling q.q[go.shape.int64] directly—the many nop calls in q.q make the actual implementation not inlinable. (The wrapper implementation q.q[int64] is not in play; it is only used when a function value is needed.)
Then the escape analysis runs over p.P. It decides that q.dst flows to the heap and therefore p.P's buf escapes and needs to be heap allocated. As far as I can tell, this decision is based on q.q[go.shape.int64] having no escape annotations available at all. The escape analysis must assume the worst.
A variant that still demonstrates the problem is to remove the nop calls and just mark q noinline:
//go:noinline
func q[T int32 | int64](dst []byte, x T) []byte {
return append(dst, 1)
}
Interestingly, if I add
func q64(dst []byte, x int64) []byte {
return append(dst, 1)
}
then I still don't see q64 listed on an "escape export data" line. But if I make Q call it:
func Q(dst []byte, x int64) []byte {
return q64(dst, x)
}
Now I get the export data for q64 and the moved-to-heap goes away.
q/q.go:5:6: escape export data: Q: dst:"esc:\x00\x01\x00\x01" x:""
q/q.go:14:6: escape export data: q64: dst:"esc:\x00\x01\x00\x01" x:""
# rsc.io/tmp/escbug/p
p/p.go:9:6: can inline P with cost 23 as: func() { buf = <nil>; runtime.KeepAlive(q.Q(buf[:0], int64(1))) }
p/p.go:11:23: inlining call to q.Q
p/p.go:11:23: inlining call to q.q64
p/p.go:11:23: ~r0 does not escape
p/p.go:11:23: append does not escape
p/p.go:9:6: escape export data: P:
So perhaps in the generic version, the problem is that when exporting q.Q we don't correctly see the q.q call in a way that marks it as needing detailed export information.
Leaving for someone with more compiler expertise (and compiler responsibility). It does seem like in relatively plausible cases we are assuming the worst about the escape behavior of generic code.
Here is a simple program in two packages, checked into rsc.io/tmp/escbug.
p.P calls q.Q with a stack buffer, which the compiler incorrectly promotes to a heap allocation.
q.Q calls q.q, which returns the result of appending to the buffer. The result of the analysis should be that the argument dst leaks to the result but not to the heap. And in fact if you build this, you do get that result when analyzing q.q and q.Q:
I made this change to the compiler to print the export data related to escape analysis, and assuming I got all the possible places that need prints, the only export data printed in q is for q.Q and q.nop. Note that there is no escape info for q.q in any form (the above analysis considered both q[go.shape.int64] and q[int64]).
Now let's look at the output from building p, which I elided above. This is the full output related to p and its calls:
As I understand it, p.P calls q.Q, which on the page calls q.q, which the compiler treats as a direct call to q.q[go.shape.int64]. The first call is inlinable, so that the actual code being compiled is p.P calling q.q[go.shape.int64] directly—the many nop calls in q.q make the actual implementation not inlinable. (The wrapper implementation q.q[int64] is not in play; it is only used when a function value is needed.)
Then the escape analysis runs over p.P. It decides that q.dst flows to the heap and therefore p.P's buf escapes and needs to be heap allocated. As far as I can tell, this decision is based on q.q[go.shape.int64] having no escape annotations available at all. The escape analysis must assume the worst.
A variant that still demonstrates the problem is to remove the nop calls and just mark q noinline:
Interestingly, if I add
then I still don't see q64 listed on an "escape export data" line. But if I make Q call it:
Now I get the export data for q64 and the moved-to-heap goes away.
So perhaps in the generic version, the problem is that when exporting q.Q we don't correctly see the q.q call in a way that marks it as needing detailed export information.
Leaving for someone with more compiler expertise (and compiler responsibility). It does seem like in relatively plausible cases we are assuming the worst about the escape behavior of generic code.