4.1 Problemas de Desempenho em Programas Spark
4.1.4 Particionamento de Dados
A quantidade de dados que podem ser processados em paralelo no cluster depende da forma em que o conjunto de dados está particionado. De acordo com diferentes fontes na literatura (como (KARAU; WARREN, 2017), (GANELIN et al., 2016) e (LI et al., 2017)), o desempenho de programas Spark é altamente influenciado pela estratégia de particionamento utilizada para distribuir os dados através do cluster.
A estratégia de particionamento do RDD pode vir junta com o conjunto de dados, algo que acontece ao se ler dados do HDFS, por exemplo. Uma vez que o HDFS particiona automaticamente o conjunto de dados em vários blocos, Spark aproveita essa configuração e cria uma partição no RDD para cada bloco no HDFS (SPARK, 2019). Essa estratégia de particionamento também 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. O número de partições de um RDD e o número de núcleos de CPU alocados no cluster para executar o programa restringem a quantidade de tarefas que podem ser processadas em paralelo porque o Spark agenda e executa uma única tarefa para processar os dados de cada partição (KARAU; WARREN, 2017). Essa estratégia pode ser ajustada de acordo com o número de núcleos de CPU disponíveis para a aplicação com o objetivo de maximizar seu uso.
Um programa Spark pode não se beneficiar de forma completa dos recursos dis- poníveis no cluster quando um conjunto de dados for dividido em poucas partições. Nesse caso, recursos do cluster podem ficar ociosos porque existem poucas tarefas para serem executadas em paralelo (KARAU; WARREN, 2017). Além disso, ter uma quantidade menor de partições faz, consequentemente, com que cada partição tenha uma quantidade maior de dados, o que pode pressionar a memória nas tarefas e garbage collector (GANE- LIN et al., 2016). Em contrapartida, quando um conjunto de dados está dividido em um número excessivo de partições o programa vai sofrer sobrecarga porque vão existir muitas tarefas a mais para processar do que núcleos de CPU disponíveis, o que significa que tarefas vão precisar esperar recursos ficarem disponíveis para poderem ser processadas.
A escolha do número de partições em relação ao número de núcleos de CPU dis- poníveis é uma tarefa complexa, como mostrado em (LI et al., 2017). Escolher o número de partições exatamente igual ao número de núcleos pode não ser eficiente porque de- pendendo do tamanho do conjunto de dados, ter uma partição com muitos dados pode sobrecarregar a memória durante a sua execução da tarefa. Nos experimentos realizados em (LI et al., 2017), os melhores desempenhos foram obtidos ao se escolher uma quanti- dade de tarefas (e partições) de 1.5 vezes a quantidade de núcleos de CPU disponíveis. Na documentação do Apache Spark (SPARK, 2019), é recomendado se ter de duas à quatro tarefas por núcleo. Com base nesses valores, no exemplo do cluster que contém 16 núcleos de CPU apresentado anteriormente, o número de partições ideal para o conjunto de dados deve ser algo entre 24 (1.5 vezes) e 64 (4 vezes) partições.
Não apenas o número de partições, mas também a maneira em que esses dados estão distribuídos através do cluster pode influenciar o desempenho do programa. Isso é relevante quando o programa lida com dados do tipo chave/valor. Operações que mani- pulam de forma específica dados chave/valor (transformações byKey, como reduceByKey, e junções) precisam redistribuir os dados no cluster para agrupar valores com mesma chave em uma mesma partição (data shuffling). Esse processo é custoso devido ao esforço necessário para mover dados entre os nós do cluster, o que impacta de forma direta o desempenho do programa. Uma maneira de reduzir esse custo é fazer uso de um partici- onador, um recurso que o Spark disponibiliza para controlar como os dados chave/valor são distribuídos a partir da chave. Dessa forma, quando valores associados a uma mesma chave já se encontram na mesma partição, o processo de redistribuição não é necessário em operações chave/valor. Dessa forma, aplicar um particionador em um RDD antes de chamar uma operação chave/valor pode reduzir a comunicação entre os nós do cluster e, consequentemente, melhorar o desempenho do programa (KARAU; WARREN, 2017).
A diferença entre aplicar uma operação chave/valor em um RDD pré-particionado (que passou por um particionador) e um RDD não pré-particionado está ilustrada na Fi- gura 4.2. Nela, é possível ver que o RDD não pré-particionado (representado em azul no lado esquerdo) realiza um processo de redistribuição de dados (data shuffling) para agrupar os valores com mesma chave (representados através de triângulos de mesma cor). No segundo caso, o RDD pré-particionado (representado em vermelho no lado direito) não precisa fazer um processo de redistribuição porque os valores com mesma chave já se en- contram na mesma partição. Isso faz com que ao pré-particionar um RDD de chave/valor, uma transformação ampla (wide transformation, transformação que exige redistribuição de dados) se torne uma transformação estreita (narrow transformation, que não exige redistribuição dos dados).
rdd.reduceByKey( )f
RDD Não
Pré-Particionado Pré-ParticionadoRDD
rdd.reduceByKey( )f
Figura 4.2 – Representação da diferença de um RDD ser pré-particionado ou não antes de uma operação chave/valor.
rddA rddB
rddA.join(rddB)
(a) Junção com RDDs não co-particionados.
rddA.join(rddB)
rddA rddB
(b) Junção com RDDs co-particionados.
Figura 4.3 – Representação da diferença entre uma junção com dois RDDs particiona- dos com o mesmo particionador e uma com dois RDDs particionados com particionadores diferentes.
neficiam de um RDD já particionado, mas isso pode ser ainda mais importante para transformações que operam sobre dois RDDs, como operações de junção. Mesmo nos casos em que os dois RDDs já estão particionados, se eles tiverem sido particionados com diferentes particionadores o processo de redistribuição ainda ocorre porque valores que possuem uma mesma chave em ambos os RDDs podem não estar em uma mesma partição. Dessa forma, pré-particionar ambos os RDDs utilizando o mesmo particionador antes de uma operação de junção pode reduzir um estágio de redistribuição de dados e, consequentemente, melhorar o desempenho da aplicação.
Essa situação é representada na Figura 4.3. Podemos ver que na situação em que a junção é feita com dois RDDs que não estão co-particionados (Figura 4.3a), o processo de redistribuição ocorre independentemente deles terem sido pré-particionados ou não. Na situação oposta (Figura 4.3b), os valores de ambos os RDDs que possuem uma mesma chave já se encontram em uma mesma partição, fazendo com que o processo de redistribuição não seja necessário.