Otimizando Scripts em Python: Lições Aprendidas com Processamento Paralelo
Recentemente, tenho me dedicado a um projeto pessoal que exige bastante da minha máquina. Precisei fazer um processamento de dados de 500k parâmetros em uma base de cerca de 10k registros. Cada iteração desse script demorava cerca de 23 segundos, o que levaria em torno de +3000 horas para completar. Esse número me espantou, mas tratei como um desafio pessoal.
Revisando o Código e Implementando Cache
Revisei todo o código buscando melhorias e removendo processamentos desnecessários. Notei que alguns trechos semelhantes acabavam sendo reprocessados em algumas iterações, então desenvolvi uma solução armazenando esses dados em um tipo de cache.
Horas de trabalho e aprendizado geraram resultados, mas ainda não era o ideal. Essas mudanças melhoraram a performance em cerca de **30%**. Mas ainda era inviável.
Descobrindo a Subutilização da Máquina
Percebi que, ao iniciar o processamento, meu computador não utilizava a força total. As ventoinhas se mantinham na mesma velocidade e a temperatura do processador não mudava.
Aprendi que, por padrão, a maioria dos scripts em Python acabam sendo executados em modo seguro, não utilizando toda a força computacional disponível, permitindo outras tarefas simultâneas e preservando a temperatura do processador.
Acessando as estatísticas de uso do sistema, era claro: apenas 1–2 núcleos estavam em 100% de força, enquanto os outros se mantinham em 20–30%, com um revezamento entre eles.
Processo Baseado em Paralelismo
O que é?
A solução para o problema mencionado pode ser alcançada usando paralelismo baseado em processos. **Paralelismo** significa que dois ou mais cálculos acontecem na mesma unidade de tempo. Em Python, isso pode ser feito criando vários processos, cada um com sua própria unidade de processamento.
Uma analogia útil é pensar em uma pizzaria que precisa entregar 100 pizzas para diferentes endereços. Por padrão, o dono poderia utilizar apenas 15% dos entregadores e, mesmo com revezamento, precisaria esperar que um entregador retornasse para enviar o próximo. Com o paralelismo, até 100% dos entregadores podem trabalhar ao mesmo tempo, aumentando significativamente a performance (embora também aumente os custos).
Paralelismo no Python
Uma das formas de utilizar o paralelismo no Python é com o módulo multiprocessing e as classes Process e Pool.
Em resumo, a classe Process é usada para paralelismo baseado em funções, onde cada função do código precisa ser executada de forma específica, mas sem dependências. Já a classe Pool utiliza um padrão de gerenciamento de processos, ideal quando você tem um conjunto de tarefas homogêneas para serem executadas em paralelo. Ela é usada para paralelismo baseado em dados, com a mesma função sendo executada em múltiplos valores de entrada, cada um sendo atribuído a um processo.
Comparando com a analogia da pizzaria, o Process seria ideal para organizar as rotinas em paralelo, como o atendimento das mesas e o atendimento de pedidos por telefone. Já o Pool se aplica à rota das entregas: a função é uma só, entregar pizzas, mas os dados de entrada são diferentes, ou seja, endereços distintos.
Utilizando a Classe Process
from multprocessing import Process
import time
def atendimento_mesas():
for i in range(5):
print(f"Mesa: Mesa {i+1} sendo atendida.")
time.sleep(1) # Simula o tempo de atendimento
def atendimento_pedidos_telefone():
for i in range(5):
print(f"Telefone: Pedido {i+1} sendo processado.")
time.sleep(1)
processo_mesas = Process(target=atendimento_mesas)
processo_pedidos = Process(target=atendimento_pedidos_telefone)
processo_mesas.start()
processo_pedidos.start()
# Aguarda a conclusão dos processos
processo_mesas.join()
processo_pedidos.join()
Explicação:
1. Funções:
- atendimento_mesas(): Simula o atendimento das mesas, imprimindo uma mensagem e aguardando 1 segundo para simular o tempo de atendimento.
- atendimento_pedidos_telefone(): Simula o atendimento de pedidos por telefone, imprimindo uma mensagem e aguardando 1 segundos para simular o tempo de processamento do pedido.
2. Criação de Processos:
- Process(target=atendimento_mesas) e Process(target=atendimento_pedidos_telefone) criam novos processos para executar as funções em paralelo.
3. Início e Espera:
- processo_mesas.start() e processo_pedidos.start() iniciam a execução dos processos.
- processo_mesas.join() e processo_pedidos.join() aguardam a conclusão dos processos antes de continuar.
Quando você executa o código, as mensagens das duas funções serão intercaladas, indicando que as tarefas estão sendo executadas em paralelo.
Utilizando a Classe Process
from multprocessing import Pool
import time
def entregar_pizza(endereco):
print(f"Iniciando entrega para: {endereco}")
time.sleep(3) # Simula o tempo de entrega
print(f"Entrega concluída para: {endereco}")
return endereco
if __name__ == "__main__": # Lista de endereços para entrega
enderecos = [ "Rua A, 1", "Rua B, 2", "Rua C, 3", "Rua D, 4", "Rua E, 5" ]
with multiprocessing.Pool(processes=3) as pool:
resultados = pool.map(entregar_pizza, enderecos)
print("Todas as entregas foram realizadas!")
Explicação:
1. Função de Entrega:
- entregar_pizza(endereco): Simula a entrega de pizza para um endereço específico, imprimindo mensagens antes e depois da entrega, e aguardando 3 segundos para simular o tempo de entrega.
2. Lista de Endereços:
- enderecos: Lista contendo diferentes endereços para os quais as pizzas serão entregues.
3. Criação do Pool:
- `with Pool(processes=3) as pool`: Cria um pool de processos com até 3 processos em paralelo. Você pode ajustar o número de processos conforme necessário.
4. Mapeamento e Resultados:
- pool.map(entregar_pizza, enderecos): Mapeia a função entregar_pizza para cada endereço na lista enderecos, executando as entregas em paralelo.
- resultados: contém a lista dos endereços que foram entregues (ou retornados pela função entregar_pizza).
Quando você executa o código, as mensagens de início e conclusão das entregas para diferentes endereços serão intercaladas, mostrando que as entregas estão sendo feitas em paralelo.
Resultado
Adaptando o meu projeto para utilizar a classe Pool, junto com outras melhorias, e uma pequena redução na quantidade de parâmetros, consegui sair de uma projeção de 3000hrs para 18hrs de processamento possibilitando a continuidade do projeto. (~23s/it -> 1–2s/it)
O número que me assustou no início foi o responsável pelo aprendizado
Referências:
https://superfastpython.com/multiprocessing-pool-vs-process/
https://medium.com/@mehta.kavisha/different-methods-of-multiprocessing-in-python-70eb4009a990