Skip to content

cmd/compile: missing escape analysis on generic func impls #79592

Description

@rsc

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.Performancecompiler/runtimeIssues related to the Go compiler and/or runtime.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Todo

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions