Série de posts sobre Cython:
- Introdução ao desenvolvimento usando Cython
- Distribuição de extensões em Cython
- Análise de eficiência de operações
- Paralelismo e NoGIL
Na parte 4 da série sobre Cython falaremos sobre funções que liberam completamente o interpretador e podem ser paralelizadas usando OpenMP. No texto anterior vimos que chamadas de funções cdef
e cpdef
são muito rápidas pois elas podem ser feitas "por fora" do interpretador. Também vimos quais operaçõe podem ser feitas sem tocar no interpretador. Podemos levar esta idéia mais além e criar funções (ou contextos) nogil
, que liberam explicitamente o GIL e permitem a execução simultânea de várias threads em Python. Já vimos nos textos sobre multiprocessing (partes 1 e 2) que a utilização de vários processadores pode trazer ganhos de desempenho significativos. Neste texto exploramos algumas situações em que podemos fazer processamento paralelo em Cython.
%load_ext cython
%%cython
# exemplo de funcoes nogil e de contextos nogil
cdef void funcao_nogil(int a, float b, char[:] c) nogil:
cdef double d
pass
# toda função nogil precisa ter todos os tipos declarados e só usar operações "brancas"
with gil:
pass
# esta parte requer o GIL, ou seja, é executada SEMPRE de maneira serial.
def funcao_gil(a, b, c):
cdef double d
with nogil:
pass
# aqui só são permitidas operações "brancas" com variáveis tipadas explicitamente.
Funções (e contextos) nogil
possuem diversas limitações (parecidas com as do modo nopython que vimos no post sobre Numba). Não é possível interagir com nenhum tipo de objeto em Python, somente com variáveis que possuem tipo declarado e que o tipo está disponível diretamente em C. Isto inclui os tipos C habituais (int, float, double, char, etc) e suas versões memory view (int[:], float[:, :], etc). É importante notar que todos os tipos permitidos acessam a memória diretamente e não passam pela contagem de referências do interpretador.
A maneira mais fácil de checar se um trecho de código pode ser executado como nogil
é simplesmente executando o cython -a
como temos feito nos últimos posts. Toda linha completamente branca pode ser executada como nogil
. Qualquer traço de amarelo indica interação com o interpretador e, portanto, é necessário obter o GIL.
Apresentamos abaixo duas versões do gradiente morfológico que usamos nos últimos posts. Primeiramente, adicionamos um tipo de retorno à função morpho_gradient_cython
e a marcamos como nogil
, o que significa que ela não interage nenhuma vez com o interpretador e explicitamente o libera ao ser executada. A versão versao_cython_omp
usa a construção cython.parallel.prange
(link), que executa cada iteração do for
em uma thread diferente. Esta construção só funciona em modo nogil
.
Para compilar extensões usando OpenMP no ipython notebook é necessário adicionar as flags abaixo na %%cython
magic. Se você está usando um setup.py
, é necessário adicioanar as opções
%%cython --compile-args=-fopenmp --link-args=-fopenmp --force
import numpy as np
import cython
from cython.parallel cimport prange
cimport openmp
@cython.boundscheck(False)
@cython.nonecheck(False)
cdef void morpho_gradient_cython(long[:,:] img, long[:,:] out, int pi, int pj) nogil:
cdef int i, j, minv, maxv
minv = 255
maxv = 0
for i in range(-3, 4):
for j in range(-3, 4):
if img[pi+i, pj+j] < minv:
minv = img[pi+i, pj+j]
if img[pi+i, pj+j] > maxv:
maxv = img[pi+i, pj+j]
out[pi, pj] = maxv - minv
@cython.boundscheck(False)
@cython.nonecheck(False)
@cython.cdivision(True)
cpdef void versao_cython_omp(long[:,:] img, long[:,:] out) nogil:
cdef int i, j, k, w, h
h = img.shape[0]; w = img.shape[1]
n = (h-2)*(w-2)
for k in prange(n, nogil=True):
i = k / w
j = k % w
morpho_gradient_cython(img, out, i, j)
@cython.boundscheck(False)
@cython.nonecheck(False)
@cython.cdivision(True)
cpdef void versao_cython(long[:,:] img, long[:,:] out) nogil:
cdef int i, j, k, w, h
h = img.shape[0]; w = img.shape[1]
n = (h-2)*(w-2)
for k in range(n):
i = k / w
j = k % w
morpho_gradient_cython(img, out, i, j)
Para comparar os resultados, precisamos de imagens maiores para sentir a diferença entre as implementações. Processamento paralelo sempre involve uma quantidade significativa de overhead e o código em Cython já é muito rápido para imagens 512x512
, como é o caso da Lena. Logo, "copiamos" a Lena em uma imagem bem maior.
import scipy
import scipy.misc
import numpy as np
img_temp = scipy.misc.lena()
img2 = np.c_[img_temp, img_temp]
img3 = np.r_[img2, img2]
img4 = np.c_[img3, img3]
img = np.r_[img4, img4]
img = np.r_[img, img]
img = np.c_[img, img]
print(img.shape)
out = img.copy()
print('Versão Cython')
%timeit versao_cython(img, out)
print('Versão Cython OMP')
%timeit versao_cython_omp(img, out)
Como podemos ver, existe uma diferença significativa entre as duas implementações. Este é principal caso de uso de paralelismo em Cython: execução de um loop em paralelo em que as iterações são independentes e poderiam ser executadas em qualquer ordem. Os ganhos de desempenho podem ser supreendentes, principalmente em processadores com um grande número de cores (8, 16, 24, ...).
Um dos grandes problemas da utilização de OpenMP em Cython é que é proibida qualquer interação com objetos Python. Porém, dependendo do tipo de tarefa podemos mesmo assim explorar código paralelo usando o módulo multiprocessing
, que já exploramos em textos anteriores. Toda função cpdef
ou def
acelerada usando Cython pode ser chamada normalmente usando o multiprocessing
. No exemplo abaixo, aplicamos o gradiente morfológico em um grande número de imagens e comparamos a versão OpenMP com uma versão Cython "regular" (sem OpenMP) mas utilizando multiprocessing
.
Este problema possui as mesmas propriedades do anterior: é um conjunto de tarefas independentes que podem ser executadas em qualquer ordem. Porém, desta vez analisamos a situação em que não conseguimos paralelizar o código de cada tarefa individualmente, então executamos várias tarefas em paralelo. Um ponto importante é que não existem benefícios em criar um número maior de processos/threads que o número de processadores disponíveis. Isto pode, inclusive, tornar a execução mais lenta por causa da competição entre os processos. Logo, executar uma função com OpenMP usando multiprocessing
provavelmente não é uma boa ideia.
import os
import numpy as np
import scipy as sp
import scipy.ndimage
from multiprocessing.pool import Pool
def le_e_processa_omp(t):
imgpath = t
img = sp.ndimage.imread(imgpath, mode='L').astype('long')
out = img.copy()
versao_cython_omp(img, out)
def serial_omp(path, n=-1):
images = [x for x in os.listdir(path) if x[-3:] == 'png'][:n]
images = ['%s/%s'%(path, img) for img in images]
list(map(le_e_processa_omp, images))
def le_e_processa(t):
imgpath = t
img = sp.ndimage.imread(imgpath, mode='L').astype('long')
out = img.copy()
versao_cython(img, out)
def parallel_thread(path, n=-1):
images = [x for x in os.listdir(path) if x[-3:] == 'png'][:n]
images = ['%s/%s'%(path, img) for img in images]
pool = Pool()
pool.map(le_e_processa, images)
pool.close()
del pool
path = '/media/igor/Data1/datasets/staffs/test'
%timeit -r 1 -n 1 serial_omp(path, 50)
%timeit -r 1 -n 1 parallel_thread(path, 50)
Como podemos ver, o desempenho dos dois testes é bem parecido, mesmo usando uma versão muito mais lenta do gradiente morfológico na versão multiprocessing
. Logo, seja usando OpenMP, seja usando multiprocessing
, processamento paralelo com Cython pode trazer ganhos significativos de performance.