Profiling Rust : Résoudre les L1 Cache Misses avec perf, Flamegraph et Criterion
Table des matières
Profiler et optimiser les goulots d'étranglement de performance bas niveau dans une codebase Rust, comme les L1 cache misses excessifs, nécessite une approche systématique utilisant des outils spécialisés. Je vais détailler comment utiliser perf, cargo flamegraph, et criterion pour diagnostiquer et optimiser une section critique en performance, assurant des améliorations mesurables.
Outils et Leurs Rôles
perf(Linux) : Un profiler système pour les événements matériels comme les cache misses, cycles, et instructions. Idéal pour cibler les problèmes de L1 cache à travers l'application.cargo flamegraph: Génère des flame graphs visuels pour identifier où le temps est passé, corrélant les cache misses à des fonctions spécifiques.criterion: Un outil de microbenchmarking pour des mesures précises et répétables de petites sections de code, parfait pour les comparaisons avant-après optimisation.
Scénario d'Exemple
Considère une application Rust traitant un large tableau de structs, où perf révèle des taux élevés de L1 cache miss causant des ralentissements :
struct Point { x: f32, y: f32, z: f32 } // 12 octets
fn process_points(points: &mut [Point]) {
for p in points {
p.x += 1.0; // Accès dispersé
p.y += 1.0;
p.z += 1.0;
}
}
Problème : Le layout Array-of-Structs (AoS) cause une mauvaise localité, car accéder seulement à x tire les y et z inutiles dans la ligne de cache L1 de 64 octets, menant à des misses excessifs.
Workflow pour Optimiser les L1 Cache Misses
1. Setup et Reproduction
- Compiler avec
--releasepour une performance réaliste (cargo build --release). - Lancer l'app avec une charge de travail représentative (ex : 1M
Points).
2. Diagnostiquer avec perf
- Commande :
perf stat -e cycles,instructions,L1-dcache-loads,L1-dcache-load-misses ./target/release/app - Sortie Exemple :
10,000,000,000 cycles 15,000,000,000 instructions 5,000,000,000 L1-dcache-loads 500,000,000 L1-dcache-load-misses (10.00%) - Insight : Un taux de miss de 10% est élevé (idéal : <1-2%). Les L1 misses (50-100 cycles chacun) dominent le runtime.
3. Localiser avec cargo flamegraph
- Installation :
cargo install flamegraph - Lancement :
cargo flamegraph --bin app - Sortie : Un flame graph SVG montre
process_pointsprenant 80% du temps, avec des pics plats indiquant des stalls mémoire. - Hypothèse : L'accès strided à travers
x,y,zrécupère des données inutiles par ligne de cache.
4. Microbenchmark avec criterion
- Setup :
use criterion::{black_box, Criterion}; fn bench(c: &mut Criterion) { let mut points = vec![Point { x: 0.0, y: 0.0, z: 0.0 }; 1_000_000]; c.bench_function("process_points", |b| b.iter(|| process_points(black_box(&mut points)))); } - Baseline : 50ms par itération, variance élevée due aux cache misses.
5. Optimiser
- Passer à Struct-of-Arrays (SoA) :
struct Points { xs: Vec<f32>, ys: Vec<f32>, zs: Vec<f32> } impl Points { fn new(n: usize) -> Self { Points { xs: vec![0.0; n], ys: vec![0.0; n], zs: vec![0.0; n] } } fn process(&mut self) { for x in &mut self.xs { *x += 1.0; } // Accès contigu } } - Pourquoi : Les
xscontigus rentrent 16f32s par ligne de cache de 64 octets (vs 5Points avec padding), réduisant les loads et misses. - Alternative : Si AoS est requis, aligner
Pointavec#[repr(align(16))]et padder à 16 octets pour réduire les récupérations de ligne partielle.
6. Vérifier
- perf : Relancer
perf stat:
Les misses chutent à 1%, les cycles diminuent de 20%.8,000,000,000 cycles 12,000,000,000 instructions 3,000,000,000 L1-dcache-loads 30,000,000 L1-dcache-load-misses (1.00%) - Flamegraph : Le nouveau graphe montre
processcomme un pic plus étroit, moins memory-bound. - criterion : Le temps chute à 40ms, avec une variance plus serrée, confirmant l'efficacité du cache.
Étapes d'Optimisation Avancées
Techniques Supplémentaires
// Technique 1: Prefetching explicite
use std::arch::x86_64::_mm_prefetch;
fn process_with_prefetch(points: &mut [Point]) {
const PREFETCH_DISTANCE: usize = 64; // Lignes de cache en avance
for (i, p) in points.iter_mut().enumerate() {
// Prefetch la prochaine région
if i + PREFETCH_DISTANCE < points.len() {
unsafe {
_mm_prefetch(
&points[i + PREFETCH_DISTANCE] as *const _ as *const i8,
std::arch::x86_64::_MM_HINT_T0
);
}
}
p.x += 1.0;
p.y += 1.0;
p.z += 1.0;
}
}
// Technique 2: SIMD pour vectorisation
use std::simd::*;
fn process_simd(xs: &mut [f32]) {
const LANE_COUNT: usize = 8; // AVX2 8x f32
for chunk in xs.chunks_exact_mut(LANE_COUNT) {
let vals = f32x8::from_slice(chunk);
let incremented = vals + f32x8::splat(1.0);
incremented.copy_to_slice(chunk);
}
// Traiter le reste
for x in xs.chunks_exact_mut(LANE_COUNT).into_remainder() {
*x += 1.0;
}
}
// Technique 3: Alignement et padding optimaux
#[repr(C, align(64))] // Aligner sur ligne de cache
struct AlignedPoint {
x: f32,
y: f32,
z: f32,
_padding: [u8; 52], // Pad à 64 octets
}
Mesures Détaillées avec perf
# Profiling détaillé
perf record -e cache-misses,cache-references,cycles,instructions ./target/release/app
# Analyse par fonction
perf report --stdio
# Événements spécifiques au CPU
perf list | grep cache
perf stat -e L1-dcache-loads,L1-dcache-load-misses,L1-icache-load-misses ./app
# Profiling avec sampling
perf record -g --call-graph=dwarf ./app
perf report --no-children --sort=dso,symbol
Optimisations Algorithmiques
// Blocking/Tiling pour améliorer la localité
fn process_blocked(points: &mut [Point], block_size: usize) {
for chunk in points.chunks_mut(block_size) {
// Traiter le bloc en entier avant de passer au suivant
for p in chunk {
p.x += 1.0;
}
for p in chunk {
p.y += 1.0;
}
for p in chunk {
p.z += 1.0;
}
}
}
// Loop fusion pour réduire les passes
fn process_fused(points: &mut [Point]) {
for p in points {
// Toutes les opérations sur le même élément
p.x = p.x + 1.0;
p.y = p.y + 1.0;
p.z = p.z + 1.0;
// Calculs supplémentaires qui utilisent x, y, z
let magnitude = (p.x * p.x + p.y * p.y + p.z * p.z).sqrt();
p.x /= magnitude;
p.y /= magnitude;
p.z /= magnitude;
}
}
Workflow de Profiling Systématique
1. Collecte de Données Baseline
// Setup de benchmark complet
use criterion::{BenchmarkId, Criterion, Throughput};
fn comprehensive_bench(c: &mut Criterion) {
let sizes = [1_000, 10_000, 100_000, 1_000_000];
let mut group = c.benchmark_group("point_processing");
for size in sizes {
group.throughput(Throughput::Elements(size as u64));
let mut points = vec![Point { x: 0.0, y: 0.0, z: 0.0 }; size];
group.bench_with_input(
BenchmarkId::new("aos", size),
&size,
|b, _| b.iter(|| process_points(black_box(&mut points)))
);
let mut soa_points = Points::new(size);
group.bench_with_input(
BenchmarkId::new("soa", size),
&size,
|b, _| b.iter(|| soa_points.process())
);
}
group.finish();
}
2. Analyse de Régression
# Comparer avant/après
criterion --save-baseline before
# ... faire les changements ...
criterion --baseline before
3. Validation Multi-Architecture
#[cfg(target_arch = "x86_64")]
fn process_optimized() { /* Implémentation AVX2 */ }
#[cfg(target_arch = "aarch64")]
fn process_optimized() { /* Implémentation NEON */ }
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
fn process_optimized() { /* Fallback générique */ }
Métriques de Validation
Métriques de Performance Clés
- IPC (Instructions Per Cycle) : >2.0 indique une bonne utilisation CPU
- Cache Miss Rate : <2% pour L1, <10% pour L2
- Memory Bandwidth : % d'utilisation de la bande passante théorique
- Branch Misprediction Rate : <5%
Outils de Mesure Avancés
# Intel VTune (commercial)
vtune -collect memory-access ./app
# AMD μProf (gratuit)
AMDuProfCLI collect --config inst_based ./app
# Valgrind cachegrind
valgrind --tool=cachegrind ./app
Conclusion
Pour résoudre les L1 cache misses dans une codebase Rust, j'utiliserais perf pour détecter les taux de miss élevés, cargo flamegraph pour cibler le coupable, et criterion pour mesurer les améliorations. Le workflow—reproduire, diagnostiquer, hypothèse, optimiser, vérifier—assure des résultats guidés par les données. Dans ce cas, passer à un layout SoA a réduit drastiquement les cache misses, boostant le débit, comme confirmé par les outils de profiling. Cette approche aide les développeurs à résoudre efficacement les goulots d'étranglement.