Go Structs: Ottimizzazione della Memoria

Immagine di copertina

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!


🔗 Leggi anche:

Conosci meglio chi ha scritto questo articolo

Matteo Stabile

Ciao! Sono Matteo, e mi unisco a questo blog con grande entusiasmo per la programmazione in tutte le sue forme.

Lavoro in Poste Italiane come Software Engineer, e spero che le mie esperienze quotidiane, le sfide che affronto e le soluzioni che (ogni tanto) trovo, possano essere d’aiuto sia per chi si sta affacciando ora a questo mondo, sia per chi lo vive già da tempo. Mi capita spesso di esplorare in profondità i linguaggi che utilizzo e mi piace sperimentarne di nuovi. Passo volentieri del tempo tra repository open source, alla ricerca di ispirazione o semplicemente per imparare qualcosa di nuovo. Da qualche tempo, ho anche ripreso a scrivere codice su GitHub, un ottimo modo per mettere in pratica le idee!

Qui sul blog, vorrei condividere con voi riflessioni, esperienze e spunti, soprattutto legati al back-end.

Credo che la programmazione sia una vera e propria forma d’arte: un modo per stabilire una relazione (anche se temporanea!) con qualcosa, condividere idee in un linguaggio universale e raccontare cose che altrimenti non si potrebbero spiegare. Spero che i miei contributi vi incuriosiscano e vi siano utili!

➡️ email ➡️ GitHub

Foto di Matteo Stabile

Partners

Community, aziende e persone che supportano attivamente il blog

Vuoi diventare partner?

Collaboriamo con community, aziende e strumenti del mondo tech che vogliono raggiungere la nostra community di dev ed engineer italiani. Scrivici per proporre una collaborazione.

✉️ Scrivici

Vuoi diventare tech content creator? 🖊️

Se ti va di raccontare la tua esperienza nel mondo tech, questo è il posto giusto.

Cerchiamo voci autentiche, esempi pratici e punti di vista utili per chi legge.

Scrivici a collaborazioni[at]theredcode.it con una proposta: idea, taglio del contenuto e una breve presentazione. Non vediamo l'ora di leggere la tua esperienza!

Invia la tua idea