Por que o GCC gera uma montagem radicalmente diferente para quase o mesmo código C?

184

Ao escrever uma ftolfunção otimizada , encontrei um comportamento muito estranho GCC 4.6.1. Deixe-me mostrar o código primeiro (para maior clareza, marquei as diferenças):

fast_trunc_one, C:

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = mantissa << -exponent;                       /* diff */
    } else {
        r = mantissa >> exponent;                        /* diff */
    }

    return (r ^ -sign) + sign;                           /* diff */
}

fast_trunc_two, C:

int fast_trunc_two(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = (mantissa << -exponent) ^ -sign;             /* diff */
    } else {
        r = (mantissa >> exponent) ^ -sign;              /* diff */
    }

    return r + sign;                                     /* diff */
}

Parece o mesmo, certo? Bem, o GCC discorda. Depois de compilar com gcc -O3 -S -Wall -o test.s test.cisso, a saída do assembly:

fast_trunc_one, gerado:

_fast_trunc_one:
LFB0:
    .cfi_startproc
    movl    4(%esp), %eax
    movl    $150, %ecx
    movl    %eax, %edx
    andl    $8388607, %edx
    sarl    $23, %eax
    orl $8388608, %edx
    andl    $255, %eax
    subl    %eax, %ecx
    movl    %edx, %eax
    sarl    %cl, %eax
    testl   %ecx, %ecx
    js  L5
    rep
    ret
    .p2align 4,,7
L5:
    negl    %ecx
    movl    %edx, %eax
    sall    %cl, %eax
    ret
    .cfi_endproc

fast_trunc_two, gerado:

_fast_trunc_two:
LFB1:
    .cfi_startproc
    pushl   %ebx
    .cfi_def_cfa_offset 8
    .cfi_offset 3, -8
    movl    8(%esp), %eax
    movl    $150, %ecx
    movl    %eax, %ebx
    movl    %eax, %edx
    sarl    $23, %ebx
    andl    $8388607, %edx
    andl    $255, %ebx
    orl $8388608, %edx
    andl    $-2147483648, %eax
    subl    %ebx, %ecx
    js  L9
    sarl    %cl, %edx
    movl    %eax, %ecx
    negl    %ecx
    xorl    %ecx, %edx
    addl    %edx, %eax
    popl    %ebx
    .cfi_remember_state
    .cfi_def_cfa_offset 4
    .cfi_restore 3
    ret
    .p2align 4,,7
L9:
    .cfi_restore_state
    negl    %ecx
    sall    %cl, %edx
    movl    %eax, %ecx
    negl    %ecx
    xorl    %ecx, %edx
    addl    %edx, %eax
    popl    %ebx
    .cfi_restore 3
    .cfi_def_cfa_offset 4
    ret
    .cfi_endproc

Essa é uma diferença extrema . Na verdade, isso também aparece no perfil, fast_trunc_oneé cerca de 30% mais rápido que fast_trunc_two. Agora minha pergunta: o que está causando isso?

orlp
fonte
1
Para fins de teste, criei uma essência aqui, onde você pode copiar / colar facilmente a fonte e ver se consegue reproduzir o erro em outros sistemas / versões do GCC.
orlp
12
Coloque os casos de teste em um diretório próprio. Compile-os com -S -O3 -da -fdump-tree-all. Isso criará muitos instantâneos da representação intermediária. Caminhe por eles (eles estão numerados) lado a lado e você poderá encontrar a otimização ausente no primeiro caso.
Zwol 20/04
1
Sugestão 2: mude tudo intpara unsigned inte veja se a diferença desaparece.
zwol
5
As duas funções parecem estar fazendo contas ligeiramente diferentes. Embora os resultados possam ser os mesmos, a expressão (r + shifted) ^ signnão é a mesma que r + (shifted ^ sign). Eu acho que isso está confundindo o otimizador? FWIW, MSVC 2010 (16.00.40219.01) produz listagens quase idênticas entre si: gist.github.com/2430454
DCoder
1
@DCoder: Oh droga! Eu não percebi isso. Não é a explicação para a diferença. Deixe-me atualizar a pergunta com uma nova versão em que isso é descartado.
orlp

Respostas:

256

Atualizado para sincronizar com a edição do OP

Ao mexer no código, consegui ver como o GCC otimiza o primeiro caso.

Antes de entendermos por que eles são tão diferentes, primeiro precisamos entender como o GCC otimiza fast_trunc_one().

Acredite ou não, fast_trunc_one()está sendo otimizado para isso:

int fast_trunc_one(int i) {
    int mantissa, exponent;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);

    if (exponent < 0) {
        return (mantissa << -exponent);             /* diff */
    } else {
        return (mantissa >> exponent);              /* diff */
    }
}

Isso produz exatamente a mesma montagem que os fast_trunc_one()nomes dos registros originais e tudo mais.

Observe que não há xors na montagem para fast_trunc_one(). Foi isso que me deu.


Como assim?


Passo 1: sign = -sign

Primeiro, vamos dar uma olhada na signvariável. Desde sign = i & 0x80000000;, existem apenas dois valores possíveis que signpodem ser utilizados:

  • sign = 0
  • sign = 0x80000000

Agora reconheça isso em ambos os casos sign == -sign,. Portanto, quando eu altero o código original para isso:

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = mantissa << -exponent;
    } else {
        r = mantissa >> exponent;
    }

    return (r ^ sign) + sign;
}

Produz exatamente a mesma montagem que o original fast_trunc_one(). Vou poupar a montagem, mas é idêntica - registre nomes e tudo.


Etapa 2: Redução matemática:x + (y ^ x) = y

signpode assumir apenas um dos dois valores, 0ou 0x80000000.

  • Quando x = 0, em seguida,x + (y ^ x) = y , trivial é válido.
  • Adicionar e xoring por 0x80000000é o mesmo. Ele vira o bit do sinal. Portanto, x + (y ^ x) = ytambém vale quando x = 0x80000000.

Portanto, x + (y ^ x)reduz para y. E o código simplifica para isso:

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = (mantissa << -exponent);
    } else {
        r = (mantissa >> exponent);
    }

    return r;
}

Novamente, isso compila exatamente o mesmo assembly - registre nomes e tudo.


Esta versão acima finalmente se reduz a isso:

int fast_trunc_one(int i) {
    int mantissa, exponent;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);

    if (exponent < 0) {
        return (mantissa << -exponent);             /* diff */
    } else {
        return (mantissa >> exponent);              /* diff */
    }
}

que é exatamente o que o GCC gera na montagem.


Então, por que o compilador não otimiza fast_trunc_two() para a mesma coisa?

A parte principal fast_trunc_one()é a x + (y ^ x) = yotimização. fast_trunc_two()nox + (y ^ x) expressão está sendo dividido entre o ramo.

Suspeito que isso seja suficiente para confundir o GCC para não fazer essa otimização. (Seria necessário içar ^ -signo ramo e fundi-lo com or + sign no final.)

Por exemplo, isso produz o mesmo conjunto que fast_trunc_one():

int fast_trunc_two(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = ((mantissa << -exponent) ^ -sign) + sign;             /* diff */
    } else {
        r = ((mantissa >> exponent) ^ -sign) + sign;              /* diff */
    }

    return r;                                     /* diff */
}
Mysticial
fonte
4
Editar, parece que eu respondi a revisão dois. A revisão atual virou os dois exemplos e mudou um pouco o código ... isso é confuso.
Mysticial
2
@ nightcracker Não se preocupe. Atualizei minha resposta para sincronizar com a versão atual.
Mysticial
1
@Mysticial: a sua declaração final não é mais verdade com a nova versão, tornando a sua resposta void (que não responde a pergunta mais importante, "Por que GCC gerar tal montagem radicalmente diferente" .)
orlp
11
Resposta atualizada novamente. Não tenho certeza se é satisfatório o suficiente. Mas acho que não posso fazer muito melhor sem saber exatamente como funciona a otimização relevante do GCC.
Mysticial
4
@Mysticial: Estritamente falando, desde que o tipo assinado esteja sendo usado incorretamente neste código, praticamente todas as transformações que o compilador está fazendo aqui são casos em que o comportamento é indefinido ...
R .. GitHub PARE DE AJUDAR ICE
63

Essa é a natureza dos compiladores. Supondo que eles seguirão o caminho mais rápido ou melhor, é bastante falso. Qualquer pessoa que implique que você não precisa fazer nada no seu código para otimizar, porque "compiladores modernos" preenchem o espaço em branco, fazem o melhor trabalho, criam o código mais rápido etc. Na verdade, vi o gcc piorar de 3.x para 4.x no braço, pelo menos. O 4.x pode ter atingido o 3.x nesse ponto, mas desde o início produziu um código mais lento. Com a prática, você pode aprender a escrever seu código para que o compilador não precise trabalhar tanto e, como resultado, produz resultados mais consistentes e esperados.

O erro aqui são suas expectativas sobre o que será produzido, não o que realmente foi produzido. Se você deseja que o compilador gere a mesma saída, alimente a mesma entrada. Não é matematicamente o mesmo, não é o mesmo, mas na verdade é o mesmo, sem caminhos diferentes, sem operações de compartilhamento ou distribuição de uma versão para a outra. Este é um bom exercício para entender como escrever seu código e ver o que os compiladores fazem com ele. Não cometa o erro de supor que, porque uma versão do gcc para um destino de processador em um dia produziu um certo resultado, que é uma regra para todos os compiladores e todo o código. Você precisa usar muitos compiladores e muitos destinos para ter uma ideia do que está acontecendo.

O gcc é bastante desagradável, convido você a olhar por trás da cortina, olhar as entranhas do gcc, tentar adicionar um alvo ou modificar você mesmo. Mal é mantida unida por fita adesiva e fiação. Uma linha extra de código adicionada ou removida em locais críticos e desmoronando. O fato de ter produzido código utilizável é algo para se agradar, em vez de se preocupar com o motivo de não atender a outras expectativas.

você olhou para quais versões diferentes do gcc produzem? 3.xe 4.x em particular 4.5 vs 4.6 vs 4.7, etc? e para diferentes processadores de destino, x86, arm, mips, etc ou diferentes tipos de x86, se esse for o compilador nativo que você usa, 32 bits versus 64 bits, etc.? E então llvm (clang) para diferentes alvos?

O Mystical fez um excelente trabalho no processo de reflexão necessário para solucionar o problema de analisar / otimizar o código, esperando que um compilador desenvolva algo que não seja esperado de nenhum "compilador moderno".

Sem entrar nas propriedades matemáticas, o código deste formulário

if (exponent < 0) {
  r = mantissa << -exponent;                       /* diff */
} else {
  r = mantissa >> exponent;                        /* diff */
}
return (r ^ -sign) + sign;                           /* diff */

vai levar o compilador para A: implementá-lo dessa forma, execute o if-then-else e, em seguida, convergir no código comum para concluir e retornar. ou B: salve um ramo, pois esse é o final da função. Também não se preocupe em usar ou salvar r.

if (exponent < 0) {
  return((mantissa << -exponent)^-sign)+sign;
} else {
  return((mantissa << -exponent)^-sign)+sign;
}

Então você pode entrar como Mystical apontou que a variável de sinal desaparece todos juntos para o código conforme escrito. Eu não esperaria que o compilador visse a variável de sinal desaparecer, então você deveria ter feito isso sozinho e não forçado o compilador a tentar descobrir isso.

Esta é uma oportunidade perfeita para descobrir o código fonte do gcc. Parece que você encontrou um caso em que o otimizador viu uma coisa em um caso e outra em outro caso. Depois, dê o próximo passo e veja se você não consegue que o gcc veja esse caso. Toda otimização existe porque algum indivíduo ou grupo reconheceu a otimização e a colocou intencionalmente lá. Para que essa otimização esteja lá e funcione toda vez que alguém tiver que colocá-la lá (e depois testá-la e mantê-la no futuro).

Definitivamente, não assuma que menos código é mais rápido e mais lento, é muito fácil criar e encontrar exemplos de que isso não é verdade. Na maioria das vezes, pode ser o caso de menos código ser mais rápido que mais código. Como demonstrei desde o início, você pode criar mais código para salvar ramificações nesse caso ou em loop, etc., e fazer com que o resultado líquido seja um código mais rápido.

O resultado final é que você alimentou uma fonte diferente do compilador e esperou os mesmos resultados. O problema não é a saída do compilador, mas as expectativas do usuário. É bastante fácil demonstrar para um compilador e processador específico, a adição de uma linha de código que torna toda uma função muito mais lenta. Por exemplo, por que alterar a = b + 2; para a = b + c + 2; causa _fill_in_the_blank_compiler_name_ gerar código radicalmente diferente e mais lento? A resposta, obviamente, sendo o compilador, recebeu um código diferente na entrada, portanto é perfeitamente válido que o compilador gere uma saída diferente. (Melhor ainda é quando você troca duas linhas de código não relacionadas e faz com que a saída mude drasticamente). Não há relação esperada entre a complexidade e o tamanho da entrada com a complexidade e o tamanho da saída.

for(ra=0;ra<20;ra++) dummy(ra);

Produziu algo entre 60-100 linhas de montador. Desenrolou o loop. Não contei as linhas, se você pensar bem, tem que adicionar, copiar o resultado da entrada para a chamada de função, fazer a chamada de função, três operações no mínimo. portanto, dependendo do alvo que provavelmente tenha 60 instruções, pelo menos, 80 se quatro por loop, 100 se cinco por loop, etc.

old_timer
fonte
Por que você vandalizou sua resposta? Oded também parecia discordar da edição ;-).
Peter - Restabelece Monica
@ PeterA.Schneider, todas as suas respostas parecem ter sido vandalizadas na mesma data. Acho que alguém com os dados da sua conta (roubada?) Fez isso.
precisa
23

O Mysticial já deu uma ótima explicação, mas pensei em acrescentar, FWIW, que não há realmente nada fundamental sobre por que um compilador faria a otimização para um e não para o outro.

O clangcompilador do LLVM , por exemplo, fornece o mesmo código para ambas as funções (exceto o nome da função), fornecendo:

_fast_trunc_two:                        ## @fast_trunc_one
        movl    %edi, %edx
        andl    $-2147483648, %edx      ## imm = 0xFFFFFFFF80000000
        movl    %edi, %esi
        andl    $8388607, %esi          ## imm = 0x7FFFFF
        orl     $8388608, %esi          ## imm = 0x800000
        shrl    $23, %edi
        movzbl  %dil, %eax
        movl    $150, %ecx
        subl    %eax, %ecx
        js      LBB0_1
        shrl    %cl, %esi
        jmp     LBB0_3
LBB0_1:                                 ## %if.then
        negl    %ecx
        shll    %cl, %esi
LBB0_3:                                 ## %if.end
        movl    %edx, %eax
        negl    %eax
        xorl    %esi, %eax
        addl    %edx, %eax
        ret

Esse código não é tão curto quanto a primeira versão do gcc do OP, mas não tão longo quanto a segunda.

O código de outro compilador (que não vou citar), compilando para x86_64, produz isso para as duas funções:

fast_trunc_one:
        movl      %edi, %ecx        
        shrl      $23, %ecx         
        movl      %edi, %eax        
        movzbl    %cl, %edx         
        andl      $8388607, %eax    
        negl      %edx              
        orl       $8388608, %eax    
        addl      $150, %edx        
        movl      %eax, %esi        
        movl      %edx, %ecx        
        andl      $-2147483648, %edi
        negl      %ecx              
        movl      %edi, %r8d        
        shll      %cl, %esi         
        negl      %r8d              
        movl      %edx, %ecx        
        shrl      %cl, %eax         
        testl     %edx, %edx        
        cmovl     %esi, %eax        
        xorl      %r8d, %eax        
        addl      %edi, %eax        
        ret                         

o que é fascinante, pois calcula os dois lados do if e depois usa um movimento condicional no final para escolher o caminho certo.

O compilador Open64 produz o seguinte:

fast_trunc_one: 
    movl %edi,%r9d                  
    sarl $23,%r9d                   
    movzbl %r9b,%r9d                
    addl $-150,%r9d                 
    movl %edi,%eax                  
    movl %r9d,%r8d                  
    andl $8388607,%eax              
    negl %r8d                       
    orl $8388608,%eax               
    testl %r8d,%r8d                 
    jl .LBB2_fast_trunc_one         
    movl %r8d,%ecx                  
    movl %eax,%edx                  
    sarl %cl,%edx                   
.Lt_0_1538:
    andl $-2147483648,%edi          
    movl %edi,%eax                  
    negl %eax                       
    xorl %edx,%eax                  
    addl %edi,%eax                  
    ret                             
    .p2align 5,,31
.LBB2_fast_trunc_one:
    movl %r9d,%ecx                  
    movl %eax,%edx                  
    shll %cl,%edx                   
    jmp .Lt_0_1538                  

e código semelhante, mas não idêntico fast_trunc_two.

De qualquer forma, quando se trata de otimização, é uma loteria - é o que é ... nem sempre é fácil saber por que o código é compilado de alguma maneira específica.

Charphacy
fonte
10
É o compilador que você não nomeará um supercompilador ultra-secreto?
orlp
4
o compilador Top Secret é provavelmente Intel icc. Eu só tenho a variante de 32 bits, mas produz código muito semelhante a isso.
Janus Troelsen
5
Eu também acredito que é ICC. O compilador sabe que o processador é capaz de paralelismo no nível de instrução e, portanto, ambas as ramificações podem ser computadas simultaneamente. A sobrecarga da movimentação condicional é muito menor que a sobrecarga da previsão de ramificação falsa.
Filip Navara