optimization - ¿Puede la optimización del compilador introducir errores?
compiler-construction compiler-optimization (22)
Hoy tuve una conversación con un amigo mío y debatimos durante un par de horas sobre la "optimización del compilador".
Defendí el punto de que a veces , una optimización del compilador puede introducir errores o, al menos, un comportamiento no deseado.
Mi amigo totalmente en desacuerdo, diciendo que "los compiladores son construidos por personas inteligentes y hacen cosas inteligentes" y, por lo tanto, nunca pueden salir mal.
Él no me convenció en absoluto, pero tengo que admitir que carezco de ejemplos de la vida real para fortalecer mi punto.
¿Quién está aquí? Si lo estoy, ¿tiene algún ejemplo de la vida real donde una optimización del compilador produjo un error en el software resultante? Si estoy equivocado, ¿debería dejar de programar y aprender a pescar?
¿Es probable? No en un producto importante, pero ciertamente es posible. Las optimizaciones del compilador son código generado; no importa de dónde provenga el código (lo escribe o algo lo genera), puede contener errores.
Aliasing puede causar problemas con ciertas optimizaciones, por lo que los compiladores tienen una opción para deshabilitar esas optimizaciones. De la Wikipedia :
Para habilitar dichas optimizaciones de manera predecible, el estándar ISO para el lenguaje de programación C (incluida su edición C99 más nueva) especifica que es ilegal (con algunas excepciones) que los punteros de diferentes tipos hagan referencia a la misma ubicación de memoria. Esta regla, conocida como "aliasing estricto", permite incrementos impresionantes en el rendimiento [cita requerida], pero se sabe que rompe un código válido. Varios proyectos de software infringen intencionalmente esta porción del estándar C99. Por ejemplo, Python 2.x lo hizo para implementar el recuento de referencias, [1] y los cambios requeridos en las estructuras de objetos básicos en Python 3 para habilitar esta optimización. El kernel de Linux hace esto porque el alias estricto causa problemas con la optimización del código en línea. [2] En tales casos, cuando se compila con gcc, se invoca la opción -fno-strict-aliasing para evitar optimizaciones no deseadas o no válidas que podrían producir código incorrecto.
Ayer tuve un problema con .net 4 con algo que parece ...
double x=0.4;
if(x<0.5) { below5(); } else { above5(); }
Y llamaría above5();
Pero si realmente uso x
alguna parte, llamaría below5();
double x=0.4;
if(x<0.5) { below5(); } else { System.Console.Write(x); above5(); }
No es exactamente el mismo código pero similar.
Ciertamente, estoy de acuerdo en que es tonto decirlo porque los compiladores están escritos por "personas inteligentes" que, por lo tanto, son infalibles. Las personas inteligentes también diseñaron el Hindenberg y el puente Tacoma Narrows Bridge. Incluso si es cierto que los compiladores-escritores se encuentran entre los programadores más inteligentes, también es cierto que los compiladores se encuentran entre los programas más complejos que existen. Por supuesto que tienen errores.
Por otro lado, la experiencia nos dice que la confiabilidad de los compiladores comerciales es muy alta. He tenido muchas muchas veces que alguien me dijo que la razón por la cual el programa no funciona DEBE ser debido a un error en el compilador porque lo ha revisado con mucho cuidado y está seguro de que es 100% correcto ... y luego encontramos que, de hecho, el programa tiene un error y no el compilador. Estoy tratando de pensar en ocasiones en las que me he encontrado personalmente con algo de lo que estaba realmente seguro era un error en el compilador, y solo puedo recordar un ejemplo.
Entonces, en general: confíe en su compilador. Pero, ¿alguna vez se equivocan? Por supuesto.
Debido a las exhaustivas pruebas y la relativa simplicidad del código real de C ++ (C ++ tiene menos de 100 palabras clave / operadores) los errores del compilador son relativamente raros. Mal estilo de programación a menudo es lo único que los encuentra. Y generalmente el compilador se bloqueará o producirá un error de compilador interno. La única excepción a esta regla es GCC. GCC, especialmente las versiones anteriores, tenían muchas optimizaciones experimentales habilitadas en O3
y, a veces, incluso en los otros niveles de O. GCC también apunta a tantos backends que esto deja más espacio para errores en su representación intermedia.
Es teóricamente posible, seguro. Pero si no confías en las herramientas para hacer lo que se supone que deben hacer, ¿por qué usarlas? Pero de inmediato, cualquiera discutiendo desde el puesto de
"los compiladores son creados por personas inteligentes y hacen cosas inteligentes" y, por lo tanto, nunca pueden salir mal.
está haciendo un argumento tonto.
Entonces, hasta que tenga razones para creer que un compilador lo está haciendo, ¿por qué adoptar una postura al respecto?
He tenido un problema en .NET 3.5 si compila con optimización, agrega otra variable a un método que se llama de forma similar a una variable existente del mismo tipo en el mismo ámbito, luego una de las dos (variable nueva o anterior) no lo hará ser válido en el tiempo de ejecución y todas las referencias a la variable no válida se reemplazan por referencias a la otra.
Entonces, por ejemplo, si tengo abcd del tipo MyCustomClass y tengo abdc del tipo MyCustomClass y establezco abcd.a = 5 y abdc.a = 7, entonces ambas variables tendrán la propiedad a = 7. Para solucionar el problema, se deben eliminar las dos variables, compilar el programa (con suerte sin errores) y luego se deben volver a agregar.
Creo que me he topado con este problema algunas veces con .NET 4.0 y C # cuando hago también aplicaciones de Silverlight. En mi último trabajo, nos encontramos con el problema bastante a menudo en C ++. Pudo haber sido porque las compilaciones tardaron 15 minutos, así que solo creamos las bibliotecas que necesitábamos, pero a veces el código optimizado era exactamente el mismo que el de la compilación anterior, aunque se había agregado un código nuevo y no se habían informado errores de compilación.
Sí, los optimizadores de código son construidos por personas inteligentes. También son muy complicados, por lo que es común tener errores. Sugiero probar completamente cualquier versión optimizada de un producto grande. Por lo general, los productos de uso limitado no merecen una versión completa, pero aún así deben ser probados en general para asegurarse de que realicen correctamente sus tareas comunes.
La optimización del compilador (y del tiempo de ejecución) ciertamente puede introducir un comportamiento no deseado , pero al menos solo debería ocurrir si se basa en un comportamiento no especificado (o incluso se hacen suposiciones incorrectas sobre el comportamiento bien especificado).
Ahora más allá de eso, por supuesto, los compiladores pueden tener errores en ellos. Algunos de ellos pueden estar relacionados con optimizaciones, y las implicaciones podrían ser muy sutiles, de hecho es probable que lo sean, ya que es más probable que se reparen errores obvios.
Suponiendo que incluya JIT como compiladores, he visto errores en las versiones lanzadas de .NET JIT y Hotspot JVM (no tengo detalles por el momento, lamentablemente) que fueron reproducibles en situaciones particularmente extrañas. Ya sea que se debieran a optimizaciones particulares o no, no sé.
La optimización del compilador puede revelar (o activar) errores latentes (u ocultos) en su código. Puede que haya un error en tu código C ++ que no conoces, que simplemente no lo ves. En ese caso, es un error oculto o inactivo, porque esa rama del código no se ejecuta [suficientes veces].
La probabilidad de un error en su código es mucho mayor (miles de veces más) que un error en el código del compilador: debido a que los compiladores se prueban ampliamente. ¡Por TDD más prácticamente por todas las personas que los utilizan desde su lanzamiento!). Por lo tanto, es prácticamente imposible que un error sea descubierto por usted y no lo descubra literalmente cientos de miles de veces que lo usan otras personas.
Un error inactivo o un error oculto es solo un error que aún no se le revela al programador. Las personas que pueden afirmar que su código C ++ no tiene errores (ocultos) son muy raros. Requiere conocimiento de C ++ (muy pocos pueden reclamarlo) y pruebas exhaustivas del código. No se trata solo del programador, sino del código en sí (el estilo de desarrollo). Ser propenso a errores está en el carácter del código (qué tan rigurosamente se prueba) y / o el programador (qué tan disciplinado está en la prueba y qué tan bien conoce C ++ y la programación).
Errores de Seguridad + Simultaneidad: Esto es incluso peor si incluimos simultaneidad y seguridad como errores. Pero, después de todo, estos "son" errores. Escribir un código que en primer lugar está libre de errores en términos de concurrencia y seguridad es casi imposible. Es por eso que siempre hay un error en el código, que puede revelarse (u olvidarse) en la optimización del compilador.
Las optimizaciones del compilador pueden introducir errores o comportamientos no deseados. Es por eso que puedes apagarlos.
Un ejemplo: un compilador puede optimizar el acceso de lectura / escritura a una ubicación de memoria, haciendo cosas como eliminar lecturas duplicadas o escrituras duplicadas, o volver a ordenar ciertas operaciones. Si la ubicación de la memoria en cuestión solo es utilizada por un único hilo y en realidad es memoria, eso puede estar bien. Pero si la ubicación de la memoria es un registro IO de un dispositivo de hardware, entonces la reordenación o eliminación de las escrituras puede ser completamente incorrecta. En esta situación, normalmente debe escribir el código sabiendo que el compilador podría "optimizarlo", y así saber que el enfoque ingenuo no funciona.
Actualización: como señaló Adam Robinson en un comentario, el escenario que describo más arriba es más un error de programación que un error del optimizador. Pero el punto que estaba tratando de ilustrar es que algunos programas, que de otra manera son correctos, combinados con algunas optimizaciones, que de otro modo funcionarían correctamente, pueden introducir errores en el programa cuando se combinan entre sí. En algunos casos, la especificación del lenguaje dice "Debe hacer las cosas de esta manera porque este tipo de optimizaciones pueden ocurrir y su programa fallará", en cuyo caso se trata de un error en el código. Pero a veces un compilador tiene una función de optimización (generalmente opcional) que puede generar código incorrecto porque el compilador está intentando demasiado para optimizar el código o no puede detectar que la optimización es inapropiada. En este caso, el programador debe saber cuándo es seguro activar la optimización en cuestión.
Otro ejemplo: el kernel de Linux tenía un error en el que un puntero NULL potencialmente estaba siendo desreferenciado antes de que una prueba para ese puntero sea nula. Sin embargo, en algunos casos, fue posible mapear la memoria para hacer frente a cero, lo que permitió que la desreferenciación tuviera éxito. El compilador, al darse cuenta de que el puntero fue desreferenciado, asumió que no podía ser NULO, luego eliminó la prueba NULA más tarde y todo el código en esa rama. Esto introdujo una vulnerabilidad de seguridad en el código , ya que la función procedería a usar un puntero no válido que contenía datos suministrados por el atacante. Para los casos en que el puntero era legítimamente nulo y la memoria no estaba asignada a dirección cero, el kernel todavía se OOPS como antes. Entonces, antes de la optimización, el código contenía un error; después de que contenía dos, y uno de ellos permitió un exploit de raíz local.
CERT tiene una presentación llamada "Optimizaciones peligrosas y la pérdida de causalidad" por Robert C. Seacord que enumera una gran cantidad de optimizaciones que introducen (o exponen) errores en los programas. Discute los diversos tipos de optimizaciones que son posibles, desde "hacer lo que hace el hardware" hasta "atrapar todo comportamiento indefinido posible" para "hacer cualquier cosa que no esté prohibida".
Algunos ejemplos de código que están perfectamente bien hasta que un compilador de optimización agresiva lo tiene en sus manos:
Comprobando el desbordamiento
// fails because the overflow test gets removed if (ptr + len < ptr || ptr + len > max) return EINVAL;
Uso de artitmética de desbordamiento en absoluto:
// The compiler optimizes this to an infinite loop for (i = 1; i > 0; i += i) ++j;
Borrando la memoria de información sensible:
// the compiler can remove these "useless writes" memset(password_buffer, 0, sizeof(password_buffer));
El problema aquí es que los compiladores, durante décadas, han sido menos agresivos en la optimización, por lo que generaciones de programadores C aprenden y entienden cosas como la adición de complemento de dos dígitos y cómo se desborda. Entonces el estándar de lenguaje C es modificado por los desarrolladores del compilador, y las reglas sutiles cambian, a pesar de que el hardware no cambia. La especificación del lenguaje C es un contrato entre los desarrolladores y los compiladores, pero los términos del acuerdo están sujetos a cambios a lo largo del tiempo y no todos entienden todos los detalles, o aceptan que los detalles son incluso razonables.
Esta es la razón por la cual la mayoría de los compiladores ofrecen indicadores para desactivar (o activar) las optimizaciones. ¿Está escrito su programa con el entendimiento de que los enteros pueden desbordarse? Luego debe desactivar las optimizaciones de desbordamiento, ya que pueden introducir errores. ¿Su programa evita estrictamente alias punteros? Luego puede activar las optimizaciones que asumen que los punteros nunca tienen alias. ¿Su programa intenta borrar la memoria para evitar la filtración de información? Oh, en ese caso no tiene suerte: o necesita desactivar la eliminación de código muerto o necesita saber, de antemano, que su compilador va a eliminar su código "muerto", y usar algún trabajo - Por eso.
Me encontré con esto unas cuantas veces con un compilador más reciente que crea código antiguo. El código anterior funcionaría pero dependía de un comportamiento indefinido en algunos casos, como la sobrecarga del operador indebidamente definida / emitida. Funcionaría en la compilación de depuración VS2003 o VS2005, pero en la versión fallaría.
Al abrir el ensamblado generado, quedó claro que el compilador acababa de eliminar el 80% de la funcionalidad de la función en cuestión. Reescribir el código para no usar un comportamiento indefinido lo aclaró.
Un ejemplo más obvio: VS2008 vs GCC
Declarado:
Function foo( const type & tp );
Llamado:
foo( foo2() );
donde foo2()
devuelve un objeto del type
de clase;
Tiende a bloquearse en GCC porque el objeto no está asignado en la pila en este caso, pero VS hace algo de optimización para evitar esto y probablemente funcione.
Nunca escuché o usé un compilador cuyas directivas no pudieran alterar el comportamiento de un programa. Generalmente esto es algo bueno , pero requiere que lea el manual.
Y tuve una situación reciente en la que una directiva de compilación eliminó un error. Por supuesto, el error todavía está allí, pero tengo una solución temporal hasta que solucione el problema correctamente.
Para combinar las otras publicaciones:
Los compiladores ocasionalmente tienen errores en su código, como la mayoría del software. El argumento de la "gente inteligente" es completamente irrelevante para esto, ya que los satélites de la NASA y otras aplicaciones creadas por personas inteligentes también tienen errores. La codificación que hace la optimización es diferente a la que no, por lo que si el error está en el optimizador, entonces su código optimizado puede contener errores, mientras que su código no optimizado no lo hará.
Como señalaron el Sr. Shiny y New, es posible que el código que es ingenuo con respecto a la simultaneidad y / o problemas de sincronización se ejecute de manera satisfactoria sin optimización, pero falle con la optimización ya que esto puede cambiar el tiempo de ejecución. Podría culpar a un problema de este tipo en el código fuente, pero si solo se manifiesta cuando está optimizado, algunas personas podrían culpar a la optimización.
Puede pasar. Incluso ha afectado a Linux .
Sí, las optimizaciones del compilador pueden ser peligrosas. Por lo general, los proyectos de software en tiempo real prohíben las optimizaciones por esta misma razón. De todos modos, ¿sabes de algún software sin errores?
Las optimizaciones agresivas pueden almacenar en caché o incluso hacer suposiciones extrañas con sus variables. El problema no es solo con la estabilidad de su código, sino que también pueden engañar a su depurador. He visto varias veces que un depurador no representa los contenidos de la memoria porque algunas optimizaciones conservan un valor variable dentro de los registros de la micro
Lo mismo puede pasarle a tu código. La optimización pone una variable en un registro y no escribe en la variable hasta que haya terminado. Ahora imagina qué diferentes cosas pueden ser si tu código tiene punteros a las variables en tu pila y tiene varios hilos
Sí. Un buen ejemplo es el patrón de bloqueo con doble verificación. En C ++ no hay forma de implementar de forma segura el bloqueo comprobado porque el compilador puede volver a ordenar las instrucciones de manera que tengan sentido en un sistema de subproceso único pero no en uno de subprocesos múltiples. Se puede encontrar una discusión completa en http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf
Se podrían habilitar más optimizaciones más agresivas si el programa que compila tiene un buen conjunto de pruebas. Entonces es posible ejecutar esa suite y estar algo más seguro de que el programa funciona correctamente. Además, puede preparar sus propias pruebas que coincidan estrechamente con las que planea hacer en producción.
También es cierto que cualquier programa grande puede tener (y probablemente tenga) algunos errores de forma independiente en los conmutadores que utiliza para compilarlo.
Según recuerdo, los primeros Delphi 1 tenían un error donde los resultados de Min y Max se revertían. También hubo un error oscuro con algunos valores de coma flotante solo cuando el valor del punto flotante se usó dentro de un dll. Es cierto que ha pasado más de una década, por lo que mi memoria puede ser un poco confusa.
Solo un ejemplo: hace unos días, alguien discovered que gcc 4.5 con la opción -foptimize-sibling-calls
(que está implícito en -O2
) produce un ejecutable de Emacs que falla al inicio.
Esto aparentemente ha sido arreglado desde entonces.
Todo lo que posiblemente pueda imaginar haciendo con o para un programa presentará errores.
Cuando un error desaparece al desactivar las optimizaciones, la mayoría de las veces sigue siendo tu culpa
Soy responsable de una aplicación comercial, escrita principalmente en C ++: comencé con VC5, porté a VC6 temprano, ahora porté con éxito a VC2008. Creció a más de 1 millón de líneas en los últimos 10 años.
En ese momento pude confirmar un error de generación de código único que ocurrió cuando las optimizaciones agresivas estaban habilitadas.
Entonces, ¿por qué me quejo? Porque al mismo tiempo, hubo docenas de errores que me hicieron dudar del compilador, pero resultó ser mi insuficiente comprensión del estándar de C ++. La norma da cabida a optimizaciones que el compilador puede o no utilizar.
A lo largo de los años, en diferentes foros, he visto muchas publicaciones que culpan al compilador, y finalmente resultan ser errores en el código original. Sin duda, muchos de ellos ocultan errores que necesitan una comprensión detallada de los conceptos utilizados en el estándar, pero los errores del código fuente, no obstante.
Por qué respondo tan tarde: deja de culpar al compilador antes de haber confirmado que en realidad es culpa del compilador.