Comment construire un harnais de benchmarking personnalisé en Rust
Everett Pompeii
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
:
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 :
Ensuite, nous devons informer cargo bench
de notre crate de benchmark personnalisée play_game
.
Nous mettons donc à jour notre fichier Cargo.toml
:
É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
:
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
:
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
:
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.
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 CustomBenchmark
s :
Créer une Fonction de Benchmark
Maintenant, nous pouvons créer une fonction de benchmark qui joue au jeu FizzBuzzFibonacci :
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 notredhat::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
, avecprint
défini surfalse
. - Retourner nos statistiques d’allocation sur le tas comme
dhat::HeapStats
.
🐰 Nous avons défini
false
pour la fonctionplay_game
. Cela empêcheplay_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 :
- Les ressources nécessaires pour afficher sur la sortie standard sont-elles importants pour nous ?
- L’affichage sur la sortie standard est-il une source potentielle de bruit ?
Pour notre exemple, nous avons conclu :
- Non, afficher sur la sortie standard n’est pas important pour nous.
- 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
.
Si nous avions plus d’un benchmark, nous répéterions le même processus :
- Créez une fonction de benchmark.
- Créez un
CustomBenchmark
pour la fonction de benchmark. - Enregistrez le
CustomBenchmark
dans la collectioninventory
.
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 !
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.
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.
Exécuter le Harnais de Benchmark Personnalisé
Tout est maintenant en place. Nous pouvons enfin exécuter notre harnais de benchmark personnalisé.
La sortie vers la console et vers un fichier nommé results.json
devrait ressembler à ceci :
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 :
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 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.