c gcc assembly inline-assembly

Bucle sobre matrices con ensamblaje en línea



gcc assembly (3)

Al hacer un bucle sobre una matriz con ensamblaje en línea, ¿debo usar el modificador de registro "r" o el modificador de memoria "m"?

Consideremos un ejemplo que agrega dos matrices flotantes x , e y y escribe los resultados en z . Normalmente usaría intrínsecos para hacer esto así

for(int i=0; i<n/4; i++) { __m128 x4 = _mm_load_ps(&x[4*i]); __m128 y4 = _mm_load_ps(&y[4*i]); __m128 s = _mm_add_ps(x4,y4); _mm_store_ps(&z[4*i], s); }

Aquí está la solución de ensamblaje en línea que se me ocurrió usando el modificador de registro "r"

void add_asm1(float *x, float *y, float *z, unsigned n) { for(int i=0; i<n; i+=4) { __asm__ __volatile__ ( "movaps (%1,%%rax,4), %%xmm0/n" "addps (%2,%%rax,4), %%xmm0/n" "movaps %%xmm0, (%0,%%rax,4)/n" : : "r" (z), "r" (y), "r" (x), "a" (i) : ); } }

Esto genera un ensamblaje similar a GCC. La principal diferencia es que GCC agrega 16 al registro de índice y usa una escala de 1, mientras que la solución de ensamblaje en línea agrega 4 al registro de índice y usa una escala de 4.

No pude usar un registro general para el iterador. Tuve que especificar uno que en este caso era rax . ¿Hay alguna razón para esto?

Aquí está la solución que se me ocurrió usando el modificador de memoria "m"

void add_asm2(float *x, float *y, float *z, unsigned n) { for(int i=0; i<n; i+=4) { __asm__ __volatile__ ( "movaps %1, %%xmm0/n" "addps %2, %%xmm0/n" "movaps %%xmm0, %0/n" : "=m" (z[i]) : "m" (y[i]), "m" (x[i]) : ); } }

Esto es menos eficiente ya que no utiliza un registro de índice y en su lugar tiene que agregar 16 al registro base de cada matriz. El ensamblado generado es (gcc (Ubuntu 5.2.1-22ubuntu2) con gcc -O3 -S asmtest.c ):

.L22 movaps (%rsi), %xmm0 addps (%rdi), %xmm0 movaps %xmm0, (%rdx) addl $4, %eax addq $16, %rdx addq $16, %rsi addq $16, %rdi cmpl %eax, %ecx ja .L22

¿Existe una solución mejor usando el modificador de memoria "m"? ¿Hay alguna forma de hacer que use un registro de índice? La razón por la que pregunté es que me pareció más lógico usar el modificador de memoria "m" ya que estoy leyendo y escribiendo memoria. Además, con el modificador de registro "r", nunca utilizo una lista de operandos de salida que me pareció extraño al principio.

¿Quizás haya una solución mejor que usar "r" o "m"?

Aquí está el código completo que usé para probar esto

#include <stdio.h> #include <x86intrin.h> #define N 64 void add_intrin(float *x, float *y, float *z, unsigned n) { for(int i=0; i<n; i+=4) { __m128 x4 = _mm_load_ps(&x[i]); __m128 y4 = _mm_load_ps(&y[i]); __m128 s = _mm_add_ps(x4,y4); _mm_store_ps(&z[i], s); } } void add_intrin2(float *x, float *y, float *z, unsigned n) { for(int i=0; i<n/4; i++) { __m128 x4 = _mm_load_ps(&x[4*i]); __m128 y4 = _mm_load_ps(&y[4*i]); __m128 s = _mm_add_ps(x4,y4); _mm_store_ps(&z[4*i], s); } } void add_asm1(float *x, float *y, float *z, unsigned n) { for(int i=0; i<n; i+=4) { __asm__ __volatile__ ( "movaps (%1,%%rax,4), %%xmm0/n" "addps (%2,%%rax,4), %%xmm0/n" "movaps %%xmm0, (%0,%%rax,4)/n" : : "r" (z), "r" (y), "r" (x), "a" (i) : ); } } void add_asm2(float *x, float *y, float *z, unsigned n) { for(int i=0; i<n; i+=4) { __asm__ __volatile__ ( "movaps %1, %%xmm0/n" "addps %2, %%xmm0/n" "movaps %%xmm0, %0/n" : "=m" (z[i]) : "m" (y[i]), "m" (x[i]) : ); } } int main(void) { float x[N], y[N], z1[N], z2[N], z3[N]; for(int i=0; i<N; i++) x[i] = 1.0f, y[i] = 2.0f; add_intrin2(x,y,z1,N); add_asm1(x,y,z2,N); add_asm2(x,y,z3,N); for(int i=0; i<N; i++) printf("%.0f ", z1[i]); puts(""); for(int i=0; i<N; i++) printf("%.0f ", z2[i]); puts(""); for(int i=0; i<N; i++) printf("%.0f ", z3[i]); puts(""); }


Cuando compilo su código add_asm2 con gcc (4.9.2) obtengo:

add_asm2: .LFB0: .cfi_startproc xorl %eax, %eax xorl %r8d, %r8d testl %ecx, %ecx je .L1 .p2align 4,,10 .p2align 3 .L5: #APP # 3 "add_asm2.c" 1 movaps (%rsi,%rax), %xmm0 addps (%rdi,%rax), %xmm0 movaps %xmm0, (%rdx,%rax) # 0 "" 2 #NO_APP addl $4, %r8d addq $16, %rax cmpl %r8d, %ecx ja .L5 .L1: rep; ret .cfi_endproc

así que no es perfecto (usa un registro redundante), pero usa cargas indexadas ...


Evite el asm en línea siempre que sea posible: https://gcc.gnu.org/wiki/DontUseInlineAsm . Bloquea muchas optimizaciones. Pero si realmente no puede sostener manualmente el compilador para hacer el asm que desea, probablemente debería escribir todo su bucle en asm para que pueda desenrollarlo y ajustarlo manualmente, en lugar de hacer cosas como esta.

Puede usar una restricción r para el índice. Use el modificador q para obtener el nombre del registro de 64 bits, para que pueda usarlo en un modo de direccionamiento. Cuando se compila para objetivos de 32 bits, el modificador q selecciona el nombre del registro de 32 bits, por lo que el mismo código aún funciona.

Si desea elegir qué tipo de modo de direccionamiento se utiliza, deberá hacerlo usted mismo, utilizando operandos de puntero con restricciones r .

La sintaxis asm en línea de GNU C no asume que usted lee o escribe memoria apuntada por operandos de puntero. (por ejemplo, tal vez esté utilizando un asm en línea and en el valor del puntero). Por lo tanto, debe hacer algo con un clobber de "memory" o con operandos de entrada / salida de memoria para hacerle saber qué memoria modifica. Un clobber de "memory" es fácil, pero obliga a derramar / recargar todo, excepto los locales. Consulte la Clobbers para ver un ejemplo del uso de un operando de entrada ficticio.

Específicamente, una "m" (*(const float (*)[]) fptr) le dirá al compilador que todo el objeto de matriz es una entrada, longitud arbitraria . es decir, el asm no puede reordenarse con ninguna tienda que use fptr como parte de la dirección (o que use la matriz a la que se sabe que apunta). También funciona con una restricción "=m" o "+m" (sin la const , obviamente).

El uso de un tamaño específico como "m" (*(const float (*)[4]) fptr) permite decirle al compilador lo que hace / no lee. (O escribe). Luego puede (si se permite lo contrario) hundir una tienda en un elemento posterior más allá de la declaración asm , y combinarlo con otra tienda (o eliminar la tienda muerta) de cualquier tienda que su asm en línea no lea.

Otro gran beneficio de una restricción m es que -funroll-loops puede funcionar generando direcciones con desplazamientos constantes. Hacer el direccionamiento nosotros mismos evita que el compilador haga un solo incremento cada 4 iteraciones o algo así, porque cada valor de nivel de fuente de i debe aparecer en un registro.

Aquí está mi versión, con algunos ajustes como se señala en los comentarios.

#include <immintrin.h> void add_asm1_memclobber(float *x, float *y, float *z, unsigned n) { __m128 vectmp; // let the compiler choose a scratch register for(int i=0; i<n; i+=4) { __asm__ __volatile__ ( "movaps (%[y],%q[idx],4), %[vectmp]/n/t" // q modifier: 64bit version of a GP reg "addps (%[x],%q[idx],4), %[vectmp]/n/t" "movaps %[vectmp], (%[z],%q[idx],4)/n/t" : [vectmp] "=x" (vectmp) // "=m" (z[i]) // gives worse code if the compiler prepares a reg we don''t use : [z] "r" (z), [y] "r" (y), [x] "r" (x), [idx] "r" (i) // unrolling is impossible this way (without an insn for every increment by 4) : "memory" // you can avoid a "memory" clobber with dummy input/output operands ); } }

Salida del asm del explorador del compilador Godbolt para esta y un par de versiones a continuación.

Su versión debe declarar %xmm0 como %xmm0 , o lo pasará mal cuando esté en línea. Mi versión usa una variable temporal como un operando de solo salida que nunca se usa. Esto le da al compilador total libertad para la asignación de registros.

Si desea evitar el control de "memoria", puede utilizar operandos de entrada / salida de memoria ficticia como "m" (*(const __m128*)&x[i]) para indicarle al compilador qué memoria lee y escribe su función. Esto es necesario para garantizar la generación correcta de código si hizo algo como x[4] = 1.0; justo antes de ejecutar ese bucle. (E incluso si no escribió algo así de simple, la propagación constante y en línea puede reducirlo a eso). Y también para asegurarse de que el compilador no lea de z[] antes de que se ejecute el bucle.

En este caso, obtenemos resultados horribles: gcc5.x en realidad incrementa 3 punteros adicionales porque decide usar modos de direccionamiento [reg] en lugar de indexados. ¡No sabe que el asm en línea nunca hace referencia a esos operandos de memoria usando el modo de direccionamiento creado por la restricción!

# gcc5.4 with dummy constraints like "=m" (*(__m128*)&z[i]) instead of "memory" clobber .L11: movaps (%rsi,%rax,4), %xmm0 # y, i, vectmp addps (%rdi,%rax,4), %xmm0 # x, i, vectmp movaps %xmm0, (%rdx,%rax,4) # vectmp, z, i addl $4, %eax #, i addq $16, %r10 #, ivtmp.19 addq $16, %r9 #, ivtmp.21 addq $16, %r8 #, ivtmp.22 cmpl %eax, %ecx # i, n ja .L11 #,

r8, r9 y r10 son los punteros adicionales que el bloque asm en línea no usa.

Puede usar una restricción que le dice a gcc que una matriz completa de longitud arbitraria es una entrada o una salida: "m" (*(const struct {char a; char x[];} *) pStr) de la respuesta de @David Wohlferd en un asm strlen . Dado que queremos usar modos de direccionamiento indexados, tendremos la dirección base de las tres matrices en los registros, y esta forma de restricción solicita la dirección base como un operando, en lugar de un puntero a la memoria actual que se está operando.

Esto realmente funciona sin incrementos de contador adicionales dentro del bucle:

void add_asm1_dummy_whole_array(const float *restrict x, const float *restrict y, float *restrict z, unsigned n) { __m128 vectmp; // let the compiler choose a scratch register for(int i=0; i<n; i+=4) { __asm__ __volatile__ ( "movaps (%[y],%q[idx],4), %[vectmp]/n/t" // q modifier: 64bit version of a GP reg "addps (%[x],%q[idx],4), %[vectmp]/n/t" "movaps %[vectmp], (%[z],%q[idx],4)/n/t" : [vectmp] "=x" (vectmp) // "=m" (z[i]) // gives worse code if the compiler prepares a reg we don''t use , "=m" (*(struct {float a; float x[];} *) z) : [z] "r" (z), [y] "r" (y), [x] "r" (x), [idx] "r" (i) // unrolling is impossible this way (without an insn for every increment by 4) , "m" (*(const struct {float a; float x[];} *) x), "m" (*(const struct {float a; float x[];} *) y) ); } }

Esto nos da el mismo bucle interno que obtuvimos con un clobber de "memory" :

.L19: # with clobbers like "m" (*(const struct {float a; float x[];} *) y) movaps (%rsi,%rax,4), %xmm0 # y, i, vectmp addps (%rdi,%rax,4), %xmm0 # x, i, vectmp movaps %xmm0, (%rdx,%rax,4) # vectmp, z, i addl $4, %eax #, i cmpl %eax, %ecx # i, n ja .L19 #,

Le dice al compilador que cada bloque asm lee o escribe las matrices completas, por lo que puede evitar innecesariamente que se entrelacen con otro código (por ejemplo, después de desenrollar completamente con un recuento de iteraciones bajo). No deja de desenrollarse, pero el requisito de tener cada valor de índice en un registro lo hace menos efectivo.

Una versión con restricciones m , que gcc puede desenrollar :

#include <immintrin.h> void add_asm1(float *x, float *y, float *z, unsigned n) { __m128 vectmp; // let the compiler choose a scratch register for(int i=0; i<n; i+=4) { __asm__ __volatile__ ( // "movaps %[yi], %[vectmp]/n/t" "addps %[xi], %[vectmp]/n/t" // We requested that the %[yi] input be in the same register as the [vectmp] dummy output "movaps %[vectmp], %[zi]/n/t" // ugly ugly type-punning casts; __m128 is a may_alias type so it''s safe. : [vectmp] "=x" (vectmp), [zi] "=m" (*(__m128*)&z[i]) : [yi] "0" (*(__m128*)&y[i]) // or [yi] "xm" (*(__m128*)&y[i]), and uncomment the movaps load , [xi] "xm" (*(__m128*)&x[i]) : // memory clobber not needed ); } }

Usar [yi] como un operando de entrada / salida +x sería más simple, pero escribirlo de esta manera hace un cambio menor para descomentar la carga en el asm en línea, en lugar de permitir que el compilador obtenga un valor en los registros para nosotros.


gcc también tiene extensiones vectoriales incorporadas que son incluso multiplataforma:

typedef float v4sf __attribute__((vector_size(16))); void add_vector(float *x, float *y, float *z, unsigned n) { for(int i=0; i<n/4; i+=1) { *(v4sf*)(z + 4*i) = *(v4sf*)(x + 4*i) + *(v4sf*)(y + 4*i); } }

En mi gcc versión 4.7.2, el ensamblado generado es:

.L28: movaps (%rdi,%rax), %xmm0 addps (%rsi,%rax), %xmm0 movaps %xmm0, (%rdx,%rax) addq $16, %rax cmpq %rcx, %rax jne .L28