Pular para o conteúdo

    Capítulo 4, Ambiente

    Por que o NumPy é rápido

    A resposta é a memória. Uma lista do Python guarda ponteiros para objetos espalhados. Um array do NumPy guarda os números lado a lado, em um bloco contínuo.

    Código deste capítulo: ambiente/cap04_por_que_rapido.py

    Cada elemento de uma lista é um objeto

    Em Python, 7 não é só o número sete. É um objeto inteiro, com cabeçalho, contagem de referências e tipo. A lista guarda um ponteiro para cada um desses objetos, e cada objeto mora em um lugar diferente da memória. Isso permite misturar tipos em uma mesma lista, ao custo de espaço e de velocidade. O array renuncia a essa flexibilidade de propósito:

    ambiente/cap04_por_que_rapido.pylinhas 10 a 21
    import sys
    
    import numpy as np
    
    lista = list(range(1_000))
    array = np.arange(1_000)
    
    print(type(lista[0]).__name__, "ocupa", sys.getsizeof(lista[0]), "bytes cada")
    print(array.dtype, "ocupa", array.itemsize, "bytes por elemento")
    
    bytes_lista = sys.getsizeof(lista) + sum(sys.getsizeof(x) for x in lista)
    print("a lista gasta mais de 3 vezes a memória do array:", bytes_lista > 3 * array.nbytes)
    
    Saída
    int ocupa 28 bytes cada
    int64 ocupa 8 bytes por elemento
    a lista gasta mais de 3 vezes a memória do array: True
    

    Cada inteiro Python ocupa 28 bytes, enquanto cada elemento do array ocupa 8. E o array não tem ponteiros: os mil números estão em um bloco contínuo de 8 mil bytes.

    Um tipo só, um bloco só

    Como todos os elementos têm o mesmo tipo e o mesmo tamanho, o NumPy não precisa verificar o tipo de cada um nem seguir ponteiros. O processador lê a memória em sequência, o que aproveita o cache, e consegue operar em vários números por instrução. É a combinação "mesmo tipo, bloco contínuo" que permite entregar a operação inteira a código compilado.

    O preço é que o array não aceita misturar. Se você tentar, o NumPy não reclama: converte tudo para um tipo comum, silenciosamente:

    ambiente/cap04_por_que_rapido.pylinhas 26 a 27
    misturado = np.array([7, "a", 3.2, 9])
    print(misturado.dtype, misturado)
    
    Saída
    <U32 ['7' 'a' '3.2' '9']
    

    O <U32 quer dizer "texto Unicode de até 32 caracteres". Os números viraram texto, e você não poderia mais somá-los. Esse é um erro clássico: ele não faz barulho, e só aparece mais tarde, quando uma conta dá errado.

    O bloco contínuo também é visível nas propriedades do array:

    ambiente/cap04_por_que_rapido.pylinha 29
    print(array.flags["C_CONTIGUOUS"], array.strides)
    
    Saída
    True (8,)
    

    O strides diz quantos bytes o NumPy avança para chegar ao próximo elemento: 8, exatamente o tamanho de um int64. O capítulo 29 explica esse detalhe, que é a base de como o NumPy fatia arrays sem copiar nada.

    Medir, em vez de acreditar

    A diferença de velocidade é grande o bastante para uma medição simples a mostrar. Eu repito cada medida algumas vezes e fico com a melhor, que é a menos afetada pelo ruído da máquina. Como os tempos absolutos variam de computador para computador, o que eu mostro é a comparação:

    ambiente/cap04_por_que_rapido.pylinhas 34 a 51
    import time
    
    
    def medir(funcao, repeticoes=5):
        melhor = float("inf")
        for _ in range(repeticoes):
            inicio = time.perf_counter()
            funcao()
            melhor = min(melhor, time.perf_counter() - inicio)
        return melhor
    
    
    grande_lista = list(range(1_000_000))
    grande_array = np.arange(1_000_000)
    t_lista = medir(lambda: [x * 2 for x in grande_lista])
    t_array = medir(lambda: grande_array * 2)
    print("o array foi mais rápido:", t_array < t_lista)
    print("pelo menos 10 vezes mais rápido:", t_lista > 10 * t_array)
    
    Saída
    o array foi mais rápido: True
    pelo menos 10 vezes mais rápido: True
    

    Na prática, a diferença costuma ficar em dezenas de vezes. E ela cresce com o tamanho: com poucos elementos, o custo fixo de chamar o NumPy domina e o ganho some. O capítulo 18 volta a esse ponto.

    Lista PythonArray NumPy
    TiposQualquer misturaUm só, fixo
    MemóriaPonteiros para objetos espalhadosUm bloco contínuo
    Operação em massaLaço do interpretadorCódigo compilado
    RedimensionarFácil (append)Caro (cria outro array)
    Quando usarPoucos itens, tipos variadosMuitos números do mesmo tipo

    O que "vetorizar" quer dizer

    Operar sobre o array inteiro de uma vez, sem escrever o laço, é o que chamamos de vetorização. O grande_array * 2 do exemplo acima é isso: o laço existe, mas roda em código compilado. O capítulo 12 trata disso em profundidade.

    Exercício 1

    Quanto ocupa um array?

    Escreva bytes_do_array(n, dtype) que devolva quantos bytes ocupa um array de n elementos de um tipo, usando np.dtype(...).itemsize.