Una instrucción, múltiples datos (SIMD) es una forma de paralelismo a nivel de hardware. La CPU realiza el mismo cálculo sobre varios elementos al mismo tiempo.
Considera, por ejemplo, sumar dos arrays de cuatro números de punto flotante de 32 bits elemento por elemento.
Una implementación escalar emitiría cuatro instrucciones addss separadas, una por elemento.
Una implementación SIMD usa un solo addps para hacer las cuatro sumas en paralelo.
Eso da cuatro veces más trabajo por instrucción.
En ensamblador x86-64, las instrucciones SIMD normalmente se realizan usando registros xmm.
Algunas de esas instrucciones operan sobre enteros.
Otras operan sobre números de punto flotante.
Esos enteros o números de punto flotante se denominan empaquetados. Cada uno ocupa un espacio, llamado carril, dentro del mismo registro. Las instrucciones SIMD normalmente operan en paralelo sobre valores que están en la misma posición de carril en dos operandos fuente. El resultado de cada operación se guarda en el carril que está en la misma posición en el operando destino.
Un carril no es un límite estricto. Es una interpretación que cada instrucción le da a una secuencia de bytes. Un operando de 128 bits contiene 16 bytes. Según la instrucción, esos bytes se pueden interpretar como 16 carriles de 1 byte, 8 carriles de 2 bytes, 4 carriles de 4 bytes, 2 carriles de 8 bytes, o incluso un único carril de 16 bytes.
Por ejemplo, la instrucción addps suma valores de punto flotante empaquetados de precisión simple (4 bytes).
Para esta instrucción, cada fragmento de 4 bytes en un operando es un carril distinto.
En un operando de 128 bits de ancho (16 bytes):
| operando | carril 4 | carril 3 | carril 2 | carril 1 |
|---|---|---|---|---|
| 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 carriles en el caso de mulpd.
Esto se debe a que esta instrucción realiza una multiplicación empaquetada sobre valores de precisión doble (8 bytes):
| operando | carril 2 | carril 1 |
|---|---|---|
| fuente 1 | 4.2 | 2.1 |
| * | * | * |
| fuente 2 | 1.0 | 92.4 |
| = | = | = |
| resultado | 4.2 | 194.04 |
En este concepto nos vamos a centrar en una pequeña selección de instrucciones SIMD.
Operan sobre valores de punto flotante empaquetados usando registros xmm.
Como regla general, las instrucciones SIMD que operan sobre valores de punto flotante tienen una forma de dos operandos. Su nombre sigue cierta estructura:
add, mul, etc.).p para indicar 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 precisión doble).Por lo tanto, el nombre empaquetado se obtiene del nombre escalar sustituyendo la s (escalar) en medio del mnemónico por una p (empaquetado).
El nombre de la operación y el sufijo de tamaño no cambian:
| scalar | packed | meaning |
|---|---|---|
addss |
addps |
suma, precisión simple (carriles de 32 bits) |
addsd |
addpd |
suma, precisión doble (carriles de 64 bits) |
mulss |
mulps |
multiplicación, precisión simple |
mulsd |
mulpd |
multiplicación, precisión doble |
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 de punto flotante empaquetados de precisión simple y de precisión doble.
Siguen la misma regla de nombres, con cada s de escalar reemplazada por una p de empaquetado:
| scalar | packed | meaning |
|---|---|---|
cvtss2sd |
cvtps2pd |
convierte de precisión simple a precisión doble |
cvtsd2ss |
cvtpd2ps |
convierte de precisión doble a precisión simple |
Estas instrucciones ilustran el punto anterior de que un carril es una interpretación, no un límite estricto. Los operandos fuente y destino tienen tamaños de carril distintos, así que tienen distinta cantidad de carriles.
Para cvtps2pd, la fuente (ps) tiene 4 carriles de precisión simple.
El destino (pd) tiene 2 carriles de precisión doble.
Solo se leen los 2 carriles inferiores de la fuente.
Los 2 superiores se ignoran:
| operando | carril 4 | carril 3 | carril 2 | carril 1 |
|---|---|---|---|---|
fuente como ps
|
ignorado | ignorado | 2.5 | 1.5 |
destino como pd
|
2.5 | 1.5 |
Para cvtpd2ps, la fuente (pd) tiene 2 carriles de precisión doble.
El destino (ps) tiene 4 carriles de precisión simple.
Ambos carriles de la fuente se convierten y se escriben en los 2 carriles inferiores del destino.
Los 2 carriles superiores del destino se ponen a cero:
| operando | carril 4 | carril 3 | carril 2 | carril 1 |
|---|---|---|---|---|
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).
La cantidad de bytes que se leen de la memoria la determina la instrucción.
Por ejemplo, addps lee 16 bytes (4 números de punto flotante de precisión simple).
cvtps2pd, en cambio, lee solo 8 bytes (2 números de punto flotante de precisión simple), porque es todo lo que necesita como entrada.
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 es de 16 bytes de ancho, 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 requisito de alineación.
Hay cuatro instrucciones que copian 128 bits de valores de punto flotante empaquetados.
Operan entre dos registros xmm, o entre un registro xmm y una ubicación de memoria:
| instruction | description |
|---|---|
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 no alineada |
movapd |
copia datos empaquetados de precisión doble desde, o hacia, una ubicación de memoria alineada |
movupd |
copia datos empaquetados de precisión doble desde, o hacia, una ubicación de memoria no alineada |
Todas esas instrucciones se pueden usar para copiar valores entre dos registros xmm.
Si la operación involucra una ubicación de memoria, movaps y movapd requieren que esté alineada.
movaps y movapd generan un fallo si se les da una dirección desalineada.
También sirven como una verificación de seguridad de que la disposición de tus datos es la que crees que es.
Por otro lado, movups y movupd se pueden usar 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 desarrollo activo en el ecosistema x86-64 durante décadas. Cada extensión de la arquitectura trae nuevas instrucciones o nuevos usos para instrucciones antiguas.
En particular, las extensiones AVX y AVX2 introducen algunos cambios relevantes.
Primero, compatibilidad con registros SIMD de 256 bits, llamados ymm.
Extienden los registros xmm correspondientes, de forma parecida a como rax contiene a eax.
Segundo, 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 instrucciones SIMD, sino también las que operan sobre valores de punto flotante escalares.
Esas variantes siguen la misma notación que su equivalente en SSE, con una v agregada al principio:
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
Tercero, se relaja la alineación requerida por los operandos de memoria SIMD.
Solo los movimientos con alineación explícita, como vmovaps, requieren esta alineación.
En este track, nos hemos limitado a las instrucciones y los registros hasta las extensiones SSE anteriores. Esto es suficiente para enseñar los fundamentos y garantiza la compatibilidad con una amplia variedad de CPU.
Sin embargo, el test-runner es totalmente compatible con AVX y AVX2, e incluso con las extensiones más nuevas que forman parte de AVX-512. Puedes usarlas libremente.
Es una buena práctica ser consistente en el uso de las variantes de SIMD. Intenta no mezclar las variantes SSE y AVX para las mismas instrucciones.
Si tu base de código mezcla esas variantes, es una buena práctica usar vzeroupper después de terminar tu trabajo con AVX y antes de usar SSE.
Esto rompe las cadenas de dependencia y evita una costosa pausa cuando la CPU cambia entre el estado AVX y el estado SSE.
Estás escribiendo código para el sistema de datos del piso de una fábrica de autopartes automotrices. Los componentes del motor y del 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 usando instrucciones SIMD, no operaciones escalares.
El piso de la fábrica tiene dos líneas de ensamblaje en paralelo. Al final de cada turno, ambas líneas reportan su rendimiento para 4 contenedores de piezas como números de precisión simple. El tablero del supervisor muestra el rendimiento combinado por contenedor, así que los dos informes se suman contenedor por contenedor. El sistema de informes administra los tres búferes, y estos 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 la suma por contenedor en result.
Los argumentos, en orden, son:
line_a: dirección de memoria alineada a 16 bytes que contiene 4 números de punto flotante de 32 bitsline_b: dirección de memoria alineada a 16 bytes que contiene 4 números de punto flotante de 32 bitsresult: dirección de memoria alineada a 16 bytes donde la función escribirá 4 números de punto flotante de 32 bitsEsta función no devuelve ningún valor; en su lugar, el resultado se almacena en el búfer que se 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 medidor de precisión a un búfer que siempre está alineado a 16 bytes. Los valores objetivo provienen del paquete de la orden de trabajo, que no tiene 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 leerla desde la memoria.
Define una función scaled_deviation que, dados measured y 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 de punto flotante de 64 bitstarget: dirección de memoria que contiene 2 números de punto flotante de 64 bits (sin garantía de alineación)sensitivity: dirección de memoria que contiene 2 números de punto 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 de punto flotante de 64 bits (sin garantía de alineación)La desviación escalada es (measured - target) * sensitivity, carril por carril.
Esta función no devuelve ningún valor; en su lugar, el resultado se almacena en el búfer que se pasa (result) como un par de números de punto 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 a la bahía de inspección final, las lecturas dimensionales de un lote de 4 piezas se reescalan a doble precisión y se corrigen según 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 luego multiplica ese resultado por un factor de conversión constante de 0.5.
Ten en cuenta que la reference y el offset son los mismos para ambas filas del lote.
El factor de conversión es el mismo en cada calibración, 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 de punto flotante de 32 bits (sin garantía de alineación)reference: dirección de memoria alineada a 16 bytes que contiene 2 números de punto flotante de 64 bitsoffset: dirección de memoria alineada a 16 bytes que contiene 2 números de punto flotante de 64 bitsresult: dirección de memoria alineada a 16 bytes donde la función escribirá 4 números de punto flotante de 64 bits (32 bytes en total)La función debe, para cada una de las dos filas del lote:
offset del par convertido.reference entre el resultado.0.5.result.Esta función no devuelve ningún valor; en su lugar, el resultado se almacena en el búfer que se 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 debe normalizarse antes de reportarse. Cada puntuación se multiplica por su propia ganancia por pieza y luego se divide por 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 corrida y se pasa en una dirección de memoria que contiene dos números de punto 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 una cantidad 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 de punto flotante de 64 bits (se modifica in situ)gains: dirección de memoria alineada a 16 bytes que contiene n números de punto flotante de 64 bitsscale: dirección de memoria alineada a 16 bytes que contiene 2 números de punto flotante de 64 bitsn: un entero positivo par de 64 bits (mínimo 2), la cantidad 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), el segundo carril escala el segundo (carriles en posiciones impares).
Cada puntuación se multiplica por su ganancia correspondiente y luego se divide por el carril de escala adecuado: score * gain / scale_lane.
Esta función no devuelve ningún valor; en su lugar, el resultado se almacena 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.