Skip to content

Latest commit

 

History

History
443 lines (314 loc) · 23.4 KB

File metadata and controls

443 lines (314 loc) · 23.4 KB

🔝 Retour au Sommaire

41.1.1 — Cache L1, L2, L3

Chapitre 41 : Optimisation CPU et Mémoire · Section 41.1 · Sous-section 1
Niveau : Expert · Prérequis : Section 41.1 (introduction au cache CPU)


Introduction

La section 41.1 a posé le constat : la mémoire est le goulot d'étranglement, et le cache est la solution architecturale. Mais « le cache » n'est pas un bloc monolithique. Sur un processeur moderne, il se décompose en trois niveaux — L1, L2, L3 — chacun avec des caractéristiques distinctes en termes de taille, de latence, de partage entre cœurs et de politique d'éviction.

Comprendre ces niveaux en détail n'est pas un exercice académique. C'est la base de décisions concrètes en C++ : quelle taille donner à un buffer de travail ? Faut-il découper un traitement en blocs (tiling) ? Pourquoi un programme multi-thread est-il plus lent que prévu ? Les réponses dépendent directement de la hiérarchie cache de la machine cible.


Vue d'ensemble de la hiérarchie

Le schéma suivant représente l'architecture cache typique d'un processeur desktop ou serveur en 2025-2026 (Intel Core de 13e/14e génération, AMD Zen 4/Zen 5) :

┌────────────────────────────────────────────────────────────────┐
│                          CPU Package                           │
│                                                                │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐     ┌──────────┐     │
│  │  Cœur 0  │  │  Cœur 1  │  │  Cœur 2  │ ... │  Cœur N  │     │
│  │          │  │          │  │          │     │          │     │
│  │ ┌──────┐ │  │ ┌──────┐ │  │ ┌──────┐ │     │ ┌──────┐ │     │
│  │ │L1 I/D│ │  │ │L1 I/D│ │  │ │L1 I/D│ │     │ │L1 I/D│ │     │
│  │ │32+32K│ │  │ │32+32K│ │  │ │32+32K│ │     │ │32+32K│ │     │
│  │ └──────┘ │  │ └──────┘ │  │ └──────┘ │     │ └──────┘ │     │
│  │ ┌──────┐ │  │ ┌──────┐ │  │ ┌──────┐ │     │ ┌──────┐ │     │
│  │ │  L2  │ │  │ │  L2  │ │  │ │  L2  │ │     │ │  L2  │ │     │
│  │ │0.5-2M│ │  │ │0.5-2M│ │  │ │0.5-2M│ │     │ │0.5-2M│ │     │
│  │ └──────┘ │  │ └──────┘ │  │ └──────┘ │     │ └──────┘ │     │
│  └──────────┘  └──────────┘  └──────────┘     └──────────┘     │
│                                                                │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                    Cache L3 partagé                      │  │
│  │                      16 – 96 MiB                         │  │
│  └──────────────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────────┘
                              │
                    ┌─────────┴──────────┐
                    │   RAM principale   │
                    │   (DDR5, DDR4)     │
                    │    16 – 512 GiB    │
                    └────────────────────┘

Le principe directeur est simple : plus on monte vers le CPU, plus c'est rapide et petit ; plus on descend vers la RAM, plus c'est lent et grand.


Cache L1 : la mémoire ultra-rapide

Caractéristiques

Le cache L1 est le plus proche du cœur d'exécution. C'est la mémoire la plus rapide à laquelle le processeur puisse accéder après ses registres internes.

Propriété Valeur typique (2025-2026)
Latence 1–1,5 ns (~4–5 cycles à 4 GHz)
Taille données (L1d) 32 KiB – 48 KiB par cœur
Taille instructions (L1i) 32 KiB – 64 KiB par cœur
Associativité 8-way ou 12-way set-associative
Taille de cache line 64 octets
Portée Privé à un cœur
Bande passante 2 lectures + 1 écriture par cycle (typique)

Le L1 est systématiquement séparé en deux caches distincts : le L1d (data) pour les données et le L1i (instruction) pour le code machine. Cette séparation (architecture Harvard modifiée) permet au processeur de charger simultanément une instruction et une donnée sans conflit.

Implication pour le développeur C++

Avec 32 KiB de L1d, une cache line de 64 octets représente 512 lignes disponibles. Cela signifie qu'un std::vector<int> de 8 192 éléments (32 KiB) tient entièrement dans le L1d. Un buffer de travail qui reste dans cette enveloppe sera servi à la vitesse maximale du processeur.

// Ce vecteur tient dans le L1d (32 KiB = 8192 × 4 octets)
std::vector<int> hot_buffer(8192);

// Ce vecteur dépasse le L1d → accès L2 fréquents
std::vector<int> warm_buffer(32'768);  // 128 KiB

La règle pratique : les structures de données « chaudes » — celles accédées dans la boucle interne — doivent idéalement tenir dans le L1d. Si ce n'est pas possible, le L2 est l'objectif secondaire.

Le L1i et l'optimisation du code

Le cache d'instructions L1i est souvent négligé, mais il joue un rôle dans les programmes au code footprint important — grands exécutables avec de nombreux templates instanciés, code fortement inliné, ou binaires générés par des compilateurs agressifs sur -O3. Un L1i trop sollicité génère des instruction cache misses qui se manifestent comme des ralentissements diffus, difficiles à attribuer à une ligne de code précise.

On peut les mesurer avec :

perf stat -e L1-icache-load-misses ./mon_programme

En pratique, les misses L1i deviennent un problème surtout dans les grands systèmes (bases de données, navigateurs web, serveurs applicatifs). Pour la majorité des programmes C++ de taille modérée, le L1d est le facteur limitant.


Cache L2 : le tampon intermédiaire

Caractéristiques

Le L2 est un cache unifié (données + instructions) qui sert de tampon entre le L1 ultra-rapide et le L3 partagé. Il absorbe les misses du L1 sans pénaliser autant qu'un accès L3 ou RAM.

Propriété Valeur typique (2025-2026)
Latence 4–7 ns (~12–20 cycles)
Taille 256 KiB – 2 MiB par cœur
Associativité 8-way à 16-way set-associative
Taille de cache line 64 octets
Portée Privé à un cœur

La taille du L2 varie significativement selon les architectures :

  • Intel Raptor Lake / Arrow Lake : 2 MiB par P-core, 4 MiB par cluster de E-cores.
  • AMD Zen 4 / Zen 5 : 1 MiB par cœur.
  • Apple M3/M4 : 4 MiB par P-core (généreusement dimensionné).
  • ARM Cortex-A78 (serveurs, mobile) : 256–512 KiB par cœur.

Implication pour le développeur C++

Le L2 est le niveau de cache le plus critique pour les boucles de travail de taille moyenne — les cas où le dataset dépasse le L1 mais reste suffisamment petit pour ne pas atteindre la RAM. Beaucoup de programmes passent l'essentiel de leur temps dans cette zone.

La technique de cache tiling (ou loop blocking) consiste à découper un grand traitement en blocs dont la taille est calibrée pour tenir dans le L2. L'idée : plutôt que de parcourir une matrice entière ligne par ligne (ce qui dépasse le L2 après quelques lignes), on la traite par sous-blocs carrés qui tiennent intégralement dans le cache.

#include <cstddef>

constexpr std::size_t N = 4096;  
int A[N][N], B[N][N], C[N][N];  

// Multiplication matricielle NAÏVE — dépasse tous les niveaux de cache
void matmul_naive() {
    for (std::size_t i = 0; i < N; ++i)
        for (std::size_t j = 0; j < N; ++j)
            for (std::size_t k = 0; k < N; ++k)
                C[i][j] += A[i][k] * B[k][j];
}

// Multiplication matricielle avec TILING — bloc calibré pour le L2
void matmul_tiled() {
    // Taille de bloc choisie pour que 3 blocs (A, B, C) 
    // tiennent dans le L2 : 3 × 64 × 64 × 4 = 48 KiB ≪ 1 MiB L2
    constexpr std::size_t BLOCK = 64;
    
    for (std::size_t ii = 0; ii < N; ii += BLOCK)
        for (std::size_t jj = 0; jj < N; jj += BLOCK)
            for (std::size_t kk = 0; kk < N; kk += BLOCK)
                for (std::size_t i = ii; i < ii + BLOCK; ++i)
                    for (std::size_t j = jj; j < jj + BLOCK; ++j)
                        for (std::size_t k = kk; k < kk + BLOCK; ++k)
                            C[i][j] += A[i][k] * B[k][j];
}

La version tiled est typiquement 3× à 8× plus rapide que la version naïve sur de grandes matrices, uniquement grâce à une meilleure utilisation du cache. L'algorithme reste O(n³) dans les deux cas — seul le pattern d'accès mémoire a changé.

Le choix de la taille de bloc (BLOCK) dépend directement de la taille du L2 de la machine cible. La règle empirique : on veut que les blocs de travail des matrices d'entrée et de sortie tiennent simultanément dans le L2, avec une marge pour les autres données en cache.


Cache L3 : le cache partagé

Caractéristiques

Le L3 est le dernier rempart avant la RAM. Sa caractéristique distinctive est d'être partagé entre tous les cœurs du processeur (ou d'un chiplet / CCD sur AMD).

Propriété Valeur typique (2025-2026)
Latence 10–30 ns (~30–90 cycles)
Taille 16 MiB – 96 MiB (partagé)
Associativité 12-way à 16-way
Taille de cache line 64 octets
Portée Partagé entre cœurs (ou par CCD/chiplet)

Tailles notables en 2025-2026 :

  • AMD Ryzen 9 (Zen 4) : 64 MiB (2 CCD × 32 MiB).
  • AMD Ryzen 9 X3D (Zen 4 + 3D V-Cache) : 128 MiB — un record pour le desktop.
  • Intel Core i9 Raptor Lake : 36 MiB.
  • Intel Xeon (Sapphire Rapids) : jusqu'à 112,5 MiB.
  • Apple M4 Max : 48 MiB.

Le L3 comme arbitre du multi-threading

Le caractère partagé du L3 a deux conséquences majeures :

1. Communication implicite entre cœurs. Lorsque le cœur 0 modifie une donnée, cette modification doit devenir visible aux autres cœurs. Le protocole de cohérence de cache (MESI, MOESI, ou MESIF selon l'architecture) utilise le L3 comme point de coordination. Un cache miss en L3 sur une donnée modifiée par un autre cœur déclenche un transfert inter-cœurs (snoop) plus coûteux qu'un simple accès RAM séquentiel.

2. Contention sur la capacité. Si 16 cœurs exécutent chacun un programme avec un working set de 4 MiB, l'ensemble occupe 64 MiB de L3. Sur un processeur avec 32 MiB de L3, des évictions se produisent — chaque cœur voit ses données expulsées par les autres. C'est un phénomène de cache thrashing qui se traduit par une dégradation non linéaire des performances lorsqu'on augmente le parallélisme.

Implication pour le développeur C++

Le L3 est le niveau pertinent pour les datasets de taille moyenne à grande : tables de hachage de quelques MiB, index en mémoire, buffers de traitement par lots. Si votre working set tient dans le L3, les accès mémoire coûtent 10–30 ns. S'il le dépasse, chaque miss se paie 50–100 ns en RAM.

Pour les programmes multi-thread, le partage du L3 signifie qu'il faut raisonner en termes de budget L3 par cœur :

Budget L3 effectif par cœur ≈ Taille L3 totale / Nombre de cœurs actifs

Sur un AMD Zen 4 avec 32 MiB de L3 et 8 cœurs actifs, chaque cœur dispose en moyenne de 4 MiB de L3 effectif. Si votre algorithme parallèle nécessite un working set de 6 MiB par thread, vous aurez de la contention L3 — et les performances ne scaleront pas linéairement avec le nombre de threads.


Politiques d'éviction et associativité

Lorsqu'un cache est plein et qu'une nouvelle cache line doit être chargée, une ligne existante doit être évincée. Le choix de la ligne à évincer est déterminé par deux mécanismes.

Associativité (set-associativity)

Un cache N-way set-associative divise ses lignes en sets. Une adresse mémoire donnée ne peut être placée que dans un set spécifique, mais elle peut occuper n'importe laquelle des N lignes (ways) de ce set.

Concrètement, pour un L1d de 32 KiB, 8-way, avec des lignes de 64 octets :

Nombre de sets = 32 KiB / (8 ways × 64 octets) = 64 sets

Une adresse mémoire est mappée à un set par ses bits d'adresse intermédiaires. Si 9 adresses différentes tombent dans le même set (plus de 8 ways), une éviction est inévitable — c'est un conflict miss, distinct d'un capacity miss (le cache est globalement plein).

Les conflict misses sont rares en pratique grâce aux associativités élevées (8-way à 16-way) des caches modernes, mais ils peuvent apparaître dans des patterns d'accès très réguliers avec des pas (strides) qui sont des puissances de 2. C'est un piège classique avec les matrices dont les dimensions sont des puissances de 2.

Politique de remplacement

Au sein d'un set, l'algorithme de remplacement décide quelle ligne évincer. Les processeurs modernes utilisent des approximations de LRU (Least Recently Used) — la ligne la moins récemment accédée est évincée. Les implémentations exactes varient : pseudo-LRU à arbre binaire sur Intel, RRIP (Re-Reference Interval Prediction) sur certains AMD.

En tant que développeur, on ne contrôle pas directement cette politique, mais on l'influence indirectement en :

  • Réduisant le working set pour éviter les évictions.
  • Améliorant la localité temporelle pour que les données utiles restent « récentes ».
  • Évitant les patterns pathologiques (strides en puissance de 2 sur de grandes structures).

Inclusive vs exclusive vs non-inclusive

La relation entre les niveaux de cache varie selon les architectures :

  • Inclusive : le L3 contient une copie de tout ce qui est dans L1 et L2. Avantage : la cohérence est simplifiée (il suffit de vérifier le L3 pour savoir si une donnée est dans le CPU). Inconvénient : le L3 « gaspille » de l'espace en dupliquant les données des caches supérieurs. Intel a historiquement privilégié cette approche, notamment jusqu'à Skylake.
  • Exclusive : une cache line ne peut être présente que dans un seul niveau à la fois. Le cache total effectif est la somme des trois niveaux. Plus efficace en capacité, mais la cohérence est plus complexe. AMD Zen utilisait cette approche pour le L3.
  • Non-inclusive (NINE) : un compromis — le L3 ne garantit ni l'inclusion ni l'exclusion. C'est l'approche des architectures Intel récentes (Skylake-X et au-delà) et des AMD Zen 3/4/5. En pratique, c'est le modèle dominant en 2026.

La distinction a un impact subtil mais réel : sur un cache inclusif, la capacité effective est celle du L3 seul (car L1/L2 sont dupliqués). Sur un cache exclusif, la capacité effective est L1 + L2 + L3. Pour les calculs de dimensionnement de working set, il faut connaître la politique de son architecture cible.


Interroger la hiérarchie cache de votre machine

Avec lscpu

$ lscpu | grep -i cache
L1d cache:                       32 KiB   (par cœur)  
L1i cache:                       32 KiB   (par cœur)  
L2 cache:                        1 MiB    (par cœur)  
L3 cache:                        32 MiB   (partagé)  

Avec le système de fichiers /sys

Le répertoire /sys/devices/system/cpu/cpu0/cache/ expose des informations détaillées pour chaque niveau :

# Lister les niveaux de cache du cœur 0
$ ls /sys/devices/system/cpu/cpu0/cache/
index0/  index1/  index2/  index3/

# Détails du L1d (index0 typiquement)
$ cat /sys/devices/system/cpu/cpu0/cache/index0/level
1
$ cat /sys/devices/system/cpu/cpu0/cache/index0/type
Data
$ cat /sys/devices/system/cpu/cpu0/cache/index0/size
32K
$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
64
$ cat /sys/devices/system/cpu/cpu0/cache/index0/ways_of_associativity
8
$ cat /sys/devices/system/cpu/cpu0/cache/index0/number_of_sets
64

Avec getconf (POSIX)

$ getconf LEVEL1_DCACHE_SIZE
32768
$ getconf LEVEL1_DCACHE_LINESIZE
64
$ getconf LEVEL2_CACHE_SIZE
1048576
$ getconf LEVEL3_CACHE_SIZE
33554432

Interrogation programmatique en C++

On peut aussi interroger la taille de cache line depuis le code, ce qui est utile pour du code portable qui doit s'adapter au matériel :

#include <new>        // std::hardware_destructive_interference_size (C++17)
#include <cstddef>
#include <iostream>

int main() {
    // C++17 : taille de cache line pour éviter le false sharing
    // Attention : constexpr, déterminé à la compilation, pas à l'exécution
    #ifdef __cpp_lib_hardware_interference_size
        std::cout << "Cache line (destructive): "
                  << std::hardware_destructive_interference_size << " octets\n";
        std::cout << "Cache line (constructive): "
                  << std::hardware_constructive_interference_size << " octets\n";
    #else
        // Fallback : 64 octets est la valeur correcte pour x86-64
        std::cout << "Cache line (fallback): 64 octets\n";
    #endif
}

⚠️ Note importante : std::hardware_destructive_interference_size est une constante constexpr déterminée à la compilation, pas à l'exécution. GCC et Clang sur x86-64 la définissent généralement à 64, ce qui est correct pour toutes les architectures x86 depuis le Pentium 4. Sur ARM, la valeur peut être 64 ou 128 selon la cible de compilation. Sur certains compilateurs et plateformes, cette constante n'est pas encore disponible — d'où le #ifdef de protection.


Mesurer les performances cache avec perf stat

perf stat permet de lire les compteurs matériels du processeur, y compris ceux liés aux caches. Voici les compteurs les plus utiles :

perf stat -e \
  L1-dcache-loads,L1-dcache-load-misses,\
  L1-icache-load-misses,\
  l2_rqsts.miss,\
  LLC-loads,LLC-load-misses,\
  dTLB-load-misses \
  ./mon_programme

Interprétation des résultats :

Compteur Signification Seuil d'alerte
L1-dcache-load-misses / L1-dcache-loads Taux de miss L1d > 5 % → investiguer
l2_rqsts.miss Misses L2 absolus Dépend du workload
LLC-load-misses / LLC-loads Taux de miss L3 (Last Level Cache) > 20 % → problème de working set
dTLB-load-misses Misses du TLB données > 1 % → considérer les huge pages

Exemple concret : dimensionnement et mesure

Considérons un programme qui effectue des accès aléatoires dans un tableau de taille variable :

#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <numeric>
#include <random>
#include <vector>

volatile std::uint64_t sink; // empêche l'optimisation

void random_access(const std::vector<int>& data,
                   const std::vector<std::size_t>& indices) {
    std::uint64_t sum = 0;
    for (auto idx : indices)
        sum += data[idx];
    sink = sum;
}

int main() {
    constexpr std::size_t NUM_ACCESSES = 10'000'000;
    // Modifier ARRAY_SIZE pour observer la transition entre niveaux de cache
    constexpr std::size_t ARRAY_SIZE = 1'000'000;  // ~4 MiB (int = 4 octets)

    std::vector<int> data(ARRAY_SIZE);
    std::iota(data.begin(), data.end(), 0);

    // Génération d'indices aléatoires
    std::mt19937 gen(42);
    std::uniform_int_distribution<std::size_t> dist(0, ARRAY_SIZE - 1);
    std::vector<std::size_t> indices(NUM_ACCESSES);
    std::generate(indices.begin(), indices.end(), [&]{ return dist(gen); });

    random_access(data, indices);
}

En variant ARRAY_SIZE et en mesurant avec perf stat, on observe les transitions entre niveaux de cache :

ARRAY_SIZE Taille mémoire Tient dans… Latence par accès
4 096 16 KiB L1d (32 KiB) ~1,5 ns
8 192 32 KiB L1d (limite) ~2 ns
65 536 256 KiB L2 (1 MiB) ~5 ns
262 144 1 MiB L2 (limite) ~8 ns
4 000 000 16 MiB L3 (32 MiB) ~18 ns
16 000 000 64 MiB Dépasse L3 ~65 ns

Les sauts de latence entre les plateaux correspondent exactement aux transitions L1→L2, L2→L3 et L3→RAM. Ce type de micro-benchmark est un outil de diagnostic puissant pour comprendre le comportement cache d'un programme réel.


Dimensionner ses structures en fonction du cache

Les informations ci-dessus mènent à des règles pratiques de dimensionnement pour le code C++ :

Boucles internes (hot loops)

Le working set de la boucle la plus interne devrait tenir dans le L1d (32–48 KiB). C'est l'objectif idéal. Cela concerne les accumulateurs, les compteurs, les petits buffers de travail, les variables scalaires fréquemment accédées.

Buffers de traitement

Les buffers intermédiaires — blocs de données traités par lot, fenêtres glissantes, caches applicatifs — devraient être dimensionnés pour tenir dans le L2 (256 KiB – 2 MiB selon l'architecture). C'est la cible naturelle pour les techniques de tiling.

Structures de données « tièdes »

Les tables de hachage, les index, les structures de données persistantes consultées régulièrement mais pas en boucle serrée, gagnent à tenir dans le L3 (16–96 MiB). Au-delà, chaque accès risque de toucher la RAM.

Au-delà du L3

Pour les datasets qui dépassent le L3 (grandes bases de données en mémoire, graphes volumineux), les optimisations de cache restent importantes mais se concentrent sur la localité des patterns d'accès plutôt que sur le dimensionnement pur. Les techniques de prefetching logiciel (__builtin_prefetch) et de huge pages (2 MiB ou 1 GiB) deviennent alors pertinentes pour réduire la pression sur le TLB.


Résumé

Niveau Taille typique Latence Portée Rôle pour le développeur
L1d 32–48 KiB ~1 ns Privé/cœur Hot loops, variables critiques
L1i 32–64 KiB ~1 ns Privé/cœur Code des boucles internes
L2 256 KiB – 2 MiB ~5 ns Privé/cœur Buffers de travail, tiling
L3 16–96 MiB ~15 ns Partagé Structures tièdes, index
RAM 16–512 GiB ~70 ns Globale Tout le reste

La section suivante (41.1.2) explore l'unité fondamentale de transfert entre ces niveaux — la cache line de 64 octets — et le problème de false sharing qui survient lorsque plusieurs threads accèdent à la même cache line.


⏭️ Cache lines et false sharing