DEV Community

renanpyd
renanpyd

Posted on

Como aprendi Apache Spark — Parte 4: Shuffle — onde a performance começa a ficar avançada

No artigo anterior, vimos que existe uma relação fundamental no Apache Spark:

Dados
  ↓
Partitions
  ↓
Tasks
  ↓
Parallelism
  ↓
Performance
Enter fullscreen mode Exit fullscreen mode

Também vimos que nem sempre os dados permanecem nas mesmas partições durante todo o processamento.

Operações como:

df.repartition(...)
Enter fullscreen mode Exit fullscreen mode
df.groupBy(...)
Enter fullscreen mode Exit fullscreen mode
df.join(...)
Enter fullscreen mode Exit fullscreen mode

podem exigir uma redistribuição dos dados.

E é nesse momento que aparece um dos conceitos mais importantes para entender performance no Spark:

Shuffle

Quando comecei a entender o que realmente acontecia durante um shuffle, vários problemas de performance que antes pareciam aleatórios começaram a fazer sentido.

Porque shuffle não significa apenas:

"Spark reorganizou algumas linhas."

Em um ambiente distribuído, reorganizar dados pode significar:

serializar
    ↓
escrever
    ↓
transferir pela rede
    ↓
ler
    ↓
desserializar
    ↓
agrupar
    ↓
continuar processando
Enter fullscreen mode Exit fullscreen mode

E tudo isso tem custo.


1. O que é Shuffle?

Vamos começar com um exemplo simples.

Imagine que temos dados distribuídos entre três partições:

Partition 1

customer=A
customer=B
customer=C


Partition 2

customer=A
customer=D
customer=B


Partition 3

customer=C
customer=A
customer=D
Enter fullscreen mode Exit fullscreen mode

Agora queremos contar quantos registros existem para cada cliente:

resultado = (
    df
    .groupBy("customer")
    .count()
)
Enter fullscreen mode Exit fullscreen mode

Para calcular:

A → 3
B → 2
C → 2
D → 2
Enter fullscreen mode Exit fullscreen mode

Spark precisa reunir registros relacionados à mesma chave.

Mas os registros de customer=A, por exemplo, estão espalhados entre diferentes partições.

Temos:

P1 → A
P2 → A
P3 → A
Enter fullscreen mode Exit fullscreen mode

Em algum momento esses dados precisam ser redistribuídos.

Conceitualmente:

ANTES

Partition 1     Partition 2     Partition 3

A               A               C
B               D               A
C               B               D


                 ↓
              SHUFFLE
                 ↓


DEPOIS

Partition A     Partition B     Partition C

A               B               C
A               B               C
A               D               D
Enter fullscreen mode Exit fullscreen mode

A distribuição exata depende da estratégia e do particionamento utilizados, mas a ideia central é esta:

Shuffle redistribui dados entre partições para permitir que uma etapa posterior da computação seja executada.


2. Por que Shuffle é caro?

Se todos os dados estivessem dentro de um único processo, reorganizar registros seria relativamente simples.

Mas Spark é distribuído.

Imagine:

Machine A
Executor 1

Machine B
Executor 2

Machine C
Executor 3
Enter fullscreen mode Exit fullscreen mode

Agora precisamos mover dados de uma máquina para outra:

Executor 1 ──────────► Executor 2
     │                      ▲
     │                      │
     ▼                      │
Executor 3 ─────────────────┘
Enter fullscreen mode Exit fullscreen mode

Isso pode envolver:

  • CPU;
  • serialização;
  • memória;
  • disco;
  • rede;
  • desserialização;
  • gerenciamento de buffers;
  • sincronização entre etapas.

Portanto, shuffle pode transformar uma operação aparentemente simples em uma operação distribuída relativamente cara.


3. Narrow vs. Wide Dependencies

Na Parte 3 vimos rapidamente a diferença entre operações narrow e wide.

Agora ela se torna essencial.

Narrow dependency

Imagine:

df.filter("valor > 100")
Enter fullscreen mode Exit fullscreen mode

Cada partição pode executar o filtro sobre seus próprios registros.

Partition 1 ───► Filter ───► Partition 1'

Partition 2 ───► Filter ───► Partition 2'

Partition 3 ───► Filter ───► Partition 3'
Enter fullscreen mode Exit fullscreen mode

Não precisamos necessariamente enviar registros de uma partição para outra.

Outro exemplo:

rdd.map(lambda x: x * 2)
Enter fullscreen mode Exit fullscreen mode

Conceitualmente:

P1 ───► P1'
P2 ───► P2'
P3 ───► P3'
Enter fullscreen mode Exit fullscreen mode

Isso é muito diferente de uma operação que precisa reorganizar os dados globalmente.


4. Wide dependency

Agora considere:

df.groupBy("customer_id").count()
Enter fullscreen mode Exit fullscreen mode

Os registros do mesmo cliente podem estar em qualquer partição.

Temos algo como:

P1 ──┐       ┌──► P1'
     ├───────┤
P2 ──┼───────┼──► P2'
     ├───────┤
P3 ──┘       └──► P3'
Enter fullscreen mode Exit fullscreen mode

Uma partição de saída pode depender de dados provenientes de várias partições anteriores.

Isso caracteriza uma dependência ampla — wide dependency.

E frequentemente significa:

Wide Dependency
       ↓
    Shuffle
       ↓
Stage Boundary
Enter fullscreen mode Exit fullscreen mode

Esse relacionamento é uma das chaves para entender o DAG de uma aplicação Spark.


5. Shuffle cria fronteiras entre Stages

Na Parte 2 vimos:

Action
   ↓
 Job
   ↓
Stages
   ↓
Tasks
Enter fullscreen mode Exit fullscreen mode

Agora conseguimos entender melhor como Spark pode separar determinados Stages.

Considere:

resultado = (
    df
    .filter("status = 'ACTIVE'")
    .groupBy("country")
    .count()
)

resultado.show()
Enter fullscreen mode Exit fullscreen mode

Podemos imaginar:

STAGE 1

Read
 ↓
Filter
 ↓
Partial Processing
 ↓

========= SHUFFLE =========

 ↓
STAGE 2

Aggregation
 ↓
Result
Enter fullscreen mode Exit fullscreen mode

O shuffle cria uma dependência entre conjuntos de Tasks.

As Tasks da etapa posterior precisam dos dados produzidos pela etapa anterior.

Isso forma uma fronteira natural na execução.


6. Shuffle Write e Shuffle Read

Esse processo pode ser dividido conceitualmente em duas partes:

Stage anterior
      │
      ▼
SHUFFLE WRITE
      │
      ▼
   Network
      │
      ▼
SHUFFLE READ
      │
      ▼
Próximo Stage
Enter fullscreen mode Exit fullscreen mode

Shuffle Write

As Tasks do Stage anterior produzem dados que serão consumidos posteriormente.

Esses dados precisam ser organizados para as partições de destino.

Podemos imaginar:

Task 1
  │
  ├── dados para Partition A
  ├── dados para Partition B
  └── dados para Partition C

Task 2
  │
  ├── dados para Partition A
  ├── dados para Partition B
  └── dados para Partition C
Enter fullscreen mode Exit fullscreen mode

Esses resultados formam a saída do shuffle.


Shuffle Read

No Stage seguinte, as Tasks precisam buscar os blocos correspondentes à sua partição.

Por exemplo:

                  Task A

Task 1 output ──────┐
Task 2 output ──────┼──► Partition A
Task 3 output ──────┘
Enter fullscreen mode Exit fullscreen mode

Agora começamos a entender métricas encontradas no Spark UI:

Shuffle Read
Shuffle Write
Enter fullscreen mode Exit fullscreen mode

Elas não são apenas números abstratos.

Representam movimentação real de dados durante a computação distribuída.


7. Um exemplo com groupBy

Considere:

resultado = (
    df
    .groupBy("estado")
    .count()
)
Enter fullscreen mode Exit fullscreen mode

Antes da agregação:

Executor 1

SP
RJ
SP
MG


Executor 2

MG
SP
BA
RJ


Executor 3

SP
BA
SP
MG
Enter fullscreen mode Exit fullscreen mode

Para calcular os totais por estado, os registros precisam ser reorganizados de acordo com a chave.

Algo conceitualmente semelhante a:

                  SHUFFLE

Executor 1 ─────────┐
Executor 2 ─────────┼──► Partition SP
Executor 3 ─────────┘


Executor 1 ─────────┐
Executor 2 ─────────┼──► Partition RJ
Executor 3 ─────────┘


Executor 1 ─────────┐
Executor 2 ─────────┼──► Partition MG
Executor 3 ─────────┘
Enter fullscreen mode Exit fullscreen mode

Depois disso, cada partição pode executar sua parte da agregação.


8. groupByKey() vs. reduceByKey()

No mundo dos RDDs, um exemplo clássico para entender shuffle é comparar:

rdd.groupByKey()
Enter fullscreen mode Exit fullscreen mode

com:

rdd.reduceByKey(...)
Enter fullscreen mode Exit fullscreen mode

Imagine:

("spark", 1)
("spark", 1)
("spark", 1)
("python", 1)
Enter fullscreen mode Exit fullscreen mode

Com groupByKey(), os valores associados às chaves precisam ser agrupados para então serem processados.

Conceitualmente:

spark → [1, 1, 1]
python → [1]
Enter fullscreen mode Exit fullscreen mode

Agora considere:

rdd.reduceByKey(lambda a, b: a + b)
Enter fullscreen mode Exit fullscreen mode

Spark pode realizar agregações locais antes de transferir os dados entre partições.

Imagine:

PARTITION 1

spark → 1
spark → 1
python → 1
Enter fullscreen mode Exit fullscreen mode

Antes de enviar:

spark → 2
python → 1
Enter fullscreen mode Exit fullscreen mode

Outra partição:

PARTITION 2

spark → 1
spark → 1
Enter fullscreen mode Exit fullscreen mode

pode produzir:

spark → 2
Enter fullscreen mode Exit fullscreen mode

Então, em vez de mover todos os registros individualmente, podemos movimentar resultados parcialmente agregados.

Partition 1 → spark:2
Partition 2 → spark:2

             ↓

          spark:4
Enter fullscreen mode Exit fullscreen mode

Essa é uma otimização extremamente importante.

A lição é:

Quanto menos dados precisarmos movimentar, melhor.


9. Map-side aggregation

O conceito anterior é frequentemente associado à ideia de realizar parte da agregação antes do shuffle.

Visualmente:

SEM AGREGAÇÃO LOCAL

1
1
1
1
1
1
1
1
      ↓
   NETWORK
      ↓
sum()
Enter fullscreen mode Exit fullscreen mode

versus:

COM AGREGAÇÃO LOCAL

Partition A
1 + 1 + 1 + 1
      ↓
      4


Partition B
1 + 1 + 1 + 1
      ↓
      4


4 ───┐
     ├──► NETWORK ───► 8
4 ───┘
Enter fullscreen mode Exit fullscreen mode

Mover:

4
4
Enter fullscreen mode Exit fullscreen mode

é muito mais eficiente do que mover todos os registros originais.

Essa ideia aparece de diferentes formas nas otimizações do Spark.


10. Joins também podem gerar Shuffle

Agora chegamos a um caso extremamente comum em Engenharia de Dados.

Imagine:

pedidos.join(
    clientes,
    "customer_id"
)
Enter fullscreen mode Exit fullscreen mode

Temos:

PEDIDOS

customer_id=1
customer_id=5
customer_id=3
customer_id=1
Enter fullscreen mode Exit fullscreen mode

e:

CLIENTES

customer_id=1
customer_id=2
customer_id=3
customer_id=5
Enter fullscreen mode Exit fullscreen mode

Para fazer o join, registros com a mesma chave precisam se encontrar.

Conceitualmente:

Pedidos ──────┐
              │
              ▼
           SHUFFLE
              │
              ▼
Clientes ─────┘

customer_id=1 ──► mesma partition
customer_id=2 ──► mesma partition
customer_id=3 ──► mesma partition
...
Enter fullscreen mode Exit fullscreen mode

Dependendo da estratégia de join, isso pode significar mover grandes volumes de dados pela rede.

E aqui surge uma pergunta importante:

E se uma das tabelas for muito pequena?

Talvez não faça sentido redistribuir os dois lados.

É aí que entra uma técnica que veremos em detalhes mais adiante:

Broadcast Join

Em vez de movimentar duas tabelas grandes:

Large Table ──► Shuffle
Large Table ──► Shuffle
Enter fullscreen mode Exit fullscreen mode

podemos, em determinados cenários, distribuir uma tabela pequena para os Executors:

                 Small Table
                /     |     \
               ▼      ▼      ▼
          Executor Executor Executor

             Large Dataset
Enter fullscreen mode Exit fullscreen mode

Assim podemos evitar um shuffle grande de um dos lados.

Essa é uma das otimizações mais importantes em joins Spark.


11. Shuffle não usa apenas rede

É comum resumir:

"Shuffle é caro porque usa rede."

Isso é verdade, mas incompleto.

Shuffle pode envolver:

                    SHUFFLE
                       │
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
       CPU           Memory          Disk
        │              │              │
 serialization       buffers        spill
        │              │              │
        └──────────────┼──────────────┘
                       ▼
                    Network
Enter fullscreen mode Exit fullscreen mode

Ou seja, um shuffle pesado pode pressionar vários recursos simultaneamente.


12. Quando memória não é suficiente: Spill

Imagine que uma Task precisa organizar uma quantidade significativa de dados durante uma agregação ou ordenação.

Idealmente, parte desse trabalho acontece em memória.

Mas a memória é finita.

Quando Spark não consegue manter todo o estado necessário em memória, pode precisar utilizar disco.

Esse comportamento é conhecido como:

Spill

Conceitualmente:

Dados
  ↓
Memory
  ↓
Memory insuficiente
  ↓
Disk Spill
Enter fullscreen mode Exit fullscreen mode

Depois, esses dados podem precisar ser lidos novamente.

Temos:

Memory
  ↓
Disk Write
  ↓
Disk Read
  ↓
Continue Processing
Enter fullscreen mode Exit fullscreen mode

Agora o custo aumentou.

Além de CPU e rede, temos I/O em disco.


13. Memory Spill vs. Disk Spill

Ao analisar métricas de execução, podemos encontrar indicadores relacionados a spill.

A ideia principal é perceber que:

Uma Task precisou usar disco porque o processamento intermediário ultrapassou o que podia ser tratado eficientemente em memória.

Isso pode acontecer em operações como:

  • agregações;
  • joins;
  • sorts;
  • shuffles;
  • processamento de partições muito grandes.

Se encontramos grandes volumes de spill, precisamos investigar.

Não significa automaticamente:

"adicione mais memória."

Pode significar:

  • partições grandes demais;
  • skew;
  • estratégia de join inadequada;
  • volume excessivo de shuffle;
  • configuração inadequada;
  • algoritmo inadequado.

Performance tuning exige descobrir a causa.


14. Shuffle + Data Skew = combinação perigosa

Na Parte 3 vimos Data Skew.

Agora podemos entender por que ele é especialmente problemático durante shuffle.

Imagine:

customer_id

A → 100.000.000 registros
B → 500.000
C → 400.000
D → 300.000
Enter fullscreen mode Exit fullscreen mode

Depois do shuffle por customer_id:

Partition A █████████████████████████████████████████

Partition B █

Partition C █

Partition D █
Enter fullscreen mode Exit fullscreen mode

Agora temos Tasks como:

Task A → 25 minutos

Task B → 20 segundos

Task C → 18 segundos

Task D → 15 segundos
Enter fullscreen mode Exit fullscreen mode

Depois de alguns segundos:

Executor 1 → █████████████████ trabalhando

Executor 2 → idle

Executor 3 → idle

Executor 4 → idle
Enter fullscreen mode Exit fullscreen mode

O cluster possui recursos.

Mas uma única partição concentra grande parte do trabalho.

Esse é um dos padrões mais importantes para reconhecer no Spark UI.


15. Stragglers

Tasks que demoram muito mais que as demais são frequentemente chamadas de stragglers.

Imagine:

Stage 17

Task 001 → 21 s
Task 002 → 19 s
Task 003 → 22 s
Task 004 → 20 s
Task 005 → 18 s
...
Task 199 → 20 s

Task 200 → 14 min
Enter fullscreen mode Exit fullscreen mode

O Stage precisa esperar:

Task 200
Enter fullscreen mode Exit fullscreen mode

Então:

199 Tasks concluídas
         +
1 Task extremamente lenta
         =
Stage ainda executando
Enter fullscreen mode Exit fullscreen mode

O problema pode ser skew.

Mas também pode ser:

  • hardware;
  • I/O;
  • garbage collection;
  • spill;
  • rede;
  • tamanho da partição;
  • executor problemático.

Por isso precisamos analisar métricas antes de concluir.


16. repartition() é um Shuffle explícito

Agora podemos revisitar a Parte 3.

Quando executamos:

df.repartition(200)
Enter fullscreen mode Exit fullscreen mode

Spark precisa redistribuir os registros entre novas partições.

Conceitualmente:

ANTES

P1 ─────┐
P2 ─────┤
P3 ─────┼────► SHUFFLE
P4 ─────┤
P5 ─────┘

                 ↓

DEPOIS

P1'
P2'
P3'
...
P200'
Enter fullscreen mode Exit fullscreen mode

Isso explica por que adicionar repartition() arbitrariamente pode prejudicar performance.

Talvez tenhamos:

Read
 ↓
repartition()
 ↓
SHUFFLE
 ↓
groupBy()
 ↓
SHUFFLE
 ↓
join()
 ↓
SHUFFLE
Enter fullscreen mode Exit fullscreen mode

Agora imagine isso sobre vários terabytes.

Estamos movimentando uma quantidade enorme de dados entre máquinas.


17. Shuffle e serialização

Dados não simplesmente "viajam" pela rede como objetos Python ou JVM abstratos.

Eles precisam ser representados de uma forma adequada para transferência e armazenamento.

Simplificando:

Object
  ↓
Serialization
  ↓
Bytes
  ↓
Network / Disk
  ↓
Deserialization
  ↓
Object
Enter fullscreen mode Exit fullscreen mode

Isso também custa CPU.

Em workloads grandes, detalhes aparentemente pequenos começam a se multiplicar por:

milhões
bilhões
trilhões
Enter fullscreen mode Exit fullscreen mode

de registros.

É por isso que otimizações distribuídas frequentemente tratam não apenas de algoritmos, mas também de:

  • representação;
  • serialização;
  • compressão;
  • layout;
  • movimentação de dados.

18. Shuffle e compressão

Existe um trade-off interessante.

Podemos comprimir dados antes de movimentá-los.

Conceitualmente:

100 GB
  ↓
Compression
  ↓
30 GB
  ↓
Network
Enter fullscreen mode Exit fullscreen mode

Reduzimos o volume transferido.

Mas agora precisamos gastar CPU:

CPU → compress
Network → transfer
CPU → decompress
Enter fullscreen mode Exit fullscreen mode

Temos um trade-off:

CPU
 ↕
Network
Enter fullscreen mode Exit fullscreen mode

Em sistemas distribuídos, otimização frequentemente significa escolher onde queremos pagar o custo.


19. Como identificar Shuffle no Spark UI

Agora o Spark UI começa a ficar ainda mais interessante.

Podemos observar métricas como:

Shuffle Read
Shuffle Write
Input
Output
Task Duration
GC Time
Spill
Enter fullscreen mode Exit fullscreen mode

Imagine um Stage:

Input:          200 GB
Shuffle Write:  180 GB
Enter fullscreen mode Exit fullscreen mode

E o próximo:

Shuffle Read:   180 GB
Output:          20 GB
Enter fullscreen mode Exit fullscreen mode

Isso nos conta uma história.

Talvez tenhamos movimentado:

180 GB
Enter fullscreen mode Exit fullscreen mode

para produzir:

20 GB
Enter fullscreen mode Exit fullscreen mode

Então surge uma pergunta:

Poderíamos reduzir os dados antes do shuffle?

Talvez um filtro pudesse acontecer antes.

Talvez uma projeção pudesse eliminar colunas desnecessárias.

Talvez a estratégia de join pudesse mudar.

Esse é o tipo de pergunta que transforma métricas em otimização.


20. Filter Early

Considere:

resultado = (
    df
    .groupBy("customer_id")
    .count()
)
Enter fullscreen mode Exit fullscreen mode

sobre:

1 TB
Enter fullscreen mode Exit fullscreen mode

Agora imagine que só precisamos de registros:

status = 'ACTIVE'
Enter fullscreen mode Exit fullscreen mode

Se filtrarmos antes:

resultado = (
    df
    .filter("status = 'ACTIVE'")
    .groupBy("customer_id")
    .count()
)
Enter fullscreen mode Exit fullscreen mode

e apenas 20% dos registros forem ativos, conceitualmente podemos passar de:

1 TB
 ↓
SHUFFLE
Enter fullscreen mode Exit fullscreen mode

para algo mais próximo de:

1 TB
 ↓
FILTER
 ↓
200 GB
 ↓
SHUFFLE
Enter fullscreen mode Exit fullscreen mode

Reduzimos drasticamente o volume que precisa atravessar a operação cara.

Essa é uma ideia simples e poderosa:

Reduza os dados antes de movimentá-los, quando semanticamente possível.


21. Project Early

O mesmo raciocínio vale para colunas.

Imagine um dataset com:

100 colunas
Enter fullscreen mode Exit fullscreen mode

mas nosso processamento precisa apenas de:

customer_id
valor
estado
Enter fullscreen mode Exit fullscreen mode

Podemos selecionar cedo:

df = df.select(
    "customer_id",
    "valor",
    "estado"
)
Enter fullscreen mode Exit fullscreen mode

Conceitualmente:

100 columns
     ↓
SELECT
     ↓
3 columns
     ↓
SHUFFLE
Enter fullscreen mode Exit fullscreen mode

Dependendo do plano de execução e do formato da fonte, isso também pode reduzir bastante a quantidade de dados manipulada.

A ideia continua:

Não mova dados que você não precisa.


22. Aggregate Early

Quando possível, também podemos reduzir cardinalidade antes de determinadas movimentações.

Imagine:

1 bilhão de registros
Enter fullscreen mode Exit fullscreen mode

que podem ser parcialmente agregados localmente antes de uma redistribuição.

Se conseguirmos transformar:

1.000.000.000 rows
Enter fullscreen mode Exit fullscreen mode

em:

10.000.000 partial results
Enter fullscreen mode Exit fullscreen mode

antes da transferência, temos uma diferença enorme.

Essa é a lógica por trás de várias estratégias de agregação distribuída.


23. O melhor Shuffle é o Shuffle que não precisamos fazer

Essa frase resume boa parte do tuning de sistemas distribuídos:

Movimentação de dados é cara.

Isso não significa que devemos eliminar todos os shuffles.

Muitos são necessários.

Um:

groupBy()
Enter fullscreen mode Exit fullscreen mode

pode exigir redistribuição.

Um:

join()
Enter fullscreen mode Exit fullscreen mode

pode exigir redistribuição.

Um:

repartition()
Enter fullscreen mode Exit fullscreen mode

existe justamente para redistribuir.

A pergunta correta não é:

"Como elimino todos os shuffles?"

É:

"Esse shuffle é necessário e, se for, estou movimentando apenas os dados necessários?"


24. Um exemplo de pipeline ruim

Imagine:

df = spark.read.parquet("/data/events")

df = df.repartition(5000)

df = df.select("*")

resultado = (
    df
    .groupBy("customer_id")
    .count()
)
Enter fullscreen mode Exit fullscreen mode

Podemos ter:

READ
 ↓
REPARTITION
 ↓
SHUFFLE
 ↓
GROUP BY
 ↓
SHUFFLE
 ↓
RESULT
Enter fullscreen mode Exit fullscreen mode

Agora suponha que só precisamos de eventos de compra.

Podemos reconsiderar:

df = (
    spark.read.parquet("/data/events")
    .filter("event_type = 'purchase'")
    .select("customer_id")
)

resultado = (
    df
    .groupBy("customer_id")
    .count()
)
Enter fullscreen mode Exit fullscreen mode

Conceitualmente:

READ
 ↓
FILTER
 ↓
PROJECT
 ↓
menos dados
 ↓
SHUFFLE necessário
 ↓
AGGREGATION
Enter fullscreen mode Exit fullscreen mode

Não estamos "fazendo Spark ficar rápido".

Estamos simplesmente reduzindo trabalho desnecessário.

Essa distinção é importante.


25. Mais memória nem sempre resolve Shuffle

Um padrão comum de troubleshooting é:

Job lento
   ↓
aumentar memória
Enter fullscreen mode Exit fullscreen mode

Depois:

continua lento
   ↓
aumentar memória novamente
Enter fullscreen mode Exit fullscreen mode

Mas talvez o problema real seja:

2 TB de Shuffle
Enter fullscreen mode Exit fullscreen mode

Adicionar memória pode ajudar em alguns aspectos.

Mas não elimina necessariamente:

  • movimentação de rede;
  • serialização;
  • skew;
  • quantidade de dados;
  • algoritmo inadequado.

Talvez o problema precise ser atacado no plano da computação.


26. Mais Executors também não resolvem tudo

Outro pensamento comum:

"Vou dobrar o cluster."

Podemos passar de:

10 Executors
Enter fullscreen mode Exit fullscreen mode

para:

20 Executors
Enter fullscreen mode Exit fullscreen mode

Mas se o problema for uma chave extremamente skewed:

customer_id = 123

80% dos registros
Enter fullscreen mode Exit fullscreen mode

podemos continuar tendo:

1 Task gigantesca
Enter fullscreen mode Exit fullscreen mode

e vários Executors ociosos.

Escalar infraestrutura sem entender o gargalo pode simplesmente aumentar custo.


27. Performance e custo caminham juntos

Em ambientes cloud, essa relação é ainda mais importante.

Imagine:

Job A

Cluster: 20 workers
Tempo: 2 horas
Enter fullscreen mode Exit fullscreen mode

Depois de reduzir shuffles desnecessários:

Job A

Cluster: 20 workers
Tempo: 35 minutos
Enter fullscreen mode Exit fullscreen mode

Não ganhamos apenas velocidade.

Reduzimos consumo de:

  • CPU;
  • memória;
  • disco;
  • rede;
  • tempo de cluster.

Portanto:

Performance
     ↓
Efficiency
     ↓
Cost
Enter fullscreen mode Exit fullscreen mode

Performance tuning também é FinOps.


28. O Spark moderno consegue ajudar

Nas versões modernas do Spark, mecanismos de otimização conseguem melhorar várias decisões automaticamente.

Um dos mais importantes é:

Adaptive Query Execution — AQE

Durante a execução, Spark pode utilizar estatísticas reais para adaptar partes do plano.

Isso pode ajudar em cenários relacionados a:

  • shuffle partitions;
  • estratégias de join;
  • skew;
  • coalescing de partições.

Conceitualmente:

Plano inicial
     ↓
Execução começa
     ↓
Estatísticas reais
     ↓
AQE
     ↓
Plano adaptado
Enter fullscreen mode Exit fullscreen mode

Isso é uma evolução enorme em relação aos primeiros anos do Spark.

Mas existe uma lição importante:

Automação não elimina a necessidade de entender o sistema.

Quando sabemos como shuffle funciona, conseguimos interpretar melhor o que o Spark está tentando otimizar.


29. Como eu investigaria um Shuffle caro

Imagine um Job demorando muito.

Meu raciocínio seria algo parecido com:

JOB LENTO
   ↓
Qual Stage?
   ↓
Existe Shuffle?
   ↓
Quanto Shuffle Read/Write?
   ↓
As Tasks têm duração semelhante?
   ↓
Existe Skew?
   ↓
Existe Spill?
   ↓
Qual operação criou o Shuffle?
Enter fullscreen mode Exit fullscreen mode

Depois:

groupBy?
join?
repartition?
distinct?
orderBy?
aggregation?
Enter fullscreen mode Exit fullscreen mode

Então perguntaria:

Posso filtrar antes?

Posso selecionar menos colunas?

Posso agregar antes?

O repartition é necessário?

A chave está skewed?

Existe uma estratégia melhor de join?

Uma tabela poderia ser broadcast?
Enter fullscreen mode Exit fullscreen mode

Isso é muito mais eficiente do que ajustar parâmetros aleatoriamente.


30. Checklist de Shuffle

Quando analiso um pipeline Spark, algumas perguntas são especialmente úteis:

  • Quanto de Shuffle Write existe?
  • Quanto de Shuffle Read existe?
  • Qual operação gerou o shuffle?
  • O shuffle é realmente necessário?
  • Estamos movimentando colunas desnecessárias?
  • Podemos filtrar dados antes?
  • Existe agregação local possível?
  • Existe data skew?
  • Existem Tasks muito maiores que as outras?
  • Há spill para disco?
  • repartition() está sendo usado sem necessidade?
  • O número de shuffle partitions faz sentido?
  • O join está usando uma estratégia adequada?
  • Uma das tabelas é pequena o suficiente para outra estratégia?
  • O AQE está ajudando?
  • O problema é computação ou movimentação de dados?

Essas perguntas normalmente são mais valiosas do que procurar:

"Qual configuração deixa Spark mais rápido?"


31. A mudança de mentalidade

Quando comecei a aprender Spark, era fácil olhar para:

df.groupBy("customer_id").count()
Enter fullscreen mode Exit fullscreen mode

e enxergar apenas:

agrupar
+
contar
Enter fullscreen mode Exit fullscreen mode

Depois de entender arquitetura distribuída, começo a enxergar:

Dataset
   ↓
Partitions
   ↓
Local Processing
   ↓
Shuffle Write
   ↓
Network
   ↓
Shuffle Read
   ↓
New Partitions
   ↓
Aggregation
   ↓
Result
Enter fullscreen mode Exit fullscreen mode

Essa segunda visão é muito mais próxima do que realmente importa para Engenharia de Dados em escala.


32. O princípio que levo para outros sistemas

Existe uma ideia aqui que vai muito além do Spark:

Em sistemas distribuídos, mover dados costuma ser caro.

Esse princípio aparece em:

  • bancos distribuídos;
  • Data Warehouses;
  • Lakehouses;
  • sistemas de streaming;
  • microserviços;
  • Machine Learning distribuído;
  • HPC;
  • arquiteturas cloud.

Quanto maior o volume, mais importante fica pensar:

Onde os dados estão?

Onde a computação está?

Quanto precisa atravessar a rede?
Enter fullscreen mode Exit fullscreen mode

Muitas decisões de arquitetura nascem dessas três perguntas.


33. A principal lição deste capítulo

Se eu pudesse resumir shuffle em uma única sequência, seria:

Dados distribuídos
       ↓
Operação precisa reorganizá-los
       ↓
Shuffle Write
       ↓
Disk / Memory
       ↓
Network
       ↓
Shuffle Read
       ↓
Novas Partitions
       ↓
Próximo Stage
Enter fullscreen mode Exit fullscreen mode

E resumiria a otimização em uma pergunta:

Quanto dado realmente precisa atravessar esse caminho?

Quanto menos dados desnecessários movimentarmos, melhor.


Próximo artigo

Agora chegamos naturalmente a um dos assuntos que mais causam diferença entre um pipeline eficiente e um pipeline caro:

Joins

No próximo artigo vamos entender por que isto:

pedidos.join(
    clientes,
    "customer_id"
)
Enter fullscreen mode Exit fullscreen mode

pode resultar em planos de execução completamente diferentes dependendo dos dados.

Vamos explorar:

  • joins distribuídos;
  • shuffle joins;
  • Broadcast Hash Join;
  • Sort Merge Join;
  • estratégias de join;
  • tabelas grandes vs. pequenas;
  • estatísticas;
  • broadcast;
  • data skew;
  • joins com chaves problemáticas;
  • leitura do plano com explain();
  • Catalyst Optimizer;
  • AQE;
  • e como escolher uma estratégia baseada nos dados, não em receitas prontas.

Vamos evoluir deste modelo:

Table A ───┐
           ├──► SHUFFLE ───► JOIN
Table B ───┘
Enter fullscreen mode Exit fullscreen mode

para entender quando podemos fazer algo próximo de:

               Small Table
              /     |     \
             ▼      ▼      ▼

Large ───► Executor Executor Executor
             │        │        │
             ▼        ▼        ▼
            JOIN     JOIN     JOIN
Enter fullscreen mode Exit fullscreen mode

É aí que Spark começa a mostrar de verdade o poder do seu otimizador.


← Parte 3: Partições, paralelismo e performance

Próximo: Parte 5 — Joins no Apache Spark: Shuffle, Broadcast e estratégias de execução

Top comments (0)