Como extrair o máximo que a sua máquina pode oferecer!

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:

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))
Resultado da execução do código acima

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))
Resultado da execução utilizando multiprocessing

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))
Resultado da execução do código normal
Estado dos processadores na execução do código normal

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))
Resultado da execução com multiprocessing
Estado dos processadores na execução com multiprocessing

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!