Introduction
Cet e-book a été détourné en slides. Pour reproduire le rendu de la présentation…
- Maximisez la fenêtre de votre navigateur web
- Réglez le niveau de zoom à 200% (peut varier selon votre écran + config)
- Passez en plein écran (F11 pour Firefox) pour plus d’immersion
- Eventuellement, masquez la barre latérale avec le bouton à trois traits en haut
Boucle simple
#![allow(unused)] fn main() { for i in 0..5 { println!("{i}"); } }
x .. y
est un intervalle
semi-ouvert, comme le
range(x, y)
de Python.
D’autres types d’intervalles existent, par exemple…
x ..= y
est un intervalle ferméx ..
est un intervalle infini commençant à x
Ces intervalles ne servent pas qu’à itérer sur des entiers, donc…
- Toutes les permutations de bornes fermées, ouvertes et infinies sont permises
- Les bornes n’ont pas besoin d’être entières
Boucle simple (sous le capot)
#![allow(unused)] fn main() { // Ce code... // // for item in 0..5 { // println!("{item}"); // } // // ...est interprété comme suit : // let mut iterator = (0..5).into_iter(); while let Some(item) = iterator.next() { println!("{item}"); } }
Les boucles for
acceptent en paramètre n’importe quel objet
itérable.
Cela nous donne accès à la puissance des itérateurs. Voyons ça plus en détail…
Itérateurs
Tous les itérateurs ont des méthodes qui produisent d’autres itérateurs :
#![allow(unused)] fn main() { for (idx, value) in (2..).into_iter().step_by(3).skip(1).take(4) .chain(1..=3) .enumerate() { println!("{idx}: {value}"); } }
On peut produire des itérateurs assez complexes rien qu’avec cet outil.
Comme l’itération est paresseuse, le compilateur optimise plutôt bien ces pipelines.
Collections
Il n’y a pas que les intervalles qui sont itérables, les collections aussi :
#![allow(unused)] fn main() { println!("Tableau alloué sur la pile :"); for i in [1, 23, 456] { println!("{i}"); } println!("\nTableau alloué sur le tas, valeur initiale uniforme :"); for j in vec![42; 3] { println!("{j}"); } }
La biliothèque standard fournit diverses collections, un peu plus puissantes qu’en C++.
Mais pour bien les utiliser, il faut maîtriser la notion de propriété (ownership).
Déplacement
En Rust, généralement, utiliser une valeur la déplace, comme std::move
en
C++11.
Mais les règles sont un peu différentes :
- Tous les types supportent le déplacement, on ne peut pas l’interdire.
- Le déplacement est toujours un
memcpy()
(~facile à éliminer pour l’optimiseur). - L’absence de use-after-move est vérifiée, donc le code suivant ne compile pas.
#![allow(unused)] fn main() { // Définition d'une valeur non copiable let numbers = vec![1.2, 3.4, 5.6]; // Utilisation de la valeur => Déplacement for x in numbers { println!("{x}"); } // Réutilisation après déplacement => Erreur ! println!("{numbers:?}"); }
Types copiables
Si memcpy()
crée une copie indépendante (ex : types primitifs), le
use-after-move est en fait OK.
On peut donc, dans ce cas, autoriser la réutilisation de l’original :
#![allow(unused)] fn main() { // Définition d'une valeur copiable (tableau de flottants) let numbers = [1.2, 3.4, 5.6]; // Utilisation du tableau => Copie for x in numbers { println!("{x}"); } // Réutilisation après copie => OK ! println!("{numbers:?}"); }
Les copies non triviales nécessitent un clone()
explicite : audit des
performances facilité !
Emprunt
Les copies peuvent être coûteuses si le compilateur ne parvient pas à les éliminer.
Dans le code “chaud”, on privilégiera donc les accès en place via l’emprunt (cf références C++) :
#![allow(unused)] fn main() { // Accès en lecture seule let mut numbers = vec![1.2, 3.4, 5.6]; for x in &numbers { println!("{x}"); } // Accès en écriture for x in &mut numbers { *x = 4.2; } println!("{numbers:?}"); }
Prix à payer pour les garanties de sûreté de Rust : des restrictions sur les emprunts.
- Régime type R/W lock : 1 emprunt en écriture ou N emprunts partagés
- Sauf exceptions, pas d’accès en écriture via un emprunt partagé
- Un emprunt ne peut pas sortir de la portée de la variable parente
- Toute modification de la variable parente invalide les emprunts
Sur ce, nous pouvons fermer cette parenthèse et revenir à nos itérateurs…
Map et collect
La méthode d’itérateur map()
permet de passer chaque élément par une
fonction.
La méthode collect()
permet de construire une collection à partir d’un
itérateur.
En les combinant, on peut facilement transformer une collection en une autre :
#![allow(unused)] fn main() { let before = [4.2_f32; 10]; println!("Avant: {before:?}"); let after = before.into_iter() .map(|x| x.powi(2)) .collect::<Vec<_>>(); println!("Après: {after:?}"); }
De zip à SAXPY
La méthode zip()
permet de passer d’une paire d’itérateurs à un itérateur de
paires.
En combinaison avec map()
et collect()
, elle permet d’implémenter SAXPY :
#![allow(unused)] fn main() { let a = 4.2; let x = [1.2; 3]; let y = [3.4; 3]; println!("Computing {a} * {x:?} + {y:?}"); let z = x.into_iter().zip(y) .map(|(x, y)| a * x + y) .collect::<Vec<_>>(); println!("Result: {z:?}"); }
Attention aux itérateurs de longueur inhomogène: zip()
tronque au plus court
!1
Meilleur choix par défaut que C++ où c’est du comportement indéfini, mais reste piégeux.
Triadique FMA
Le modèle d’itération Rust est plus flexible que les algorithmes STL.
Ainsi, l’opération triadique fma(X, Y, Z)
se calcule sans difficulté :
#![allow(unused)] fn main() { let x = [1.2_f32; 3]; let y = [3.4; 3]; let z = [5.6; 3]; println!("Computing fma({x:?}, {y:?}, {z:?})"); let result = x.into_iter().zip(y).zip(z) .map(|((x, y), z)| x.mul_add(y, z)) .collect::<Vec<_>>(); println!("Result: {result:?}"); }
Réductions
Les itérateurs Rust fournissent de nombreuses méthodes de réduction, comme
sum()
:
#![allow(unused)] fn main() { let numbers = [4.2; 10]; println!("Before: {numbers:?}"); let sum = numbers.into_iter() .sum::<f32>(); println!("After: {sum:?}"); }
Mais ces réductions font l’équivalent d’une boucle simple, dans le cas de
sum()
…
#![allow(unused)] fn main() { let numbers = [4.2; 10]; let mut sum = 0.0f32; for x in numbers { sum += x; } }
Elles ont donc une mauvaise performance en virgule flottante :( Pour faire mieux, on peut :
- Augmenter le parallélisme d’instruction (cf
std::reduce
de C++20,iterator_ilp
en Rust…) - Arrêter de jouer aux dés avec le compilateur et passer au SIMD explicite
Réduction SIMD: Le futur
On peut tester dans la version nightly de rustc le prototype de
std::simd
:
use std::simd::prelude::*;
// Données d'entrée
let input = [4.2; 1024];
// Extraction de la portion alignée des données d'entrée
let (peel, body, tail): (&[f32], &[Simd<f32, LANES>], &[f32]) =
input.as_simd::<LANES>();
// Somme de la portion alignée
let body_sum = body.iter()
.sum::<Simd<f32, LANES>>() // Somme de vecteurs SIMD
.reduce_sum(); // Réduction à un scalaire unique
// Somme complète
let sum = peel.iter() // Scalaires avant portion alignée
.chain(std::iter::once(&body_sum)) // Somme de la portion alignée
.chain(tail.iter()) // Scalaires après portion alignée
.sum::<f32>();
Et si on ajoute un peu de parallélisme d’instructions1, on atteint la performance crète HW !
Cf slide 23 de cette présentation de Pierre.
Réduction SIMD: Le présent
En attendant std::simd
, on peut vivre avec des alternatives stables comme…
slipstream
: SIMD explicite portable basé sur l’autovectorisation.safe_arch
: Redesign plus ergonomique des intrinsèques x86.
Produit scalaire simple
Intuitivement, il suffirait de combiner zip()
, map()
et sum()
…
#![allow(unused)] fn main() { let x = [2.4; 1024]; let y = [4.2; 1024]; let dot = x.into_iter().zip(y) .map(|(x, y)| x * y) .sum::<f32>(); println!("{dot}"); }
…mais on a vu que sum()
n’est pas optimal en virgule flottante !
Et as_simd()
n’aidera pas ici car il y a deux entrées x
et y
d’alignement a priori distinct.
La solution ? Basculer vers un format de données nativement SIMD (et donc bien aligné) :
type Vector = Simd<f32, 8>;
let x = [Vector::splat(2.4); 1024 / 8];
let y = [Vector::splat(4.2); 1024 / 8];
// ... et on calcule comme précédemment ...
Produit scalaire optimisé
On peut gagner encore un facteur ≤ 2 en tirant parti du FMA (fused multiply-add) :
#![allow(unused)] fn main() { type Vector = f32; let x = [2.4f32; 1024]; let y = [4.2; 1024]; let dot = x.into_iter().zip(y) .fold(Vector::default(), |acc, (x, y)| x.mul_add(y, acc)); }
Et un peu de parallélisme d’instructions plus tard, on obtient des performances satisfaisantes1 :
Le speedup FMA est assez loin de 2x pour la même raison qu’AVX-512 avant : calcul trop simple…
Parallélisation simple
Il suffit d’un cargo add rayon
pour pouvoir faire ceci…
use rayon::prelude::*;
let [x, y] = [[2.4; 1024], [4.2; 1024]];
let dot = x.into_par_iter().zip(y)
.fold(|| 0.0,
|acc, (x, y)| x.mul_add(y, acc))
.sum();
…avec détection à la compilation des accès concurrents non protégés, bien sûr.
rayon
complète la bibliothèque standard avec des itérateurs parallèles.
Le fonctionnement est similaire à Intel TBB :
- Toute tâche parallélisable peut être coupée en deux parties ~égales.
- Avant de traiter une tâche, un thread la coupe en deux, met la moitié de côté.
- Un thread sans travail peut voler le travail laissé de côté par les autres threads.
Quand les threads sont assez “nourris”, l’exécution devient séquentielle.
Limites du tout-automatique
Malheureusement, rayon
a été optimisé sur des tâches plus complexes qu’un
produit scalaire.
Donc par défaut, il utilise tous les hyperthreads1 et descend à granularité trop fine :
On peut changer ça avec la variable d’environnement
RAYON_NUM_THREADS
ou un
ThreadPoolBuilder
.
Parallélisation optimisée
On configure facilement une granularité séquentielle avec
par_chunks()
…
let dot = x.par_chunks(chunk_size).zip(y.par_chunks(chunk_size))
.map(|(x_chunk, y_chunk)| dot_sequential(x_chunk, y_chunk))
.sum();
…avec à la clé un gain de performances ≤ 3x dans la région où paralléliser est rentable :
Parallélisation : Le futur
Rayon a néanmoins deux limitations importantes qui gagneraient à être éliminées :
- Le coût d’ordonnancement des tâches est assez élevé (~µs).
- Pas gênant tant qu’on parallélise à gros grain ex: traitement batch d’images.
- Coûteux sur des tâches simples comme
dot()
(cache L1 parcouru en ~100ns).
- L’ordonnanceur ne tient pas compte de la topologie NUMA/NUCA du système.
- Vol de travail entre noeuds NUMA = accès mémoire inefficaces. A minimiser !
- Aujourd’hui contourné avec le hack habituel (1 processus / noeud NUMA).
Sur mon temps libre, je travaille sur un prototype d’ordonnanceur plus optimal.
Intégrer cette R&D à rayon
sera bien plus simple que si c’était dans std
comme en C++ !
Tableaux N-d et algèbre linéaire
Au-delà du produit scalaire, on a vite fait le tour de ce qui est facile avec
std
seule.
Comme en C++, plusieurs bibliothèques plus avancées coexistent1 :
ndarray
fournit des tableaux N-d dynamiques + extensionslinalg
,stats
etrand
- Option la plus similaire à l’écosystème NumPy en Python.
- Bon choix par défaut (compromis ergonomie/flexibilité/perf).
nalgebra
se spécialise dans l’algèbre linéaire vecteur/matrice.- Permet de spécifier les dimensions à la compilation → allocation pile, inlining.
faer
est le nouveau challenger de l’algèbre linéaire.- Fort accent sur les performances, affiche des résultats intéressants.
- Ne supporte ni les dimensions à la compilation, ni les tenseurs actuellement.
- Diverses options spécialisées graphisme :
glam
,euclid
,cgmath
…
Signe évident pour l’expert qu’aucune ne répond pleinement aux besoins…
Exemple ndarray
Pour donner un aperçu de l’API ndarray, voici un produit de matrice basique :
use ndarray::prelude::*;
let x = array![[1.2, 3.4],
[5.6, 7.8],
[9.1, 2.3]];
let y = array![[0.0, 0.1, 0.2],
[1.0, 1.1, 1.2]];
let mut out = Array2::<f64>::zeros((3, 3));
// ndarray fournit sa propre API d'itération optimisée...
azip!((mut out_row in out.rows_mut(), x_row in x.rows()) {
// ...mais on peut aussi itérer de façon standard
for (out_elem, y_col) in out_row.iter_mut().zip(y.columns()) {
*out_elem = x_row.dot(&y_col);
}
});
Bien sûr, ceci n’est qu’une démo d’API, n’implémentez jamais matmul
comme
ça :
- Accès inefficace aux colonnes de y (éléments espacés)
- SIMD inexistant (conséquence du point précédent)
- Cache CPU mal utilisé sur les grandes matrices (pas de blocking)
- Exécution séquentielle sur un seul coeur CPU
- …alors qu’il suffit d’un
x.dot(&y)
pour confier tout ce travail à un BLAS !
Enfonçons le clou
Voici ce qui se passe quand vous implémentez un produit de matrice naïf :
Maintenant, parlons GPU
Voyons comment Rust se positionne par rapport à mon idéal1 du calcul GPU :
- Portabilité de l’appli vers un maximum de fabricants, GPUs et OSes.
- Environnement de dev & exécution faciles à installer et maintenir.
- Accès au support fabricant, y compris outils : débogueur, profileur…
- Facile d’écrire du code kernel GPU + plomberie CPU/GPU.
- Donne un accès complet au matériel, y compris subgroups, textures, tensor cores…
- Peu de duplication de code entre GPUs, hors optimisations spécifiques rares.
- Bonne interopérabilité avec la visualisation (le GPU est fait pour ça !).
Cet idéal n’inclut pas le partage de code kernel CPU/GPU/FPGA. Ca fait des années que ce désir produit des usines à gaz compliquées et non performantes, il me semble temps d’arrêter les frais.
Comme tous les idéaux, ses points-clés et leur importance relative sont personnels et sujets à débat.
Les premiers 90% de la solution…
Les APIs graphiques sont optimales sur la plupart des points : portabilité, dev/déploiement facile, support fabricant/outils, accès complet HW, partage de code inter-GPU, interop visualisation.
Il y a des nuances entre les différentes APIs portables qui orienteront le choix :
- OpenGL est préférable pour le support des anciens GPUs & OSes (< 2012).
- Vulkan résout de nombreux problèmes d’OpenGL
sur les GPUs récent. Côté support…
- Excellent sous Linux : Programmes qui tournent avec zéro travail d’installation.
- Bon sous Windows : Le pilote OS ne sera pas forcément OK, le pilote fabricant si.
- Bof sous macOS : Nécessite une couche d’émulation type MoltenVK.
- WebGPU1 améliore l’ergonomie et le
support Win/Mac/Web + ancien HW, mais à un prix :
- API jeune, donc change encore souvent + couverture incomplète des fonctionnalités HW.
- Couche d’abstraction → Les outils type débogueur, profileur… sont plus difficiles à utiliser.
Pour ces raisons, je recommande pour l’instant Vulkan (via vulkano) pour les applis Linux.
Bien qu’issue du monde du web, cette API est aussi utilisable en natif via des implémentations comme wgpu.
Et la facilité d’écriture ?
C’est le gros défaut de cette approche : plus bas niveau que CUDA, SYCL et cie.
Si je prends Vulkan, l’API la plus avancée, il faut d’abord au minimum1…
- Charger la bibliothèque Vulkan.
- Créer une instance d’API en spécifiant ses paramètres globaux (ex: logging).
- Choisir un ou plusieurs physical devices (ex : GPU intégré ou discret, émulation CPU).
- Créer pour chacun un contexte device et sa ou ses command queues (cf CUDA streams).
Puis pour me préparer à un calcul donné, je dois…
- Ecrire un shader en spécifiant l’interface avec l’hôte (buffers, images…).
- Compiler le shader, d’abord en SPIR-V puis en binaire adapté au device.
- Créer des ressources à attacher au shader : buffers, images & samplers…
- Créer un pipeline qui spécifie les aspects stables de l’interface shader-hôte.
- Grouper les ressources en descriptor sets adaptés au pipeline.
Et enfin, pour chaque batch de calculs que j’exécute, je dois…
- Construire un ou plusieurs command buffers qui mettent en place un pipeline, attachent les descriptor sets, programment des exécutions du shader, éventuellement à répétition…
- Les soumettre à la ou les command queues appropriées.
- Bien gérer la synchronisation entre ces jobs et avec l’hôte.
Quelques étapes en plus pour du rendu écran. Chaque étape est hautement configurable.
Analyse et perspectives
Ce caractère bas niveau a des avantages et inconvénients :
- Avantage : On a la main sur plus de choses, on peut optimiser davantage.
- Inconvénient : On doit jongler avec beaucoup de concepts, dur pour le débutant !
Pour le code hôte, on peut simplifier énormément les choses en combinant deux approches :
- Privilégier les bindings haut niveau (ex :
vulkano
) qui automatisent les aspects piégeux1. - Construire un squelette réutilisable, notamment pour l’initialisation.
Pour le code GPU (“kernels”), se pose aujourd’hui la question du langage.
- Historiquement, on était forcé d’utiliser un DSL dédié (GLSL, WGSL…)
- Très rapide à apprendre, plutôt ergonomique pour des choses simples.
- Mais pas de partage de code (notam. définitions types/fns) possible avec l’hôte !
- Aujourd’hui, on a l’alternative émergente
rust-gpu
- Les shaders sont en Rust → Partage de code avec l’hôte facile.
- Mais encore très jeune : pas d’opérations atomiques, de subgroups…
Pour l’instant, je recommande les DSLs plus matures. Mais rust-gpu
est
clairement la clé qui permettra l’arrivée d’APIs GPGPU plus haut niveau en Rust.
Cf prototype krnl
.
Ex : temps de vie des ressources, interfaces shaders/hôte, synchronisation…
Démo : Gray-Scott en live
Avec ces APIs, la visualisation temps réel est un ajout facile, il serait dommage de s’en priver.
Quelques autres succès de cette implémentation :
- Développé sur GPUs AMD, mais a fonctionné sur NVidia dès la 1ère exécution1.
- Meilleures perfs mesurées durant la première école Gray-Scott.
Ceux qui ont déjà dû utiliser d’autres approches de portabilité (OpenCL, SYCL…) comprendront l’exploit.
Et le calcul distribué ?
Malheureusement, loin d’être aussi mature que le calcul au sein d’un noeud.
Côté HPC classique, on a un binding MPI et quelques prototypes PGAS comme Lamellar.
Mais l’effort R&D actuel est plutôt centré sur des besoins big data, par exemple…
- Des data frames basés sur Apache Arrow : Ballista et son backend DataFusion…
- Arroyo qui cherche à voir jusqu’où on peut pousser la perf du SQL.
- Renoir et Timely Dataflow qui se basent sur un modèle d’itérateurs/flux.
Du coup, on peut se demander quelle est la bonne stratégie :
- Tout faire en MPI brut comme au bon vieux temps du Fortran 77 ?
- Développer davantage les frameworks HPC haut niveau à la HPX pour Rust ?
- Adapter les piles big data aux besoins du calcul scientifique ?
La réponse dépendra sans doute de votre projet…
Le reste du hibou
N’oublions pas qu’une vraie appli de calcul n’est pas que de l’arithmétique flottante.
On a aussi besoin de configuration, E/S disque & réseau, plots, doc, tests, benchmarks, binaires…
Passé sous silence dans les présentations d’autres langages… parce que c’est pénible ? 😬
Configuration
Les macros de dérivation simplifient grandement toutes les tâches de type (dé)sérialisation.
Exemple : La gestion clap
des arguments
CLI du plotteur utilisé pour cette présentation :
/// Simple bulk plotter from criterion data
#[derive(Debug, Parser)]
#[command(version, about)]
struct Args {
/// Path to root of Rust project where criterion data was acquired
#[arg(short, long, default_value = ".")]
input_path: Box<Path>,
/* ... Autres arguments ... */
/// Regex matching the traces to be plotted
regex: Regex,
}
//
fn main() -> Result<()> {
// Parse CLI arguments
let args = Args::parse();
En un Args::parse()
, nous avons expédié parsing, validation de typage,
génération du --help
…
…et nous récupérons une struct
pré-remplie avec notre configuration
utilisateur CLI.
Formats de données
On utilise aussi cette approche pour les entrées/sorties structurées :
csv
,
serde_json
…
/// Criterion estimates
#[derive(Debug, Deserialize)]
#[non_exhaustive]
pub struct Estimates {
/// Median execution time (ns)
pub median: Estimate,
}
/// Single criterion estimate
#[derive(Debug, Deserialize)]
pub struct Estimate {
/// Confidence interval
pub confidence_interval: ConfidenceInterval,
/// Point estimate
pub point_estimate: f32,
/// Standard error
pub standard_error: f32,
}
// ... autres définitions, puis au point d'utilisation ...
let estimates = serde_json::from_slice::<Estimates>(&json_bytes[..])?;
Pour des tableaux flottants N-d, on utilisera juste des types ndarray
, comme
hdf5
.
Gestion des erreurs simple
Pas d’entrées/sorties correctes sans une gestion des erreurs.
Dans une appli simple, il peut suffire de les propager en ajoutant des infos de contexte.
C’est facile à faire avec l’aide de
anyhow
ou
eyre
:
// Even the main function can return a Result
fn main() -> anyhow::Result<()> {
// Parse CLI arguments
let args = Args::parse();
// Load data points from Criterion
let data = criterion::read_all(&args).context("loading data from Criterion")?;
// Rearrange data in a layout suitable for plotting
let traces = Traces::new(data).context("rearranging data into plot traces")?;
// Abort if there is nothing to plot
if traces.is_empty() {
bail!("specified regex does not select any trace")
}
// Draw the plot
plot::draw(&args, traces).context("drawing the performance plot")
}
Gestion des erreurs avancée
Une bibliothèque définira plutôt ses propres types erreur, souvent avec
thiserror
:
use thiserror::Error;
/// Requested file path is not suitable for hwloc consumption
#[derive(Copy, Clone, Debug, Error, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PathError {
/// Path contains the NUL char, and is thus not compatible with C
#[error("hwloc file paths can't contain NUL chars")]
ContainsNul,
/// Path contains non-Unicode data
///
/// We need paths to be valid Unicode, even though most operating systems do
/// not mandate it, because that is a prerequisite for portably converting
/// paths to `char*` for C/hwloc consumption.
#[error("hwloc file paths can't contain non-Unicode data")]
NotUnicode,
}
Ca simplifie l’écriture d’applis avec une gestion d’erreurs plus fine, par exemple…
- Se remettre de certaines erreurs (ex : réessayer si timeout, demander une correction…)
- Adapter le message d’erreur à l’utilisateur (ex : traductions, enlever des infos sensibles…)
Plots
Besoin de visualiser des données ? C’est faisable sans s’infliger la lenteur et
l’UX de matplotlib
.
Pour des plots interactifs sur des pages web, il y a un binding vers le classique plotly :
use plotly::{Plot, Scatter};
let mut plot = Plot::new();
let trace = Scatter::new(vec![0, 1, 2], vec![2, 1, 0]);
plot.add_trace(trace);
plot.write_html("out.html");
Pour des images statiques, beaucoup d’options1 mais je conseille de commencer par plotters.
Démo : Notebooks et dataframes
Pour les fans de IPython, Jupyter et Pandas, il y a aussi
evcxr_repl
,
evcxr_jupyter
et polars
:
Crates.io
Vous l’aurez remarqué, je viens d’énumérer beaucoup de bibliothèques externes.
Sur l’axe de la hashtable C maison à
left-pad
, le
Rust idiomatique est plutôt côté NPM.
La raison : cargo
trivialise la gestion de dépendances tant qu’il n’y a
pas de C/++ impliqué.
$ cargo add plotly
Updating crates.io index
Adding plotly v0.8.4 to dependencies.
Features:
- getrandom
- image
- js-sys
- kaleido
- ndarray
- plotly_image
- plotly_kaleido
- plotly_ndarray
- wasm
- wasm-bindgen
- wasm-bindgen-futures
Updating crates.io index
Mais n’oubliez pas que ce grand pouvoir peut impliquer de grandes responsabilités1.
Maintien à jour des deps, changements d’API, arrêts de maintenance, supply chain attacks…
Documentation
Rustdoc est ce que Doxygen aurait dû être :
- Trivial à mettre en place dans son projet (
cargo doc
+ docs.rs) - Support langage irréprochable (intégré au compilateur)
- S’écarte beaucoup moins du Markdown usuel dans sa syntaxe
- Exemples testés automatiquement par
cargo test
Grâce à cet outil, ce style de docs est plus fréquent dans la communauté Rust :