El operador lógico AND ( &&
) utiliza la evaluación de cortocircuito, lo que significa que la segunda prueba solo se realiza si la primera comparación se evalúa como verdadera. Esto es a menudo exactamente la semántica que necesita. Por ejemplo, considere el siguiente código:
if ((p != nullptr) && (p->first > 0))
Debe asegurarse de que el puntero no sea nulo antes de desreferenciarlo. Si esto no fuera una evaluación de cortocircuito, tendría un comportamiento indefinido porque estaría desreferenciando un puntero nulo.
También es posible que la evaluación de cortocircuito produzca una ganancia de rendimiento en casos donde la evaluación de las condiciones es un proceso costoso. Por ejemplo:
if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))
Si DoLengthyCheck1
falla, no tiene sentido llamar DoLengthyCheck2
.
Sin embargo, en el binario resultante, una operación de cortocircuito a menudo da como resultado dos ramas, ya que esta es la forma más fácil para que el compilador conserve esta semántica. (Es por eso que, en el otro lado de la moneda, la evaluación de cortocircuito a veces puede inhibir el potencial de optimización.) Puede ver esto mirando la parte relevante del código objeto generado para su if
declaración por GCC 5.4:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13w, 478 ; (curr[i] < 479)
ja .L5
cmp ax, 478 ; (l[i + shift] < 479)
ja .L5
add r8d, 1 ; nontopOverlap++
Puede ver aquí las dos comparaciones ( cmp
instrucciones) aquí, cada una seguida de un salto / rama condicional por separado ( ja
o salto si está arriba).
Es una regla general que las ramas son lentas y, por lo tanto, deben evitarse en bucles estrechos. Esto ha sido cierto en prácticamente todos los procesadores x86, desde el humilde 8088 (cuyos tiempos de recuperación lentos y cola de captación previa extremadamente pequeña [comparable a una caché de instrucciones], combinada con la falta total de predicción de ramificación, significaban que las ramificaciones tomadas requerían que la caché se volcara ) a implementaciones modernas (cuyas largas canalizaciones hacen que las ramas erróneas sean igualmente caras). Tenga en cuenta la pequeña advertencia que me metí allí. Los procesadores modernos desde el Pentium Pro tienen motores de predicción de sucursales avanzados que están diseñados para minimizar el costo de las sucursales. Si la dirección de la rama se puede predecir adecuadamente, el costo es mínimo. La mayoría de las veces, esto funciona bien, pero si te encuentras en casos patológicos en los que el predictor de rama no está de tu lado,Su código puede ser extremadamente lento . Presumiblemente, aquí es donde se encuentra aquí, ya que dice que su matriz no está ordenada.
Usted dice que los puntos de referencia confirmaron que reemplazar el &&
con un *
hace que el código sea notablemente más rápido. La razón de esto es evidente cuando comparamos la porción relevante del código objeto:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
xor r15d, r15d ; (curr[i] < 479)
cmp r13w, 478
setbe r15b
xor r14d, r14d ; (l[i + shift] < 479)
cmp ax, 478
setbe r14b
imul r14d, r15d ; meld results of the two comparisons
cmp r14d, 1 ; nontopOverlap++
sbb r8d, -1
Es un poco contrario a la intuición que esto podría ser más rápido, ya que hay más instrucciones aquí, pero así es como a veces funciona la optimización. cmp
Aquí se ven las mismas comparaciones ( ), pero ahora, cada una está precedida por una xor
y seguida de una setbe
. El XOR es solo un truco estándar para borrar un registro. Esta setbe
es una instrucción x86 que establece un bit en función del valor de un indicador, y a menudo se usa para implementar código sin ramificación. Aquí, setbe
es el inverso de ja
. Establece su registro de destino en 1 si la comparación fue inferior o igual (dado que el registro se puso a cero previamente, de lo contrario será 0), mientras que se ja
ramificó si la comparación fue superior. Una vez que estos dos valores se han obtenido en el r15b
yr14b
registros, se multiplican usando imul
. La multiplicación era tradicionalmente una operación relativamente lenta, pero es muy rápida en los procesadores modernos, y esto será especialmente rápido, ya que solo está multiplicando dos valores de tamaño de byte.
También podría haber reemplazado la multiplicación con el operador AND ( &
) bit a bit , que no realiza una evaluación de cortocircuito. Esto hace que el código sea mucho más claro y es un patrón que los compiladores generalmente reconocen. Pero cuando hace esto con su código y lo compila con GCC 5.4, continúa emitiendo la primera rama:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13w, 478 ; (curr[i] < 479)
ja .L4
cmp ax, 478 ; (l[i + shift] < 479)
setbe r14b
cmp r14d, 1 ; nontopOverlap++
sbb r8d, -1
No hay ninguna razón técnica para emitir el código de esta manera, pero por alguna razón, sus heurísticas internas le dicen que es más rápido. Que sería probablemente será más rápido si el predictor de saltos fue de su lado, pero es probable que sea más lento si la predicción de saltos falla con más frecuencia que lo consigue.
Las generaciones más nuevas del compilador (y otros compiladores, como Clang) conocen esta regla, y a veces la usarán para generar el mismo código que hubieras buscado optimizando a mano. Regularmente veo que Clang traduce &&
expresiones al mismo código que se habría emitido si lo hubiera usado &
. La siguiente es la salida relevante de GCC 6.2 con su código usando el &&
operador normal :
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13d, 478 ; (curr[i] < 479)
jg .L7
xor r14d, r14d ; (l[i + shift] < 479)
cmp eax, 478
setle r14b
add esi, r14d ; nontopOverlap++
Tenga en cuenta lo inteligente que es esto ! Utiliza condiciones firmadas ( jg
y setle
) en lugar de condiciones no firmadas ( ja
y setbe
), pero esto no es importante. Puede ver que todavía hace la comparación y la ramificación para la primera condición, como la versión anterior, y utiliza la misma setCC
instrucción para generar código sin ramificación para la segunda condición, pero se ha vuelto mucho más eficiente en la forma en que aumenta . En lugar de hacer una segunda comparación redundante para establecer los indicadores para una sbb
operación, utiliza el conocimiento que r14d
será 1 o 0 para simplemente agregar incondicionalmente este valor nontopOverlap
. Si r14d
es 0, entonces la suma es un no-op; de lo contrario, agrega 1, exactamente como se supone que debe hacer.
GCC 6.2 en realidad produce un código más eficiente cuando utiliza el &&
operador de cortocircuito que el &
operador bit a bit :
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13d, 478 ; (curr[i] < 479)
jg .L6
cmp eax, 478 ; (l[i + shift] < 479)
setle r14b
cmp r14b, 1 ; nontopOverlap++
sbb esi, -1
La rama y el conjunto condicional todavía están allí, pero ahora vuelve a la forma menos inteligente de incrementar nontopOverlap
. ¡Esta es una lección importante de por qué debes tener cuidado al intentar superar a tu compilador!
Pero si puede probar con puntos de referencia que el código de ramificación es realmente más lento, entonces puede ser útil intentar y superar su compilador. Solo tiene que hacerlo con una inspección cuidadosa del desensamblaje, y prepárese para reevaluar sus decisiones cuando actualice a una versión posterior del compilador. Por ejemplo, el código que tiene podría reescribirse como:
nontopOverlap += ((curr[i] < 479) & (l[i + shift] < 479));
Aquí no hay ninguna if
declaración, y la gran mayoría de los compiladores nunca pensarán en emitir código de ramificación para esto. GCC no es una excepción; Todas las versiones generan algo similar a lo siguiente:
movzx r14d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r14d, 478 ; (curr[i] < 479)
setle r15b
xor r13d, r13d ; (l[i + shift] < 479)
cmp eax, 478
setle r13b
and r13d, r15d ; meld results of the two comparisons
add esi, r13d ; nontopOverlap++
Si ha estado siguiendo los ejemplos anteriores, esto debería serle muy familiar. Ambas comparaciones se realizan de una manera sin sucursales, los resultados intermedios se and
ed juntos, y luego este resultado (que será ya sea 0 o 1) se add
ed a nontopOverlap
. Si desea un código sin ramificación, esto prácticamente garantizará que lo obtenga.
GCC 7 se ha vuelto aún más inteligente. Ahora genera un código prácticamente idéntico (excepto una ligera reorganización de las instrucciones) para el truco anterior como el código original. Entonces, la respuesta a su pregunta, "¿Por qué el compilador se comporta de esta manera?" , probablemente sea porque no son perfectos! Intentan utilizar la heurística para generar el código más óptimo posible, pero no siempre toman las mejores decisiones. ¡Pero al menos pueden volverse más inteligentes con el tiempo!
Una forma de ver esta situación es que el código de ramificación tiene el mejor rendimiento en el mejor de los casos . Si la predicción de bifurcación es exitosa, omitir operaciones innecesarias resultará en un tiempo de ejecución un poco más rápido. Sin embargo, el código sin ramificación tiene el mejor rendimiento en el peor de los casos . Si falla la predicción de la rama, ejecutar algunas instrucciones adicionales según sea necesario para evitar una rama definitivamente será más rápido que una rama mal predicha. Incluso el compilador más inteligente e inteligente tendrá dificultades para tomar esta decisión.
Y para su pregunta de si esto es algo a lo que los programadores deben estar atentos, la respuesta es casi seguro que no, excepto en ciertos circuitos que está tratando de acelerar a través de micro optimizaciones. Luego, se sienta con el desmontaje y encuentra formas de ajustarlo. Y, como dije antes, prepárate para revisar esas decisiones cuando actualices a una versión más nueva del compilador, ya que puede hacer algo estúpido con tu código complicado o puede haber cambiado su heurística de optimización lo suficiente como para que puedas retroceder a usar su código original. ¡Comenta a fondo!