MFormations
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

  1. Mesurer avant d'optimiser : les intuitions sont souvent fausses
  2. pprof : CPU, mémoire, mutex, block — 4 profilers différents
  3. GC : GOGC, GOMEMLIMIT, ballast — comprendre avant de tuner
  4. Escape analysis : garder les variables sur la stack
  5. sync.Pool : réduire la pression GC pour les allocations temporaires
  6. Data-oriented design : organisé pour le cache CPU
  7. BCE : le compilateur élimine les bounds checks avec range
  8. Benchstat : comparer statistiquement les benchmarks