Tracks
/
x86-64 Assembly
x86-64 Assembly
/
Ejercicios
/
Línea de producción
Línea de producción

Línea de producción

Ejercicio de aprendizaje

Introducción

SIMD

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.

Instrucciones SIMD de punto flotante

Como regla general, las instrucciones SIMD que operan sobre valores de punto flotante tienen una forma de dos operandos. Su nombre sigue cierta estructura:

  1. La operación que se realiza, con el mismo nombre que su contraparte no SIMD (también llamada escalar) (por ejemplo, add, mul, etc.).
  2. Un sufijo p para indicar que la instrucción opera sobre datos empaquetados, en la misma posición que antes ocupaba una s (de datos escalares).
  3. Un sufijo para indicar el tamaño de cada carril, con la misma semántica que su contraparte no SIMD (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
Aritmética

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)
Conversión entre precisiones

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)
Operandos de memoria

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.

Carga y almacenamiento de valores

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.

Extensiones de SIMD

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.

Note

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.

Instrucciones

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.

Note

Los cálculos de este ejercicio deben realizarse usando instrucciones SIMD, no operaciones escalares.

1. Combina los rendimientos de dos líneas

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:

  1. line_a: dirección de memoria alineada a 16 bytes que contiene 4 números de punto flotante de 32 bits
  2. line_b: dirección de memoria alineada a 16 bytes que contiene 4 números de punto flotante de 32 bits
  3. result: dirección de memoria alineada a 16 bytes donde la función escribirá 4 números de punto flotante de 32 bits

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

2. Calcula la desviación escalada

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:

  1. measured: dirección de memoria alineada a 16 bytes que contiene 2 números de punto flotante de 64 bits
  2. target: dirección de memoria que contiene 2 números de punto flotante de 64 bits (sin garantía de alineación)
  3. sensitivity: dirección de memoria que contiene 2 números de punto flotante de 64 bits (sin garantía de alineación)
  4. 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};

3. Calibra un lote

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:

  1. raw: dirección de memoria que contiene 4 números de punto flotante de 32 bits (sin garantía de alineación)
  2. reference: dirección de memoria alineada a 16 bytes que contiene 2 números de punto flotante de 64 bits
  3. offset: dirección de memoria alineada a 16 bytes que contiene 2 números de punto flotante de 64 bits
  4. result: 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:

  1. Convertir los 2 valores sin procesar de precisión simple de la fila a doble precisión.
  2. Restar el offset del par convertido.
  3. Dividir la reference entre el resultado.
  4. Multiplicar carril por carril cada número de punto flotante de 64 bits por el factor de conversión fijo de 0.5.
  5. Almacenar los 2 números de punto flotante de 64 bits en los 16 bytes correspondientes de 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};

4. Normaliza las puntuaciones a lo largo de una línea

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:

  1. scores: dirección de memoria alineada a 16 bytes que contiene n números de punto flotante de 64 bits (se modifica in situ)
  2. gains: dirección de memoria alineada a 16 bytes que contiene n números de punto flotante de 64 bits
  3. scale: dirección de memoria alineada a 16 bytes que contiene 2 números de punto flotante de 64 bits
  4. n: un entero positivo par de 64 bits (mínimo 2), la cantidad de puntuaciones que hay que normalizar

Los 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};
Editar en GitHub El enlace se abre en una ventana o una pestaña nuevas
x86-64 Assembly Exercism

¿Todo listo para empezar Línea de producción?

Regístrate en Exercism para aprender y dominar x86-64 Assembly con 22 conceptos130 ejercicios y mentoría humana real, todo gratis.