Skip to content

Latest commit

 

History

History
648 lines (453 loc) · 24.3 KB

File metadata and controls

648 lines (453 loc) · 24.3 KB

🔝 Retour au Sommaire

41.3.1 — Intrinsics

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)


Introduction

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.


Headers et activation

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.cpp

Si 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'  

Activer le SIMD par fonction (sans flag global)

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
}

Types de données SIMD

Les intrinsics manipulent des types opaques représentant des registres SIMD :

Types 128-bit (SSE — registres xmm)

Type Contenu Éléments
__m128 4 × float 4
__m128d 2 × double 2
__m128i Entiers (int8 → int64, au choix) 16, 8, 4, ou 2

Types 256-bit (AVX — registres ymm)

Type Contenu Éléments
__m256 8 × float 8
__m256d 4 × double 4
__m256i Entiers (int8 → int64, au choix) 32, 16, 8, ou 4

Types 512-bit (AVX-512 — registres zmm)

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.


Nomenclature des intrinsics Intel

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, ...

Suffixes de type courants

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

Exemples de décodage

_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

Opérations fondamentales

Chargement (Load)

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éfaut

Recommandation : 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.

Stockage (Store)

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éfaut

Initialisation (Set)

Construire 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_ps est un piège classique. Préférer _mm256_setr_ps pour la lisibilité, ou _mm256_set1_ps quand toutes les lanes sont identiques.


Arithmétique

Opérations élémentaires

__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])

Fused Multiply-Add (FMA)

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.

Opérations entières (AVX2)

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)

Comparaisons et masques

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 !=

Extraction du masque en entier

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.


Réarrangement des données (Shuffle, Permute, Blend)

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.

Permute (réarrangement au sein d'un registre)

__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.

Blend (sélection conditionnelle)

__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 a

Gather (chargement indirect — AVX2)

Le 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.


Réductions horizontales

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.

Somme horizontale d'un __m256 (8 floats → 1 float)

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.

Minimum horizontal

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);
}

Exemple complet : produit scalaire (dot product)

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;
}

Analyse de performance

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
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.


Gestion de l'épilogue (éléments restants)

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 :

Boucle scalaire de fin

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).

Chargement masqué (AVX-512 ou AVX2 avec masques)

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);

Chevauchement de la dernière itération

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.


Debugging et inspection des registres SIMD

Imprimer un registre __m256

#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 et les registres SIMD

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.


Ressources de référence

Intel Intrinsics Guide

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.

Latence et débit : uops.info

https://uops.info/

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.


Pièges courants

1. Mélange AVX et SSE legacy

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 transition

2. Oubli du _mm256_zeroupper()

Avant 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.

3. Division SIMD

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  

4. Conversions de type implicites inexistantes

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)

Résumé

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

⏭️ Auto-vectorisation du compilateur