Mesa de mezclas

Mesa de mezclas

Ejercicio de aprendizaje

Introducción

SIMD: números enteros

Un concepto anterior presentó los valores de coma flotante empaquetados: varios números guardados en un único registro xmm, sobre los que se opera carril a carril y en paralelo. Esos mismos registros xmm también pueden contener enteros empaquetados.

Sintaxis

La mayor parte del modelo SIMD de coma flotante se traslada sin cambios a los enteros empaquetados:

  • Un registro de 128 bits se divide en carriles.
  • Las instrucciones actúan en paralelo sobre los carriles que ocupan la misma posición.
  • Los operandos de memoria siguen las mismas reglas de alineación de 16 bytes.

Sin embargo, la sintaxis es un poco distinta:

  1. Un prefijo p que indica que la instrucción opera sobre datos empaquetados.
  2. La operación que se realiza, con el mismo nombre que su equivalente no SIMD (también llamado escalar) (por ejemplo, add, mul, etc.).
  3. Un sufijo que indica el tamaño de cada carril.

Los enteros empaquetados tienen cuatro tamaños de carril, y cada uno tiene su propio sufijo:

ancho de carril bytes carriles en 128 bits sufijo
byte 1 16 b
word 2 8 w
dword 4 4 d
qword 8 2 q

Por ejemplo:

instrucción significado
paddb add empaquetado, carriles de 8 bits (16 carriles)
paddw add empaquetado, carriles de 16 bits (8 carriles)
paddd add empaquetado, carriles de 32 bits (4 carriles)
paddq add empaquetado, carriles de 64 bits (2 carriles)

Algunas instrucciones toman carriles de un tamaño como entrada, pero de otro tamaño como salida. Siguen la misma convención general, pero con dos sufijos de tamaño. El primero indica el tamaño del carril de entrada y el segundo indica el tamaño del carril de salida:

instrucción significado
pmovsxwd movsx empaquetado, de carriles de 16 bits a carriles de 32 bits
pmuldq mul empaquetado, de carriles de 32 bits a carriles de 64 bits

Movimientos de memoria

Dos instrucciones con nombre de número entero copian 128 bits entre un registro xmm y la memoria:

instrucción descripción
movdqa copia enteros empaquetados desde o hacia una ubicación alineada
movdqu copia enteros empaquetados desde o hacia una ubicación no alineada

Se comportan como movaps y movups: movdqa genera un fallo ante una dirección mal alineada, mientras que movdqu acepta cualquiera. Las cuatro copian 128 bits sin interpretarlos. El par con nombre de número entero se usa con datos enteros por convención, no por obligación.

Note

Aquí dq significa double-qword, es decir, 128 bits (16 bytes).

Suma y resta

La suma y la resta siguen la regla de nomenclatura:

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

No hay una forma separada para con signo y sin signo. En complemento a dos, la suma y la resta producen los mismos bits tanto si los carriles se leen como con signo como si se leen sin signo, así que una sola instrucción sirve para ambos casos. La interpretación es cosa tuya, exactamente igual que con las escalares add y sub.

Estas instrucciones dan la vuelta al desbordarse, igual que sus equivalentes escalares. Un carril de 8 bits contiene valores módulo 256, así que un paddb de 200 + 100 produce 300 - 256 = 44, descartando los bits que no caben.

Ten en cuenta que esos bits sobrantes no se trasladan al carril siguiente. Se opera sobre cada carril por separado de los demás, aunque compartan el mismo registro.

Suma y resta con saturación

El SIMD de enteros añade una operación que el SIMD de coma flotante no tiene: la suma y la resta con saturación, que limitan en lugar de dar la vuelta. Un resultado por encima del rango del carril pasa a ser el valor más grande que el carril puede contener; un resultado por debajo del rango pasa a ser el más pequeño.

Las formas con saturación insertan s (con signo) o us (sin signo) antes del sufijo de tamaño:

instrucción significado
paddsb add, con saturación, carriles de 8 bits con signo
paddusb add, con saturación, carriles de 8 bits sin signo
psubsw sub, con saturación, carriles de 16 bits con signo
psubusw sub, con saturación, carriles de 16 bits sin signo

El rango de limitación es el rango representable completo para un número entero del tamaño y la signatura correspondientes. Para un byte:

  • Los bytes sin signo se limitan a [0, 255]: un paddusb de 200 + 100 da 255, y un psubusb de 5 - 10 da 0.
  • Los bytes con signo se limitan a [-128, 127]: un paddsb de 100 + 50 da 127.

La saturación importa cuando un carril contiene una cantidad acotada, como un canal de píxel o una muestra de audio. Dar la vuelta convertiría un píxel demasiado brillante en uno oscuro, mientras que limitarlo lo mantiene en su brillo máximo, que es el resultado que quieres.

Note

La suma y la resta con saturación solo existen para carriles de byte y de word, no para dword ni qword.

Multiplicación

Multiplicar dos valores de N bits puede producir un producto de 2N bits, pero el carril de destino solo tiene N bits de ancho. Las operaciones SIMD resuelven esto especificando qué mitad del producto conservar, ya sea los N bits bajos o los N bits altos.

Para los carriles de 16 bits se usan tres instrucciones:

instrucción significado
pmullw mul, carriles de 16 bits, conserva los 16 bits bajos de cada producto
pmulhw mul, carriles de 16 bits, conserva los 16 bits altos, operandos con signo
pmulhuw mul, carriles de 16 bits, conserva los 16 bits altos, operandos sin signo

Los 16 bits bajos de un producto son los mismos tanto si los operandos se leen como con signo como si se leen sin signo, así que hay un único pmullw.

Los 16 bits altos, sin embargo, varían según la signatura del resultado. Por eso la multiplicación de la mitad alta tiene formas separadas para la multiplicación con signo y sin signo:

  1. pmulhw, para la multiplicación con signo.
  2. pmulhuw, con una u adicional, para la multiplicación sin signo.

Fíjate en la sintaxis:

  1. Una p, de entero empaquetado.
  2. La operación que se realiza, mul.
  3. Una h, para indicar que se seleccionan los bits altos ("high") del resultado.
  4. Una u opcional si el resultado debe interpretarse como sin signo (es decir, no se extiende con signo).
  5. Por último, el sufijo de tamaño w, para indicar que es una operación de word (16 bits).

La multiplicación empaquetada para words sigue la regla anterior exactamente. La multiplicación de dword (32 bits) también sigue la regla, pero solo existe la variante que selecciona la mitad baja: pmulld.

No hay una variante para la multiplicación de dword que seleccione los bits altos. Sin embargo, hay variantes que ensanchan la multiplicación, guardando el producto completo de 64 bits de los carriles con índice par (es decir, los carriles en las posiciones 0 y 2):

instrucción significado
pmuludq multiplica los elementos de 32 bits con índice par, sin signo, en 2 productos completos de 64 bits
pmuldq multiplica los elementos de 32 bits con índice par, con signo, en 2 productos completos de 64 bits

Fíjate en que la sintaxis usa dq, posiblemente con una u delante para la multiplicación sin signo. Esto se debe a que las instrucciones toman carriles dword y producen carriles qword.

División

No hay división de enteros empaquetados. El código que la necesita convierte los valores a coma flotante, divide y vuelve a convertir.

Ensanchamiento de enteros

Hay equivalentes empaquetados para movsx y movzx. Siguen la misma sintaxis que mencionamos para las instrucciones que toman entradas con un tamaño de carril distinto del de su salida:

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

Fíjate en que el número de carriles lo fija el ancho mayor (el de salida). La instrucción lee esa cantidad de carriles de la parte baja de la fuente. Esta es la misma semántica que ya hemos visto para las instrucciones cvt empaquetadas entre números de coma flotante de precisión simple y doble.

Conversión entre enteros y números de coma flotante

Hay instrucciones para convertir entre números enteros de 32 bits con signo y carriles de coma flotante. Siguen la sintaxis habitual de las instrucciones cvt y cvtt, pero en lugar de si un entero empaquetado se representa con 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

Las instrucciones que usan pi para representar enteros empaquetados escriben en los registros mmx heredados y están efectivamente obsoletas en x86-64. Prefiere las formas con dq, que usan registros xmm.

Los mismos comentarios que hicimos para la conversión escalar entre números de coma flotante y enteros son válidos aquí. Los números de coma flotante se redondean según un registro especial llamado MXCSR, cuyo modo no puedes dar por supuesto al entrar en una función. Hay una variante cvtt (con una t adicional) que siempre trunca el resultado.

También es posible usar round para tener los valores de coma flotante empaquetados en un estado conocido antes de convertir. El valor de control de redondeo es el mismo que el de la round escalar y la sintaxis también es la misma para los valores de coma flotante empaquetados:

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

Instrucciones

Estás escribiendo el núcleo digital de una pequeña mesa de mezclas en un estudio de grabación.

El sonido llega como un flujo de muestras. Cada muestra es un número que describe la posición del cono de un altavoz en un instante. Una mesa procesa un bloque completo de muestras a la vez, aplicando la misma operación a cada una.

Tienes cinco tareas, y cada una opera sobre un bloque de muestras que está en memoria. Recibes los operandos a través de direcciones de memoria y escribes tu respuesta en una dirección de resultado.

Note

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

1. Mezclar dos pistas

A veces, el estudio graba los instrumentos en pistas diferentes. Para reproducirlos por los mismos altavoces, sumas las dos pistas en una sola.

Implementa la función mix_tracks, que suma dos pistas, muestra a muestra. Cada pista es un bloque de 8 muestras de 16 bits con signo. Una suma que desborde el rango de 16 bits con signo debe saturarse en lugar de dar la vuelta.

Esta función recibe como argumentos, en este orden:

  • result: dirección de memoria alineada a 16 bytes de un búfer donde se escriben las 8 muestras mezcladas.
  • track_a: dirección de memoria alineada a 16 bytes de la primera pista, con 8 muestras de 16 bits con signo.
  • track_b: dirección de memoria alineada a 16 bytes de la segunda pista, con 8 muestras de 16 bits con signo.
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}

Esta función no devuelve ningún valor.

2. Eliminar el sangrado

Cuando dos micrófonos graban a la vez, cada uno capta una copia tenue de la fuente del otro. Esta copia no deseada se llama sangrado. Una vez medido este sangrado, hay que restarlo para limpiar la pista.

Implementa la función remove_bleed, que resta el bloque de sangrado del bloque de la pista, muestra a muestra. Cada bloque tiene 8 muestras de 16 bits con signo, y la resta debe saturarse.

Esta función recibe como argumentos, en este orden:

  • result: dirección de memoria donde se escriben las 8 muestras limpias (no se garantiza la alineación).
  • track: dirección de memoria alineada a 16 bytes de la pista grabada, 8 muestras de 16 bits con signo.
  • bleed: dirección de memoria del sangrado que hay que eliminar, 8 muestras de 16 bits con signo (no se garantiza la alineación).
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}

Esta función no devuelve ningún valor.

3. Combinar los medidores

Sobre cada canal hay un medidor de nivel, una pequeña barra cuyo brillo indica lo fuerte que suena ese canal. El panel frontal tiene un medidor maestro que debe brillar tanto como los dos canales combinados. Sumas los dos bloques de medidores para hacerlo funcionar.

Ten en cuenta que un medidor brillante se queda al máximo brillo en lugar de volver a apagarse, así que la suma es saturante.

Implementa la función combine_meters, que suma dos bloques de medidores muestra a muestra. Esta función recibe como argumentos, en este orden:

  • result: dirección de memoria alineada a 16 bytes donde se escriben los 16 valores combinados.
  • meter_a: dirección de memoria alineada a 16 bytes del bloque de medidores del primer canal, con 16 valores de 8 bits sin signo empaquetados.
  • meter_b: dirección de memoria alineada a 16 bytes del bloque de medidores del segundo canal, con 16 valores de 8 bits sin signo empaquetados.

Cada bloque tiene 16 valores de 8 bits sin signo. Hay que sumarlos, saturando al rango sin signo.

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}

Esta función no devuelve ningún valor.

4. Aplicar un fundido

La canción no debe parar de golpe, sino que debe hacer un fundido de salida al final. Tienes un bloque de coeficientes de ganancia, uno por muestra, que van disminuyendo cada vez más.

Cada coeficiente es una fracción expresada sobre 65536. Por ejemplo, si una muestra tiene un valor de 16384, en realidad expresa 16384 / 65536, que es 1 / 4. El valor más grande que se puede usar, 32767, es justo un poco menos de la mitad.

Como 65536 es 2^16, multiplicar una muestra por su coeficiente y quedarse con la mitad alta del producto da sample * gain / 65536.

Implementa la función apply_fade, que escala cada muestra según su coeficiente. Esta función recibe como argumentos, en este orden:

  • result: dirección de memoria alineada a 16 bytes donde se escriben todas las muestras escaladas.
  • track: dirección de memoria alineada a 16 bytes de la pista, con n muestras de 16 bits con signo.
  • gains: dirección de memoria alineada a 16 bytes de los coeficientes de ganancia, con n valores de 16 bits con signo, cada uno en el rango de 0 a 32767.
  • n: el número de muestras, que nunca es cero y siempre es múltiplo de 8, como un entero de 64 bits sin signo.

Ten en cuenta que la pista es un array que contiene n muestras de 16 bits con signo, donde n nunca es cero y siempre es múltiplo de 8. Para cada muestra, el resultado escalado son los 16 bits altos del producto con signo: 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

Quedarse con la mitad alta redondea hacia menos infinito. Por eso 20000 * 32767 se escala a 9999, mientras que -20000 * 32767 se escala a -10000 en lugar de a -9999.

Esta función no devuelve ningún valor.

5. Atenuar una pista

El batería se grabó demasiado alto. Para bajar toda la pista, divides cada muestra entre una constante.

Implementa la función attenuate_track. Esta función recibe como argumentos, en este orden:

  • result: dirección de memoria alineada a 16 bytes donde se escriben los resultados, como enteros de 32 bits con signo.
  • samples: dirección de memoria alineada a 16 bytes de las muestras, n valores de 16 bits con signo.
  • divisor: dirección de memoria alineada a 16 bytes de 4 copias idénticas del divisor, como enteros de 32 bits.
  • n: el número de muestras, que nunca es cero y siempre es múltiplo de 4, como un entero de 64 bits sin signo.

Ten en cuenta que samples es un array que contiene n muestras de 16 bits con signo, donde n nunca es cero y siempre es múltiplo de 4.

El divisor se proporciona como 4 copias del mismo entero de 32 bits, ya replicadas para que pueda llenar los cuatro carriles a la vez.

El SIMD de enteros no tiene división empaquetada, así que el trabajo se hace en coma flotante empaquetada y luego se convierte de nuevo a enteros de 32 bits empaquetados para guardarlos en result. Convierte de vuelta truncando hacia cero, de modo que un cociente de 2.3 se convierte en 2 y -2.3 en -2.

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

Esta función no devuelve ningún valor.

Editar en GitHub El enlace se abre en una ventana o pestaña nueva
x86-64 Assembly Exercism

¿Listo para empezar Mesa de mezclas?

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