cargo-flamegraph em Rust: Profiling de Performance

Aprenda a usar cargo-flamegraph em Rust para encontrar gargalos de CPU. Guia com instalação, símbolos, benchmarks, leitura do gráfico e otimização segura.

Use cargo-flamegraph quando você já confirmou um problema de CPU e precisa descobrir quais funções e pilhas de chamadas consomem mais tempo. Instale a ferramenta, compile com otimizações e símbolos de debug, reproduza uma carga representativa e abra o arquivo flamegraph.svg. Os frames mais largos concentram mais amostras; eles indicam onde investigar, não onde editar às cegas.

cargo install flamegraph --locked
CARGO_PROFILE_RELEASE_DEBUG=true cargo flamegraph --release

Um flame graph complementa benchmarks e métricas de produção. Ele não responde sozinho se a aplicação está rápida o suficiente, nem mede corretamente uma espera de rede apenas por mostrar pilhas de CPU. O fluxo confiável é: estabelecer uma baseline, perfilar a mesma carga, formular uma hipótese, alterar uma coisa e medir novamente.

Resposta rápida: fluxo recomendado

EtapaComando ou açãoObjetivo
Instalarcargo install flamegraph --lockedAdicionar o subcomando ao Cargo
Ver opções reaiscargo flamegraph --helpConfirmar flags da versão instalada
Gerar gráficocargo flamegraph --releasePerfilar o alvo padrão otimizado
Selecionar bináriocargo flamegraph --bin minha-api --releaseEvitar medir o executável errado
Perfilar benchmarkcargo flamegraph --bench parser -- --benchExercitar uma carga CPU-bound específica
Preservar símbolosdebug = true e strip = falseMelhorar a identificação dos frames
Abrir resultadonavegador em flamegraph.svgExplorar pilhas e funções quentes
Confirmar melhoriacargo bench ou teste de cargaSeparar ganho real de impressão visual

Antes de automatizar comandos, confira a ajuda da versão homologada. Flags aceitas e backends disponíveis podem variar entre releases e sistemas operacionais.

O que é um flame graph

Um flame graph é uma visualização agregada de pilhas de chamadas coletadas durante a execução. O profiler amostra periodicamente o programa e registra onde a CPU estava naquele instante. Depois, pilhas semelhantes são combinadas em retângulos.

A leitura básica é simples:

  • o eixo horizontal representa a proporção de amostras, não uma linha do tempo;
  • o eixo vertical representa a profundidade da pilha de chamadas;
  • um frame sobre outro foi chamado pelo frame abaixo;
  • quanto mais largo o frame, mais amostras passaram por aquela função ou por seus descendentes;
  • as cores normalmente ajudam a distinguir frames, mas não significam automaticamente “bom”, “ruim”, “rápido” ou “lento”.

Duas torres afastadas no gráfico não necessariamente aconteceram em momentos separados. O desenho agrega stacks por nome e caminho. Para analisar uma sequência temporal, latência por requisição ou espera assíncrona, você pode precisar de tracing, métricas, spans ou outras ferramentas além do flame graph.

O guia de profiling e performance em produção mostra como combinar essa visão com observabilidade. Já o artigo de otimização de performance em Rust cobre técnicas que só devem ser aplicadas depois que o gargalo foi medido.

Instalando no Linux e no macOS

Instale o subcomando pelo Cargo:

cargo install flamegraph --locked
cargo flamegraph --version
cargo flamegraph --help

No Linux, o fluxo normalmente depende do perf, fornecido pelos pacotes de ferramentas de performance da distribuição. O nome exato do pacote varia. Confirme primeiro:

perf --version

Se perf não estiver disponível, instale o pacote correspondente ao kernel e à distribuição usados. Em containers mínimos, talvez seja necessário perfilar no host ou conceder capacidades adicionais; abrir privilégios amplos em produção apenas para gerar um gráfico raramente é uma boa escolha.

O kernel Linux também pode restringir a coleta por meio de perf_event_paranoid. Não copie um comando que reduz a proteção global sem entender o ambiente. Prefira uma estação de profiling, uma VM de teste ou um runner controlado, com a menor permissão necessária e uma política aprovada pelo time.

No macOS, o backend de profiling e as permissões são diferentes. O sistema pode solicitar privilégios adicionais para observar processos. Execute cargo flamegraph --help, confira a documentação da versão instalada e faça a primeira coleta em um projeto local simples antes de depender do comando em uma pipeline.

Compile com otimizações e símbolos úteis

Perfilar um build de debug quase sempre responde à pergunta errada. Código sem otimização pode ser dezenas de vezes mais lento, conter checks e chamadas que desaparecerão em release e produzir um hot path diferente do artefato entregue.

Crie um profile específico no Cargo.toml:

[profile.profiling]
inherits = "release"
debug = true
strip = false

Depois execute:

cargo flamegraph --profile profiling

Esse profile mantém otimizações de release e preserva informações úteis para simbolização. Uma alternativa pontual é:

CARGO_PROFILE_RELEASE_DEBUG=true cargo flamegraph --release

Evite ativar strip = true no artefato usado para profiling. Também lembre que otimizações como inlining, monomorfização e LTO podem fundir ou remover funções. Isso não significa necessariamente que o profiler falhou; significa que o gráfico representa o código de máquina otimizado, não uma transcrição perfeita do código-fonte.

Se a aplicação usa um profile de produção personalizado, reproduza as mesmas opções importantes. Mudar simultaneamente opt-level, LTO, allocator, features e carga torna a comparação difícil de interpretar.

Gerando o primeiro flame graph

Para um projeto com um único binário:

cargo flamegraph --release

Em um workspace ou pacote com vários alvos, seja explícito:

cargo flamegraph --package minha-api --bin servidor --release

Se o programa aceita argumentos, separe os argumentos do Cargo dos argumentos do executável com --:

cargo flamegraph --release -- processar --entrada dados.json

A saída padrão costuma ser um arquivo SVG no diretório atual. Abra-o no navegador e use a busca do próprio gráfico para localizar nomes de módulos, crates e funções.

Não gere o perfil com uma execução trivial se o problema aparece somente após aquecimento, com cache preenchido, lote grande ou concorrência. Prepare uma carga reproduzível. Por exemplo, para uma CLI de parsing:

cargo flamegraph --profile profiling -- \
  processar --entrada benches/dados/grandes.json

Para uma API, suba o servidor sob o profiler e gere tráfego de outra sessão. Mantenha taxa, duração, dataset e configuração registrados para repetir a coleta depois.

Como ler o gráfico na prática

Comece pelos frames largos perto do topo e siga a pilha para baixo. Pergunte:

  1. a função pertence ao código local, à biblioteca padrão, ao allocator, ao runtime ou a uma dependência?
  2. o custo é esperado para a carga executada?
  3. a função aparece porque faz trabalho útil ou porque é chamada vezes demais?
  4. existe serialização, hashing, cópia, alocação ou conversão repetida dentro do hot path?
  5. a pilha representa CPU ativa ou o problema real é espera, contenção ou I/O?

Imagine um serviço que recebe JSON, valida o payload e grava no banco. O gráfico pode mostrar serde_json largo. Isso não prova que trocar de biblioteca é a solução. Talvez o serviço esteja desserializando o mesmo conteúdo duas vezes, convertendo estruturas em Value, clonando strings ou registrando o payload completo em cada requisição.

Outro cenário comum é encontrar alocação e desalocação espalhadas por muitas pilhas. Antes de trocar o allocator, procure construções intermediárias, format! em loops, collect() desnecessário, clones e buffers sem capacidade inicial. Alterações locais e explícitas costumam ser mais fáceis de validar.

Exemplo: encontrar trabalho repetido

Considere uma função que compila uma expressão regular dentro do processamento:

fn contar_ids(linhas: &[String]) -> usize {
    linhas
        .iter()
        .filter(|linha| {
            let regex = regex::Regex::new(r"ID-[0-9]{6}").unwrap();
            regex.is_match(linha)
        })
        .count()
}

Um flame graph de uma carga grande pode destacar compilação, parsing da expressão e alocações. A hipótese é mover a construção para fora do loop:

fn contar_ids(linhas: &[String]) -> usize {
    let regex = regex::Regex::new(r"ID-[0-9]{6}").unwrap();

    linhas
        .iter()
        .filter(|linha| regex.is_match(linha))
        .count()
}

Mas o gráfico não encerra a análise. Crie um benchmark com Criterion para comparar as duas implementações na mesma entrada. Se o ganho for relevante e os testes continuarem passando, a mudança tem evidência.

cargo-flamegraph com benchmarks Criterion

Benchmark e profiler respondem perguntas diferentes. Criterion calcula distribuições de tempo, warm-up, amostras e comparação com baseline. O flame graph mostra o caminho interno usado durante a execução.

Um benchmark simples pode ser declarado assim:

[dev-dependencies]
criterion = "0.5"

[[bench]]
name = "parser"
harness = false

E perfilado com:

cargo flamegraph --bench parser -- --bench

O argumento final depende do harness e da versão das ferramentas. Confirme o comportamento com cargo flamegraph --help, cargo bench --help e uma execução curta.

Para benchmarks muito rápidos, o profiler pode coletar poucas amostras. Aumente o trabalho por iteração ou a duração da carga de forma controlada, sem misturar setup caro no trecho que você pretende investigar. O conteúdo sobre testes e benchmarks em Rust ajuda a separar correção, medição e diagnóstico.

Async, Tokio e o risco de interpretar CPU como latência

Em aplicações Tokio, um flame graph pode mostrar executors, polling de futures, alocação, parsing e trabalho síncrono. Ele é útil para descobrir uma tarefa CPU-bound bloqueando uma worker thread, mas não é uma visão completa da latência assíncrona.

Se uma requisição passa a maior parte do tempo aguardando PostgreSQL, Redis ou uma API externa, o consumo de CPU pode ser pequeno. A largura dos frames não revelará por si só a duração da espera. Combine:

  • flame graph para hot paths de CPU;
  • Tokio Console para tarefas, polls e recursos assíncronos;
  • Tracing para spans e causalidade;
  • métricas de latência, throughput, erros e saturação;
  • profiler do banco quando a query é o gargalo.

Também procure funções síncronas pesadas dentro de handlers async: compressão, hashing caro, parsing volumoso e loops CPU-bound podem ocupar a thread do runtime. Nesses casos, medir a pilha ajuda a justificar isolamento, batching ou paralelismo apropriado.

Flame graph em container e produção

Perfilar dentro de container pode exigir acesso a recursos do kernel que a configuração padrão bloqueia. Não transforme --privileged em receita genérica. Uma abordagem mais segura é reproduzir a imagem e a carga em staging, usar uma máquina dedicada ou perfilar o processo a partir do host com permissões restritas.

Se o incidente só ocorre em produção:

  1. confirme que a coleta é permitida pela política da empresa;
  2. limite duração e impacto;
  3. evite registrar argumentos, caminhos ou símbolos sensíveis em artefatos públicos;
  4. preserve versão, commit, target e configuração da carga;
  5. armazene o SVG com acesso controlado;
  6. correlacione o período com métricas e traces.

Profilers por amostragem costumam ter overhead menor do que instrumentar cada chamada, mas “menor” não significa “zero”. Faça um teste de impacto antes de usar em serviço crítico.

O que cargo-flamegraph não resolve

Latência de rede e banco

Pouca CPU pode coexistir com resposta lenta. Use tracing e métricas de dependências.

Vazamento ou pico de memória

O flame graph de CPU pode mostrar alocadores, mas não substitui um profiler de heap, métricas de RSS ou análise de retenção.

Contenção entre threads

Locks podem aparecer indiretamente, porém uma ferramenta especializada em concorrência ou eventos do sistema pode ser necessária.

Regressão estatística

A largura visual não é um teste de performance. Confirme com Criterion, hyperfine ou uma ferramenta de carga adequada.

Código não exercitado

O profiler só vê caminhos executados. Uma carga pouco representativa produz um gráfico preciso da situação errada.

Armadilhas comuns

Perfilar em debug

Você encontra custos que não representam produção. Use um profile otimizado com símbolos.

Escolher o frame mais largo e reescrevê-lo

O frame pode ser trabalho inevitável ou apenas o ponto onde custos de muitos chamadores se acumulam. Siga a pilha e entenda o contexto.

Confundir cor com gravidade

No flame graph tradicional, a cor não é uma escala universal de lentidão. A largura é o sinal principal.

Trocar várias coisas ao mesmo tempo

Alterar allocator, estrutura de dados, paralelismo e flags de compilação impede saber qual mudança produziu o resultado.

Comparar cargas diferentes

Um gráfico com mil itens e outro com um milhão não formam uma comparação útil sem normalização e contexto.

Ignorar símbolos ausentes

Endereços e frames desconhecidos reduzem a capacidade de diagnóstico. Verifique debug, strip, bibliotecas nativas e simbolização.

Otimizar uma dependência sem revisar o uso

Às vezes a crate aparece larga porque o código a chama repetidamente ou usa uma API mais genérica do que precisa. Investigue o padrão de chamada antes de substituir a biblioteca.

Colocando profiling no fluxo da equipe

Um processo simples e repetível vale mais do que um SVG isolado:

  1. registre a métrica problemática: p95, throughput, CPU por operação ou duração do lote;
  2. salve uma baseline com a carga e o commit;
  3. gere o flame graph no mesmo ambiente;
  4. escreva uma hipótese específica;
  5. implemente a menor mudança capaz de testá-la;
  6. execute testes de correção;
  7. repita benchmark e profiling;
  8. documente ganho, custo e trade-offs;
  9. reverta se não houver melhoria consistente.

Em equipes com CI, o gráfico pode ser publicado como artefato de um job manual. Evite falhar pull requests porque um frame ficou visualmente maior. Runners compartilhados sofrem interferência de CPU, frequência, vizinhos e virtualização. Para gates, use uma infraestrutura de benchmark controlada, tolerâncias definidas e histórico suficiente para distinguir ruído de regressão.

Checklist antes de otimizar

  • O problema foi confirmado por uma métrica real.
  • A carga reproduz o cenário relevante.
  • O binário usa otimizações comparáveis às de produção.
  • Símbolos de debug estão disponíveis e strip está desativado.
  • O alvo correto do workspace foi selecionado.
  • O gráfico possui amostras suficientes.
  • A pilha foi analisada, não apenas o frame mais largo.
  • CPU foi distinguida de espera por I/O.
  • Uma hipótese pequena foi registrada.
  • Criterion ou teste de carga confirmará o resultado.
  • Testes funcionais protegem contra regressões de correção.
  • O artefato não será publicado com dados sensíveis.

cargo-flamegraph para portfólio e entrevistas

Um projeto de portfólio fica mais convincente quando mostra uma investigação completa. Em vez de escrever “otimizei o parser”, apresente:

  • dataset e comando reproduzível;
  • baseline de tempo e throughput;
  • flame graph anterior;
  • hipótese encontrada na pilha;
  • mudança pequena no código;
  • benchmark posterior com distribuição;
  • trade-off de memória, legibilidade ou complexidade;
  • gráfico posterior como evidência complementar.

Esse material demonstra uma habilidade valorizada em vagas Rust de backend, infraestrutura, bancos de dados, embedded, engines, segurança e developer tooling: tomar decisões de performance a partir de evidências. Também mostra que você sabe evitar otimização prematura e preservar correção.

Perguntas frequentes

Para que serve cargo-flamegraph?

Ele gera uma visualização agregada das pilhas de chamadas amostradas durante a execução de um programa Rust, ajudando a localizar caminhos que concentram CPU.

Qual comando gera o gráfico?

O começo mais comum é:

cargo install flamegraph --locked
CARGO_PROFILE_RELEASE_DEBUG=true cargo flamegraph --release

Ajuste pacote, binário, profile e argumentos para o projeto real.

Frame largo significa código ruim?

Não. Significa que muitas amostras passaram por aquele frame ou seus descendentes. O custo pode ser esperado, inevitável ou causado por quem chama a função.

Devo usar Criterion ou cargo-flamegraph?

Use os dois: Criterion mede e compara; cargo-flamegraph ajuda a explicar onde a CPU foi consumida.

Funciona com Tokio?

Sim, mas CPU profiling não representa sozinho esperas assíncronas. Combine com Tokio Console, tracing e métricas de dependências.

Conclusão

cargo-flamegraph é uma das formas mais práticas de transformar “minha aplicação Rust está usando muita CPU” em uma investigação concreta. Ele mostra pilhas quentes, revela trabalho repetido e ajuda a direcionar benchmarks para a parte certa do sistema.

A sequência recomendada é: meça o problema, compile com otimizações e símbolos, reproduza a carga, leia a pilha completa, formule uma hipótese e confirme a mudança com benchmark. Não trate o gráfico como ranking automático de funções ruins e não use CPU profiling para explicar toda forma de latência.

Comece com uma carga pequena, porém representativa:

cargo install flamegraph --locked
cargo flamegraph --profile profiling

Abra flamegraph.svg, escolha um caminho largo que pertença ao cenário medido e investigue por que ele existe. A melhor otimização não é a que deixa o gráfico mais bonito; é a que melhora uma métrica relevante sem quebrar correção, operação ou manutenção.