All articles

[Veille Technique] Rust / Python interop

Amrltqt
··7 min read
[Veille Technique] Rust / Python interop

Je ne me suis pas entraîné depuis un moment. La tête dans le guidon, j'ai mis la veille technique de côté un peu trop longtemps à mon goût.


[Veille Technique] Rust / Python interop

Je ne me suis pas entraîné depuis un moment. La tête dans le guidon, j'ai mis la veille technique de côté un peu trop longtemps à mon goût.

En lisant le code de nanochat de Karpathy, je me suis rendu compte que j'étais un peu largué. Rien de mieux qu'aller se casser les dents sur projet pour mettre les idées dans le bon ordre.

Cet article va résumer une partie de mes sur l'interopérabilité Rust vers Python. Il y a 10 ans j'ai fait la même chose en C vers Python, c'était fun, voyons un peu comment les choses ont évolués depuis.

La méthode employée est un classique, un terminal, un éditeur et je fonce. Ici je vais essayer une autre approche, je vais vous dire tout ce qui s'est passé, plutôt que vous donner une recette toute faite.

Mise en place

Je vais faire des prédictions de séries temporelles. Je vais démarrer en python, gérer l'entraînement et les prédictions dans un bout de code rust et les servir en python.

L'environnement :

  • Python 3.13.7
  • uv 0.8.13
  • rustc 1.89.0

J'ai créé un dossier pyseries et initialisé un projet avec uv init et directement dans le repertoire j'ai créé un projet rust avec cargo new pyseries-rs.

Découverte de pyo3

Si j'en suis là, c'est parce qu'en lisant nanochat j'ai découvert pyo3 et je me suis dit que c'était génial. Karpathy l'utilise pour son tokenizer et en un coup d'oeil on distingue très bien comment sont décrit les modules, les fonctions etc. Donc on va l'essayer et voir ce que ça donne.

Si vous n'avez pas envie de lire cet article et d'aller vite, il suffit de lire la doc de pyo3. Je crois quand même que c'est bien de voir ce qui s'est passé de mon côté. Vous allez gagner de précieuses heures de mise en place.

Quand on fait un cargo new pyseries-rs, on crée un template pour un exécutable et pas une lib. Donc je vais ajouter les dépendances pour pyo3 et faire une lib.

En regardant la doc je découvre qu'on peut faire varier le crate-type pour définir une cdylib*.*Le nom est cryptique mais il signifie que c'est une bibliothèque de liens dynamique (les fameuses .dll sur windows ou .so sous linux).

text
[package]
name = "pyseries-rs"
version = "0.1.0"
edition = "2024"

[lib]
name = "pyseries-rs"
crate-type = ["cdylib"]

[dependencies]
pyo3 = { version = "0.26.0", features = ["extension-module"] }

Il y a des pièges ici, je vous en reparle plus loin.

Un peu de code maintenant

On va reprendre bêtement (deuxième piège) celui de la doc et faire un round de compilation.

Dans mon lib.rs je glisse l'exemple.

text
use pyo3::prelude::*;

#[pyfunction]
fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
    Ok((a + b).to_string())
}

#[pymodule]
fn string_sum(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_as_string, m)?)?;
    Ok(())
}

On reconnaît des choses intéressantes. D'abord les annotations pyfunction et pymodulesemblent indiquer qu'on décrit des artefacts python.

Si on regarde de plus près on voit que la fonction sum_as_string est classique, à part qu'elle retourne un type PyResult.

La deuxième fonction est un peu plus déroutante. Elle reçoit une référence de module en entrée et on lui ajoute la fonction annotée pyfunction. Il y a une macro wrap_pyfunction! au milieu qui doit cacher la complexité du binding.

Bonjour Maturin

En avançant dans la doc je retrouve maturin , que j'avais vu rapidement dans le pyproject.toml de nanochat. Je comprend maintenant que c'est un outil de build pour aider à la mise en oeuvre de la compilation, des liens et de toute la magie noire pour rendre python et rust interopérables.

On l'installe et j'initialise le projet

text
uv add maturin
uv run maturin init

Et c'est l'échec (on va en avoir quelques uns, c'est le but de l'article)

text
💥 maturin failed
  Caused by: `maturin init` cannot be run on existing projects

Notre projet existe déjà, si je redémarre un projet dans le futur je commence avec maturin. Je vais continuer en manuel en attendant.

Dans le pyproject.tomlje vais rajouter le build-system (inspiré de nanochat)

text
[build-system]
requires = ["maturin>=1.7,<2.0"]
build-backend = "maturin"

[tool.maturin]
module-name = "pyseries_rs"
bindings = "pyo3"
python-source = "."
manifest-path = "pyseries-rs/Cargo.toml"

Alors, plusieurs choses ici, car j'ai fais ça progressivement.

  • Si on ne met pas la configuration [tool.maturin] on va pas forcément bien cibler le Cargo.toml.
  • En python les modules n'ont pas de tiret, j'ai donc mis un pyseries_rs et c'est le début de la catastrophe. Vous allez voir.

Ma configuration au point je vais utiliser la commande uv run maturin develop . Au bout de quelques essais pour produire le pyproject.toml ci-dessus j'obtiens une compilation

text
Built pyseries @ file:///Users/quentin/code/pyseries
Installed 1 package in 1ms
🔗 Found pyo3 bindings
🐍 Found CPython 3.12 at /Users/quentin/code/pyseries/.venv/bin/python
📡 Using build options bindings from pyproject.toml
Audited 1 package in 1ms
   Compiling pyo3-build-config v0.26.0
   Compiling pyo3-ffi v0.26.0
   Compiling pyo3-macros-backend v0.26.0
   Compiling pyo3 v0.26.0
   Compiling pyo3-macros v0.26.0
   Compiling pyseries_rs v0.1.0 (/Users/quentin/code/pyseries/pyseries_rs)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.98s
⚠️  Warning: Couldn't find the symbol `PyInit_pyseries_rs` in the native library. Python will fail to import this module. If you're using pyo3, check that `#[pymodule]` uses `pyseries_rs` as module name
📦 Built wheel for CPython 3.12 to /var/folders/ld/mzw0q0c541q94y5hpzttqqhm0000gn/T/.tmpi8eC3a/pyseries-0.1.0-cp312-cp312-macosx_11_0_arm64.whl
✏️ Setting installed package as editable
🛠 Installed pyseries-0.1.0

Plein d'info ici:

  • J'ai du python 3.12 au lieu de 3.13, ça vient d'uv et de mes contraintes, tant mieux.
  • Le code rust à été compilé dans l'opération
  • Le package rust créé une wheel qui est immédiatement installée comme editable dans le projet.
  • Et : ⚠️ Warning: Couldn't find the symbol `PyInit_pyseries_rs` in the native library. Python will fail to import this module. If you're using pyo3, check that `#[pymodule]` uses `pyseries_rs` as module name

L'alerte est préoccupante, je comprend que le nom de la fonction annotée*#[pymodule]* est importante. Il me faut donc la renommer. J'ai aussi renommé tout côtéCargo.toml pour être 100% sur de pas avoir de problème avec les tiret.

text
#[pymodule]
fn pyseries_rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_as_string, m)?)?;
    Ok(())
}

On recompile avec uv run maturin develop. Plus d'alerte, comportement nominal.

Test final

On va simplement utiliser le REPL de l'installation python de l'environnement virtuel.

text
uv run python
>>> import pyseries_rs
>>> pyseries_rs.sum_as_string(1, 2)
'3'

Ça marche ! Maturin nous a fait une wheel python, l'a installé le module dans l'environnement virtuel d'uv. Si je relance uv run maturin develople module rust est mis à jour.

Conclusion

C'est génial, je ne m'attendais pas à ce que ce soit aussi peu contraignant de démarrer un projet comme ça. J'ai souvenir qu'il fallait faire plus d'effort pour obtenir un résultat similaire en C. Des efforts surtout liés au build et à la configuration de la librairie pour faire un wheel.

Au moment où j'écris, je n'ai pas entamé la suite, la librairie pour faire des prédictions. J'espère pouvoir proposer la suite rapidement.

GPT generated image

Stay in the loop

Get new articles delivered directly to your inbox. No spam, unsubscribe anytime.