Skip to content

The project option pattern requires allocation due to interface boxing #7851

@MrAlias

Description

@MrAlias

Our options policy design includes the creation of options using With* functions similar to this:

type Config struct {
	IntSlice []int
}

type Option interface {
	apply(Config) Config
}

type intSliceOpt struct {
	val []int
}

func (o intSliceOpt) apply(c Config) Config {
	c.IntSlice = o.val
	return c
}

func WithIntSlice(val []int) Option {
	return intOpt{val: []val} // Allocated to the heap when returned as an Option.
}

This With* pattern requires can1, at a minimum, require 1 heap allocation everytime the function is called due to interface boxing.

This is problematic as these options are sometimes used in "hot path" or performance sensitive call paths.

Originally posted by @dashpole in #7777 (comment)

Benchmark

To evaluate this claim the following benchmark was used:

func BenchmarkWithIntSlice(b *testing.B) {
	var opt Option
	var c Config
	ints := []int{1, 2, 3, 4, 5}

	b.ReportAllocs()
	b.ResetTimer()
	for b.Loop() {
		opt = WithIntSlice(ints)
		c = opt.apply(c)
	}

	_ = c
}

Running this:

> go version
go version go1.25.6 X:nodwarf5 linux/amd64

> go test -bench=BenchmarkWithIntSlice
goos: linux
goarch: amd64
pkg: testing/attropt
cpu: Intel(R) Core(TM) i7-8550U CPU @ 1.80GHz
BenchmarkWithIntSlice-8   	22197412	        54.39 ns/op	      24 B/op	       1 allocs/op
PASS
ok  	testing/attropt	1.210s

Caveat: not all options are the same

The modern Go compiler looks smart enough to get itself out of certain allocations. Take for instance:

type Config struct {
	Int      int
}

type Option interface {
	apply(Config) Config
}

type intOpt struct {
	val int
}

func (o intOpt) apply(c Config) Config {
	c.Int = o.val
	return c
}

func WithInt(val int) Option {
	return intOpt{val: val}
}

func BenchmarkWithInt(b *testing.B) {
	var opt Option
	var c Config

	b.ReportAllocs()
	b.ResetTimer()
	for n := 0; n < b.N; n++ {
		opt = WithInt(n)
		c = opt.apply(c)
	}

	_ = c
}
> go test -bench=BenchmarkWithInt$
 goos: linux
goarch: amd64
pkg: testing/attropt
cpu: Intel(R) Core(TM) i7-8550U CPU @ 1.80GHz
BenchmarkWithInt-8   	31261698	        32.41 ns/op	       8 B/op	       0 allocs/op

Seen here, there are no allocations for this case.

Looking at the escape analysis for this ...

[...]
[...]: inlining call to WithInt
[...]: intOpt{...} escapes to heap
[...]

WithInt is simple enough that it gets inlined. The intOpt instance still escapes to the heap, but since the opt var is local, that heap is not allocated repeatedly for each iteration.

Footnotes

  1. see the following caveats

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions