Estive lendo sobre div
e mul
montagem 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.s
arquivo 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?
-3689348814741910323
convertido emCCCCCCCCCCCCCCCD
comouint64_t
ou aproximadamente (2 ^ 64) * 4/5.div
instrução no-O0
. (cc @ clifford)Respostas:
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-Os
para tamanho de código pequenodiv
, no entanto, o GCC usa .) Usar um inverso multiplicativo em vez de divisão é como usar emlea
vez demul
eadd
Como resultado, você só tende a ver
div
ouidiv
produzir 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 .
fonte
-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.)-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 CollatzDividir 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 é
CCCCCCCCCCCCCCCD
hexadecimal, 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.110011001100
recorrente - 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) ou0.110011001100...
binário é 4/5. Divida a representação binária por 4 (mova para a direita 2 lugares) e obteremos0.001100110011...
qual, por inspeção trivial, pode ser adicionado o original a ser obtido0.111111111111...
, que obviamente é igual a 1, da mesma forma0.9999999...
em decimal é igual a um. Portanto, sabemos quex + x/4 = 1
, por isso5x/4 = 1
,x=4/5
. Isso é representado comoCCCCCCCCCCCCD
em hexadecimal para arredondamento (como o dígito binário além do último presente seria a1
).fonte
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.
fonte
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:
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:
fonte
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 ".
fonte