Quando o comportamento indefinido em C pulou a barreira da causalidade


8

Alguns compiladores C hiper-modernos inferirão que, se um programa chamar o comportamento indefinido quando receber determinadas entradas, essas entradas nunca serão recebidas. Consequentemente, qualquer código que seja irrelevante, a menos que tais entradas sejam recebidas, poderá ser eliminado.

Como um exemplo simples, 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);
}

um compilador pode inferir que, como a avaliação de value >> (32-amount)produzirá comportamento indefinido quando amountfor zero, a função blahnunca será chamada com yigual a zero; a chamada para foopode, assim, ser incondicional.

Pelo que posso dizer, essa filosofia parece ter se mantido em algum momento por volta de 2010. As primeiras evidências que eu vi de suas raízes remontam a 2009, e foram consagradas no padrão C11, que afirma explicitamente que se o Comportamento Indefinido ocorrer a qualquer momento ponto na execução de um programa, o comportamento de todo o programa retroativamente se torna indefinido.

Foi a noção de que os compiladores devem tentar usar o Comportamento indefinido para justificar otimizações causais reversas (ou seja, o Comportamento indefinido na rotateleftfunção deve fazer com que o compilador assuma que blahdeve ter sido chamado com um diferente de zero y, se alguma coisa causaria ou não ya valor diferente de zero) seriamente defendido antes de 2009? Quando uma coisa dessas foi proposta pela primeira vez como uma técnica de otimização?

[Termo aditivo]

Alguns compiladores, mesmo no século XX, incluíram opções para permitir certos tipos de inferências sobre loops e os valores nele calculados. Por exemplo, dado

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

um compilador, mesmo sem as inferências opcionais, pode reescrevê-lo como:

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

já que o comportamento desse código corresponderia precisamente ao original, mesmo que o compilador especificasse que os intvalores sempre envolvem o mod-65536 como complemento de dois . A opção de inferência adicional permitiria ao compilador reconhecer que, uma vez que ie x1000deve cruzar zero ao mesmo tempo, a variável anterior pode ser eliminada:

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

Em um sistema em que os intvalores envolvem o mod 65536, uma tentativa de executar um dos dois primeiros loops com nigual a 33 resultaria na doSomething()invocação de 33 vezes. O último loop, por outro lado, não seria invocado doSomething(), mesmo que a primeira invocação doSomething()tivesse precedido qualquer estouro aritmético. Esse comportamento pode ser considerado "não causal", mas os efeitos são razoavelmente bem restritos e há muitos casos em que o comportamento seria comprovadamente inofensivo (nos casos em que é necessário que uma função produza algum valor quando recebe alguma entrada, mas o valor pode ser arbitrário se a entrada for inválida, fazendo com que o loop termine mais rapidamente quando receber um valor inválido denseria realmente benéfico). Além disso, a documentação do compilador tendia a se desculpar pelo fato de alterar o comportamento de qualquer programa - mesmo daqueles envolvidos no UB.

Estou interessado em saber quando as atitudes dos escritores de compiladores se afastaram da ideia de que as plataformas deveriam documentar, na prática, algumas restrições comportamentais utilizáveis, mesmo em casos não exigidos pela Norma, para a idéia de que quaisquer construções que se baseariam em comportamentos não exigidos pela Norma. O padrão deve ter a marca ilegítima, mesmo que na maioria dos compiladores existentes funcione tão bem ou melhor do que qualquer código estritamente compatível que atenda aos mesmos requisitos (geralmente permitindo otimizações que não seriam possíveis no código estritamente compatível).


Comentários não são para discussão prolongada; esta conversa foi movida para o bate-papo .
Maple_shaft

1
@rwong: Não vejo como isso viola as leis do tempo, muito menos a causalidade. Se as transmissões estáticas ilegítimas terminarem a execução do programa (dificilmente um modelo de execução obscuro) e se o modelo de construção for tal que nenhuma classe adicional possa ser definida após a vinculação, nenhuma entrada no programa poderá causar shape->Is2D()a invocação em um objeto que não foi derivado de Shape2D. Há uma enorme diferença entre a otimização fora código que só seria relevante se um comportamento indefinido crítico já aconteceu contra o código que só seria relevante nos casos em que ...
supercat

... não há caminhos de execução a jusante que não invocam alguma forma de comportamento indefinido. Embora a definição de "comportamento indefinido crítico" dada no Anexo L (IIRC) seja um pouco vaga, uma implementação pode razoavelmente implementar a conversão estática e o envio virtual, de modo que o código indicado salte para um endereço arbitrário (é UB crítico). Para ele sempre pular Shape2D::Is­2Dé realmente melhor do que o programa merece.
supercat

1
@rwong: Quanto à duplicata, a intenção da minha pergunta era perguntar quando, na história da humanidade, a interpretação normativa do Comportamento Indefinido em relação a, por exemplo, o excesso passou de "Se o comportamento natural de uma plataforma permitir que um aplicativo atenda a seus requisitos sem verificação de erro , ter o padrão exigido por qualquer comportamento contrário pode aumentar a complexidade do código-fonte, do código executável e do código do compilador necessário para que um programa atenda aos requisitos; portanto, o Padrão deve deixar a plataforma fazer o que faz melhor "para" Os compiladores devem assumir que programadores ...
supercat

1
... sempre incluirá código para evitar estouros em todos os casos em que eles não desejem lançar mísseis nucleares. "Historicamente, dada a especificação", escreva uma função que, dados dois argumentos do tipo 'int', retorne o produto se for necessário. representável como 'int', ou então encerra o programa ou retorna um número arbitrário ", int prod(int x, int y) {return x*y;}seria suficiente. Seguir" não lançar armas nucleares "de maneira estritamente compatível, no entanto, exigiria código mais difícil de ler e quase certamente correr muito mais lento em muitas plataformas.
supercat

Respostas:


4

O comportamento indefinido é usado em situações em que não é possível que a especificação especifique o comportamento e sempre foi escrito para permitir absolutamente qualquer comportamento possível.

As regras extremamente flexíveis para o UB são úteis quando você pensa sobre o que um compilador em conformidade com as especificações deve passar. Você pode ter potência de compilação suficiente para emitir um erro ao fazer um UB ruim em um caso, mas adicione algumas camadas de recursão e agora o melhor que você pode fazer é um aviso. A especificação não tem conceito de "avisos"; portanto, se a especificação tivesse dado um comportamento, teria que ser "um erro".

A razão pela qual vemos cada vez mais efeitos colaterais disso é o impulso para a otimização. Escrever um otimizador em conformidade com especificações é difícil. Escrever um otimizador em conformidade com especificações, que também faz um trabalho notavelmente bom, adivinhar o que você pretendia quando saiu da especificação é brutal. É muito mais fácil para os compiladores se eles assumem que UB significa UB.

Isto é especialmente verdade para o gcc, que tenta oferecer suporte a muitos conjuntos de instruções com o mesmo compilador. É muito mais fácil permitir que o UB produza comportamentos UB do que tentar lidar com todas as maneiras pelas quais todos os códigos UB podem dar errado em todas as plataformas e fatorá-lo nas frases iniciais do otimizador.


1
A razão pela qual C ganhou sua reputação de velocidade é que os programadores que sabiam que, mesmo em alguns casos em que o Padrão não impunha nenhum requisito, o comportamento de sua plataforma satisfaria seus requisitos, podendo fazer uso desse comportamento. Se uma plataforma garantir que x-y > zarbitrariamente produzirá 0 ou 1 quando x-ynão for representável como "int", ela terá mais oportunidades de otimização do que uma plataforma que exige que a expressão seja escrita como UINT_MAX/2+1+x+y > UINT_MAX/2+1+zou (long long)x+y > z.
Supercat 02/08

1
Antes de 2009, uma interpretação típica de muitas formas de UB seria, por exemplo, "Mesmo que a execução de uma instrução shift-right-by-N na CPU de destino possa parar o barramento por vários minutos com a interrupção desativada quando N for -1 [I já ouviu falar de tal CPU], um compilador pode passar qualquer N para a instrução shift-right-by-N sem validação, mascarar N com 31 ou escolher arbitrariamente entre essas opções ". Nos casos em que qualquer um dos comportamentos acima atendesse aos requisitos, os programadores não precisavam perder tempo (deles ou da máquina) impedindo-os de ocorrer. O que levou a mudança?
Supercat 02/08

@ supercat Eu diria que, mesmo em 2015, uma interpretação "típica" do UB seria igualmente benigna. O problema não é o caso típico da UB, são os casos incomuns. Eu diria que 95-98% dos UB que escrevi acidentalmente "funcionaram como pretendido". São os 2-5% restantes em que você não consegue entender por que o UB ficou tão fora de controle até que você tenha passado três anos cavando fundo nas entranhas do otimizador do gcc para perceber que realmente era um caso tão estranho quanto eles pensavam. ... parecia fácil no começo.
Cort Ammon

Um excelente exemplo que encontrei foi a violação de uma regra de definição única (ODR) que escrevi por acidente, nomeando duas classes da mesma coisa e obtendo um ataque severo de pilha quando chamei uma função e ela executou a outra. Parece razoável dizer "por que você não usou o nome da classe mais próximo ou pelo menos me avisou" até entender como o vinculador resolve os problemas de nomenclatura. Simplesmente não havia opção de comportamento "bom" disponível, a menos que eles colocassem grandes quantidades de código extra no compilador para capturá-lo.
Cort Ammon

A tecnologia do vinculador geralmente é horrível, mas existem barreiras substanciais de galinha e ovo para melhorá-la. Caso contrário, acho que é necessário que C padronize um conjunto de tipos de portabilidade / otimização typedefs e macros, de modo que os compiladores existentes que suportam os comportamentos implícitos possam suportar o novo padrão apenas adicionando um arquivo de cabeçalho e o código que deseja usar o novo recursos poderiam ser usados com compiladores mais antigos que apoiaram os comportamentos meramente através da inclusão de um arquivo de cabeçalho "compatibilidade", mas compiladores, então, seria capaz de alcançar muito melhores otimizações ...
supercat

4

"O comportamento indefinido pode fazer com que o compilador reescreva o código" acontece há muito tempo, em otimizações de loop.

Faça um loop (aeb são apontadores para dobrar, por exemplo)

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

Nós incrementamos um int, copiamos um elemento do array, comparamos com um limite. Um compilador de otimização primeiro remove a indexação:

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

Removemos o caso n <= 0:

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

Agora eliminamos a variável 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;
}

Agora, se n = 2 ^ 29 em um sistema de 32 bits ou 2 ^ 61 em um sistema de 64 bits, em implementações típicas, teremos o limite tmp1 == e nenhum código será executado. Agora substitua a atribuição por algo que demore muito para que o código original nunca seja executado na falha inevitável porque leva muito tempo e o compilador alterou o código.


Se nenhum dos ponteiros for volátil, eu não consideraria uma reescrita do código. Por muito tempo, os compiladores têm permissão de redimensionar gravações para não- volatileponteiros, portanto, o comportamento no caso em que né tão grande que os ponteiros seriam agrupados seria equivalente a ter um armazenamento fora dos limites desobstruir um local de armazenamento temporário iantes de qualquer outra coisa acontece. Se aou bera volátil, a plataforma documentava que os acessos voláteis geram operações físicas de carga / armazenamento na sequência solicitada e define a maneira como esses pedidos ... #
22820

... poderia afetar deterministicamente a execução do programa, então eu argumentaria que um compilador deve se abster das otimizações indicadas (embora eu reconheça prontamente que alguns podem não ter sido programados para impedir tais otimizações, a menos que itambém se tornem voláteis). Esse seria um caso de esquina comportamental bastante raro. Se ae bnão for volátil, sugiro que não haja um significado pretendido plausível para o que o código deve fazer se nfor tão grande que substitua toda a memória. Por outro lado, muitas outras formas de UB têm significados pretendidos plausíveis.
Supercat

Após uma análise mais aprofundada, posso pensar em situações em que suposições sobre as leis da aritmética inteira poderiam fazer com que os loops terminassem prematuramente antes de qualquer UB ocorrer e sem o loop a fazer cálculos ilegítimos de endereços. No FORTRAN, há uma distinção clara entre variáveis ​​de controle de loop e outras variáveis, mas C não tem essa distinção, o que é lamentável, pois exigir que os programadores sempre evitem o estouro de variáveis ​​de controle de loop seria muito menos um impedimento para a codificação eficiente do que ter que evitar o excesso qualquer lugar.
Supercat

Eu me pergunto como as regras da linguagem poderiam ser melhor escritas para que o código que ficaria satisfeito com uma variedade de comportamentos em caso de transbordamento pudesse dizer isso, sem impedir otimizações genuinamente úteis. Existem muitos casos em que o código deve produzir uma saída estritamente definida com entrada válida, mas uma saída vagamente definida quando uma entrada inválida. Por exemplo, suponha que um programador que esteja disposto a escrever if (x-y>z) do_something(); `não se importe se é do_somethingexecutado em caso de estouro, desde que o estouro não tenha outro efeito. Existe alguma maneira de reescrever o de cima que não vai ...
supercat

... gerar código desnecessariamente ineficiente em pelo menos algumas plataformas (em comparação com o que essas plataformas poderiam gerar se tivesse uma escolha livre de invocar ou não do_something)? Mesmo que as otimizações de loop fossem proibidas de gerar comportamento inconsistente com um modelo de estouro solto, os programadores poderiam escrever código de forma a permitir que os compiladores gerassem código ideal. Existe alguma maneira de solucionar as ineficiências compelidas por um modelo "evitar transbordamento a todo custo"?
Supercat2 /

3

Sempre foi o caso em C e C ++ que, como resultado de um comportamento indefinido, tudo pode acontecer. Portanto, também sempre foi o caso de um compilador assumir que seu código não invoca um comportamento indefinido: ou não há um comportamento indefinido em seu código, então a suposição estava correta. Ou há um comportamento indefinido no seu código; o que quer que aconteça como resultado da suposição incorreta é coberto por " tudo pode acontecer".

Se você observar o recurso "restringir" em C, o ponto principal do recurso é que o compilador pode assumir que não há comportamento indefinido; portanto, chegamos ao ponto em que o compilador não apenas pode, mas realmente deve assumir, que não há indefinido comportamento.

No exemplo que você fornece, as instruções do assembler geralmente usadas em computadores baseados em x86 para implementar o deslocamento para a esquerda ou direita mudam em 0 bits se a contagem de turnos for 32 para código de 32 bits ou 64 para código de 64 bits. Isso na maioria dos casos práticos leva a resultados indesejáveis ​​(e resultados que não são os mesmos que no ARM ou PowerPC, por exemplo), portanto o compilador é bastante justificado para assumir que esse tipo de comportamento indefinido não ocorre. Você pode alterar seu código para

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

e sugira aos desenvolvedores do gcc ou Clang que, na maioria dos processadores, o código "amount == 0" seja removido pelo compilador, porque o código do assembler gerado para o código de deslocamento produzirá o mesmo resultado que o valor quando o valor == 0.


1
Eu posso pensar em muito poucas plataformas em que uma implementação de x>>y[para não assinado x] que funcionaria quando a variável ymantivesse qualquer valor de 0 a 31 e fizesse algo diferente de produzir 0 ou x>>(y & 31)para outros valores, poderia ser tão eficiente quanto uma que fez outra coisa ; Não conheço nenhuma plataforma em que garantir que nenhuma outra ação que não seja uma das anteriores possa resultar em custos significativos. A idéia de que os programadores deveriam usar uma formulação mais complicada em código que nunca precisaria ser executada em máquinas obscuras seria vista como absurda.
supercat

3
Minha pergunta é: quando as coisas mudam de "x >> 32` podem arbitrariamente render xou 0, ou podem ser capturadas em algumas plataformas obscuras" para " x>>32podem fazer com que o compilador reescreva o significado de outro código"? A evidência mais antiga que posso encontrar é de 2009, mas estou curiosa para saber se existem evidências anteriores.
Supercat

@ Reduplicador: Minha substituição funciona perfeitamente bem para valores que fazem sentido, ou seja, 0 <= quantidade <32. E se o seu (valor >> 32 - quantidade) está correto ou não, não me importo, mas deixando os parênteses de fora é tão ruim quanto um bug.
precisa saber é o seguinte

Não viu a restrição para 0<=amount && amount<32. Se valores maiores / menores fazem sentido? Eu pensei se eles fazem parte da questão. E não usar parênteses em face das operações de bit é provavelmente uma má idéia, com certeza, mas certamente não é um bug.
Deduplicator

@Deduplicator, isso ocorre porque algumas CPUs implementam nativamente o operador shift como (y mod 32)para 32 bits xe (y mod 64)64 bits x. Observe que é relativamente fácil emitir código que obterá um comportamento uniforme em todas as arquiteturas de CPU - mascarando a quantidade de turnos. Isso geralmente requer uma instrução extra. Mas infelizmente ...
rwong 6/08/2015

-1

Isso ocorre porque há um erro no seu 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);
}

Em outras palavras, ele apenas ultrapassa a barreira da causalidade se o compilador perceber que, dadas determinadas entradas, você está invocando um comportamento indefinido além da dúvida .

Ao retornar um pouco antes da invocação de comportamento indefinido, você informa ao compilador que está conscientemente impedindo a execução desse comportamento indefinido, e o compilador reconhece isso.

Em outras palavras, quando você tem um compilador que tenta aplicar a especificação de uma maneira muito estrita, precisa implementar todas as validações de argumentos possíveis no seu código. Além disso, essa validação deve ocorrer antes da invocação do referido comportamento indefinido.

Esperar! E tem mais!

Agora, com os compiladores fazendo essas coisas super-loucas, mas super-lógicas, é seu imperativo dizer ao compilador que uma função não deve continuar a execução. Assim, a noreturnpalavra-chave na foo()função agora se torna obrigatória .


4
Desculpe, mas essa não é a questão.
Deduplicator

@Duplicador Se essa não fosse a pergunta, ela deveria ter sido encerrada por se basear em opiniões (debate aberto). Efetivamente, está adivinhando as motivações dos comitês C e C ++ e as dos fornecedores / gravadores do compilador.
Rwong

1
É uma questão sobre interpretação histórica, uso e lógica da UB na linguagem C, não restrita ao padrão e ao comitê. Adivinhar os comitês? Não tão.
Deduplicator

1
... reconheça que este último deve receber apenas uma instrução. Não sei se os hiper-modernistas estão tentando desesperadamente tornar C competitivo nos campos em que foi substituído, mas estão minando sua utilidade no único campo em que é rei e são, pelo que sei, dificultando otimizações úteis ao invés de mais fácil.
Supercat

1
@ago: Na verdade, em um sistema com 24 bits int, o primeiro seria bem-sucedido e o segundo falharia. Nos sistemas incorporados, vale tudo. Ou muito vai; Lembro-me de um em que int = 32 bits e long = 40 bits, que é o único sistema que já vi em que long é muito menos eficiente que o int32_t.
precisa saber é o seguinte
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.