6.19. Desempenho

As mesmas decisões de desenho que tornam o numpy rápido na câmara – chamadas de biblioteca em arrays inteiros, buffers tipificados compactos, vistas que partilham dados com a sua fonte – também expõem um conjunto de hábitos que vale a pena conhecer. A página Forma e strides já abordou a regra de disposição no último eixo; esta página cataloga os hábitos de alocação e dtype que mais importam num ciclo de processamento contínuo.

6.19.1. Escolha um dtype razoável

O dtype predefinido de cada construtor é float. Para dados que são naturalmente de 8 ou 16 bits – amostras de ADC, pixels de imagem, leituras de sensor – passe dtype= explicitamente para um dos tipos inteiros:

adc = np.array(adc_samples, dtype=np.uint16)

A poupança de RAM é de 2x para uint16 e de 4x para uint8 em relação ao padrão float de 4 bytes. A matemática também corre mais rapidamente porque os caminhos de código inteiro dentro do numpy são mais enxutos do que os genéricos de vírgula flutuante. A regra de overflow inteiro abordada em Dtypes aplica-se – converta para um tipo mais largo antes de aritmética que possa causar overflow.

6.19.2. Prefira ndarray a um iterável

A maioria das reduções e funções universais aceita tanto um iterável como um ndarray

np.sum([1, 2, 3, 4, 5])               # works, but slow
np.sum(np.array([1, 2, 3, 4, 5]))     # ~3x faster

A forma iterável obriga o numpy a percorrer a entrada um objeto Python de cada vez, convertendo cada um para um número antes de o poder utilizar. Com um ndarray, a conversão já está feita e a chamada percorre diretamente o buffer compacto.

Quando os mesmos dados são utilizados mais do que uma vez, construa o ndarray uma vez e passe-o adiante. Quando os dados existem apenas como uma lista Python e são consumidos uma vez, o custo de conversão pode superar a aceleração – o próprio construtor array() tem de percorrer a lista e alocar memória.

6.19.3. Prefira vistas a cópias

O fatiamento, a indexação de eixo único de um array de maior rank, reshape(), transpose() e frombuffer() devolvem vistas que partilham dados com a fonte. São essencialmente gratuitas.

copy(), flatten(), a indexação booleana (a[mask]) e qualquer expressão aritmética alocam uma cópia. Recorra a estas apenas quando for genuinamente necessário um buffer independente.

Em caso de dúvida, ndinfo() imprime a localização do buffer subjacente; dois arrays que reportam o mesmo endereço partilham os seus dados. A tabela completa de vista vs. cópia encontra-se em Vistas e cópias.

6.19.4. Alocar uma vez, depois escrever

A maior armadilha de desempenho na câmara é alocar arrays novos dentro de um ciclo que corre muitas vezes por segundo. Cada novo ndarray solicita RAM à câmara, e alocações frequentes desperdiçam-na.

A maioria das funções universais aceita out= para que o resultado possa ser escrito num array já existente:

x = np.linspace(0, 2 * np.pi, num=512)
y = np.zeros(512)        # allocate once

while True:
    np.sin(x, out=y)
    # use y ...

image.Image.to_ndarray() aceita buffer= pelo mesmo motivo; spectrogram() e os conversores no estilo from_int32_buffer() aceitam tanto out= como scratchpad=. Aloque tudo uma vez e reutilize.

6.19.5. Utilize operadores in-place

b = b + 1 aloca um temporário do tamanho de b, copia e volta a atribuir. b += 1 modifica b diretamente:

# makes a temporary
b = b + 1

# no temporary
b += 1

A mesma ideia aplica-se a expressões compostas. a + b * c aloca um temporário para b * c. Dividir a expressão em sub-atribuições simples que escrevem num buffer pré-alocado elimina os temporários:

# one temporary for (a + b), another for the ``* 2``
out = (a + b) * 2

# zero temporaries
out[:]  = a
out    += b
out    *= 2

6.19.6. Construa o resultado, não lhe acrescente elementos

ndarray não tem append – intencionalmente. Aumentar um array implicaria alocar um novo buffer maior e copiar o conteúdo antigo para ele. Num microcontrolador, pré-aloque o tamanho final e preencha-o

out = np.zeros(N, dtype=np.float)
for i in range(N):
    out[i] = some_calculation(i)

Quando N genuinamente não é conhecido antecipadamente, escreva para uma list Python e converta uma vez no final com array().

6.19.7. Atribuição por fatia em vez de novos arrays

Muitos padrões de «construir um novo array a partir de partes de outros» podem ser expressos como atribuições por fatia num buffer pré-alocado em vez de uma nova alocação a cada chamada.

Uma janela deslizante sobre um fluxo de amostras – a base de um filtro de média móvel – é o caso canónico. O buffer contém as últimas N amostras; em cada iteração descarta a mais antiga e acrescenta a mais recente. A forma óbvia reconstrói o buffer a cada iteração:

while True:
    sample = read_sample()
    buf = np.concatenate((buf[1:],              # new buffer every loop
                          np.array([sample])))
    avg = np.mean(buf)

Isso é uma nova alocação – e uma cópia de N - 1 elementos – por amostra. A forma com atribuição por fatia desloca in-place:

N   = 16
buf = np.zeros(N, dtype=np.float)               # allocate once

while True:
    sample   = read_sample()
    buf[:-1] = buf[1:]                          # shift left by one
    buf[-1]  = sample                           # append at the end
    avg      = np.mean(buf)

buf[:-1] = buf[1:] é a linha interessante: duas vistas sobrepostas no mesmo buffer, a fatia do lado direito lida de uma extremidade e escrita na outra. O numpy percorre a memória subjacente na ordem que torna o deslocamento in-place seguro. Nunca é alocado um novo array dentro do ciclo.

6.19.8. Atenção às máscaras booleanas em ciclos de processamento contínuo

A indexação booleana e where() produzem um novo array a cada chamada – o tamanho do resultado depende dos dados, por isso nenhum buffer pré-alocado pode absorver a alocação. A construção repetida de máscaras num ciclo de processamento contínuo enche a RAM com arrays descartáveis. Um gc.collect() periódico recupera o espaço:

import gc

for i in range(1000):
    mask = a < threshold
    _    = a[mask]
    if i % 100 == 0:
        gc.collect()

O mesmo aviso aplica-se a expressões booleanas compostas como (a > lo) & (a < hi) – cada operador aloca um novo array bool. Quando uma máscara é reutilizada, construa-a uma vez e guarde-a:

mask = a < threshold
foo[mask] = 0
bar[mask] = 1