Definimos uma taxonomia que agrupa e caracteriza os problemas que podem influenciar negativamente o desempenho de um programa Spark. Como mostrado ex- perimentalmente na seção anterior, esses problemas são causados por más escolhas de programação e ajustes na configuração do cluster. Dessa forma, ter conhecimento sobre os problemas que podem aparecer e saber como eles podem ser corrigidos ou melhorados é importante para se ter programas com bom desempenho. O objetivo dessa taxonomia é classificar problemas de desempenhos e guiar as decisões de programadores e adminis- tradores de cluster durante o desenvolvimento e execução de programas Spark.
A Figura 4.20 apresenta a taxonomia de problemas de desempenho para o Apache Spark. Essa taxonomia foi dividida em seis categorias de problemas de desempenho (caixas em cinza escuro): Gerenciamento de Recursos do Cluster ; Particionamento dos Dados; Transmissão de Dados; Persistência de Dados; Redistribuição de Dados; e Projeto de Implementação do Programa. Definimos 11 problemas de desempenho divididos nessas categorias (caixas em cinza claro). Essas categorias e os problemas descritos em cada uma são apresentados a seguir.
I1 - Gerenciamento de Recursos do Cluster: essa categoria representa os aspectos
relacionados com a alocação de recursos que devem ser considerados no ajuste da con- figuração do cluster para executar um programa Spark. O recursos considerados nesse processo são: (i) o número de nós no cluster (workers); (ii) o número de executores em cada nó; e (iii) o número de núcleos de CPU e quantidade de memória disponível para cada executor.
Taxonomia de Problemas de Desempenho para Apache Spark Gerenciamento de Recursos do Cluster Memória Insuficiente Particionamento dos Dados Particionamento Ineficiente
Transmissão de Dados Persistência de Dados
RDD Não Persistido Alocação Ineficiente de Recursos Nível de Persistência Ineficiente Variável Não Transmitida Redistribuição de Dados RDD Não Pré-Particionado Particionadores Diferentes Projeto de Implementação do Programa groupByKey vs. reduceByKey reduceByKey vs. aggregateByKey repartition vs. coalesce
Figura 4.20 – Taxonomia de problemas de desempenho para programas Spark.
I1.1 - Memória Insuficiente: quando um programa Spark é submetido para execução no cluster, é necessário que seja alocada um quantidade suficiente de memória RAM para ele, principalmente quando o programa persiste RDDs em memória. Não ter memória suficiente pode causar perda de desempenho devido à falta de espaço para persistir todos os dados de um RDD, o que pode causar uma sobrecarga no programa porque parte do RDD tem que ser re-computado quando for necessário. Além disso, alocar pouca memória para os executores pode causar erros por falta de memória devido à grande quantidade de dados que estão sendo processados ou perda de desempenho quando dados são transferidos entre disco e memória. Nos nossos experimentos, os efeitos de ter memória insuficiente ficam evidentes na Figura 4.11 e também podem ser observados na Tabela 4.4, em que podemos ver que as configurações que tiveram menor quantidade de memória (conf4 e conf10 ) tiveram os piores desempenhos e uma quantidade significativa de falhas.
I1.2 - Alocação Ineficiente de Recursos: o número de executores e a quantidade de me- mória RAM e núcleos de CPU alocados para cada executor têm impacto direto no desem- penho. Dessa forma, mesmo que todos os recursos disponíveis sejam alocados, a forma em que eles são alocados pode influenciar de forma positiva ou negativa o desempenho. Assim, deve existir um bom equilíbrio na alocação desses recursos considerando as carac- terísticas do programa que será executado, como considerar se dados serão persistidos e a quantidade de dados que serão processados. O impacto de alocar os recursos do cluster de diferentes maneiras foi evidente nos resultados dos nossos experimentos (figuras 4.9, 4.10 e 4.11).
I2 - Particionamento dos Dados: o número de partições de um RDD e o número de
núcleos de CPU alocados para o programa determinam a quantidade de dados que podem ser processados em paralelo. Como dito anteriormente, a estratégia de particionamento do RDD pode vir junta com o conjunto de dados ou pode ser configurada ou programada de forma explícita, através da definição do número de partições que um RDD vai ter.
Essa estratégia pode ser ajustada de acordo com o número de núcleos de CPU disponíveis para o programa com o objetivo de maximizar seu uso.
I2.1 - Particionamento Ineficiente: uma vez que a quantidade de partições de um RDD determina o número de tarefas que o Spark precisa processar e que a quantidade de núcleos de CPU disponíveis restringe o número de tarefas que podem ser executadas em paralelo, ter o RDD particionado de forma ineficiente (muitas ou poucas partições em relação à quantidade de recursos disponíveis) pode impactar negativamente o desempenho do programa uma vez que os recursos do cluster não estarão sendo utilizados de forma completa. Nossos experimentos mostraram que diferentes números de partições podem causar mudanças significativas no desempenho (Figura 4.14).
I3 - Transmissão de Dados: a troca de dados entre o programa Driver e os executores
pode causar uma sobrecarga na execução porque Spark precisa enviar cópias desses dados para cada tarefa que será executada. Dessa forma, reduzir a quantidade de dados que será transmitido do Driver para os executores pode reduzir o impacto negativo que essa transmissão tem no desempenho da aplicação.
I3.1 - Variável Não Transmitida: não utilizar variáveis de transmissão (Broadcas Vari- ables) para transmitir dados nos casos em que se tem alguma variável pesada ou uma grande quantidade de tarefas (e, consequentemente, cópias para fazer e transmitir) pode influenciar negativamente o desempenho do programa. Utilizar variáveis de transmissão faz com que tarefas referenciem cópias locais no executor ao invés de uma cópia cada uma. O impacto disso foi observado nos nossos experimentos (Figura 4.13), em que obtivemos um desempenho 31% melhor quando utilizamos uma variável de transmissão.
I4 - Persistência de Dados: RDDs podem ser persistidos em memória ou em disco para
evitar re-computar um mesmo RDD várias vezes devido a estratégia de avaliação tardia (lazy evaluation) adotada pelo Spark. Dessa forma, o ato de persistir um RDD ou não nos casos em que ele é utilizado em várias ações pode levar a problemas de desempenho dependendo do tamanho do conjunto de dados, complexidade da carga de trabalho e nível de persistência.
I4.1 - RDD Não-Persistido: utilizar um RDD em mais de uma ação pode acarretar em operações redundantes devido à abordagem de avalização tardia de Spark. Nos casos em que o RDD demanda muito esforço para ser computado, como várias operações que demandam redistribuição de dados, esse trabalho redundante de computar o mesmo RDD várias vezes pode prejudicar o desempenho do programa. A influência negativa de não persistir RDDs que são reutilizados pode ser vista na Figura 4.12.
I4.2 - Nível de Persistência Ineficiente: escolher um nível de persistência errado para o contexto pode prejudicar o desempenho de um programa. Esse nível inclui o local que
irá armazenar os dados, como em memória ou em disco, e a forma em que os dados serão armazenados, como dados serializados ou não. O desafio é escolher o nível de persistên- cia mais adequado para maximizar o desempenho do programa, que envolve analisar a quantidade de dados que serão persistidos e a quantidade de recursos de armazenamento disponíveis para isso. Nos nossos experimentos, os melhores resultados foram obtidos com os dados persistidos em memória (Figura 4.12), entretanto, outros níveis de persistência podem ser mais adequados em outros contextos.
I5 - Redistribuição de Dados: redistribuir dados do tipo chave/valor no cluster para
agrupar valores com mesma chave em uma mesma partição é um processo custoso devido ao esforço necessário para mover os dados no cluster. Por esse motivo, o uso de operações que exigem redistribuição de dados (data shuffling) deve ser minimizado para reduzir seu impacto no desempenho. Fazer uso de recursos que permitem controlar como os dados são particionados, como os particionadores, é uma forma de reduzir os custos da redistribuição de dados.
I5.1 - RDD Não Pré-Particionado: controlar como dados do tipo chave/valor são agru- pados de forma estratégica utilizando particionadores pode reduzir a comunicação entre os nós do cluster e, consequentemente, melhorar o desempenho do programa (KARAU; WARREN, 2017). Operações que trabalham com chave/valor, como a operação reduceBy- Key por exemplo, se beneficiam de um RDD pré-particionamento porque os valores que possuem uma chave em comum já se encontram em uma mesma partição. Dessa forma, o processo de redistribuição dos dados não é mais necessário, o que pode melhorar o de- sempenho do programa. A Figura 4.15 mostra que obtivemos um desempenho duas vezes melhor ao aplicar uma operação chave/valor em um RDD pré-particionado nos nossos experimentos.
I5.2 - Particionadores Diferentes: em operações chave/valor que operam sobre dois RDDs (junções) é necessário levar em consideração a organização dos dois RDDs. Se ambos os RDDs não tiverem sido pré-particionados ou tiverem sido particionados com particiona- dores diferentes, o processo de redistribuição ocorre e pode impactar o desempenho do programa devido aos custos de mover dados dos dois RDDs no cluster. Mesmo que nossos experimentos tenham mostrado pouco impacto no desempenho (Figura 4.16), podemos ver que os resultados na Tabela 4.5 (programas 1 e 2) mostram que aplicar uma junção em dois RDDs particionados de diferentes maneiras causa uma estágio extra de redistribuição.
I6 - Projeto de Implementação do Programa: a escolha de operações que causam
redistribuição de dados deve ser guiada pela forma em que essas operações administram essa redistribuição. O momento em que a redistribuição dos dados é feita pode reduzir consideravelmente a quantidade de dados que serão movimentados no cluster, impactando de forma direta o desempenho do programa.
I6.1 - groupByKey versus reduceByKey: as operações groupByKey e reduceByKey podem ser utilizadas para aplicar uma função sobre todos os valores que possuem uma mesma chave. Com a operação groupByKey, a redistribuição de dados é feita em todo o conjunto de dados e, só após, todos os valores com uma mesma chave são processados em uma única tarefa. Já com a operação reduceByKey, primeiro é realizada a redução local com os valores que possuem uma mesma chave e que já se encontram em uma mesma partição. Em seguida, esses resultados intermediários são redistribuídos para serem processados com outros de mesma chave, o que pode reduzir consideravelmente a quantidade de dados que são redistribuídos (ver Figura 4.17).
I6.2 - reduceByKey versus aggregateByKey: operações de agregação são comumente apli- cadas em dados do tipo chave/valor para reduzir os valores que possuem uma mesma chave a um único valor. Se a agregação envolve mudança de tipo dos valores agregados, é possível utilizar as operações reduceByKey e aggregateByKey. Ambas as operações ge- renciam a redistribuição de dados de forma semelhante, mas seus comportamentos são diferentes em relação aos objetos que precisam ser criados para mudar o tipo dos valores agregados. A operação reduceByKey requer que todos os valores do RDD sejam mapea- dos para o novo tipo antes que ela seja aplicada, o que pode sobrecarregar o programa porque leva à criação de novos objetos na mesma quantidade de elementos no RDD. Já a operação aggregateByKey consegue reduzir a criação de objetos ao permitir que valores de diferentes tipos sejam agregados. Essa diferença impacta no desempenho do programa, como podemos ver na Figura 4.18.
I6.3 - repartition versus coalesce: ajustar o número de partições de um RDD pode ser uma forma de evitar sobrecargas e melhorar a paralelização no processamento dos da- dos (GANELIN et al., 2016). As operações repartition e coalesce podem ser usadas para esse propósito. Quando o número de partições precisa ser aumentado, ambas as opera- ções operam de forma similar. Mas quando esse número precisa ser reduzido, a operação coalesce tem um melhor desempenho porque ela consegue evitar a redistribuição de da- dos ao mesclar partições que se encontram em um mesmo nó no cluster, enquanto que a operação repartition sempre redistribui os dados. Isso mostrou ter um grande impacto no desempenho nos nossos experimentos, como podemos ver na Figura 4.19).
4.5
Considerações Finais
Este capítulo apresentou como contribuição um estudo experimental sobre proble- mas de desempenho em programas Spark. Nesse estudo, investigamos diferentes aspectos sobre o desempenho de programas Spark que foram identificados a partir de fontes na literatura e documentação do Spark, como (SPARK, 2019), (GANELIN et al., 2016) e (KARAU; WARREN, 2017), e na experiência adquirida ao desenvolver e executar pro-
gramas Spark. Executamos experimentos em que diferentes versões de programas e con- figurações no cluster foram exploradas para avaliar o impacto de diferentes decisões. A partir desses experimentos, derivamos uma taxonomia de problemas de desempenho em programas Spark.
O propósito da taxonomia é caracterizar os diferentes problemas que podem apa- recer em programas Spark e fornecer um guia para que desenvolvedores e administradores de cluster possam usar como referência para ter programas mais eficientes. Dessa forma, as dimensões e categorias da taxonomia podem ser utilizadas como uma lista a ser veri- ficada para garantir que as escolhas tomadas durante o desenvolvimento do programa ou na configuração do cluster não vão prejudicar o desempenho do programa. Além disso, a taxonomia também pode ser utilizada como uma referência para testes de desempenho de modo que diferentes opções podem ser testadas para que uma tomada de decisão seja feita. As categorias propostas na taxonomia podem ser consideradas de forma indepen- dente uma das outras e suas dimensões (problemas de desempenho) podem aparecer em um mesmo programa.
É importante notar que a taxonomia pode não abordar todos os possíveis pro- blemas de desempenho que podem surgir em um programa Spark, mas reflete os desem- penhos que foram encontrados em nossa investigação. Dessa forma, a taxonomia pode ser estendida futuramente caso novos problemas de desempenho sejam identificados. No- vos problemas podem ser classificados de acordo com as categorias que criamos, assim como originar novas categorias que sejam mais adequadas para agrupar problemas mais específicos.
5 Uma Taxonomia de Defeitos Funcionais
para o Apache Spark
Este capítulo apresenta o resultado de um estudo sobre defeitos em aplicações Spark. A partir desse estudo, foi desenvolvida uma taxonomia que agrupa e categoriza defeitos que podem aparecer no código de programas Spark. Os defeitos foram identi- ficados a partir de uma análise da documentação do Apache Spark (SPARK, 2019) e de códigos fonte de programas desenvolvidos pelo autor e de outras fontes na literatura, como os programas apresentados em (ZAHARIA et al., 2015), (GANELIN et al., 2016) e (KARAU; WARREN, 2017). O objetivo desta taxonomia é descrever possíveis defeitos que podem aparecer no desenvolvimento de programas Spark e servir como um guia para o desenvolvimento de aplicações de modo a evitar esses tipos de defeitos. Os defeitos des- critos na taxonomia foram analisados através de um estudo em postagens sobre o Apache Spark no Stack Overflow de modo a verificar quais defeitos geram mais interesse por parte da comunidade de desenvolvedores do Apache Spark.
Este capítulo está organizado da seguinte forma: a Seção 5.1 apresenta e exem- plifica defeitos funcionais que podem aparecer em programas Spark; a Seção 5.2 apresenta a taxonomia de defeitos funcionais para Spark; a Seção 5.3 apresenta uma análise sobre a taxonomia em postagens no Stack Overflow; a Seção 5.4 apresenta discussões sobre os resultados do estudo; e, por último, a Seção 5.5 apresenta considerações finais sobre os resultados apresentados nesse capítulo.