How to Make Go Structs More Efficient
Language
- unknown
by James Smith (Golang Project Structure Admin)
It may be surprising to learn that two Go structs can contain exactly the same fields and yet one can require more — or less — memory than the other.
Since we generally want to ensure that we never use more memory than required, we are going to look at a technique called structure packing and how it can be applied to the Go programming language.
Table of Contents
Creating an Example Struct
Let’s look at the following example code, which just defines two custom types, one of which is a struct containing three fields:
package main
type City uint8
const (
NewYork City = iota
London
Paris
Mumbai
)
type Person struct {
currentResidence City
uniqueID int64
passportNumber int16
}
As you can see, I have declared some example City
constants, so you can see how the type could be used to represent four different locations around the world. The iota
keyword just ensures that each of the constants has a unique numeric value.
Now let’s declare a Person
variable and see how much memory it requires:
package main
import (
"fmt"
"unsafe"
)
func main() {
me := Person{
currentResidence: London,
uniqueID: 9248511308,
passportNumber: 10564,
}
fmt.Printf(
"My Person struct uses %d bytes.\n",
unsafe.Sizeof(me),
)
}
When I run this code, it tells me that our Person
struct uses a total of 24 bytes in order to hold its data.
And yet how can that be so?
Let’s think it through step-by-step: the uniqueID
field is a 64-bit number, so it requires eight bytes of storage, the passportNumber
field is a 16-bit number, so it only requires two bytes of storage, and the currentResidence
field is a custom City
type that’s stored as an 8-bit number, only requiring one byte of storage.
(Note that we haven’t taken into account whether a field’s numeric value is signed or unsigned, because an unsigned integer takes up exactly the same amount of space in memory as its corresponding signed integer, which is why the maximum value of an unsigned integer is always greater than the maximum value of a signed integer, because a signed integer will already have used one bit just to store the number’s sign.)
If we add up the amount of storage needed to store the three fields separately, we only get to eleven bytes (because 8 + 2 + 1 == 11
).
However, as we’ve just seen, the Person
struct seemed to be using more than twice as much memory as that!
Thinking About Data Alignment
When we were measuring the size of our data, we talked in terms of bytes.
However, a computer’s CPU doesn’t read data in bytes — instead, it reads data in words.
A word is equivalent to eight bytes on a 64-bit system.
On an older 32-bit system, which some of you may still be using, a word is equivalent to four bytes.
It’s important to remember that a machine will tend to read data in multiples of words, and this is what explains why our Person
struct used 24 bytes.
It was using a single word for each field, even though only the uniqueID
field (which was a 64-bit integer) made full use of the word.
My machine has a 64-bit processor, so if each word is eight bytes, then it becomes easy to understand how we get to the number twenty-four (because 8 * 3 == 24
).
The passportNumber
field, which was a 16-bit integer and so only required two bytes of storage, would still access a whole word from memory but only use the first quarter of it, leaving the last three quarters unused.
Likewise, the currentResidence
field, which is a custom City
type that is ultimately equivalent to an 8-bit integer, only really requires a single byte of storage and yet a whole word is still accessed from memory and the last seven bytes are not used at all.
Reducing the Amount of Memory Our Struct Requires
There’s not much we can do to reduce the memory footprint of the uniqueID
field, since it must use a whole word, otherwise it wouldn’t be able to represent a 64-bit number.
However, did you notice how it would be possible to fit the passportNumber
and currentResidence
fields into a single word, since they only use up three bytes between them?
In fact, we can force the Go compiler to share one word of memory for these two fields simply by reordering them when we define our Person
struct.
When we defined it in the previous example, we placed the uniqueID
field in between the other two fields, which meant that the smaller fields couldn’t share memory, because they were separated by a whole word between them.
If, on the other hand, we were to put the passportNumber
and currentResidence
fields together as either the first or last fields in the struct, then we suddenly allow them to share memory, such as in the example below:
package main
type Person struct {
uniqueID int64
passportNumber int16
currentResidence City // remember that this is really a uint8
}
When I run the main
function that we wrote earlier with this slightly modified Person
definition, I can see that the same data is now stored on my machine using only 16 bytes of memory.
This is still more than the 11 bytes of memory that we calculated the fields should need in total, but since we can only use a multiple of 8 bytes, which is a 64-bit word, that’s as small as we can possibly make the struct, since 8 bytes wouldn’t be enough.
This ordering of the Person
struct is certainly more efficient than the one we began with, and, indeed, it is now as efficient as can be.
Reordering the fields of a struct in order to reduce its memory usage is exactly what was meant by the term structure packing that I used at the very beginning of this blog post.
Using the Fieldalignment Tool
The Go team has developed an official tool called fieldalignment
that will search for structs in your code that contain fields that aren’t ordered as efficiently as they otherwise could be.
The tool can be installed by running the following command:
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
Then we can run the program on a Go file or package by giving it the appropriate path like so:
fieldalignment main.go
Running the fieldalignment
tool on my initial example code would produce the following output:
main.go:12:13: struct of size 24 could be 16
Note that if you include the --fix
flag when running the program, as shown below, it will automatically edit your Go file so that the struct fields are in the most efficient order:
fieldalignment --fix main.go
Using the tool like this means that you never need to worry about manually reordering the fields in structs in order to ensure the most efficient use of memory.