Skip to content

Latest commit

 

History

History
573 lines (402 loc) · 23.5 KB

File metadata and controls

573 lines (402 loc) · 23.5 KB

🔝 Retour au Sommaire

43.2.4 — nanobind : Alternative moderne et plus rapide

Module 15 : Interopérabilité · Niveau Expert


Introduction

nanobind est le successeur spirituel de pybind11, développé par le même auteur — Wenzel Jakob. Là où pybind11 a été conçu en 2015 comme un remplacement léger de Boost.Python compatible C++11, nanobind part d'une feuille blanche en 2022 avec un objectif différent : minimiser le coût du binding lui-même — taille des binaires, temps de compilation, overhead à l'exécution — en tirant parti du C++17 et des évolutions de CPython 3.12+.

Le constat de départ est que pybind11, malgré sa simplicité d'utilisation, génère des binaires volumineux et des temps de compilation élevés pour les projets à grande échelle. Chaque .def(...) instancie des templates complexes, et le mécanisme de type casting interne duplique du code à travers les unités de compilation. nanobind repense ces mécanismes pour produire un résultat plus compact et plus rapide, au prix de quelques contraintes supplémentaires.

En 2026, nanobind a atteint une maturité suffisante pour être le choix recommandé pour les nouveaux projets ciblant C++17 ou ultérieur et Python 3.8+. pybind11 reste pertinent pour les bases de code existantes et les projets qui doivent supporter des environnements plus anciens.


43.2.4.1 — Pourquoi nanobind existe

Les limites de pybind11 à grande échelle

pybind11 fonctionne remarquablement bien pour des projets de taille modeste — quelques dizaines de classes, quelques centaines de méthodes. Mais à l'échelle de projets industriels (milliers de classes, dizaines de milliers de méthodes), plusieurs problèmes apparaissent.

Taille des binaires. Chaque binding instancie des templates de conversion de types, de gestion d'exceptions, et de dispatch d'arguments. Ces instanciations sont dupliquées dans chaque unité de compilation. Un projet avec 200 fichiers de binding peut produire un module .so de plusieurs dizaines de mégaoctets, dont l'essentiel est du code de plomberie pybind11 — pas du code métier.

Temps de compilation. Les headers pybind11 sont lourds en templates. Chaque fichier de binding inclut une quantité importante de code template qui doit être parsé, instancié et optimisé par le compilateur. Sur un projet volumineux, la compilation des bindings peut représenter une fraction significative du temps de build total.

Overhead à l'exécution. Le mécanisme de dispatch des arguments et de conversion des types de pybind11 effectue plusieurs allocations dynamiques et recherches de type à chaque appel de fonction. Pour des fonctions appelées très fréquemment, cet overhead est mesurable.

L'approche nanobind

nanobind attaque ces trois problèmes simultanément :

  • Code partagé au lieu de code instancié. Les mécanismes de conversion et de dispatch sont implémentés dans une petite bibliothèque compilée (libnanobind) plutôt que dans des templates instanciés dans chaque fichier de binding. Le code de plomberie est compilé une seule fois.
  • C++17 obligatoire. En exigeant C++17, nanobind utilise if constexpr, les fold expressions, std::string_view et d'autres fonctionnalités qui réduisent la complexité des templates et permettent au compilateur de générer du code plus compact.
  • Intégration CPython plus directe. nanobind exploite des APIs CPython internes (comme le protocole vectorcall de Python 3.8+) pour réduire l'overhead des appels de fonctions.

43.2.4.2 — Gains mesurables

Comparaison quantitative

Les chiffres suivants sont issus de la documentation officielle de nanobind et de benchmarks publiés. Ils comparent le même binding (la bibliothèque de test pybind11) compilé avec pybind11 et nanobind :

Métrique pybind11 nanobind Facteur
Taille du binaire (.so) ~14 Mo ~1.2 Mo ~12× plus petit
Temps de compilation ~12 s ~3 s ~4× plus rapide
Overhead appel de fonction ~90 ns ~30 ns ~3× plus rapide
Headers parsés par fichier ~470K lignes ~100K lignes ~5× moins

Ces gains ne sont pas marginaux — ils changent la donne pour les projets à grande échelle. Un module qui prend 5 minutes à compiler avec pybind11 peut passer sous la minute avec nanobind. Un binaire de 50 Mo peut descendre à 5 Mo.

Pourquoi la taille du binaire compte

Un binaire plus petit signifie :

  • Des images Docker plus légères (critique pour le déploiement cloud).
  • Un chargement du module plus rapide (import en Python).
  • Une empreinte mémoire réduite (le code est mappé en mémoire).
  • Des temps de CI/CD réduits (moins de données à transférer et stocker).

43.2.4.3 — Différences sémantiques avec pybind11

nanobind n'est pas un drop-in replacement de pybind11. Bien que l'API soit très similaire en surface, plusieurs choix de design divergent. Ces différences sont intentionnelles — elles reflètent des leçons apprises après des années d'utilisation de pybind11.

Sémantique de propriété : move par défaut

pybind11 : par défaut, quand un objet C++ est passé à Python, il est copié. La copie vit indépendamment en Python.

nanobind : par défaut, l'objet est déplacé (move semantics). Si le move n'est pas possible, une copie est effectuée. Ce choix favorise la performance et reflète mieux les pratiques du C++ moderne.

Conséquence pratique : en nanobind, passer un objet C++ à Python peut invalider l'objet d'origine (état moved-from). Si le code s'appuie sur le fait que l'objet d'origine reste intact après le binding, il faudra ajuster la logique ou forcer une copie explicite.

Pas de conversion automatique implicite des conteneurs STL

pybind11 : avec #include <pybind11/stl.h>, tous les conteneurs STL (vector, map, etc.) sont automatiquement convertis (par copie) dans les deux sens.

nanobind : les conversions implicites de conteneurs existent mais doivent être activées explicitement, et nanobind favorise les bindings opaques par défaut. La philosophie est que la copie silencieuse d'un conteneur à chaque franchissement de frontière est un piège de performance que la bibliothèque ne devrait pas encourager.

// nanobind — binding opaque d'un vector (recommandé)
nb::bind_vector<std::vector<double>>(m, "DoubleVector");

Les conversions implicites sont toujours disponibles via #include <nanobind/stl/vector.h>, mais le développeur doit opter consciemment pour chaque type de conteneur.

Holder type par défaut

pybind11 : le holder par défaut est std::unique_ptr<T>, déclarable dans py::class_<T, std::unique_ptr<T>>.

nanobind : il n'y a pas de holder type par défaut au sens de pybind11. nanobind utilise un système d'introspection interne plus léger. Les std::unique_ptr et std::shared_ptr restent supportés, mais la déclaration est légèrement différente :

// pybind11
py::class_<Engine, std::shared_ptr<Engine>>(m, "Engine");

// nanobind
nb::class_<Engine>(m, "Engine");
// shared_ptr est géré automatiquement si détecté dans les signatures

Gestion des exceptions

nanobind adopte une approche plus légère pour les exceptions. Les exceptions C++ standard sont toujours converties en exceptions Python, mais le mécanisme est plus direct et produit moins de code généré. Les register_exception_translator personnalisés fonctionnent de manière similaire.

GIL (Global Interpreter Lock)

pybind11 : le GIL est maintenu par défaut pendant les appels C++. Il faut explicitement le relâcher avec py::gil_scoped_release.

nanobind : même comportement par défaut, mais nanobind fournit des annotations plus explicites et encourage davantage la libération du GIL pour les opérations longues :

// nanobind — relâcher le GIL
m.def("heavy_compute", &heavy_compute, nb::call_guard<nb::gil_scoped_release>());

43.2.4.4 — Installation et configuration CMake

Installation via pip

pip install nanobind

Installation via FetchContent (recommandée)

cmake_minimum_required(VERSION 3.24)  
project(myproject LANGUAGES CXX)  

include(FetchContent)  
FetchContent_Declare(  
    nanobind
    GIT_REPOSITORY https://github.com/wjakob/nanobind.git
    GIT_TAG        v2.6.1   # Vérifier la dernière version stable
    GIT_SHALLOW    TRUE
)
FetchContent_MakeAvailable(nanobind)

find_package(Python3 REQUIRED COMPONENTS Interpreter Development.Module)

# Module Python
nanobind_add_module(mymodule
    python/bindings.cpp
)
target_link_libraries(mymodule PRIVATE mylib)  
target_compile_features(mymodule PRIVATE cxx_std_17)  # C++17 minimum  

La fonction nanobind_add_module joue le même rôle que pybind11_add_module : elle configure le type de target, les suffixes, les flags de visibilité et les includes.

Différence structurelle : la bibliothèque compilée

Contrairement à pybind11 (header-only), nanobind inclut une petite bibliothèque compilée (libnanobind). nanobind_add_module gère automatiquement sa compilation et son linkage. C'est cette bibliothèque qui contient le code de dispatch et de conversion partagé — c'est elle qui explique la réduction de taille des binaires.


43.2.4.5 — Syntaxe comparée : pybind11 vs nanobind

Includes et namespace

// pybind11                              // nanobind
#include <pybind11/pybind11.h>           #include <nanobind/nanobind.h>
#include <pybind11/stl.h>               #include <nanobind/stl/string.h>
                                         #include <nanobind/stl/vector.h>
namespace py = pybind11;                 namespace nb = nanobind;  
using namespace pybind11::literals;      using namespace nb::literals;  

Différence notable : nanobind utilise des includes granulaires par type (stl/string.h, stl/vector.h) au lieu d'un include global (stl.h). Cela réduit le nombre de templates instanciés et accélère la compilation.

Module

// pybind11                              // nanobind
PYBIND11_MODULE(mymod, m) {              NB_MODULE(mymod, m) {
    // ...                                   // ...
}                                        }

Classe et constructeur

// pybind11
py::class_<Sensor>(m, "Sensor")
    .def(py::init<std::string>(), py::arg("id"))
    .def("value", &Sensor::value);

// nanobind
nb::class_<Sensor>(m, "Sensor")
    .def(nb::init<std::string>(), nb::arg("id"))
    .def("value", &Sensor::value);

La syntaxe est quasi identique — seul le namespace change.

Propriétés

// pybind11
.def_property_readonly("name", &Sensor::name)
.def_readwrite("x", &Point::x)

// nanobind — identique
.def_prop_ro("name", &Sensor::name)       // Nom abrégé
.def_rw("x", &Point::x)                   // Nom abrégé

nanobind utilise des noms abrégés (def_prop_ro au lieu de def_property_readonly, def_rw au lieu de def_readwrite). Les noms longs sont aussi acceptés pour compatibilité, mais la documentation officielle utilise les formes courtes.

Valeurs par défaut

// pybind11
m.def("func", &func, py::arg("x"), py::arg("y") = 0);

// nanobind — identique
m.def("func", &func, nb::arg("x"), nb::arg("y") = 0);

Surcharge

// pybind11
m.def("compute", py::overload_cast<double>(&compute));

// nanobind
m.def("compute", nb::overload_cast<double>(&compute));

Return value policies

// pybind11
.def("get_data", &Obj::get_data, py::return_value_policy::reference_internal)

// nanobind
.def("get_data", &Obj::get_data, nb::rv_policy::reference_internal)

nanobind utilise nb::rv_policy au lieu de py::return_value_policy — plus concis, même sémantique. Les policies disponibles sont les mêmes : copy, move, reference, reference_internal, take_ownership, automatic, plus none et automatic_reference spécifiques à nanobind.

Keep-alive

// pybind11
.def("__iter__", ..., py::keep_alive<0, 1>())

// nanobind
.def("__iter__", ..., nb::keep_alive<0, 1>())

Héritage et trampoline

// pybind11
class PyShape : public Shape {
    using Shape::Shape;
    double area() const override {
        PYBIND11_OVERRIDE_PURE(double, Shape, area);
    }
};

py::class_<Shape, PyShape>(m, "Shape");

// nanobind
struct PyShape : Shape {
    NB_TRAMPOLINE(Shape, 2);  // 2 = nombre de méthodes virtuelles overridées

    double area() const override {
        NB_OVERRIDE_PURE(area);  // Plus concis — types déduits automatiquement
    }
};

nb::class_<Shape, PyShape>(m, "Shape");

La macro NB_TRAMPOLINE prend le nombre de méthodes virtuelles overridées, ce qui permet à nanobind de pré-allouer l'espace nécessaire. NB_OVERRIDE_PURE est plus concis que PYBIND11_OVERRIDE_PURE : les types de retour et la classe parente sont déduits automatiquement.

Enums

// pybind11
py::enum_<LogLevel>(m, "LogLevel")
    .value("Debug", LogLevel::Debug)
    .value("Info", LogLevel::Info);

// nanobind — identique
nb::enum_<LogLevel>(m, "LogLevel")
    .value("Debug", LogLevel::Debug)
    .value("Info", LogLevel::Info);

43.2.4.6 — NumPy et tenseurs

nanobind fournit nb::ndarray, un type plus flexible que py::array_t de pybind11. Il supporte non seulement NumPy, mais aussi les frameworks de tenseurs (PyTorch, JAX, TensorFlow) via le protocole DLPack.

Recevoir un array NumPy

#include <nanobind/ndarray.h>

// Spécifier le type, le framework et les contraintes au compile-time
double compute_mean(nb::ndarray<double, nb::shape<-1>, nb::c_contig, nb::device::cpu> input) {
    double sum = 0.0;
    const double* ptr = input.data();
    size_t n = input.shape(0);
    for (size_t i = 0; i < n; ++i) {
        sum += ptr[i];
    }
    return sum / static_cast<double>(n);
}

Les annotations template sont plus expressives que celles de pybind11 :

Annotation Signification
double Type des éléments
nb::shape<-1> 1 dimension, taille quelconque (-1 = dynamique)
nb::shape<3, 4> Exactement 3×4
nb::c_contig Mémoire contiguë en C-order (row-major)
nb::f_contig Mémoire contiguë en Fortran-order (column-major)
nb::device::cpu Données sur CPU
nb::device::cuda Données sur GPU CUDA
nb::numpy Exige un numpy.ndarray
nb::pytorch Exige un torch.Tensor
nb::jax Exige un tableau JAX

Support multi-framework

C'est l'avantage majeur de nb::ndarray sur py::array_t : une même fonction C++ peut accepter des tenseurs de différents frameworks.

// Accepte à la fois numpy.ndarray et torch.Tensor (CPU)
void process(nb::ndarray<float, nb::shape<-1, -1>, nb::c_contig, nb::device::cpu> tensor) {
    const float* data = tensor.data();
    size_t rows = tensor.shape(0);
    size_t cols = tensor.shape(1);
    // ... traitement identique quel que soit le framework source
}
import numpy as np  
import torch  

# Les deux fonctionnent avec le même binding C++
process(np.zeros((100, 200), dtype=np.float32))  
process(torch.zeros(100, 200))  

Retourner un array

nb::ndarray<nb::numpy, double, nb::shape<-1>> create_data(size_t n) {
    double* data = new double[n];
    for (size_t i = 0; i < n; ++i) data[i] = static_cast<double>(i);

    // nanobind gère la libération via le deleter
    nb::capsule deleter(data, [](void* p) noexcept {
        delete[] static_cast<double*>(p);
    });

    return nb::ndarray<nb::numpy, double, nb::shape<-1>>(
        data, {n}, deleter
    );
}

Le nb::capsule encapsule la logique de libération mémoire. Quand l'array Python est garbage-collecté, le deleter est appelé automatiquement.


43.2.4.7 — Fonctionnalités spécifiques à nanobind

nb::typed<> pour les stubs automatiques

nanobind peut générer automatiquement des fichiers .pyi (type stubs) pour l'autocomplétion dans les IDE Python. Les annotations nb::typed<> permettent de spécifier des types Python plus précis que ce que le C++ exprime :

m.def("get_items", &get_items,
      nb::rv_policy::reference,
      nb::sig("def get_items() -> list[str]"));

La signature Python générée dans le stub sera def get_items() -> list[str] — permettant à mypy et aux IDE de valider le typage.

Génération automatique de stubs

# Générer les stubs .pyi
python -m nanobind.stubgen -m mymodule -o mymodule.pyi

Ou intégré dans CMake :

nanobind_add_module(mymodule python/bindings.cpp)  
nanobind_add_stub(mymodule_stub  
    MODULE mymodule
    OUTPUT mymodule.pyi
)

Les stubs permettent l'autocomplétion, le type checking avec mypy/pyright, et une documentation intégrée dans l'IDE — un avantage significatif pour les utilisateurs Python du module.

Compilation incrémentale améliorée

nanobind sépare le code de dispatch (compilé une fois dans libnanobind) du code de binding (compilé dans chaque fichier .cpp). Cette séparation réduit naturellement le travail du compilateur lors des modifications incrémentales : changer un binding ne recompile que le fichier concerné, pas le mécanisme de dispatch.

Support des sous-interpréteurs

nanobind supporte nativement les sous-interpréteurs Python (PEP 554), une fonctionnalité importante pour les applications embarquant Python (serveurs, IDE, moteurs de jeu). pybind11 a des limitations connues dans ce domaine.


43.2.4.8 — Migration de pybind11 vers nanobind

Quand migrer

Situation Recommandation
Nouveau projet, C++17+, Python 3.8+ nanobind directement
Projet existant, binaires volumineux, builds lents Migrer progressivement
Projet existant, aucune plainte sur les builds Rester sur pybind11
Projet devant supporter C++11/14 Rester sur pybind11
Projet devant supporter Python 3.6/3.7 Rester sur pybind11
Besoin de support PyTorch/JAX natif nanobind (nb::ndarray)

Processus de migration

La migration est généralement mécanique grâce à la similarité des APIs. Voici les étapes principales :

1. Remplacer les includes et le namespace :

// Avant (pybind11)
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
namespace py = pybind11;

// Après (nanobind)
#include <nanobind/nanobind.h>
#include <nanobind/stl/string.h>
#include <nanobind/stl/vector.h>
namespace nb = nanobind;

2. Remplacer la macro du module :

// Avant
PYBIND11_MODULE(mymod, m) { }

// Après
NB_MODULE(mymod, m) { }

3. Remplacer les noms des méthodes de binding :

pybind11 nanobind
py::init<>() nb::init<>()
py::arg(...) nb::arg(...)
py::return_value_policy::X nb::rv_policy::X
py::keep_alive<N,M>() nb::keep_alive<N,M>()
.def_property_readonly(...) .def_prop_ro(...)
.def_property(...) .def_prop_rw(...)
.def_readwrite(...) .def_rw(...)
.def_readonly(...) .def_ro(...)
PYBIND11_OVERRIDE_PURE(...) NB_OVERRIDE_PURE(...)
py::overload_cast<T>(...) nb::overload_cast<T>(...)
py::array_t<T> nb::ndarray<T, ...>
py::buffer_protocol() Pas d'équivalent direct (utiliser nb::ndarray)

4. Adapter les includes STL :

Remplacer #include <pybind11/stl.h> par des includes individuels pour chaque type utilisé. C'est l'étape la plus laborieuse dans les projets volumineux, mais elle peut être faite fichier par fichier.

5. Vérifier la sémantique de propriété :

C'est le point le plus délicat. Si le code pybind11 s'appuyait implicitement sur le fait que les objets sont copiés par défaut, le passage à la sémantique move de nanobind peut changer le comportement. Auditer les fonctions qui passent des objets C++ à Python et vérifier que l'objet d'origine n'est pas utilisé après le transfert.

6. Mettre à jour CMake :

# Avant
FetchContent_Declare(pybind11 ...)  
pybind11_add_module(mymod ...)  

# Après
FetchContent_Declare(nanobind ...)  
nanobind_add_module(mymod ...)  

Migration progressive (coexistence)

Pour les projets volumineux, une migration en une seule fois est risquée. Il est possible de migrer fichier par fichier en maintenant deux modules temporairement, ou de migrer un sous-module à la fois si les bindings sont déjà fractionnés.

pybind11 et nanobind ne peuvent pas coexister dans la même unité de compilation (les macros et les structures internes sont incompatibles), mais ils peuvent coexister dans le même projet sous forme de modules séparés.


43.2.4.9 — Limites et précautions

Ce que nanobind ne supporte pas (encore)

  • py::args / py::kwargs de style pybind11. nanobind a son propre mécanisme (nb::args, nb::kwargs) avec une syntaxe similaire mais pas identique.
  • Certains custom type casters complexes. L'API de type casting interne est différente. Les type casters pybind11 ne sont pas compatibles et doivent être réécrits.
  • Embedding Python. pybind11 supporte l'embedding de l'interpréteur Python dans une application C++ via py::scoped_interpreter. nanobind ne supporte pas ce cas d'usage — il se concentre sur la création de modules d'extension.

Écosystème et communauté

L'écosystème pybind11 est considérablement plus large en 2026 : davantage de tutoriels, de réponses Stack Overflow, de projets exemples, et de bibliothèques tierces qui s'appuient dessus. nanobind croît rapidement — des projets majeurs comme Mitsuba 3, Dr.Jit et d'autres l'ont adopté — mais le corpus de documentation communautaire est plus restreint.

La documentation officielle est la référence

nanobind évolue activement. Les différences d'API entre versions mineures sont plus fréquentes que pour pybind11 (qui est très stable). La documentation officielle (https://nanobind.readthedocs.io) est la source de vérité pour les détails d'API et les breaking changes.


Résumé : arbre de décision pybind11 vs nanobind

Nouveau projet ?
  │
  ├── Oui
  │     ├── C++17+ et Python 3.8+ disponibles ?
  │     │     ├── Oui → nanobind ✓
  │     │     └── Non → pybind11
  │     └── Besoin de support PyTorch/JAX natif ?
  │           └── Oui → nanobind ✓ (nb::ndarray)
  │
  └── Projet existant avec pybind11
        ├── Builds lents ou binaires trop gros ?
        │     ├── Oui → Migrer vers nanobind (progressivement)
        │     └── Non → Rester sur pybind11
        └── Besoin d'embedder Python ?
              └── Oui → Rester sur pybind11

Tableau de synthèse finale

Critère pybind11 nanobind
C++ minimum C++11 C++17
Python minimum 3.6 3.8
Architecture Header-only Bibliothèque compilée + headers
Taille binaire Élevée Réduite (~10×)
Temps de compilation Modéré à élevé Réduit (~4×)
Overhead d'appel ~90 ns ~30 ns
Sémantique par défaut Copie Move
Support NumPy py::array_t<T> nb::ndarray<T> (multi-framework)
Support PyTorch/JAX Non natif Natif via nb::ndarray
Génération de stubs Externe (pybind11-stubgen) Intégrée (nanobind.stubgen)
Embedding Python Oui Non
Maturité écosystème Très large (2015+) En croissance (2022+)
Maintenance Active Active (même auteur)

📎 Ce chapitre a couvert l'interopérabilité C++ ↔ Python en profondeur. La section suivante (43.3) aborde l'axe stratégique de 2026 : l'interopérabilité C++ ↔ Rust, avec le bridge cxx, autocxx, et les stratégies de migration progressive.

⏭️ C++ et Rust : FFI et interopérabilité