Let’s take a look at something no-one sensible would worry about. I want to build a string to use as a key for a cache, something like <type>:<client Id>:<id>. “P:phil:76HS87ak” for example. But I want to do it fast. And I don’t want too many allocations. And I thought I was clever. And I was wrong.

Lets try the naive way. Surely this can’t be the best way?

key := itemType + ":" + clientId + ":" + id

I’m sure someone has told me that will be slow and cause lots of allocations. Something about every + generating another string allocation. What other ways can we think of? We could use fmt.Sprintf().

key := fmt.Sprintf("%s:%s:%s", itemType, clientId, id)

We could use strings.Join().

key = strings.Join([]string{itemType, clientId, id}, ":")

Or, if you’re feeling very pleased with yourself, you could use bytes.Buffer. Very low-level. Very fancy. Feels, scalable.

l := len(itemType) + len(clientId) + len(id) + 2

buf := make([]byte, 0, l)

w := bytes.NewBuffer(buf)

w.WriteString(itemType)

w.WriteRune(':')

w.WriteString(clientId)

w.WriteRune(':')

w.WriteString(id)

key := w.String()

Lots of code there, controls allocations, must be good, right? I thought so. But let’s benchmark.

BenchmarkSimpleKey-8 10000000 141 ns/op 31 B/op 1 allocs/op BenchmarkSprintfKey-8 5000000 392 ns/op 79 B/op 4 allocs/op BenchmarkJoinKey-8 10000000 156 ns/op 63 B/op 2 allocs/op BenchmarkBufferKey-8 5000000 268 ns/op 175 B/op 3 allocs/op

Oh. Ah. That’s embarrassing.

[Edit: strings.Join() appears to have some special cases in go 1.8 for joining 3 strings or fewer, and is only 1 allocation in those cases. See https://github.com/golang/go/blob/master/src/strings/strings.go#L340-L367. Not sure why they didn’t go the whole hog and use *(*string)(unsafe.Pointer(&b)) at the end of the routine instead]

bytes.Buffer

So what’s going on. Let’s look at the bytes.Buffer case. Where are the allocations?

buf := make([]byte, 0, l)

OK, that’s one allocation. That makes sense. We can’t avoid allocating enough memory to hold the final string. Happy with this one.

w := bytes.NewBuffer(buf)

Yes, bytes.NewBuffer() allocates a Buffer structure. Here’s the source code.

func NewBuffer(buf []byte) *Buffer { return &Buffer{buf: buf} }

This creates a new Buffer and returns a pointer to it. By returning a pointer it forces the compiler to do an allocation. So that’s two. Where’s the third?

key := w.String()

Loyal followers of my blog will remember from https://medium.com/@philpearl/byte-vs-string-in-go-d645b67ca7ff that casting a []byte to a string causes an allocation. And that’s what happens here.

After some feedback I’ve got something even crazier to try. Let’s not bother measuring a nice new []byte to build our Buffer with. Lets try this.

w := bytes.Buffer{}

w.WriteString(itemType)

w.WriteRune(':')

w.WriteString(clientId)

w.WriteRune(':')

w.WriteString(id)

key := w.String()

Surely that’s got to be worse? We didn’t put in nearly as much effort.

BenchmarkSimpleBufferKey-8 5000000 265 ns/op 143 B/op 2 allocs/op

Somehow we saved an allocation by not trying so hard! What’s going on here? Well, it turns out that the bytes.Buffer structure contains a 64 byte array that is used to seed the buffer if you don’t supply your own. It looks like the compiler always uses an allocation when making a bytes.Buffer{}, even if you don’t use a pointer for it, so we still get 2 allocations. I’m not sure what the trigger is to make the compiler do that: perhaps that can be the subject of another blog post.

You can reduce this down to one allocation if you can reuse the bytes.Buffer and call its Reset() method (thanks internet folk for the reminder). This keeps hold of the internal []byte and reduces its capacity to 0, so we’re left with just the allocation in .String(). But in this simple case you’re still better off using + as it’s simpler and cleaner, and you don’t need to hold on to a bytes.Buffer.

Plain old +

We all know that just concatenating strings causes loads of allocations and we shouldn’t do it, but apparently that’s just not true. What’s going on? It turns out the compiler spots string additions and passes them to this little function https://golang.org/pkg/runtime/?m=all#concatstrings. What does this do? Well, it’s the equivalent of this below: it just builds a buffer the right size and copies the strings into it. And then uses the trick from https://medium.com/@philpearl/byte-vs-string-in-go-d645b67ca7ff to convert it into a string without an additional allocation. The compiler can do this with a clear conscience as it allocates the []byte and doesn’t tell anyone about it, so there’s no way anyone can accidentally change it and alter the string.

l := len(itemType) + len(clientId) + len(id) + 2

buf := make([]byte, l)

offset := 0

copy(buf, itemType)

offset += len(itemType)

copy(buf[offset:], ":")

offset += len(":")

copy(buf[offset:], clientId)

offset += len(clientId)

copy(buf[offset:], ":")

offset += len(":")

copy(buf[offset:], id) key := ByteSliceToString(buf)

If we run this code through a benchmark we get the following, which is equivalent to just using +.

BenchmarkCopyKey-8 10000000 114 ns/op 31 B/op 1 allocs/op

We can still confuse the compiler and stop it using the optimisation. Lets try splitting it over multiple lines.

key := itemType

key += ":"

key += clientId

key += ":"

key += id

This is more what I expected from simple concatenation.

BenchmarkSimpleMultilineKey-8 5000000 256 ns/op 63 B/op 4 allocs/op

So, I’m not talking to bytes.Buffer anymore (I’m sure I’ll forgive it in a day or so). And I need to apologise to the go compiler. And I’m going to keep benchmarking things, because my instincts about what’s good are demonstrably wrong.

If you’ve read this far and liked reading, then consider pressing the like button. My understanding is that is what the button is for, and it’s a shame to waste it.

(see https://syslog.ravelin.com/bytes-buffer-revisited-edee5a882030 for an update on this post)

By day, Phil uses his coding super-power to fight crime at ravelin.com