Skip to Content
Course content

220: Benchmarking with -benchmem

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

I've seen this happen to some of the best engineers I know: they run a benchmark, see that the ns/op (nanoseconds per operation) is low, and assume the code is "fast." But "fast" is a dangerous word in Go. You can have a function that runs quickly in a vacuum but absolutely hammers the garbage collector (GC) in production because it's allocating memory like crazy.

Let's look at a piece of code that looks innocent enough. Imagine you're writing a helper to convert a slice of integers into a comma-separated string for a log entry.

func FormatIDs(ids []int) string {
    var result string
    for i, id := range ids {
        if i > 0 {
            result += ","
        }
        result += strconv.Itoa(id)
    }
    return result
}

The Hidden Cost of String Concatenation

If you run a standard benchmark on this with a small slice, you might see something like 200 ns/op. You'd think, "Great, it's fast." But here is the problem: strings in Go are immutable. Every time you use +=, Go creates a brand new string in memory and copies the old data into it. For a slice of 10 elements, you aren't just making one string; you're creating and discarding dozens of intermediate strings.

In a high-throughput system, this creates "GC pressure." The CPU spends more time cleaning up those discarded strings than actually running your business logic. This is why ns/op doesn't tell the whole story.

Visualizing the Heap with -benchmem

To see what's actually happening under the hood, we need to use the -benchmem flag. This tells the Go testing tool to include memory allocation statistics in the output.

If you run go test -bench . -benchmem, you'll see two critical new columns: B/op (bytes allocated per operation) and allocs/op (how many distinct allocations happened per operation).

BenchmarkFormatIDs-8    1000000    210 ns/op    480 B/op    18 allocs/op

Look at that: 18 allocs/op. For a simple string join, that is a disaster. Every single call to this function is triggering 18 separate memory allocations. If this function is called 10,000 times a second, you're forcing the GC to track and clean up 180,000 objects every second just to format some IDs.

Pre-allocating with strings.Builder

To fix this, we need to stop creating intermediate strings. The professional way to handle this in Go is strings.Builder. It uses an internal byte slice that grows dynamically, significantly reducing the number of allocations.

But we can go one step further. If we have a rough idea of how large the final string will be, we can use Builder.Grow() to allocate the entire required memory block once.

func FormatIDs(ids []int) string {
    if len(ids) == 0 {
        return ""
    }

    var b strings.Builder
    // Rough estimate: 5 digits per ID plus a comma
    b.Grow(len(ids) * 6) 

    for i, id := range ids {
        if i > 0 {
            b.WriteByte(',')
        }
        b.WriteString(strconv.Itoa(id))
    }
    return b.String()
}

Now, run the benchmark again with -benchmem. You'll likely see the allocs/op drop from 18 down to 1 or 2. I personally find it incredibly satisfying to see that number drop. By reducing the allocations, you've not only made the function slightly faster in terms of nanoseconds, but you've made the entire application more stable by giving the garbage collector a break.




📋 Practical Task

Optimizing the UniqueInteger Filter

You have a function that filters out duplicate integers from a slice. While it works correctly, it is performing far too many allocations because it initializes a map and a result slice without any capacity hints.

Your Task:

  1. Create a file named filter_test.go.
  2. Implement a BenchmarkFilterUnique function that tests a slice of 100 integers (with some duplicates).
  3. Run the benchmark using go test -bench . -benchmem and note the allocs/op.
  4. Optimize the FilterUnique function by pre-allocating the result slice capacity (using make([]int, 0, len(input))) and providing a capacity hint to the map.
  5. Run the benchmark again and verify that the allocs/op has decreased.

Starting Code:

func FilterUnique(input []int) []int {
    keys := make(map[int]bool)
    var list []int
    for _, entry := range input {
        if _, value := keys[entry]; !value {
            keys[entry] = true
            list = append(list, entry)
        }
    }
    return list
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.