¿Cuándo saltó el comportamiento indefinido en C la barrera de la causalidad?


8

Algunos compiladores de C hipermodernos inferirán que si un programa invocará Comportamiento indefinido cuando se le den ciertas entradas, tales entradas nunca se recibirán. En consecuencia, cualquier código que sería irrelevante a menos que se reciban tales entradas puede ser eliminado.

Como un simple ejemplo, dado:

void foo(uint32_t);

uint32_t rotateleft(uint_t value, uint32_t amount)
{
  return (value << amount) | (value >> (32-amount));
}

uint32_t blah(uint32_t x, uint32_t y)
{
  if (y != 0) foo(y);
  return rotateleft(x,y);
}

un compilador puede inferir que debido a que la evaluación de value >> (32-amount)producirá Comportamiento indefinido cuando amountes cero, la función blahnunca se llamará con yigual a cero; el llamado a foopuede ser hecho incondicional.

Por lo que puedo decir, esta filosofía parece haberse apoderado en algún momento alrededor de 2010. La evidencia más temprana que he visto de sus raíces se remonta a 2009, y se ha consagrado en el estándar C11 que establece explícitamente que si se produce un comportamiento indefinido en cualquier momento En la ejecución de un programa, el comportamiento de todo el programa retroactivamente se vuelve indefinido.

Fue la idea de que los compiladores deben intentar utilizar un comportamiento indefinido para justificar optimizaciones inversa-causales (es decir, el comportamiento no definido en la rotateleftfunción debe hacer que el compilador de suponer que blahdebe haber sido llamada con un no-cero y, o no lo volvería a hacer que ya mantener un valor distinto de cero) ¿abogó seriamente antes de 2009? ¿Cuándo se propuso por primera vez tal cosa como una técnica de optimización?

[Apéndice]

Algunos compiladores, incluso en el siglo XX, incluyeron opciones para permitir ciertos tipos de inferencias sobre bucles y los valores calculados en ellos. Por ejemplo, dado

int i; int total=0;
for (i=n; i>=0; i--)
{
  doSomething();
  total += i*1000;
}

un compilador, incluso sin las inferencias opcionales, podría reescribirlo como:

int i; int total=0; int x1000;
for (i=n, x1000=n*1000; i>0; i--, x1000-=1000)
{
  doSomething();
  total += x1000;
}

dado que el comportamiento de ese código coincidiría con precisión con el original, incluso si el compilador especificara que los intvalores siempre se ajustan a la moda mod-65536 del complemento a dos . La opción de inferencia adicional permitiría al compilador reconocer que, dado que iy x1000debe cruzar cero al mismo tiempo, la primera variable se puede eliminar:

int total=0; int x1000;
for (x1000=n*1000; x1000 > 0; x1000-=1000)
{
  doSomething();
  total += x1000;
}

En un sistema donde los intvalores envuelven el mod 65536, un intento de ejecutar cualquiera de los dos primeros bucles con un valor nigual a 33 resultaría en doSomething()ser invocado 33 veces. El último bucle, por el contrario, no invocaría doSomething()en absoluto, a pesar de que la primera invocación doSomething()hubiera precedido cualquier desbordamiento aritmético. Tal comportamiento podría considerarse "no causal", pero los efectos están razonablemente bien restringidos y hay muchos casos en los que el comportamiento sería demostrablemente inofensivo (en los casos en que se requiere que una función produzca algún valor cuando se le da cualquier entrada, pero el valor puede ser arbitrario si la entrada no es válida, haciendo que el ciclo finalice más rápido cuando se le da un valor no válido denen realidad sería beneficioso) Además, la documentación del compilador tendía a pedir disculpas por el hecho de que cambiaría el comportamiento de cualquier programa, incluso de aquellos que participan en UB.

Me interesa saber cuándo las actitudes de los escritores de compiladores cambiaron de la idea de que las plataformas deberían, cuando sea práctico, documentar algunas restricciones de comportamiento utilizables, incluso en casos no obligatorios por el Estándar, a la idea de que cualquier construcción que dependería de cualquier comportamiento no ordenado por el Standard debería ser calificado de ilegítimo incluso si en la mayoría de los compiladores existentes funcionaría tan bien o mejor que cualquier código estrictamente compatible que cumpla los mismos requisitos (a menudo permitiendo optimizaciones que no serían posibles en un código estrictamente compatible).


Los comentarios no son para discusión extendida; Esta conversación se ha movido al chat .
maple_shaft

1
@rwong: No veo cómo eso incluso viola las leyes del tiempo, y mucho menos la causalidad. Si las conversiones estáticas ilegítimas terminaron la ejecución del programa (apenas un modelo de ejecución oscuro), y si el modelo de compilación fuera tal que no se pudieran definir clases adicionales después de la vinculación, entonces ninguna entrada al programa podría hacer shape->Is2D()que se invoque en un objeto que no se derivó de Shape2D. Hay una gran diferencia entre optimizar el código que solo sería relevante si ya ha ocurrido un Comportamiento indefinido crítico versus el código que solo sería relevante en los casos en que ...
supercat

... no hay rutas de ejecución posteriores que no invoquen alguna forma de Comportamiento indefinido. Si bien la definición de "comportamiento crítico indefinido" que figura en el Anexo L (IIRC) es un poco vaga, una implementación podría implementar razonablemente la transmisión estática y el envío virtual de manera que el código indicado salte a una dirección arbitraria (es crítico UB). Para que saltar siempre Shape2D::Is­2Des realmente mejor de lo que merece el programa.
supercat

1
@rwong: En cuanto al duplicado, la intención de mi pregunta era preguntar cuándo en la historia humana la interpretación normativa de Comportamiento indefinido con respecto a, por ejemplo, el desbordamiento cambió de "Si el comportamiento natural de una plataforma permitiría que una aplicación satisfaga sus requisitos sin verificación de errores , tener el mandato estándar de cualquier comportamiento contrario puede aumentar la complejidad del código fuente, el código ejecutable y el código del compilador necesarios para que un programa cumpla con los requisitos, por lo que el Estándar debería permitir que la plataforma haga lo que mejor hace "a" Los compiladores deben asumir que los programadores ...
supercat

1
... siempre incluirá código para evitar desbordamientos en los casos en que no quieran lanzar misiles nucleares. "Históricamente, dada la especificación", escriba una función que, dados dos argumentos de tipo 'int', devuelva el producto si es representable como 'int', o bien termina el programa o devuelve un número arbitrario ", int prod(int x, int y) {return x*y;}habría sido suficiente. Cumplir con" no lanzar armas nucleares "de manera estrictamente compatible, requeriría un código que es más difícil de leer y casi ciertamente corre mucho más lento en muchas plataformas.
supercat

Respuestas:


4

El comportamiento indefinido se usa en situaciones en las que no es factible que la especificación especifique el comportamiento, y siempre se ha escrito para permitir absolutamente cualquier comportamiento posible.

Las reglas extremadamente flexibles para UB son útiles cuando piensa en lo que debe pasar un compilador conforme a las especificaciones. Es posible que tenga suficiente potencia de compilación para emitir un error cuando hace un mal UB en un caso, pero agregue algunas capas de recursión y ahora lo mejor que puede hacer es una advertencia. La especificación no tiene el concepto de "advertencias", por lo que si la especificación hubiera dado un comportamiento, tendría que ser "un error".

La razón por la que vemos más y más efectos secundarios de esto es el impulso para la optimización. Escribir un optimizador de conformidad de especificaciones es difícil. Escribir un optimizador de conformidad de especificaciones que también hace un trabajo notablemente bueno adivinando lo que pretendías cuando saliste de la especificación es brutal. Es mucho más fácil para los compiladores si llegan a suponer que UB significa UB.

Esto es especialmente cierto para gcc, que intenta admitir muchos conjuntos de instrucciones con el mismo compilador. Es mucho más fácil dejar que UB produzca comportamientos UB que tratar de lidiar con todas las formas en que cada código UB podría salir mal en cada plataforma, y ​​tenerlo en cuenta en las primeras frases del optimizador.


1
La razón por la que C obtuvo su reputación de velocidad es que los programadores que sabían que incluso en algunos casos en los que el Estándar no imponía requisitos, el comportamiento de su plataforma satisfaría sus requisitos, podrían hacer uso de dicho comportamiento. Si una plataforma garantiza que x-y > zarrojará arbitrariamente 0 o 1 cuando x-yno sea representable como "int", dicha plataforma tendrá más oportunidades de optimización que una plataforma que requiera que la expresión se escriba como UINT_MAX/2+1+x+y > UINT_MAX/2+1+zo (long long)x+y > z.
supercat

1
Antes de aproximadamente 2009, una interpretación típica de muchas formas de UB sería, por ejemplo, "incluso si la ejecución de una instrucción shift-right-by-N en la CPU de destino pudiera detener el bus durante varios minutos con la interrupción desactivada cuando N es -1 [I he oído hablar de tal CPU], un compilador puede pasar cualquier N a la instrucción shift-right-by-N sin validación, o enmascarar N con 31, o elegir arbitrariamente entre esas opciones ". En los casos en que cualquiera de los comportamientos anteriores cumpliría con los requisitos, los programadores no necesitaban perder el tiempo (el suyo o el de la máquina) evitando que ocurrieran. ¿Qué provocó el cambio?
supercat

@supercat Diría que, incluso en 2015, una interpretación "típica" de UB sería igualmente benigna. El problema no es el caso típico de UB, son los casos inusuales. Yo diría que entre el 95 y el 98% de UB he escrito accidentalmente "funcionó según lo previsto". Es el 2-5% restante donde no puedes entender por qué la UB se salió de control hasta que pasaste 3 años cavando en las entrañas del optimizador de gcc para darte cuenta de que en realidad era tan extravagante como pensaban. ... al principio parecía fácil.
Cort Ammon

Un excelente ejemplo con el que me encontré fue una violación de la Regla de una definición (ODR) que escribí por accidente, nombrando a dos clases de la misma manera, y obteniendo una fuerte carga de pila cuando llamé a una función y ejecutó la otra. Parece razonable decir "por qué no usaste el nombre de clase más cercano, o al menos me avisaste", hasta que comprendas cómo el enlazador resuelve los problemas de nombres. Simplemente no había una opción de "buen" comportamiento disponible a menos que pusieran cantidades masivas de código extra en el compilador para atraparlo.
Cort Ammon

La tecnología Linker es generalmente horrible, pero existen barreras considerables para mejorarla. De lo contrario, lo que creo que es necesario es que C estandarice un conjunto de tipos de letra de portabilidad / optimización y macros de tal manera que los compiladores existentes que soportan los comportamientos implícitos puedan soportar el nuevo estándar simplemente agregando un archivo de encabezado y un código que desee usar el nuevo características podrían ser utilizados con los compiladores de más edad que apoyaron los comportamientos simplemente mediante la inclusión de un archivo de cabecera "compatibilidad", pero los compiladores serían entonces capaces de lograr mucho mejores optimizaciones ...
supercat

4

"El comportamiento indefinido puede hacer que el compilador reescriba el código" ha sucedido durante mucho tiempo, en optimizaciones de bucle.

Tome un bucle (a y b son punteros para duplicar, por ejemplo)

for (i = 0; i < n; ++i) a [i] = b [i];

Incrementamos un int, copiamos un elemento de matriz, lo comparamos con un límite. Un compilador optimizador primero elimina la indexación:

double* tmp1 = a;
double* tmp2 = b;
for (i = 0; i < n; ++i) *tmp1++ = *tmp2++;

Eliminamos el caso n <= 0:

i = 0;
if (n > 0) {
    double* tmp1 = a;
    double* tmp2 = b;
    for (; i < n; ++i) *tmp1++ = *tmp2++;
}

Ahora eliminamos la variable i:

i = 0;
if (n > 0) {
    double* tmp1 = a;
    double* tmp2 = b;
    double* limit = tmp1 + n;
    for (; tmp1 != limit; tmp1++, tmp2++) *tmp1 = *tmp2;
    i = n;
}

Ahora, si n = 2 ^ 29 en un sistema de 32 bits o 2 ^ 61 en un sistema de 64 bits, en implementaciones típicas tendremos tmp1 == límite, y no se ejecutará ningún código. Ahora reemplace la tarea con algo que lleve mucho tiempo para que el código original nunca se encuentre con el inevitable bloqueo porque lleva demasiado tiempo y el compilador ha cambiado el código.


Si ninguno de los punteros es volátil, no lo consideraría una reescritura del código. Durante mucho tiempo, se ha permitido a los compiladores volver a secuenciar las escrituras a los no volatilepunteros, por lo que el comportamiento en el caso de que nsea ​​tan grande que los punteros se ajusten sería equivalente a tener una ubicación de almacenamiento temporal fuera de los límites de una ubicación de almacenamiento temporal que contenga iantes que nada sucede Si ao bfue volátil, la plataforma documentó que los accesos volátiles generan operaciones físicas de carga / almacenamiento en la secuencia solicitada, y la plataforma define cualquier medio a través del cual tales solicitudes ...
supercat

... podría afectar determinísticamente la ejecución del programa, entonces diría que un compilador debe abstenerse de las optimizaciones indicadas (aunque reconoceré fácilmente que algunas podrían no haber sido programadas para evitar tales optimizaciones a menos que itambién se volvieran volátiles). Sin embargo, ese sería un caso de esquina de comportamiento bastante raro. Si ay bno son volátiles, sugeriría que no habría un significado posible para lo que debería hacer el código si nes tan grande como para sobrescribir toda la memoria. Por el contrario, muchas otras formas de UB tienen significados posibles.
supercat

Tras una consideración adicional, puedo pensar en situaciones en las que las suposiciones sobre las leyes de la aritmética de enteros podrían hacer que los bucles terminen prematuramente antes de que ocurra cualquier UB y sin que el bucle realice cálculos de direcciones ilegítimos. En FORTRAN, hay una clara distinción entre las variables de control de bucle y otras variables, pero C carece de esa distinción, lo cual es lamentable ya que exigir a los programadores que eviten siempre el desbordamiento de las variables de control de bucle sería un impedimento mucho menor para la codificación eficiente que tener que evitar el desbordamiento en cualquier sitio.
supercat

Me pregunto cómo se podrían escribir mejor las reglas del lenguaje para que el código que estaría contento con una variedad de comportamientos en caso de desbordamiento pudiera decir eso, sin impedir optimizaciones realmente útiles. Hay muchos casos en los que el código debe producir una salida estrictamente definida dada una entrada válida, pero una salida poco definida cuando se le da una entrada no válida. Por ejemplo, supongamos que un programador se inclinaría a escribir if (x-y>z) do_something(); `no le importa si se do_somethingejecuta en caso de desbordamiento, siempre que el desbordamiento no tenga otro efecto. ¿Hay alguna manera de reescribir lo anterior que no ...
supercat

... generar código innecesariamente ineficiente en al menos algunas plataformas (en comparación con lo que esas plataformas podrían generar si se les da la opción libre de invocar o no do_something)? Incluso si se prohibiera que las optimizaciones de bucle produjeran comportamientos inconsistentes con un modelo de desbordamiento suelto, los programadores podrían escribir código de tal manera que los compiladores puedan generar un código óptimo. ¿Hay alguna forma de evitar las ineficiencias obligadas por un modelo de "evitar el desbordamiento a toda costa"?
supercat

3

Siempre ha sido el caso en C y C ++ que, como resultado de un comportamiento indefinido, cualquier cosa puede suceder. Por lo tanto, siempre ha sido el caso de que un compilador puede suponer que su código no invoca un comportamiento indefinido: o no hay un comportamiento indefinido en su código, entonces la suposición fue correcta. O bien, si hay un comportamiento indefinido en su código, lo que suceda como resultado de la suposición incorrecta está cubierto por " cualquier cosa puede suceder".

Si observa la característica "restringir" en C, el punto completo de la característica es que el compilador puede asumir que no hay un comportamiento indefinido, por lo que llegamos al punto en el que el compilador no solo puede sino que debe asumir que no hay indefinido comportamiento.

En el ejemplo que da, las instrucciones del ensamblador que generalmente se usan en las computadoras basadas en x86 para implementar el desplazamiento hacia la izquierda o hacia la derecha cambiarán en 0 bits si el recuento de cambios es 32 para el código de 32 bits o 64 para el código de 64 bits. Esto en la mayoría de los casos prácticos conducirá a resultados no deseados (y resultados que no son los mismos que en ARM o PowerPC, por ejemplo), por lo que el compilador está bastante justificado para suponer que este tipo de comportamiento indefinido no ocurre. Podrías cambiar tu código a

uint32_t rotateleft(uint_t value, uint32_t amount)
{
   return amount == 0 ? value : (value << amount) | (value >> (32-amount));
}

y sugiera a los desarrolladores de gcc o Clang que en la mayoría de los procesadores el compilador debe eliminar el código "cantidad == 0", porque el código ensamblador generado para el código de cambio producirá el mismo resultado que el valor cuando cantidad == 0.


1
Puedo pensar en muy pocas plataformas donde una implementación de x>>y[para unsigned x] que funcionaría cuando la variable ytuviera cualquier valor de 0 a 31, e hiciera algo más que ceder 0 o x>>(y & 31)para otros valores, podría ser tan eficiente como una que hiciera otra cosa ; No conozco ninguna plataforma donde garantizar que ninguna otra acción que no sea una de las anteriores agregaría un costo significativo. La idea de que los programadores deberían usar una formulación más complicada en el código que nunca tendría que ejecutarse en máquinas oscuras habría sido vista como absurda.
supercat

3
Mi pregunta es: ¿cuándo cambiaron las cosas de "x >> 32" podría producirse arbitrariamente xo 0, o podrían quedar atrapados en algunas plataformas oscuras "a" x>>32podría causar que el compilador reescriba el significado de otro código "? La evidencia más temprana que puedo encontrar es de 2009, pero tengo curiosidad por saber si existe evidencia anterior.
supercat

@Deduplicator: Mi reemplazo funciona perfectamente bien para valores que tienen sentido, es decir 0 <= cantidad <32. Y si su (valor >> 32 - cantidad) es correcto o no, no me importa, pero dejando los paréntesis fuera Es tan malo como un error.
gnasher729

No vi la restricción a 0<=amount && amount<32. ¿Tienen sentido los valores mayores / menores? Pensé si lo hacen es parte de la pregunta. Y no usar paréntesis frente a las operaciones de bit es probablemente una mala idea, claro, pero ciertamente no es un error.
Deduplicador

@Deduplicator esto se debe a que algunas CPU implementan de forma nativa el operador shift como (y mod 32)para 32 bits xy (y mod 64)para 64 bits x. Tenga en cuenta que es relativamente fácil emitir código que logrará un comportamiento uniforme en todas las arquitecturas de CPU, enmascarando la cantidad de cambio. Esto generalmente requiere una instrucción adicional. Pero, por desgracia ...
rwong

-1

Esto se debe a que hay un error en su código:

uint32_t blah(uint32_t x, uint32_t y)
{
    if (y != 0) 
    {
        foo(y);
        return x; ////// missing return here //////
    }
    return rotateleft(x, y);
}

En otras palabras, solo salta la barrera de la causalidad si el compilador ve que, dadas ciertas entradas, está invocando un comportamiento indefinido fuera de toda duda .

Al regresar justo antes de invocar un comportamiento indefinido, le dice al compilador que está evitando conscientemente que se ejecute ese comportamiento indefinido, y el compilador lo reconoce.

En otras palabras, cuando tiene un compilador que intenta aplicar la especificación de una manera muy estricta, debe implementar todas las validaciones de argumentos posibles en su código. Además, esta validación debe ocurrir antes de la invocación de dicho comportamiento indefinido.

¡Espere! ¡Y hay más!

Ahora, con los compiladores haciendo estas cosas súper locas pero súper lógicas, es imperativo decirle al compilador que no se supone que una función continúe la ejecución. Por lo tanto, la noreturnpalabra clave en la foo()función ahora se vuelve obligatoria .


44
Lo siento, pero esa no es la pregunta.
Deduplicador

@Deduplicator Si esa no hubiera sido la pregunta, esta pregunta debería haberse cerrado por estar basada en la opinión (debate abierto). Efectivamente, está cuestionando las motivaciones de los comités C y C ++ y la de los vendedores / escritores del compilador.
rwong

1
Es una pregunta sobre la interpretación histórica, el uso y la lógica de UB en el lenguaje C, no restringido al estándar y al comité. ¿Adivinando los comités? No tan.
Deduplicador el

1
... reconozca que este último solo debe tomar una instrucción. No sé si los hipermodernistas están tratando desesperadamente de hacer que C sea competitivo en los campos donde ha sido reemplazado, pero están socavando su utilidad en el único campo donde es el rey, y por lo que puedo decir, hacen que las optimizaciones útiles sean más difíciles en lugar de más fácil.
supercat

1
@ago: En realidad, en un sistema con 24 bits int, el primero tendría éxito y el segundo fallaría. En los sistemas empotrados, todo vale. O va mucho; Puedo recordar uno donde int = 32 bit, y long = 40 bit, que es el único sistema que vi donde long es mucho menos eficiente que int32_t.
gnasher729
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.