Introdução ao conceito de paralelismo

Lidar com quantidades massivas de dados é uma das tarefas mais intensas que existem na Ciência de Dados.  Tratar os dados, criar novas variáveis e filtragens são operações que quando aplicadas em um 'oceano' de informações podem custar muito tempo.

Um paralelo que podemos fazer para o processo de filtragem de dados, por exemplo, é imaginar que em uma fila existem 10 pessoas e gostaríamos de separar das demais aqueles indivíduos que têm cabelo longo, olhos castanhos e idade entre 12 e 20 anos. Então, selecionamos um recrutador para realizar essa seleção de indivíduos. Ele irá em cada uma das dez pessoas  da fila saber quais condizem com as características que serão selecionadas. Quando temos um recrutador nesse processo de avaliação, nós nomeamos a ação de seleção como ‘operação em serial’.

Quando esse processo é feito com 10 pessoas é algo simples, mas quando queremos realizar a mesma ação em 100 mil pessoas esse processo pode demorar bastante. Para trabalhar com tantas pessoas assim, a melhor forma é separar as pessoas em outras filas, para que mais recrutadores possam trabalhar ao mesmo tempo, chamamos essa estratégia de PARALELIZAR.

A ideia de PARALELISMO vem de realizar operações SIMULTANEAMENTE com o objetivo de reduzir o tempo de processamento da tarefa em questão. Neste artigo apresentamos apenas uma das forma de se organizar o fluxo de instruções no paralelismo, Single Instruction Multiple Data - SIMD, outra abordagens existem :  `Multiple Instruction Multiple Data` - MIMD, `Single Instruction Single Data` - SISD e  `Multiple Instruction Single Data` - MISD. Essa é uma taxonomia simples apresentada por Flynn no artigo para classificar o fluxo de instruções em unidades de processamento que podem trabalhar em paralelo..

Operações em `chunks`

Umas das maneiras mais intuitivas de entender o paralelismo é a partir da técnica de separação em \textit{chunks} ou pedaços. Para explicar isso vamos buscar formalizar um pouco mais esse conhecimento.

Dados que `a` é uma constante, `x` e `y` são dois vetores com `N` elementos cada, podemos realizar a operação `a*x + y` que combina multiplicação por escalar e soma. Realizar essa operação em serial é o mesmo que executar os comandos de multiplicação por escalar e soma elemento a elemento.

Na computação, quem realiza todos os tipos de comando do Sistema Operacional (SO) são os núcleos do processador (CPU). No caso da operação em questão, podemos distribuir os elementos dos vetores `x` e `y` entre os núcleos de processamento, ou seja, cada núcleo será responsável por executar `a*x + y`, simultaneamente, em diferentes partes dos vetores, e ao final reconstruir o vetor que é resultante da operação (figura 2).


É importante notar que otimizar o tempo de processamento é organizar os núcleos de processamento de forma que todos recebam a mesma quantidade de trabalho ('Load balancing').

Quando Não realizar o paralelismo ?

É notória a vantagem de realizar paralelismo, mas em que condições é possível de se realizá-lo ? O paralelismo vem como alternativa quando diferentes elementos podem ser tratados separadamente. Retomando o exemplo anterior, podemos realizar a operação em um laço de repetição abaixo, onde `z` é um vetor de tamanho N e `i` é o índice do elemento dos vetores :

z[i] = a*x[i] + y[i]

Nessa operação os elementos de memória podem ser separados independentemente, ou seja, a posição um do vetor `x`  (x[0]) não é alterada por nenhuma outra operação.

Para a operação abaixo:

z[i] = z[i-1] + x[i] + y[i-1]

Neste caso, existe uma dependência entre elementos `z`, ou seja, só é possível executar em z[i] se z[i-1] já tenha sido calculada. Neste caso, não é possível realizar o paralelismo em operações deste tipo.

Como começar a realizar o paralelismo


Umas das maneiras mais simples de realizar operações em paralelo é utilizando a biblioteca `PYTORCH`. Esse pacote simplifica e evita que o usuário tenha necessidade de realizar comandos em APIs destinadas a computação paralela como : `CUDA`, `OpenMP` e `MPI`.


Após a instalação do PYTORCH, o primeiro passo é definir o  tipo do dispositivo que será utilizado para realizar as operações :


device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

Neste caso, estamos decidindo entre dois tipos de dispositivos, o primeiro seria o `cuda` que engloba placas de vídeo de propósito geral (GPGPU) e o segundo seria o `cpu` que é o processador convencional. Uma vez definido em qual tipo de dispositivo serão realizadas as operações, podemos enviar as informações e instruções para os dispositivos a partir da propriedade dos objetos `.to()`. Para exemplificar usaremos a operação z[i] = a*x[i] +y[i] em um loop de repetição. Inicialmente, preenchemos `a` ,`x` e `y` com valores aleatórios:

a = torch.tensor(2.0).to(device)

x = torch.rand(50).to(device)

y = torch.rand(50).to(device)

Assim, enviamos `a`, `x` e `y` para o dispositivo e assim a operação :

a*x+y

Essa operação será distribuída pelas unidades de processamento disponíveis.

Considerações Finais

O paralelismo é uma ferramenta espetacular quando lidamos com a necessidade de operar em uma massa de dados. Agora que passamos pelos princípios do paralelismo, pelas operações que podem ser paralelizadas e qual a biblioteca em Python que é possível realizar paralelismo de forma simples, abrimos espaço para a criatividade e aplicações nas mais diversas áreas do conhecimento, além de conseguir entender qual a verdadeira utilidade do GPU para Ciência de dados.

Créditos

Negócio vetor criado por freepik - br.freepik.com