Elemar DEV

Tecnologia e desenvolvimento .net

Como Parallel LINQ (PLINQ) funciona?

Olá galera, tudo certin? Em um sábado chuvoso em Caxias do Sul, ansioso para iniciar minha jornada rumo ao TechEd (na distante São Paulo [uma horinha e meia de vôo]), escrevo mais um “postizinho”!

Que assunto abordo hoje? PLINQ.

Importante dizer que este não é, exatamente, um post com material introdutório sobre o assunto. Presumo que você, leitor, tenha algum conhecimento sobre o tema. [Se houver feedback positivo, posso até iniciar uma série mostrando os primeiros passos com essa tecnologia.]

Pela complexidade do assunto, resolvi não tentar, de forma alguma esgotar o tema. O que mostro aqui é uma visão alta, conceitual, que visa permitir maior segurança para quem estiver começando a usar essa ferramenta seriamente.

Go Code!

Sobre paralelismo no .NET

Há dois pontos que gostaria de colocar antes de começar a falar, efetivamente, sobre PLINQ:

  1. paralelismo não é justificativa para código ruim – acredito que melhor desempenho deve ser perseguido, primeiramente, fazendo uso dos melhores métodos de codificação.
    1. Estamos respeitando as boas práticas e nosso código é, de fato, o mais performático que conseguimos produzir?
    2. Sim, a utilização de mais de um processador pode trazer ganho efetivo de desempenho aos nossos programas. Não usar, seria como desperdiçar recursos. Mas, não é justificativa para que, em função disso, deixemos de tomar cuidado com a forma como escrevemos.
  2. sempre que possível, devemos utilizar todos os recursos disponíveis – nossos computadores tem 1, 2, 4 cores… se eles estão ali, temos o dever, a obrigação de fazer uso efetivo deles. Não há nada mais deprimente que ver uma aplicação se “contorcendo” em um super computador. Observar a carga de consumo dos cores e perceber que apenas um está em 100% enquanto os outros estão “calminhos, calminhos”.

O .NET Framework 4 nos dá mecanismos poderosos para que possamos aproveitar todo poder computacional de nossos computadores. Vamos usar isso e, se possível, sempre buscando entregar o melhor valor percebido possível para os stakeholders.

Distruindo ilusões…

Gostaria de dizer que deixar nossos programas prontos para utilizar paralelismo fosse apenas questão de adicionar a operação AsParallel em todos os nossos loops! Infelizmente, não é assim.

PLINQ torna muito mais fácil utilizar diversos “core” em nossos programas, mas ainda temos que fazer a coisa da forma certa. Ou seja, ainda não é uma tarefa trivial, mas ficou bem mais fácil.

Importante destacar que as extensões de paralelismo do .net estão relacionadas a Enumerable, não com Queryable. Isso significa que PLINQ não ajuda com LINQ to SQL ou EF. Embora pareça estranho, é lógico, visto que, nesses casos, o paralelismo que deva existir deveria ser implementado pelo banco de dados.

Por que PLINQ ainda não é trivial?

Respondendo de forma simples, ainda temos que entender e planejar quando o acessso a dados precisa ser sincronizado. Ainda temos que medir os efeitos das versões paralelizadas e sequenciais dos métodos declarados em ParallelEnumerable. Algumas operações LINQ funcionam facin em paralelo. Outras, nem tanto (por exemplo, sorting requer entradas completa).

Um exemplo trivial

Considere o código que segue:

1 var nums = data.Where(n => n < 200).Select(n => Fibonacci(n));

A funcionalidade do código não é relevante aqui. Importante apenas é saber que data consiste de uma fonte IEnumerable, que serve como entrada para os métodos Where e Select.  Se desejássemos tornar esse código “paralelizado”, basta:

1 var nums = data.AsParallel() 2 .Where(n => n < 200).Select(n => Fibonacci(n));

Lindo! Smiley piscando

Claro, podemos escrever facilmente uma versão em sintax query:

1 var nums = from n in data.AsParallel() 2 where n < 200 3 select Fibonacci(n);

Este primeiro exemplo é muito simples, mas nos permite perceber alguns conceitos importantes utilziados em PLINQ. O método AsParallel() é o responsável por converter nossa consulta normal, em uma consulta paralela. Uma vez que tenhamos chamado AsParallel, todas as operações que seguem serão executadas em multiplos cores, com multiplas threads. AsParallel() é um extension method que recebe um IEnumerable e retorna um IParallelEnumerable. PLINQ foi implementado como um conjunto de extension methods para IParallelEnumerable. Eles tem, quase todos, a mesma assinatura que os extension methods definidos na classe Enumerable que estendem objetos IEnumerable. De forma geral, tudo aquilo que sabemos fazer usando LINQ, continua válido para PLINQ.

Claro que, este primeiro exemplo é simples demais. Nossa consulta é muito simples mas não tem qualquer dado compartilhado. A ordem dos resultados também não importa. Obviamente, isso torna simples evidente a o incremento de performance na proporção em que temos mais “processadores” em nossos computadores é fácil para essas condições.

Conceito fundamental de PLINQ: particionamento

Cada consulta com PLINQ começa pelo particionamento das entradas. Esse particionamento pega os dados recebidos na entrada e os distribuí entre as diversas “tasks” criadas para executar a consulta. Particionamento é um dos aspectos mais importantes do PLINQ. Há diferentes abordagens para essa operação. Entender como PLINQ decide qual usar, e como cada uma funciona é, fundamental para entender PLINQ. Perceba: particionamento não pode consumir muito tempo – se não fosse assim, PLINQ tomaria muito tempo organizando os dados e pouco tempo processando-os. Alegre

PLINQ usa quatro diferentes métodos de particionamento. O método utilizado é usado de acordo com os dados recebidos na entrada, e o tipo que consulta estamos criando. São tipos de particionamento:

  1. separação por intervalo – a separação por intervalo divide os dados da entrada pelo número de “tasks” e dá a cada uma um conjunto de itens. Por exemplo, uma entrada com 1000 itens executando em uma máquina com quatro cores criaria quatro intervalos com 250 itens cada; Essa abordagem é utilizada somente quando a fonte para a consulta suporta índices e a consulta indica quantos elementos estão disponíveis na entrada (o que limita a coisa toda a sequências que realizem, ou sejam compatíveis com, IList<T>);
  2. separação por chunks – esse método dá a cada “task” um “chunk” de entradas a cada vez que requisita mais trabalho. O funcionamento interno desse método vai continuar evoluindo, eu acho. O tamanho de cada chunk será, inicialmente, pequeno. Na medida que o trabalho vai sendo executado, os chunks crescem. A regra geral é que todas as tarefas terminem no mesmo instante. Esse é o método utilizado para entradas não indexadas e/ou que não possam ter o tamanho determinado.
  3. separação por faixas – um particionamento resultante desse método é um caso especial de “separação por intervalo” que otimiza processando os primeiros elementos da sequência de entrada. Cada “task” processa itens “pulando” N itens e processa outros M. Apos processar esses M itens, a “task” pula N itens outra vez. Esse método é utilizado, geralmente, quando a consulta possui a operação TakeWhile ou SkipWhile.
  4. separação Hash – relacionada com as operações Join, GroupJoin, GroupBy, Distinct, Except, Union e Intersect. Sabemos que essas são as operações mais pesadas, então, esse método objetiva garantir o melhor resultado para essas consultas. Funciona garantindo que todos os itens que gerem um mesmo Hash sejam processados por uma mesma task.

Conceito fundamental de PLINQ: métodos de paralelização

Independente do método usado para particionamento, há três diferentes abordagens usadas pelo PLINQ para paralelizar as tarefas em nosso código: Pipelining, Stop and Go e enumeração invertida. Veja:

  1. Pipelining – Nesse modelo, uma thread captura uma enumeração. Multiplos threads são usados para processar a consulta em cada um dos elementos da sequência de entrada. A medida que um novo elemento é requisitado, ele irá ser processado por uma diferente thread. O numéro de threads usadas pelo PLINQ nesse modelo irá ser, geralmente, o número de cores. No exemplo que utilizei acima, em um computador com dois cores, PLINQ utilizaria dois threads. O primeiro item recuperado seria processado por uma thread. Imediatamente, o segundo item seria processado pela segunda thread. Então, quando um desses itens for “concluído”, o terceiro item será requisitado, e a assim sucessivamente até que toda a sequência de entrada tenha sido processada. Em uma máquina com mais processadores, mais itens são processados.
  2. Stop and Go – significa que uma thread iniciando a enumeração irá reunir resultados com todas as demais threads executando na expressão. Esse método é usado quando for solicitado um ToList() ou ToArray() ou quando PLINQ necessitar de um conjunto completo antes de continuar (para questões de ordenação, por exemplo); Ou seja, toda a operação é executada com a thread principal interrompida até que todo o processamento esteja concluído;
  3. Enumeração invertida – a idéia é poder percorrer a lista resultante, sem ter que processar toda a consulta como em Stop and Go.  Usa-se para isso o método ForAll().

PLINQ e execução tardia (Lazy operations)

Todas as operações LINQ são executadas de forma tardia (lazily). Criamos consultas e estas consultas somente são executadas quando uma tarefa requisitar items produzidos por elas. LINQ to Objects dá um passo adiante, executando a consulta a medida que cada item for executado (salve yield return). PLINQ trabalha de forma diferente! Seu modelo se aproxima, muito da forma como o EF funciona, onde, quando requisitamos por um primeiro item, toda sequência é gerada. Repare que eu disse que o modelo de execução do PLINQ se aproxima do modelo do EF. Aproximado sim, igual não! Uma mal compreensão de como PLINQ executa suas consultas faz com que utilizemos mais recursos que o necessário.

Para ilustrar bem o modelo, proponho o seguinte exemplo:

using System; using System.Linq; namespace Parallel { class Program { static void Main(string[] args) { var answers = from n in Enumerable.Range(0, 60) where n.SomeTest() select n.SomeProjection(); var iter = answers.GetEnumerator(); Console.WriteLine("Starting ..."); while (iter.MoveNext()) { Console.WriteLine("called movenext"); Console.WriteLine(iter.Current); } Console.ReadLine(); } } static class ExtensionMethods { public static bool SomeTest(this int that) { Console.WriteLine("Testing {0}.", that); return that % 10 == 0; } public static string SomeProjection(this int that) { Console.WriteLine("projecting {0}.", that); return string.Format("{0} in {1}", that, DateTime.Now.ToLongTimeString()); } } }

Nessa implementação, optei por usar métodos simples de teste e de projeção. A razão de criar eles, é para poder mostrar a sequência em que as operações são executadas. Da mesma forma, no lugar de usar um for each optei por usar o enumerator. A idéia é ter condições de mostrar com mais detalhes o que acontece. A saída desse programa, quando executado, seria essa:

Starting ... Testing 0. projecting 0. called movenext 0 in 18:47:43 Testing 1. Testing 2. Testing 3. Testing 4. Testing 5. Testing 6. Testing 7. Testing 8. Testing 9. Testing 10. projecting 10. called movenext 10 in 18:47:43 Testing 11. Testing 12. Testing 13. Testing 14. Testing 15. Testing 16. ...

A query não começa até que o primeiro MoveNext seja evocado no enumerador. A primeira chamada executa a consulta na medida exata para prover o primeiro elemento. A próxima chamada para MoveNext() processa o segundo elemento e.. assim caminha a humanidade Smiley piscando

As coisas mudam um cadin quando introduzimos paralelismo… Observe a mudança de atribuição para Answers:

 

var answers = from n in ParallelEnumerable.Range(0, 60) where n.SomeTest() select n.SomeProjection();

 

 

que dá por resultado:

Starting ... Testing 0. Testing 15. Testing 16. Testing 17. projecting 0. Testing 45. Testing 46. Testing 47. Testing 48. Testing 49. Testing 50. projecting 50. Testing 30. projecting 30. Testing 18. Testing 19. Testing 20. projecting 20. Testing 31. Testing 32. Testing 33. Testing 34.

Observe que o resultado é muito diferente. A primeira chamada para MoveNext faz com que PLINQ inicie todas as threads envolvidas na geração de resultados. Isso faz com que alguns resultados sejam produzidos . Cada MoveNext() vai pegar um resultado já produzido. Não há maneiras de determinar em que ordem as entradas serão processadas. Tudo que sabemos é que a consulta começa em diversas threads assim que o primeiro MoveNext for executado.

Agora, observe essa consulta:

var answers = (from n in ParallelEnumerable.Range(0, 500) where n.SomeTest() select n.SomeProjection()) .Skip(20).Take(20);

Essa consulta irá resultar algo parecido com nosso primeiro exemplo. O que aconteceu aqui? PLINQ interpretou o que desejamos e, veja, não há forma de obter o resultado correto sem, ou gerar o conjunto completo, ou processar em sequência .. Qual é mais rápido .. ?? É muito bom esse PLINQ Smiley piscando

Agora, uma versão que força a geração da lista completa:

var answers = (from n in ParallelEnumerable.Range(0, 500) where n.SomeTest() orderby n.ToString().Length select n.SomeProjection()) .Skip(20).Take(20);

O que acontece aqui? O framework usa todos os processadores para gerar todas as alternativas o mais rápido possível. Depois, Stop And Go para o Order By. Por fim, sequência para Skip e Take. É realemente muito bom esse PLINQ.

Mais umas palavrinhas …

PLINQ tenta criar a melhor implementação para as consultas que escrevemos de forma a gerar os resultados com o menor consumo possível de recursos. Algumas vezes, isso significa que agir mais como LINQ to Objects, requisitando entradas uma por uma. Em outros casos, se comportando mais como EF em que a requisição do primeiro item gera toda a sequência. Por fim, há casos onde um “mix” das duas abordagens é executado. Entender os conceitos que apresentei aqui hoje, permitem que escrevamos consultas mais eficientes.

PLINQ é um framework muito rico. Há varias possibilidades de tuning para as consultas. Embora não possamos determinar diretamente o funcionamento de nossas consultas, podemos, assim como ocorre em SQL, dar dicas para o “analisador” sobre qual seria o plano mais adequado para nossa consulta.

PLINQ torna computação paralela muito mais fácil do que era antes de seu advento. Além disso, é importante que tenhamos consciência de que a importância da computação paralela tende a aumentar nos próximos anos. Mas, a coisa continua não sendo fácil. Nossa tarefa, enquanto desenvolvedores é olhar para nossos loops e tentar determinar o que podemos paralelizar. Tentar implementar sempre versões “paralelizáveis” de nossos algorítmos. Medir resultados..

Por hoje, era isso Smiley piscando

5 Comentários em “Como Parallel LINQ (PLINQ) funciona?

  1. Pingback: Como se fosse a primeira vez… « Elemar DEV

  2. saulo
    30/09/2010

    Muito bom o artigo !! Parabéns. Se puder poderia escrever uma série introdutória sobre o plinq ?
    []‘s

  3. Robson Fernandes
    18/04/2011

    Excelente!!! Meus parabéns! Artigo simplesmente Fantástico!

  4. Vinicius
    16/05/2013

    Realmente muito bom o artigo..

Deixe uma resposta

Preencha os seus dados abaixo ou clique em um ícone para log in:

WordPress.com Logo

Você está comentando usando sua conta WordPress.com. Sair / Mudar )

Imagem do Twitter

Você está comentando usando sua conta Twitter. Sair / Mudar )

Foto do Facebook

Você está comentando usando sua conta Facebook. Sair / Mudar )

Conectando a %s

Informação

Publicado às 11/09/2010 por em Post e marcado , .

Estatísticas

  • 428,823 hits
%d bloggers like this: