Go Structs: Ottimizzazione della Memoria

banner

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!

Post correlati

TheRedCode.it - Il mondo #tech a piccoli #bit

Partners

Community, aziende e persone che supportano attivamente il blog

Logo di Codemotion
Logo di GrUSP
Logo di Python Milano
Logo di Schrodinger Hat
Logo di Python Biella Group
Logo di Fuzzy Brains
Logo di Django Girls
Logo di Improove
Logo del libro open source
Logo di NgRome
Logo de La Locanda del Tech
Logo di Tomorrow Devs
Logo di DevDojo
Logo di Cloud Native Days 2025

Non perderti gli ultimi aggiornamenti, iscriviti a TheRedCode Digest!

La tecnologia corre, e tu devi correre più veloce per rimanere sempre sul pezzo! 🚀

Riceverai una volta al mese (o anche meno) con codici sconto per partecipare agli eventi del settore, quiz per vincere dei gadget e i recap degli articoli più interessanti pubblicati sul blog

Ci sto!

Vuoi diventare #tech content creator? 🖊️

Se vuoi raccontare la tua sul mondo #tech con dei post a tema o vuoi condividere la tua esperienza con la community, sei nel posto giusto! 😉

Manda una mail a collaborazioni[at]theredcode.it con la tua proposta e diventa la prossima penna del blog!

Ma sì, facciamolo!