Ce cours porte sur l'assembleur, le SIMD et l'écriture d'un petit interpréteur — trois façons différentes d'exploiter le matériel. On commence par la mémoire, parce que c'est le problème de performance n°1 dans un moteur, et parce que tout le reste (ASM, SIMD, VM à registres) n'a de sens qu'une fois qu'on a compris pourquoi. Code de démonstration : code/01_memoire. Des exercices accompagnent ce chapitre (distribués séparément en cours). Les mesures citées ont été faites sur un Ryzen 9 8940HX (Zen 4), avec g++ 13 en
-O2; sous MSVC/O2, les ordres de grandeur sont les mêmes.
À la fin, vous devez pouvoir expliquer :
struct est disposée en mémoire :
alignement, padding,
alignof, alignas, allocation alignée.Gardez une phrase en tête tout au long de ce cours, elle en est le fil rouge : le matériel a des préférences, et le code rapide est celui qui les respecte. La mémoire, l'ASM, le SIMD puis l'interpréteur ne sont que quatre angles différents sur cette même idée.
En 1945, John von Neumann rédige le First Draft of a Report on the EDVAC. Il y décrit une machine où le programme et les données partagent la même mémoire : c'est le stored-program computer. Presque tous les ordinateurs actuels en descendent :
+-------------------------------+
| CPU |
| +-------------+ +---------+ |
| | Unité de | | ALU | | ALU = Arithmetic Logic Unit
| | contrôle | | (calcul)| |
| +-------------+ +---------+ |
| [ registres ] |
+---------------+---------------+
| bus <== le "von Neumann bottleneck"
+---------------+---------------+
| Mémoire (code + données) |
+-------------------------------+
En 1977, dans sa conférence du prix Turing, John Backus (le créateur de FORTRAN) nomme le goulot d'étranglement de cette architecture :
« Surely there must be a less primitive way of making big changes in the store than by pushing vast numbers of words back and forth through the von Neumann bottleneck. » — John Backus, Can Programming Be Liberated from the von Neumann Style?, 1977
On pourrait croire le problème réglé depuis. C'est l'inverse : il s'est aggravé.
À partir des années 80, la vitesse des CPU progresse bien plus vite que celle de la DRAM. En 1995, deux chercheurs, Wulf et McKee, formalisent le problème dans une courte note restée célèbre :
« The difference between diverging exponentials also grows exponentially. » — Wm. A. Wulf & Sally A. McKee, Hitting the Memory Wall: Implications of the Obvious, ACM SIGARCH Computer Architecture News, 1995
Leur argument tient en une observation simple mais dérangeante : à l'époque, la vitesse des microprocesseurs progressait d'environ 55 à 60 % par an, quand la latence d'accès à la DRAM ne s'améliorait que de 7 à 9 % par an. Deux courbes exponentielles qui n'avancent pas au même rythme finissent, mathématiquement, par diverger de plus en plus vite. Les auteurs projetaient qu'un accès mémoire, qui coûtait environ 1,5 cycle CPU en 2000, pourrait en coûter près de 99 en 2010 si rien ne changeait — un ordinateur qui passerait l'essentiel de son temps à attendre la RAM plutôt qu'à calculer. Dans les faits, ce scénario ne s'est pas réalisé aussi brutalement : des caches plus grands, un prefetching plus intelligent et l'exécution out-of-order ont largement amorti le choc. Mais l'alerte a eu l'effet recherché — c'est très exactement ce qui a poussé toute l'industrie à concevoir du matériel et des logiciels autour du cache plutôt qu'en l'ignorant.
Un CPU moderne exécute une instruction simple en moins d'une nanoseconde. Aller chercher une donnée en RAM prend environ 100 ns. Sans cache, le CPU passerait l'essentiel de son temps à attendre.
Maurice Wilkes (déjà père de l'EDSAC) décrit en 1965 une petite mémoire rapide placée devant la mémoire principale. Il l'appelle slave memory (M. V. Wilkes, Slave Memories and Dynamic Storage Allocation, IEEE Trans. Electronic Computers, 1965). Le premier cache commercial arrive en 1968 sur l'IBM System/360 Model 85. Avec l'Intel 80486 (1989), le cache L1 entre dans la puce du CPU.
Jusqu'au milieu des années 2000, il suffisait d'attendre la génération de CPU suivante pour qu'un programme aille plus vite. Vers 2005, le Dennard scaling s'arrête : réduire la taille des transistors ne permet plus d'augmenter la fréquence sans faire fondre la puce. Les fabricants se tournent alors vers :
« The free lunch is over. » — Herb Sutter, The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software, Dr. Dobb's Journal, 2005
Les consoles ont toujours été des machines contraintes. L'Atari 2600 avait 128 octets de RAM, la NES 2 Ko, la PS2 32 Mo. La PS3 imposait en plus son processeur Cell, dont les cœurs SPU travaillaient sur une mémoire locale de 256 Ko : il fallait penser en flux de données pour en tirer quelque chose.
C'est de cette contrainte permanente qu'est né le Data-Oriented Design (DOD), une manière de penser les données avant de penser les objets :
« The purpose of all programs, and all parts of those programs, is to transform data from one form to another. » — Mike Acton, Data-Oriented Design and C++, CppCon 2014
Le reste de la conférence est une attaque en règle contre trois habitudes qu'il qualifie de « mensonges » que la programmation orientée objet nous a fait intérioriser :
Animal → Chien → Labrador) semble naturel, mais ce n'est
pas ce que le CPU exécute : il transforme des octets, pas des
concepts.Les chiffres ci-dessous sont des ordres de grandeur. Ils sont inspirés de Latency Numbers Every Programmer Should Know (Jeff Dean, Peter Norvig, mis à jour par Colin Scott). La dernière colonne ramène tout à l'échelle humaine : 1 ns devient 1 seconde.
| Niveau | Taille typique | Latence | « 1 ns = 1 s » |
|---|---|---|---|
| Registre | 16 GPR x 64 bits + 16/32 SIMD | ~0 (dans le cœur) | instantané |
| Cache L1d | 32 à 48 Ko par cœur (+ L1i séparé, en général 32 Ko) | ~1 ns | 1 s |
| Cache L2 | 0,5 à 2 Mo par cœur | ~4 ns | 4 s |
| Cache L3 | 16 à 128 Mo (jusqu'à ~192 Mo sur les puces « 3D V-Cache » les plus extrêmes), partagé — souvent par groupe de cœurs (un CCD/CCX chez AMD) plutôt que par tout le CPU, voir plus bas | ~10-20 ns | 15 s |
| RAM (DDR5) | 16 à 64 Go | ~80-100 ns | 1 min 40 |
| SSD NVMe (lecture random) | To | ~20-100 µs | 1 jour |
| HDD (seek) | To | ~5-10 ms | 4 mois |
| Paquet réseau Europe → US | - | ~80-150 ms | 3 à 5 ans |
/\
/ \ registres <- le CPU calcule ICI
/----\
/ L1 \ ~1 ns
/--------\
/ L2 \ ~4 ns
/------------\
/ L3 \ ~15 ns (partagé entre cœurs)
/----------------\
/ RAM \ ~100 ns
/--------------------\
/ SSD / disque \ µs .. ms
/------------------------\
plus petit, plus rapide, plus cher <-----> plus grand, plus lent, moins cher
Un exemple réel, mesuré sur un Ryzen 9 8940HX (Zen 4, 16 cœurs) :
$ lscpu -C
NAME ONE-SIZE ALL-SIZE WAYS TYPE LEVEL SETS PHY-LINE COHERENCY-SIZE
L1d 32K 512K 8 Data 1 64 1 64
L1i 32K 512K 8 Instruction 1 64 1 64
L2 1M 16M 8 Unified 2 2048 1 64
L3 32M 64M 16 Unified 3 32768 1 64
Comment lire ce tableau lscpu :
ONE-SIZE est la taille par cœur,
ALL-SIZE la somme sur toute la puce (16 cœurs ici). L1d et
L1i : 32K × 16 = 512K, cohérent avec « 32 à 48 Ko par cœur ». L2 : 1M ×
16 = 16M, cohérent avec « par cœur » aussi. L3 est le cas
piège : ONE-SIZE vaut 32M mais
ALL-SIZE ne vaut que 64M, pas 32 × 16 = 512M. Cette puce
(deux CCD Zen 4, 8 cœurs chacun) a en réalité deux pools de L3
de 32 Mo, un par CCD — pas un seul pool de 64 Mo partagé par
les 16 cœurs. Un thread sur le CCD A ne voit pas dans
son L3 ce qu'un thread du CCD B y a mis : c'est un effet proche d'un
mini-NUMA, à garder en tête pour le placement des threads d'un moteur
(cours multithreading), et une bonne raison de ne jamais supposer qu'un
CPU « à 64 Mo de L3 » offre un seul cache uniforme de cette taille.
Sous Windows : Gestionnaire des tâches > Performance > Processeur affiche L1, L2 et L3. Pour le détail par cœur, utiliser Coreinfo (Sysinternals) ou CPU-Z. Regardez les chiffres de votre propre machine avant le prochain cours.
32 Ko de L1d, c'est ridicule — à peine la taille d'une texture 90×90 en RGBA. Tout le reste de la frame vit plus loin, plus lentement. Le jeu (au sens propre) consiste à faire en sorte que ce qui sert maintenant tienne dans ces 32 Ko.
Référence incontournable, un peu datée mais toujours juste : Ulrich Drepper, What Every Programmer Should Know About Memory, 2007 (PDF).
Un registre est une petite case mémoire à l'intérieur du cœur du CPU, faite de bascules (flip-flops). C'est la seule mémoire sur laquelle l'ALU calcule directement. Additionner deux nombres en mémoire se fait donc en trois temps :
Sur x86-64 on dispose de :
rax, rbx,
rcx, rdx, rsi, rdi,
rbp, rsp, r8 à r15.
Intel APX (Advanced Performance Extensions),
annoncé en 2023 et déjà supporté par MSVC (/feature:APX),
en ajoute 16 autres ;rip (instruction pointer) et
rflags ;xmm (128
bits, SSE), ymm (256 bits, AVX), puis 32 zmm
(512 bits, AVX-512).Tout cela est détaillé dans la partie 2.
Pourquoi c'est important pour le projet : une VM « register-based » comme Lua 5 imite ce modèle. Les instructions du bytecode disent « prends le registre 1 et le registre 2, mets la somme dans le registre 0 ». Une VM « stack-based » (JVM, CPython, Lua 4) empile et dépile à la place. La différence de performance vient de la mémoire : moins de copies et moins d'instructions à décoder (voir partie 4).
Une distinction utile à connaître : les registres
architecturaux (rax...) sont une abstraction. Un
cœur moderne en possède en réalité des centaines de physiques
grâce au register renaming, au service de l'exécution
out-of-order. Ça n'a pas d'impact direct sur votre code, mais
ça explique pourquoi écrire xor eax, eax est si efficace :
le CPU le reconnaît comme une zeroing idiom et casse la
dépendance sur l'ancienne valeur du registre.
Le cache ne stocke pas des octets isolés mais des cache lines (ou cache blocks) de 64 octets sur toutes les machines x86 et ARM courantes. Accéder à 1 octet charge toute la ligne de 64 octets qui le contient.
Une adresse est découpée ainsi :
adresse (virtuelle ou physique)
+-------------------------------+-----------+----------+
| tag | index | offset |
+-------------------------------+-----------+----------+
(quel set) (6 bits = 64 octets)
Quand un set est plein, une ligne est évincée (en gros LRU, Least Recently Used : on évince la ligne la moins récemment utilisée).
Le CPU observe les accès mémoire. Quand il détecte un motif régulier
(stride : +8, +8, +8...), il charge les lignes suivantes
avant qu'on les demande. D'où la règle d'or : accès
séquentiels = prefetcher content. À l'inverse, suivre des
pointeurs éparpillés (liste chaînée, arbre, graphe d'objets
new) le rend aveugle.
Les adresses manipulées par le programme sont virtuelles. L'OS et la MMU (Memory Management Unit, l'unité de traduction d'adresses intégrée au CPU) les traduisent en adresses physiques par pages (4 Ko par défaut, 2 Mo ou 1 Go en large pages). La traduction est mise en cache dans le TLB (Translation Lookaside Buffer), qui ne compte que quelques centaines à quelques milliers d'entrées. Un TLB miss coûte un page walk, soit plusieurs accès mémoire.
VirtualAlloc(..., MEM_LARGE_PAGES, ...)
exige le privilège SeLockMemoryPrivilege.const size_t N = 4096;
std::vector<int32_t> grid(N * N, 1);
// row-major : accès séquentiel
for (size_t y = 0; y < N; ++y)
for (size_t x = 0; x < N; ++x)
sum += grid[y * N + x];
// column-major : saute de N * 4 octets = 16 Ko à chaque accès
for (size_t x = 0; x < N; ++x)
for (size_t y = 0; y < N; ++y)
sum += grid[y * N + x];grid row-major (y puis x) 1.815 ms
grid column-major (x puis y) 70.636 ms
Facteur ~40. Le calcul et la complexité algorithmique sont identiques. Attention, une partie de l'écart vient de l'auto-vectorisation : la version row-major est vectorisée, l'autre non. C'est un bon teaser pour la partie 3.
std::vector vs std::liststd::vector sum 1.084 ms
std::list sum 473.850 ms
La liste a été « fragmentée » en mélangeant l'ordre de chaînage des nœuds, ce qui simule un heap après des heures de jeu. Facteur ~400. Chaque nœud coûte un cache miss probable (~100 ns), et le prefetcher ne peut rien anticiper.
Attention à un piège classique de mesure : si on
construit la liste d'un coup avec push_back sans rien
fragmenter, l'allocateur place souvent les nœuds côte à côte en mémoire,
et l'écart tombe à ~6x seulement — on pourrait en conclure à tort que «
la liste, ça va ». C'est la version fragmentée qui reflète la réalité
d'un std::list qui vit longtemps dans un moteur
(insertions/suppressions répétées). Voir Bjarne Stroustrup, Why you
should avoid Linked Lists (GoingNative 2012), ou CPU Caches and
Why You Care de Scott Meyers (code::dive 2014).
struct ParticleAoS { // 80 octets : ~1 particule par cache line
float px, py, pz, vx, vy, vz;
float color[4];
float life, size;
char name[32]; // données froides
};
struct ParticlesSoA { // un tableau par champ
std::vector<float> px, py, pz, vx, vy, vz;
};sizeof(ParticleAoS) = 80 octets
AoS update position 12.734 ms
SoA update position 4.850 ms
La mise à jour des positions ne lit que 24 octets sur 80. En AoS, on
charge quand même les 56 autres dans le cache. En SoA, chaque boucle
parcourt un tableau contigu de float, ce qui est idéal pour
le cache et pour le SIMD.
Les ECS (Entity Component System), comme EnTT, flecs ou Unity DOTS, sont une industrialisation de l'idée SoA.
Chaque démonstration ci-dessus illustre une technique. En résumé, du plus simple au plus spécialisé :
Accès séquentiel (démo 1) : parcourir la mémoire dans l'ordre où elle est rangée exploite le prefetcher et la localité spatiale. C'est le levier le plus rentable, et il est gratuit.
Structures compactes et contiguës (démo 2) :
préférer std::vector à une structure chaînée
(std::list, arbre, graphe d'objets alloués séparément un
par un) dès que c'est possible.
SoA (démo 3) : ne charger que les champs réellement lus par la boucle chaude, pas toute la struct.
Réduire le working set par blocs (loop tiling / cache blocking) : traiter les données par petits paquets qui tiennent en L1/L2 avant de passer au paquet suivant. Inutile sur un parcours déjà séquentiel (démo 1), mais indispensable pour des accès 2D « voisins » (convolution, flou, jeu de la vie, simulation de fluide).
Prefetch logiciel : donner un indice explicite au CPU quand le prochain accès est connu à l'avance mais non séquentiel (parcours de graphe de scène, de BVH, de liste triée par distance) :
#include <xmmintrin.h>
_mm_prefetch(reinterpret_cast<const char*>(next_node), _MM_HINT_T0); // MSVC et GCC/Clang
// équivalent portable : __builtin_prefetch(next_node) sous GCC/Clang_MM_HINT_T0 charge dans tous les niveaux de cache
(jusqu'à L1) ; _MM_HINT_T1/_MM_HINT_T2 visent
L2/L3 seulement, pour une donnée qui ne servira que plus tard.
C'est un outil de dernier recours : le prefetcher
matériel couvre déjà l'immense majorité des cas (tout accès à
stride régulier), et un mauvais indice gaspille de la bande
passante sans bénéfice. À réserver aux parcours véritablement
irréguliers.
Mesurer plutôt que deviner :
perf stat -e cache-misses,cache-references (Linux),
Memory Access analysis (VTune) — voir plus bas.
Deux threads écrivent dans deux variables différentes situées sur la même cache line. Chaque écriture invalide la ligne dans le cache de l'autre cœur, et les performances s'effondrent :
#include <new>
struct alignas(std::hardware_destructive_interference_size) PerThreadCounter
{
std::atomic<uint64_t> value;
};std::hardware_destructive_interference_size vaut 64 sur
x86-64 (MSVC, GCC et Clang).
Est-ce un problème de correction (data race), et un mutex
protège-t-il contre ça ? Non, sur les deux points. C'est
uniquement un problème de performance. Deux threads qui
écrivent chacun dans sa propre variable ne se marchent jamais
dessus au sens de la mémoire (pas de data race, pas de
comportement indéfini) : le résultat du calcul est correct, avec ou sans
false sharing. Le protocole de cohérence de cache (MESI/MOESI) raisonne
à la granularité de la ligne entière, pas de la
variable individuelle : quand le cœur A écrit dans
counters[0].value, le protocole invalide la copie de
toute la ligne de 64 octets dans le cache du cœur B,
même si B ne touche que counters[1].value sur cette même
ligne. B doit alors relire la ligne (depuis un cache plus lent, ou via
le bus inter-cœurs) avant de pouvoir écrire à son tour — et inversement
au prochain tour. La ligne « ping-pong » entre les cœurs à chaque
écriture, purement à cause du placement physique,
indépendamment de toute synchronisation logicielle.
Un std::mutex ne règle donc rien ici : il protège une
variable effectivement partagée contre un accès concurrent
incorrect, mais dans le false sharing, chaque thread a déjà sa
propre variable — il n'y a rien d'incorrect à protéger.
Ajouter un verrou par variable ne ferait qu'empiler le coût du lock
par-dessus le ping-pong de cache line, sans le
supprimer (et le verrou lui-même, quelque part en mémoire, peut être
victime du même phénomène s'il est mal placé). La seule vraie solution
est le placement mémoire : séparer physiquement les
données sur des lignes de cache différentes (alignas
ci-dessus), typiquement en regroupant les données par
thread plutôt que par « type logique ».
perf stat -e cache-misses,cache-references,
valgrind --tool=cachegrind.alignof(T)
(une puissance de 2) : ses adresses doivent être multiples de cette
valeur. Sur x86-64 : char 1, short 2,
int et float 4, double,
int64_t et pointeur 8, __m128 16,
__m256 32.struct sont placés dans
l'ordre de déclaration, chacun à l'offset multiple de son
alignement suivant. Le compilateur n'a pas le droit de les
réordonner.alignof(struct) est le plus grand alignement de ses
membres.sizeof(struct) est arrondi au multiple de
alignof(struct), avec du padding de fin,
pour que les tableaux fonctionnent.struct Bad { bool active; double x; bool visible; int32_t id; bool dirty; };
struct Good { double x; int32_t id; bool active; bool visible; bool dirty; };Bad sizeof=32 alignof= 8 Good sizeof=16 alignof= 8
offsetof(Bad, active) = 0 offsetof(Good, x) = 0
offsetof(Bad, x) = 8 offsetof(Good, id) = 8
offsetof(Bad, visible) = 16 offsetof(Good, active) = 12
offsetof(Bad, id) = 20 offsetof(Good, visible) = 13
offsetof(Bad, dirty) = 24 offsetof(Good, dirty) = 14
Bad (32 octets) a=active x=x v=visible i=id d=dirty .=padding
octet : 0 8 16 24 32
a.......xxxxxxxxv...iiiid.......
Good (16 octets)
octet : 0 8 16
xxxxxxxxiiiiavd.
Même contenu, deux fois plus petit : deux fois plus d'éléments par cache line.
sizeof et alignof dans le Quick Info./d1reportSingleClassLayoutBad :class Bad size(32):
+---
0 | active
| <alignment member> (size=7)
8 | x
16 | visible
| <alignment member> (size=3)
20 | id
24 | dirty
| <alignment member> (size=7)
+---
Un bloc est « aligné sur N octets » quand son adresse de départ est un multiple de N. Sur 64 octets consécutifs (une cache line), voici où tombent les multiples de 16, 32 et 64 — et ce qui se passe quand une donnée ne tombe pas dessus :
adresse : 0 16 32 48 64
|---------|---------|---------|---------|
^ ^ ^ ^ ^
mult.16/32/64 mult.16 mult.16/32 mult.16 mult.16/32/64
(= 1 cache line entière)
__m128 (16 octets) ALIGNÉ, adresse 16 :
.........[XXXXXXXX]..............
16 32
-> tient entièrement dans la ligne [0,64). movaps fonctionne.
__m128 (16 octets) MAL ALIGNÉ, adresse 20 (décalé de 4) :
............[XXXXXXXX]...........
20 36
-> ne commence pas à un multiple de 16 : movaps/_mm_load_ps LÈVE UNE EXCEPTION (#GP).
movups/_mm_loadu_ps fonctionne, mais reste décalé.
__m256 (32 octets) ALIGNÉ, adresse 32 :
....................[XXXXXXXXXXXXXXXX]
32 64
-> tient exactement dans la moitié haute de la ligne.
donnée de 16 octets à l'adresse 60 (fin de ligne) :
..........................[XXXX|XXXX] ...[XXXX]...
60 64(fin ligne 1) 64+12 (début ligne 2)
-> CHEVAUCHE deux cache lines : deux lectures au lieu d'une, même avec loadu.
Deux conséquences concrètes :
movaps, _mm_load_ps, et plus généralement
tout accès SSE « aligned ») : le CPU vérifie l'adresse au niveau
matériel et déclenche une exception (general protection
fault, #GP) si elle n'est pas multiple de 16.
movups/_mm_loadu_ps n'ont pas cette
contrainte, mais ne suppriment pas le coût d'un chevauchement de
ligne.int à une adresse impaire
provoque une bus error. x86 tolère, ARM64 aussi pour les
données scalaires._mm_load_ps /
movaps crashent (general protection
fault) si l'adresse n'est pas alignée sur 16.
_mm_loadu_ps / movups acceptent tout. Sur les
CPU récents, ils sont aussi rapides quand la donnée est alignée
(partie 3).struct alignas(16) Vec4 { float x, y, z, w; }; // imposé
#pragma pack(push, 1) // supprime le padding
struct FileHeader { char magic[4]; uint32_t version; uint16_t flags; };
#pragma pack(pop)#pragma pack sert à coller à un format de
fichier ou réseau : header de .wav (cours audio),
bytecode sérialisé. À éviter pour les données de calcul.
| Méthode | MSVC | Remarque |
|---|---|---|
new T[n] avec alignof(T) > 16 |
oui (C++17) | l'operator new aligné est appelé automatiquement |
::operator new(size, std::align_val_t{64}) |
oui | libérer avec
::operator delete(p, std::align_val_t{64}) |
_aligned_malloc(size, align) /
_aligned_free |
oui | spécifique MSVC |
_mm_malloc / _mm_free |
oui | fourni avec les intrinsics |
std::aligned_alloc (C++17) |
NON | incompatible avec le free de la CRT
(C RunTime, la bibliothèque C standard liée par défaut)
Windows |
Piège très fréquent :
std::aligned_alloc apparaît partout sur cppreference, mais
ne compile pas sous MSVC. La bonne réponse :
_aligned_malloc, ou mieux, operator new avec
std::align_val_t, ou un std::vector avec un
allocateur aligné.
Qu'est-ce que l'aliasing, en programmation ? Le mot est le même qu'en traitement du signal (repliement de spectre), mais le sens n'a rien à voir. Ici, l'aliasing désigne simplement le fait que deux expressions — pointeurs, références, vues — peuvent désigner la même zone mémoire :
void add(const int* a, const int* b, int* out) { *out = *a + *b; }
add(&x, &y, &x); // out ALIASE a : la fonction écrit dans la variable qu'elle vient de lireTant que le compilateur ne peut pas prouver que deux
pointeurs ne se recouvrent jamais, il doit rester prudent : il ne peut
pas charger *a une fois en registre et le réutiliser après
avoir écrit *out, il doit relire à chaque usage — par
précaution, au cas où l'écriture aurait modifié la valeur. C'est un
manque à gagner pur pour l'optimisation, et en particulier pour la
vectorisation (voir 3.2 : sans __restrict,
MSVC génère un test de chevauchement à l'exécution avant de choisir la
version vectorisée). D'où le mot-clé __restrict (MSVC) /
__restrict__ (GCC/Clang) : une promesse du
programmeur au compilateur — « ces pointeurs ne se recouvrent
jamais » — à ne faire que si c'est vrai, sous peine de comportement
indéfini.
Un cas particulier et plus sournois : lire un float
comme un uint32_t via un cast de pointeur est un
comportement indéfini (strict aliasing rule :
le compilateur suppose que deux pointeurs de types différents et non
apparentés ne peuvent pas aliaser, et optimise sur cette base — même si
à l'exécution c'est faux). Les méthodes correctes :
float f = 1.0f;
uint32_t bits = std::bit_cast<uint32_t>(f); // C++20, <bit>
uint32_t bits2; std::memcpy(&bits2, &f, 4); // C++ classique : le compilateur optimise en un simple movC'est indispensable pour le projet : encoder et décoder des instructions, faire du NaN-boxing, lire du bytecode binaire.
x86 et ARM (en pratique) sont little-endian :
l'octet de poids faible est stocké en premier. 0x11223344
s'écrit 44 33 22 11 en mémoire. En C++20 :
std::endian::native, et std::byteswap en
C++23. Ça compte dès qu'on écrit un fichier de bytecode
ou qu'on lit un dump mémoire dans Visual Studio.
Un CPU moderne est un pipeline : fetch → decode
→ rename → schedule → execute → retire, avec des dizaines d'étages
et une exécution out-of-order. Pour ne pas attendre le résultat
d'un if, le CPU prédit la branche et
exécute de manière spéculative. En cas d'erreur (branch
misprediction), il jette le travail en cours : ~15 à 20
cycles perdus.
La question Stack Overflow la plus célèbre : Why is
processing a sorted array faster than processing an unsorted
array? (2012). Même boucle, même données, mais une fois triées,
le if (data[i] >= 128) devient prévisible.
Ça compte pour la suite du cours :
switch(opcode) est une branche indirecte très
difficile à prédire. C'est le cœur des optimisations de
dispatch (partie 4).| Notion | Où elle réapparaît dans l'interpréteur |
|---|---|
| Hiérarchie mémoire | le bytecode et les registres de la VM doivent tenir dans L1/L2 |
| Registres | VM register-based (Lua 5) vs stack-based |
| Alignement / padding | taille de la Value (tagged union 16 octets), registres
vec4 alignés 16 |
| AoS / SoA | exécuter un script sur N entités (batch mode) |
Endianness / bit_cast |
encodage des instructions sur 32 bits, fichier de bytecode |
| Prédiction de branchement | dispatch du switch, instructions
branchless |
do_not_optimize ou affichez le résultat. Et toujours
mesurer en Release (en Debug MSVC, avec
_ITERATOR_DEBUG_LEVEL, les conteneurs STL sont 10 à 100x
plus lents).sizeof d'une struct vide vaut 1 ? »
Oui : deux objets distincts doivent avoir des adresses distinctes. Voir
[[no_unique_address]] (MSVC :
[[msvc::no_unique_address]]).movaps crashe), des perfs du
cache et de la portabilité ARM.vptr
ajoute 8 octets en tête d'objet, et chaque appel virtuel est une
indirection + une branche indirecte (lien avec le dispatch de
l'interpréteur, partie 4).Articles et papiers
Conférences (vidéos)
Livres
Outils