introduction
Unless you are prototyping the service, you may be concerned about the memory usage of your application. Smaller memory footprint, lower infrastructure costs, and scaling becomes easier/latency.
Although Go is known for not consuming a lot of memory, there are ways to reduce consumption further. Some of them require a lot of refactoring, but many are easy to do.
Pre-allocated slices
An array is a collection of the same type with continuous memory. Array type definition specifies length and element type. The main problem with arrays is that their size is fixed - they cannot be resized because the length of the array is part of their type.
Unlike array types, the slice type does not have a specified length. Slices are declared the same way as arrays, but no element counts.
Slices are wrappers for arrays, they do not own any data - they are references to arrays. They consist of pointers to the array, length of segments, and their capacity (number of elements in the underlying array).
When you append to a slice without a new value capacity - a new array with a larger capacity is created and the values from the current array are copied into the new array. This results in unnecessary allocation and CPU cycles.
To better understand this, let's take a look at the following code snippet:
func main() { var ints []int for i := 0; i < 5; i++ { ints = append(ints, i) ("Address: %p, Length: %d, Capacity: %d, Values: %v\n", ints, len(ints), cap(ints), ints) } }
The output is as follows:
Address: 0xc0000160c8, Length: 1, Capacity: 1, Values: [0]
Address: 0xc0000160f0, Length: 2, Capacity: 2, Values: [0 1]
Address: 0xc00001e080, Length: 3, Capacity: 4, Values: [0 1 2]
Address: 0xc00001e080, Length: 4, Capacity: 4, Values: [0 1 2 3]
Address: 0xc00001a140, Length: 5, Capacity: 8, Values: [0 1 2 3 4]
Looking at the output, we can conclude that whenever we have to increase capacity (increase by 2 times), we must create a new underlying array (new memory address) and copy the value into the new array.
The interesting fact is that the factor of capacity growth was once twice as high as capacity <1024, and 1.25 times as high as >= 1024. Since Go 1.18, this has becomeMore linear。
name time/op Append-10 3.81ns ± 0% PreallocAssign-10 0.41ns ± 0% name alloc/op Append-10 45.0B ± 0% PreallocAssign-10 8.00B ± 0% name allocs/op Append-10 0.00 PreallocAssign-10 0.00
Looking at the above benchmark, we can conclude that there is a big difference between assigning values to pre-allocated slices and appending values to slices.
Two linters help to pre-allocate slices:
- prealloc: A static analysis tool for finding slice declarations that may be pre-allocated.
- makezero: A static analysis tool for finding slice declarations that are not initialized with zero length and are later used with append.
Order fields in a structure
You may not have thought of this before, but the order of fields in the structure is important for memory consumption.
The following structure is an example:
type Post struct { IsDraft bool // 1 byte Title string // 16 bytes ID int64 // 8 bytes Description string // 16 bytes IsDeleted bool // 1 byte Author string // 16 bytes CreatedAt // 24 bytes } func main(){ p := Post{} ((p)) }
The output of the above function is 96 (bytes), while all fields are added to 82 bytes. Where does the extra 14 bytes come from?
Modern 64-bit CPUs get data in 64-bit (8-byte) blocks. If we have an older 32-bit CPU, it will execute a 32-bit (4-byte) block.
The first cycle takes up 8 bytes.IsDraft
The field takes 1 byte and has 7 unused bytes. It cannot occupy "half" of a field.
The second and third cyclesTitle
String, the fourth loop is takenID
, and so on. Use againIsDeleted
field, it takes 1 byte and has 7 unused bytes.
What really matters is sorting fields from top to bottom by their size. Sorting the above structures, reducing the size to 88 bytes. The last two fieldsIsDraft
andIsDeleted
Put in the same block, reducing the number of unused bytes from 14 (2x7) to 6 (1 x 6), saving 8 bytes in the process.
type Post struct { CreatedAt // 24 bytes Title string // 16 bytes Description string // 16 bytes Author string // 16 bytes ID int64 // 8 bytes IsDeleted bool // 1 byte } func main(){ p := Post{} ((p)) }
Go types that occupy <8 bytes on a 64-bit architecture:
- bool: 1 byte
- int8/uint8: 1 byte
- int16/uint16: 2 bytes
- int32/uint32/rune: 4 bytes
- float32: 4 bytes
- byte: 1 byte
Rather than manually checking the structures and sorting them by size, use linter to find these structures and (used to) report the "correct" sort.
- maligned: Deprecated linter, used to report unaligned structures and print out correctly sorted fields. It was deprecated a year ago, but you can still install the old version and use it.
- govet/fieldalignment: As part of gotools and govet linter, fieldalignment prints out the current/ideal size of the unaligned structure and structure.
To install and run fieldalignment:
go install /x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest fieldalignment -fix <package_path>
Use govet/fieldalignment in the above code:
fieldalignment: struct of size 96 could be 88 (govet)
Use map[string]struct{} instead of map[string]bool
Go does not have built-in collections, usually usedmap[string]bool{}
to represent a collection. Although it's more readable, it's very important to use it as a collection because it has two states (false/true) and uses extra memory compared to an empty structure.
Empty structure (struct{}
) is a structure type without additional fields, occupying zero-byte storage space.
I don't recommend this unless your map/set contains a lot of values and you need to get extra memory or you are developing for a low memory platform.
Extreme examples of using 100 000 000 to write to a map:
func BenchmarkBool(b *) { m := make(map[uint]bool) for i := uint(0); i < 100_000_000; i++ { m[i] = true } } func BenchmarkEmptyStruct(b *) { m := make(map[uint]struct{}) for i := uint(0); i < 100_000_000; i++ { m[i] = struct{}{} } }
The following results are obtained, which are very consistent throughout the entire operation:
name time/op Bool 12.4s ± 0% EmptyStruct 12.0s ± 0% name alloc/op Bool 3.78GB ± 0% EmptyStruct 3.43GB ± 0% name allocs/op Bool 3.91M ± 0% EmptyStruct 3.90M ± 0%
Using these numbers, we can conclude that writing speeds using empty structure maps are 3.2% faster and allocated memory is reduced by 10%.
In addition, usemap[type]struct{}
is the correct solution to implementing a collection, because each key has a value. usemap[type]bool
, each key has two possible values, which is not a collection, and if the goal is to create a collection, it may be misused.
However, readability is most important than (negligible) memory improvements. Compared to empty structures, it is easier to master the search using boolean values:
m := make(map[string]bool{}) if m["key"]{ // Do something } v := make(map[string]struct{}{}) if _, ok := v["key"]; ok{ // Do something }
Reference link:Easy memory-saving tricks in Go | Emir Ribic ()
The above is the detailed explanation of Golang's simple memory management skills. For more information about Golang's memory management, please follow my other related articles!