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…


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

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 !

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.

Comparatif de méthodes de sommes

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 :

Comparatif de méthodes de produit scalaire

1

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 :

Résultats de la parallélisation tout-automatique

1

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 :

Résultats de la parallélisation tout-automatique

Parallélisation : Le futur

Rayon a néanmoins deux limitations importantes qui gagneraient à être éliminées :

  1. 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).
  2. 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 + extensions linalg, stats et rand
    • 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
1

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 :

Comparatif de méthodes de produit matriciel

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.

1

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.

1

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

  1. Charger la bibliothèque Vulkan.
  2. Créer une instance d’API en spécifiant ses paramètres globaux (ex: logging).
  3. Choisir un ou plusieurs physical devices (ex : GPU intégré ou discret, émulation CPU).
  4. 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…

  1. Ecrire un shader en spécifiant l’interface avec l’hôte (buffers, images…).
  2. Compiler le shader, d’abord en SPIR-V puis en binaire adapté au device.
  3. Créer des ressources à attacher au shader : buffers, images & samplers
  4. Créer un pipeline qui spécifie les aspects stables de l’interface shader-hôte.
  5. Grouper les ressources en descriptor sets adaptés au pipeline.

Et enfin, pour chaque batch de calculs que j’exécute, je dois…

  1. 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…
  2. Les soumettre à la ou les command queues appropriées.
  3. Bien gérer la synchronisation entre ces jobs et avec l’hôte.
1

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 :

  1. Privilégier les bindings haut niveau (ex : vulkano) qui automatisent les aspects piégeux1.
  2. 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.

1

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.

Capture d’écran d’une visualisation temps réel de la réaction de Gray Scott

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.
1

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.

Guide pratique pour dessiner un hibou en 2 étapes

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.

1

Entre autres egui_plot, poloto, lowcharts, charts

Démo : Notebooks et dataframes

Pour les fans de IPython, Jupyter et Pandas, il y a aussi evcxr_repl, evcxr_jupyter et polars :

Capture d’écran de notebook evcxr

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.

1

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 :