Single instruction, multiple data (SIMD) es una forma de paralelismo a nivel de hardware. La CPU realiza el mismo cálculo sobre varios elementos a la vez.
Piensa, por ejemplo, en sumar elemento a elemento dos arrays de cuatro números de coma flotante de 32 bits.
Una implementación escalar emitiría cuatro instrucciones addss distintas, una por elemento.
Una implementación SIMD usa un único addps para hacer las cuatro sumas en paralelo.
Eso supone cuatro veces más trabajo por instrucción.
En ensamblador x86-64, las instrucciones SIMD se realizan normalmente con registros xmm.
Algunas de esas instrucciones operan sobre números enteros.
Otras operan sobre números en coma flotante.
Esos números enteros o en coma flotante se denominan packed. Cada uno ocupa un espacio, llamado lane, dentro del mismo registro. Las instrucciones SIMD suelen operar en paralelo sobre los valores que ocupan la misma posición de lane en los dos operandos fuente. El resultado de cada operación se guarda en el lane que ocupa esa misma posición en el operando destino.
Un lane no es un límite rígido. Es una interpretación que cada instrucción da a una secuencia de bytes. Un operando de 128 bits contiene 16 bytes. Según la instrucción, esos bytes pueden interpretarse como 16 lanes de 1 byte, 8 lanes de 2 bytes, 4 lanes de 4 bytes, 2 lanes de 8 bytes o incluso un único lane de 16 bytes.
Por ejemplo, la instrucción addps suma valores en coma flotante de precisión simple (4 bytes) empaquetados.
Para esta instrucción, cada bloque de 4 bytes de un operando es un lane distinto.
En un operando de 128 bits de ancho (16 bytes):
| operando | 4.º lane | 3.er lane | 2.º lane | 1.er lane |
|---|---|---|---|---|
| fuente 1 | 1,5 | 2,1 | 6,2 | 3,4 |
| + | + | + | + | + |
| fuente 2 | 0,5 | 8,26 | -13,4 | 22,9 |
| = | = | = | = | = |
| resultado | 2,0 | 10,36 | -7,2 | 26,3 |
Por otro lado, un operando de 128 bits se interpretaría como si tuviera 2 lanes en el caso de mulpd.
Esto se debe a que esta instrucción realiza una multiplicación empaquetada sobre valores de doble precisión (8 bytes):
| operando | 2.º lane | 1.er lane |
|---|---|---|
| fuente 1 | 4,2 | 2,1 |
| * | * | * |
| fuente 2 | 1,0 | 92,4 |
| = | = | = |
| resultado | 4,2 | 194,04 |
En este concepto nos centraremos en una pequeña selección de instrucciones SIMD.
Operan sobre valores en coma flotante empaquetados usando registros xmm.
Como regla general, las instrucciones SIMD que operan sobre valores en coma flotante adoptan una forma de dos operandos. Su nombre sigue una estructura concreta:
add, mul, etc.).p que indica que la instrucción opera sobre datos empaquetados, en la misma posición que antes ocupaba una s (de datos escalares).s para precisión simple y d para doble precisión).El nombre empaquetado se obtiene, por tanto, a partir del nombre escalar sustituyendo la s (escalar) que hay en medio del mnemónico por una p (empaquetado).
El nombre de la operación y el sufijo de tamaño no cambian:
| escalar | empaquetado | significado |
|---|---|---|
addss |
addps |
suma, precisión simple (lanes de 32 bits) |
addsd |
addpd |
suma, doble precisión (lanes de 64 bits) |
mulss |
mulps |
multiplicación, precisión simple |
mulsd |
mulpd |
multiplicación, doble precisión |
Las instrucciones SIMD aritméticas siguen la regla anterior. Algunos ejemplos:
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)
También hay instrucciones SIMD para convertir entre valores en coma flotante empaquetados de precisión simple y de doble precisión.
Siguen la misma regla de nomenclatura, sustituyendo cada s de escalar por una p de empaquetado:
| escalar | empaquetado | significado |
|---|---|---|
cvtss2sd |
cvtps2pd |
convierte de precisión simple a doble precisión |
cvtsd2ss |
cvtpd2ps |
convierte de doble precisión a precisión simple |
Estas instrucciones ilustran el punto anterior de que un lane es una interpretación, no un límite rígido. Los operandos fuente y destino tienen lanes de distinto tamaño, por lo que tienen distinto número de lanes.
En cvtps2pd, la fuente (ps) tiene 4 lanes de precisión simple.
El destino (pd) tiene 2 lanes de doble precisión.
Solo se leen los 2 lanes inferiores de la fuente.
Los 2 superiores se ignoran:
| operando | 4.º lane | 3.er lane | 2.º lane | 1.er lane |
|---|---|---|---|---|
fuente como ps
|
ignorado | ignorado | 2,5 | 1,5 |
destino como pd
|
2,5 | 1,5 |
En cvtpd2ps, la fuente (pd) tiene 2 lanes de doble precisión.
El destino (ps) tiene 4 lanes de precisión simple.
Ambos lanes de la fuente se convierten y se escriben en los 2 lanes inferiores del destino.
Los 2 lanes superiores del destino se ponen a cero:
| operando | 4.º lane | 3.er lane | 2.º lane | 1.er lane |
|---|---|---|---|---|
fuente como pd
|
4,0 | 3,0 | ||
destino como ps
|
0 | 0 | 4,0 | 3,0 |
Algunos ejemplos:
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)
Las instrucciones SIMD anteriores se han mostrado todas con operandos de registro a registro. También pueden tomar un operando de memoria como fuente (pero no como destino).
El número de bytes que se leen de la memoria lo determina la instrucción.
Por ejemplo, addps lee 16 bytes (4 números de coma flotante de precisión simple).
cvtps2pd, en cambio, lee solo 8 bytes (2 números de coma flotante de precisión simple), porque es toda la entrada que necesita.
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
Cuando el operando de memoria ocupa 16 bytes, debe estar alineado a 16 bytes. Una ubicación de memoria alineada es aquella cuya dirección es múltiplo del tamaño de la operación (16 bytes en este caso).
Para declarar memoria alineada, puedes usar la directiva align en la sección .data y .rodata, o alignb, en la sección .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
Los operandos de memoria menores de 16 bytes, como la fuente de 8 bytes de cvtps2pd, no tienen ningún requisito de alineación.
Hay cuatro instrucciones que copian 128 bits de valores en coma flotante empaquetados.
Operan entre dos registros xmm, o entre un registro xmm y una ubicación de memoria:
| instrucción | descripción |
|---|---|
movaps |
copia datos empaquetados de precisión simple desde, o hacia, una ubicación de memoria alineada |
movups |
copia datos empaquetados de precisión simple desde, o hacia, una ubicación de memoria desalineada |
movapd |
copia datos empaquetados de doble precisión desde, o hacia, una ubicación de memoria alineada |
movupd |
copia datos empaquetados de doble precisión desde, o hacia, una ubicación de memoria desalineada |
Todas esas instrucciones pueden usarse para copiar valores entre dos registros xmm.
Si la operación incluye una ubicación de memoria, movaps y movapd exigen que esté alineada.
movaps y movapd generarán un fallo si reciben una dirección desalineada.
Además, sirven como comprobación de seguridad de que la disposición de tus datos es la que crees que es.
Por otro lado, movups y movupd pueden usarse con cualquier ubicación de memoria, esté alineada o no.
El prefijo de tamaño para un valor de 16 bytes en NASM es oword.
SIMD ha sido objeto de un desarrollo activo en el ecosistema x86-64 durante décadas. Cada extensión de la arquitectura aporta instrucciones nuevas o nuevos usos para instrucciones antiguas.
En particular, las extensiones AVX y AVX2 introducen algunos cambios relevantes.
En primer lugar, la compatibilidad con registros SIMD de 256 bits, llamados ymm.
Extienden los registros xmm correspondientes, igual que rax contiene eax.
En segundo lugar, incluyen compatibilidad con variantes de 3 operandos para la mayoría de las instrucciones que operan sobre registros xmm (y ymm).
Esto incluye no solo las instrucciones SIMD, sino también las que operan sobre valores en coma flotante escalares.
Esas variantes siguen la misma notación que su equivalente SSE, con una v añadida delante:
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
En tercer lugar, se relaja la alineación exigida a los operandos de memoria SIMD.
Solo los movimientos explícitamente alineados, como vmovaps, exigen esta alineación.
En este track, nos hemos limitado a las instrucciones y registros hasta las extensiones SSE anteriores. Esto es suficiente para enseñar los fundamentos y garantiza la compatibilidad con una amplia variedad de CPUs.
No obstante, el test-runner es totalmente compatible con AVX y AVX2, e incluso con las extensiones más recientes que forman parte de AVX-512. Eres libre de usarlas.
Es buena práctica ser consistente en el uso de las variantes SIMD. Intenta no mezclar las variantes SSE y AVX para las mismas instrucciones.
Si tu base de código mezcla esas variantes, es buena práctica usar vzeroupper después de terminar tu trabajo con AVX y antes de usar SSE.
Esto rompe cadenas de dependencias y evita una costosa pausa cuando la CPU pasa entre el estado AVX y el estado SSE.
Estás escribiendo código para el sistema de datos de la planta de producción de una fábrica de piezas de automoción. Los componentes de motor y chasis pasan por una serie de estaciones de mecanizado. Cada estación produce mediciones que hay que combinar, comparar, calibrar o normalizar antes de que se ejecute la siguiente etapa. Las funciones que implementas aquí son una por cada estación.
Los cálculos de este ejercicio deben realizarse con instrucciones SIMD, no con operaciones escalares.
En la planta funcionan dos líneas de montaje paralelas. Al final de cada turno, ambas líneas comunican sus rendimientos para 4 contenedores de piezas como números de precisión simple. El panel del supervisor muestra el rendimiento combinado por contenedor, así que los dos informes se suman contenedor a contenedor. El sistema de informes gestiona los tres búferes, que siempre están alineados a 16 bytes.
Define una función sum_yields que, dados los rendimientos por contenedor de line_a y line_b, escriba en result la suma de cada contenedor.
Los argumentos, en orden, son:
line_a: dirección de memoria alineada a 16 bytes que contiene 4 números en coma flotante de 32 bitsline_b: dirección de memoria alineada a 16 bytes que contiene 4 números en coma flotante de 32 bitsresult: dirección de memoria alineada a 16 bytes donde la función escribirá 4 números en coma flotante de 32 bitsEsta función no devuelve ningún valor; en su lugar, el resultado se guarda en el búfer que se le pasa (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 operaciones, una estación de muestreo mide las piezas mecanizadas y las compara con las dimensiones objetivo. Los valores medidos llegan desde un calibre de precisión a un búfer que siempre está alineado a 16 bytes. Los valores objetivo proceden del paquete de la orden de trabajo, que no tiene ninguna garantía de alineación. La desviación se multiplica por un factor de sensibilidad por eje antes de pasarla al verificador de tolerancias posterior. En esta etapa, el verificador posterior lee la desviación directamente de un registro SIMD en lugar de la memoria.
Define una función scaled_deviation que, dados measured, target y una sensitivity, escriba la desviación escalada en result.
Los argumentos, en orden, son:
measured: dirección de memoria alineada a 16 bytes que contiene 2 números en coma flotante de 64 bitstarget: dirección de memoria que contiene 2 números en coma flotante de 64 bits (sin garantía de alineación)sensitivity: dirección de memoria que contiene 2 números en coma flotante de 64 bits (sin garantía de alineación)result: dirección de memoria de un búfer donde la función escribirá 2 números en coma flotante de 64 bits (sin garantía de alineación)La desviación escalada es (measured - target) * sensitivity, carril a carril.
Esta función no devuelve ningún valor; en su lugar, el resultado se guarda en el búfer que se le pasa (result) como un par de números en coma flotante de 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};
Antes de que las piezas entren en la zona de inspección final, las lecturas dimensionales de un lote de 4 piezas se reescalan a doble precisión y se corrigen para tener en cuenta una deriva de temperatura conocida.
Las 4 lecturas sin procesar llegan como números de precisión simple.
Para cada fila de 2 lecturas, la calibración calcula reference / (raw - offset) y después multiplica este resultado por un factor de conversión constante de 0.5.
Ten en cuenta que la referencia y el desplazamiento son los mismos para las dos filas del lote. El factor de conversión es el mismo en todas las calibraciones, así que, en lugar de recibirlo como argumento, debes declararlo como una constante empaquetada de 16 bytes en memoria.
Define una función calibrate_batch que, dado un lote raw, una reference, un offset y un búfer result, escriba los valores calibrados en result.
Los argumentos, en orden, son:
raw: dirección de memoria que contiene 4 números en coma flotante de 32 bits (sin garantía de alineación)reference: dirección de memoria alineada a 16 bytes que contiene 2 números en coma flotante de 64 bitsoffset: dirección de memoria alineada a 16 bytes que contiene 2 números en coma flotante de 64 bitsresult: dirección de memoria alineada a 16 bytes donde la función escribirá 4 números en coma flotante de 64 bits (32 bytes en total)La función debe, para cada una de las dos filas del lote:
offset al par convertido.reference entre el resultado.0.5.result.Esta función no devuelve ningún valor; en su lugar, el resultado se guarda en el búfer que se le pasa (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};
Al final de la línea, cada pieza tiene una puntuación de calidad que hay que normalizar antes de comunicarla. Cada puntuación se multiplica por su propia ganancia por pieza y después se divide entre una escala de producción por eje. El búfer de ganancias contiene una ganancia por pieza.
La escala de producción es la misma para toda la tanda y se pasa en una dirección de memoria que contiene dos números en coma flotante de 64 bits. Las puntuaciones se actualizan in situ: el mismo búfer que contenía las puntuaciones sin procesar ahora contiene las puntuaciones normalizadas.
Define una función normalize_scores que, dado un array scores, un array gains, una scale y un recuento n, normalice las puntuaciones in situ.
Los argumentos, en orden, son:
scores: dirección de memoria alineada a 16 bytes que contiene n números en coma flotante de 64 bits (modificada in situ)gains: dirección de memoria alineada a 16 bytes que contiene n números en coma flotante de 64 bitsscale: dirección de memoria alineada a 16 bytes que contiene 2 números en coma flotante de 64 bitsn: un número entero positivo y par de 64 bits (mínimo 2), el número de puntuaciones que hay que normalizarLos dos carriles de la escala se aplican a los dos elementos de cada par.
El primer carril escala el primer elemento de cada par (carriles en posiciones pares), y el segundo carril escala el segundo (carriles en posiciones impares).
Cada puntuación se multiplica por su ganancia correspondiente y después se divide entre el carril de escala adecuado: score * gain / scale_lane.
Esta función no devuelve ningún valor; en su lugar, el resultado se guarda en el búfer de entrada (scores), en la misma posición de la score correspondiente que se normalizó:
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};
Regístrate en Exercism para aprender y dominar x86-64 Assembly con 22 conceptos130 ejercicios y mentoría humana real, todo gratis.