Parcours
/
x86-64 Assembly
x86-64 Assembly
/
Exercices
/
Chaîne de production
Chaîne de production

Chaîne de production

Exercice d'apprentissage

Introduction

SIMD

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.

Les instructions SIMD en virgule flottante

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 :

  1. L'opération effectuée, avec le même nom que son équivalent non SIMD (aussi appelé scalaire) (par exemple add, mul, etc.).
  2. Un suffixe 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).
  3. Un suffixe indiquant la taille de chaque voie, avec la même sémantique que son équivalent non SIMD (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
Arithmétique

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)
Conversion entre précisions

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)
Opérandes mémoire

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.

Chargement et stockage des valeurs

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.

Les extensions SIMD

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.

Note

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.

Instructions

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.

Note

Les calculs de cet exercice doivent être effectués à l'aide d'instructions SIMD, et non d'opérations scalaires.

1. Combine les rendements de deux lignes

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 :

  1. line_a : adresse mémoire alignée sur 16 octets contenant 4 nombres à virgule flottante 32 bits
  2. line_b : adresse mémoire alignée sur 16 octets contenant 4 nombres à virgule flottante 32 bits
  3. result : adresse mémoire alignée sur 16 octets où la fonction écrira 4 nombres à virgule flottante 32 bits

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

2. Calcule l'écart mis à l'échelle

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 :

  1. measured : adresse mémoire alignée sur 16 octets contenant 2 nombres à virgule flottante 64 bits
  2. target : adresse mémoire contenant 2 nombres à virgule flottante 64 bits (alignement non garanti)
  3. sensitivity : adresse mémoire contenant 2 nombres à virgule flottante 64 bits (alignement non garanti)
  4. 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};

3. Calibre un lot

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 :

  1. raw : adresse mémoire contenant 4 nombres à virgule flottante 32 bits (alignement non garanti)
  2. reference : adresse mémoire alignée sur 16 octets contenant 2 nombres à virgule flottante 64 bits
  3. offset : adresse mémoire alignée sur 16 octets contenant 2 nombres à virgule flottante 64 bits
  4. result : 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 :

  1. Convertir les 2 valeurs brutes en simple précision de la ligne en double précision.
  2. Soustraire offset de la paire convertie.
  3. Diviser reference par le résultat.
  4. Multiplier, voie par voie, chaque nombre à virgule flottante 64 bits par le facteur de conversion fixe de 0.5.
  5. Stocker les 2 nombres à virgule flottante 64 bits dans les 16 octets correspondants de 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};

4. Normalise les scores le long d'une ligne

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 :

  1. scores : adresse mémoire alignée sur 16 octets contenant n nombres à virgule flottante 64 bits (modifiée en place)
  2. gains : adresse mémoire alignée sur 16 octets contenant n nombres à virgule flottante 64 bits
  3. scale : adresse mémoire alignée sur 16 octets contenant 2 nombres à virgule flottante 64 bits
  4. n : un entier 64 bits positif et pair (minimum 2), le nombre de scores à normaliser

Les 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};
Modifie via GitHub Le lien s'ouvre dans une nouvelle fenêtre ou un nouvel onglet
x86-64 Assembly Exercism

Prêt à commencer Chaîne de production ?

Inscris-toi sur Exercism pour apprendre et maîtriser x86-64 Assembly avec 22 concepts130 exercices, et un vrai mentorat humain, le tout gratuitement.