Recentemente encarei um grande desafio junto com um colega de trabalho, no qual descobrimos o multiprocessing do Python, e o quão poderoso ele é.
Para vocês entenderem melhor o problema, tínhamos alguns scripts em python, que foram escritos para serem executados em servidores on premises, sem computação distribuída, e utilizando a linguagem na sua forma mais básica e pura. Era o que dava pra ser feito com os recursos disponíveis e o cenário da época.
Quando recebemos esse problema, a primeira reação foi: “Cara… isso aqui é trabalho para o Spark… tem que ser reescrito.”. A questão é que não tínhamos tempo para reescrever todos os scripts utilizando a arquitetura que considerávamos a ideal.
Após várias conversas com várias pessoas, todos tínhamos o mesmo sentimento: “Precisamos de uma versão 2.0 onde tudo será reescrito na melhor arquitetura possível”.
Mas e até lá, o que pode ser feito com a versão atual para que rodem num tempo aceitável?!
O quão ruim estava a execução?! 6 scripts com tempos entre 8h30m e 18h. Isso ai, um dos scripts levava 3/4 de um dia inteiro para ser executado.
Esse era o desafio: Melhorar a performance no processamento de vários milhões de registros sem reescrever o código.
Primeiros passos…
Começamos com alguns pequenos refatoramentos no código, com objetivo de deixar mais limpo e na tentativa de liberar memória durante o processamento. Porém isso não surtiu muito efeito.
Segunda ação, foi quebrar o dataset original em datasets menores para serem processados em sequência. Dessa forma ganhamos mais memória para ser usada durante o processamento.
A terceira ação, foi utilizarmos mais maquinas no processamento. Ainda não estamos trabalhando com o processamento distribuido, mas dessa forma conseguimos executar cada dataset em uma maquina e trabalhar com processamento paralelo. Criamos um orquestrador, que ficou responsável por buscar cada dataset, e disparar a chamada para cada script.
E a quarta ação, talvez seja a mais importante delas, e a que me motivou a escrever esse post: A utilização do multiprocessing do python para executar o código em todas as cpus da maquina de forma PARALELA.
Notamos que ao executar o código na sua forma normal, a máquina muitas vezes utilizava apenas 1 core para a execução do processamento, e quando usava mais de 1 core, não fazia de forma paralela.
Após essas 4 ações, conseguimos baixar o tempo de execução de cada script para menos de 20 minutos cada.
IMPORTANTE
Antes de entrar em mais detalhes sobre o multiprocessing, vale lembrar que Multiprocessing É DIFERENTE das threads. Notei que ao conversar sobre Multiprocessing com algumas pessoas, todas citavam as Threads do Python.
Vou deixar dois links bem interessantes que explicam de forma bem simples e prática a diferença entre ambas:
- https://timber.io/blog/multiprocessing-vs-multithreading-in-python-what-you-need-to-know/
- https://medium.com/@bfortuner/python-multithreading-vs-multiprocessing-73072ce5600b
A mágica por trás do multiprocessing
Existe algo chamado Global Interpreter Lock, que é um mecanismo usado pelo CPython(que é a implementação padrão do Python) para garantir que apenas 1 thread por vez execute o Python. Mas por que isso existe? Beeeeeem resumidamente: Para controle de concorrência, dessa forma, ele garante que não terá outros processos tratando os mesmos endereços de memória.
E é ai que entra o Multiprocessing.
Como a própria documentação oficial diz:
“O pacote de multiprocessamento oferece simultaneidade local e remota, efetivamente contornando o Global Interpreter Lock usando subprocessos em vez de threads.”
Python.org
E por que usar o multiprocessing? novamente, a documentação oficial:
“… o módulo de multiprocessamento permite que o programador aproveite totalmente vários processadores em uma determinada máquina”.
Python.org
É isso ai, ele “dibra” o GIL(Global Interpreter Lock) usando subprocessos, dessa forma conseguimos utilizar todos os cpus da maquina de forma PARALELA :D.
Lindo isso né?! 😀
Vou mostrar 2 exemplos bem simples que eu fiz apenas para exemplificar o uso e a execução do multiprocessing.
Análisando a execução do Multiprocessing
A primeira coisa que quero mostrar, é justamente o diferencial dele, a execução em vários processos:
Código normal:
import time
import os
def spawn(num):
print("PID SPAWN: ", os.getpid(), "NUM: ", num)
calc = num**2
if __name__ == '__main__':
print("PID MAIN: ", os.getpid())
start = time.time()
for i in range(10):
spawn(i)
end = time.time()
print("Tempo em segundos: ", int(end - start))

Notem que no resultado da execução, tanto o PID principal quanto o PID de cada execução da função spawn são os mesmos, e os números foram executados na ordem.
Código com Multiprocessing utilizando Pool
import multiprocessing
import time
import os
def spawn(num):
print("PID SPAWN: ", os.getpid(), "NUM: ", num)
calc = num**2
if __name__ == '__main__':
start = time.time()
print("PID MAIN: ", os.getpid())
with multiprocessing.Pool(processes=8) as pool:
data = pool.map(spawn, [x for x in range(10)])
pool.close()
end = time.time()
print("Tempo em segundos: ", int(end - start))

Notem que agora temos PIDs diferentes, e que os números não estão mais em ordem. Isso é muito legal :D!
Teste de performance
Vamos continuar com os mesmos códigos, mas vamos remover os prints dos PIDs, e vamos aumentar o numero de registros para 100.000.000 com o objetivo de medir em quanto tempo eles serão processados.
Código normal
import multiprocessing
import time
import os
def spawn(num):
#print("PID SPAWN: ", os.getpid(), "NUM: ", num)
calc = num**2
if __name__ == '__main__':
print("PID MAIN: ", os.getpid())
start = time.time()
for i in range(100000000):
spawn(i)
end = time.time()
print("Tempo em segundos: ", int(end - start))


Notem que com o código normal, tivemos 100.000.000 de registros processados em 35 segundos e analisando os processadores, vimos apenas 1 sendo usado 100%.
Código com Multiprocessing utilizando Pool
import multiprocessing
import time
import os
def spawn(num):
#print("PID SPAWN: ", os.getpid(), "NUM: ", num)
calc = num**2
if __name__ == '__main__':
start = time.time()
print("PID MAIN: ", os.getpid())
with multiprocessing.Pool(processes=8) as pool:
data = pool.map(spawn, [x for x in range(100000000)])
pool.close()
end = time.time()
print("Tempo em segundos: ", int(end - start))


Aqui podemos ver bem a diferença, para o mesmo número de registros, a execução levou apenas 19 segundos, e utilizou todos os processadores.
Demais né?! 😀
Porém é importante ressaltar, que se o volume de dados a ser processado não for muito grande, você não conseguirá ver ganhos utilizando o multiprocessing. O real ganho vai aparecendo conforme o volume de dados aumenta.
Conclusão
Bom, esse problema que conseguimos resolver com multiprocessing nos trouxe uma lição muito importante: olhar o hardware.
As vezes não analisamos a infraestrutura que irá suportar os nossos códigos, e quando olhamos, fazemos de forma superficial.
As vezes os problemas não estão no código propriamente dito, mas na forma como ele é executado.
Antes de sair… dá uma conferida na série de posts para o Projeto Video Creator que eu postei aqui no blog. Pra quem curte automatizar o trabalho, vai gostar do conteúdo =D.
Abraços!