Skip to Content
Course content

77: Benchmarking Go Code

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I was looking at some code in a project recently where we were building up a large configuration string by concatenating a bunch of small pieces together. It looked fine, but the logs showed this specific function was taking way longer than it should. My gut told me it was a memory allocation issue, but "gut feelings" don't get code merged in a professional environment. I needed numbers.

The "Quick and Dirty" timing trap

My first instinct—the lazy engineer instinct—was to just wrap the function call in a time.Since() block and print the result. I tried something like this:

func main() {
    start := time.Now()
    buildLargeString()
    fmt.Printf("Took %v\n", time.Since(start))
}

I ran it three times. First time: 12ms. Second time: 15ms. Third time: 8ms. This is useless. Between background OS processes, CPU throttling, and the Go runtime's garbage collector kicking in at random intervals, a single execution is just noise. To get a real sense of performance, I need to run the code thousands of times and average the result. I could write a for loop to do that manually, but Go has a first-class way of handling this that's much more scientific.

Letting the testing package take the wheel

I moved the logic into a _test.go file. In Go, benchmarks are just functions in your test files that start with the word Benchmark and take a pointer to testing.B. Let's look at the version I wrote for the "naive" concatenation using the + operator:

func BenchmarkNaiveConcat(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var s string
        for j := 0; j < 1000; j++ {
            s += "append this"
        }
    }
}

You'll notice the b.N. This is the part that trips people up at first. You don't decide what N is; the Go benchmark tool does. It starts by running the loop once. If that's too fast to measure accurately, it increases N to 100, then 10,000, and so on, until it has a statistically significant sample. It basically keeps running your code until it's confident in the timing.

I ran it using go test -bench=. and got this:

BenchmarkNaiveConcat-12    2000    540230 ns/op

That 540230 ns/op is the key. It took about 540 microseconds per operation. Now I have a baseline. If I change the code and that number goes down, I've actually improved it.

The battle of the string builders

Now I can actually test my hypothesis. I suspect strings.Builder will be faster because it avoids creating a new string in memory every single time we use +. I added a second benchmark to the same file:

func BenchmarkBuilderConcat(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for j := 0; j < 1000; j++ {
            sb.WriteString("append this")
        }
        _ = sb.String()
    }
}

Running them side-by-side gave me a very clear winner:

BenchmarkNaiveConcat-12    2000    540230 ns/op
BenchmarkBuilderConcat-12  45000     23100 ns/op

The strings.Builder version is nearly 23 times faster. In a tight loop or a high-traffic API endpoint, that's the difference between a snappy app and one that spends all its time waiting for the garbage collector to clean up thousands of discarded string fragments.

Looking deeper with memory stats

The time difference is obvious, but I wanted to see why it was happening. I added the -benchmem flag to my command: go test -bench=. -benchmem.

Now the output looks like this:

BenchmarkNaiveConcat-12    2000    540230 ns/op    450120 B/op    999 allocs/op
BenchmarkBuilderConcat-12  45000     23100 ns/op     15200 B/op      12 allocs/op

This is the "aha!" moment. The naive version was performing 999 allocations per operation. Every single + was forcing Go to allocate a new, larger block of memory and copy the old string into it. The Builder version only allocated 12 times. It's not just that the code is "faster"; it's that it's doing drastically less work for the memory manager.




📋 Practical Task

Optimizing the Integer-to-CSV Converter

You have a function that takes a slice of integers and converts them into a single comma-separated string. Currently, it uses fmt.Sprintf inside a loop, which is notoriously slow for this kind of work.

Your goal:

  • Create a _test.go file.
  • Write a benchmark for the existing Sprintf implementation.
  • Implement a second version of the function using strconv.Itoa and strings.Builder.
  • Write a benchmark for your new implementation.
  • Run both benchmarks with the -benchmem flag and verify that your new version reduces both the ns/op and the allocs/op.
// The slow implementation to start with
func ConvertToCSV(nums []int) string {
    var res string
    for i, n := range nums {
        if i > 0 {
            res += ","
        }
        res += fmt.Sprintf("%d", n)
    }
    return res
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.