Go: Functional options are slow

about | archive


[ 2022-May-23 10:19 ]

The Go "functional options" pattern is a way of passing options to a function. The function takes a variable number of arguments, which are themselves functions (a type like ...func(*config). I think it was first introduced by Rob Pike in a 2014 blog post. It is now used by many APIs. For example, gRPC's DialContext(), AWS's LoadDefaultConfig(), and OpenTelemetry's Tracer.Start(). This style should be avoided when performance is critical. This article describes a microbenchmark that shows functional options are slower, require more instructions, are less likely to be inlined, and may require memory allocations. In the worst case, Go must allocate a slice for the ... argument, and an additional object for each option in the slice. Often, the compiler's escape analysis can optimize these away, but not always. In particular, the compiler can never optimize an interface method call. The alternative is to pass a struct with the configuration values. This does not rely on the compiler to be efficient. In my opinion, performance-critical code should be efficient "by design," and not rely on compiler optimizations that may or may not apply. This article presents a brief experiment to try and demonstrate the performance differences.

Functional options versus configuration structs

The alternative to functional options is passing a configuration struct, like the standard library's http.Server or tls.Config. To make this concrete, let's consider a constructor function that creates a type *Foo with two options: a boolean (enabling a feature that is disabled by default), and an integer (taking a numeric value). With functional options, the code would look like the following:

Functional options creating *Foo


func NewFoo(options ...FooOption) *Foo {
  // ... implementation
}

func CallNewFoo() *Foo {
  return NewFoo(WithBoolOption(), WithIntOption(42))
}

The configuration struct version would look something like the following:

Configuration struct creating *Foo


func NewFooStruct(config FooConfig) *Foo {
  // ... implementation
}

func CallNewFooStruct() *Foo {
  return NewFooStruct(FooConfig{BoolOption: true, IntOption: 42})
}

While the syntax is different, these two versions do the same thing. I will focus on the performance differences. I wrote a microbenchmark to compare these implementations. The measurements show that functional options are at best around 3 nanoseconds/call slower. When calling through an interface, the functional options are about 5× slower, which increases with the number of parameters. Even worse, the compiler can inline the configuration struct version more aggressively, which makes it even more efficient in some cases. The code is on Github.

Results

I ran this benchmark on my laptop, with an old and slow Intel Core i7-7Y75 (Kaby Lake) CPU. The table below shows the execution time, number of allocations, and bytes allocated per call. For the configuration struct version, I had to annotate the function with the //go:noinline pragma, since otherwise the compiler inlined the function and basically eliminated the entire benchmark (making no allocations). This shows that while compiler optimizations can reduce the overhead for functional options, those same optimizations can reduce the overhead for configuration structs even more.

For the "direct" call, the overhead of functional options is low: a few extra nanoseconds per call. However, when calling via an interface, the compiler is required to heap allocate the slice argument, so the functional options version is about 5× slower. The difference gets even larger as more options are passed.

Implementationnsallocationsbytes allocated
Functional options38.5918
Config struct (inlining disabled)34.3718
Functional options interface159.60340
Config struct interface38.7918

Code size in instructions

To compare the code size, I compiled the two implementations of CallNewFoo and CallNewFooStruct using go test -o out.bin, then disassembled it with go tool objdump -S out > out.asm. The number of instructions and code bytes below show that functional options require more than double the amount of code. This may be the cause for the small performance difference.

ImplementationNumber of instructionsCode bytes
Functional options (func + closure)33 + 4 = 37154 + 11 = 165
Config struct1561

Conclusion

In conclusion, configuration structs should be preferred for performance-critical APIs. While the performance can be the same with functional options, it will depend on compiler optimizations (notably inlining and escape analysis). These optimizations cannot be applied when using interfaces. Configuration structs, on the other hand, are still efficient with an interface call.

In most circumstances, this does not matter. Options are most commonly passed in to constructors that are called once at startup, then reused many times. In this case, the performance differences won't matter, and you should use whatever style you think is best. However, if you are designing an API that will get called often, then these extra allocations and code can add up.

Subjective reasons to avoid functional options

Ignoring the performance differences, I think passing configuration structs is slightly better than functional options. For excellent arguments in favor of function options, I recommend Dave Cheney's article about why functional arguments make friendly APIs. My stylistic arguments in favor of configuration structs are the following: