Single instruction, multiple data (SIMD) est une forme de parallélisme au niveau matériel. Le même calcul est effectué par le processeur sur plusieurs éléments en même temps.
Prenons par exemple la somme, élément par élément, de deux tableaux de quatre nombres à virgule flottante de 32 bits.
Une implémentation scalaire émettrait quatre instructions addss distinctes, une par élément.
Une implémentation SIMD utilise une seule instruction addps pour effectuer les quatre additions en parallèle.
On obtient ainsi quatre fois plus de travail par instruction.
En assembleur x86-64, les instructions SIMD s'effectuent généralement à l'aide de registres xmm.
Certaines de ces instructions opèrent sur des entiers.
D'autres opèrent sur des nombres à virgule flottante.
Ces entiers ou ces nombres à virgule flottante sont dits empaquetés. Chacun occupe un espace, appelé voie, au sein du même registre. Les instructions SIMD opèrent généralement en parallèle sur les valeurs situées à la même voie dans les deux opérandes sources. Le résultat de chaque opération est stocké dans la voie de même position de l'opérande destination.
Une voie n'est pas une limite stricte. C'est l'interprétation que chaque instruction donne à une suite d'octets. Un opérande de 128 bits contient 16 octets. Selon l'instruction, ces octets peuvent être interprétés comme 16 voies de 1 octet, 8 voies de 2 octets, 4 voies de 4 octets, 2 voies de 8 octets, voire une seule voie de 16 octets.
Par exemple, l'instruction addps additionne des valeurs à virgule flottante simple précision (4 octets) empaquetées.
Pour cette instruction, chaque bloc de 4 octets d'un opérande constitue une voie distincte.
Dans un opérande de 128 bits de large (16 octets) :
| opérande | 4e voie | 3e voie | 2e voie | 1re voie |
|---|---|---|---|---|
| source 1 | 1,5 | 2,1 | 6,2 | 3,4 |
| + | + | + | + | + |
| source 2 | 0,5 | 8,26 | -13,4 | 22,9 |
| = | = | = | = | = |
| résultat | 2,0 | 10,36 | -7,2 | 26,3 |
À l'inverse, un opérande de 128 bits serait interprété comme ayant 2 voies dans le cas de mulpd.
En effet, cette instruction effectue une multiplication empaquetée sur des valeurs double précision (8 octets) :
| opérande | 2e voie | 1re voie |
|---|---|---|
| source 1 | 4,2 | 2,1 |
| * | * | * |
| source 2 | 1,0 | 92,4 |
| = | = | = |
| résultat | 4,2 | 194,04 |
Dans ce concept, on va s'intéresser à une petite sélection d'instructions SIMD.
Elles opèrent sur des valeurs à virgule flottante empaquetées à l'aide de registres xmm.
En règle générale, les instructions SIMD qui opèrent sur des valeurs à virgule flottante ont une forme à deux opérandes. Leur nom suit une structure précise :
add, mul, etc.).p pour indiquer que l'instruction opère sur des données empaquetées, à la position qu'occupait auparavant un s (pour des données scalaires).s pour la simple précision et d pour la double précision).Le nom empaqueté s'obtient donc à partir du nom scalaire en remplaçant le s (scalaire) situé au milieu de la mnémonique par un p (empaqueté).
Le nom de l'opération et le suffixe de taille restent inchangés :
| scalaire | empaqueté | signification |
|---|---|---|
addss |
addps |
addition, simple précision (voies de 32 bits) |
addsd |
addpd |
addition, double précision (voies de 64 bits) |
mulss |
mulps |
multiplication, simple précision |
mulsd |
mulpd |
multiplication, double précision |
Les instructions SIMD arithmétiques suivent la règle ci-dessus. Quelques exemples :
addps xmm0, xmm1 ; xmm0 = xmm0 + xmm1 (each 32-bit float at the same position)
subpd xmm2, xmm3 ; xmm2 = xmm2 - xmm3 (each 64-bit float at the same position)
mulps xmm4, xmm5 ; xmm4 = xmm4 * xmm5 (each 32-bit float at the same position)
divpd xmm6, xmm7 ; xmm6 = xmm6 / xmm7 (each 64-bit float at the same position)
Il existe également des instructions SIMD qui convertissent des valeurs à virgule flottante empaquetées entre simple et double précision.
Elles suivent la même règle de nommage, chaque s de scalaire étant remplacé par un p d'empaqueté :
| scalaire | empaqueté | signification |
|---|---|---|
cvtss2sd |
cvtps2pd |
convertit la simple précision en double précision |
cvtsd2ss |
cvtpd2ps |
convertit la double précision en simple précision |
Ces instructions illustrent le point évoqué plus haut : une voie est une interprétation, pas une limite stricte. Les opérandes source et destination ont des tailles de voie différentes, donc des nombres de voies différents.
Pour cvtps2pd, l'opérande source (ps) comporte 4 voies en simple précision.
La destination (pd) comporte 2 voies en double précision.
Seules les 2 voies inférieures de la source sont lues.
Les 2 voies supérieures sont ignorées :
| opérande | 4e voie | 3e voie | 2e voie | 1re voie |
|---|---|---|---|---|
source en ps
|
ignorée | ignorée | 2,5 | 1,5 |
destination en pd
|
2,5 | 1,5 |
Pour cvtpd2ps, la source (pd) comporte 2 voies en double précision.
La destination (ps) comporte 4 voies en simple précision.
Les deux voies de la source sont converties et écrites dans les 2 voies inférieures de la destination.
Les 2 voies supérieures de la destination sont mises à zéro :
| opérande | 4e voie | 3e voie | 2e voie | 1re voie |
|---|---|---|---|---|
source en pd
|
4,0 | 3,0 | ||
destination en ps
|
0 | 0 | 4,0 | 3,0 |
Quelques exemples :
cvtps2pd xmm0, xmm1 ; xmm0 = xmm1 as 2 doubles (low 2 lanes of xmm1 are read)
cvtpd2ps xmm0, xmm1 ; xmm0 = xmm1 as 2 floats (in low 2 lanes; upper 2 zeroed)
Les instructions SIMD ci-dessus ont toutes été présentées avec des opérandes registre-registre. Elles peuvent aussi prendre un opérande mémoire comme source (mais pas comme destination).
Le nombre d'octets lus en mémoire est déterminé par l'instruction.
Par exemple, addps lit 16 octets (4 nombres à virgule flottante en simple précision).
À l'inverse, cvtps2pd ne lit que 8 octets (2 nombres à virgule flottante en simple précision), car c'est tout ce dont il a besoin en entrée.
addps xmm0, [rsi] ; xmm0 = xmm0 + 4 floats at [rsi]
mulpd xmm1, [rsi + 16] ; xmm1 = xmm1 * 2 doubles at [rsi + 16]
cvtps2pd xmm2, [rsi + 8] ; xmm2 = (2 floats at [rsi + 8]) converted to 2 doubles
Lorsque l'opérande mémoire fait 16 octets de large, il doit être aligné sur 16 octets. Un emplacement mémoire aligné est un emplacement dont l'adresse est un multiple de la taille de l'opération (16 octets dans ce cas).
Pour déclarer de la mémoire alignée, tu peux utiliser la directive align dans les sections .data et .rodata, ou alignb dans la section .bss :
section .rodata
align 16
mem1: dd 4.0, 3.5, 2.0, -1.0
section .data
align 16
mem2: dq 6.0, 2.1
section .bss
alignb 16
mem3: resb 16
section .text
fn:
movaps xmm0, oword [rel mem1] ; loads 4 packed 32-bit floats in xmm0
movapd xmm1, oword [rel mem2] ; loads 2 packed 64-bit floats in xmm1
movaps oword [rel mem3], xmm0 ; stores 4 packed 32-bit floats in mem3
ret
Les opérandes mémoire de moins de 16 octets, comme la source de 8 octets de cvtps2pd, n'ont aucune contrainte d'alignement.
Quatre instructions copient 128 bits de valeurs à virgule flottante empaquetées.
Elles opèrent entre deux registres xmm, ou entre un registre xmm et un emplacement mémoire :
| instruction | description |
|---|---|
movaps |
copie des données empaquetées en simple précision depuis un emplacement mémoire aligné, ou vers un tel emplacement |
movups |
copie des données empaquetées en simple précision depuis un emplacement mémoire non aligné, ou vers un tel emplacement |
movapd |
copie des données empaquetées en double précision depuis un emplacement mémoire aligné, ou vers un tel emplacement |
movupd |
copie des données empaquetées en double précision depuis un emplacement mémoire non aligné, ou vers un tel emplacement |
Toutes ces instructions peuvent servir à copier des valeurs entre deux registres xmm.
Si l'opération implique un emplacement mémoire, movaps et movapd exigent qu'il soit aligné.
movaps et movapd déclenchent une erreur si on leur donne une adresse non alignée.
Elles font aussi office de contrôle de sécurité, en confirmant que l'agencement des données est bien celui que l'on croit.
À l'inverse, movups et movupd peuvent être utilisées avec n'importe quel emplacement mémoire, aligné ou non.
Dans NASM, le préfixe de taille d'une valeur de 16 octets est oword.
Le SIMD fait l'objet d'un développement actif dans l'écosystème x86-64 depuis des décennies. Chaque extension de l'architecture apporte de nouvelles instructions, ou de nouveaux usages pour des instructions existantes.
En particulier, les extensions AVX et AVX2 introduisent plusieurs changements notables.
D'abord, la prise en charge de registres SIMD de 256 bits, appelés ymm.
Ils étendent les registres xmm correspondants, un peu comme rax contient eax.
Ensuite, elles prennent en charge des variantes à 3 opérandes pour la plupart des instructions qui opèrent sur des registres xmm (et ymm).
Cela concerne non seulement les instructions SIMD, mais aussi celles qui opèrent sur des valeurs à virgule flottante scalaires.
Ces variantes suivent la même notation que leur équivalent SSE, avec un v ajouté devant :
vaddsd xmm0, xmm1, xmm2 ; xmm0 = xmm1 + xmm2 (scalar sum of 64-bit floats)
vmulps xmm1, xmm3, xmm4 ; xmm1 = xmm3 * xmm4 (packed multiplication of 32-bit floats)
vmovupd ymm5, yword [mem] ; this copies 32 bytes from the location at mem to ymm5
Enfin, les contraintes d'alignement des opérandes mémoire SIMD sont assouplies.
Seuls les déplacements explicitement alignés, comme vmovaps, exigent cet alignement.
Dans ce parcours, on s'est limité aux instructions et aux registres des extensions SSE antérieures. C'est suffisant pour enseigner les bases et garantir la compatibilité avec un large éventail de processeurs.
Cependant, l'exécuteur de tests prend totalement en charge AVX et AVX2, et même les extensions plus récentes qui font partie d'AVX-512. Tu es libre de les utiliser.
C'est une bonne pratique d'être cohérent dans l'usage que tu fais des variantes SIMD. Essaie de ne pas mélanger les variantes SSE et AVX pour les mêmes instructions.
Si ta base de code mélange ces variantes, il est conseillé d'utiliser vzeroupper une fois le travail avec AVX terminé, et avant de passer à SSE.
Cela rompt les chaînes de dépendances et évite un blocage coûteux lorsque le processeur passe de l'état AVX à l'état SSE.
Tu écris du code pour le système de données d'un atelier de production de pièces automobiles. Les composants de moteur et de châssis défilent à travers une série de stations d'usinage. Chaque station produit des mesures qui doivent être combinées, comparées, calibrées ou normalisées avant que l'étape suivante ne démarre. Les fonctions que tu implémentes ici correspondent chacune à une station.
Les calculs de cet exercice doivent être effectués à l'aide d'instructions SIMD, et non d'opérations scalaires.
L'atelier fait fonctionner deux chaînes de montage parallèles. À la fin de chaque poste, les deux lignes déclarent leurs rendements pour 4 bacs de pièces sous forme de nombres en simple précision. Le tableau de bord du superviseur affiche le rendement combiné par bac, donc les deux rapports sont additionnés bac par bac. Les trois tampons sont gérés par le système de déclaration et sont toujours alignés sur 16 octets.
Définis une fonction sum_yields qui, à partir des rendements par bac de line_a et line_b, écrit la somme bac par bac dans result.
Les arguments, dans l'ordre, sont :
line_a : adresse mémoire alignée sur 16 octets contenant 4 nombres à virgule flottante 32 bitsline_b : adresse mémoire alignée sur 16 octets contenant 4 nombres à virgule flottante 32 bitsresult : adresse mémoire alignée sur 16 octets où la fonction écrira 4 nombres à virgule flottante 32 bitsCette fonction n'a pas de valeur de retour ; le résultat est stocké dans le tampon passé (result) :
line_a = {10.0, 20.0, 30.0, 40.0};
line_b = { 5.0, 15.0, 25.0, 35.0};
sum_yields(line_a, line_b, result);
// => result == {15.0, 35.0, 55.0, 75.0};
Entre deux opérations, une station d'échantillonnage mesure des pièces usinées et les compare à des dimensions cibles. Les valeurs mesurées arrivent d'une jauge de précision dans un tampon toujours aligné sur 16 octets. Les valeurs cibles proviennent du dossier de fabrication, qui n'offre aucune garantie d'alignement. L'écart est multiplié par un facteur de sensibilité par axe avant d'être transmis au contrôleur de tolérance en aval. À cette étape, le contrôleur en aval lit l'écart directement depuis un registre SIMD plutôt que depuis la mémoire.
Définis une fonction scaled_deviation qui, à partir de measured, de target et de sensitivity, écrit l'écart mis à l'échelle dans result.
Les arguments, dans l'ordre, sont :
measured : adresse mémoire alignée sur 16 octets contenant 2 nombres à virgule flottante 64 bitstarget : adresse mémoire contenant 2 nombres à virgule flottante 64 bits (alignement non garanti)sensitivity : adresse mémoire contenant 2 nombres à virgule flottante 64 bits (alignement non garanti)result : adresse mémoire d'un tampon où la fonction écrira 2 nombres à virgule flottante 64 bits (alignement non garanti)L'écart mis à l'échelle est (measured - target) * sensitivity, voie par voie.
Cette fonction n'a pas de valeur de retour ; le résultat est stocké dans le tampon passé (result) sous forme de paire de nombres à virgule flottante 64 bits.
measured = {10.5, 20.5};
target = {10.0, 20.0};
sensitivity = { 2.0, 4.0};
scaled_deviation(measured, target, sensitivity, result);
// => result == {1.0, 2.0};
Avant que les pièces n'entrent dans le poste de contrôle final, les relevés dimensionnels d'un lot de 4 pièces sont remis à l'échelle en double précision et corrigés d'une dérive de température connue.
Les 4 relevés bruts arrivent sous forme de nombres en simple précision.
Pour chaque ligne de 2 relevés, le calibrage calcule reference / (raw - offset), puis multiplie ce résultat par un facteur de conversion constant de 0.5.
Note que la référence et le décalage sont identiques pour les deux lignes du lot. Le facteur de conversion est le même à chaque calibrage ; plutôt que de le recevoir en argument, tu dois le déclarer comme une constante compactée de 16 octets en mémoire.
Définis une fonction calibrate_batch qui, à partir du lot raw, de reference, de offset et du tampon result, écrit les valeurs calibrées dans result.
Les arguments, dans l'ordre, sont :
raw : adresse mémoire contenant 4 nombres à virgule flottante 32 bits (alignement non garanti)reference : adresse mémoire alignée sur 16 octets contenant 2 nombres à virgule flottante 64 bitsoffset : adresse mémoire alignée sur 16 octets contenant 2 nombres à virgule flottante 64 bitsresult : adresse mémoire alignée sur 16 octets où la fonction écrira 4 nombres à virgule flottante 64 bits (32 octets au total)Pour chacune des deux lignes du lot, la fonction doit :
offset de la paire convertie.reference par le résultat.0.5.result.Cette fonction n'a pas de valeur de retour ; le résultat est stocké dans le tampon passé (result) :
raw = {10.0, 20.0, 25.0, 50.0};
reference = {100.0, 100.0};
offset = { 5.0, 10.0};
calibrate_batch(raw, reference, offset, result);
// => result == {10.0, 5.0, 2.5, 1.25};
Au bout de la ligne, chaque pièce possède un score de qualité qui doit être normalisé avant d'être communiqué. Chaque score est multiplié par son propre gain par pièce, puis divisé par une échelle de production par axe. Le tampon des gains contient un gain par pièce.
L'échelle de production est la même pour toute la série et est passée dans une adresse mémoire contenant deux nombres à virgule flottante 64 bits. Les scores sont mis à jour en place : le même tampon qui contenait les scores bruts contient désormais les scores normalisés.
Définis une fonction normalize_scores qui, à partir d'un tableau scores, d'un tableau gains, de scale et d'un nombre n, normalise les scores en place.
Les arguments, dans l'ordre, sont :
scores : adresse mémoire alignée sur 16 octets contenant n nombres à virgule flottante 64 bits (modifiée en place)gains : adresse mémoire alignée sur 16 octets contenant n nombres à virgule flottante 64 bitsscale : adresse mémoire alignée sur 16 octets contenant 2 nombres à virgule flottante 64 bitsn : un entier 64 bits positif et pair (minimum 2), le nombre de scores à normaliserLes deux voies de l'échelle s'appliquent aux deux éléments de chaque paire.
La première voie met à l'échelle le premier élément de chaque paire (voies aux positions paires), la seconde met à l'échelle le second (voies aux positions impaires).
Chaque score est multiplié par le gain correspondant, puis divisé par la voie d'échelle appropriée : score * gain / scale_lane.
Cette fonction n'a pas de valeur de retour ; le résultat est stocké dans le tampon d'entrée (scores), à la même position que le score correspondant qui a été normalisé :
n = 4
scores = {10.0, 20.0, 30.0, 40.0};
gains = { 2.0, 1.5, 1.0, 0.5};
scale = { 2.0, 4.0};
normalize_scores(scores, gains, scale, n);
// => scores == {10.0, 7.5, 15.0, 5.0};
Inscris-toi sur Exercism pour apprendre et maîtriser x86-64 Assembly avec 22 concepts130 exercices, et un vrai mentorat humain, le tout gratuitement.