quarta-feira, 21 de novembro de 2012

Liberando o GIL do python para paralelizar seu código com threads

No Python o Global Interpreter Locker impede que duas threads executem ao mesmo tempo. Uma thread só é executada quando nenhuma outra estiver executando. A solução mais comum para aproveitar todos os cores de uma máquina em python é abandonar threads e utilizar vários processos. No entanto há uma maneira de aproveitar todos os cores com threads no python. Para isso vamos precisar criar uma extensão em C.

Primeiro vamos criar uma extensão que não libere o GIL para podermos comparar e conferir a melhoria de performance depois. O pivô dessa extensão é a seguinte função:

static int reduce_com_gil(int max, int (*f)(int x, int y)) {
   int retorno = 0;
   int i;
   for(i=0; i < max; i++){
       retorno = (*f)(retorno, i);
   }
   return retorno;
}

Essa função faz algo similar ao que um reduce faria, mas indo de 0 ao valor indicado em max. Para cada iteração a função enviada como segundo parâmetro é executada. A idéia é causar um grande processamento para que meu core fique travado. O uso dela está descrito no código abaixo:

static PyObject *antigil_calcular_com_gil(PyObject *self) {
   int valor = reduce_com_gil(100*1000, *antigil_calculos);
   char numero [5000];
   sprintf(numero, "%d", valor );
   return Py_BuildValue("s", numero);
}


Repare que eu passo para a função reduce_com_gil o ponteiro da função antigil_calculos. Essa outra função faz diversos cálculos a cada iteração do reduce. O nome antigil é o nome da extensão de exemplo. O código completo da extensão pode ser visto aqui.

Instalada a extensão, podemos testar a performance da lib com o seguinte código:

import antigil
antigil.calcular_com_gil()


E o seguinte comando:

$ time python teste.py
real 0m2.702s
user 0m2.692s


Mas o que a gente quer é saber como se comportam as threads. No caso o seguinte script abre 4 threads para aproveitar os 4 cores da minha máquina:

import antigil
from threading import Thread

threads = []
for i in xrange(4):
   t = Thread(target=antigil.calcular_com_gil)
   t.start()
   threads.append(t)

for t in threads:
   t.join()


No entando, acaba não aproveitando. Repare que ao executar 4 threads, o GIL age e impede que duas sejam executadas ao mesmo tempo. Dessa forma só um core da máquina é aproveitado. Isso pode ser observado pelo tempo total que é aproximadamente quatro vezes o tempo de execução de uma:

$ time python teste.py
real 0m10.910s
user 0m10.881s


Bom, vamos então à extensão não bloqueante. A chave do sucesso nesse caso são as macros Py_BEGIN_ALLOW_THREADS e Py_END_ALLOW_THREADS que respectivamente liberam o GIL e obtem o GIL de volta. Essas macros estão definidas em Python.h. O código da função reduce ficaria assim:

static int reduce_sem_gil(int max, int (*f)(int x, int y)) {
   int retorno = 0;
   Py_BEGIN_ALLOW_THREADS
   int i;
   for(i=0; i < max; i++){

       retorno = (*f)(retorno, i);
   }
   Py_END_ALLOW_THREADS
   return retorno;
}


Pronto, basta criar a função antigil_calcular_sem_gil similar à antigil_calcular_com_gil, utilizando a função reduce_sem_gil e então podemos repetir o teste. No caso parametrizei o script de teste para que você possa escolher qual função deseja executar:

import antigil
from threading import Thread
import sys

if 'com-gil' in sys.argv:
    calcular = antigil.calcular_com_gil
elif 'sem-gil' in sys.argv:
    calcular = antigil.calcular_sem_gil
else:
    print "Informar com-gil ou sem-gil"
    exit(0)

threads = []
for i in xrange(4):
   t = Thread(target=calcular)
   t.start()
   threads.append(t)

for t in threads:
   t.join()


O resultado é bem animador e mostra bem que o código rodou em paralelo:

$ time python teste.py sem-gil
real 0m3.414s
user 0m13.413s


Como o código utiliza apenas CPU, sem IO algum, ao aumentar para 8 threads, mesmo a versão que libera o GIL dobra de tempo pois só tenho disponível 4 cores na minha máquina. No entanto acredito que se fizer alguma operação de IO entre as macros Py_BEGIN_ALLOW_THREADS e Py_END_ALLOW_THREADS outras threads poderão ser executadas no caminho. Isso eu ainda preciso validar.

Só é preciso ter muito cuidado pois código entre essas macros está em território perigoso. A alteração de váriaveis globais ou ponteiros que podem ser compartilhados entre outras threads podem causar erros inesperados a qualquer momento. Portanto, é importante utilizar somente váriaveis locais e dados copiados.

O código completo da extensão em C antigil pode ser visto aqui.


2 comentários:

  1. Esse esquema parece bem interessante, mas nesse caso o código da thread teria que obrigatoriamente ser em C, correto? Outra dúvida, como acessar, por exemplo, um vetor a partir de todas as threads?

    ResponderExcluir
  2. Exato, obrigatoriamente teria que ser em C.
    No caso de você querer acessar um vetor entre todas as threads, vc teria que ter ele globalmente ou passar o ponteiro via parametro para as funcoes. No entanto voce teria que lidar com o problema de acesso simultaneo a ele e possivelmente teria problemas de race condition.

    ResponderExcluir