Por que o GCC usa a multiplicação por um número estranho na implementação da divisão inteira?

228

Estive lendo sobre dive mulmontagem de operações, e eu decidi vê-los em ação, escrevendo um programa simples em C:

Arquivo division.c

#include <stdlib.h>
#include <stdio.h>

int main()
{
    size_t i = 9;
    size_t j = i / 5;
    printf("%zu\n",j);
    return 0;
}

E, em seguida, gerando o código da linguagem assembly com:

gcc -S division.c -O0 -masm=intel

Mas olhando para o division.sarquivo gerado , ele não contém nenhuma operação div! Em vez disso, ele faz algum tipo de magia negra com números de mágica e deslocamento de bits. Aqui está um trecho de código que calcula i/5:

mov     rax, QWORD PTR [rbp-16]   ; Move i (=9) to RAX
movabs  rdx, -3689348814741910323 ; Move some magic number to RDX (?)
mul     rdx                       ; Multiply 9 by magic number
mov     rax, rdx                  ; Take only the upper 64 bits of the result
shr     rax, 2                    ; Shift these bits 2 places to the right (?)
mov     QWORD PTR [rbp-8], rax    ; Magically, RAX contains 9/5=1 now, 
                                  ; so we can assign it to j

O que está acontecendo aqui? Por que o GCC não usa div? Como ele gera esse número mágico e por que tudo funciona?

qiubit
fonte
29
O gcc otimiza as divisões por constantes, tente as divisões por 2,3,4,5,6,7,8 e você provavelmente verá um código muito diferente para cada caso.
precisa
28
Nota: O número mágico é -3689348814741910323convertido em CCCCCCCCCCCCCCCDcomo uint64_tou aproximadamente (2 ^ 64) * 4/5.
chux - Restabelece Monica
32
@qiubit: O compilador nem gera perversamente código ineficiente apenas porque a otimização está desabilitada. Uma "otimização" trivial que não envolve reordenação de código ou eliminação de variável será executada independentemente, por exemplo. Essencialmente, uma única declaração de origem será convertida no código mais eficiente para essa operação isoladamente. A otimização do compilador leva em consideração o código circundante, e não apenas a instrução única.
Clifford
20
Leia este artigo incrível: Labor of Division
Jester
9
Alguns compiladores realmente irá perversamente gerar código ineficiente porque a otimização está desativado. Em particular, eles fazem isso para facilitar a depuração, como a capacidade de definir pontos de interrupção em linhas de código individuais. De fato, o GCC é bastante incomum, pois não possui um modo "sem otimizações" verdadeiro, porque muitas de suas otimizações são constitutivamente ativadas. Este é um exemplo de onde você pode ver isso no GCC. Clang, por outro lado, e MSVC, irá emitir uma divinstrução no -O0. (cc @ clifford)
Cody Gray

Respostas:

169

A divisão inteira é uma das operações aritméticas mais lentas que você pode executar em um processador moderno, com latência de até dezenas de ciclos e taxa de transferência ruim. (Para x86, consulte as tabelas de instruções e o guia de microarquitetura de Agner Fog ).

Se você conhece o divisor com antecedência, pode evitar a divisão substituindo-a por um conjunto de outras operações (multiplicações, adições e turnos) que têm o efeito equivalente. Mesmo que sejam necessárias várias operações, muitas vezes ainda é muito mais rápido que a própria divisão inteira.

A implementação do /operador C dessa maneira, em vez de com uma sequência de instruções múltiplas, divé apenas a maneira padrão do GCC de fazer a divisão por constantes. Não requer otimização entre operações e não altera nada, mesmo para depuração. (Usar -Ospara tamanho de código pequeno div, no entanto, o GCC usa .) Usar um inverso multiplicativo em vez de divisão é como usar em leavez de muleadd

Como resultado, você só tende a ver divou idivproduzir se o divisor não for conhecido no momento da compilação.

Para obter informações sobre como o compilador gera essas seqüências, bem como o código para permitir que você as gere por si mesmo (quase certamente desnecessário, a menos que você esteja trabalhando com um compilador braindead ), consulte libdivide .

Sneftel
fonte
5
Não sei se é justo agrupar operações FP e números inteiros em uma comparação de velocidade, @fuz. Talvez Sneftel deva estar dizendo que a divisão é a operação inteira mais lenta que você pode executar em um processador moderno? Além disso, alguns links para explicações adicionais dessa "mágica" foram fornecidos nos comentários. Você acha que seria apropriado coletar sua resposta para ter visibilidade? 1 , 2 , 3
Cody Gray
1
Como a sequência de operações é funcionalmente idêntica ... isso é sempre um requisito, mesmo em -O3. O compilador precisa criar um código que dê resultados corretos para todos os possíveis valores de entrada. Isso muda apenas para o ponto flutuante com -ffast-math, e no AFAIK não há otimizações de número inteiro "perigosas". (Com a otimização ativada, o compilador pode ser capaz de provar alguma coisa sobre o possível intervalo de valores que permite que ele use algo que só funciona para inteiros assinados não negativos, por exemplo.)
Peter Cordes
6
A resposta real é que o gcc -O0 ainda transforma o código através de representações internas como parte de transformar C em código de máquina . Acontece que inversos multiplicativos modulares são ativados por padrão, mesmo em -O0(mas não com -Os). Outros compiladores (como o clang) usarão o DIV para constantes sem potência de 2 em -O0. relacionado: Eu acho que incluí um parágrafo sobre isso na minha resposta asm escrita por conjecturas de Collatz
Peter Cordes
6
@ PeterCordes E sim, acho que o GCC (e muitos outros compiladores) se esqueceram de apresentar uma boa justificativa para "que tipos de otimizações se aplicam quando a otimização está desativada". Depois de passar a maior parte do dia rastreando um bug obscuro de codegen, estou um pouco irritado com isso no momento.
Sneftel
9
@ Sneftel: Provavelmente é apenas porque o número de desenvolvedores de aplicativos que reclamam ativamente aos desenvolvedores do compilador sobre o código executado mais rápido do que o esperado é relativamente pequeno.
dan04
121

Dividir por 5 é o mesmo que multiplicar 1/5, que é novamente o mesmo que multiplicar por 4/5 e deslocar 2 bits para a direita. O valor em questão é CCCCCCCCCCCCCCCDhexadecimal, que é a representação binária de 4/5 se colocada após um ponto hexadecimal (ou seja, o binário de quatro quintos é 0.110011001100recorrente - veja abaixo o porquê). Eu acho que você pode pegar daqui! Convém verificar a aritmética de ponto fixo (embora note que ela é arredondada para um número inteiro no final.

Por que razão, a multiplicação é mais rápida que a divisão e, quando o divisor é fixo, esta é uma rota mais rápida.

Consulte Multiplicação Recíproca, um tutorial para uma descrição detalhada de como funciona, explicando em termos de ponto fixo. Ele mostra como o algoritmo para encontrar os recíprocos funciona e como lidar com a divisão e o módulo assinados.

Vamos considerar por um minuto por que 0.CCCCCCCC...(hex) ou 0.110011001100...binário é 4/5. Divida a representação binária por 4 (mova para a direita 2 lugares) e obteremos 0.001100110011...qual, por inspeção trivial, pode ser adicionado o original a ser obtido 0.111111111111..., que obviamente é igual a 1, da mesma forma 0.9999999...em decimal é igual a um. Portanto, sabemos que x + x/4 = 1, por isso 5x/4 = 1, x=4/5. Isso é representado como CCCCCCCCCCCCDem hexadecimal para arredondamento (como o dígito binário além do último presente seria a 1).

abligh
fonte
2
@ user2357112 fique à vontade para postar sua própria resposta, mas não concordo. Você pode pensar na multiplicação como uma multiplicação de 64,0 por 0,64 bits, fornecendo uma resposta de ponto fixo de 128 bits, da qual os 64 bits mais baixos são descartados e, em seguida, uma divisão por 4 (como aponto no primeiro parágrafo). Você pode conseguir uma resposta aritmética modular alternativa que explica os movimentos dos bits igualmente bem, mas tenho certeza de que isso funciona como uma explicação.
abligh
6
O valor é realmente "CCCCCCCCCCCCCCCD". O último D é importante, pois garante que, quando o resultado for truncado, as divisões exatas saiam com a resposta certa.
plugwash
4
Deixa pra lá. Não vi que eles pegassem os 64 bits superiores do resultado da multiplicação de 128 bits; não é algo que você pode fazer na maioria dos idiomas, então eu não percebi inicialmente que isso estava acontecendo. Essa resposta seria muito melhorada com uma menção explícita de como pegar os 64 bits superiores do resultado de 128 bits equivale a multiplicar por um número de ponto fixo e arredondar para baixo. (Além disso, seria bom explicar por que tem que ser 4/5 em vez de 1/5 e por que temos que arredondar 4/5 para cima em vez de para baixo.)
user2357112 suporta Monica
2
Você deve ter certeza de que tamanho de erro é necessário para lançar uma divisão em 5 para cima através de um limite de arredondamento e comparar com o pior caso de erro na sua caclulação. Presumivelmente, os desenvolvedores do gcc fizeram isso e concluíram que ele sempre fornecerá os resultados corretos.
plugwash
3
Na verdade, você provavelmente só precisa verificar os 5 valores de entrada mais altos possíveis, se esses arredondarem corretamente tudo o resto deveria.
Plugwash
60

Em geral, a multiplicação é muito mais rápida que a divisão. Portanto, se conseguirmos multiplicar pelo recíproco, podemos acelerar significativamente a divisão por uma constante

Uma desvantagem é que não podemos representar exatamente o recíproco (a menos que a divisão tenha uma potência de dois, mas nesse caso geralmente podemos apenas converter a divisão em uma pequena mudança). Portanto, para garantir respostas corretas, precisamos ter cuidado para que o erro em nosso recíproco não cause erros em nosso resultado final.

-3689348814741910323 é 0xCCCCCCCCCCCCCCCCCD, que é um valor de pouco mais de 4/5, expresso em 0,64 ponto fixo.

Quando multiplicamos um número inteiro de 64 bits por um número de ponto fixo de 0,64, obtemos um resultado de 64,64. Truncamos o valor para um número inteiro de 64 bits (arredondando-o efetivamente para zero) e, em seguida, executamos um deslocamento adicional que divide por quatro e trunca novamente. Observando o nível de bit, fica claro que podemos tratar ambos os truncamentos como um único truncamento.

Isso claramente nos dá pelo menos uma aproximação da divisão por 5, mas nos dá uma resposta exata corretamente arredondada para zero?

Para obter uma resposta exata, o erro precisa ser pequeno o suficiente para não empurrar a resposta sobre um limite de arredondamento.

A resposta exata para uma divisão por 5 sempre terá uma parte fracionária de 0, 1/5, 2/5, 3/5 ou 4/5. Portanto, um erro positivo menor que 1/5 no resultado multiplicado e deslocado nunca empurrará o resultado acima de um limite de arredondamento.

O erro em nossa constante é (1/5) * 2 -64 . O valor de i é menor que 2 64, portanto, o erro após a multiplicação é menor que 1/5. Após a divisão por 4, o erro é menor que (1/5) * 2 −2 .

(1/5) * 2 −2 <1/5, portanto a resposta sempre será igual a fazer uma divisão exata e arredondar para zero.


Infelizmente, isso não funciona para todos os divisores.

Se tentarmos representar 4/7 como um número de ponto fixo de 0,64 com arredondamento para zero, terminamos com um erro de (6/7) * 2 -64 . Depois de multiplicar por um valor i abaixo de 2 64 , terminamos com um erro abaixo de 6/7 e depois de dividir por quatro, terminamos com um erro abaixo de 1,5 / 7, que é maior que 1/7.

Portanto, para implementar a divisão por 7 corretamente, precisamos multiplicar por um número de ponto fixo de 0,65. Podemos implementar isso multiplicando pelos 64 bits inferiores do nosso número de ponto fixo e adicionando o número original (isso pode transbordar no bit de transporte) e, em seguida, rodando o transporte.

plugwash
fonte
8
Essa resposta transformou inversos multiplicativos modulares de "matemática que parece mais complicada do que eu quero dedicar tempo" em algo que faz sentido. +1 para a versão fácil de entender. Eu nunca precisei fazer nada além de usar constantes geradas pelo compilador; portanto, apenas procurei em outros artigos que explicam a matemática.
Peter Cordes
2
Não vejo nada a ver com aritmética modular no código. Não sei de onde outros comentaristas estão conseguindo isso.
plugwash
3
É o módulo 2 ^ n, como toda matemática inteira em um registro. en.wikipedia.org/wiki/…
Peter Cordes
4
Os inversos multiplicativos modulares do @PeterCordes são usados ​​para a divisão exata, mas eles não são úteis para a divisão geral
harold
4
@PeterCordes multiplicação por ponto fixo recíproco? Eu não sei o que todos chamam, mas eu provavelmente chamaria assim, é bastante descritivo #
harold
12

Aqui está o link para um documento de um algoritmo que produz os valores e o código que eu vejo no Visual Studio (na maioria dos casos) e que suponho que ainda seja usado no GCC para dividir um número inteiro variável por um número inteiro constante.

http://gmplib.org/~tege/divcnst-pldi94.pdf

No artigo, uma palavra-chave tem N bits, uma palavra-chave tem 2N bits, n = numerador = dividendo, d = denominador = divisor, ℓ é definido inicialmente como teto (log2 (d)), shpre é pré-deslocamento (usado antes da multiplicação ) = e = número de bits zero à direita em d, shpost é pós-deslocamento (usado após multiplicar), prec é precisão = N - e = N - shpre. O objetivo é otimizar o cálculo de n / d usando um pré-turno, multiplicação e pós-turno.

Role para baixo até a figura 6.2, que define como um multiplicador de udword (o tamanho máximo é de N + 1 bits) é gerado, mas não explica claramente o processo. Vou explicar isso abaixo.

A Figura 4.2 e a Figura 6.2 mostram como o multiplicador pode ser reduzido a um multiplicador de N bits ou menos para a maioria dos divisores. A Equação 4.5 explica como a fórmula usada para lidar com multiplicadores de bits N + 1 na figura 4.1 e 4.2 foi derivada.

No caso do X86 moderno e de outros processadores, o tempo de multiplicação é fixo, portanto a pré-mudança não ajuda nesses processadores, mas ainda ajuda a reduzir o multiplicador de N + 1 bits para N bits. Não sei se o GCC ou o Visual Studio eliminaram a pré-mudança para destinos X86.

Voltando à Figura 6.2. O numerador (dividendo) para mlow e mhigh pode ser maior que uma palavra-chave somente quando denominador (divisor)> 2 ^ (N-1) (quando ℓ == N => mlow = 2 ^ (2N)), nesse caso, o a substituição otimizada para n / d é uma comparação (se n> = d, q = 1, senão q = 0), portanto, nenhum multiplicador é gerado. Os valores iniciais de mlow e mhigh serão N + 1 bits, e duas divisões udword / uword podem ser usadas para produzir cada valor de N + 1 bit (mlow ou mhigh). Usando o X86 no modo de 64 bits como exemplo:

; upper 8 bytes of dividend = 2^(ℓ) = (upper part of 2^(N+ℓ))
; lower 8 bytes of dividend for mlow  = 0
; lower 8 bytes of dividend for mhigh = 2^(N+ℓ-prec) = 2^(ℓ+shpre) = 2^(ℓ+e)
dividend  dq    2 dup(?)        ;16 byte dividend
divisor   dq    1 dup(?)        ; 8 byte divisor

; ...
        mov     rcx,divisor
        mov     rdx,0
        mov     rax,dividend+8     ;upper 8 bytes of dividend
        div     rcx                ;after div, rax == 1
        mov     rax,dividend       ;lower 8 bytes of dividend
        div     rcx
        mov     rdx,1              ;rdx:rax = N+1 bit value = 65 bit value

Você pode testar isso com o GCC. Você já viu como j = i / 5 é tratado. Veja como j = i / 7 é tratado (que deve ser o caso multiplicador de N + 1 bit).

Na maioria dos processadores atuais, a multiplicação tem um tempo fixo, portanto não é necessário um pré-turno. Para X86, o resultado final é uma sequência de duas instruções para a maioria dos divisores e uma sequência de cinco instruções para divisores como 7 (para emular um multiplicador de N + 1 bit, como mostrado na equação 4.5 e na figura 4.2 do arquivo pdf). Exemplo de código X86-64:

;       rax = dividend, rbx = 64 bit (or less) multiplier, rcx = post shift count
;       two instruction sequence for most divisors:

        mul     rbx                     ;rdx = upper 64 bits of product
        shr     rdx,cl                  ;rdx = quotient
;
;       five instruction sequence for divisors like 7
;       to emulate 65 bit multiplier (rbx = lower 64 bits of multiplier)

        mul     rbx                     ;rdx = upper 64 bits of product
        sub     rbx,rdx                 ;rbx -= rdx
        shr     rbx,1                   ;rbx >>= 1
        add     rdx,rbx                 ;rdx = upper 64 bits of corrected product
        shr     rdx,cl                  ;rdx = quotient
;       ...
rcgldr
fonte
Esse documento descreve a implementação no gcc, então acho que é uma suposição segura de que o mesmo algo ainda seja usado.
Peter Cordes
Esse documento de 1994 descreve a implementação no gcc, portanto, houve tempo para o gcc atualizar seu algoritmo. Apenas no caso de outras pessoas não terem tempo para verificar o significado dos 94 nesse URL.
Ed Grimm
0

Responderei de um ângulo um pouco diferente: porque é permitido fazê-lo.

C e C ++ são definidos em uma máquina abstrata. O compilador transforma esse programa em termos da máquina abstrata em máquina concreta, seguindo a regra do tipo " como se ".

  • O compilador tem permissão para fazer QUALQUER alteração, desde que não altere o comportamento observável, conforme especificado pela máquina abstrata. Não há expectativa razoável de que o compilador transforme seu código da maneira mais direta possível (mesmo quando muitos programadores em C assumem isso). Geralmente, isso ocorre porque o compilador deseja otimizar o desempenho em comparação com a abordagem direta (conforme discutido em outras respostas detalhadamente).
  • Se, em qualquer circunstância, o compilador "otimizar" um programa correto para algo que tenha um comportamento observável diferente, isso é um bug do compilador.
  • Qualquer comportamento indefinido em nosso código (estouro de número inteiro assinado é um exemplo clássico) e este contrato é anulado.
dmeister
fonte