Velocidades de << >> multiplicação e divisão

9

Você pode usar <<para multiplicar e >>dividir números em python quando eu cronometro eles, acho que o uso da maneira de deslocamento binário é 10x mais rápido do que dividir ou multiplicar da maneira regular.

Por que usar <<e >>muito mais rápido que *e /?

Quais são os processos por trás dos bastidores *e /tão lentos?

Crizly
fonte
2
A troca de bits é mais rápida em todos os idiomas, não apenas no Python. Muitos processadores possuem uma instrução de troca de bits nativa que a realizará em um ou dois ciclos de clock.
Robert Harvey
4
No entanto, deve-se ter em mente que o deslocamento de bits, em vez de usar os operadores normais de divisão e multiplicação, geralmente é uma prática ruim e pode prejudicar a legibilidade.
Azar
6
@crizly Porque, na melhor das hipóteses, é uma micro-otimização e há uma boa chance de o compilador alterá-lo para uma mudança no bytecode de qualquer maneira (se possível). Existem exceções, como quando o código é extremamente crítico para o desempenho, mas na maioria das vezes tudo o que você faz é ofuscar seu código.
Azar
7
@ Crizly: Qualquer compilador com um otimizador decente reconhecerá multiplicações e divisões que podem ser feitas com mudanças de bits e gerará código que as usará. Não exagere no código, tentando enganar o compilador.
Blrfl
2
Em esta questão em StackOverflow um microbenchmark encontrado ligeiramente melhor desempenho em Python 3 para multiplicação por 2 do que para um deslocamento para a esquerda equivalente, para números suficientes pequenas. Eu acho que rastreei o motivo até pequenas multiplicações (atualmente) serem otimizadas de maneira diferente das mudanças de bits. Isso mostra que você não pode dar como certo o que será executado mais rapidamente com base na teoria.
Dan Getz

Respostas:

15

Vamos olhar para dois pequenos programas C que mudam e dividem um pouco.

#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int b = i << 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int d = i / 4;
}

Estes são então compilados gcc -Spara ver qual será a montagem real.

Com a versão de troca de bits, da chamada atoipara retornar:

    callq   _atoi
    movl    $0, %ecx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    shll    $2, %eax
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

Enquanto a versão dividida:

    callq   _atoi
    movl    $0, %ecx
    movl    $4, %edx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    movl    %edx, -28(%rbp)         ## 4-byte Spill
    cltd
    movl    -28(%rbp), %r8d         ## 4-byte Reload
    idivl   %r8d
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

Só de olhar para isso, há várias outras instruções na versão dividida em comparação com a mudança de bits.

A chave é o que eles fazem?

Na versão de deslocamento de bits, a instrução principal é shll $2, %eaxqual é o deslocamento deixado lógico - existe a divisão, e todo o resto está apenas movendo valores.

Na versão dividida, você pode ver o idivl %r8d- mas logo acima disso há uma cltd(converter muito para o dobro) e alguma lógica adicional em torno do derramamento e do recarregamento. Esse trabalho adicional, sabendo que estamos lidando com uma matemática em vez de bits, é frequentemente necessário para evitar vários erros que podem ocorrer ao fazer apenas a matemática de bits.

Vamos fazer uma multiplicação rápida:

#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int b = i >> 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int d = i * 4;
}

Em vez de passar por tudo isso, há uma linha diferente:

$ diff mult.s bit.s
24c24
> $ 2,% eax
---
<sarl $ 2,% eax

Aqui, o compilador foi capaz de identificar que a matemática poderia ser feita com uma mudança, no entanto, em vez de uma mudança lógica, ela faz uma mudança aritmética. A diferença entre estes seria óbvia se os executássemos - sarlpreserva o sinal. Então, -2 * 4 = -8enquanto shllisso não acontece.

Vamos analisar isso em um script perl rápido:

#!/usr/bin/perl

$foo = 4;
print $foo << 2, "\n";
print $foo * 4, "\n";

$foo = -4;
print $foo << 2, "\n";
print $foo * 4, "\n";

Resultado:

16
16
18446744073709551600
-16

Um ... -4 << 2é o 18446744073709551600que não é exatamente o que você provavelmente espera ao lidar com multiplicação e divisão. Está certo, mas não é uma multiplicação inteira.

E, portanto, tenha cuidado com a otimização prematura. Deixe o compilador otimizar para você - ele sabe o que realmente está tentando fazer e provavelmente fará um trabalho melhor com menos bugs.


fonte
12
Pode ser mais claro emparelhar << 2com * 4e >> 2com / 4manter as direções de mudança iguais em cada exemplo.
precisa saber é o seguinte
5

As respostas existentes realmente não abordam o lado do hardware, então aqui está um pouco desse ângulo. A sabedoria convencional é que multiplicação e divisão são muito mais lentas do que mudanças, mas a história atual hoje é mais sutil.

Por exemplo, certamente é verdade que a multiplicação é uma operação mais complexa para implementar no hardware, mas nem sempre acaba sempre mais lenta . Como se vê, addtambém é significativamente mais complexo de implementar do que xor(ou em geral qualquer operação bit a bit), mas add(e sub) normalmente recebe transistores suficientes dedicados à sua operação que acabam sendo tão rápidos quanto os operadores bit a bit. Portanto, você não pode apenas olhar para a complexidade da implementação de hardware como um guia de velocidade.

Então, vamos analisar em detalhes as mudanças versus os operadores "completos", como multiplicação e mudança.

Mudança

Em quase todo o hardware, a troca por uma quantidade constante (ou seja, uma quantidade que o compilador pode determinar em tempo de compilação) é rápida . Em particular, isso geralmente acontece com a latência de um único ciclo e com uma taxa de transferência de 1 por ciclo ou melhor. Em alguns hardwares (por exemplo, alguns chips Intel e ARM), certas mudanças por uma constante podem até ser "livres", pois podem ser incorporadas a outra instrução ( leana Intel, as habilidades especiais de mudança da primeira fonte no ARM).

Mudar por uma quantidade variável é mais uma área cinzenta. No hardware antigo, isso às vezes era muito lento e a velocidade mudava de geração para geração. Por exemplo, no lançamento inicial do P4 da Intel, a troca por uma quantidade variável era notoriamente lenta - exigindo tempo proporcional à quantidade de troca! Nessa plataforma, o uso de multiplicações para substituir turnos pode ser lucrativo (ou seja, o mundo ficou de cabeça para baixo). Nos chips anteriores da Intel, bem como nas gerações subseqüentes, a troca por uma quantidade variável não era tão dolorosa.

Nos chips Intel atuais, a troca por uma quantidade variável não é particularmente rápida, mas também não é terrível. A arquitetura x86 é prejudicada quando se trata de turnos variáveis, porque eles definem a operação de uma maneira incomum: as quantidades de turnos de 0 não modificam os sinalizadores de condição, mas todos os outros turnos. Isso inibe a renomeação eficiente do registrador de sinalizadores, pois ele não pode ser determinado até que o turno execute se instruções subsequentes devem ler os códigos de condição escritos pelo turno ou alguma instrução anterior. Além disso, os turnos gravam apenas em parte do registro de sinalizadores, o que pode causar uma paralisação parcial dos sinalizadores.

O resultado é que, nas arquiteturas recentes da Intel, o deslocamento por uma quantidade variável leva três "micro-operações", enquanto a maioria das outras operações simples (adição, operações bit a bit e até multiplicação) leva apenas 1. Essas mudanças podem ser executadas no máximo uma vez a cada 2 ciclos .

Multiplicação

A tendência no hardware moderno de desktop e laptop é tornar a multiplicação uma operação rápida. Nos recentes chips Intel e AMD, de fato, uma multiplicação pode ser emitida a cada ciclo (chamamos isso de taxa de transferência recíproca ). A latência , no entanto, de uma multiplicação é de 3 ciclos. Isso significa que você obtém o resultado de qualquer multiplicação 3 ciclos depois de iniciá-lo, mas é possível iniciar uma nova multiplicação a cada ciclo. Qual valor (1 ciclo ou 3 ciclos) é mais importante depende da estrutura do seu algoritmo. Se a multiplicação fizer parte de uma cadeia de dependência crítica, a latência é importante. Caso contrário, a taxa de transferência recíproca ou outros fatores podem ser mais importantes.

O principal argumento é que, nos modernos chips de laptop (ou melhor), a multiplicação é uma operação rápida e provavelmente mais rápida que a sequência de instruções 3 ou 4 que um compilador emitiria para "acertar o arredondamento" para mudanças de força reduzidas. Para turnos variáveis, na Intel, a multiplicação também seria geralmente preferida devido aos problemas mencionados acima.

Em plataformas de fator de forma menores, a multiplicação ainda pode ser mais lenta, pois a criação de um multiplicador completo e rápido de 32 ou 64 bits requer muitos transistores e energia. Se alguém puder preencher os detalhes do desempenho da multiplicação em chips móveis recentes, isso será muito apreciado.

Dividir

A divisão é uma operação mais complexa, em termos de hardware, do que multiplicação e também é muito menos comum no código real - o que significa que menos recursos provavelmente serão alocados a ela. A tendência nos chips modernos ainda é em direção a divisores mais rápidos, mas mesmo os chips topo de linha modernos levam de 10 a 40 ciclos para fazer uma divisão, e eles são canalizados apenas parcialmente. Em geral, as divisões de 64 bits são ainda mais lentas que as de 32 bits. Diferentemente da maioria das outras operações, a divisão pode levar um número variável de ciclos, dependendo dos argumentos.

Evite divisões e substitua por turnos (ou deixe o compilador fazê-lo, mas pode ser necessário verificar a montagem), se puder!

BeeOnRope
fonte
2

BINARY_LSHIFT e BINARY_RSHIFT são processos mais simples algoritmicamente que BINARY_MULTIPLY e BINARY_FLOOR_DIVIDE e podem levar menos ciclos de relógio. Ou seja, se você tiver qualquer número binário e precisar mudar de bits por N, tudo o que você precisa fazer é mudar os dígitos por muitos espaços e substituir por zeros. A multiplicação binária é geralmente mais complicada , embora técnicas como o multiplicador Dadda o tornem bastante rápido.

Concedido, é possível que um compilador de otimização reconheça casos quando você multiplica / divide por potências de dois e substitui pelo deslocamento de esquerda / direita apropriado. Observando o código de bytes desmontado, o python aparentemente não faz isso:

>>> dis.dis(lambda x: x*4)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (4)
              6 BINARY_MULTIPLY     
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x<<2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_LSHIFT       
              7 RETURN_VALUE        


>>> dis.dis(lambda x: x//2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_FLOOR_DIVIDE 
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x>>1)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (1)
              6 BINARY_RSHIFT       
              7 RETURN_VALUE        

No entanto, no meu processador, acho que a multiplicação e o deslocamento para a esquerda / direita têm um timing semelhante, e a divisão do piso (por uma potência de dois) é cerca de 25% mais lenta:

>>> import timeit

>>> timeit.repeat("z=a + 4", setup="a = 37")
[0.03717184066772461, 0.03291916847229004, 0.03287005424499512]

>>> timeit.repeat("z=a - 4", setup="a = 37")
[0.03534698486328125, 0.03207516670227051, 0.03196907043457031]

>>> timeit.repeat("z=a * 4", setup="a = 37")
[0.04594111442565918, 0.0408930778503418, 0.045324087142944336]

>>> timeit.repeat("z=a // 4", setup="a = 37")
[0.05412912368774414, 0.05091404914855957, 0.04910898208618164]

>>> timeit.repeat("z=a << 2", setup="a = 37")
[0.04751706123352051, 0.04259490966796875, 0.041903018951416016]

>>> timeit.repeat("z=a >> 2", setup="a = 37")
[0.04719185829162598, 0.04201006889343262, 0.042105913162231445]
dr jimbob
fonte