Graham King

Solvitas perambulum

Go: The price of interface{}

Summary
Go's empty `interface{}` allows functions to accept any type, making it versatile. However, this flexibility incurs a cost; assigning a value to an `interface{}` triggers a memory allocation, causing increased heap garbage and longer garbage collection pauses. For example, a logging function can be implemented using both `logIface(level byte, msg interface{})` and `logString(level byte, msg string)`, where `logIface` is significantly slower due to the necessary allocation. Benchmarks show `logIface` takes 126 ns/op with 1 allocation, while `logString` is faster at 6.11 ns/op with no allocations. Thus, while `interface{}` is powerful, it's best avoided in performance-critical sections of code.

Go’s empty interface{} is the interface that everything implements. It allows functions that can be passed any type. The function func f(any interface{}) can be called with a string f("a string"), an integer f(42), a custom type, or anything else.

This flexibility comes at a cost. When you assign a value to a type interface{}, Go will call runtime.convT2E to create the interface structure (read more about Go interface internals). That requires a memory allocation. More memory allocations means more garbage on the heap, which means longer garbage collection pauses.

Here’s an example. A log function two ways, neither of which do anything because we’re logging at ‘prod’ level, and the messages are ‘debug’. One is more expensive than the other.

package main

import "fmt"

const (
	debug byte = iota
	prod
)

var logLevel = prod

func main() {
	logIface(debug, "Test interface")
	logString(debug, "Test string")
}

func logIface(level byte, msg interface{}) {
	if level >= logLevel {
		fmt.Println(msg)
	}
}

func logString(level byte, msg string) {
	if level >= logLevel {
		fmt.Println(msg)
	}
}

And here are benchmarks:

package main

import "testing"

func BenchmarkIface(b *testing.B) {
	for i := 0; i < b.N; i++ {
		logIface(debug, "test iface")
	}
}

func BenchmarkString(b *testing.B) {
	for i := 0; i < b.N; i++ {
		logString(debug, "test string")
	}
}

Here’s the output of go test -bench Benchmark -benchmem on my machine, go1.4.2 linux/amd64:

BenchmarkIface  10000000               126 ns/op              16 B/op          1 allocs/op
BenchmarkString 200000000                6.11 ns/op            0 B/op          0 allocs/op

To see what is happening ask Go for the assembly output:

go build -gcflags -S <filename>.go

Notice the CALL runtime.convT2E before CALL logIface, but not before CALL logString.

Or use the fantastic Go Compiler Exporer. The call to runtime.convT2E is on line 27.

Even though logIface doesn’t do anything, the runtime still needs to convert the string to an interface to call the function, and that’s the memory allocation. It’s good to look out for interface{} usage in your inner-most loops - look for fmt and log package functions, container types, and many other places.

interface{} is a wonderful thing to have, except in the most frequently called sections of you code.