Skip to content

cmd/compile: Struct parameter leaks to return values despite copying field #80806

Open

Description

@nicholasngai

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 to l
  • l flows to the return value via return 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).

Activity

  1. randall77 commented on Aug 10, 2026

    @randall77
    Contributor

    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.Val and l.RandomPtr anywhere 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.

  2. added this to the Unplanned milestone on Aug 10, 2026
  3. nicholasngai commented on Aug 11, 2026

    @nicholasngai
    Author

    Thanks for the response! That’s helpful to know!

    Out of curiosity, why do *struct{}, error, and string values embedded in the struct all cause the ~r0 ← l flow, but passing int64 doesn’t? Is it because things with pointers (interfaces, pointers, and strings) all have the potential to point to the parent struct, but value types like int64 don’t?

    package main
    
    import (
    	"testing"
    )
    
    type Val struct {
    	Val       int64
    	RandomInt int64
    }
    
    type PtrVal struct {
    	Val       *int64
    	RandomInt int64
    }
    
    func BenchmarkTest(b *testing.B) {
    	for b.Loop() {
    		foo()
    	}
    }
    
    //go:noinline
    func foo() Val {
    	l := PtrVal{Val: new(int64)}
    	if l.RandomInt != 0 {
    		return Val{RandomInt: l.RandomInt}
    	}
    	return Val{Val: *l.Val}
    }
    ./main_test.go:25:22: new(int64) does not escape
    
  4. randall77 commented on Aug 11, 2026

    @randall77
    Contributor

    Escape analysis only tracks types with pointers. Val{RandomInt: l.RandomInt} is a read from l, but it has a pointer-free type so escape analysis knows no pointers can escape via returning that type.

  5. nicholasngai commented on Aug 11, 2026

    @nicholasngai
    Author

    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

    type Value[T] struct {
    	Val T
    	Err error
    }

    Basically a tuple of T and error, 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.

  6. added
    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.
    on Aug 24, 2026
  7. hazyhaar commented on Aug 29, 2026

    @hazyhaar

    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.go and leaks.go):

    • When evaluating l := PtrWithPtr{Val: new(int64)}, the escape analysis builds a flow graph where new(int64) is linked to the aggregate node representing l.
    • When evaluating l.RandomPtr in return ValWithPtr{RandomPtr: l.RandomPtr}, the flow solver connects the entire aggregate l to the return value ~r0 (coarse-grained aggregate flow).
    • Because l as a whole is marked as flowing to the return, all pointer-containing fields assigned into l (including l.Val = new(int64)) transitively inherit the escape to heap (EscHeap), even though l.Val is never returned.

    2. Proposed Realignment for Go 1.28

    Introduce field-sensitive flow tracking for non-address-taken struct values in escape/solve.go. Accessing l.FieldA should only propagate flow edges from FieldA rather than collapsing all fields of l into 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:

    // WORKAROUND: Keep discrete local pointer variables before constructing structs
    //go:noinline
    func foo() ValWithPtr {
        val := new(int64) // 'val' stays on stack if its dereferenced value is returned
        var randomPtr *struct{}
    
        if randomPtr != nil {
            return ValWithPtr{RandomPtr: randomPtr}
        }
        return ValWithPtr{Val: *val} // 0 allocations, 'new(int64)' does not escape
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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

    Projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions