Como aprendi Apache Spark — Parte 18: Performance Tuning avançado com Catalyst, AQE e Spark UI
Nota histórica: esta série revisita minha trajetória em Engenharia de Dados iniciada em 2012 e conecta seus fundamentos às tecnologias modernas. Recursos como Adaptive Query Execution e as otimizações atuais do Spark SQL surgiram e amadureceram posteriormente.
Nos capítulos anteriores, exploramos os fundamentos da execução distribuída do Apache Spark.
Entendemos Driver, Executors, Jobs, Stages, Tasks, partições, shuffle, memória, joins e armazenamento colunar.
Também estudamos Structured Streaming, CDC, Lakehouse, Data Quality, Airflow e DataOps.
Agora chegamos a um tema que conecta praticamente tudo isso:
Performance Tuning.
Imagine que um pipeline Spark processa 500 milhões de registros.
A execução demora 90 minutos.
A primeira reação de muitas equipes é aumentar a infraestrutura.
Mais Executors.
Mais memória.
Mais CPU.
Mas será que o problema realmente está na capacidade computacional?
Talvez o Spark esteja lendo colunas desnecessárias.
Talvez exista um shuffle gigantesco.
Talvez um join esteja utilizando uma estratégia inadequada.
Talvez uma única partição esteja concentrando metade dos dados.
Ou talvez os Executors estejam passando grande parte do tempo aguardando operações de I/O.
Antes de aumentar recursos, precisamos compreender o comportamento da aplicação.
A principal ideia deste capítulo é:
Otimização de Spark começa pelo diagnóstico, não pela configuração.
1. Performance não é apenas tempo de execução
Quando avaliamos performance, normalmente começamos pela duração do Job.
Mas esse indicador sozinho não explica o problema.
Considere duas aplicações:
Application A
Execution Time: 10 minutes
Infrastructure Cost: High
Application B
Execution Time: 12 minutes
Infrastructure Cost: Low
Qual delas é melhor?
Depende dos requisitos.
Se o SLA é de 15 minutos, a aplicação B pode oferecer melhor eficiência econômica.
Agora imagine que a aplicação A atende um serviço crítico com SLA de 11 minutos.
Nesse caso, a conclusão pode ser diferente.
Por isso, devemos avaliar:
- Tempo de execução.
- Throughput.
- Latência.
- CPU.
- Memória.
- Shuffle.
- Spill.
- I/O.
- Custo.
- Confiabilidade.
- Cumprimento de SLA.
Performance Tuning é uma atividade de otimização multidimensional.
2. O caminho de uma consulta Spark SQL
Quando escrevemos uma transformação com DataFrames, não estamos especificando diretamente todas as operações físicas.
O Spark constrói e otimiza planos de execução.
Conceitualmente:
DATAFRAME / SQL
|
v
UNRESOLVED PLAN
|
v
ANALYSIS
|
v
ANALYZED LOGICAL PLAN
|
v
LOGICAL OPTIMIZATION
|
v
OPTIMIZED LOGICAL PLAN
|
v
PHYSICAL PLANNING
|
v
PHYSICAL PLAN
|
v
EXECUTION
Esse processo envolve componentes importantes do Spark SQL, especialmente o Catalyst Optimizer.
Compreender esse fluxo ajuda a entender por que duas consultas aparentemente semelhantes podem apresentar desempenhos muito diferentes.
3. O que é Catalyst Optimizer?
O Catalyst Optimizer é o framework de otimização de consultas utilizado pelo Spark SQL.
Ele trabalha com representações estruturadas de expressões e planos.
Entre suas responsabilidades estão:
- Análise e resolução de atributos.
- Aplicação de regras de otimização lógica.
- Simplificação de expressões.
- Reorganização de determinadas operações.
- Apoio ao planejamento físico.
- Seleção de estratégias de execução.
Por exemplo, considere:
resultado = (
df
.filter(F.col("status") == "PAGO")
.select("pedido_id", "valor")
)
O Spark pode utilizar técnicas como:
Predicate Pushdown
Tentar aproximar filtros da leitura dos dados, quando o formato e a fonte oferecem suporte.
Column Pruning
Evitar a leitura ou propagação de colunas desnecessárias.
Essas otimizações podem reduzir significativamente o trabalho executado.
4. Predicate Pushdown
Imagine uma tabela Parquet com 100 milhões de registros.
Queremos apenas pedidos pagos.
pedidos_pagos = df.filter(
F.col("status") == "PAGO"
)
Se a fonte e o plano permitirem, o Spark poderá encaminhar parte da condição para o mecanismo de leitura.
Conceitualmente:
WITHOUT PUSHDOWN
Read All Data
|
v
Spark Filter
|
v
Result
WITH PUSHDOWN
Storage Reader
|
v
Apply Supported Predicate
|
v
Read Relevant Data
|
v
Result
Mas existe uma ressalva importante.
Predicate Pushdown não significa que o Spark necessariamente deixará de ler todos os arquivos ou todos os bytes.
O benefício depende do formato, das estatísticas disponíveis, da organização dos dados e das capacidades do leitor.
Em Parquet, por exemplo, estatísticas de row groups podem permitir eliminar determinadas regiões de leitura.
5. Column Pruning
Imagine uma tabela com 80 colunas.
Mas precisamos apenas de três.
resultado = df.select(
"pedido_id",
"cliente_id",
"valor"
)
Em formatos colunares, o Spark pode evitar ler colunas que não são necessárias.
Isso reduz:
- I/O.
- Desserialização.
- Memória.
- Transferência de dados.
É uma das razões pelas quais formatos como Parquet e ORC são tão importantes em workloads analíticos.
Mas devemos lembrar que nem todas as fontes oferecem as mesmas capacidades de projeção.
6. Inspecionando o plano com explain()
Uma das ferramentas mais importantes para Performance Tuning é:
df.explain()
Podemos utilizar:
df.explain("formatted")
Ou:
df.explain("extended")
Também podemos solicitar informações de custo quando disponíveis:
df.explain("cost")
O modo cost pode apresentar estatísticas e estimativas, dependendo da disponibilidade de metadados.
O plano físico mostra como o Spark pretende executar a consulta.
Ele pode revelar:
- Scans.
- Filters.
- Projections.
- Exchanges.
- Sorts.
- Joins.
- Aggregations.
- Estratégias de particionamento.
O plano é uma das primeiras evidências que devemos analisar.
7. Entendendo Exchange
Quando observamos um plano físico, um operador merece atenção especial:
Exchange
Ele frequentemente indica uma redistribuição de dados entre partições.
Por exemplo:
HashAggregate
|
v
Exchange hashpartitioning(...)
|
v
HashAggregate
|
v
Scan
Essa redistribuição pode envolver shuffle.
Como estudamos na Parte 4, shuffle pode exigir:
- Serialização.
- Escrita em disco.
- Transferência pela rede.
- Leitura remota.
- Ordenação.
- Gerenciamento de memória.
Por isso, operadores Exchange são pontos importantes de investigação.
Mas nem todo Exchange é um problema.
Muitas operações distribuídas precisam redistribuir dados para produzir resultados corretos.
O objetivo é eliminar redistribuições desnecessárias e tornar as necessárias mais eficientes.
8. O papel do Tungsten
Outro conjunto importante de otimizações do Spark é associado ao projeto Tungsten.
Tungsten introduziu e consolidou técnicas de execução orientadas à eficiência de CPU e memória.
Entre elas:
- Representações internas compactas.
- Gerenciamento eficiente de memória.
- Geração de código.
- Redução de overhead de objetos.
- Execução otimizada de operadores.
Uma dessas técnicas é conhecida como Whole-Stage Code Generation.
O Spark pode combinar determinadas operações físicas em código gerado, reduzindo overhead de execução.
Em planos físicos, podemos encontrar marcadores associados a operadores que participam de code generation.
Isso não significa que todos os operadores sejam automaticamente combinados.
A aplicação depende das operações e das capacidades do mecanismo.
9. Whole-Stage Code Generation
Imagine uma sequência:
Scan
|
v
Filter
|
v
Project
Em determinadas condições, o Spark pode gerar código para executar operações compatíveis de maneira integrada.
Conceitualmente:
WITHOUT FUSION
Scan
|
v
Filter
|
v
Project
WITH CODE GENERATION
+---------------------+
| Generated Execution |
| Scan + Filter |
| + Project |
+---------------------+
O objetivo é reduzir overhead entre operadores.
Entretanto, a presença de code generation não garante que uma consulta seja rápida.
Se o principal gargalo for I/O remoto ou shuffle, outras otimizações poderão ter impacto maior.
10. Adaptive Query Execution
Agora chegamos a um dos recursos mais importantes do Spark SQL moderno.
Adaptive Query Execution (AQE) permite que o Spark ajuste determinadas decisões de execução utilizando estatísticas coletadas durante o processamento.
Sem adaptação, decisões físicas são tomadas com base nas informações disponíveis antes da execução.
Mas estimativas podem estar incorretas.
Com AQE, o Spark pode revisar partes do plano após obter informações reais de estágios executados.
Conceitualmente:
INITIAL PHYSICAL PLAN
|
v
EXECUTE QUERY STAGE
|
v
COLLECT RUNTIME STATISTICS
|
v
ADAPT PHYSICAL PLAN
|
v
CONTINUE EXECUTION
AQE não significa que o Spark reescreve arbitrariamente toda a aplicação.
Ele adapta determinadas decisões dentro das capacidades do mecanismo.
11. Habilitando AQE
Em versões modernas do Spark SQL, AQE normalmente já vem habilitado por padrão.
Podemos verificar:
spark.conf.get(
"spark.sql.adaptive.enabled"
)
Ou configurar explicitamente:
spark.conf.set(
"spark.sql.adaptive.enabled",
"true"
)
A configuração deve ser analisada conforme a versão do Spark.
Também devemos considerar que determinadas otimizações adaptativas possuem parâmetros próprios.
12. Coalescing de partições pós-shuffle
Imagine que configuramos:
spark.conf.set(
"spark.sql.shuffle.partitions",
"200"
)
Mas, após um shuffle, descobrimos que a quantidade de dados é pequena.
Executar 200 tarefas muito pequenas pode introduzir overhead desnecessário.
AQE pode combinar partições de shuffle.
Conceitualmente:
BEFORE AQE
200 Small Partitions
|
v
200 Tasks
AFTER AQE
Coalesced Partitions
|
v
Fewer Tasks
O número final depende das estatísticas e das configurações adaptativas.
Essa otimização pode reduzir overhead de scheduling e melhorar a eficiência.
Mas não devemos interpretar isso como uma razão para ignorar completamente o particionamento.
13. Conversão adaptativa de joins
Imagine que o Spark inicialmente planeja um Sort-Merge Join.
Durante a execução, descobre que um dos lados é suficientemente pequeno.
Dependendo das condições, AQE pode converter a estratégia para Broadcast Hash Join.
Conceitualmente:
INITIAL PLAN
SortMergeJoin
ADAPTIVE PLAN
BroadcastHashJoin
Essa mudança pode evitar trabalho desnecessário de shuffle e sort.
Mas a conversão depende de:
- Estatísticas.
- Tipo de join.
- Tamanho dos dados.
- Configurações.
- Restrições do plano.
Não é garantida em qualquer consulta.
14. Broadcast Hash Join
Broadcast Join é especialmente útil quando um dos datasets é pequeno.
Imagine:
FACT TABLE
500 million rows
DIMENSION TABLE
50 thousand rows
Se a dimensão couber confortavelmente nos limites de broadcast, podemos distribuí-la aos Executors.
Conceitualmente:
SMALL DIMENSION
|
v
BROADCAST
|
+------ Executor 1
|
+------ Executor 2
|
+------ Executor 3
Isso pode evitar a redistribuição completa de ambos os lados.
Exemplo:
from pyspark.sql.functions import broadcast
resultado = fatos.join(
broadcast(dimensao),
on="produto_id",
how="inner"
)
Mas forçar broadcast sem avaliar o tamanho real pode causar pressão de memória ou falhas.
O objetivo não é utilizar broadcast sempre.
É utilizá-lo quando fizer sentido.
15. Sort-Merge Join
Sort-Merge Join é uma estratégia comum para joins distribuídos de grande volume.
Conceitualmente:
LEFT DATASET
|
v
SHUFFLE BY KEY
|
v
SORT
|
+--------+
|
v
JOIN
^
+--------+
|
SORT
^
|
SHUFFLE BY KEY
^
|
RIGHT DATASET
Essa estratégia pode ser eficiente para grandes datasets.
Mas também pode envolver shuffle e sort custosos.
Precisamos observar:
- Volume transferido.
- Distribuição das chaves.
- Número de partições.
- Spill.
- Duração das tarefas.
16. Data Skew
Data Skew acontece quando os dados estão distribuídos de maneira desigual entre partições.
Imagine:
Partition 1: 1 million rows
Partition 2: 1 million rows
Partition 3: 1 million rows
Partition 4: 80 million rows
Mesmo que três Tasks terminem rapidamente, a quarta pode demorar muito.
Conceitualmente:
Task 1 ███
Task 2 ███
Task 3 ███
Task 4 ███████████████████████
Esse é um dos problemas mais importantes em processamento distribuído.
Adicionar Executors pode não resolver o gargalo se uma única tarefa continuar concentrando o trabalho.
17. Skew Join Optimization
AQE pode identificar determinadas partições de shuffle desproporcionalmente grandes em joins compatíveis.
Quando as condições são atendidas, o Spark pode dividir o processamento dessas partições.
Conceitualmente:
SKEWED PARTITION
|
v
SPLIT WORK
|
+----+----+
| | |
v v v
Task Task Task
Isso pode melhorar o paralelismo.
Mas a otimização depende de parâmetros como:
spark.sql.adaptive.skewJoin.enabled
Também existem limiares relacionados ao tamanho e à desproporção das partições.
É importante verificar o plano adaptativo e as métricas reais para confirmar se a otimização foi aplicada.
18. Identificando Skew na prática
Uma maneira simples de investigar a distribuição de uma chave é contar sua frequência.
distribuicao = (
df
.groupBy("cliente_id")
.count()
.orderBy(F.desc("count"))
)
distribuicao.show(20)
Isso pode revelar chaves muito frequentes.
Por exemplo:
cliente_id | count
-----------|---------
101 | 50000000
102 | 120000
103 | 95000
Mas essa consulta também possui custo.
Em datasets muito grandes, devemos avaliar amostragem, estatísticas e métricas já disponíveis antes de executar diagnósticos adicionais.
19. Salting
Uma técnica para lidar com determinadas situações de skew é conhecida como salting.
A ideia é distribuir registros associados a uma chave muito frequente entre diferentes subchaves artificiais.
Conceitualmente:
Original Key:
cliente_id = 101
Transformamos em:
101_0
101_1
101_2
101_3
Isso pode distribuir o processamento.
Mas salting precisa ser implementado corretamente nos dois lados de um join.
Também pode aumentar o volume de dados e introduzir complexidade.
Portanto, devemos considerá-lo somente após analisar as estratégias nativas do Spark.
20. Shuffle Partitions
Uma configuração conhecida é:
spark.conf.set(
"spark.sql.shuffle.partitions",
"200"
)
Ela define um número inicial de partições para determinadas operações de shuffle em Spark SQL.
Mas qual é o número ideal?
Não existe uma resposta universal.
Depende de:
- Volume de dados.
- Tamanho das partições.
- Recursos disponíveis.
- Operações.
- Skew.
- AQE.
- Características do cluster.
Um número muito pequeno pode produzir tarefas grandes.
Um número excessivo pode aumentar overhead.
O objetivo é encontrar um equilíbrio.
21. repartition() vs. coalesce()
Essas operações já apareceram em capítulos anteriores, mas merecem atenção no contexto de tuning.
repartition()
Pode redistribuir dados com shuffle.
df_reparticionado = df.repartition(100)
coalesce()
Normalmente reduz o número de partições sem exigir um shuffle completo.
df_reduzido = df.coalesce(20)
Mas não devemos assumir que coalesce() é sempre mais rápido.
Reduzir demais o número de partições pode diminuir o paralelismo e concentrar trabalho.
A escolha depende da operação seguinte e da distribuição dos dados.
22. Dynamic Partition Pruning
Dynamic Partition Pruning é uma otimização que pode reduzir a leitura de partições em determinadas consultas com joins.
Imagine uma tabela de fatos particionada por data.
Queremos apenas datas presentes em uma tabela menor.
Conceitualmente:
DIMENSION / FILTER
|
v
RELEVANT PARTITION VALUES
|
v
PRUNE FACT PARTITIONS
|
v
READ LESS DATA
O Spark pode utilizar informações derivadas da consulta para evitar a leitura de partições desnecessárias.
Mas a aplicação depende do plano, da estrutura das tabelas e das configurações.
Não devemos assumir que qualquer filtro em um join produzirá pruning automaticamente.
23. Partition Pruning vs. Predicate Pushdown
Esses conceitos são relacionados, mas diferentes.
Partition Pruning
Evita ler partições de armazenamento que não atendem ao filtro.
Predicate Pushdown
Encaminha predicados para a fonte de dados quando suportado.
Exemplo:
PARTITION PRUNING
Read only:
date=2026-11-05
PREDICATE PUSHDOWN
Within selected files,
apply supported filter
during reading.
Ambas as técnicas podem reduzir I/O.
Mas atuam em níveis diferentes.
24. Caching
O Spark permite persistir DataFrames para reutilização.
df.cache()
Mas o cache é materializado quando uma Action apropriada executa o plano.
Por exemplo:
df.cache()
df.count()
Depois disso, operações posteriores podem reutilizar dados armazenados em cache.
Entretanto, cache não deve ser utilizado indiscriminadamente.
Ele consome recursos.
Se o DataFrame for utilizado apenas uma vez, o custo de materialização pode superar o benefício.
25. Quando utilizar cache?
Caching pode fazer sentido quando:
- Um DataFrame é reutilizado várias vezes.
- O cálculo original é caro.
- Existe memória suficiente.
- O custo de recomputação é significativo.
Pode ser inadequado quando:
- O dataset é utilizado uma única vez.
- O volume é muito grande.
- Existe pressão de memória.
- O cache provoca eviction ou spill excessivo.
- A origem já oferece leitura eficiente.
Uma prática importante é liberar recursos quando o cache não for mais necessário.
df.unpersist()
26. Spill
Spill acontece quando determinadas operações precisam utilizar armazenamento externo porque os dados não cabem nos recursos de memória disponíveis para aquela execução.
Isso pode ocorrer em:
- Sort.
- Aggregation.
- Join.
- Shuffle.
Conceitualmente:
IN-MEMORY PROCESSING
|
v
MEMORY PRESSURE
|
v
DISK SPILL
|
v
ADDITIONAL I/O
Spill não significa necessariamente falha.
É um mecanismo que permite continuar determinadas operações.
Mas volumes elevados de spill podem indicar gargalos.
Precisamos observar métricas de:
- Memory Bytes Spilled.
- Disk Bytes Spilled.
- Shuffle Read.
- Shuffle Write.
27. Spark UI
A Spark UI é uma das ferramentas mais importantes para diagnóstico.
Ela oferece informações sobre:
- Jobs.
- Stages.
- Tasks.
- SQL Queries.
- Executors.
- Storage.
- Environment.
Durante uma aplicação local, a interface normalmente pode ser acessada em:
http://localhost:4040
A porta pode mudar quando já está ocupada.
Em ambientes distribuídos, o acesso depende da configuração e da infraestrutura.
28. O que observar na Spark UI?
Quando uma aplicação está lenta, começo por perguntas como:
Qual Stage está consumindo mais tempo?
Existe uma Task muito mais lenta do que as demais?
Quanto shuffle está sendo realizado?
Existe spill?
Os Executors estão ocupados ou ociosos?
Há falhas ou retries?
O plano SQL corresponde à estratégia esperada?
Essas perguntas ajudam a direcionar a investigação.
29. Interpretando Tasks lentas
Imagine um Stage com 100 Tasks.
Noventa e nove terminam em 10 segundos.
Uma demora cinco minutos.
Conceitualmente:
99 Tasks: 10 seconds
1 Task : 300 seconds
Esse padrão sugere investigar:
- Data Skew.
- Tamanho desigual das partições.
- Problemas localizados de I/O.
- Garbage Collection.
- Executor específico.
- Localidade dos dados.
Não devemos concluir automaticamente que é skew.
Precisamos cruzar as métricas.
30. Garbage Collection
O Spark executa sobre a JVM em grande parte de sua arquitetura.
Pressão de memória e criação excessiva de objetos podem aumentar o tempo gasto em Garbage Collection.
Na Spark UI, métricas de GC podem ajudar a identificar esse comportamento.
Mas GC elevado pode ser consequência de diferentes problemas:
- Objetos em excesso.
- Partições grandes.
- Estruturas intermediárias.
- Configuração inadequada.
- Pressão de memória.
- UDFs ou operações específicas.
A solução depende da causa.
Aumentar memória indiscriminadamente pode não resolver o problema.
31. Python UDFs vs. funções nativas
Em PySpark, podemos criar User Defined Functions.
Exemplo:
from pyspark.sql.types import StringType
from pyspark.sql.functions import udf
@udf(StringType())
def classificar(valor):
return "ALTO" if valor > 100 else "BAIXO"
Mas, quando possível, funções nativas do Spark SQL costumam oferecer melhor integração com o mecanismo de otimização.
Uma alternativa:
resultado = df.withColumn(
"classificacao",
F.when(
F.col("valor") > 100,
"ALTO"
).otherwise("BAIXO")
)
Funções nativas podem permitir execução mais eficiente e melhor otimização.
Isso não significa que todas as UDFs sejam inadequadas.
Mas devemos evitar UDFs quando uma expressão nativa resolve o problema de maneira simples.
32. Pandas UDFs e execução vetorizada
Pandas UDFs podem utilizar Apache Arrow para transferências colunares entre JVM e Python em operações compatíveis.
Isso pode reduzir overhead em comparação com determinadas UDFs Python convencionais.
Entretanto, seu desempenho depende de:
- Tipo de operação.
- Tamanho dos batches.
- Conversão de dados.
- Memória.
- Versões.
- Compatibilidade.
- Custo da função.
Não devemos assumir que uma Pandas UDF será sempre mais rápida do que uma expressão nativa Spark SQL.
Em muitos cenários, funções nativas continuam sendo a primeira opção.
33. Small Files Problem
Imagine uma tabela com milhares de arquivos muito pequenos.
file_0001.parquet
file_0002.parquet
file_0003.parquet
...
file_9999.parquet
Mesmo que o volume total seja moderado, o número de arquivos pode aumentar overhead de:
- Listagem.
- Planejamento.
- Abertura.
- Leitura.
- Metadados.
Esse problema aparece frequentemente em pipelines incrementais e streaming.
Formatos Lakehouse podem oferecer mecanismos de manutenção e compactação, dependendo da implementação.
Mas a estratégia deve considerar frequência, custo e concorrência.
34. Tamanho de arquivos e particionamento
Não existe um tamanho universal ideal de arquivo.
A escolha depende de:
- Formato.
- Engine.
- Storage.
- Padrão de leitura.
- Frequência de atualização.
- Concorrência.
- Volume.
- Custo.
Arquivos muito pequenos aumentam overhead.
Arquivos excessivamente grandes podem limitar paralelismo ou tornar determinadas operações menos eficientes.
Precisamos medir o comportamento real.
35. Benchmarking
Otimização sem medição é especulação.
Uma abordagem correta compara versões sob condições controladas.
Exemplo:
Baseline:
Execution Time = 40 minutes
Optimized:
Execution Time = 25 minutes
Podemos calcular:
[
\text{Speedup}
\frac{T_{\text{baseline}}}
{T_{\text{optimized}}}
]
Nesse exemplo:
[
\text{Speedup}
\frac{40}{25}
1.6
]
Ou seja, a execução otimizada foi 1,6 vez mais rápida.
A redução percentual do tempo foi:
[
\frac{40-25}{40}\times100=37.5\%
]
Mas precisamos garantir que os resultados sejam equivalentes.
Uma otimização que produz dados incorretos não representa melhoria.
36. Como realizar um benchmark confiável?
Uma metodologia básica:
- Definir o workload.
- Registrar versões e configurações.
- Fixar ou documentar o volume de dados.
- Medir o baseline.
- Identificar o gargalo.
- Alterar uma variável relevante.
- Reexecutar.
- Comparar métricas.
- Validar resultados.
- Registrar conclusões.
Também devemos considerar:
- Cache.
- Aquecimento da JVM.
- Concorrência.
- Variabilidade de I/O.
- Estado do cluster.
- Estatísticas.
- Número de execuções.
Uma única execução pode não ser representativa.
37. Laboratório prático: analisando planos de execução
Vamos criar dois DataFrames e observar o plano de um join.
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
spark = (
SparkSession.builder
.master("local[*]")
.appName("SparkPerformanceLab")
.config(
"spark.sql.adaptive.enabled",
"true"
)
.getOrCreate()
)
fatos = (
spark.range(0, 1_000_000)
.withColumn(
"produto_id",
F.col("id") % 1000
)
.withColumn(
"valor",
F.col("id") * 0.5
)
)
dimensao = (
spark.range(0, 1000)
.withColumnRenamed(
"id",
"produto_id"
)
.withColumn(
"categoria",
F.concat(
F.lit("CAT_"),
F.col("produto_id")
)
)
)
Agora realizamos o join:
resultado = fatos.join(
dimensao,
on="produto_id",
how="inner"
)
Inspecionamos:
resultado.explain("formatted")
Dependendo das estatísticas e configurações, o Spark poderá escolher uma estratégia de broadcast.
38. Comparando com Broadcast Hint
Podemos criar uma versão com broadcast explícito.
from pyspark.sql.functions import broadcast
resultado_broadcast = fatos.join(
broadcast(dimensao),
on="produto_id",
how="inner"
)
resultado_broadcast.explain(
"formatted"
)
Agora podemos comparar os planos.
O objetivo não é provar que o hint é sempre melhor.
É observar se o Spark já havia escolhido broadcast automaticamente e se o hint realmente modifica o plano.
39. Medindo tempo de execução
Podemos utilizar time.perf_counter().
import time
inicio = time.perf_counter()
total = resultado.count()
duracao = (
time.perf_counter() - inicio
)
print("Registros:", total)
print("Duração:", duracao)
Depois repetimos para outra estratégia.
Mas existe um detalhe importante.
O otimizador pode eliminar operações que não são necessárias para calcular um simples count().
Portanto, esse benchmark pode não medir todo o custo das transformações que queremos comparar.
Para comparar joins, devemos utilizar uma Action que efetivamente dependa das colunas produzidas pelo join e verificar o plano executado.
Essa é uma lição importante:
O benchmark precisa executar o trabalho que pretendemos medir.
40. Uma metodologia de Performance Tuning
Quando recebo uma aplicação Spark lenta, sigo uma sequência lógica.
1. Understand the Workload
|
v
2. Inspect Physical Plan
|
v
3. Analyze Spark UI
|
v
4. Identify Bottleneck
|
v
5. Form Hypothesis
|
v
6. Apply Controlled Change
|
v
7. Benchmark
|
v
8. Validate Correctness
|
v
9. Document Results
Essa abordagem evita alterações aleatórias.
Também facilita explicar tecnicamente por que determinada otimização foi realizada.
41. Matriz de diagnóstico
| Sintoma | Possíveis causas | Primeira investigação |
|---|---|---|
| Stage muito lento | Shuffle, skew, I/O | Spark UI e plano físico |
| Uma Task muito lenta | Skew, partição grande | Distribuição de Tasks |
| Spill elevado | Memória, sort, agregação | Métricas de spill |
| Muitos Jobs pequenos | Overhead, ações repetidas | Jobs e código |
| Leitura excessiva | Falta de pruning | Scan e filtros |
| Join custoso | Estratégia inadequada | Plano de join |
| GC elevado | Pressão de memória | Executors e GC |
| Muitos arquivos | Small Files | Layout de armazenamento |
| Baixo paralelismo | Poucas partições | Tasks e recursos |
| Custo elevado | Recursos ociosos ou excesso de trabalho | Métricas e billing |
Essa matriz não substitui diagnóstico.
Ela ajuda a formular hipóteses.
42. Erros comuns em Performance Tuning
Alguns erros aparecem repetidamente.
Aumentar Executors antes de analisar o plano.
Pode elevar custos sem resolver o gargalo.
Forçar broadcast indiscriminadamente.
Pode causar pressão de memória.
Aumentar shuffle partitions sem medir.
Pode aumentar overhead.
Reduzir partições demais.
Pode limitar paralelismo.
Utilizar cache em todos os DataFrames.
Pode consumir memória sem benefício.
Ignorar Data Skew.
Uma única Task pode determinar a duração do Stage.
Utilizar Python UDFs para operações nativas.
Pode introduzir overhead desnecessário.
Ignorar Small Files.
Pode prejudicar planejamento e leitura.
Comparar benchmarks sem controlar variáveis.
Pode produzir conclusões incorretas.
Otimizar tempo sem validar resultado.
Pode comprometer a qualidade dos dados.
43. Performance e custo
Uma otimização não precisa apenas reduzir tempo.
Ela pode reduzir custo.
Imagine:
Before:
20 Executors
40 Minutes
After:
10 Executors
35 Minutes
A segunda execução utiliza menos recursos e termina mais rapidamente.
Mas precisamos considerar o modelo de cobrança e a utilização real.
Em plataformas cloud, custo depende de fatores como:
- Tipo de instância.
- Tempo de uso.
- Autoscaling.
- Storage.
- Rede.
- Serviços gerenciados.
- Overhead de inicialização.
Por isso, performance tuning e FinOps estão relacionados.
A melhor configuração é aquela que atende os requisitos com eficiência técnica e econômica.
44. Como penso hoje
Quando avalio uma aplicação Spark, não começo perguntando:
Quantos Executors devemos adicionar?
Começo perguntando:
- Qual operação está lenta?
- Qual Stage concentra o tempo?
- Existe shuffle excessivo?
- Há Data Skew?
- O Spark está lendo dados desnecessários?
- Qual estratégia de join foi escolhida?
- AQE está ativo?
- Existe spill?
- O cache realmente ajuda?
- O número de arquivos é adequado?
- O benchmark é representativo?
- Qual é o custo por execução?
- O resultado continua correto?
Essas perguntas transformam tuning em engenharia baseada em evidências.
45. A principal lição deste capítulo
O Apache Spark possui mecanismos sofisticados de otimização.
Catalyst ajuda a otimizar consultas.
Tungsten melhora a eficiência da execução.
AQE adapta determinadas decisões com base em estatísticas reais.
Spark UI permite investigar o comportamento da aplicação.
Mas nenhuma dessas capacidades elimina a necessidade de compreender os dados e o workload.
O princípio mais importante é:
Performance Tuning não consiste em encontrar uma configuração mágica. Consiste em identificar o gargalo real, formular uma hipótese, medir e validar a melhoria.
Próximo artigo
Agora que entendemos como diagnosticar e otimizar Jobs Spark, vamos avançar para a operação em ambientes distribuídos e cloud.
Apache Spark em produção: Kubernetes, Databricks, AWS EMR e arquiteturas cloud.
Vamos explorar:
- Spark Standalone.
- YARN.
- Kubernetes.
- Databricks.
- AWS EMR.
- Separação entre compute e storage.
- Deploy Mode.
- Autoscaling.
- Dynamic Allocation.
- Segurança.
- Observabilidade.
- Custos.
- Estratégias de arquitetura.
- Critérios para escolher uma plataforma.
A pergunta central será:
Como escolher e operar a infraestrutura adequada para aplicações Spark em produção?
Série: Como aprendi Apache Spark
← Parte 17: Orquestração, Airflow, CI/CD e DataOps
Próximo: Parte 19 — Spark em produção: Kubernetes, Databricks, AWS EMR e arquiteturas cloud
Top comments (0)