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:
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)
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:
misturado = np.array([7, "a", 3.2, 9])
print(misturado.dtype, misturado)
<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:
print(array.flags["C_CONTIGUOUS"], array.strides)
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:
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)
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 Python | Array NumPy | |
|---|---|---|
| Tipos | Qualquer mistura | Um só, fixo |
| Memória | Ponteiros para objetos espalhados | Um bloco contínuo |
| Operação em massa | Laço do interpretador | Código compilado |
| Redimensionar | Fácil (append) | Caro (cria outro array) |
| Quando usar | Poucos itens, tipos variados | Muitos 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 * 2do 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.