Me pregunto sobre el uso de código como el siguiente
int result = 0;
int factor = 1;
for (...) {
result = ...
factor *= 10;
}
return result;
Si el ciclo se repite a lo largo de los n
tiempos, entonces factor
se multiplica por tiempos 10
exactos n
. Sin embargo, factor
solo se usa después de haber sido multiplicado por 10
un total de n-1
veces. Si suponemos que factor
nunca se desborda, excepto en la última iteración del bucle, pero puede desbordarse en la última iteración del bucle, ¿debería ser aceptable ese código? En este caso, el valor de factor
nunca se utilizará después de que se haya producido el desbordamiento.
Estoy debatiendo si un código como este debería ser aceptado. Sería posible poner la multiplicación dentro de una declaración if y simplemente no hacer la multiplicación en la última iteración del ciclo cuando puede desbordarse. La desventaja es que satura el código y agrega una rama innecesaria que necesitaría verificar en todas las iteraciones de bucle anteriores. También podría repetir el ciclo una vez menos y replicar el cuerpo del ciclo una vez después del ciclo, nuevamente, esto complica el código.
El código real en cuestión se usa en un bucle interno ajustado que consume una gran parte del tiempo total de la CPU en una aplicación de gráficos en tiempo real.
Respuestas:
Los compiladores suponen que un programa C ++ válido no contiene UB. Considere por ejemplo:
Si
x == nullptr
luego lo desreferencia y le asigna un valor es UB. Por lo tanto, la única forma en que esto podría terminar en un programa válido es cuandox == nullptr
nunca dará verdadero y el compilador puede asumir bajo la regla como si, lo anterior es equivalente a:Ahora en tu código
La última multiplicación de
factor
no puede suceder en un programa válido (el desbordamiento firmado no está definido). Por lo tanto, la asignación aresult
no puede suceder. Como no hay forma de bifurcarse antes de la última iteración, tampoco puede ocurrir la iteración anterior. Eventualmente, la parte del código que es correcta (es decir, nunca ocurre un comportamiento indefinido) es:fuente
INT_MAX >= 10000000000
, con una función diferente llamada en el caso de queINT_MAX
sea más pequeña.i <= n
bucles son siempre no infinitos, como losi<n
bucles. Y promueva elint i
ancho del puntero en un bucle en lugar de tener que rehacer el signo para una posible indexación de matriz envolvente a los primeros elementos de la matriz 4G.El comportamiento del
int
desbordamiento no está definido.No importa si lees
factor
fuera del cuerpo del bucle; si se ha desbordado para entonces, entonces el comportamiento de su código en, después y algo paradójicamente antes de que el desbordamiento sea indefinido.Un problema que puede surgir al mantener este código es que los compiladores se están volviendo cada vez más agresivos cuando se trata de la optimización. En particular, están desarrollando un hábito en el que suponen que el comportamiento indefinido nunca ocurre. Para que este sea el caso, pueden eliminar el
for
bucle por completo.¿No puede usar un
unsigned
tipo parafactor
aunque debería preocuparse por la conversión no deseada deint
aunsigned
en expresiones que contengan ambos?fuente
factor
se "usa" en la tarea de regreso a sí misma.Puede ser perspicaz considerar los optimizadores del mundo real. El desenrollado de bucles es una técnica conocida. La idea básica del desenrollamiento del ciclo operativo es que
podría implementarse mejor detrás de escena como
Este es el caso fácil, con un límite fijo. Pero los compiladores modernos también pueden hacer esto para límites variables:
se convierte
Obviamente, esto solo funciona si el compilador sabe que N <= 3. Y ahí es donde volvemos a la pregunta original. Debido a que el compilador sabe que no se produce un desbordamiento firmado , sabe que el bucle puede ejecutarse un máximo de 9 veces en arquitecturas de 32 bits.
10^10 > 2^32
. Por lo tanto, puede desenrollar un bucle de 9 iteraciones. ¡Pero el máximo previsto era 10 iteraciones! .Lo que podría suceder es que obtenga un salto relativo a una instrucción de ensamblaje (9-N) con N = 10, por lo que un desplazamiento de -1, que es la instrucción de salto en sí. Ups Esta es una optimización de bucle perfectamente válida para C ++ bien definido, pero el ejemplo dado se convierte en un bucle infinito ajustado.
fuente
Cualquier desbordamiento de entero con signo da como resultado un comportamiento indefinido, independientemente de si el valor desbordado se lee o no.
Tal vez en su caso de uso pueda sacar la primera iteración del bucle, convirtiendo esto
dentro de esto
Con la optimización habilitada, el compilador podría desenrollar el segundo bucle anterior en un salto condicional.
fuente
factor *= 10;
Este es UB; en términos de ISO C ++, el comportamiento completo de todo el programa no está especificado para una ejecución que finalmente llega a UB. El ejemplo clásico es que, en lo que respecta al estándar C ++, puede hacer que los demonios salgan volando de tu nariz. (Recomiendo no usar una implementación donde los demonios nasales sean una posibilidad real). Ver otras respuestas para más detalles.
Los compiladores pueden "causar problemas" en el momento de la compilación para las rutas de ejecución que pueden ver que conducen a una UB visible en tiempo de compilación, por ejemplo, supongamos que nunca se alcanzan esos bloques básicos.
Consulte también Lo que todo programador de C debe saber sobre el comportamiento indefinido (blog LLVM). Como se explica allí, UB de desbordamiento firmado permite a los compiladores probar que los
for(... i <= n ...)
bucles no son infinitos, incluso para desconocidosn
. También les permite "promover" los contadores de bucle int al ancho del puntero en lugar de rehacer la extensión de signo. (Entonces, la consecuencia de UB en ese caso podría ser acceder fuera de los elementos bajos de 64k o 4G de una matriz, si esperaba un ajuste firmadoi
en su rango de valores).En algunos casos, los compiladores emitirán una instrucción ilegal como x86
ud2
para un bloque que probablemente cause UB si alguna vez se ejecuta. (Tenga en cuenta que una función puede no siempre ser llamado, por lo que los compiladores pueden no en los estribos General Ir y romper otras funciones, o incluso los posibles caminos a través de una función que no golpeó UB. Es decir, el código de máquina que compila al mosto todavía trabajo para todas las entradas que no conducen a UB.)Probablemente la solución más eficiente es pelar manualmente la última iteración para
factor*=10
evitar lo innecesario .O si el cuerpo del bucle es grande, considere simplemente usar un tipo sin signo para
factor
. Luego, puede dejar que se multiplique el desbordamiento sin signo y simplemente se ajustará bien a una potencia de 2 (el número de bits de valor en el tipo sin signo).Esto está bien incluso si lo usa con tipos con signo, especialmente si su conversión sin signo-> con signo nunca se desborda.
La conversión entre sin signo y el complemento de 2 con signo es libre (mismo patrón de bits para todos los valores); el ajuste del módulo para int -> unsigned especificado por el estándar C ++ se simplifica al uso del mismo patrón de bits, a diferencia del complemento o signo / magnitud.
Y unsigned- >igned es igualmente trivial, aunque está definido por la implementación para valores mayores que
INT_MAX
. Si no está utilizando el enorme resultado sin firmar de la última iteración, no tiene nada de qué preocuparse. Pero si es así, vea ¿La conversión de sin firmar a firma no está definida? . El caso del valor no encaja está definido por la implementación , lo que significa que una implementación debe elegir algún comportamiento; los cuerdos simplemente truncan (si es necesario) el patrón de bits sin signo y lo usan como firmado, porque eso funciona para valores dentro del rango de la misma manera sin trabajo adicional. Y definitivamente no es UB. Por lo tanto, los grandes valores sin signo pueden convertirse en enteros con signo negativo. por ejemplo, después de queint x = u;
gcc y clang no se optimicenx>=0
como siempre es cierto, incluso sin ellos-fwrapv
, porque definieron el comportamiento.fuente
Si puede tolerar algunas instrucciones de ensamblaje adicionales en el bucle, en lugar de
puedes escribir:
para evitar la última multiplicación.
!factor
no introducirá una rama:Este código
también da como resultado un ensamblaje sin ramificación después de la optimización:
(Compilado con GCC 8.3.0
-O3
)fuente
factor
ligeramente la latencia de la cadena de dependencia transportada en bucle . O no: cuando se compila a 2x LEA es casi tan eficiente como LEA + añadir que verf *= 10
comof*5*2
, contest
latencia oculta por la primeraLEA
. Pero cuesta uops adicionales dentro del ciclo, por lo que hay un posible inconveniente en el rendimiento (o al menos un problema de amigabilidad con el hyperthreading)No mostraste lo que está entre paréntesis de la
for
declaración, pero voy a asumir que es algo como esto:Simplemente puede mover el incremento de contador y la verificación de terminación de bucle al cuerpo:
El número de instrucciones de montaje en el bucle seguirá siendo el mismo.
Inspirado en la presentación de Andrei Alexandrescu "La velocidad se encuentra en la mente de las personas".
fuente
Considere la función:
Según la justificación publicada, los autores de la Norma habrían esperado que si se invocara esta función en (por ejemplo) una computadora común de 32 bits con argumentos de 0xC000 y 0xC000, promover los operandos de
*
asigned int
causaría que el cálculo produjera -0x10000000 , que cuando se convierte enunsigned
produciría la0x90000000u
misma respuesta que si hubieranunsigned short
promovido aunsigned
. No obstante, gcc a veces optimizará esa función de manera que se comportaría sin sentido si se produce un desbordamiento. Cualquier código en el que alguna combinación de entradas pueda causar un desbordamiento debe procesarse con la-fwrapv
opción a menos que sea aceptable permitir a los creadores de entradas mal formadas deliberadamente ejecutar código arbitrario de su elección.fuente
¿Por qué no esto?
fuente
...
cuerpo del bucle parafactor = 1
ofactor = 10
, solo 100 o más. Tendría que pelar la primera iteración y comenzar de nuevofactor = 1
si desea que esto funcione.Hay muchas caras diferentes de Comportamiento indefinido, y lo que es aceptable depende del uso.
Eso, por sí mismo, es un poco inusual, pero sea como sea ... si este es el caso, entonces la UB probablemente esté dentro del reino "permisible, aceptable" . La programación de gráficos es conocida por hacks y cosas feas. Siempre y cuando "funcione" y no demore más de 16.6 ms en producir un marco, por lo general, a nadie le importa. Pero aún así, tenga en cuenta lo que significa invocar a UB.
Primero, está el estándar. Desde ese punto de vista, no hay nada que discutir y no hay forma de justificar, su código es simplemente inválido. No hay si ni cuándo, simplemente no es un código válido. También podría decir que es medio dedo desde su punto de vista, y el 95-99% del tiempo estará listo para ir de todos modos.
Luego, está el lado del hardware. Hay algunas arquitecturas poco comunes y extrañas en las que esto es un problema. Estoy diciendo "poco común, raro" porque en la arquitectura que conforma el 80% de todas las computadoras (o las dos arquitecturas que juntas conforman el 95% de todas las computadoras) el desbordamiento es un "sí, lo que sea, no me importa" cosa en el nivel de hardware. Seguro que obtienes un resultado basura (aunque aún predecible), pero no suceden cosas malas.
Eso no esEn el caso de todas las arquitecturas, es muy posible que tenga una trampa en el desbordamiento (aunque viendo cómo habla de una aplicación de gráficos, las posibilidades de estar en una arquitectura tan extraña son bastante pequeñas). ¿Es la portabilidad un problema? Si es así, es posible que desee abstenerse.
Por último, está el lado del compilador / optimizador. Una razón por la cual el desbordamiento no está definido es que simplemente dejarlo así fue más fácil de hacer frente al hardware una vez. Pero otra razón es que, por ejemplo,
x+1
se garantiza que siempre será mayor quex
, y el compilador / optimizador puede explotar este conocimiento. Ahora, para el caso mencionado anteriormente, se sabe que los compiladores actúan de esta manera y simplemente eliminan los bloques completos (existió un exploit de Linux hace algunos años que se basó en que el compilador eliminó algún código de validación debido exactamente a esto).Para su caso, dudaría seriamente de que el compilador haga algunas optimizaciones especiales, extrañas. Sin embargo, qué sabes, qué sé yo. En caso de duda, pruébalo. Si funciona, estás listo para irte.
(Y finalmente, por supuesto, hay una auditoría de código, puede que tenga que perder el tiempo discutiendo esto con un auditor si no tiene suerte).
fuente