Table de mixage

Table de mixage

Exercice d'apprentissage

Introduction

SIMD : les entiers

Un concept précédent a introduit les valeurs à virgule flottante empaquetées : plusieurs nombres contenus dans un seul registre xmm, sur lesquels on opère voie par voie et en parallèle. Les mêmes registres xmm peuvent aussi contenir des entiers empaquetés.

Syntaxe

L'essentiel du modèle SIMD à virgule flottante se transpose tel quel aux entiers empaquetés :

  • Un registre de 128 bits est divisé en voies
  • Les instructions agissent en parallèle sur les voies situées à la même position
  • Les opérandes en mémoire suivent les mêmes règles d'alignement sur 16 octets

Cependant, la syntaxe est légèrement différente :

  1. Un préfixe p pour indiquer que l'instruction opère sur des données empaquetées.
  2. L'opération effectuée, avec le même nom que son équivalent non SIMD (aussi appelé scalaire) (par exemple add, mul, etc.).
  3. Un suffixe pour indiquer la taille de chaque voie.

Les entiers empaquetés ont quatre tailles de voie, et chacune a son propre suffixe :

largeur de voie octets voies dans 128 bits suffixe
octet 1 16 b
mot 2 8 w
dword 4 4 d
qword 8 2 q

Par exemple :

instruction signification
paddb add empaqueté, voies de 8 bits (16 voies)
paddw add empaqueté, voies de 16 bits (8 voies)
paddd add empaqueté, voies de 32 bits (4 voies)
paddq add empaqueté, voies de 64 bits (2 voies)

Certaines instructions prennent en entrée des voies d'une taille donnée, mais renvoient une autre taille. Elles suivent la même convention générale, mais avec deux suffixes de taille. Le premier indique la taille de la voie d'entrée, le second la taille de la voie de sortie :

instruction signification
pmovsxwd movsx empaqueté, de voies de 16 bits vers des voies de 32 bits
pmuldq mul empaqueté, de voies de 32 bits vers des voies de 64 bits

Déplacements en mémoire

Deux instructions au nom d'entier copient 128 bits entre un registre xmm et la mémoire :

instruction description
movdqa copie des entiers empaquetés depuis ou vers un emplacement aligné
movdqu copie des entiers empaquetés depuis ou vers un emplacement non aligné

Elles se comportent comme movaps et movups : movdqa provoque une erreur sur une adresse mal alignée, tandis que movdqu accepte n'importe quelle adresse. Toutes les quatre copient 128 bits sans les interpréter. La paire au nom d'entier s'utilise avec des données entières par convention, non par obligation.

Note

Ici, dq signifie double-qword, c'est-à-dire 128 bits (16 octets).

Addition et soustraction

L'addition et la soustraction suivent la règle de nommage :

paddb xmm0, xmm1 ; 16 lanes: each  8-bit, xmm0 += xmm1
paddw xmm2, xmm3 ;  8 lanes: each 16-bit, xmm2 += xmm3

psubd xmm4, xmm5 ;  4 lanes: each 32-bit, xmm4 -= xmm5
psubq xmm6, xmm7 ;  2 lanes: each 64-bit, xmm6 -= xmm7

Il n'existe pas de forme distincte pour les nombres signés et non signés. En complément à deux, l'addition et la soustraction produisent les mêmes bits, que les voies soient lues comme signées ou non signées, si bien qu'une seule instruction suffit pour les deux. C'est à toi de choisir l'interprétation, exactement comme avec les instructions scalaires add et sub.

En cas de dépassement, ces instructions effectuent un repli, comme leurs équivalents scalaires. Une voie de 8 bits contient des valeurs modulo 256 ; un paddb de 200 + 100 produit donc 300 - 256 = 44, en écartant les bits qui ne tiennent pas.

Remarque : ces bits excédentaires ne se propagent pas vers la voie suivante. Chaque voie est traitée indépendamment des autres, même si elles partagent le même registre.

Addition et soustraction saturantes

Le SIMD entier ajoute une opération que le SIMD à virgule flottante ne possède pas : l'addition et la soustraction saturantes, qui bornent le résultat au lieu d'effectuer un repli. Un résultat supérieur à la plage de la voie devient la plus grande valeur que la voie peut contenir ; un résultat inférieur à la plage devient la plus petite.

Les formes saturantes insèrent s (signé) ou us (non signé) avant le suffixe de taille :

instruction signification
paddsb add, saturant, voies signées de 8 bits
paddusb add, saturant, voies non signées de 8 bits
psubsw sub, saturant, voies signées de 16 bits
psubusw sub, saturant, voies non signées de 16 bits

La plage de saturation correspond à l'intervalle complet représentable pour un entier de la taille et du signe correspondants. Pour un octet :

  • Les octets non signés sont bornés à [0, 255] : un paddusb de 200 + 100 donne 255, et un psubusb de 5 - 10 donne 0.
  • Les octets signés sont bornés à [-128, 127] : un paddsb de 100 + 50 donne 127.

La saturation compte lorsqu'une voie contient une grandeur bornée, comme un canal de pixel ou un échantillon audio. Un repli rendrait sombre un pixel trop lumineux, alors que la saturation le maintient à sa luminosité maximale, ce qui est le résultat souhaité.

Note

L'addition et la soustraction saturantes n'existent que pour les voies d'octets et de mots, pas pour les dword ni les qword.

Multiplication

Multiplier deux valeurs de N bits peut produire un résultat de 2N bits, mais la voie de destination ne fait que N bits de large. Les opérations SIMD résolvent ce problème en précisant quelle moitié du produit conserver : soit les N bits de poids faible, soit les N bits de poids fort.

Pour les voies de 16 bits, trois instructions sont utilisées :

instruction signification
pmullw mul, voies de 16 bits, conserve les 16 bits de poids faible de chaque produit
pmulhw mul, voies de 16 bits, conserve les 16 bits de poids fort, opérandes signés
pmulhuw mul, voies de 16 bits, conserve les 16 bits de poids fort, opérandes non signés

Les 16 bits de poids faible d'un produit sont les mêmes, que les opérandes soient lus comme signés ou non signés ; il n'y a donc qu'un seul pmullw.

Les 16 bits de poids fort, en revanche, varient selon le signe du résultat. C'est pourquoi la multiplication de la moitié haute a des formes distinctes pour la multiplication signée et non signée :

  1. pmulhw, pour la multiplication signée.
  2. pmulhuw, avec un u supplémentaire, pour la multiplication non signée.

Remarque sur la syntaxe :

  1. Un p, pour entier empaqueté.
  2. L'opération effectuée, mul.
  3. Un h, pour indiquer que les bits supérieurs (« forts ») du résultat sont sélectionnés.
  4. Un u facultatif si le résultat doit être interprété comme non signé (c'est-à-dire qu'il n'est pas étendu en signe).
  5. Enfin, le suffixe de taille w, pour indiquer qu'il s'agit d'une opération sur des mots (16 bits).

La multiplication empaquetée sur les mots suit exactement la règle ci-dessus. La multiplication sur les dword (32 bits) suit aussi la règle, mais seule existe la variante qui sélectionne la moitié basse : pmulld.

Il n'existe pas de variante de la multiplication sur les dword qui sélectionne les bits de poids fort. Il existe toutefois des variantes qui élargissent la multiplication, en stockant le produit complet sur 64 bits des voies d'indice pair (c'est-à-dire les voies aux positions 0 et 2) :

instruction signification
pmuludq multiplie les éléments de 32 bits d'indice pair, non signés, en 2 produits complets de 64 bits
pmuldq multiplie les éléments de 32 bits d'indice pair, signés, en 2 produits complets de 64 bits

Remarque : la syntaxe utilise dq, éventuellement précédé d'un u pour la multiplication non signée. C'est parce que ces instructions prennent des voies dword en entrée et produisent des voies qword en sortie.

Division

Il n'existe pas de division entière empaquetée. Le code qui en a besoin convertit les valeurs en virgule flottante, effectue la division, puis reconvertit.

Élargir les entiers

Il existe des équivalents empaquetés de movsx et movzx. Ils suivent la même syntaxe que celle mentionnée pour les instructions dont les entrées ont une taille de voie différente de celle de leurs sorties :

pmovsxwd xmm0, xmm1   ; 4 words -> 4 dwords, sign-extended
pmovzxbw xmm0, xmm1   ; 8 bytes -> 8 words, zero-extended

Remarque : le nombre de voies est déterminé par la largeur la plus grande (celle de la sortie). L'instruction lit autant de voies dans la partie basse de la source. C'est la même sémantique que celle déjà rencontrée pour les instructions cvt empaquetées entre flottants simple et double précision.

Convertir entre entiers et flottants

Il existe des instructions pour convertir entre des entiers signés de 32 bits et des voies à virgule flottante. Elles suivent la syntaxe habituelle des instructions cvt et cvtt, mais au lieu de si, un entier empaqueté est représenté par dq :

cvtdq2ps xmm0, xmm1 ; convert 32-bit signed integers in xmm1 to 32-bit floats in xmm0
cvtdq2pd xmm2, xmm3 ; convert 32-bit signed integers in xmm3 to 64-bit floats in xmm2
cvtps2dq xmm4, xmm5 ; convert 32-bit floats in xmm5 to 32-bit signed integers in xmm4
Caution

Les instructions qui utilisent pi pour représenter des entiers empaquetés écrivent dans les registres mmx hérités et sont de fait obsolètes sur x86-64. Préfère les formes dq, qui utilisent les registres xmm.

Les mêmes remarques faites pour la conversion scalaire entre nombres à virgule flottante et entiers s'appliquent ici. Les nombres à virgule flottante sont arrondis selon un registre spécial appelé MXCSR, dont on ne peut pas présumer le mode à l'entrée d'une fonction. Il existe une variante cvtt (avec un t supplémentaire) qui tronque toujours le résultat.

Il est aussi possible d'utiliser round pour mettre les valeurs à virgule flottante empaquetées dans un état connu avant la conversion. La valeur de contrôle de round est la même que pour le round scalaire, et la syntaxe est également la même pour les valeurs à virgule flottante empaquetées :

roundps xmm0, xmm1, 1 ; xmm0 = floor(xmm1), packed 32-bit floats
roundpd xmm2, xmm3, 2 ; xmm2 = ceil(xmm3), packed 64-bit floats

Instructions

Tu écris le cœur numérique d'une petite console de mixage dans un studio d'enregistrement.

Le son arrive sous forme d'un flux d'échantillons. Chaque échantillon est un nombre qui décrit la position du cône d'un haut-parleur à un instant donné. Une console traite tout un bloc d'échantillons à la fois, en appliquant la même opération à chacun.

Tu as cinq tâches, chacune portant sur un bloc d'échantillons stocké en mémoire. Tu reçois les opérandes par des adresses mémoire et tu écris ton résultat à une adresse de résultat.

Note

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

1. Mixe deux pistes

Le studio enregistre parfois des instruments sur des pistes différentes. Pour les diffuser sur les mêmes enceintes, tu additionnes les deux pistes pour n'en faire qu'une.

Implémente la fonction mix_tracks, qui additionne deux pistes, échantillon par échantillon. Chaque piste est un bloc de 8 échantillons signés de 16 bits. Une somme qui dépasse la plage des entiers signés de 16 bits doit être saturée plutôt que de déborder.

Cette fonction prend comme arguments, dans cet ordre :

  • result : adresse mémoire alignée sur 16 octets d'un tampon dans lequel sont écrits les 8 échantillons mixés.
  • track_a : adresse mémoire alignée sur 16 octets de la première piste, avec 8 échantillons signés de 16 bits.
  • track_b : adresse mémoire alignée sur 16 octets de la seconde piste, avec 8 échantillons signés de 16 bits.
track_a = {10000, 30000, 20000, -30000, -20000, -100,  100, 0}
track_b = { 5000,  5000, 20000,  -5000, -20000,  100, -100, 0}
result  = {15000, 32767, 32767, -32768, -32768,    0,    0, 0}

Cette fonction ne renvoie aucune valeur.

2. Supprime la fuite

Quand deux microphones enregistrent en même temps, chacun capte une copie ténue de la source de l'autre. Cette copie indésirable s'appelle la fuite. Une fois cette fuite mesurée, il faut la soustraire pour nettoyer la piste.

Implémente la fonction remove_bleed, qui soustrait le bloc de fuite du bloc de la piste, échantillon par échantillon. Chaque bloc est constitué de 8 échantillons signés de 16 bits, et la soustraction doit saturer.

Cette fonction prend comme arguments, dans cet ordre :

  • result : adresse mémoire où sont écrits les 8 échantillons nettoyés (alignement non garanti).
  • track : adresse mémoire alignée sur 16 octets de la piste enregistrée, 8 échantillons signés de 16 bits.
  • bleed : adresse mémoire de la fuite à retirer, 8 échantillons signés de 16 bits (alignement non garanti).
track  = {10000, -30000, 5000, 32767, 0,  200, -200, 15000}
bleed  = { 2000,   5000, 5000,    -5, 0,  300,  300,  5000}
result = { 8000, -32768,    0, 32767, 0, -100, -500, 10000}

Cette fonction ne renvoie aucune valeur.

3. Combine les vumètres

Au-dessus de chaque canal se trouve un vumètre, une petite barre dont la luminosité indique le volume de ce canal. La face avant possède un vumètre principal qui doit briller aussi intensément que les deux canaux réunis. Tu additionnes les deux blocs de vumètres pour le piloter.

Note qu'un vumètre déjà lumineux reste à sa luminosité maximale au lieu de repartir vers le sombre, donc la somme sature.

Implémente la fonction combine_meters, qui additionne deux blocs de vumètres échantillon par échantillon. Cette fonction prend comme arguments, dans cet ordre :

  • result : adresse mémoire alignée sur 16 octets où sont écrites les 16 valeurs combinées.
  • meter_a : adresse mémoire alignée sur 16 octets du bloc de vumètres du premier canal, avec 16 valeurs non signées de 8 bits compactées.
  • meter_b : adresse mémoire alignée sur 16 octets du bloc de vumètres du second canal, avec 16 valeurs non signées de 8 bits compactées.

Chaque bloc contient 16 valeurs non signées de 8 bits. Il faut les additionner, en saturant sur la plage non signée.

meter_a = {200, 100,  50, 255, 0, 128, 10, 240, 1, 2, 3, 4, 5, 6, 7, 8}
meter_b = {100, 100, 200,   1, 0, 128, 10,  20, 1, 2, 3, 4, 5, 6, 7, 8}
result  = {255, 200, 250, 255, 0, 255, 20, 255, 2, 4, 6, 8, 10, 12, 14, 16}

Cette fonction ne renvoie aucune valeur.

4. Applique un fondu

La chanson ne doit pas s'arrêter brutalement, elle doit s'estomper en fondu à la fin. Tu disposes d'un bloc de coefficients de gain, un par échantillon, qui diminuent progressivement, de plus en plus faibles.

Chaque coefficient est une fraction exprimée sur 65536. Par exemple, si un échantillon vaut 16384, il exprime en fait 16384 / 65536, soit 1 / 4. La plus grande valeur utilisable, 32767, est juste en dessous de la moitié.

Comme 65536 vaut 2^16, multiplier un échantillon par son coefficient et ne garder que la moitié haute du produit donne sample * gain / 65536.

Implémente la fonction apply_fade, qui met à l'échelle chaque échantillon selon son coefficient. Cette fonction prend comme arguments, dans cet ordre :

  • result : adresse mémoire alignée sur 16 octets où sont écrits tous les échantillons mis à l'échelle.
  • track : adresse mémoire alignée sur 16 octets de la piste, avec n échantillons signés de 16 bits.
  • gains : adresse mémoire alignée sur 16 octets des coefficients de gain, avec n valeurs signées de 16 bits, chacune dans la plage allant de 0 à 32767.
  • n : le nombre d'échantillons, toujours non nul et multiple de 8, sous forme d'un entier non signé de 64 bits.

Note que la piste est un tableau qui contient n échantillons signés de 16 bits, où n n'est jamais nul et toujours un multiple de 8. Pour chaque échantillon, le résultat mis à l'échelle correspond aux 16 bits hauts du produit signé : sample * gain.

track  = {20000, 20000, 20000, 20000, -20000, -20000, -20000, -20000}
gains  = {32767, 24576, 16384,  8192,  32767,  24576,  16384,   8192}
result = { 9999,  7500,  5000,  2500, -10000,  -7500,  -5000,  -2500}
Note

Conserver la moitié haute arrondit vers moins l'infini. C'est pourquoi 20000 * 32767 donne 9999, tandis que -20000 * 32767 donne -10000 et non -9999.

Cette fonction ne renvoie aucune valeur.

5. Atténue une piste

Le batteur a été enregistré bien trop fort. Pour baisser toute la piste, tu divises chaque échantillon par une constante.

Implémente la fonction attenuate_track. Cette fonction prend comme arguments, dans cet ordre :

  • result : adresse mémoire alignée sur 16 octets où sont écrits les résultats, sous forme d'entiers signés de 32 bits.
  • samples : adresse mémoire alignée sur 16 octets des échantillons, n valeurs signées de 16 bits.
  • divisor : adresse mémoire alignée sur 16 octets de 4 copies identiques du diviseur, sous forme d'entiers de 32 bits.
  • n est le nombre d'échantillons, toujours non nul et multiple de 4, sous forme d'un entier non signé de 64 bits.

Note que samples est un tableau qui contient n échantillons signés de 16 bits, où n n'est jamais nul et toujours un multiple de 4.

Le diviseur est fourni sous forme de 4 copies du même entier de 32 bits, déjà répliquées pour pouvoir remplir les quatre voies d'un coup.

Le SIMD sur entiers n'a pas de division compactée, le travail se fait donc en virgule flottante compactée, avant d'être reconverti en entiers de 32 bits compactés pour être stockés dans result. La conversion se fait par troncature vers zéro : un quotient de 2.3 devient 2 et -2.3 devient -2.

samples = {300, 150, 90, -120, 100, 50, -50, 7}
divisor = {3, 3, 3, 3}
result  = {100,  50, 30,  -40,  33, 16, -16, 2}

Cette fonction ne renvoie aucune valeur.

Modifie via GitHub Le lien s'ouvre dans une nouvelle fenêtre ou un nouvel onglet
x86-64 Assembly Exercism

Prêt à commencer Table de mixage ?

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