Go Structs: Ottimizzazione della Memoria

Usando Go è molto semplice realizzare qualsiasi tipo di algoritmo (davvero, credetemi) o API complessa dopo averla progettata consapevolemente. Come linguaggio di programmazione, è sicuramente uno dei principali facilitatori. Non presenta particolari problematiche e anche il/la dev meno esperto/a può realizzare soluzione efficienti.
Ovviamente i punti di attenzione nell’uso quotidiano vertono più sulla sintassi e sull’attenzione a gestire correttamente gli errori, usare variabili in modo ragionato e gestire il packaging dei moduli.
Tuttavia, come accade spesso in questo campo, a un certo punto scrivere codice e fare assunzioni sul funzionamento del motore che c’è sotto il cofano, non basta più. Quindi iniziamo a ottimizzare.
Tra gli aspetti più interessanti di Go sicuramente la gestione della memoria è uno tra i primissimi. Conoscere gli aspetti di ottimizzazione dell’occupazione di memoria o anche gli accessi (concorrenti e non) alle locazioni, può fare la differenza.
In questo articolo parleremo di questo tema in modo tecnico, utilizzando esempi che potrete eseguire sulla vostra macchina e modificare per capire meglio.
La memoria in Go
Uno strumento che Go mette a disposizione (come C) sono le struct
, ovvero un tipo di dato strutturato che rappresenta una collezione di campi di vario tipo (base o non). Tradotto in termini più vicini al metallo, un raggruppamento di dati correlati, memorizzati in un unico blocco di memoria contigua. Nella definizione delle struct
, l’ordine dei campi interni conta, e ora vediamo come.
A differenza di C, il compilatore Go fa in modo che ogni indirizzo di memoria di un campo di una struct sia allineato e quindi si trovi a un offset che è multiplo di 8 (su architetture a 64bit). Lo fa inserendo padding tra i campi della struct, cioè spazio vuoto che rimane inutilizzato.
Facciamo subito un test:
package main
import (
"fmt"
"unsafe"
)
type Misaligned struct {
A int8 // 1 byte
B int64 // 8 byte
C bool // 1 byte
}
func main() {
fmt.Println(unsafe.Sizeof(Misaligned{}))
}
La struct
Misaligned ha 3 campi la cui somma totale in numero di byte in memoria (da semplice addizione) sarebbe 10. Tuttavia (spoiler) il risultato che vedrete eseguendolo è 24. Questo perché in realtà Go inserisce padding tra i campi della struct, in questo modo:
type Misaligned struct {
A int8 // 1 byte
_ [7]byte // padding
B int64 // 8 byte
C bool // 1 byte
_ [7]byte // padding
}
Affinché il campo A sia allineato a B, Go inserisce 7 byte di padding tra i due campi e dopo il campo C per allineare l’intera struttura ae un indirizzo multiplo di 8. Per maggiore chiarezza, riportiamo anche gli offset di memoria in una tabella:
| Campo | Tipo | Offset | Dimensione |
| ---------- | ------- | ------ | ----------- |
| `A` | `int8` | 0 | 1 byte |
| `_` | `PAD` | 1–7 | 7 byte |
| `B` | `int64` | 8 | 8 byte |
| `C` | `bool` | 16 | 1 byte |
| `_` | `PAD` | 17–23 | 7 byte |
| **Totale** | | | **24 byte** |
Come ulteriore prova e capire meglio potremmo leggere gli indirizzi di memoria dei campi in un’istanza di questa struct
in questo modo:
func main() {
m := Misaligned{A: 1, B: 42, C: true}
fmt.Printf("Offset A: %p\n", &m.A)
fmt.Printf("Offset B: %p\n", &m.B)
fmt.Printf("Offset C: %p\n", &m.C)
}
E ottenendo, ad esempio:
Offset A: 0x14000116018
Offset B: 0x14000116020
Offset C: 0x14000116028
Si vede che tra l’offset del campo A e quello del campo B c’è un “salto”:
- A si trova all’offset 0x14000116018
- L’indirizzo successivo 0x14000116019, in cui viene messo il padding
- B si trova all’offset 0x14000116020
E quindi? Per ottimizzare il layout della struct nell’esempio, potremmo dichiararla così:
type Aligned struct {
B int64 // 8 byte
A int8 // 1 byte
C bool // 1 byte
_ [7]byte //padding
}
A questo punto, l’output precedente sarà:
Offset A: 0x140000ac020
Offset B: 0x140000ac018
Offset C: 0x140000ac021
Ora non c’è più padding tra i campi e la dimensione occupata dai campi è di 10 bytes. In questo caso il compilatore allinea comunque la struttura a un indirizzo multiplo di 8, aggiungendo 7 bytes in coda. Tuttavia ora abbiamo visibilmente:
- minor spreco di memoria: non ci sono blocchi vuoti inutilizzati come tra il campo A e il campo B della struct Misaligned;
- caching più efficiente: struct più piccole e contigue hanno bisogno di meno spazio e c’è più spazio per tutti;
- meno probabilità che la CPU debba accedere a due pagine di memoria diverse per leggere i dati: in pratica è come se l’ultima frase della pagina di un libro non finisse mai alla pagina successiva (o comunque molto raramente).
Facciamo qualche altro esempio pratico e divertente: vediamo le prestazioni con un piccolo benchmark che alloca un milione di volte le due struct
inizializzate:
package main
import (
"testing"
)
type Misaligned struct {
A int8 // 1 byte
B int64 // 8 byte
C bool // 1 byte
}
type Aligned struct {
B int64 // 8 byte
A int8 // 1 byte
C bool // 1 byte
}
const N = 1_000_000
func BenchmarkMisaligned(b *testing.B) {
for i := 0; i < b.N; i++ {
s := make([]Misaligned, N)
for j := 0; j < N; j++ {
s[j] = Misaligned{A: 42, B: 42, C: true}
}
}
}
func BenchmarkAligned(b *testing.B) {
for i := 0; i < b.N; i++ {
s := make([]Aligned, N)
for j := 0; j < N; j++ {
s[j] = Aligned{A: 42, B: 42, C: true}
}
}
}
func BenchmarkStructSizes(b *testing.B) {
b.ReportAllocs()
}
Esguendo il comando:
go test -bench=. -benchmem
Il risultato (che dipende dall’hardware sottostante) sarà qualcosa del tipo:
goos: darwin
goarch: arm64
pkg: 1_go_layout
cpu: Apple M4 Pro
BenchmarkMisaligned-12 1881 636419 ns/op 24002578 B/op 1 allocs/op
BenchmarkAligned-12 2432 494908 ns/op 16007187 B/op 1 allocs/op
....
Già in questo semplice benchmark c’è una palese differenza, facciamo due conti:
- memoria: in caso di struttura non ottimizzata abbiamo circa 24MB di memoria allocata che diventano 16MB solo ordinando i campi;
- cpu: i tempi per operazione nel caso di struttura disallineata sono di 636µs/op contro i 494µs/op in caso di struttura ordinata.
In sostanza, stiamo dicendo che solo ordinando i campi di una struct abbiamo:
- meno occupazione di memoria (e ce lo aspettavamo): 33% in meno;
- miglioramento delle prestazioni di circa il 22% !!!
Ok, spero di avervi convinto! 🔥
Concludendo, facciamo delle doverose considerazioni:
Go non ci obbliga a stare attenti a questo genere di ottimizzazioni ma è buona prassi buttarci un occhio, ed esistono dei tool che ci danno dei suggerimenti come vet (ho provato anche a scrivere una piccola estensione di VSCode per aiutare questo genere di ottimizzazione, la trovate qui);
- anche solo ordinare i campi in ordine decrescente è sufficiente per ottenere un miglioramento sensibile delle prestazioni;
Questo genere di osservazioni sono cruciali quando il nostro software è soggetto a carichi pesanti che mettono a dura prova la CPU e il lavoro del GC.
Alla prossima!