
Comment appeler Rust depuis Python
Python est assez rapide, surtout lorsque vous vous appuyez sur NumPy, Polars ou d’autres bibliothèques bien optimisées écrites dans des langages compilés comme C. Mais de temps en temps, vous vous retrouvez avec une boucle chaude qui ne se vectorise pas : peut-être que vous parcourez une liste de chaînes pour les nettoyer, ou que vous analysez un texte désordonné où chaque caractère compte. Vous le profilez, vous confirmez le coupable et vous regardez une boucle for qui consomme la moitié de votre temps d’exécution. C’est le moment où Rust brille.
Rust vous offre des performances prévisibles, un contrôle strict de la mémoire et une concurrence sans peur, sans les tracas de la gestion manuelle de la mémoire. Si vous pensez : pas un autre langage à apprendre !, la bonne nouvelle est que vous n’avez pas besoin d’abandonner Python pour utiliser Rust. Vous pouvez conserver votre orchestration, vos cahiers, vos tests et déplacer uniquement les minuscules et ennuyeuses boucles internes vers Rust. Cela maintient la courbe d’apprentissage de Rust à un minimum absolu.
Dans cet article, je vais montrer comment appeler Rust depuis Python et comparer les différences de performances entre l’exécution de Python pur et une combinaison Python/Rust. Ce ne sera pas un tutoriel sur la programmation Rust, car je suppose que vous en connaissez au moins les bases.
Pourquoi s’embêter ?
Maintenant, vous pourriez penser : si je connais Rust, pourquoi devrais-je même prendre la peine de l’intégrer à Python – juste un programme en Rust, n’est-ce pas ?
Eh bien, tout d’abord, je dirais que connaître Rust n’en fait pas automatiquement le meilleur langage pour l’ensemble de votre application. Pour de nombreux systèmes, par exemple le ML, l’IA, les scripts et les backends Web, etc., Python est déjà le langage de choix.
Deuxièmement, la plupart du code n’est pas critique en termes de performances. Pour les parties qui le sont, vous n’avez souvent besoin que d’un très petit sous-ensemble de Rust pour faire une réelle différence, donc un tout petit peu de connaissances sur Rust peut aller très loin.
Enfin, l’écosystème de Python est difficile à remplacer. Même si vous connaissez bien Rust, Python vous donne un accès immédiat à des outils comme :
- pandas
- NumPy
- scikit-apprendre
- Jupyter
- Flux d’air
- Outils FastAPI
- une énorme quantité de bibliothèques de scripts et d’automatisation
Rust est peut-être plus rapide, mais Python gagne souvent en termes de portée de l’écosystème et de commodité de développement.
J’espère que j’en ai fait assez pour vous convaincre de donner une chance à l’intégration de Rust avec Python. Ceci étant dit, commençons.
Rouille et Maturin
Pour nos cas d’utilisation, nous avons besoin de deux choses : Rust et un outil appelé maturin.
La plupart d’entre vous connaissent Rust. Un langage compilé rapidement qui s’est imposé ces dernières années. Vous n’en avez peut-être pas entendu parler maturité, cependant.
Maturin est essentiellement un outil de construction et de packaging pour les extensions Python écrites en Rust (en utilisant PyO3 ou rust-cpython). Cela nous aide à effectuer les opérations suivantes :
Construit votre code Rust dans un module Python
- Prend votre caisse Rust et la compile dans une bibliothèque partagée (.pyd sous Windows, .so sous Linux, .dylib sous macOS) que Python peut importer.
- Définit automatiquement les indicateurs de compilateur corrects pour la version/débogage et pour la version Python que vous ciblez.
- Fonctionne avec la fonctionnalité de module d’extension de PyO3, afin que Python puisse importer la bibliothèque compilée en tant que module normal.
Emballer des roues pour la distribution
- Les roues sont les fichiers .whl que vous téléchargez sur PyPI (binaires précompilés).
- Maturin prend en charge la construction de roues pour beaucoup de Linux, macOSet Fenêtres qui fonctionnent sur toutes les versions et plates-formes de Python.
- Il effectue une compilation croisée en cas de besoin ou s’exécute dans une image Docker pour satisfaire aux règles « manylinux » de PyPI.
Publie sur PyPI
- Avec une seule commande, Maturin peut créer votre extension Rust et la télécharger.
- Gère automatiquement les informations d’identification, les métadonnées et les balises de plate-forme.
Intègre Rust dans l’empaquetage Python
- Maturin génère un pyproject.toml qui définit votre projet afin que les outils Python comme pip sachent comment le construire.
- Prise en charge PEP 517, donc pip install fonctionne même si l’utilisateur n’a pas installé maturin.
- Fonctionne de manière transparente avec les outils de configuration lorsque vous mélangez du code Python et Rust dans un seul package.
OK, c’est assez de théorie, passons à l’écriture, à l’exécution et au timing de quelques exemples de code.
Mise en place d’un environnement de développement
Comme d’habitude, nous mettrons en place un environnement de développement distinct pour effectuer notre travail. De cette façon, notre travail n’interférera pas avec d’autres projets que nous pourrions avoir en cours. J’utilise l’outil UV pour cela et j’utilise WSL2 Ubuntu pour Windows comme système d’exploitation.
$ uv init pyrust
$ cd pyrust
$ uv venv pyrust
$ source pyrust/bin/activate
(pyrust) $
Installer la rouille
Nous pouvons maintenant installer Rust avec cette simple commande.
(pyrust) $ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Finalement, 3 options s’afficheront sur votre écran comme ceci.
Welcome to Rust!
This will download and install the official compiler for the Rust
programming language, and its package manager, Cargo.
...
...
...
1) Proceed with standard installation (default - just press enter)
2) Customize installation
3) Cancel installation
Appuyez sur 1, puis appuyez sur Entrée lorsque vous êtes invité à indiquer les options d’installation si vous souhaitez utiliser les options par défaut. Pour vous assurer que Rust est correctement installé, exécutez la commande suivante.
(pyrust) $ rustc --version
rustc 1.89.0 (29483883e 2025-08-04)
Exemple 1 — Un équivalent Hello World
Commençons par un exemple simple d’appel de Rust depuis Python. Créez un nouveau sous-dossier et ajoutez ces trois fichiers.
Cargo.toml
[package]
name = "hello_rust"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.25", features = ["extension-module"] }
pyproject.toml
[build-system]
requires = ["maturin>=1.5,<2"]
build-backend = "maturin"
[project]
name = "hello_rust"
version = "0.1.0"
requires-python = ">=3.9"
Enfin, notre fichier source Rust va dans le sous-dossier src/lib.rs
use pyo3::prelude::*;
/// A simple function we’ll expose to Python
#[pyfunction]
fn greet(name: &str) -> PyResult<String> {
Ok(format!("Hello, {} from Rust!", name))
}
/// The module definition
#[pymodule]
fn hello_rust(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(greet, m)?)?;
Ok(())
}
Maintenant, exécutez-le avec…
(pyrust) $ python -c "import hello_rust as hr; print(hr.greet('world'))"
# Output
Hello, world from Rust!
Nous mettons notre code Rust dans src/lib.rs pour suivre la convention selon laquelle le code de la bibliothèque Rust y va, plutôt que dans src/main.rs, qui est réservé au code exécutable Rust autonome.
Maturin + PyO3 recherche dans src/lib.rs le #[pymodule] fonction, qui enregistre vos fonctions Rust pour que Python puisse les appeler.
Exemple 2 — Boucles Python vs boucles Rust
Considérez quelque chose de délibérément banal mais représentatif : vous avez une liste de phrases et devez les normaliser. Par normaliser, j’entends les convertir en une forme standard et cohérente avant un traitement ultérieur.
Supposons que nous voulions tout mettre en minuscules, supprimer la ponctuation et diviser en jetons. Ceci est difficile à vectoriser efficacement car la logique se ramifie sur chaque caractère.
En Python pur, vous pourriez écrire ceci : –
# ------------------------
# Python baseline
# ------------------------
def process_one_py(text: str) -> list[str]:
word = []
out = []
for c in text:
if c.isalnum():
word.append(c.lower())
else:
if word:
out.append("".join(word))
word = []
if word:
out.append("".join(word))
return out
# Run the above for many inputs
def batch_process_py(texts: list[str]) -> list[list[str]]:
return [process_one_py
Ainsi, par exemple,
(pyrust) $ batch_process_py["Hello, World! 123", "This is a test"]
Je reviendrais,
[['hello', 'world', '123'], ['this', 'is', 'a', 'test']]
Voici à quoi pourrait ressembler l'équivalent de Rust,
/// src/lib.rs
use pyo3::prelude::*;
use pyo3::wrap_pyfunction;
/// Process one string: lowercase + drop punctuation + split on whitespace
fn process_one(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut word = String::new();
for c in text.chars() {
if c.is_alphanumeric() {
word.push(c.to_ascii_lowercase());
} else if c.is_whitespace() {
if !word.is_empty() {
out.push(std::mem::take(&mut word));
}
}
// ignore punctuation entirely
}
if !word.is_empty() {
out.push(word);
}
out
}
#[pyfunction]
fn batch_process(texts: Vec<String>) -> PyResult<Vec<Vec<String>>> {
Ok(texts.iter().map(|t| process_one
#[pymodule]
fn rust_text(_py: Python<'_>, m: &Bound<PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(batch_process, m)?)?;
Ok(())
}
Ok, exécutons ces deux programmes avec une entrée importante (500 000 textes) et voyons quelles sont les différences d'exécution. Pour cela, j'ai écrit un script Python de référence comme suit.
from time import perf_counter
from statistics import median
import random
import string
import rust_text # the compiled extension
# ------------------------
# Python baseline
# ------------------------
def process_one_py(text: str) -> list[str]:
word = []
out = []
for c in text:
if c.isalnum():
word.append(c.lower())
elif c.isspace():
if word:
out.append("".join(word))
word = []
# ignore punctuation
if word:
out.append("".join(word))
return out
def batch_process_py(texts: list[str]) -> list[list[str]]:
return [process_one_py
# ------------------------
# Synthetic data
# ------------------------
def make_texts(n=500_000, vocab=10_000, mean_len=40):
words = ["".join(random.choices(string.ascii_lowercase, k=5)) for _ in range(vocab)]
texts = []
for _ in range(n):
L = max(3, int(random.expovariate(1/mean_len)))
texts.append(" ".join(random.choice(words) for _ in range(L)))
return texts
texts = make_texts()
# ------------------------
# Timing helper
# ------------------------
def timeit(fn, *args, repeat=5):
runs = []
for _ in range(repeat):
t0 = perf_counter()
fn(*args)
t1 = perf_counter()
runs.append(t1 - t0)
return median(runs)
# ------------------------
# Run benchmarks
# ------------------------
py_time = timeit(batch_process_py, texts)
rust_time = timeit(rust_text.batch_process, texts)
n = len(texts)
print("\n--- Benchmark ---")
print(f"Python median: {py_time:.3f} s | throughput: {n/py_time:,.0f} texts/s")
print(f"Rust 1-thread median: {rust_time:.3f} s | throughput: {n/rust_time:,.0f} texts/s")
Comme auparavant, nous devons compiler notre code Rust pour que Python puisse l'importer. Dans l'exemple précédent, maturin a été utilisé indirectement comme backend de construction via pyproject.toml. Ici, nous l'appelons directement depuis la ligne de commande :
(pyrust) $ maturin develop --release
Et maintenant, nous pouvons simplement exécuter notre code de référence comme ceci.
(pyrust) $ python benchmark.py
--- Benchmark ---
Python median: 5.159 s | throughput: 96,919 texts/s
Rust 1-thread median: 3.024 s | throughput: 165,343 texts/s
C'était une accélération raisonnable sans trop d'effort. Il y a une autre chose que nous pouvons utiliser pour obtenir des diminutions encore plus importantes du temps d'exécution.
Rust a accès à une bibliothèque de parallélisation appelée Rayonne, ce qui facilite la diffusion du code sur plusieurs cœurs de processeur. En un mot, Rayonne…
- Remplaçons les itérateurs séquentiels (iter()) avec des itérateurs parallèles (par_iter()).
- Divise automatiquement vos données en morceaux, répartit le travail entre les threads du processeur, puis fusionne les résultats.
- Supprime la complexité de la gestion et de la synchronisation des threads
Exemple 3 — Ajout de parallélisme à notre code Rust existant
C'est simple. Si nous regardons le code Rust de l'exemple précédent, il nous suffit d'apporter les trois modifications mineures suivantes (marquées par des commentaires ci-dessous).
/// src/lib.rs
use pyo3::prelude::*;
use pyo3::wrap_pyfunction;
/// Add this line - Change 1
use rayon::prelude::*;
/// Process one string: lowercase + drop punctuation + split on whitespace
fn process_one(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut word = String::new();
for c in text.chars() {
if c.is_alphanumeric() {
word.push(c.to_ascii_lowercase());
} else if c.is_whitespace() {
if !word.is_empty() {
out.push(std::mem::take(&mut word));
}
}
// ignore punctuation entirely
}
if !word.is_empty() {
out.push(word);
}
out
}
#[pyfunction]
fn batch_process(texts: Vec<String>) -> PyResult<Vec<Vec<String>>> {
Ok(texts.iter().map(|t| process_one
}
/// Add this function - change 2
#[pyfunction]
fn batch_process_parallel(texts: Vec<String>) -> PyResult<Vec<Vec<String>>> {
Ok(texts.par_iter().map(|t| process_one
}
#[pymodule]
fn rust_text(_py: Python<'_>, m: &Bound<PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(batch_process, m)?)?;
// Add this line - change 3
m.add_function(wrap_pyfunction!(batch_process_parallel, m)?)?;
Ok(())
}
Dans notre code Python de référence, il nous suffit d'ajouter un appel au code Rust parallèle et d'imprimer les nouveaux résultats.
...
...
# ------------------------
# Run amended benchmarks
# ------------------------
py_time = timeit(batch_process_py, texts)
rust_time = timeit(rust_text.batch_process, texts)
rust_par_time = timeit(rust_text.batch_process_parallel, texts)
n = len(texts)
print("\n--- Benchmark ---")
print(f"Python median: {py_time:.3f} s | throughput: {n/py_time:,.0f} texts/s")
print(f"Rust 1-thread median: {rust_time:.3f} s | throughput: {n/rust_time:,.0f} texts/s")
print(f"Rust Rayon median: {rust_par_time:.3f} s | throughput: {n/rust_par_time:,.0f} texts/s")
Voici les résultats de l’exécution du benchmark modifié.
--- Benchmark ---
Python median: 5.171 s | throughput: 96,694 texts/s
Rust 1-thread median: 3.091 s | throughput: 161,755 texts/s
Rust Rayon median: 2.223 s | throughput: 224,914 texts/s
Le code Rust parallélisé a réduit d'environ 27 % le temps Rust non parallélisé et était plus de deux fois plus rapide que le code Python nu. Pas trop mal.
Résumé
Python est généralement assez rapide pour la plupart des tâches. Mais si le profilage montre un point lent qui ne peut pas être vectorisé et affecte réellement votre temps d'exécution, vous n'êtes pas obligé d'abandonner Python ou de réécrire l'intégralité de votre projet. Au lieu de cela, vous pouvez déplacer uniquement les parties critiques pour les performances vers Rust et laisser le reste de votre code tel quel.
Avec PyO3 et maturin, vous pouvez compiler du code Rust dans un module Python qui fonctionne parfaitement avec vos bibliothèques existantes. Cela vous permet de conserver la plupart de votre code Python, de vos tests, de votre packaging et de vos flux de travail, tout en bénéficiant des avantages de vitesse, de sécurité de la mémoire et de concurrence de Rust là où vous en avez le plus besoin.
Les exemples simples et les benchmarks présentés ici montrent que la réécriture d'une petite partie seulement de votre code dans Rust peut rendre Python beaucoup plus rapide. L'ajout de Rayon pour le parallélisme améliore encore plus les performances, avec seulement quelques modifications de code et aucun outil compliqué. Il s'agit d'un moyen pratique et simple d'accélérer les charges de travail Python sans basculer l'ensemble de votre projet vers Rust.



