Capítulo 18, Básico
Medir desempenho de verdade
Otimizar sem medir é adivinhar. Este capítulo mostra como medir o tempo e a memória de um trecho de código sem se enganar, e prova a afirmação do capítulo 4 com números.
Código deste capítulo: básico/cap18_medir_desempenho.py
O `timeit` e o melhor de vários
Um tempo medido uma vez é ruído: o processador pode estar ocupado, o cache pode estar frio. O módulo timeit repete a medida, e a regra é usar o menor dos tempos, que é o menos afetado pelo que o seu computador estava fazendo ao mesmo tempo:
import timeit
import numpy as np
a = np.arange(1_000_000)
tempos = timeit.repeat(lambda: a.sum(), number=20, repeat=5)
print("melhor tempo por chamada abaixo de 10 ms:", min(tempos) / 20 < 0.01)
melhor tempo por chamada abaixo de 10 ms: True
Em um notebook, %timeit a.sum() faz tudo isso por você. Em um script, use timeit.repeat e olhe o min.
Quanto custa começar: tamanhos pequenos não contam a história
Chamar uma função do NumPy tem um custo fixo (verificar tipos, alocar o array de saída). Para poucos elementos, esse custo domina, e o array pode até perder para uma lista. A vantagem só aparece, e cresce, quando os dados crescem:
def razao(n):
lista = list(range(n))
array = np.arange(n)
t_lista = min(timeit.repeat(lambda: [x * 2 for x in lista], number=50, repeat=5))
t_array = min(timeit.repeat(lambda: array * 2, number=50, repeat=5))
return t_lista / t_array
print("a vantagem cresce com o tamanho:", razao(100_000) > razao(100))
print("em 100 mil elementos o array ganha:", razao(100_000) > 5)
a vantagem cresce com o tamanho: True
em 100 mil elementos o array ganha: True
Por isso, medir com 10 elementos e concluir que "o NumPy não é mais rápido" é um erro clássico. O teste tem de ser feito no tamanho real dos seus dados.
Medir memória
Tempo é só uma metade. Muitas análises falham por falta de memória, e não por lentidão. O tracemalloc mede a memória alocada pelo Python, e o NumPy reporta os seus arrays a ele:
import tracemalloc
tracemalloc.start()
x = np.zeros(1_000_000)
atual, pico = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(round(atual / 1e6, 1), "MB")
8.0 MB
Um milhão de float64 ocupa 8 MB, exatamente size * itemsize. Para um array que já existe, o nbytes dá a resposta sem medir nada.
Armadilhas de medição
| Armadilha | O que acontece | O que fazer |
|---|---|---|
| Medir uma só vez | Ruído domina | Repetir e usar o menor tempo |
| Medir dados minúsculos | O custo fixo domina | Usar o tamanho real |
| Incluir a criação dos dados | Mede a coisa errada | Criar fora da função medida |
| Comparar resultados diferentes | A conta mais rápida pode estar errada | Conferir com np.allclose antes |
| Confiar em número absoluto | Muda de máquina para máquina | Comparar razões |
| Otimizar o que não é gargalo | Esforço sem efeito | Perfilar antes (cProfile) |
Meça antes de mexer
Eu só otimizo depois de saber onde o tempo vai. Para um programa inteiro, o
cProfile(capítulo 48 do curso de Python) mostra qual função consome o tempo. Para um trecho, otimeitcompara alternativas. E, antes de aceitar uma versão mais rápida, eu confiro que ela produz o mesmo resultado.
Exercício 1
Comparar duas implementações
Escreva comparar(f, g) que devolva quantas vezes f é mais lenta que g (a razão entre os melhores tempos, em 5 repetições). Use para comparar sum(range(10000)) com np.arange(10000).sum().