c++ gcc optimization

c++ - El indicador de optimización de gcc-O3 hace que el código sea más lento que-O2



optimization (1)

Encuentro este tema ¿Por qué es más rápido procesar una matriz ordenada que una matriz sin clasificar? . E intente ejecutar este código. Y encuentro un comportamiento extraño. Si compilo este código con el indicador de optimización 2.98605 sec se tarda 2.98605 sec en ejecutarse. Si compilo con -O2 tarda 1.98093 sec . Intento ejecutar este código varias veces (5 o 6) en la misma máquina en el mismo entorno, cierro el resto del software (Chrome, Skype, etc.).

gcc --version gcc (Ubuntu 4.9.2-0ubuntu1~14.04) 4.9.2 Copyright (C) 2014 Free Software Foundation, Inc. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

Entonces, por favor, ¿puedes explicarme por qué sucede esto? Leo el manual de gcc y veo que -O2 incluye -O2 . Gracias por ayudar.

PS agregar código

#include <algorithm> #include <ctime> #include <iostream> int main() { // Generate data const unsigned arraySize = 32768; int data[arraySize]; for (unsigned c = 0; c < arraySize; ++c) data[c] = std::rand() % 256; // !!! With this, the next loop runs faster std::sort(data, data + arraySize); // Test clock_t start = clock(); long long sum = 0; for (unsigned i = 0; i < 100000; ++i) { // Primary loop for (unsigned c = 0; c < arraySize; ++c) { if (data[c] >= 128) sum += data[c]; } } double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC; std::cout << elapsedTime << std::endl; std::cout << "sum = " << sum << std::endl; }


gcc -O3 usa un cmov para el condicional, por lo que alarga la cadena de dependencia transportada en bucle para incluir un cmov (que es 2 uops y 2 ciclos de latencia en su CPU Intel Sandybridge, de acuerdo con las tablas de instrucciones de Agner Fog . Consulte también el x86 etiqueta wiki). Este es uno de los casos donde cmov apesta .

Si los datos cmov incluso moderadamente impredecibles, cmov probablemente sería una victoria, por lo que esta es una elección bastante sensata para un compilador. (Sin embargo, los compiladores a veces pueden usar demasiado código sin ramificación ).

Puse su código en el explorador del compilador Godbolt para ver el asm (con un bonito resaltado y filtrado de líneas irrelevantes. Sin embargo, todavía tiene que desplazarse hacia abajo más allá de todo el código de clasificación para llegar a main ()).

.L82: # the inner loop from gcc -O3 movsx rcx, DWORD PTR [rdx] # sign-extending load of data[c] mov rsi, rcx add rcx, rbx # rcx = sum+data[c] cmp esi, 127 cmovg rbx, rcx # sum = data[c]>127 ? rcx : sum add rdx, 4 # pointer-increment cmp r12, rdx jne .L82

gcc podría haber guardado el MOV usando LEA en lugar de ADD.

Los cuellos de botella del bucle en la latencia de ADD-> CMOV (3 ciclos), ya que una iteración del bucle escribe rbx con CMO, y la siguiente iteración lee rbx con ADD.

El bucle solo contiene 8 uops de dominio fusionado, por lo que puede emitirse a uno por cada 2 ciclos. La presión del puerto de ejecución tampoco es un cuello de botella tan malo como la latencia de la cadena sum dep, pero está cerca (Sandybridge solo tiene 3 puertos ALU, a diferencia de los 4 de Haswell).

Por cierto, escribirlo como sum += (data[c] >= 128 ? data[c] : 0); sacar el cmov de la cadena dep transportada en bucle es potencialmente útil. Todavía hay muchas instrucciones, pero el cmov en cada iteración es independiente. Esto se compila como se esperaba en gcc6.3 -O2 y anteriores , pero gcc7 se optimiza en un cmov en la ruta crítica ( https://gcc.gnu.org/bugzilla/show_bug.cgi?id=82666 ). (También se vectoriza automáticamente con versiones anteriores de gcc que la forma if() de escribirlo).

Clang saca el cmov de la ruta crítica incluso con la fuente original.

gcc -O2 usa una rama (para gcc5.xy anteriores), que predice bien porque sus datos están ordenados. Dado que las CPU modernas utilizan la predicción de ramificación para manejar las dependencias de control, la cadena de dependencia transportada en bucle es más corta: solo un add (latencia de 1 ciclo).

La comparación y la ramificación en cada iteración es independiente, gracias a la predicción de ramificación + ejecución especulativa, que permite que la ejecución continúe antes de que se sepa con certeza la dirección de la ramificación.

.L83: # The inner loop from gcc -O2 movsx rcx, DWORD PTR [rdx] # load with sign-extension from int32 to int64 cmp ecx, 127 jle .L82 # conditional-jump over the next instruction add rbp, rcx # sum+=data[c] .L82: add rdx, 4 cmp rbx, rdx jne .L83

Hay dos cadenas de dependencia transportadas en bucle: sum y el contador de bucles. sum es de 0 o 1 ciclo de largo, y el contador de bucle siempre es de 1 ciclo de largo. Sin embargo, el bucle es de 5 uops de dominio fusionado en Sandybridge, por lo que no puede ejecutarse a 1c por iteración de todos modos, por lo que la latencia no es un cuello de botella.

Probablemente se ejecuta en aproximadamente una iteración por 2 ciclos (cuello de botella en el rendimiento de la instrucción de bifurcación), frente a uno por cada 3 ciclos para el bucle -O3. El siguiente cuello de botella sería el rendimiento de ALU uop: 4 ALU uops (en el caso no tomado) pero solo 3 puertos ALU. (ADD puede ejecutarse en cualquier puerto).

Esta predicción de análisis de canalización coincide casi exactamente con sus tiempos de ~ 3 segundos para -O3 frente a ~ 2 segundos para -O2.

Haswell / Skylake podría ejecutar el caso no tomado a uno por 1.25 ciclos, ya que puede ejecutar una rama no tomada en el mismo ciclo que una rama tomada y tiene 4 puertos ALU. (O un poco menos, ya que un ciclo de 5 uop no se emite a las 4 uops en cada ciclo ).

(Recién probado: Skylake @ 3.9GHz ejecuta la versión ramificada de todo el programa en 1.45s, o la versión sin ramificación en 1.68s. Por lo tanto, la diferencia es mucho menor allí).

g ++ 6.3.1 usa cmov incluso en -O2 , pero g ++ 5.4 todavía se comporta como 4.9.2.

Con g ++ 6.3.1 y g ++ 5.4, el uso de -fprofile-generate / -fprofile-use produce la versión ramificada incluso en -O3 (con -fno-tree-vectorize ).

La versión CMOV del bucle de los nuevos gcc usa add ecx,-128 / cmovge rbx,rdx lugar de CMP / CMOV. Eso es un poco extraño, pero probablemente no lo desacelere. ADD escribe un registro de salida, así como banderas, por lo que crea más presión sobre el número de registros físicos. Pero mientras no sea un cuello de botella, debería ser casi igual.

El nuevo gcc auto-vectoriza el ciclo con -O3, que es una aceleración significativa incluso con solo SSE2. (p. ej., mi i7-6700k Skylake ejecuta la versión vectorizada en 0.74s, aproximadamente el doble de rápido que el escalar. O -O3 -march=native en 0.35s, usando vectores AVX2 256b).

La versión vectorizada parece muchas instrucciones, pero no está tan mal, y la mayoría de ellas no son parte de una cadena de dep. Solo tiene que descomprimir en elementos de 64 bits cerca del final. Sin embargo, hace pcmpgtd dos veces, porque no se da cuenta de que podría simplemente extender a cero en lugar de extender a signo cuando la condición ya ha puesto a cero todos los enteros negativos.