sexta-feira, 25 de dezembro de 2015

Acelerando programas usando Cython - parte 4

Série de posts sobre Cython:

  1. Introdução ao desenvolvimento usando Cython
  2. Distribuição de extensões em Cython
  3. Análise de eficiência de operações
  4. 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.

In [7]:
%load_ext cython
The cython extension is already loaded. To reload it, use:
  %reload_ext cython
In [8]:
%%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

In [9]:
%%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.

In [10]:
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)
(4096, 4096)
In [11]:
out = img.copy()
print('Versão Cython')
%timeit versao_cython(img, out)
print('Versão Cython OMP')
%timeit versao_cython_omp(img, out)
Versão Cython
1 loops, best of 3: 4.9 s per loop
Versão Cython OMP
1 loops, best of 3: 2.3 s per loop

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.

In [12]:
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)
1 loops, best of 1: 50.8 s per loop
1 loops, best of 1: 45.3 s per loop

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.

Nenhum comentário:

Postar um comentário