🇧🇷 Entendendo o Spark: Desvendando os Bastidores do Processamento Distribuído

🇬🇧 – To read this article in English click here

 Se você está aqui, provavelmente já teve algum contato com o Spark ou pelo menos ouviu falar do seu uso em processamento de grandes volumes de dados. Este artigo não é um tutorial para iniciantes, nem vai te ensinar comandos básicos. Minha proposta é explorar o que realmente acontece nos bastidores do Spark, desvendando os conceitos que tornam essa ferramenta tão poderosa. Vamos falar sobre o papel dos workers, cores, partições, o funcionamento do processamento distribuído, a diferença entre narrow e wide transformations, e como otimizar recursos para obter o máximo desempenho.

Antes de tudo, vale a pena entender o que é um cluster Spark, já que ele é o coração de qualquer operação distribuída. Um cluster Spark é, essencialmente, um conjunto de máquinas que trabalham juntas para processar os dados de forma paralela. Nele, temos diferentes papéis: o driver, que coordena a execução, e os executors, que realmente processam os dados. Cada executor roda em um worker node e é responsável por uma parte do trabalho, utilizando recursos como CPU (cores) e memória. Essa divisão de tarefas é o que permite que o Spark escale e lide com datasets gigantescos.

Agora que você sabe onde tudo acontece, vamos mergulhar nas engrenagens. Como os workers recebem e processam dados? Como o Spark decide quantas partições criar e como distribuir o trabalho? Entender isso é crucial para ajustar configurações e evitar gargalos, seja na alocação de recursos ou no uso de transformações eficientes.

Driver

No cluster Spark, o driver é o cérebro da operação. É ele que coordena tudo: envia as tarefas para os workers(vamos falar sobre ele), acompanha o progresso e coleta os resultados. Quando você escreve um código Spark, o driver é quem interpreta suas instruções e transforma o trabalho em estágios distribuídos.

O driver roda na máquina que você usa para iniciar o job (ou em um nó específico do cluster, dependendo da configuração). Ele mantém o SparkContext, que é como uma central de comando, e também armazena o DAG (Directed Acyclic Graph), que representa a ordem das operações a serem executadas.

Um detalhe importante: o driver precisa de recursos suficientes. Se o volume de dados for muito grande e ele não tiver memória suficiente, você pode acabar com um bottleneck. Ele também é responsável por dividir os dados em partições(vamos falar cobre elas) e decidir quais tarefas vão para quais workers. Por isso, um driver bem dimensionado é tão crucial quanto os executors no seu cluster.

Workers

Os workers são as máquinas que realmente fazem o trabalho pesado no Spark. Cada worker executa tarefas específicas, processando partes dos dados distribuídos pelo driver. Dentro de um worker, você tem os executors(vamos falar mais sobre eles), que são responsáveis por rodar as tarefas e armazenar os dados necessários para o processamento.

Pense nos workers como “braços” que recebem ordens do driver e colocam a mão na massa. Eles usam os recursos de CPU (os cores) e memória disponíveis para processar as partições dos dados. Quanto mais workers e recursos você tiver, maior o poder de processamento.

O que acontece nos bastidores é que o driver divide o trabalho em várias tarefas pequenas, distribui essas tarefas para os workers e monitora o andamento. Se um worker falha, o Spark consegue redistribuir a tarefa para outro, garantindo a resiliência do sistema.

Cores

Os cores são as unidades de processamento dentro de cada worker. Eles representam quantas tarefas podem ser executadas simultaneamente em um executor. Cada executor recebe um número de cores e os usa para processar as partições dos dados.

No Spark, mais cores significam maior paralelismo. Se um executor tem 4 cores, ele pode rodar até 4 tarefas ao mesmo tempo. No entanto, não adianta exagerar: o número de cores precisa ser balanceado com a quantidade de partições e o tamanho do cluster. Se você der muitos cores para poucos executors, pode acabar desperdiçando recursos.

Um detalhe importante: tarefas que dependem de operações complexas, como wide transformations(vamos falar mais sobre), podem consumir mais CPU. Por isso, ajustar o número de cores por executor é essencial para evitar gargalos e maximizar o desempenho.

Cálculo:

Calcular o número ideal de workers e cores no Spark é crucial para aproveitar bem os recursos do cluster. A fórmula básica é:

Total de cores disponíveis = Número de workers × Número de cores por worker.

Se nas configuracoes, voce definir que seu processamento tera 5 workers e 8 cores, você terá 40 cores no total para processar tarefas em paralelo. Entao seu processamento vai trabalhar com 40 cores.

Ao configurar, lembre-se:

  • Deixe 1 core por worker para o sistema operacional, ou seja, use 7 cores em vez de 8.
  • O número de tarefas deve ser maior que o número de cores disponíveis para garantir que todas as partições sejam processadas sem gargalos.

Essa distribuição equilibrada evita desperdício de recursos e melhora a performance geral do job.

Partições:

As partições são a menor unidade de dados que o Spark trabalha. Quando você carrega um dataset, o Spark o divide em várias partes menores, chamadas de partições, para distribuir o processamento entre os workers. Cada tarefa no Spark processa uma partição por vez.

O número de partições influencia diretamente o desempenho. Partições demais podem gerar sobrecarga no cluster, enquanto partições de menos podem deixar os recursos subutilizados. A regra geral é que o número de partições deve ser maior que o número total de cores disponíveis, para que todos os cores tenham trabalho o tempo todo. Geralmente, se deve ter pelo menos de 2 a 3 vezes o número de cores totais.

 Exemplo: Se definimos 40 Cores(5 Workers com 8 Cores cada) precisamos de 160 a 240 partições 

Você pode ajustar o número de partições usando funções como repartition() ou configurando spark.sql.shuffle.partitions para operações que geram partições novas, como joins e aggregations. Um bom ajuste de partições pode fazer toda a diferença no tempo de execução dos seus jobs.

Por padrão, o Spark processa 128 MB por partição (valor que pode ser alterado). Por isso, o ideal é ajustar as partições de um arquivo de acordo com o seu tamanho.

Exemplo:
Se um arquivo Parquet tem 100 GB, o cálculo seria:
Partições = 100 × 1024 / 128 = 800

Ou seja, o ideal seria algo próximo a 800 partições. Para ajustar isso, você pode usar coalesce(800) ou um valor próximo, se quiser diminuir o número de partições manualmente.

Esse cálculo ajuda a evitar overmemory (falaremos mais sobre isso), garantindo que as partições sejam equilibradas em relação aos recursos disponíveis.

Memory:

A memória no Spark é dividida entre armazenamento de dados e execução de tarefas. Ela precisa ser bem gerenciada para evitar problemas como overmemory, que ocorre quando as tarefas exigem mais memória do que o executor tem disponível. Isso pode causar falhas ou lentidão, já que o Spark começa a usar disco como backup (spill).

Outro ponto importante é o overhead, que é a memória reservada para gerenciar o próprio Spark (como metadados, informações de tarefas e outras operações internas). Por padrão, o Spark aloca 10% da memória total do executor para overhead, mas isso pode ser ajustado com a configuração spark.executor.memoryOverhead.

Se sua aplicação estiver consumindo muita memória, considere:

  • Aumentar o número de partições para reduzir o tamanho de cada uma.
  • Ajustar o tamanho de memória dos executors (spark.executor.memory).
  • Revisar o código para evitar ações desnecessárias, como coletas excessivas para o driver.

Com isso, você minimiza problemas e garante um uso eficiente dos recursos do cluster.

Conectando as peças:

Vamos construir um exemplo prático conectando todos os conceitos que discutimos.

Exemplo: Carregando um arquivo Parquet de 100 GB no Spark

Suponha que você precise carregar um arquivo Parquet de 100 GB no Spark, e tem um cluster com 5 workers. Vamos seguir os passos de configuração para otimizar o processamento.

  1. Partições:
    Como o Spark processa 128 MB por partição, o cálculo para o número de partições seria:
    Partições = (100 GB × 1024 MB/GB) / 128 MB = 800 partições
  2. Alocação de recursos:
    Vamos supor que cada worker tenha 4 cores e 14 GB de memória disponível para execução. Se você tem 5 workers, o total de memória para processamento seria 5 × 14 GB = 70 GB de memória disponíveis para o Spark.
  3. Execução:
    A carga de trabalho será distribuída entre os executores nos workers. Para evitar problemas como overmemory (onde a memória alocada não é suficiente), o número de partições deve ser adequado ao número de cores e memória disponíveis, para que cada tarefa consiga ser executada sem sobrecarregar a memória de um único executor.

Estrutura do Cluster

Aqui está como seria a visualização do cluster e como ele gerencia os recursos:

Cluster Spark

│

├── Worker 1 (Nó 1)

│   ├── Executor 1

│   │   ├── Memória: 14 GB

│   │   └── Cores: 4

│   └── Sistema: 2 GB

│

├── Worker 2 (Nó 2)

│   ├── Executor 2

│   │   ├── Memória: 14 GB

│   │   └── Cores: 4

│   └── Sistema: 2 GB

│

├── Worker 3 (Nó 3)

│   ├── Executor 3

│   │   ├── Memória: 14 GB

│   │   └── Cores: 4

│   └── Sistema: 2 GB

│

├── Worker 4 (Nó 4)

│   ├── Executor 4

│   │   ├── Memória: 14 GB

│   │   └── Cores: 4

│   └── Sistema: 2 GB

│

└── Worker 5 (Nó 5)

    â”œâ”€â”€ Executor 5

    â”‚   ├── Memória: 14 GB

    â”‚   └── Cores: 4

    â””── Sistema: 2 GB

Detalhamento:

  • Partições: O arquivo de 100 GB será dividido em 800 partições, distribuídas entre os 5 workers.
  • Memória e Cores: Cada executor tem 14 GB de memória e 4 cores. Cada partição será processada em paralelo, com cada executor utilizando seus cores e memória.
  • Evitar Overmemory: Ao distribuir bem as partições e ajustar a configuração de memória, o Spark evita sobrecarga, garantindo que cada executor tenha memória suficiente para processar suas tarefas sem recorrer ao uso excessivo de disco (spill).

Resumo:

O Spark usa seus 5 workers, com 14 GB de memória e 4 cores por worker, para processar o arquivo Parquet de 100 GB, que foi dividido em 800 partições. Esse balanceamento garante que o trabalho seja feito de forma eficiente, aproveitando os recursos do cluster e evitando problemas de memória.

Shuffle:

O shuffle no Spark é o processo de redistribuição de dados entre diferentes partições ou nós do cluster. Isso acontece quando o Spark precisa reorganizar os dados para uma operação que depende de uma nova distribuição, como uma join, groupBy, ou reduceByKey.

Embora o shuffle seja essencial para muitas operações, ele é uma das partes mais caras em termos de desempenho, porque envolve a leitura, movimentação e escrita de grandes volumes de dados entre os nós do cluster. Isso pode causar aumento no tempo de execução e sobrecarga de rede.

Dicas para otimizar o shuffle:

  • Reduzir o número de operações que causam shuffle, como groupBy e join desnecessários.
  • Ajustar o número de partições para garantir que não haja sobrecarga nas operações de shuffle.
  • Usar coalesce ou repartition para controlar o número de partições antes de realizar operações de shuffle, evitando que o Spark crie partições em excesso.

Evitar e otimizar o shuffle é crucial para garantir que seu job seja eficiente no Spark.

Narrow transformation:

Uma narrow transformation é uma operação onde os dados de uma partição são transformados sem a necessidade de movimentar os dados entre as partições. Ou seja, cada entrada de uma partição gera uma única saída para a mesma partição. Exemplos comuns incluem map, filter e flatMap. Essas transformações são eficientes porque não envolvem o custo do shuffle e podem ser executadas de forma paralela em várias partições sem necessidade de comunicação entre elas.

Wide transformation:

Já uma wide transformation envolve operações que requerem a movimentação de dados entre diferentes partições. Isso ocorre quando os dados precisam ser reorganizados, como em operações de groupBy, join ou reduceByKey. Essas transformações são mais custosas porque geram o shuffle, onde os dados são redistribuídos pelo cluster, o que pode afetar o desempenho, principalmente em grandes volumes de dados.

Conteúdo extra:

Performance em cluster em larga escala
Ao trabalhar com clusters em larga escala no Spark, o gerenciamento de recursos e a configuração adequada de parâmetros de execução são essenciais para garantir um desempenho eficiente. Um dos fatores cruciais para a performance é a configuração do número de partições durante as operações de shuffle.

spark.sql.shuffle.partitions
Esse parâmetro define o número de partições para operações que geram shuffle, como joins ou groupBy. O valor padrão é 200, mas isso pode ser ajustado dependendo do tamanho do seu dataset e da configuração do cluster. Ter um número muito baixo pode resultar em sobrecarga de memória, enquanto um número muito alto pode aumentar o tempo de execução devido à gestão de muitas partições pequenas. A regra é configurar de forma que as partições se ajustem ao número de recursos disponíveis, garantindo um balanceamento entre paralelismo e uso de memória.

Ajustar spark.sql.shuffle.partitions de forma otimizada, levando em consideração a escala do cluster e a complexidade das operações, pode melhorar significativamente a performance e reduzir o tempo de execução de jobs pesados.

Agora você tem uma noção maior de como funciona o spark debaixo dos panos, espero que tenha gostado da leitura e aprendido algo com esse artigo.