¿Por qué agregar comentarios de ensamblado en línea causa un cambio tan radical en el código generado por GCC?

82

Entonces, tenía este código:

constexpr unsigned N = 1000;
void f1(char* sum, char* a, char* b) {
    for(int i = 0; i < N; ++i) {
        sum[i] = a[i] + b[i];
    }
}

void f2(char* sum, char* a, char* b) {
    char* end = sum + N;
    while(sum != end) {
        *sum++ = *a++ + *b++;
    }
}

Quería ver el código que generaría GCC 4.7.2. Así que ejecuté g++ -march=native -O3 -masm=intel -S a.c++ -std=c++11y obtuve el siguiente resultado:

        .file   "a.c++"
        .intel_syntax noprefix
        .text
        .p2align 4,,15
        .globl  _Z2f1PcS_S_
        .type   _Z2f1PcS_S_, @function
_Z2f1PcS_S_:
.LFB0:
        .cfi_startproc
        lea     rcx, [rdx+16]
        lea     rax, [rdi+16]
        cmp     rdi, rcx
        setae   r8b
        cmp     rdx, rax
        setae   cl
        or      cl, r8b
        je      .L5
        lea     rcx, [rsi+16]
        cmp     rdi, rcx
        setae   cl
        cmp     rsi, rax
        setae   al
        or      cl, al
        je      .L5
        xor     eax, eax
        .p2align 4,,10
        .p2align 3
.L3:
        movdqu  xmm0, XMMWORD PTR [rdx+rax]
        movdqu  xmm1, XMMWORD PTR [rsi+rax]
        paddb   xmm0, xmm1
        movdqu  XMMWORD PTR [rdi+rax], xmm0
        add     rax, 16
        cmp     rax, 992
        jne     .L3
        mov     ax, 8
        mov     r9d, 992
.L2:
        sub     eax, 1
        lea     rcx, [rdx+r9]
        add     rdi, r9
        lea     r8, [rax+1]
        add     rsi, r9
        xor     eax, eax
        .p2align 4,,10
        .p2align 3
.L4:
        movzx   edx, BYTE PTR [rcx+rax]
        add     dl, BYTE PTR [rsi+rax]
        mov     BYTE PTR [rdi+rax], dl
        add     rax, 1
        cmp     rax, r8
        jne     .L4
        rep
        ret
.L5:
        mov     eax, 1000
        xor     r9d, r9d
        jmp     .L2
        .cfi_endproc
.LFE0:
        .size   _Z2f1PcS_S_, .-_Z2f1PcS_S_
        .p2align 4,,15
        .globl  _Z2f2PcS_S_
        .type   _Z2f2PcS_S_, @function
_Z2f2PcS_S_:
.LFB1:
        .cfi_startproc
        lea     rcx, [rdx+16]
        lea     rax, [rdi+16]
        cmp     rdi, rcx
        setae   r8b
        cmp     rdx, rax
        setae   cl
        or      cl, r8b
        je      .L19
        lea     rcx, [rsi+16]
        cmp     rdi, rcx
        setae   cl
        cmp     rsi, rax
        setae   al
        or      cl, al
        je      .L19
        xor     eax, eax
        .p2align 4,,10
        .p2align 3
.L17:
        movdqu  xmm0, XMMWORD PTR [rdx+rax]
        movdqu  xmm1, XMMWORD PTR [rsi+rax]
        paddb   xmm0, xmm1
        movdqu  XMMWORD PTR [rdi+rax], xmm0
        add     rax, 16
        cmp     rax, 992
        jne     .L17
        add     rdi, 992
        add     rsi, 992
        add     rdx, 992
        mov     r8d, 8
.L16:
        xor     eax, eax
        .p2align 4,,10
        .p2align 3
.L18:
        movzx   ecx, BYTE PTR [rdx+rax]
        add     cl, BYTE PTR [rsi+rax]
        mov     BYTE PTR [rdi+rax], cl
        add     rax, 1
        cmp     rax, r8
        jne     .L18
        rep
        ret
.L19:
        mov     r8d, 1000
        jmp     .L16
        .cfi_endproc
.LFE1:
        .size   _Z2f2PcS_S_, .-_Z2f2PcS_S_
        .ident  "GCC: (GNU) 4.7.2"
        .section        .note.GNU-stack,"",@progbits

Apesto en la lectura del ensamblaje, así que decidí agregar algunos marcadores para saber dónde fueron los cuerpos de los bucles:

constexpr unsigned N = 1000;
void f1(char* sum, char* a, char* b) {
    for(int i = 0; i < N; ++i) {
        asm("# im in ur loop");
        sum[i] = a[i] + b[i];
    }
}

void f2(char* sum, char* a, char* b) {
    char* end = sum + N;
    while(sum != end) {
        asm("# im in ur loop");
        *sum++ = *a++ + *b++;
    }
}

Y GCC escupió esto:

    .file   "a.c++"
    .intel_syntax noprefix
    .text
    .p2align 4,,15
    .globl  _Z2f1PcS_S_
    .type   _Z2f1PcS_S_, @function
_Z2f1PcS_S_:
.LFB0:
    .cfi_startproc
    xor eax, eax
    .p2align 4,,10
    .p2align 3
.L2:
#APP
# 4 "a.c++" 1
    # im in ur loop
# 0 "" 2
#NO_APP
    movzx   ecx, BYTE PTR [rdx+rax]
    add cl, BYTE PTR [rsi+rax]
    mov BYTE PTR [rdi+rax], cl
    add rax, 1
    cmp rax, 1000
    jne .L2
    rep
    ret
    .cfi_endproc
.LFE0:
    .size   _Z2f1PcS_S_, .-_Z2f1PcS_S_
    .p2align 4,,15
    .globl  _Z2f2PcS_S_
    .type   _Z2f2PcS_S_, @function
_Z2f2PcS_S_:
.LFB1:
    .cfi_startproc
    xor eax, eax
    .p2align 4,,10
    .p2align 3
.L6:
#APP
# 12 "a.c++" 1
    # im in ur loop
# 0 "" 2
#NO_APP
    movzx   ecx, BYTE PTR [rdx+rax]
    add cl, BYTE PTR [rsi+rax]
    mov BYTE PTR [rdi+rax], cl
    add rax, 1
    cmp rax, 1000
    jne .L6
    rep
    ret
    .cfi_endproc
.LFE1:
    .size   _Z2f2PcS_S_, .-_Z2f2PcS_S_
    .ident  "GCC: (GNU) 4.7.2"
    .section    .note.GNU-stack,"",@progbits

Esto es considerablemente más corto y tiene algunas diferencias significativas como la falta de instrucciones SIMD. Esperaba el mismo resultado, con algunos comentarios en algún punto intermedio. ¿Estoy haciendo alguna suposición incorrecta aquí? ¿El optimizador de GCC está obstaculizado por los comentarios de ASM?

R. Martinho Fernandes
fuente
28
Esperaría que GCC (y la mayoría de los compiladores) traten la construcción ASM como cajas de bloques. Entonces no pueden razonar sobre lo que sucede a través de una caja así. Y eso inhibe muchas optimizaciones, especialmente aquellas que se llevan a cabo a través de los límites del ciclo.
Ira Baxter
10
Pruebe la asmforma extendida con salida vacía y listas de golpes.
Kerrek SB
4
@ R.MartinhoFernandes: asm("# im in ur loop" : : );(ver documentación )
Mike Seymour
16
Tenga en cuenta que puede obtener un poco más de ayuda al mirar el ensamblado generado agregando la -fverbose-asmbandera, que agrega algunas anotaciones para ayudar a identificar cómo se mueven las cosas entre los registros.
Matthew Slattery
1
Muy interesante. ¿Puede utilizarse para evitar selectivamente la optimización en bucles?
SChepurin

Respuestas:

62

Las interacciones con las optimizaciones se explican aproximadamente en la mitad de la página "Instrucciones de ensamblador con operandos de expresión C" en la documentación.

GCC no intenta entender nada del ensamblado real dentro del asm; lo único que sabe sobre el contenido es lo que usted (opcionalmente) le dice en la especificación del operando de entrada y salida y en la lista de registro de golpes.

En particular, tenga en cuenta:

Una asminstrucción sin operandos de salida se tratará de manera idéntica a una asminstrucción volátil .

y

La volatilepalabra clave indica que la instrucción tiene efectos secundarios importantes [...]

Entonces, la presencia del asminterior de su bucle ha inhibido una optimización de vectorización, porque GCC asume que tiene efectos secundarios.

Matthew Slattery
fuente
1
Tenga en cuenta que los efectos secundarios de una instrucción Basic Asm no deben incluir la modificación de registros o cualquier memoria que su código C ++ alguna vez lea / escriba. Pero sí, la asmdeclaración tiene que ejecutarse una vez por cada vez que lo haría en la máquina abstracta de C ++, y GCC elige no vectorizar y luego emitir el asm 16 veces seguidas por paddb. Sin embargo, creo que eso sería legal, porque los accesos de caracteres no lo son volatile. (A diferencia de una declaración de asm extendida con una "memory"paliza)
Peter Cordes
1
Consulte gcc.gnu.org/wiki/ConvertBasicAsmToExtended para conocer las razones para no usar declaraciones GNU C Basic Asm en general. Aunque este caso de uso (solo un marcador de comentario) es uno de los pocos en los que no es descabellado probarlo.
Peter Cordes
23

Tenga en cuenta que gcc vectorizó el código, dividiendo el cuerpo del bucle en dos partes, la primera procesando 16 elementos a la vez y la segunda haciendo el resto más tarde.

Como comentó Ira, el compilador no analiza el bloque asm, por lo que no sabe que es solo un comentario. Incluso si lo hiciera, no tiene forma de saber lo que pretendía. Los bucles optimizados tienen el cuerpo duplicado, ¿debería poner su conjunto en cada uno? ¿Le gustaría que no se ejecute 1000 veces? No lo sabe, por lo que toma la ruta segura y vuelve al simple bucle único.

Bufón
fuente
3

No estoy de acuerdo con "gcc no entiende lo que hay en el asm()bloque". Por ejemplo, gcc puede lidiar bastante bien con la optimización de parámetros e incluso con la reorganización de asm()bloques de manera que se entremezclan con el código C generado. Por eso, si observa el ensamblador en línea en, por ejemplo, el kernel de Linux, casi siempre tiene el prefijo __volatile__para asegurarse de que el compilador "no mueva el código". He tenido gcc moviendo mi "rdtsc", lo que hizo mis mediciones del tiempo que tomó hacer cierta cosa.

Como se documenta, gcc trata ciertos tipos de asm()bloques como "especiales" y, por lo tanto, no optimiza el código en ninguno de los lados del bloque.

Eso no quiere decir que gcc, a veces, no se confunda con bloques ensambladores en línea, o simplemente decida renunciar a alguna optimización particular porque no puede seguir las consecuencias del código ensamblador, etc., etc. Más importante aún, a menudo puede confundirse al faltar etiquetas clobber, por lo que si tiene alguna instrucción comocpuidque cambia el valor de EAX-EDX, pero usted escribió el código para que solo use EAX, el compilador puede almacenar cosas en EBX, ECX y EDX, y luego su código actúa muy extraño cuando estos registros se sobrescriben ... Si tiene suerte, se bloquea de inmediato, entonces es fácil averiguar qué sucede. Pero si no tiene suerte, se colapsa en el futuro ... Otro truco es la instrucción de división que da un segundo resultado en edx. Si no le importa el módulo, es fácil olvidar que se cambió EDX.

Mats Petersson
fuente
1
gcc realmente no entiende lo que hay en el bloque asm; debe decirlo a través de una declaración asm extendida. sin esta información adicional, gcc no se moverá alrededor de dichos bloques. gcc tampoco se confunde en los casos que usted indica: simplemente cometió un error de programación al decirle a gcc que puede usar esos registros cuando, de hecho, su código los ataca.
Recuerda a Monica el
Respuesta tardía, pero creo que vale la pena decirlo. volatile asmle dice a GCC que el código puede tener "efectos secundarios importantes" y lo tratará con más cuidado. Se puede todavía ser eliminado como parte de muertos-código-optimización o se mueve hacia fuera. La interacción con el código C debe asumir ese caso (raro) e imponer una evaluación secuencial estricta (por ejemplo, creando dependencias dentro del ASM).
edmz
GNU C Basic asm (sin restricciones de operandos, como los OP asm("")) es implícitamente volátil, al igual que Extended asm sin operandos de salida. GCC no comprende la cadena de la plantilla asm, solo las restricciones; por eso es esencial describir de manera precisa y completa su asm al compilador usando restricciones. La sustitución de operandos en la cadena de plantilla no requiere más comprensión que printfusar una cadena de formato. TL: DR: no use GNU C Basic asm para nada, excepto tal vez casos de uso como este con comentarios puros.
Peter Cordes
-2

Esta respuesta ahora está modificada: originalmente se escribió con una mentalidad que consideraba Basic Asm en línea como una herramienta bastante especificada, pero no es nada de eso en GCC. Basic Asm es débil, por lo que se editó la respuesta.

Cada comentario de ensamblado actúa como un punto de interrupción.

EDITAR: Pero uno roto, ya que usa Basic Asm. Inline asm(una asmdeclaración dentro del cuerpo de una función) sin una lista de golpes explícita es una característica débilmente especificada en GCC y su comportamiento es difícil de definir. No parece (no entiendo completamente sus garantías) adjunto a nada en particular, por lo que si bien el código ensamblador debe ejecutarse en algún momento si se ejecuta la función, no está claro cuándo se ejecuta para cualquier Nivel de optimización trivial . Un punto de interrupción que se puede reordenar con una instrucción vecina no es un "punto de interrupción" muy útil. FIN EDITAR

Puede ejecutar su programa en un intérprete que se interrumpe en cada comentario e imprime el estado de cada variable (utilizando información de depuración). Estos puntos deben existir para que observes el entorno (estado de registros y memoria).

Sin el comentario, no existe ningún punto de observación y el bucle se compila como una única función matemática que toma un entorno y produce un entorno modificado.

Quiere saber la respuesta de una pregunta sin sentido: quiere saber cómo se compila cada instrucción (o tal vez bloque, o tal vez rango de instrucción), pero no se compila ninguna instrucción (o bloque) aislada; todo el material se compila como un todo.

Una mejor pregunta sería:

Hola GCC. ¿Por qué cree que esta salida de ASM está implementando el código fuente? Explique paso a paso, con cada suposición.

Pero entonces no querría leer una prueba más larga que la salida de asm, escrita en términos de representación interna de GCC.

curioso
fuente
1
Estos puntos deben existir para que observes el entorno (estado de registros y memoria). - esto podría ser cierto para el código no optimizado. Con las optimizaciones habilitadas, las funciones completas pueden desaparecer del binario. Estamos hablando de código optimizado aquí.
Bartek Banachewicz
1
Estamos hablando de ensamblado generado como resultado de la compilación con optimizaciones habilitadas. Por tanto, se equivoca al afirmar que todo debe existir.
Bartek Banachewicz
1
Sí, IDK por qué alguien lo haría, y estoy de acuerdo en que nadie debería hacerlo. Como explica el enlace en mi último comentario, nadie debería hacerlo, y ha habido un debate sobre su fortalecimiento (por ejemplo, con un "memory"golpe implícito ) como una curita para el código de errores existente que seguramente existe. Incluso para instrucciones como asm("cli")esa que solo afectan una parte del estado arquitectónico que el código generado por el compilador no toca, aún lo necesita ordenado wrt. cargas / almacenes generados por el compilador (por ejemplo, si está deshabilitando interrupciones alrededor de una sección crítica).
Peter Cordes
1
Dado que no es seguro aplastar la zona roja, incluso el guardado / restauración manual ineficiente de registros (con push / pop) dentro de la declaración asm no es seguro, a menos que usted add rsp, -128primero. Pero hacer eso es obviamente una muerte cerebral.
Peter Cordes
1
Actualmente, GCC trata a Basic Asm exactamente equivalente a asm("" :::)(implícitamente volátil porque no tiene salidas, pero no está vinculado al resto del código por dependencias de entrada o salida. Y sin "memory"clobber). Y, por supuesto, no %operandreemplaza la cadena de la plantilla, por lo que literal %no tiene que escaparse como %%. Entonces sí, de acuerdo, desaprobar Basic Asm fuera de las __attribute__((naked))funciones y el alcance global sería una buena idea.
Peter Cordes