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.

segunda-feira, 7 de dezembro de 2015

Acelerando Python usando Numba

Implementações em Python puro são frequentemente muito lentas e qualquer tentativa de acelerar estes programas impacta diretamente a produtividade de um cientista que uma Python. Falamos anteriormente de Processamento Paralelo com multiprocessing e Cython como maneiras de acelerar experimentos. Ambas soluções, entretanto, exigem modificações não triviais (e potencialmente extensivas) no código, aumentando sua complexidade.

Neste texto irei apresentar uma alternativa chamada Numba, um compilador Just in Time(JIT) que compila um subconjunto da linguagem Python em código de máquina eficiente. Numba é muito mais limitado que Cython e não é capaz de traduzir todas (a maioria das?) construções Python de maneira eficiente. As funcionalidades suportadas, porém, podem ser aceleradas significativamente sem grande esforço.

Primeiramente, um compilador JIT traduz, durante a execução, código escrito em uma linguagem de alto nível como Python em instruções nativas da CPU em que está sendo executado. Otimizações são aplicadas somente nas partes mais lentas do código. A vantagem disto é que não é necessário compilar nada antes da execução do programa, porém as primeira execuções de uma função "jitted" são mais lentas.

Uma das grandes vantagens de se usar Numba é que o código compilado é Python puro. Não é necessário adicionar nenhum tipo de anotação ou código extra. Tudo é feito automaticamente pelo Numba e o código gerado é bastante rápido em alguns casos específicos. A maneira mais fácil de instalar este pacote é utilizando o Anaconda Python. Um simples conda install numba instala todo o necessário sem nenhuma complicação, mesmo em Windows.

Vamos usar como exemplo nosso já cansado gradiente morfológico. Também manteremos a versão Cython que criamos em um post anterior.

Versão Python

In [1]:
import numpy as np
import scipy as sp
import scipy.ndimage
import time
import matplotlib.pyplot as plt

def morpho_gradient_py(img, out, p):
    pi, pj = p
    minv = 255
    maxv = 0
    
    for i in range(-1, 2):
        for j in range(-1, 2):
            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

def versao_python(img):
    out = np.zeros(img.shape, img.dtype)
    for i in range(1, img.shape[0]-1):
        for j in range(1, img.shape[1]-1):
            morpho_gradient_py(img, out, (i, j))
    return out

Versão Cython

In [2]:
%load_ext cython
In [3]:
%%cython
import numpy as np
import scipy as sp
import scipy.ndimage
import time
import matplotlib.pyplot as plt
import cython

@cython.boundscheck(False)
@cython.nonecheck(False)
cdef morpho_gradient_cython(long[:,:] img, long[:,:] out, p):
    cdef int i, j, pi, pj, minv, maxv
    
    pi, pj = p
    minv = 255
    maxv = 0
    
    for i in range(-1, 2):
        for j in range(-1, 2):
            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)
cpdef versao_cython(long[:,:] img):
    cdef int i, j, w, h
    cdef long[:,:] out
    h = img.shape[0]; w = img.shape[1]
    out = np.zeros((h, w), np.int)
    for i in range(1, h-1):
        for j in range(1, w-1):
            morpho_gradient_cython(img, out, (i, j))
    return out

Versão Numba

In [4]:
import numba

@numba.jit
def morpho_gradient_numba(img, out, p):
    pi, pj = p
    minv = 255
    maxv = 0
    
    for i in range(-1, 2):
        for j in range(-1, 2):
            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

@numba.jit
def versao_numba(img):
    out = np.zeros(img.shape, img.dtype)
    for i in range(1, img.shape[0]-1):
        for j in range(1, img.shape[1]-1):
            morpho_gradient_numba(img, out, (i, j))
    return out

A versão Numba é basicamente a versão Python com um decorador adicionado às funções. Diferentemente de Cython, não adicionamos nenhum tipo manualmente nem precisamos compilar nosso código explicitamente. Vamos agora aos resultados numéricos.

In [5]:
import scipy

img = scipy.misc.lena()

print('Versão Python')
%timeit versao_python(img)
print('Versão Cython')
%timeit versao_cython(img)
print('Versão Numba')
%timeit versao_numba(img)
Versão Python
1 loops, best of 3: 3.23 s per loop
Versão Cython
10 loops, best of 3: 39.7 ms per loop
Versão Numba
The slowest run took 25.12 times longer than the fastest. This could mean that an intermediate result is being cached 
1 loops, best of 3: 26.6 ms per loop

Ambas as versões aceleradas obtiveram performance muito superior ao programa em Python puro. Surpreendentemente, o código em Numba obteve um desempenho superior ao código em Cython. Isto ocorre, principalmente, pois usamos somente funcionalidades suportadas no modo nativo do Numba, chamado de nopython. Isto inclui, basicamente, manipulação de arrays com Numpy (funções suportadas), chamadas de função com decorador @jit e controle de fluxo padrão de Python (if, for i in range(..), while). Existe suporte limitado para listas e é melhor evitar funções recursivas. Uma lista completa das funcionalidades suportadas pode ser vista na documentação do projeto. Funções nopython podem ser compiladas para código nativo bastante rápido e o próprio processo de compilação não só é invisível para o programador com também é bem rápido, diferentemente de Cython.

Porém, o modo nopython só é ativado se a função só utilizar as funcionalidades permitidas. Se uma única operação não for suportada o Numba ativa o modo object e trata todas as variáveis como objetos Python. Ou seja, apesar de algumas otimizações ainda serem possíveis, o programa será muito mais lento. Não é possível, como em Cython, misturar código que interage com o interpretador e código compilado. Vejamos abaixo um exemplo do mesmo código acima compilado no modo object.

In [6]:
@numba.jit(forceobj=True)
def morpho_gradient_numba_ruim(img, out, p):
    pi, pj = p
    minv = 255
    maxv = 0
    
    for i in range(-1, 2):
        for j in range(-1, 2):
            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

@numba.jit(forceobj=True)
def versao_numba_ruim(img):
    out = np.zeros(img.shape, img.dtype)
    for i in range(1, img.shape[0]-1):
        for j in range(1, img.shape[1]-1):
            morpho_gradient_numba_ruim(img, out, (i, j))
    return out


print('Versão Numba Object')
%timeit versao_numba_ruim(img)
Versão Numba Object
1 loops, best of 3: 18.3 s per loop

Como podemos ver, o resultado pode ser desastroso. Apesar da aparente limitação, Numba é muito útil para o que se propõe a fazer: acelerar código em Python que contém diversos loops e acessa arrays do Numpy. Uma boa maneira de checar se um código poderá ser acelerado é usar o decorador @jit(nopython=True). Esta opção força a compilação em modo nopython e levanta uma exceção se o código não puder ser compilado deste modo.

Mesmo que a função inteira não possa ser compilada, se houver algum loop que só possui operações nopython o Numba pode executar uma otimização chamada loop lifting. Vejamos o exemplo abaixo.

In [7]:
@numba.jit
def morpho_gradient_numba_lift(img, out, p):
    pi, pj = p
    minv = 255
    maxv = 0
    for i in range(-1, 2):
        for j in range(-1, 2):
            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

@numba.jit
def versao_numba_lift(img):
    out = np.zeros(img.shape, np.uint8)
    d = {}

    for i in range(1, img.shape[0]-1):
        for j in range(1, img.shape[1]-1):
            morpho_gradient_numba_lift(img, out, (i, j))

    d[5] = 0

    return out


print('Versão Numba Object')
%timeit versao_numba_lift(img)
Versão Numba Object
The slowest run took 19.17 times longer than the fastest. This could mean that an intermediate result is being cached 
1 loops, best of 3: 25.3 ms per loop

A função versao_numba_lift não pode ser compilada em modo nopython, mas os dois for podem. Logo, ele extrai esta parte da função, aplica loop lifting e termina com um código tão rápido quanto o anterior. Porém, isto não funcionaria bem se fizéssemos o mesmo na função morpho_gradient_numba_lift. Apesar do loop da outra função ser realmente acelerado, uma funcionalidade importante do Numba é que a chamada de funções em modo nopython é muito rápida para outras funções nopython, mas lenta para funções Python (ref). Como em Cython, é possível ganhar muita velocidade se todas as nossas funções forem compiladas no modo acelerado.


Como pudemos ver, Numba pode ser uma excelente alternativa para acelerar código escrito em Python. Em um próximo post apresentarei as funções (e limitações) de Numba para rodar código em GPUs NVidia usando CUDA.