Modern Go Engineering
Chapitre 11
11 - Performance Go
11 - Performance Go
Cours 11 : Performance Go
1. Introduction à la performance Go
Go est conçu pour la performance : compilation rapide, exécution native, concurrence légère. Cependant, écrire du Go performant nécessite de comprendre : le modèle mémoire, le garbage collector, l'escape analysis, et les optimisations du compilateur.
1.1 Philosophie
// Go privilégie la clarté sur l'optimisation prématurée
// 1. Écrire du code correct
// 2. Mesurer
// 3. Optimiser les hotspots
// Principe : "Make it correct, make it clear, make it concise, make it fast. In that order."
2. Profiling avec pprof
2.1 Profiling CPU
Le profiling CPU mesure où le programme passe son temps.
package main
import (
"os"
"runtime/pprof"
)
func main() {
f, _ := os.Create("cpu.pprof")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// Code à profiler
doWork()
}
// Avec net/http/pprof
import _ "net/http/pprof"
// Puis : go tool pprof http://localhost:6060/debug/pprof/profile
// Commandes pprof :
// top10 - Top 10 hotspots
// list func - Voir le code avec annotations
// web - Graphique SVG
// peek func - Callers/callees
2.2 Profiling mémoire (heap)
// Profiling heap
f, _ := os.Create("heap.pprof")
defer f.Close()
// Écrire le heap profile
if err := pprof.WriteHeapProfile(f); err != nil {
log.Fatal(err)
}
// Ou via HTTP
// http://localhost:6060/debug/pprof/heap
// Analyse :
// go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap
// go tool pprof -alloc_objects http://localhost:6060/debug/pprof/heap
// go tool pprof -inuse_space http://localhost:6060/debug/pprof/heap
2.3 Profiling allocs
// Profiling des allocations
// go tool pprof -alloc_objects http://localhost:6060/debug/pprof/heap
// Exemple : identifier les allocations excessives
type User struct {
ID string
Name string
Age int
}
// ❌ Mauvaise : allocation à chaque appel
func GetUserName(id string) string {
u := User{ID: id} // allocation sur le heap
return u.Name
}
// ✅ Bonne : pas d'allocation inutile
func GetUserNameDirect(id string) string {
return ""
}
// Voir l'escape analysis : go build -gcflags="-m" main.go
2.4 Profiling mutex et block
// Mutex profiling
import "runtime"
// Activer le profiling
runtime.SetMutexProfileFraction(1)
runtime.SetBlockProfileRate(1)
// Via HTTP
// http://localhost:6060/debug/pprof/mutex
// http://localhost:6060/debug/pprof/block
// Mutex profiling montre les contention
// Utile pour identifier les locks hotspots
3. Benchmarks avancés
3.1 Écrire des benchmarks
package bench
import "testing"
// Benchmark standard
func BenchmarkSum(b *testing.B) {
data := make([]int, 1000)
for i := range data {
data[i] = i
}
b.ResetTimer() // Ignorer le setup
for i := 0; i < b.N; i++ {
Sum(data)
}
}
// Benchmark avec allocations
func BenchmarkAlloc(b *testing.B) {
b.ReportAllocs() // Reporter les allocations
for i := 0; i < b.N; i++ {
_ = make([]int, 0, 100)
}
}
// Benchmark parallèle
func BenchmarkParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
doWork()
}
})
}
// Benchmark de différentes tailles
func BenchmarkSizes(b *testing.B) {
sizes := []int{10, 100, 1000, 10000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
data := make([]int, size)
for i := 0; i < b.N; i++ {
Sum(data)
}
})
}
}
3.2 Benchstat
# Comparer deux benchmarks
go test -bench=. -count=10 ./... > old.txt
# Faire des changements
go test -bench=. -count=10 ./... > new.txt
# Analyser avec benchstat
benchstat old.txt new.txt
# Exemple de sortie :
# name old time/op new time/op delta
# Sum-8 1.23µs ± 2% 0.89µs ± 3% -27.6%
4. Garbage Collection
4.1 Comprendre le GC Go
Go utilise un GC concurrent, tri-color, mark-and-sweep avec :
- GC percent (GOGC) : quand déclencher le GC
- Soft memory limit (GOMEMLIMIT) : limite mémoire
// Variables d'environnement
// GOGC=100 (default) : GC quand heap * 2
// GOGC=off : désactive le GC (danger)
// GOMEMLIMIT=1GiB : limite mémoire soft
// Runtime
import "runtime/debug"
func init() {
// Set GC percent
debug.SetGCPercent(100)
// Set memory limit
debug.SetMemoryLimit(1 << 30) // 1 GiB
}
4.2 Ballast
// Ballast : mémoire réservée pour retarder le GC
// Utile pour les services avec pics de mémoire
func init() {
// Réserver 1 GiB de ballast
ballast := make([]byte, 1<<30)
runtime.KeepAlive(ballast) // Empêcher l'optimisation
}
// Sans ballast :
// GC se déclenche à 100 MB, heap redescend à 50 MB
// Avec ballast 1 GiB :
// Heap total = ballast + allocations réelles
// GC se déclenche quand ballast + réel >= 2 * (ballast + réel_base)
// = moins de GC cycles
// Go 1.19+ : GOMEMLIMIT est préféré au ballast
4.3 GC Tuning
package main
import (
"runtime"
"runtime/debug"
)
func tuneGC() {
// Pour services web : réduire la latence
// Option 1 : Augmenter GOGC (moins de GC, plus de mémoire)
debug.SetGCPercent(200) // GC tous les 2x heap
// Option 2 : Soft memory limit (Go 1.19+)
debug.SetMemoryLimit(2 << 30) // 2 GiB max
// Option 3 : Forcer le GC (rare)
runtime.GC()
// Monitoring
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc: %d MB, TotalAlloc: %d MB, GC cycles: %d\n",
m.Alloc/1e6, m.TotalAlloc/1e6, m.NumGC)
}
5. Escape Analysis
5.1 Comprendre l'escape analysis
L'escape analysis détermine si une variable doit être allouée sur le heap ou peut rester sur la stack.
// Escape to heap
func escape() *int {
x := 42 // x escape car retourné par pointeur
return &x // Alloué sur le heap
}
// No escape
func noEscape() int {
x := 42 // x reste sur la stack
return x // Copié par valeur
}
// Slice escape
func sliceEscape() []int {
s := make([]int, 100) // Alloué sur le heap (> 64KB ou size dynamique)
return s
}
// Interface escape
func interfaceEscape() any {
x := 42 // x escape car passé dans interface{}
return x
}
// Closure escape
func closureEscape() func() int {
x := 42
return func() int { // x escape car capturé par la closure
return x
}
}
5.2 Voir l'escape analysis
# Voir les décisions d'escape analysis
go build -gcflags="-m" main.go
go build -gcflags="-m -m" main.go # Plus détaillé
# Exemple de sortie :
# ./main.go:5:6: moved to heap: x
# ./main.go:12:6: can inline noEscape
5.3 Optimisations
// ✅ Stack allocation
func sum(values []int) int {
var total int // stack
for _, v := range values {
total += v
}
return total
}
// ✅ Utiliser des tableaux de taille fixe
type Point struct {
X, Y float64
}
func processPoints() {
var points [1000]Point // Stack si taille connue à la compilation
for i := range points {
points[i] = Point{X: float64(i), Y: float64(i)}
}
}
// ❌ Slice dynamique = heap
func processPointsSlice(n int) {
points := make([]Point, n) // Heap si n est variable
for i := range points {
points[i] = Point{X: float64(i), Y: float64(i)}
}
}
6. Optimisation des allocations
6.1 sync.Pool
// sync.Pool : réutiliser des objets pour réduire les allocations GC
var bufferPool = sync.Pool{
New: func() any {
return bytes.NewBuffer(make([]byte, 0, 1024))
},
}
func processRequest(data []byte) string {
buf := bufferPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufferPool.Put(buf)
}()
buf.Write(data)
buf.WriteString(" processed")
return buf.String()
}
// Benchmark avec/sans Pool
func BenchmarkWithPool(b *testing.B) {
for i := 0; i < b.N; i++ {
processRequest([]byte("hello"))
}
}
func BenchmarkWithoutPool(b *testing.B) {
for i := 0; i < b.N; i++ {
buf := bytes.NewBuffer(make([]byte, 0, 1024))
buf.Write([]byte("hello"))
buf.WriteString(" processed")
_ = buf.String()
}
}
6.2 Reusable buffers
// Buffer ring : réutiliser sans sync.Pool
type RingBuffer struct {
bufs []*bytes.Buffer
index int
mu sync.Mutex
}
func NewRingBuffer(size int) *RingBuffer {
rb := &RingBuffer{
bufs: make([]*bytes.Buffer, size),
}
for i := range rb.bufs {
rb.bufs[i] = bytes.NewBuffer(make([]byte, 0, 4096))
}
return rb
}
func (rb *RingBuffer) Get() *bytes.Buffer {
rb.mu.Lock()
defer rb.mu.Unlock()
buf := rb.bufs[rb.index]
buf.Reset()
rb.index = (rb.index + 1) % len(rb.bufs)
return buf
}
// Zero-allocation string building
func concatStrings(strs []string) string {
var total int
for _, s := range strs {
total += len(s)
}
var b strings.Builder
b.Grow(total) // Pré-allocation
for _, s := range strs {
b.WriteString(s)
}
return b.String()
}
6.3 Réduction des allocations
// ❌ Allocations multiples
func processLogs(logs []string) map[string]int {
counts := make(map[string]int)
for _, log := range logs {
parts := strings.Split(log, ",") // allocation
key := parts[0] + ":" + parts[1] // allocation
counts[key]++
}
return counts
}
// ✅ Allocations réduites
type LogKey struct {
A, B string
}
func processLogsOptimized(logs []string) map[LogKey]int {
counts := make(map[LogKey]int)
for _, log := range logs {
// Trouver les positions sans split
commaIdx := strings.IndexByte(log, ',')
if commaIdx < 0 {
continue
}
// Utiliser des sous-chaînes (pas d'allocation si pas de modification)
key := LogKey{
A: log[:commaIdx],
B: log[commaIdx+1:],
}
counts[key]++
}
return counts
}
// ❌ Boxing (int → interface{})
func boxing() {
var sum any = 0
for i := 0; i < 1000; i++ {
sum = sum.(int) + i // allocation à chaque itération
}
}
// ✅ Pas de boxing
func noBoxing() {
sum := 0
for i := 0; i < 1000; i++ {
sum += i
}
}
7. Compiler Optimizations
7.1 Inlining
// Fonctions courtes sont automatiquement inlinées
func add(a, b int) int {
return a + b // Inlinée
}
// Voir les décisions d'inlining
// go build -gcflags="-m" main.go
// Paradoxe : une fonction plus longue peut être inlinée
// si les appels sont plus coûteux que le corps
// Forcer le no-inline (debug)
//go:noinline
func debugFunction() {
// Ne sera pas inlinée
}
7.2 Bounds Check Elimination (BCE)
// Go vérifie les bounds à chaque accès slice
// Parfois le compilateur peut éliminer ces checks
// ❌ Bounds check à chaque itération
func sumSlice(s []int) int {
var sum int
for i := 0; i < len(s); i++ {
sum += s[i] // bounds check
}
return sum
}
// ✅ BCE avec range
func sumSliceRange(s []int) int {
var sum int
for _, v := range s {
sum += v // bounds check éliminé par le compilateur
}
return sum
}
// ✅ BCE avec garde
func sumFirstThree(s []int) int {
if len(s) < 3 {
return 0
}
// Les 3 accès suivants n'auront pas de bounds check
return s[0] + s[1] + s[2]
}
8. Data-Oriented Design
8.1 Structure of Arrays vs Array of Structures
// ❌ Array of Structures (AoS)
type Entity struct {
X, Y float64
Health int
Active bool
Name string
}
func processAoS(entities []Entity) {
for i := range entities {
entities[i].X += 1.0 // Cache miss : on load tout Entity
}
}
// ✅ Structure of Arrays (SoA)
type Entities struct {
Xs []float64
Ys []float64
Health []int
Active []bool
Names []string
}
func processSoA(entities *Entities) {
// Hot loop : accès mémoire contigu → cache friendly
for i := range entities.Xs {
entities.Xs[i] += 1.0
}
}
// Benchmark
// AoS : ~50 ns/op (cache misses)
// SoA : ~10 ns/op (cache hits)
8.2 Field ordering
// L'ordre des champs impacte l'alignement mémoire
// ❌ Mauvaise : holes mémoire
type BadStruct struct {
A bool // 1 byte + 7 padding
B float64 // 8 bytes
C bool // 1 byte + 7 padding
D float64 // 8 bytes
}
// Total : 32 bytes (dont 14 de padding)
// ✅ Bonne : trié par taille décroissante
type GoodStruct struct {
B float64 // 8 bytes
D float64 // 8 bytes
A bool // 1 byte
C bool // 1 byte + 6 padding
}
// Total : 24 bytes (dont 6 de padding)
// Économie : 25%
// Voir l'alignement
fmt.Println(unsafe.Sizeof(BadStruct{})) // 32
fmt.Println(unsafe.Sizeof(GoodStruct{})) // 24
9. Profiling Web Services
package main
import (
"net/http"
_ "net/http/pprof"
"runtime"
)
func main() {
// Activation du profiling
runtime.SetMutexProfileFraction(1)
runtime.SetBlockProfileRate(1)
// Routes pprof disponibles :
// /debug/pprof/ - Index
// /debug/pprof/profile - CPU (30s)
// /debug/pprof/heap - Heap
// /debug/pprof/goroutine - Goroutines
// /debug/pprof/mutex - Mutex contention
// /debug/pprof/block - Block profiling
http.ListenAndServe(":6060", nil)
}
// Commandes utiles :
// go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
// go tool pprof http://localhost:6060/debug/pprof/heap
// go tool pprof http://localhost:6060/debug/pprof/goroutine
// Analyse :
// pprof> top10
// pprof> list problematicFunction
// pprof> web
// pprof> peek
// pprof> traces
10. Execution Tracer
// go tool trace
import (
"os"
"runtime/trace"
)
func main() {
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// Code à tracer
doWork()
}
// Analyse :
// go tool trace trace.out
// Ouvre le navigateur avec :
// - Timeline view : goroutines dans le temps
// - Goroutine analysis : état des goroutines
// - Network blocking : opérations réseau
// - Syscall : appels système
// - Scheduler latency : latence du scheduler
11. Flame Graphs
# Générer un flame graph
# Méthode 1 : pprof -> svg
go tool pprof -http=:8080 cpu.pprof
# Méthode 2 : avec flame graph script
# Installer : go install github.com/brendangregg/FlameGraph@latest
go test -bench=. -cpuprofile=cpu.pprof
pprof -svg cpu.pprof > cpu.svg
# Méthode 3 : avec uber-go/pprof
# go tool pprof -http :8080 cpu.pprof
# Cliquer sur "Flame Graph" dans l'UI
12. Diagrammes
Diagramme en cours de génération...
Diagramme en cours de génération...
Points Clés
- Mesurer avant d'optimiser : les intuitions sont souvent fausses
- pprof : CPU, mémoire, mutex, block — 4 profilers différents
- GC : GOGC, GOMEMLIMIT, ballast — comprendre avant de tuner
- Escape analysis : garder les variables sur la stack
- sync.Pool : réduire la pression GC pour les allocations temporaires
- Data-oriented design : organisé pour le cache CPU
- BCE : le compilateur élimine les bounds checks avec range
- Benchstat : comparer statistiquement les benchmarks