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?


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 */
}

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.


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.


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
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.