Analyzing the performance of Go functions with benchmarks
This blog post is complementary to episode 28 of justforfunc which you can watch right below.
In the previous blog post I discussed two different ways of merging n channels in Go, but we did not discuss which ones was faster. In the meanwhile a third way of merging channels was proposed in a YouTube comment.
This blog post will show that third way and compare all of them from a performance point of view, analyzed using benchmarks.
A third way of merging channels
Two episodes ago we discussed how two channels could be merged, using a single goroutine and nil channels. We’ll refer to that function here as mergeTwo. A justforfunc viewer proposed a way to use this function and recursion to provide a way to merge n channels.
The solution is quite smart. If we have:
- one channel, we return that channel.
- two channels or more, we merge a half of the channels and then merge them using the result of those using the efficient function.
What about no channels? We will return an already closed channel.
Written in code it would look like this:
This is a nice solution that avoids using reflection and also reduces the number of goroutines needed. On the other hand, it uses more channels than before!
So, which one is fastest? Time for benchmarks!
Writing a benchmark
Testing and benchmarks are integrated very tightly with the Go tooling, and writing a benchmark is as simple as writing a function with a name starting with
Benchmark and the appropriate signature.
The only parameter a benchmark receives is of type
testing.B and it defines the number of times we should perform the operation we’re benchmarking. This is done for statistical significance, by default benchmarks are given a number of iterations that will make the function run for a whole second.
Ok, so now we know pretty much everything we need to know to write a benchmark for one of our functions.
Benchmarking all our functions
Ok, so we’re ready to write benchmarks for each function!
And finally. one for
And now we can finally run them!
➜ go test -bench=.
BenchmarkMerge-4 200000 7074 ns/op
BenchmarkMergeReflect-4 100000 11904 ns/op
BenchmarkMergeRec-4 500000 2475 ns/op
ok github.com/campoy/justforfunc/27-merging-chans 4.077s
Great, so using recursion is the fastest possible option. Let’s believe it for now!
Using sub-benchmarks and b.Run
Unfortunately, in order to benchmark three functions we’re repeating lots of code. Wouldn’t it be nice if we could write our benchmark once?
We can write a function that iterates over our three functions:
Unfortunately, that simply gives a single and pretty meaningless measure, mixing all performances.
➜ go test -bench=.
BenchmarkMerge-4 50000 22440 ns/op
ok github.com/campoy/justforfunc/27-merging-chans 1.386s
Luckily, we can easily create subbenchmarks by calling
testing.B.Run, similarly as subtests with
testing.T.Run. In order to do so, we need a name, which is actually a pretty good idea from a documentation point of view, anyway.
When we run these benchmarks we’ll now see three different results as we wished.
➜ go test -bench=.
BenchmarkMerge/goroutines-4 200000 6145 ns/op
BenchmarkMerge/reflect-4 200000 10962 ns/op
BenchmarkMerge/recursion-4 500000 2368 ns/op
ok github.com/campoy/justforfunc/27-merging-chans 4.829s
Sweet! With way less code we’re still able to benchmark all of our functions.
Benchmarking multiple values of N
So far we’ve seen that for a single channel the recursive algorithm is the fastest, but what about other values of
We could probably just change the value of
N many times and see how it goes. But we could also just use a for loop!
In the code below we iterate over the powers of 2 from 1 to 1024, and use a combination of the function we’re benchmarking and the value of
N as the benchmark name.
Let’s run the benchmarks!
➜ go test -bench=.
BenchmarkMerge/goroutines/1-4 200000 6919 ns/op
BenchmarkMerge/goroutines/2-4 100000 13212 ns/op
BenchmarkMerge/goroutines/4-4 50000 25469 ns/op
BenchmarkMerge/goroutines/8-4 30000 50819 ns/op
BenchmarkMerge/goroutines/16-4 20000 88566 ns/op
BenchmarkMerge/goroutines/32-4 10000 162391 ns/op
BenchmarkMerge/goroutines/64-4 5000 299955 ns/op
BenchmarkMerge/goroutines/128-4 3000 574043 ns/op
BenchmarkMerge/goroutines/256-4 1000 1129372 ns/op
BenchmarkMerge/goroutines/512-4 1000 2251411 ns/op
BenchmarkMerge/goroutines/1024-4 300 4760560 ns/op
BenchmarkMerge/reflect/1-4 200000 10868 ns/op
BenchmarkMerge/reflect/2-4 100000 22335 ns/op
BenchmarkMerge/reflect/4-4 30000 54882 ns/op
BenchmarkMerge/reflect/8-4 10000 148218 ns/op
BenchmarkMerge/reflect/16-4 3000 543921 ns/op
BenchmarkMerge/reflect/32-4 1000 1694021 ns/op
BenchmarkMerge/reflect/64-4 200 6102920 ns/op
BenchmarkMerge/reflect/128-4 100 22648976 ns/op
BenchmarkMerge/reflect/256-4 20 90204929 ns/op
BenchmarkMerge/reflect/512-4 3 383579039 ns/op
BenchmarkMerge/reflect/1024-4 1 1676544681 ns/op
BenchmarkMerge/recursion/1-4 500000 2658 ns/op
BenchmarkMerge/recursion/2-4 100000 14707 ns/op
BenchmarkMerge/recursion/4-4 30000 44520 ns/op
BenchmarkMerge/recursion/8-4 10000 114676 ns/op
BenchmarkMerge/recursion/16-4 5000 261880 ns/op
BenchmarkMerge/recursion/32-4 3000 560284 ns/op
BenchmarkMerge/recursion/64-4 2000 1117642 ns/op
BenchmarkMerge/recursion/128-4 1000 2242910 ns/op
BenchmarkMerge/recursion/256-4 300 4784719 ns/op
BenchmarkMerge/recursion/512-4 100 10044186 ns/op
BenchmarkMerge/recursion/1024-4 100 20599475 ns/op
ok github.com/campoy/justforfunc/27-merging-chans 61.533s
We can see that even though the recursive algorithm is the fastest for a single channel, very quick the solution with many goroutines outperforms the others.
We can also this by plotting these numbers with some quick Python. Stop acting shocking I somtimes write in Python too!
It’s hard to see the difference, so let’s use a logarithmic scale for the Y axis (simply add
Managing the benchmark timer
There’s an obvious issue with the way we’re measuring performance. We’re also counting the time it takes to create the
N channels for our tests, which adds cost that is not relevant to our algorithms.
We can avoid this by using
The results are not that different to our previous benchmark, but we can see the timing difference increases with the value of
Can we make them faster?
We finally have a good view of how each function performs for different sizes of
N. This is great, but doesn’t really help us making our functions faster.
How could we know more? Well, benchmarks take us only so far, but the next tool we we can use is profiling! Wanna know more? Next episode coming up in two weeks.