Skip to main content
Image de couverture pour Flamegraph : trouver les optimisations sans se tromper

Flamegraph : trouver les optimisations sans se tromper

TL;DR
Un Flamegraph répond à une seule question : où le CPU passe-t-il son temps ?
Une fois le plateau localisé, le Flamegraph ne dit pas quoi faire : c’est le modèle de données qui tranche. Un calcul indépendant se parallélise ; un writer unique se sérialise. Le Flamegraph trouve le coût, l’architecture décide si on peut le découper.
Le code de cet article est disponible sur GitHub.

Quand un programme est lent, le réflexe est de relire le code « qui a l’air lent » et d’optimiser à l’instinct.
C’est presque toujours du temps perdu : l’intuition peut se tromper de cible et on complique du code qui n’en avait pas besoin…

La règle est de mesurer d’abord et comparer ensuite.

Le Flamegraph est l’outil qui répond à une question précise et une seule : sur quelles fonctions le CPU passe-t-il son temps ?

Cet article explique comment lire un Flamegraph, ce qu’il ne dit pas et comment décider quoi faire.
Les exemples sont en Rust, mais la méthode s’applique à n’importe quel langage compilé.

On travaillera avec csv-report, un outil Rust simple (code sur GitHub).
Il parcourt un dossier de fichiers CSV, parse chaque ligne, agrège des statistiques (moyenne, min, max par colonne) et affiche un rapport. Un code simple mais suffisant pour faire apparaître des problèmes de performance réels.

Avant de sortir un profiler, une vérification avec time.
csv-report lit ses fichiers en local (./data/) et la sortie est sans ambiguïté :

$ time csv-report ./data/
csv-report  100,4s user  1,0s system  99% cpu  1:41,8 total

Ici total (1 min 41,8 s) est le temps réel écoulé au mur, ce qu’on appelle real
user et system sont le temps CPU passé respectivement en code applicatif et dans le noyau.

user + system ≈ real et 99 % de CPU : le programme passe quasiment tout son temps à calculer. Le facteur limitant est le CPU et un Flamegraph aura du sens.

C’est important de le vérifier parce que time peut signifier l’inverse.

Imaginons une variante de ce même outil qui irait télécharger chaque CSV depuis le réseau (par exemple un bucket S3 ou une API) avant de le parser :

$ time csv-report-remote s3://bucket/data/
csv-report  1,6s user  0,7s system  11% cpu  21,4s total

Le piège classique : 21 secondes au total, mais seulement 2,3 s de CPU (11 %).
Le programme n’a pas calculé pendant les ~19 s restantes, il a attendu le réseau.

Si user + system est très inférieur à real, le goulot n’est pas le CPU mais l’attente (réseau, disque, verrou…). Un Flamegraph CPU sera alors quasi vide, ou pire, trompeur.

flowchart LR
    T["time"] --> Q{"user+system ≈ real ?"}
    Q -->|oui| F["Flamegraph CPU<br>pertinent"]
    Q -->|non| W["chercher ce qui attend<br>I/O · réseau · locks"]

    style F fill:#e8f5e9,stroke:#388e3c
    style W fill:#fce4ec,stroke:#c62828

Un Flamegraph est produit par échantillonnage : un sampler interrompt le programme ~1 000 fois par seconde et note la pile d’appels complète. Les piles identiques sont fusionnées et empilées en rectangles.

D’où le vocabulaire suivant :

  • une boîte (le rectangle) représente une fonction ;
  • les boîtes empilées verticalement forment une pile d’appels (chaque fonction au-dessus de celle qui l’appelle) ;
  • une boîte large et plate au sommet est un plateau : la fonction y consomme du temps sans appeler plus loin.

Flamegraph de csv-report en version lente, dominé par la compilation de la regex

Cliquer sur l'image pour la version interactive (zoom au clic)

Comment le lire ?

  • L’axe vertical, c’est qui appelle qui. Tout en bas, main ; chaque boîte au-dessus est une fonction appelée par celle d’en dessous. Plus on monte, plus on est profond dans la pile d’appels.
  • L’axe horizontal n’est PAS le temps. Un Flamegraph ne se lit pas de gauche à droite comme une frise chronologique : les boîtes sont rangées par ordre alphabétique, pas par ordre d’exécution. La position d’une boîte ne veut donc rien dire ; seule sa largeur compte.
  • La largeur, c’est le coût. Plus une boîte est large, plus la fonction était souvent présente dans les échantillons, donc plus elle a consommé de CPU. C’est la seule métrique à regarder.
  • Ce qu’on cherche : les plateaux larges au sommet. Une boîte large tout en haut d’une pile est une fonction qui consomme du temps sans rien appeler d’autre : c’est là que le travail se fait réellement et donc la première cible à optimiser.

Pour que les boîtes portent des noms de fonctions et pas des adresses hexadécimales, il faut un binaire optimisé avec les symboles de debug.

En Rust, un profil release classique enlève ces symboles (le binaire est stripé).
D’où un profil dédié dans Cargo.toml :

[profile.bench]
inherits = "release"
debug = true

Ensuite, cargo-flamegraph automatise la capture :

cargo flamegraph --profile bench --bin slow -- ./data/

Il génère un flamegraph.svg interactif : on peut zoomer sur chaque boîte en cliquant.

Sous le capot, la capture s’appuie sur perf (Linux) ou xctrace, l’outil en ligne de commande d’Instruments fourni avec Xcode (macOS) ; dtrace reste utilisable mais nécessite sudo.

La méthode est identique, les outils diffèrent :

  • Go : go tool pprof produit des Flamegraphs directement. Le runtime Go instrumente nativement les goroutines.
  • Python : py-spy échantillonne sans modifier le code ni redémarrer le processus.
  • Node.js / TypeScript : node --prof + node --prof-process, ou 0x pour un Flamegraph en une commande.
  • Java / JVM : async-profiler. Les profilers naïfs sur JVM suréchantillonnent les safe-points et biaisent les résultats : préférer un profiler wall-clock.

Flamegraph de la version lente, dominé par la compilation de la regex

Cliquer sur l'image pour la version interactive (zoom au clic)

Le Flamegraph est celui de csv-report en version lente (50 fichiers × 100 000 lignes).

Le plateau est immédiat : slow::process_file occupe presque tout le temps et l’essentiel descend dans regex::regex::string::Regex::new, puis dans regex_automata::meta::regex::Builder::build.

Ce n’est pas le parsing lui-même qui est lent, c’est la compilation de la regex appelée une fois par ligne.

Voici la version lente de parse_csv_line :

fn parse_csv_line(line: &str) -> Vec<f64> {
    // Regex recompilée à chaque appel — le goulot.
    let re = Regex::new(r"[^,]+").unwrap();
    re.find_iter(line)
        .filter_map(|m| m.as_str().trim().parse::<f64>().ok())
        .collect()
}

À la lecture, rien ne semble critique. La regex est simple, la logique correcte.  
Mais Regex::new compile à chaque appel.

Sur 50 fichiers × 100 000 lignes, ça représente 5 millions de compilations pour une regex qui ne change jamais.

Le Flamegraph rend le problème visible en une seconde.

La correction tient en deux lignes :

fn parse_csv_line(line: &str) -> impl Iterator<Item = f64> + '_ {
    // Split direct : pas d'automate, pas d'allocation par ligne.
    line.split(',').filter_map(|s| s.trim().parse::<f64>().ok())
}

Et le résultat mesuré sur 50 fichiers × 100 000 lignes :

$ time ./slow ./data/
./slow  100,4s user  1,0s system  99% cpu  1:41,8 total

$ time ./mid ./data/
./mid    0,6s user  0,0s system  93% cpu  0,6s total

~155× sur le même workload pour trois lignes changées.

Résultat identique octet pour octet.
Et tout ça en restant mono-thread : on n’a encore rien parallélisé.

Un nouveau Flamegraph confirme que le plateau de compilation de regex a disparu :

Flamegraph de csv-report après le fix split, mono-thread, dominé par le parsing des flottants

Cliquer sur l'image pour la version interactive (zoom au clic)

Le travail se concentre désormais dans process_file :

  • le parsing des flottants : from_str, parse_number ;
  • le découpage des lignes : splitCharSearcher.

Un seul thread : la barre tout en bas est main-thread.

Une fois parse_csv_line corrigé, un deuxième plateau pourrait émerger : la boucle sur les fichiers est séquentielle.

C’est l’erreur tentante : « plateau large → je parallélise ».

Mais un plateau large dit seulement que du temps CPU s’y dépense.
Il ne dit pas si ce travail est découpable.

Sur csv-report, chaque fichier CSV est parsé et agrégé indépendamment des autres : aucune donnée partagée entre fichiers et aucun ordre imposé.

C’est le cas d’école pour la parallélisation : on peut distribuer les fichiers sur plusieurs threads sans se soucier de la synchronisation.

En Rust, rayon distribue le travail sur tous les cœurs avec un par_iter :

use rayon::prelude::*;

// Avant : séquentiel
let all_stats: Vec<Vec<Stats>> = entries.iter().map(|e| process_file(&e.path())).collect();

// Après : un mot changé
let all_stats: Vec<Vec<Stats>> = entries.par_iter().map(|e| process_file(&e.path())).collect();

Le Flamegraph de la version fast avec par_iter montre un profil radicalement différent.

Flamegraph de csv-report parallélisé avec rayon, en éventail de threads

Cliquer sur l'image pour la version interactive (zoom au clic)

Le contraste est net, la pile s’élargit en éventail de threads rayon (rayon::iter::plumbing::bridge_producer_consumer::helper, rayon_core::join::join_context) et les fonctions métier (parse_csv_line, aggregate_stats) apparaissent en parallèle sur chaque worker.

Le plateau unique de la version mono-thread est remplacé par N plateaux simultanés.

$ time ./mid ./data/
./mid    0,6s user  0,0s system  93% cpu  0,6s total

$ time ./fast ./data/
./fast   0,7s user  0,0s system  655% cpu  0,1s total

~6× de gain supplémentaire juste en remplaçant iter() par par_iter() (sur une machine multi-cœurs).
Le gain se voit aussi au CPU : 93 % (un cœur saturé) contre 655 % (plusieurs cœurs en parallèle).

Plateau non découpable. Si le travail partageait un état mutable (un fichier de sortie ouvert en écriture, une connexion de base de données, un mutex global), plusieurs threads donneraient de la contention, pas de la vitesse.

Le bon choix est alors de garder cette phase séquentielle et d’optimiser le travail lui-même.

flowchart TD
    PLAT["plateau large<br>dans le Flamegraph"]
    PLAT --> Q{"items indépendants ?"}
    Q -->|oui| PAR["paralléliser<br>(rayon, threadpool)"]
    Q -->|non| SEQ["optimiser en place<br>(algo, index, batch)"]

    style PAR fill:#e8f5e9,stroke:#388e3c
    style SEQ fill:#e3f2fd,stroke:#1976d2

Le runtime peut changer ce que le Flamegraph révèle et ce qu’on peut en faire.

Si csv-report était écrit en Python, le Flamegraph montrerait le même plateau dans la boucle de parsing. La tentation est d’écrire :

from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor() as pool:
    results = list(pool.map(parse_and_aggregate, files))

Ça ne change rien au CPU. Dans le CPython classique, un seul thread exécute du bytecode à la fois (le GIL). Les threads s’attendent : on a de la concurrence, pas du parallélisme.

Le remède est ProcessPoolExecutor (processus séparés, un GIL par processus) ou une bibliothèque native qui libère le GIL (pandas, polars…).

Depuis Python 3.14 (2025), un build free-threaded sans GIL existe (officiellement supporté, mais pas encore par défaut, activable via -X gil=0) : ThreadPoolExecutor y parallélise réellement le CPU. Tant que ce build n’est pas généralisé, le raisonnement ci-dessus reste la règle.

Le code JavaScript s’exécute sur un seul thread, l’event loop.

Promise.all n’ajoute donc aucun cœur : il entrelace des tâches sur ce même thread. Pour de l’I/O (lecture disque, réseau), c’est parfait : les attentes se chevauchent pendant que le CPU est libre. Mais pour du parsing CPU pur, ça ne change rien.

Le vrai parallélisme passe par les worker_threads (l’équivalent du ProcessPoolExecutor de Python). Le piège inverse : si les fichiers sont sur S3, le Flamegraph Node sera quasi vide.

C’est time (mur > CPU) qui le révèle.

Si le Flamegraph montre un plateau dans le garbage collector (des frames comme runtime.gcBgMarkWorker ou runtime.mallocgc), ce n’est pas du vrai parsing : c’est le GC déclenché par les allocations du parsing.

Le traitement est différent, réduire les allocations plutôt que paralléliser, mais le Flamegraph le détecte bien : les boîtes GC sortent larges.

Même piège côté JVM, avec une nuance : les noms de frames dépendent du garbage collector choisi.

Avec G1 (le défaut depuis JDK 9), on verra des threads comme G1 Young Generation.
ZGC ou Parallel GC montrent d’autres frames.

Surtout, un profiler naïf rate souvent ces frames (biais des safe-points, voir plus haut).
Un profiler wall-clock comme async-profiler les montre correctement.

La méthode (time → Flamegraph → décision) est universelle. Ce qui change entre runtimes, c’est ce que le Flamegraph peut montrer et ce que « paralléliser » veut dire concrètement.

flowchart LR
    T["time<br>CPU ou attente ?"] --> F["Flamegraph<br>OÙ ça brûle"]
    F --> Q{"plateau<br>découpable ?"}
    Q -->|oui| PAR["paralléliser<br>(rayon, threads)"]
    Q -->|non| SEQ["optimiser en place<br>(algo, index, batch)"]

    style F fill:#fff3e0,stroke:#e65100
    style Q fill:#e3f2fd,stroke:#1976d2

Trois principes :

  • time avant le profiler. user + system très inférieur à real veut dire « attente », pas « CPU ». Inutile d’ouvrir un Flamegraph pour un goulot d’I/O.
  • Le Flamegraph localise, il ne prescrit pas. Il pointe les plateaux larges. Le quoi en faire dépend de ce qu’on sait du code : un calcul indépendant se parallélise, une ressource exclusive se sérialise.
  • Profiler à l’échelle réelle. Un dataset de test trop petit cache les goulots. Il faut un volume représentatif pour que les plateaux émergent.

Un plateau large peut aussi cacher des pièges plus sournois :

  • une fonction appelée des millions de fois avec un coût unitaire faible
  • une requête SQL au plan d’exécution catastrophique

Le Flamegraph la montre large sans dire pourquoi.

La prochaine étape est de combiner le Flamegraph avec un outil de diagnostic ciblé :

  • un microbench criterion pour isoler une fonction suspecte
  • EXPLAIN QUERY PLAN si un plateau descend dans du SQL
  • strace / dtrace pour de l’I/O inattendue

Le Flamegraph localise ; l’outil suivant diagnostique.