Capítulo 48, Avançado
Desempenho e profiling
A regra de ouro: meça antes de otimizar. Quase sempre o gargalo está em um lugar diferente do que você imagina.
Código deste capítulo: avancado/cap48_desempenho.py
Medir um trecho com timeit
O timeit executa um trecho várias vezes e reduz o ruído. Ele responde "qual das duas formas é mais rápida?". Aqui, buscar um item em uma lista (varre tudo, O(n)) contra buscar em um conjunto (tabela hash, O(1) em média):
import timeit
setup = "dados = list(range(10_000)); conjunto = set(dados)"
na_lista = timeit.timeit("9_999 in dados", setup=setup, number=2_000)
no_conjunto = timeit.timeit("9_999 in conjunto", setup=setup, number=2_000)
print("conjunto mais rápido:", no_conjunto < na_lista)
conjunto mais rápido: True
Eu não mostro os tempos porque mudam de máquina para máquina. A relação entre eles é o que importa, e ela só cresce com o tamanho dos dados.
Achar o gargalo com cProfile
Quando o programa inteiro está lento, ninguém sabe onde. O cProfile mostra quanto tempo cada função consome. As colunas que importam: ncalls (quantas chamadas), tottime (tempo dentro da função, sem contar as chamadas internas) e cumtime (tempo acumulado, contando tudo o que ela chamou):
import cProfile
import io
import pstats
def lento():
return sum(i * i for i in range(200_000))
def principal():
for _ in range(5):
lento()
perfil = cProfile.Profile()
perfil.enable()
principal()
perfil.disable()
relatorio = io.StringIO()
pstats.Stats(perfil, stream=relatorio).sort_stats("cumulative").print_stats(5)
print(relatorio.getvalue())
Para perfilar um script inteiro, sem alterar o código:
python -m cProfile -o perfil.out meu_script.py
python -m pstats perfil.out
O algoritmo vence a micro-otimização
Trocar for por compreensão ganha alguns por cento. Trocar um algoritmo O(n²) por O(n) ganha ordens de grandeza. Veja a detecção de duplicatas:
def tem_duplicado_lento(itens):
for i, a in enumerate(itens):
for b in itens[i + 1:]:
if a == b:
return True
return False
def tem_duplicado_rapido(itens):
return len(set(itens)) != len(itens)
amostra = list(range(2_000))
print(tem_duplicado_lento(amostra) == tem_duplicado_rapido(amostra))
t_lento = timeit.timeit(lambda: tem_duplicado_lento(amostra), number=3)
t_rapido = timeit.timeit(lambda: tem_duplicado_rapido(amostra), number=3)
print("a versão com set é mais rápida:", t_rapido < t_lento)
True
a versão com set é mais rápida: True
Memória
Para medir o consumo de memória, o tracemalloc registra o pico. O gerador mostra a vantagem de não materializar a sequência inteira:
import tracemalloc
def pico(funcao):
tracemalloc.start()
funcao()
_, maximo = tracemalloc.get_traced_memory()
tracemalloc.stop()
return maximo
pico_lista = pico(lambda: sum([n for n in range(200_000)]))
pico_gerador = pico(lambda: sum(n for n in range(200_000)))
print("gerador usa menos memória:", pico_gerador < pico_lista)
gerador usa menos memória: True
O que eu verifico antes de mexer em desempenho
| Sintoma | Suspeita | Ferramenta |
|---|---|---|
| O programa todo é lento | Algoritmo, ou espera de I/O | cProfile |
| Um trecho específico parece lento | Alternativas equivalentes | timeit |
| Consumo alto de memória | Listas completas em vez de geradores | tracemalloc |
| Início do programa demora | Imports pesados | python -X importtime meu_script.py |
| Cálculo numérico lento | Laço Python sobre números | NumPy ou outra biblioteca em C |
Ordem das ações
Primeiro, o algoritmo e as estruturas de dados. Depois, evitar trabalho repetido (
functools.cache, mover cálculos para fora do laço). Depois, trocar o trabalho em Python puro por bibliotecas em C. Concorrência e reescrita em outra linguagem ficam por último, porque são as mudanças mais caras de manter.
Exercício 1
Interseção sem laço duplo
A função intersecao_lenta(a, b) é O(n por m). Escreva intersecao_rapida com o mesmo resultado, usando um conjunto.