Vezes dois mais rápido que o deslocamento de bits, para números inteiros Python 3.x?

150

Eu estava olhando para a fonte de sortidos_containers e fiquei surpreso ao ver esta linha :

self._load, self._twice, self._half = load, load * 2, load >> 1

Aqui loadestá um número inteiro. Por que usar deslocamento de bits em um lugar e multiplicação em outro? Parece razoável que a troca de bits seja mais rápida que a divisão integral por 2, mas por que não substituir a multiplicação por uma troca também? Comparei os seguintes casos:

  1. (vezes, dividir)
  2. (turno, turno)
  3. (horários, turno)
  4. (mudar, dividir)

e descobrimos que o nº 3 é consistentemente mais rápido do que outras alternativas:

# self._load, self._twice, self._half = load, load * 2, load >> 1

import random
import timeit
import pandas as pd

x = random.randint(10 ** 3, 10 ** 6)

def test_naive():
    a, b, c = x, 2 * x, x // 2

def test_shift():
    a, b, c = x, x << 1, x >> 1    

def test_mixed():
    a, b, c = x, x * 2, x >> 1    

def test_mixed_swapped():
    a, b, c = x, x << 1, x // 2

def observe(k):
    print(k)
    return {
        'naive': timeit.timeit(test_naive),
        'shift': timeit.timeit(test_shift),
        'mixed': timeit.timeit(test_mixed),
        'mixed_swapped': timeit.timeit(test_mixed_swapped),
    }

def get_observations():
    return pd.DataFrame([observe(k) for k in range(100)])

insira a descrição da imagem aqui insira a descrição da imagem aqui

A questão:

Meu teste é válido? Se sim, por que (multiplicar, mudar) é mais rápido que (mudar, mudar)?

Eu executo o Python 3.5 no Ubuntu 14.04.

Editar

Acima está a declaração original da pergunta. Dan Getz fornece uma excelente explicação em sua resposta.

Por uma questão de integridade, aqui estão exemplos de ilustrações para maiores xquando as otimizações de multiplicação não se aplicam.

insira a descrição da imagem aqui insira a descrição da imagem aqui

hilberts_drinking_problem
fonte
3
Onde você definiu x?
JBernardo
3
Eu realmente gostaria de ver se há alguma diferença usando little endian / big endian. Muito legal pergunta btw!
LiGhTx117 5/05
1
@ LiGhTx117 Eu esperaria que isso não estivesse relacionado às operações, a menos que xseja muito grande, porque isso é apenas uma questão de como é armazenado na memória, certo?
Dan Getz
1
Estou curioso, que tal multiplicar por 0,5 em vez de dividir por 2? De experiência anterior com programação de montagem mips, a divisão normalmente resulta em uma operação de multiplicação de qualquer maneira. (Isso explicaria a preferência de bit deslocando em vez de divisão)
Sayse
2
@ Diga que o converteria em ponto flutuante. Esperamos que a divisão de piso inteiro seja mais rápida que uma ida e volta através do ponto flutuante.
Dan Getz 05/05

Respostas:

155

Parece que isso ocorre porque a multiplicação de números pequenos é otimizada no CPython 3.5, de uma maneira que as mudanças à esquerda em números pequenos não são. Deslocamentos à esquerda positivos sempre criam um objeto inteiro maior para armazenar o resultado, como parte do cálculo, enquanto que para multiplicações do tipo usado em seu teste, uma otimização especial evita isso e cria um objeto inteiro do tamanho correto. Isso pode ser visto no código fonte da implementação inteira do Python .

Como os números inteiros no Python são de precisão arbitrária, eles são armazenados como matrizes de "dígitos" inteiros, com um limite no número de bits por dígito inteiro. Portanto, no caso geral, operações envolvendo números inteiros não são operações únicas, mas precisam lidar com o caso de vários "dígitos". Em pyport.h , esse limite de bits é definido como 30 bits na plataforma de 64 bits ou 15 bits de outra forma. (Vou chamar esse número de 30 daqui para frente para manter a explicação simples. Mas observe que, se você estivesse usando o Python compilado para 32 bits, o resultado do seu benchmark dependeria se xfosse inferior a 32.768 ou não.)

Quando as entradas e saídas de uma operação ficam dentro desse limite de 30 bits, a operação pode ser manipulada de maneira otimizada, em vez da maneira geral. O início da implementação de multiplicação de números inteiros é o seguinte:

static PyObject *
long_mul(PyLongObject *a, PyLongObject *b)
{
    PyLongObject *z;

    CHECK_BINOP(a, b);

    /* fast path for single-digit multiplication */
    if (Py_ABS(Py_SIZE(a)) <= 1 && Py_ABS(Py_SIZE(b)) <= 1) {
        stwodigits v = (stwodigits)(MEDIUM_VALUE(a)) * MEDIUM_VALUE(b);
#ifdef HAVE_LONG_LONG
        return PyLong_FromLongLong((PY_LONG_LONG)v);
#else
        /* if we don't have long long then we're almost certainly
           using 15-bit digits, so v will fit in a long.  In the
           unlikely event that we're using 30-bit digits on a platform
           without long long, a large v will just cause us to fall
           through to the general multiplication code below. */
        if (v >= LONG_MIN && v <= LONG_MAX)
            return PyLong_FromLong((long)v);
#endif
    }

Portanto, ao multiplicar dois números inteiros em que cada um se encaixa em um dígito de 30 bits, isso é feito como uma multiplicação direta pelo interpretador CPython, em vez de trabalhar com os números inteiros como matrizes. ( MEDIUM_VALUE()chamado em um objeto inteiro positivo simplesmente obtém seu primeiro dígito de 30 bits.) Se o resultado couber em um único dígito de 30 bits, PyLong_FromLongLong()notará isso em um número relativamente pequeno de operações e criará um objeto inteiro de um dígito para armazenar isto.

Por outro lado, os desvios à esquerda não são otimizados dessa maneira, e todo desvio à esquerda lida com o número inteiro sendo alterado como uma matriz. Em particular, se você olhar para o código-fonte long_lshift(), no caso de um deslocamento à esquerda pequeno mas positivo, um objeto inteiro de 2 dígitos sempre será criado, apenas para ter seu comprimento truncado para 1 posteriormente: (meus comentários em /*** ***/)

static PyObject *
long_lshift(PyObject *v, PyObject *w)
{
    /*** ... ***/

    wordshift = shiftby / PyLong_SHIFT;   /*** zero for small w ***/
    remshift  = shiftby - wordshift * PyLong_SHIFT;   /*** w for small w ***/

    oldsize = Py_ABS(Py_SIZE(a));   /*** 1 for small v > 0 ***/
    newsize = oldsize + wordshift;
    if (remshift)
        ++newsize;   /*** here newsize becomes at least 2 for w > 0, v > 0 ***/
    z = _PyLong_New(newsize);

    /*** ... ***/
}

Divisão inteira

Você não perguntou sobre o pior desempenho da divisão de piso inteiro em comparação com as mudanças certas, porque isso se encaixa em suas (e minhas) expectativas. Mas dividir um pequeno número positivo por outro pequeno número positivo também não é tão otimizado quanto pequenas multiplicações. Cada //calcula tanto o quociente e o restante utilizando a função long_divrem(). Esse restante é calculado para um pequeno divisor com uma multiplicação e é armazenado em um objeto inteiro recém-alocado , que nessa situação é descartado imediatamente.

Dan Getz
fonte
1
É uma observação interessante com a divisão, obrigado por apontar. Escusado será dizer que esta é uma excelente resposta geral.
Hilberts_drinking_problem
Uma resposta bem pesquisada e escrita a uma excelente pergunta. Pode ser interessante mostrar gráficos para o tempo xfora do intervalo otimizado.
Barmar 10/10