Comment construire un harnais de benchmarking personnalisé en Rust


Qu’est-ce que le Benchmarking ?

Le benchmarking est la pratique consistant à tester les performances de votre code pour voir à quelle vitesse (latence) ou combien (débit) de travail il peut effectuer. Cette étape souvent négligée dans le développement logiciel est cruciale pour créer et maintenir un code rapide et performant. Le benchmarking fournit les métriques nécessaires aux développeurs pour comprendre comment leur code se comporte sous diverses charges de travail et conditions. Pour les mêmes raisons que vous écrivez des tests unitaires et d’intégration pour éviter les régressions de fonctionnalités, vous devriez écrire des benchmarks pour éviter les régressions de performances. Les bugs de performance sont des bugs !

Benchmarking en Rust

Les trois options populaires pour le benchmarking en Rust sont : libtest bench, Criterion, et Iai.

libtest est le framework intégré de tests unitaires et de benchmarking de Rust. Bien qu’il fasse partie de la bibliothèque standard de Rust, libtest bench est encore considéré comme instable, il n’est donc disponible que sur les versions nightly du compilateur. Pour fonctionner sur le compilateur Rust stable, un harnais de benchmarking séparé doit être utilisé. Cependant, aucun des deux n’est activement développé.

Le harnais de benchmarking le plus populaire dans l’écosystème Rust est Criterion. Il fonctionne à la fois sur les versions stables et nightly du compilateur Rust, et il est devenu le standard de facto au sein de la communauté Rust. Criterion est également beaucoup plus riche en fonctionnalités comparé à libtest bench.

Une alternative expérimentale à Criterion est Iai, créé par le même auteur que Criterion. Cependant, il utilise des comptes d’instructions plutôt que du temps d’horloge murale : instructions CPU, accès L1, accès L2 et accès RAM. Cela permet un benchmarking en une seule passe puisque ces métriques devraient rester quasiment identiques entre les exécutions.

Avec tout cela en tête, si vous cherchez à mesurer le temps d’exécution de votre code, vous devriez probablement utiliser Criterion. Si vous cherchez à évaluer les performances de votre code en CI avec des exécuteurs partagés, alors cela pourrait valoir la peine de consulter Iai. Notez cependant qu’Iai n’a pas été mis à jour depuis plus de 3 ans. Vous pourriez donc envisager d’utiliser Iai-Callgrind à la place.

Mais que faire si vous ne souhaitez pas mesurer le temps d’exécution ou le nombre d’instructions ? Et si vous voulez suivre un tout autre type de benchmark‽ Heureusement, Rust rend incroyablement facile la création d’un environnement de test personnalisé.

Comment fonctionne cargo bench

Avant de construire un harnais de benchmarking personnalisé, nous devons comprendre comment fonctionnent les benchmarks en Rust. Pour la plupart des développeurs Rust, cela signifie exécuter la commande cargo bench. La commande cargo bench compile et exécute vos benchmarks. Par défaut, cargo bench essaiera d’utiliser le harnais de benchmark intégré (mais instable) libtest. libtest bench parcourra ensuite votre code et exécutera toutes les fonctions annotées avec l’attribut #[bench]. Pour utiliser un harnais de benchmarking personnalisé, nous devons indiquer à cargo bench de ne pas utiliser libtest bench.

Utiliser un Harnais de Benchmark Personnalisé avec cargo bench

Pour que cargo bench n’utilise pas le banc de tests de libtest, nous devons ajouter ce qui suit à notre fichier Cargo.toml :

Cargo.toml
[[bench]]
harness = false

Malheureusement, nous ne pouvons pas utiliser l’attribut #[bench] avec notre harnais de benchmarking personnalisé. Peut-être un jour bientôt, mais pas aujourd’hui. Au lieu de cela, nous devons créer un répertoire benches séparé pour contenir nos benchmarks. Le répertoire benches est aux benchmarks ce que le répertoire tests est aux tests d’intégration. Chaque fichier à l’intérieur du répertoire benches est traité comme une crate séparée. La crate étant benchmarkée doit donc être une crate de bibliothèque. C’est-à-dire qu’elle doit avoir un fichier lib.rs.

Par exemple, si nous avions une crate de bibliothèque basique nommée game nous pourrions alors ajouter un fichier de benchmark personnalisé nommé play_game au répertoire benches. Notre structure de répertoire ressemblerait à ceci :

game
├── Cargo.lock
├── Cargo.toml
└── benches
└── play_game.rs
└── src
└── lib.rs

Ensuite, nous devons informer cargo bench de notre crate de benchmark personnalisée play_game. Nous mettons donc à jour notre fichier Cargo.toml :

Cargo.toml
[[bench]]
name = "play_game"
harness = false

Écrire un Code pour Mesurer les Performances

Avant de pouvoir rédiger un test de performance, nous avons besoin de disposer d’un code de bibliothèque à tester. Pour notre exemple, nous allons jouer au jeu FizzBuzzFibonacci.

Les règles pour FizzBuzzFibonacci sont les suivantes :

Écrire un programme qui imprime les entiers de 1 à 100 (inclus) :

  • Pour les multiples de trois, imprimer Fizz
  • Pour les multiples de cinq, imprimer Buzz
  • Pour les multiples des deux, trois et cinq, imprimer FizzBuzz
  • Pour les nombres qui font partie de la séquence de Fibonacci, n’imprimer que Fibonacci
  • Pour tous les autres, imprimer le nombre

Voici à quoi ressemble notre implémentation dans src/lib.rs :

src/lib.rs
pub fn play_game(n: u32, print: bool) {
let result = fizz_buzz_fibonacci(n);
if print {
println!("{result}");
}
}
fn fizz_buzz_fibonacci(n: u32) -> String {
if is_fibonacci_number(n) {
"Fibonacci".to_string()
} else {
match (n % 3, n % 5) {
(0, 0) => "FizzBuzz".to_string(),
(0, _) => "Fizz".to_string(),
(_, 0) => "Buzz".to_string(),
(_, _) => n.to_string(),
}
}
}
fn is_fibonacci_number(n: u32) -> bool {
let (mut previous, mut current) = (0, 1);
while current < n {
let next = previous + current;
previous = current;
current = next;
}
current == n
}

Créer un Harnais de Benchmarking Personnalisé

Nous allons créer un harnais de benchmarking personnalisé dans benches/play_game.rs. Ce harnais de benchmarking personnalisé va mesurer les allocations sur le tas en utilisant le crate dhat-rs. dhat-rs est un outil fantastique pour suivre les allocations sur le tas dans les programmes Rust, créé par l’expert en performance Rust Nicholas Nethercote. Pour nous aider à gérer nos fonctions de benchmark, nous utiliserons le crate inventory par le prolifique David Tolnay.

Ajoutons dhat-rs et inventory à notre fichier Cargo.toml comme dev-dependencies:

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"

Créer un Allocateur Personnalisé

Étant donné que notre banc d’essai personnalisé mesurera les allocations de tas, nous aurons besoin d’utiliser un allocateur de tas personnalisé. Rust vous permet de configurer un allocateur de tas global personnalisé en utilisant l’attribut #[global_allocator]. Ajoutez ce qui suit en haut de benches/play_game.rs :

benches/play_game.rs
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;

Cela indique à Rust d’utiliser dhat::Alloc comme notre allocateur de tas global.

🐰 Vous ne pouvez définir qu’un seul allocateur de tas global à la fois. Si vous souhaitez basculer entre plusieurs allocateurs globaux, ils doivent être gérés via la compilation conditionnelle avec les fonctionnalités de Rust.

Créer un Collecteur de Benchmark Personnalisé

Pour créer un harnais de benchmarking personnalisé, nous avons besoin d’un moyen d’identifier et de stocker nos fonctions de benchmark. Nous utiliserons une struct, judicieusement nommée CustomBenchmark pour encapsuler chaque fonction de benchmark.

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}

Un CustomBenchmark a un nom et une fonction de benchmark qui retourne dhat::HeapStats comme sortie.

Ensuite, nous utiliserons la crate inventory pour créer une collection pour tous nos CustomBenchmarks :

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}
inventory::collect!(CustomBenchmark);

Créer une Fonction de Benchmark

Maintenant, nous pouvons créer une fonction de benchmark qui joue au jeu FizzBuzzFibonacci :

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}

Ligne par ligne :

  • Créer une fonction de benchmark qui correspond à la signature utilisée dans CustomBenchmark.
  • Créer un dhat::Profiler en mode test, pour collecter les résultats de notre dhat::Alloc personnalisé, allocateur global.
  • Exécuter notre fonction play_game à l’intérieur d’une « boîte noire » afin que le compilateur n’optimise pas notre code.
  • Itérer de 1 à 100 inclusivement.
  • Pour chaque nombre, appeler play_game, avec print défini sur false.
  • Retourner nos statistiques d’allocation sur le tas comme dhat::HeapStats.

🐰 Nous avons défini print sur false pour la fonction play_game. Cela empêche play_game d’afficher des informations sur la sortie standard. Paramétrer vos fonctions de bibliothèque de cette manière peut les rendre plus adaptées au benchmarking. Cependant, cela signifie aussi que nous ne benchmarkons peut-être pas la bibliothèque exactement comme elle est utilisée en production.

Dans ce cas, nous devons nous demander :

  1. Les ressources nécessaires pour afficher sur la sortie standard sont-elles importants pour nous ?
  2. L’affichage sur la sortie standard est-il une source potentielle de bruit ?

Pour notre exemple, nous avons conclu :

  1. Non, afficher sur la sortie standard n’est pas important pour nous.
  2. Oui, c’est une source de bruit très probable.

Par conséquent, nous avons omis l’affichage sur la sortie standard dans ce benchmark. Le benchmarking est difficile, et il n’y a souvent pas de réponse unique à des questions comme celles-ci. Ça dépend.

Enregistrer la fonction de benchmark

Avec notre fonction de benchmark écrite, nous devons créer un CustomBenchmark et l’enregistrer dans notre collection de benchmarks en utilisant inventory.

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}
inventory::submit!(CustomBenchmark {
name: "bench_play_game",
benchmark_fn: bench_play_game
});

Si nous avions plus d’un benchmark, nous répéterions le même processus :

  1. Créez une fonction de benchmark.
  2. Créez un CustomBenchmark pour la fonction de benchmark.
  3. Enregistrez le CustomBenchmark dans la collection inventory.

Créer un Exécuteur de Benchmark Personnalisé

Enfin, nous devons créer un exécuteur pour notre harnais de benchmark personnalisé. Un harnais de benchmark personnalisé est en réalité juste un binaire qui exécute tous nos benchmarks pour nous et rapporte ses résultats. L’exécuteur de benchmark est ce qui orchestre tout cela.

Nous voulons que nos résultats soient produits en Format de Métrique Bencher (BMF) JSON. Pour ce faire, nous devons ajouter une dernière dépendance, la crate serde_json de… vous l’avez deviné, David Tolnay !

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"
serde_json = "1.0"

Ensuite, nous allons implémenter une méthode pour que CustomBenchmark exécute sa fonction de benchmark et retourne ensuite les résultats en BMF JSON.

benches/play_game.rs
impl CustomBenchmark {
fn run(&self) -> serde_json::Value {
let heap_stats = (self.benchmark_fn)();
let measures = serde_json::json!({
"Final Blocks": {
"value": heap_stats.curr_blocks,
},
"Final Bytes": {
"value": heap_stats.curr_bytes,
},
"Max Blocks": {
"value": heap_stats.max_blocks,
},
"Max Bytes": {
"value": heap_stats.max_bytes,
},
"Total Blocks": {
"value": heap_stats.total_blocks,
},
"Total Bytes": {
"value": heap_stats.total_bytes,
},
});
let mut benchmark_map = serde_json::Map::new();
benchmark_map.insert(self.name.to_string(), measures);
benchmark_map.into()
}
}

Les résultats BMF JSON contiennent six Mesures pour chaque benchmark :

  • Blocs Finaux : Nombre final de blocs alloués lorsque le benchmark s’est terminé.
  • Octets Finaux : Nombre final d’octets alloués lorsque le benchmark s’est terminé.
  • Blocs Max : Nombre maximal de blocs alloués à un moment donné pendant l’exécution du benchmark.
  • Octets Max : Nombre maximal d’octets alloués à un moment donné pendant l’exécution du benchmark.
  • Blocs Totaux : Nombre total de blocs alloués pendant l’exécution du benchmark.
  • Octets Totaux : Nombre total d’octets alloués pendant l’exécution du benchmark.

Enfin, nous pouvons créer une fonction main pour exécuter tous les benchmarks de notre collection inventory et produire les résultats en BMF JSON.

benches/play_game.rs
fn main() {
let mut bmf = serde_json::Map::new();
for benchmark in inventory::iter::<CustomBenchmark> {
let mut results = benchmark.run();
bmf.append(results.as_object_mut().unwrap());
}
let bmf_str = serde_json::to_string_pretty(&bmf).unwrap();
std::fs::write("results.json", &bmf_str).unwrap();
println!("{bmf_str}");
}

Exécuter le Harnais de Benchmark Personnalisé

Tout est maintenant en place. Nous pouvons enfin exécuter notre harnais de benchmark personnalisé.

Terminal window
cargo bench

La sortie vers la console et vers un fichier nommé results.json devrait ressembler à ceci :

{
"bench_play_game": {
"Current Blocks": {
"value": 0
},
"Current Bytes": {
"value": 0
},
"Max Blocks": {
"value": 1
},
"Max Bytes": {
"value": 9
},
"Total Blocks": {
"value": 100
},
"Total Bytes": {
"value": 662
}
}
}

Les chiffres exacts que vous voyez peuvent être légèrement différents selon l’architecture de votre ordinateur. Mais l’important est que vous ayez au moins des valeurs pour les quatre derniers métriques.

Suivre les résultats des benchmarks personnalisés

La plupart des résultats de benchmark sont éphémères. Ils disparaissent dès que votre terminal atteint sa limite de défilement. Certaines suites de tests de performance vous permettent de mettre en cache les résultats, mais cela demande beaucoup de travail à mettre en œuvre. Et même dans ce cas, nous ne pourrions stocker nos résultats que localement. Heureusement pour nous, notre suite de tests de performance personnalisée fonctionnera avec Bencher ! Bencher est un ensemble d’outils de benchmarking continu qui nous permet de suivre les résultats de nos benchmarks au fil du temps et de détecter les régressions de performance avant qu’elles n’arrivent en production.

Une fois que vous êtes prêt en utilisant Bencher Cloud ou Bencher Self-Hosted, vous pouvez suivre les résultats de notre suite de tests de performance personnalisée en exécutant :

Terminal window
bencher run --file results.json "cargo bench"

Vous pouvez également en savoir plus sur comment suivre les benchmarks personnalisés avec Bencher et l’adaptateur JSON pour les benchmarks.

Conclusion

Nous avons commencé ce billet en examinant les trois harnais de benchmarking les plus populaires dans l’écosystème Rust : libtest bench, Criterion, et Iai. Bien qu’ils puissent couvrir la majorité des cas d’utilisation, il peut parfois être nécessaire de mesurer autre chose que le temps d’horloge murale ou le nombre d’instructions. Cela nous a conduit à créer un harnais de benchmarking personnalisé.

Notre harnais de benchmarking personnalisé mesure les allocations de tas en utilisant dhat-rs. Les fonctions de benchmark ont été collectées à l’aide d’inventory. Lors de l’exécution, nos benchmarks produisent des résultats au format JSON Bencher Metric Format (BMF). Nous pouvions ensuite utiliser Bencher pour suivre nos résultats de benchmark personnalisés au fil du temps et détecter les régressions de performance dans CI.

Tout le code source de ce guide est disponible sur GitHub.

Bencher: Benchmarking Continu

🐰 Bencher

Bencher est une suite d’outils de benchmarking continu. Avez-vous déjà eu une régression de performance qui a impacté vos utilisateurs ? Bencher aurait pu empêcher cela de se produire. Bencher vous permet de détecter et de prévenir les régressions de performance avant qu’elles n’arrivent en production.

  • Exécuter: Exécutez vos benchmarks localement ou en CI en utilisant vos outils de benchmarking préférés. La CLI bencher enveloppe simplement votre harnais de benchmarking existant et stocke ses résultats.
  • Suivre: Suivez les résultats de vos benchmarks au fil du temps. Surveillez, interrogez et graphiquez les résultats à l’aide de la console web Bencher en fonction de la branche source, du banc d’essai et de la mesure.
  • Détecter: Détectez les régressions de performances en CI. Bencher utilise des analyses de pointe et personnalisables pour détecter les régressions de performances avant qu’elles n’arrivent en production.

Pour les mêmes raisons que les tests unitaires sont exécutés en CI pour prévenir les régressions de fonctionnalités, les benchmarks devraient être exécutés en CI avec Bencher pour prévenir les régressions de performance. Les bugs de performance sont des bugs !

Commencez à détecter les régressions de performances en CI — essayez Bencher Cloud gratuitement.

🤖 Ce document a été automatiquement généré par OpenAI GPT-4. Il peut ne pas être précis et peut contenir des erreurs. Si vous trouvez des erreurs, veuillez ouvrir une issue sur GitHub.