🔝 Retour au Sommaire
Chapitre 41 : Optimisation CPU et Mémoire · Section 41.3 · Sous-section 1
Niveau : Expert · Prérequis : Section 41.3 (SIMD et vectorisation), Chapitre 3 (types primitifs), Chapitre 42 (notions de bas niveau)
La section 41.3 a présenté le SIMD comme un levier de performance majeur et recommandé de laisser le compilateur auto-vectoriser en priorité. Mais il existe des cas où l'auto-vectorisation échoue ou produit un code sous-optimal : patterns d'accès complexes, opérations de réarrangement (shuffle), algorithmes spécialisés (cryptographie, codage vidéo, traitement du signal), ou simplement besoin de garantir un code assembleur précis dans un hotspot critique.
Dans ces cas, les intrinsics sont l'outil de choix. Ce sont des fonctions C/C++ fournies par le compilateur, qui correspondent un-pour-un à des instructions assembleur SIMD. Elles offrent le contrôle total du code machine généré, tout en restant dans le cadre du langage C++ — avec typage, gestion des registres par le compilateur, et intégration naturelle avec le reste du code.
Cette sous-section couvre les intrinsics Intel (SSE/AVX/AVX-512), qui constituent l'écosystème dominant en 2026. Les principes s'appliquent directement aux intrinsics ARM NEON (vaddq_f32, etc.) en changeant la nomenclature.
⚠️ Rappel : les intrinsics sont un outil de dernier recours pour les boucles critiques où l'auto-vectorisation (section 41.3.2) échoue. Pour la grande majorité du code, laisser le compilateur vectoriser est plus maintenable et souvent aussi performant.
Tous les intrinsics Intel sont déclarés dans un header unique qui inclut l'ensemble des générations :
#include <immintrin.h> // Tout : SSE, SSE2, SSE3, SSSE3, SSE4, AVX, AVX2, AVX-512, ...Des headers plus ciblés existent, mais <immintrin.h> est le standard en pratique :
| Header | Contenu |
|---|---|
<xmmintrin.h> |
SSE (float 128-bit) |
<emmintrin.h> |
SSE2 (double, int 128-bit) |
<smmintrin.h> |
SSE4.1 |
<immintrin.h> |
Tout (recommandé) |
Le compilateur n'active les intrinsics que si le jeu d'instructions correspondant est ciblé :
# Activer AVX2 (nécessaire pour _mm256_* sur les entiers)
g++ -O2 -mavx2 -mfma -c mon_fichier.cpp
# Ou plus simplement : tout activer selon le CPU hôte
g++ -O2 -march=native -c mon_fichier.cppSi vous utilisez des intrinsics AVX2 sans -mavx2, le compilateur émet une erreur claire :
error: always_inline function '_mm256_add_ps' requires target feature 'avx2',
but would be inlined into function 'process' that is compiled without support
for 'avx2'
On peut restreindre l'activation à une seule fonction avec l'attribut target — utile pour le runtime dispatch :
__attribute__((target("avx2,fma")))
void process_avx2(float* data, std::size_t n) {
// Les intrinsics AVX2 et FMA sont disponibles ici
// Le reste du fichier reste compilé avec le jeu d'instructions par défaut
}Les intrinsics manipulent des types opaques représentant des registres SIMD :
| Type | Contenu | Éléments |
|---|---|---|
__m128 |
4 × float |
4 |
__m128d |
2 × double |
2 |
__m128i |
Entiers (int8 → int64, au choix) | 16, 8, 4, ou 2 |
| Type | Contenu | Éléments |
|---|---|---|
__m256 |
8 × float |
8 |
__m256d |
4 × double |
4 |
__m256i |
Entiers (int8 → int64, au choix) | 32, 16, 8, ou 4 |
| Type | Contenu | Éléments |
|---|---|---|
__m512 |
16 × float |
16 |
__m512d |
8 × double |
8 |
__m512i |
Entiers (int8 → int64, au choix) | 64, 32, 16, ou 8 |
Le type __m128i / __m256i / __m512i est non typé pour les entiers : le même registre peut être interprété comme un vecteur de int8, int16, int32 ou int64 selon l'intrinsic utilisé. C'est le nom de l'intrinsic (et non le type C++) qui détermine l'interprétation.
La convention de nommage suit un schéma régulier, essentiel à maîtriser pour naviguer dans la documentation :
_mm[largeur]_[opération]_[type]
| Composant | Signification | Exemples |
|---|---|---|
_mm |
Préfixe commun | Toujours présent |
[largeur] |
Taille du registre | (vide) = 128-bit, 256 = 256-bit, 512 = 512-bit |
[opération] |
L'opération effectuée | add, mul, load, store, set, cmp, shuffle, ... |
[type] |
Type des éléments | ps = packed single (float), pd = packed double, epi32 = packed int32, si256 = raw 256-bit int, ... |
| Suffixe | Signification | Exemple d'intrinsic |
|---|---|---|
ps |
Packed Single (float) | _mm256_add_ps — 8 additions float |
pd |
Packed Double (double) | _mm256_add_pd — 4 additions double |
ss |
Scalar Single (1 float) | _mm_add_ss — 1 addition float (bas du registre) |
sd |
Scalar Double (1 double) | _mm_add_sd — 1 addition double |
epi8 |
Packed signed int8 | _mm256_add_epi8 — 32 additions int8 |
epi16 |
Packed signed int16 | _mm256_add_epi16 — 16 additions int16 |
epi32 |
Packed signed int32 | _mm256_add_epi32 — 8 additions int32 |
epi64 |
Packed signed int64 | _mm256_add_epi64 — 4 additions int64 |
epu8 |
Packed unsigned int8 | _mm256_adds_epu8 — 32 additions saturées uint8 |
si128 / si256 |
Raw integer register | _mm256_and_si256 — AND bit-à-bit sur 256 bits |
_mm256_mul_ps
│ │ │ └─ ps = packed single float
│ │ └──── mul = multiplication
│ └───────── 256 = registre 256-bit (AVX)
└───────────── _mm = préfixe Intel
→ Multiplie 8 floats (256 bits / 32 bits par float)
_mm_cmpeq_epi32
│ │ │
│ │ └─ epi32 = packed signed int32
│ └─────── cmpeq = comparaison d'égalité
└────────── _mm (pas de chiffre) = 128-bit (SSE)
→ Compare 4 int32 pour égalité, retourne un masque 128-bit
Transférer des données de la mémoire vers un registre SIMD :
float data[8] = {1, 2, 3, 4, 5, 6, 7, 8};
// Chargement aligné — l'adresse DOIT être un multiple de 32 octets
// Provoque un segfault si l'alignement n'est pas respecté
__m256 v1 = _mm256_load_ps(data); // requiert alignas(32) sur data
// Chargement non aligné — fonctionne avec n'importe quelle adresse
// Quasi même performance sur les CPU modernes (Haswell+)
__m256 v2 = _mm256_loadu_ps(data); // recommandé par défautRecommandation : utiliser _mm256_loadu_ps (non aligné) par défaut. La différence de performance avec _mm256_load_ps est nulle sur les CPU depuis 2013, tant que l'accès ne chevauche pas une frontière de cache line. L'utiliser évite les segfaults liés à un alignement incorrect.
Transférer des données d'un registre SIMD vers la mémoire :
float result[8];
// Stockage aligné
_mm256_store_ps(result, v1); // requiert alignas(32)
// Stockage non aligné
_mm256_storeu_ps(result, v1); // recommandé par défautConstruire un registre SIMD à partir de valeurs scalaires :
// Broadcast : toutes les lanes reçoivent la même valeur
__m256 all_twos = _mm256_set1_ps(2.0f); // [2, 2, 2, 2, 2, 2, 2, 2]
// Zéro
__m256 zeros = _mm256_setzero_ps(); // [0, 0, 0, 0, 0, 0, 0, 0]
// Valeurs individuelles (ATTENTION : ordre inversé, le dernier argument = lane 0)
__m256 v = _mm256_set_ps(8, 7, 6, 5, 4, 3, 2, 1);
// v = [1, 2, 3, 4, 5, 6, 7, 8] ← lane 0 est à droite dans set_ps
// Ordre naturel (premier argument = lane 0)
__m256 v2 = _mm256_setr_ps(1, 2, 3, 4, 5, 6, 7, 8);
// v2 = [1, 2, 3, 4, 5, 6, 7, 8] ← plus intuitif
⚠️ L'ordre inversé de_mm256_set_psest un piège classique. Préférer_mm256_setr_pspour la lisibilité, ou_mm256_set1_psquand toutes les lanes sont identiques.
__m256 a = _mm256_loadu_ps(src_a);
__m256 b = _mm256_loadu_ps(src_b);
__m256 sum = _mm256_add_ps(a, b); // a[i] + b[i]
__m256 diff = _mm256_sub_ps(a, b); // a[i] - b[i]
__m256 prod = _mm256_mul_ps(a, b); // a[i] * b[i]
__m256 quot = _mm256_div_ps(a, b); // a[i] / b[i]
__m256 root = _mm256_sqrt_ps(a); // sqrt(a[i])
__m256 recip = _mm256_rcp_ps(a); // ~1/a[i] (approximation rapide, 12-bit)
__m256 rsqrt = _mm256_rsqrt_ps(a); // ~1/sqrt(a[i]) (approximation 12-bit)
__m256 mn = _mm256_min_ps(a, b); // min(a[i], b[i])
__m256 mx = _mm256_max_ps(a, b); // max(a[i], b[i])L'instruction FMA calcule a × b + c en une seule instruction, avec un seul arrondi (plus précis que mul + add séparés) et un débit d'une instruction par cycle sur les CPU modernes :
// Requiert -mfma ou -march=native (disponible depuis Haswell / Zen)
__m256 result = _mm256_fmadd_ps(a, b, c); // a[i] * b[i] + c[i]
__m256 result = _mm256_fmsub_ps(a, b, c); // a[i] * b[i] - c[i]
__m256 result = _mm256_fnmadd_ps(a, b, c); // -(a[i] * b[i]) + c[i]Le FMA est omniprésent dans le calcul numérique : produits scalaires, multiplications matricielles, interpolations linéaires, polynômes de Horner. Chaque fois qu'une boucle contient un pattern a * b + c, le FMA devrait être utilisé — le compilateur le fait souvent automatiquement avec -mfma, mais les intrinsics le garantissent.
AVX2 a étendu le SIMD 256-bit aux entiers (AVX original ne supportait que les flottants en 256 bits) :
__m256i a = _mm256_loadu_si256(reinterpret_cast<const __m256i*>(src_a));
__m256i b = _mm256_loadu_si256(reinterpret_cast<const __m256i*>(src_b));
__m256i sum32 = _mm256_add_epi32(a, b); // 8 × int32 addition
__m256i sum16 = _mm256_add_epi16(a, b); // 16 × int16 addition
__m256i prod32 = _mm256_mullo_epi32(a, b); // 8 × int32 multiplication (low 32 bits)
// Opérations logiques (bitwise, indépendantes du type d'entier)
__m256i and_result = _mm256_and_si256(a, b);
__m256i or_result = _mm256_or_si256(a, b);
__m256i xor_result = _mm256_xor_si256(a, b);
// Décalages
__m256i shifted = _mm256_slli_epi32(a, 2); // a[i] << 2 (chaque int32)Les comparaisons SIMD ne retournent pas un bool — elles retournent un vecteur de masques où chaque lane contient soit tous les bits à 1 (vrai) soit tous à 0 (faux) :
__m256 a = _mm256_loadu_ps(data_a);
__m256 b = _mm256_loadu_ps(data_b);
// Comparaison : chaque lane → 0xFFFFFFFF (vrai) ou 0x00000000 (faux)
__m256 mask = _mm256_cmp_ps(a, b, _CMP_GT_OQ); // a[i] > b[i] ?
// Utilisation du masque pour sélectionner entre deux sources (blend)
__m256 result = _mm256_blendv_ps(b, a, mask);
// result[i] = mask[i] ? a[i] : b[i]Les constantes de comparaison _CMP_* les plus courantes :
| Constante | Signification |
|---|---|
_CMP_EQ_OQ |
== (ordered, quiet) |
_CMP_LT_OQ |
< |
_CMP_LE_OQ |
<= |
_CMP_GT_OQ |
> |
_CMP_GE_OQ |
>= |
_CMP_NEQ_OQ |
!= |
Pour convertir un masque SIMD en un entier (utile pour les tests de condition et le branchement) :
int bitmask = _mm256_movemask_ps(mask);
// bitmask : 8 bits, un par lane. Bit i = 1 si mask lane i est vrai.
if (bitmask == 0xFF) {
// TOUTES les lanes satisfont la condition
} else if (bitmask == 0) {
// AUCUNE lane ne satisfait la condition
} else {
// Cas partiel — traitement par masque ou par extraction individuelle
}Ce pattern est fondamental pour les algorithmes de recherche, de filtrage et de validation SIMD.
Les opérations de réarrangement sont les plus complexes du SIMD, mais aussi les plus puissantes. Elles permettent de réorganiser les éléments à l'intérieur d'un registre ou entre deux registres.
__m256 v = _mm256_setr_ps(10, 20, 30, 40, 50, 60, 70, 80);
// Permute au sein de chaque lane 128-bit (les deux moitiés sont indépendantes)
__m256 p = _mm256_permute_ps(v, 0b00'01'10'11);
// Moitié basse [10,20,30,40] → [40,30,20,10]
// Moitié haute [50,60,70,80] → [80,70,60,50]
// Permute inter-lanes (AVX2 — traverse la frontière 128-bit)
__m256 cross = _mm256_permutevar8x32_ps(v,
_mm256_setr_epi32(7, 6, 5, 4, 3, 2, 1, 0));
// → [80, 70, 60, 50, 40, 30, 20, 10] (inversion totale)
⚠️ La frontière 128-bit : en AVX (256-bit), la plupart des opérations de réarrangement traitent les deux moitiés 128-bit indépendamment. Seul AVX2 a introduit des opérations qui traversent cette frontière (_mm256_permutevar8x32_*,_mm256_permute2x128_*). C'est un piège fréquent.
__m256 a = _mm256_setr_ps(1, 2, 3, 4, 5, 6, 7, 8);
__m256 b = _mm256_setr_ps(10, 20, 30, 40, 50, 60, 70, 80);
// Sélection par masque immédiat : bit = 0 → prend a, bit = 1 → prend b
__m256 blended = _mm256_blend_ps(a, b, 0b10100101);
// → [10, 2, 30, 4, 50, 6, 70, 8]
// b a b a b a b a (bits : 1 0 1 0 0 1 0 1, LSB = lane 0)
// Sélection par masque variable (calculé à l'exécution — vu plus haut)
__m256 mask = _mm256_cmp_ps(a, _mm256_set1_ps(3.5f), _CMP_GT_OQ);
__m256 selected = _mm256_blendv_ps(a, b, mask);
// lanes où a > 3.5 → prend b ; sinon → prend aLe gather charge des éléments depuis des positions non contiguës en mémoire, identifiées par un vecteur d'indices :
float source[1000] = { /* ... */ };
__m256i indices = _mm256_setr_epi32(0, 42, 7, 100, 3, 999, 55, 12);
// Charge source[0], source[42], source[7], source[100], ...
__m256 gathered = _mm256_i32gather_ps(source, indices, sizeof(float));Le gather est puissant conceptuellement mais lent en pratique sur les CPU actuels (2–4× plus lent qu'un chargement contigu). Il est utile pour les tables de lookup SIMD et les permutations indexées, mais ne doit pas être utilisé là où un layout SoA permettrait des accès continus.
Une réduction horizontale combine toutes les lanes d'un registre en une seule valeur scalaire (somme, minimum, maximum). Le SIMD est conçu pour les opérations verticales (entre registres), pas horizontales — les réductions requièrent donc plusieurs étapes de réarrangement.
float hsum_avx(__m256 v) {
// Étape 1 : additionner la moitié haute avec la moitié basse (128-bit)
__m128 hi = _mm256_extractf128_ps(v, 1); // lanes [4,5,6,7]
__m128 lo = _mm256_castps256_ps128(v); // lanes [0,1,2,3]
__m128 sum = _mm_add_ps(lo, hi); // [0+4, 1+5, 2+6, 3+7]
// Étape 2 : réduction 128-bit → 1 float
__m128 shuf = _mm_movehdup_ps(sum); // [1+5, 1+5, 3+7, 3+7]
__m128 sums = _mm_add_ps(sum, shuf); // [0+1+4+5, ?, 2+3+6+7, ?]
shuf = _mm_movehl_ps(shuf, sums); // [2+3+6+7, ?, ?, ?]
sums = _mm_add_ss(sums, shuf); // [0+1+2+3+4+5+6+7, ?, ?, ?]
return _mm_cvtss_f32(sums); // extraire le float scalaire
}Ce pattern est verbeux mais s'exécute en ~5 instructions à faible latence. Il est réutilisable tel quel — il suffit de le copier dans un utilitaire.
Même structure, en remplaçant _mm_add_ps par _mm_min_ps :
float hmin_avx(__m256 v) {
__m128 hi = _mm256_extractf128_ps(v, 1);
__m128 lo = _mm256_castps256_ps128(v);
__m128 mn = _mm_min_ps(lo, hi);
__m128 shuf = _mm_movehdup_ps(mn);
mn = _mm_min_ps(mn, shuf);
shuf = _mm_movehl_ps(shuf, mn);
mn = _mm_min_ss(mn, shuf);
return _mm_cvtss_f32(mn);
}Le produit scalaire de deux vecteurs est un cas d'usage classique combinant chargement, multiplication FMA, et réduction horizontale :
#include <cstddef>
#include <immintrin.h>
float dot_product_avx2(const float* a, const float* b, std::size_t n) {
__m256 acc = _mm256_setzero_ps(); // accumulateur 8 × float
// Boucle principale : traite 8 éléments par itération
std::size_t i = 0;
for (; i + 7 < n; i += 8) {
__m256 va = _mm256_loadu_ps(&a[i]);
__m256 vb = _mm256_loadu_ps(&b[i]);
acc = _mm256_fmadd_ps(va, vb, acc); // acc += va * vb
}
// Réduction horizontale : 8 floats → 1 float
float result = hsum_avx(acc);
// Épilogue : traiter les éléments restants en scalaire
for (; i < n; ++i) {
result += a[i] * b[i];
}
return result;
}Pour n = 1 000 000 (4 MiB par vecteur), GCC 15, -O2 -march=native -mfma, AMD Zen 4 :
| Version | Temps | Speedup vs scalaire |
|---|---|---|
Scalaire (-O2, pas de vectorisation) |
820 µs | 1× |
Auto-vectorisée (-O2 -march=native) |
135 µs | 6,1× |
| Intrinsics AVX2 + FMA (ci-dessus) | 130 µs | 6,3× |
L'auto-vectorisation produit un code quasi identique à la version intrinsics dans ce cas simple. L'avantage des intrinsics apparaît sur des algorithmes plus complexes où le compilateur ne peut pas inférer la transformation optimale.
Lorsque n n'est pas un multiple de la largeur SIMD (8 pour AVX float), les derniers éléments ne remplissent pas un registre complet. Trois stratégies :
La plus simple et la plus courante :
// Boucle SIMD principale
for (; i + 7 < n; i += 8) { /* ... */ }
// Épilogue scalaire
for (; i < n; ++i) {
result += a[i] * b[i];
}Le surcoût est minime pour les grands tableaux (au plus 7 itérations scalaires).
AVX-512 offre des chargements masqués natifs qui ne touchent que les lanes actives :
// AVX-512 : masque pour les éléments restants
std::size_t remaining = n - i;
__mmask16 mask = (1u << remaining) - 1; // ex: remaining=3 → mask=0b0000'0111
__m512 va = _mm512_maskz_loadu_ps(mask, &a[i]); // lanes inactives → 0
__m512 vb = _mm512_maskz_loadu_ps(mask, &b[i]);
acc = _mm512_fmadd_ps(va, vb, acc);Traiter les width derniers éléments (qui chevauchent partiellement l'avant-dernière itération SIMD). Le résultat est calculé deux fois pour les éléments chevauchants, ce qui est acceptable si l'opération est idempotente ou si on utilise un masque de stockage :
// Dernière itération : charger les 8 derniers éléments (peut chevaucher)
if (n >= 8) {
__m256 va = _mm256_loadu_ps(&a[n - 8]);
__m256 vb = _mm256_loadu_ps(&b[n - 8]);
// ... traiter, mais attention aux doublons
}Cette technique est surtout utile pour les opérations de stockage (copie, transformation) où le chevauchement est transparent.
#include <cstdio>
void print_m256(__m256 v, const char* label) {
alignas(32) float tmp[8];
_mm256_store_ps(tmp, v);
std::printf("%s: [%.2f, %.2f, %.2f, %.2f, %.2f, %.2f, %.2f, %.2f]\n",
label, tmp[0], tmp[1], tmp[2], tmp[3],
tmp[4], tmp[5], tmp[6], tmp[7]);
}
// Utilisation :
__m256 v = _mm256_setr_ps(1, 2, 3, 4, 5, 6, 7, 8);
print_m256(v, "v");
// v: [1.00, 2.00, 3.00, 4.00, 5.00, 6.00, 7.00, 8.00]GDB peut afficher directement les registres xmm / ymm / zmm :
(gdb) print $ymm0.v8_float
$1 = {1, 2, 3, 4, 5, 6, 7, 8}
(gdb) print $ymm0.v4_double
$2 = {2.6468..., 4.2868..., 5.9268..., 7.5668...}
(gdb) print $ymm0.v8_int32
$3 = {1065353216, 1073741824, 1077936128, ...}La commande info registers ymm affiche tous les registres AVX.
La référence absolue pour tous les intrinsics Intel :
https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html
Ce guide interactif permet de rechercher par nom, par catégorie, par jeu d'instructions, et affiche pour chaque intrinsic :
- La signature C
- L'instruction assembleur correspondante
- La latence et le débit (throughput) par architecture
- Un pseudo-code décrivant l'opération
C'est l'outil à garder ouvert en permanence quand on écrit des intrinsics.
Pour chaque instruction SIMD, ce site fournit des mesures précises de latence et de débit sur chaque microarchitecture (Zen 4, Raptor Lake, etc.). Essentiel pour les micro-optimisations où chaque cycle compte.
Sur les anciens CPU (pré-Skylake), mélanger des instructions AVX (préfixe v) et SSE legacy (sans préfixe v) dans la même fonction provoque des pénalités de transition (VEX transition penalty) de ~70 cycles. Sur les CPU modernes (Skylake+, Zen), la pénalité a disparu, mais la bonne pratique reste de ne pas mélanger les deux.
// ❌ Mélange AVX + SSE legacy (problématique sur Haswell/Broadwell)
__m256 a = _mm256_loadu_ps(data); // AVX
__m128 b = _mm_loadu_ps(other); // SSE legacy
// ✅ Utiliser _mm256_castps256_ps128 pour rester en mode VEX
__m128 lo = _mm256_castps256_ps128(a); // pas de transitionAvant Skylake, il était recommandé d'appeler _mm256_zeroupper() (vzeroupper) après du code AVX et avant d'appeler du code SSE ou des fonctions externes, pour éviter les pénalités de transition. Sur les CPU modernes, le compilateur insère automatiquement vzeroupper aux bons endroits, mais il est bon de connaître le mécanisme.
La division flottante SIMD (_mm256_div_ps) a une latence élevée (~11–14 cycles, vs 4 pour mul). Si la division est par une constante ou par une valeur réutilisée, préférer la multiplication par l'inverse :
// Lent : division à chaque itération
__m256 result = _mm256_div_ps(a, b);
// Rapide : calculer l'inverse une fois, puis multiplier
__m256 inv_b = _mm256_rcp_ps(b); // approximation 12-bit de 1/b
result = _mm256_mul_ps(a, inv_b); // a * (1/b)
// Si plus de précision est nécessaire : itération de Newton-Raphson
// inv_b = inv_b * (2 - b * inv_b)
__m256 two = _mm256_set1_ps(2.0f);
inv_b = _mm256_mul_ps(inv_b, _mm256_fnmadd_ps(b, inv_b, two));
result = _mm256_mul_ps(a, inv_b); // précision ~24 bits Contrairement au C++ scalaire, il n'y a pas de conversion implicite entre types SIMD. Un __m256 (float) n'est pas compatible avec un __m256i (int). Les conversions sont explicites :
__m256i ints = _mm256_loadu_si256(/*...*/);
__m256 floats = _mm256_cvtepi32_ps(ints); // int32 → float
__m256i back = _mm256_cvtps_epi32(floats); // float → int32 (arrondi)
__m256i trunc = _mm256_cvttps_epi32(floats); // float → int32 (troncature)| Aspect | Détail |
|---|---|
| Header | #include <immintrin.h> |
| Activation | -mavx2 -mfma ou -march=native |
| Convention | _mm[largeur]_[opération]_[type] |
| Chargement recommandé | _mm256_loadu_ps (non aligné — performant sur CPU modernes) |
| Opération clé | FMA (_mm256_fmadd_ps) — a*b+c en 1 instruction |
| Réduction horizontale | Nécessite ~5 instructions (extract + add + shuffle) |
| Épilogue | Boucle scalaire de fin (le plus simple) |
| Référence | Intel Intrinsics Guide (intel.com) |
| Debugging | print_m256() helper, GDB $ymm0.v8_float |
| Rappel | Dernier recours après échec de l'auto-vectorisation |