DEV Community

Cover image for Como aprendi Apache Spark — Parte 18: Performance Tuning avançado
renanpyd
renanpyd

Posted on

Como aprendi Apache Spark — Parte 18: Performance Tuning avançado

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
)
Enter fullscreen mode Exit fullscreen mode

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"
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
WITH PUSHDOWN

Storage Reader
      |
      v
Apply Supported Predicate
      |
      v
Read Relevant Data
      |
      v
Result
Enter fullscreen mode Exit fullscreen mode

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"
)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Podemos utilizar:

df.explain("formatted")
Enter fullscreen mode Exit fullscreen mode

Ou:

df.explain("extended")
Enter fullscreen mode Exit fullscreen mode

Também podemos solicitar informações de custo quando disponíveis:

df.explain("cost")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Ele frequentemente indica uma redistribuição de dados entre partições.

Por exemplo:

HashAggregate
      |
      v
Exchange hashpartitioning(...)
      |
      v
HashAggregate
      |
      v
Scan
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
WITH CODE GENERATION

+---------------------+
| Generated Execution |
| Scan + Filter       |
| + Project           |
+---------------------+
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
)
Enter fullscreen mode Exit fullscreen mode

Ou configurar explicitamente:

spark.conf.set(
    "spark.sql.adaptive.enabled",
    "true"
)
Enter fullscreen mode Exit fullscreen mode

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"
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
AFTER AQE

Coalesced Partitions
        |
        v
Fewer Tasks
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
ADAPTIVE PLAN

BroadcastHashJoin
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Mesmo que três Tasks terminem rapidamente, a quarta pode demorar muito.

Conceitualmente:

Task 1  ███
Task 2  ███
Task 3  ███
Task 4  ███████████████████████
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Isso pode melhorar o paralelismo.

Mas a otimização depende de parâmetros como:

spark.sql.adaptive.skewJoin.enabled
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Isso pode revelar chaves muito frequentes.

Por exemplo:

cliente_id | count
-----------|---------
101        | 50000000
102        | 120000
103        | 95000
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Transformamos em:

101_0
101_1
101_2
101_3
Enter fullscreen mode Exit fullscreen mode

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"
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

coalesce()

Normalmente reduz o número de partições sem exigir um shuffle completo.

df_reduzido = df.coalesce(20)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
PREDICATE PUSHDOWN

Within selected files,
apply supported filter
during reading.
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Mas o cache é materializado quando uma Action apropriada executa o plano.

Por exemplo:

df.cache()

df.count()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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")
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Definir o workload.
  2. Registrar versões e configurações.
  3. Fixar ou documentar o volume de dados.
  4. Medir o baseline.
  5. Identificar o gargalo.
  6. Alterar uma variável relevante.
  7. Reexecutar.
  8. Comparar métricas.
  9. Validar resultados.
  10. 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")
        )
    )
)
Enter fullscreen mode Exit fullscreen mode

Agora realizamos o join:

resultado = fatos.join(
    dimensao,
    on="produto_id",
    how="inner"
)
Enter fullscreen mode Exit fullscreen mode

Inspecionamos:

resultado.explain("formatted")
Enter fullscreen mode Exit fullscreen mode

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"
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)