Dereferencing struct field triggers heap allocation for the complete struct

131 views
Skip to first unread message

rkerno

unread,
Aug 30, 2024, 4:54:39 PM8/30/24
to golang-nuts
Hi Everyone,


I'm struggling to wrap my head around the root cause of this issue.

Accessing certain fields of a struct triggers go to allocate the complete struct on the heap.  The key ingredients are:
  • A struct with a contained slice of structs defined inline
  • Calls to functions via a function pointer
What follows is a contrived example to demonstrate / investigate the problem.

const (
//
// When requireZeroAllocations == true...
//
//     BenchmarkAllocations-2          27763290                52.28 ns/op          0 B/op          0 allocs/op
//
// When requireZeroAllocations == false...
//
//     BenchmarkAllocations-2          14403922               1532.00 ns/op      2304 B/op          2 allocs/op
//
requireZeroAllocations = false
)

type (
X struct {
id       uint
key      string
value    any
children []X
useId    useIdFunc
useX     useXFunc
useKey   useKeyFunc
useValue useValueFunc
unused   [128]uint64 // Demonstrates that the whole struct is placed on the heap!
}
useIdFunc    = func(id uint)
useXFunc     = func(key string, value any)
useKeyFunc   = func(key string)
useValueFunc = func(value any)
)

func scanX(x X) {
if len(x.children) > 0 {
for i := 0; i < len(x.children); i++ {
scanX(x.children[i])
}
return
}

x.useId(x.id)
if !requireZeroAllocations {
// Why can we access the id field without an allocation, but as soon as we access
// the string or any fields the entire struct is placed on the heap?
x.useKey(x.key)
x.useX(x.key, x.value)
x.useValue(x.value)
}
}

func printId(id uint) {
}
func printX(key string, value any) {
}
func printKey(key string) {
}
func printValue(value any) {
}


The benchmark used to demonstrate the behaviour is:

func BenchmarkAllocations(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
scanX(
X{
id:    0,
key:   "root",
value: nil,
children: []X{
{
id:    1,
key:   "level1",
value: nil,
children: []X{
{
id:       2,
key:      "k1",
value:    "v1",
children: nil,
useId:    printId,
useX:     printX,
useKey:   printKey,
useValue: printValue,
},
},
},
},
},
)
}
})
}

Any insights or ideas why go exhibits this behaviour would be appreciated.

Cheers,
Rik

robert engels

unread,
Aug 30, 2024, 5:02:28 PM8/30/24
to rkerno, golang-nuts
because when you only access an int that is passed by value in the function it knows it can’t escape.

if you pass the string/value than the the variable can escape, and since the value could point back to the struct itself, it can escape, meaning the entire struct needs to be on the heap.


--
You received this message because you are subscribed to the Google Groups "golang-nuts" group.
To unsubscribe from this group and stop receiving emails from it, send an email to golang-nuts...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/golang-nuts/3ff0270c-caf0-4a23-9cdf-08bf40e83a33n%40googlegroups.com.

rkerno

unread,
Aug 30, 2024, 10:34:27 PM8/30/24
to golang-nuts
Thanks Robert.  I don't think it's the value that's causing this....I still get the allocation if I remove the value from the equation.  It's something to do with the root level having children.

The escape analysis from the refactored code below is:
go build -gcflags='-m=2' .
./alloc.go:12:27: parameter y leaks to {heap} with derefs=0:
./alloc.go:12:27:   flow: {heap} = y:
./alloc.go:12:27:     from y.key (dot) at ./alloc.go:19:6
./alloc.go:12:27:     from fn(y.key) (call parameter) at ./alloc.go:19:4

Strings can't reference anything, AFAIK.

BenchmarkAllocations0-2         97209274                28.76 ns/op            0 B/op          0 allocs/op
BenchmarkAllocations1-2          1395814               796.9 ns/op          1152 B/op          1 allocs/op

type (
Y struct {
key      string
children []Y

unused   [128]uint64 // Demonstrates that the whole struct is placed on the heap!
}
useKeyFunc = func(key string)
)

func scanY(fn useKeyFunc, y Y) {
if y.children != nil {
for i := 0; i < len(y.children); i++ {
scanY(fn, y.children[i])
}
return
}
fn(y.key)
}

func printKey(key string) {
}

func Benchmark_ZeroAllocations(b *testing.B) {

b.ReportAllocs()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
scanY(
printKey,
Y{
key:      "root",
children: []Y{},
},
)
}
})
}

func Benchmark_OneAllocation(b *testing.B) {

b.ReportAllocs()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
scanY(
printKey,
Y{
key: "root",
children: []Y{
{
key:      "level1",
children: nil,

},
},
},
)
}
})
}

Robert Engels

unread,
Aug 30, 2024, 10:45:46 PM8/30/24
to rkerno, golang-nuts
Yes, but the called function can retain a reference to the string - strings are pointers. it cannot retain a reference to the scalar. 

I’m guessing if any of the elements can be retained then the entire structure is allocated on the heap - I don’t think it has to but it is probably the current implementation. 

On Aug 30, 2024, at 5:34 PM, rkerno <rik.ke...@projectcatalysts.com> wrote:

Thanks Robert.  I don't think it's the value that's causing this....I still get the allocation if I remove the value from the equation.  It's something to do with the root level having children.

rkerno

unread,
Aug 31, 2024, 12:04:54 AM8/31/24
to golang-nuts
Thanks Robert, I've come to the same conclusion.  I'm guessing the approach simplifies and speeds up the escape analysis, though appears to be a significant limitation.

The following extract from escape.go seems to apply.


// Every Go language construct is lowered into this representation,
// generally without sensitivity to flow, path, or context; and
// without distinguishing elements within a compound variable. For
// example:
//
//     var x struct { f, g *int }
//     var u []*int
//
//     x.f = u[0]
//
// is modeled simply as
//
//     x = *u
//
// That is, we don't distinguish x.f from x.g, or u[0] from u[1],
// u[2], etc. However, we do record the implicit dereference involved
// in indexing a slice.

Thanks for your help on this.

Cheers,
Rik
Reply all
Reply to author
Forward
0 new messages