sábado, 17 de outubro de 2015

Processamento paralelo usando Python - parte 1

Atualização: Veja também a parte 2 da série sobre processamento paralelo.

Uma das coisas mais chatas (e angustiantes) de se fazer pesquisa é esperar aquele experimento de 3 horas acabar só para você ver que tinha um erro e você precisa executar tudo de novo (e perder mais 3 horas esperando). E o pior de tudo é que muitas vezes não estamos nem usando o máximo da capacidade de nossa máquina. Neste tutorial iremos ver uma técnica simples de programação em Python para paralelizar código que realiza tarefas independentes.

Frequentemente, nossos programas são muito similares ao trecho de código abaixo.

In [1]:
def funcao_demorada(el):
    pass
    # funcao demorada

def cria_lista(n):
    return []
    
long_list = cria_lista(1000000)
resultados = []
for el in long_list:
    resultados.append(funcao_demorada(el))
# processa resultados

Neste caso, processamos os elementos de uma lista um por vez e (idealmente) a função run_task não muda o valor de nenhuma variável global nem os atributos de nenhum dos objetos passados para a função. Como as execuções de run_task são independentes uma da outra, iremos paralelizar o loop da linha #2 e executar vários run_task ao mesmo tempo usando o módulo multiprocessing.

Nos próximos exemplos, funcao_demorada recebe uma matrizes A e B e faz uma série de operações de matrizes e cria_lista gera matrizes quadradas aleatoriamente.

Utilizaremos a classe Pool para criar um conjunto de processos que irão executar nosso código. Uma instância de Pool possui o método map, que faz o papel do for da versão serial. Ao chamar o map, o Pool seleciona um processo livre e o configura para chamar a nossa função passando como argumento um elemento de nossa lista (no nosso caso, duas matrizes). Existem alguns pontos importantes a serem considereados quando programamos usando multiprocessing:

  1. todo elemento da lista de argumentos deve ser picklable;
  2. a funcao_demorada só pode usar variáveis de sua lista de argumentos;
  3. modificações feitas nas variáveis (globais e locais) não serão propagadas para o processo original.

Além disto, é recomendado que o ponto de entrada do seu programa esteja em um if __name__ == "__main__": para evitar problemas ao rodar em Windows.

Como comparação, iremos rodar funcao_demorada 500 vezes para matrizes 250x250 e medir o tempo de uma versão serial e de uma versão paralelizada.

In [2]:
import numpy as np
import time
import multiprocessing as mp


def funcao_demorada(el):
    # isto não faz sentido algum...
    A, B = el
    A += B
    A *= B
    A = np.dot(A, B)
    A = np.linalg.inv(A)
    A = np.dot(B, A)
    B = np.dot(A, B)
    A = np.linalg.inv(A)
    A = np.dot(A, B)
    B = np.dot(B, A)
    A = np.dot(A, B)
    return A

def cria_lista(n, sz=(250, 250)):
    return [(np.random.rand(sz[0], sz[1]), np.random.rand(sz[0], sz[1])) for i in range(n)]

def versao_serial():
    lista = cria_lista(500)
    resultados = []
    for el in lista:
        resultados.append(funcao_demorada(el))

def versao_paralela():
    p = mp.Pool(mp.cpu_count())
    lista = cria_lista(500)
    resultados = p.map(funcao_demorada, lista)

def executa_varias_vezes(func, n):
    tempos = []
    for i in range(n):
        start = time.clock()
        func()
        tempos.append(time.clock() - start)
    return tempos

if __name__ == "__main__":
    tserial = executa_varias_vezes(versao_serial, 10)
    tparalela = executa_varias_vezes(versao_paralela, 10)
    
    print('Serial:', sum(tserial)/10)
    print('Paralela:', sum(tparalela)/10)
Serial: 18.259999999999998
Paralela: 4.306

O código acima foi executado em uma máquina de grande porte com 24 processadores (rodando Ubuntu) e os ganhos são significativos: a versão serial demora em média 18,25 segundos, enquanto a versão paralela demora apenas 4,3 segundos.

É importante notar, porém, que o programa não rodou 24x vezes mais rápido só por usar 24 processadores. O tempo de criar todos os processos auxiliares e de copiar os objetos necessários para cada processo pode ser significativo dependendo de cada caso. Existem também diversas diferenças entre Sistemas Operacionais que tornam o módulo multiprocessing mais ou menos efetivo. O que funciona bem em um Linux pode não funcionar igualmente bem em ambientes Windows. De qualquer maneira, mesmo máquinas com 2 ou 4 processadores podem obter aumentos de velocidade significativos usando multiprocessing.

Este exemplo simples já mostra o quão interessante pode ser a utilização de paralelismo em Python. Nos próximos posts mostrarei mais da API de processamento paralelo em Python e quanto podemos ganhar em eficiência utilizando-a.

Se este texto lhe for útil, não deixe de escrever um comentário contando a sua experiência com processamento paralelo e Python ;)

Um comentário:

  1. Ótima postagem, me surpreende não tem nenhum comentário.

    Tenho trabalho que consiste em construir um sistema distribuído na arquitetura de sacola de tarefas.

    Minha dúvida é quando vc cria um processo para cada multiplicação de matriz na sua lista, se isso se caracteriza como" sacola de tarefas" ou seja, se cada processo criado para multiplicar uma matriz em sua lista, vai para "sacola de tarefas" para serem executadas pelas threads

    ResponderExcluir