Repository navigation
cmd/compile: Struct parameter leaks to return values despite copying field #80806
Copy link
Copy link
Open
Labels
NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.Someone 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.Issues related to the Go compiler and/or runtime.
Milestone
Description
Go version
go version go1.26.5 darwin/arm64
Output of go env in your module/workspace:
AR='ar'
CC='cc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='c++'
GCCGO='gccgo'
GO111MODULE=''
GOARCH='arm64'
GOARM64='v8.0'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/Users/nngai/Library/Caches/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/Users/nngai/Library/Application Support/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -arch arm64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -ffile-prefix-map=/var/folders/0b/zx02r9711w3959xvwclcj99w0000gn/T/go-build944554271=/tmp/go-build -gno-record-gcc-switches -fno-common'
GOHOSTARCH='arm64'
GOHOSTOS='darwin'
GOINSECURE=''
GOMOD='/Users/nngai/git/cel-jit/go.mod'
GOMODCACHE='/Users/nngai/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='darwin'
GOPATH='/Users/nngai/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/opt/homebrew/Cellar/go/1.26.5/libexec'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/Users/nngai/Library/Application Support/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/opt/homebrew/Cellar/go/1.26.5/libexec/pkg/tool/darwin_arm64'
GOVCS=''
GOVERSION='go1.26.5'
GOWORK=''
PKG_CONFIG='pkg-config'What did you do?
package main
import (
"testing"
)
type ValWithPtr struct {
Val int64
RandomPtr *struct{}
}
type PtrWithPtr struct {
Val *int64
RandomPtr *struct{}
}
func BenchmarkTest(b *testing.B) {
for b.Loop() {
foo()
}
}
//go:noinline
func foo() ValWithPtr {
l := PtrWithPtr{Val: new(int64)}
if l.RandomPtr != nil {
return ValWithPtr{RandomPtr: l.RandomPtr}
}
return ValWithPtr{Val: *l.Val}
}What did you see happen?
The go compiler sees
new(int64)flows tollflows to the return value viareturn ValWithPtr{RandomPtr: l.RandomPtr}
Thus, new(int64) escapes. Output of go test -gcflags='-m=2':
./main_test.go:25:26: new(int64) escapes to heap in foo:
./main_test.go:25:26: flow: l ← &{storage for new(int64)}:
./main_test.go:25:26: from new(int64) (spill) at ./main_test.go:25:26
./main_test.go:25:26: from PtrWithPtr{...} (struct literal element) at ./main_test.go:25:17
./main_test.go:25:26: from l := PtrWithPtr{...} (assign) at ./main_test.go:25:4
./main_test.go:25:26: flow: ~r0 ← l:
./main_test.go:25:26: from l.RandomPtr (dot) at ./main_test.go:27:27
./main_test.go:25:26: from ValWithPtr{...} (struct literal element) at ./main_test.go:27:20
./main_test.go:25:26: from return ValWithPtr{...} (return) at ./main_test.go:27:3
./main_test.go:25:26: new(int64) escapes to heap
It’s not clear to me, however, why return ValWithPtr{RandomPtr: l.RandomPtr} causes l to leak from the function though. I would expect that using l.RandomPtr would just copy the *struct{} value and not hold a reference to the parent l struct.
Replacing *struct{} as the RandomPtr value with any pointer or pointer-like type such as any, error, or string also causes this behavior, despite all of these carrying copy-by-value semantics.
What did you expect to see?
No leak of l to the return value and thus no allocation in new(int64).
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.Someone 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.Issues related to the Go compiler and/or runtime.
Type
Projects
- StatusShow more project fieldsNo status
Activity
gabyhelp commentedon Aug 10, 2026
Related Issues
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
randall77 commentedon Aug 10, 2026
Unfortunately our current escape analysis is not that precise. We treat entire variables as single units, without distinguishing anything about the individual fields in a variable of compound type. So we don't know the difference between
l.Valandl.RandomPtranywhere in the code (assigning to it, or reading from it).This might be fixable, but would certainly take some work, and some compile time space/speed. I believe this choice was made when first doing escape analysis because it was the best bang for the buck. Maybe an analysis of both where we might still find benefits, and what the compile-time costs might be, would help decide if adding additional complexity would be worth it.
nicholasngai commentedon Aug 11, 2026
Thanks for the response! That’s helpful to know!
Out of curiosity, why do
*struct{},error, andstringvalues embedded in the struct all cause the~r0 ← lflow, but passingint64doesn’t? Is it because things with pointers (interfaces, pointers, and strings) all have the potential to point to the parent struct, but value types likeint64don’t?randall77 commentedon Aug 11, 2026
Escape analysis only tracks types with pointers.
Val{RandomInt: l.RandomInt}is a read froml, but it has a pointer-free type so escape analysis knows no pointers can escape via returning that type.nicholasngai commentedon Aug 11, 2026
Sounds good. Sounds like the best workaround for now would just be to track these as multiple variables instead of lumping them together as a struct then?
For context, my real code looks something like
Basically a tuple of
Tanderror, to handle error propagation and codegen more easily for a transpiler I’m writing. Writing the transpiler output errors variables is not impossible but just somewhat involved lol.hazyhaar commentedon Aug 29, 2026
Here is the exact compiler mechanism causing this unexpected heap escape, what Go 1.28 could improve, and how to avoid the allocation today.
1. The Compiler Mechanism & Discrepancy
The issue stems from the lack of field-sensitivity in Go's escape analysis solver (
cmd/compile/internal/escape/solve.goandleaks.go):l := PtrWithPtr{Val: new(int64)}, the escape analysis builds a flow graph wherenew(int64)is linked to the aggregate node representingl.l.RandomPtrinreturn ValWithPtr{RandomPtr: l.RandomPtr}, the flow solver connects the entire aggregatelto the return value~r0(coarse-grained aggregate flow).las a whole is marked as flowing to the return, all pointer-containing fields assigned intol(includingl.Val = new(int64)) transitively inherit the escape to heap (EscHeap), even thoughl.Valis never returned.2. Proposed Realignment for Go 1.28
Introduce field-sensitive flow tracking for non-address-taken struct values in
escape/solve.go. Accessingl.FieldAshould only propagate flow edges fromFieldArather than collapsing all fields oflinto a single escape class.3. Immediate Workaround Rule (Today)
To prevent the spurious heap escape on current Go versions, avoid bundling temporary heap allocations and returned pointers in the same aggregate, or unbundle the assignment into discrete local variables before constructing the struct: