🔝 Retour au Sommaire
Chapitre 41 : Optimisation CPU et Mémoire · Section 41.1 · Sous-section 1
Niveau : Expert · Prérequis : Section 41.1 (introduction au cache CPU)
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.
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.
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.
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 KiBLa 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 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_programmeEn 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.
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.
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.
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 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.
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.
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.
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.
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).
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.
$ 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é) 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$ getconf LEVEL1_DCACHE_SIZE
32768
$ getconf LEVEL1_DCACHE_LINESIZE
64
$ getconf LEVEL2_CACHE_SIZE
1048576
$ getconf LEVEL3_CACHE_SIZE
33554432On 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_sizeest une constanteconstexprdé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#ifdefde protection.
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_programmeInterpré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 |
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.
Les informations ci-dessus mènent à des règles pratiques de dimensionnement pour le code C++ :
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.
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.
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.
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.
| 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.