Golang Optimisation


19 Jan 17 - 03:25

I’m busy optimising a golang service I wrote. Golang has a fantastic tool called pprof for exactly this purpose.

go tool pprof -pdf http://localhost:8080/debug/pprof/profile > report.pdf

Do Not Concatenate

Well, sounds a little misleading, but, concatenating strings is faster done via byte buffer than the traditional “+=” or “+” as described in this stack overflow post

i.e.

package main

import (
    "bytes"
    "fmt"
)

func main() {
    var buffer bytes.Buffer

    for i := 0; i < 1000; i++ {
        buffer.WriteString("a")
    }

    fmt.Println(buffer.String())
}

This is in favour of writing out string concatenations as follows:

tmpInsert := "my string"
varString := "this is " + tmpInsert + " and I can do with it as I please"

This is actually quite expensive in the greater scheme of things, or at least, more so than running it through a byte buffer.

The reason behind it is simple (as described to me by sevs44936 on reddit). He explained to me the following:

AFAIK the improvement comes from this:

s := ""
for i := 0; i < 10; i ++ {
   s += strconv.Itoa(i)
}

First step, “0” is appended to “”. A new string with length 1 is allocated, “0” is copied into that one, the string “” is gc’d. Second step, “1” is appended to “0”. Length 2 string is allocated, “0” is copied, “1” is copied, “0” is gc’d. Third step, “2” is appended to “01”, Length 3 string allocated, “01” copied, “2” copied, “01” gc’d. … etc.

for i := 0; i < 10; i ++ {
   buf.WriteString(strconv.Itoa(i))
}
s := buf.String()

bytes.Buffer starts with a capacity of 64 Bytes, i.e. “0” is copied to the buffer, “1” is copied, “2” is copied, etc, etc. When those 64 bytes are full a new slice with 2 * 64 Bytes is allocated (+ the length of the write that triggered the expansion). Everything is copied to the new buffer. After that 2 * 128, 2 * 256, etc.

Each string append causes one allocation and creates two strings for the GC.

b.WriteString causes exponentially less and less allocations the more you write to one buffer (which could be further optimized if you have an idea about the final size, use b.Grow to set the buffer capacity in that area - might save you a couple alloc’s)

Hope this makes sense ;)

– sevs44936

The second issue I found was that time.Time.Format actually takes quite a bit of memory. I was only using this for SQL inserts/updates. Replacing this with MySQL’s NOW() has made a big difference.

Know Your Size

Another big performance saver is how you initialise your slices. For example, I was initialising my slices without defining a size:

myStrings := []string

However, if we know the size, or can calculate it in advance, we can save a lot of memory and CPU usage. Using the example above, we would usually follow it with some kind of an append to add data to the slice.

myStrings := []string
tmpString := "I'm added!"
myStrings = append(myStrings, tmpString)

Golang will make a copy of the slice, pad it and then add the tmpString variable to it. If this loop runs 1000 times, golang will need to create 1000 copies and recalculate its size 1000 times. A more efficient method would be to define the slice initially. This of course only works if you can predict the size of the slice, even if it is dynamic. This will reduce CPU and RAM usage drastically depending on your requirement.

myStrings := make([]string, 0, 1000)
tmpString := "I'm added!"
for i := range myStrings {
    myStrings = append(myStrings, tmpString)
}

“Static” Variables

Last but not least, were variables which were being declared within functions that would never change. i.e. Static variables. These variables I knew were just there so that we could change them somewhat globally in future. I say somewhat because they were inside of functions and in one horrific case, within a loop.

Declaring those as global variables instead, has obvious performance benefits as golang no longer needs to initialise and destroy the variable every time the function is called. Small things can have a great impact under load.



Archives