Maturin : Booster Python avec du Rust
Posté le 01/09/2025 dans Rust

La plupart des librairies Python actuelles qui sont à la mode comme pydantic, ruff ou uv sont écrites en grande partie en Rust pour des raisons de performance. Mais comment ont-ils géré cette intégration ?
La solution se situe dans un outil ultra pratique appelé Maturin qui permet de créer des extensions Python en Rust de manière très simple. L'idée derrière maturin est de compiler le code Rust en librairie Python directement utilisable.
Un peu comme CPython qui fait du binding entre le code C et Python, PyO3 (une dépendance de maturin) fait du binding entre Rust et Python.
Création d'un projet Python
Tu peux commencer par créer un projet Python classique avec un environnement virtuel.
On va utiliser uv qu'on a vu sur l'article précédent.
uv init myrustapp -p 3.13
cd myrustapp
uv venv -p 3.13
source .venv/bin/activate
Installation de maturin
On va maintenant installer maturin dans notre environnement virtuel.
uv add maturin
Et c'est tout !
Création du projet Rust
Pour créer le projet Rust, on va utiliser maturin init qui va nous créer notre librairie Rust avec tout ce qu'il faut dans le Cargo.toml pour faire le lien avec Python.
maturin init --bindings pyo3 myrustlib
cd myrustlib
maturin develop
Avec maturin develop, on compile la librairie et on l'installe directement dans notre environnement virtuel.
Une fois le développement terminé, il vaut mieux utiliser maturin develop -r pour créer la librairie en mode release pour de meilleures performances.
Il est également possible de builder et de déployer sur PyPI la librairie avec maturin build -r et maturin publish.
Le code Rust
Ok maintenant qu'on a notre workflow opérationnel, on va jeter un oeil au code Rust dans src/lib.rs.
use pyo3::prelude::*;
/// Formats the sum of two numbers as string.
#[pyfunction]
fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
Ok((a + b).to_string())
}
/// A Python module implemented in Rust.
#[pymodule]
fn myrustlib(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(sum_as_string, m)?)
Ok(())
}
On va rapidement détailler le code afin que tu puisses le modifier et l'étendre.
Les deux macros #[pyfunction] et #[pymodule] sont fournies par PyO3 pour faire le lien entre Rust et Python.
Pour toutes les fonctions que tu veux exposer à Python, tu utiliseras #[pyfunction]. Si tes paramètres de fonctions sont des types natifs Python (int, str, list, dict...) tu n'as rien de plus à faire.
Il te suffit alors d'ajouter les fonctions au module Python via m.add_function dans la macro #[pymodule].
Si tu veux utiliser des types Rust plus complexes, tu peux utiliser les types fournis par PyO3 comme PyList ou PyDict.
Et voilà, tu peux maintenant utiliser n'importe quel code Rust dans ton projet Python. N'oublie pas de recompiler avec maturin develop -r à chaque modification.
Accélérer NumPy
Pour le ML, tu peux utiliser rust-numpy qui fait le lien entre les tableaux NumPy et les tableaux Rust.
use numpy::ndarray::{ArrayD, ArrayViewD, ArrayViewMutD};
use numpy::{IntoPyArray, PyArrayDyn, PyReadonlyArrayDyn, PyArrayMethods};
use pyo3::{pymodule, types::PyModule, PyResult, Python, Bound};
#[pymodule]
fn rust_ext<'py>(_py: Python<'py>, m: &Bound<'py, PyModule>) -> PyResult<()> {
// example using immutable borrows producing a new array
fn axpy(a: f64, x: ArrayViewD<'_, f64>, y: ArrayViewD<'_, f64>) -> ArrayD<f64> {
a * &x + &y
}
// example using a mutable borrow to modify an array in-place
fn mult(a: f64, mut x: ArrayViewMutD<'_, f64>) {
x *= a;
}
// wrapper of `axpy`
#[pyfn(m)]
#[pyo3(name = "axpy")]
fn axpy_py<'py>(
py: Python<'py>,
a: f64,
x: PyReadonlyArrayDyn<'py, f64>,
y: PyReadonlyArrayDyn<'py, f64>,
) -> Bound<'py, PyArrayDyn<f64>> {
let x = x.as_array();
let y = y.as_array();
let z = axpy(a, x, y);
z.into_pyarray(py)
}
// wrapper of `mult`
#[pyfn(m)]
#[pyo3(name = "mult")]
fn mult_py<'py>(a: f64, x: &Bound<'py, PyArrayDyn<f64>>) {
let x = unsafe { x.as_array_mut() };
mult(a, x);
}
Ok(())
}
Dans la plupart des cas, NumPy est suffisamment performant. Mais tu n'es jamais à l'abri de tomber sur un cas critique où une petite partie de l'application nécessite un boost de performance.
Avec maturin, tu t'évites de devoir réécrire tout le projet en Rust pour te concentrer uniquement sur la partie concernée.
Quand utiliser Rust dans Python ?
Dans la plupart des cas classiques, tu n'auras pas besoin de coder en Rust.
Mais dès que tu vas devoir manipuler des milliers ou millions de données, et que tu te rends compte que Pandas ne suffit plus niveau vitesse, tu pourras coder la partie critique en Rust.
Typiquement, toute opération de filtrage, de transformation, d'agrégation, ou de calcul sur des milliers de données pourra être profondément accélérée. Tu vas passer de plusieurs secondes à quelques millisecondes.
Par exemple, je l'ai utilisé pour un projet personnel pour analyser et compter le nombre de mots clés dans des milliers de notes en excluant des stop words.
Le module mod stop_words ne contient qu'une liste de stop words dans différentes langues du type :
pub const STOP_WORDS: &[&str] = &["le", "la", "les", "un", "une", "de", "des", "et", "à", "en"];
Et notre fichier lib.rs complet est le suivant :
use pyo3::prelude::*;
use regex::Regex;
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
mod stop_words;
#[derive(Deserialize, Debug)]
struct Note {
id: String,
content: String,
project: String,
}
#[pyfunction]
fn analyze_notes_content(notes_json: &str) -> PyResult<String> {
let notes: Vec<Note> = serde_json::from_str(notes_json).map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("Failed to parse JSON: {}", e))
})?;
let mut total_words = 0;
let mut unique_projects = HashSet::new();
for note in ¬es {
total_words += note.content.split_whitespace().count();
if !note.project.is_empty() {
unique_projects.insert(¬e.project);
}
let _ = ¬e.id;
}
let analysis_result = format!(
"Analyzed {} notes. Total word count: {}. Found {} unique projects.",
notes.len(),
total_words,
unique_projects.len()
);
Ok(analysis_result)
}
#[pyfunction]
fn extract_keywords(notes_json: &str, top_n: usize) -> PyResult<String> {
let notes: Vec<Note> = serde_json::from_str(notes_json).map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("Failed to parse JSON: {}", e))
})?;
let mut word_counts: HashMap<String, usize> = HashMap::new();
let re = Regex::new(r"[^a-zA-ZÀ-ÿ\s]").map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("Failed to compile regex: {}", e))
})?;
let stop_words_set: HashSet<&str> = stop_words::STOP_WORDS.iter().cloned().collect();
for note in notes {
let lowercased_content = note.content.to_lowercase();
let contraction_re = Regex::new(r"\b[dlcjntsqu]'").unwrap();
let without_contractions = contraction_re.replace_all(&lowercased_content, "");
let cleaned_content = re.replace_all(&without_contractions, "");
for word in cleaned_content.split_whitespace() {
if !stop_words_set.contains(word) && word.len() > 1 {
// Ignore single-character words
*word_counts.entry(word.to_string()).or_insert(0) += 1;
}
}
}
let mut sorted_keywords: Vec<(&String, &usize)> = word_counts.iter().collect();
sorted_keywords.sort_by(|a, b| b.1.cmp(a.1)); // Sort by count, descending
let top_keywords: Vec<String> = sorted_keywords
.into_iter()
.take(top_n)
.map(|(word, count)| format!("\"{}\": {}", word, count))
.collect();
Ok(format!("{{{}}}", top_keywords.join(", ")))
}
#[pymodule]
fn notia_analyzer(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(analyze_notes_content, m)?)?;
m.add_function(wrap_pyfunction!(extract_keywords, m)?)?;
Ok(())
}
L'important ici c'est que la structure Note est commune entre Python et Rust et désérialisée avec serde.
Côté Python, j'ai juste alors récupéré mes notes au format JSON et appelé les deux fonctions de cette manière :
import json
from notia_analyzer import analyze_notes_content, extract_keywords
with open("notes.json", "r") as f:
notes_json = f.read()
analysis = analyze_notes_content(notes_json)
print(analysis)
top_keywords = extract_keywords(notes_json, top_n=10)
print(top_keywords)
À garder dans un coin de sa tête pour les projets futurs !