6.19. Desempenho

As mesmas decisões de design que tornam o numpy rápido na câmera – chamadas de biblioteca sobre o array inteiro, buffers tipados e compactados, views que compartilham dados com sua origem – também expõem um conjunto de hábitos que vale a pena conhecer. A página Formato e strides já abordou a regra de layout do último eixo; esta página cataloga os hábitos de alocação e de dtype que mais importam em um laço de streaming.

6.19.1. Escolha um dtype razoável

O dtype padrão de todo construtor é float. Para dados que são naturalmente de 8 bits 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 economia 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 roda mais rápido porque os caminhos de código inteiro dentro do numpy são mais enxutos do que os genéricos de ponto flutuante. A regra de overflow de inteiros abordada em Dtypes se aplica – faça o cast para um tipo mais largo antes de uma operação aritmética que possa causar overflow.

6.19.2. Prefira um ndarray a um iterável

A maioria das reduções e funções universais aceita tanto um iterável quanto 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 com iterável força o numpy a percorrer a entrada um objeto Python por vez, convertendo cada um em número antes de poder usá-lo. Com um ndarray a conversão já está feita e a chamada percorre diretamente o buffer compactado.

Quando os mesmos dados são usados mais de uma vez, construa o ndarray uma única vez e passe-o adiante. Quando os dados existem apenas como uma lista Python e são consumidos uma única vez, o custo da conversão pode superar o ganho de velocidade – o próprio construtor array() precisa percorrer a lista e alocar.

6.19.3. Prefira views a cópias

Fatiamento, indexação de um único eixo de um array de posto mais alto, reshape(), transpose() e frombuffer() retornam todos views que compartilham dados com a origem. Elas são essencialmente gratuitas.

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

Em caso de dúvida, ndinfo() imprime a localização do buffer subjacente; dois arrays que reportam o mesmo endereço compartilham seus dados. A tabela completa de view-vs-cópia está em Views e cópias.

6.19.4. Aloque uma vez, depois escreva

A maior armadilha de desempenho na câmera é alocar arrays novos dentro de um laço que roda muitas vezes por segundo. Cada novo ndarray pede RAM à câmera, e alocações novas frequentes a desperdiçam.

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

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= pela mesma razão; spectrogram() e os conversores no estilo from_int32_buffer() aceitam tanto out= quanto scratchpad=. Aloque tudo uma vez e reutilize.

6.19.5. Use operadores in-place

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

# makes a temporary
b = b + 1

# no temporary
b += 1

A mesma ideia se aplica a expressões compostas. a + b * c aloca um temporário para b * c. Dividir a expressão em sub-atribuições simples escrevendo em um 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 faça append a ele

ndarray não tem append – de propósito. Aumentar um array significaria alocar um buffer novo e maior e copiar o conteúdo antigo para ele. Em um 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 realmente não é conhecido de antemão, escreva em uma list Python e converta uma única vez ao 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 pedaços de outros” podem ser expressos como atribuições por fatia em um 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 guarda as últimas N amostras; a cada iteração descarta-se a mais antiga e adiciona-se a mais nova. 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: dois views sobrepostos no mesmo buffer, a fatia da direita lida de uma ponta e escrita na outra. O numpy percorre a memória subjacente na ordem que torna o deslocamento in-place seguro. Nenhum array novo é alocado dentro do laço.

6.19.8. Cuidado com máscaras booleanas em laços de streaming

A indexação booleana e where() produzem um novo array a cada chamada – o tamanho do resultado depende dos dados, então nenhum buffer pré-alocado pode absorver a alocação. A construção repetida de máscaras em um laço de streaming enche a RAM de 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()

A mesma ressalva se aplica 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