🔝 Retour au Sommaire
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.
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.
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_viewet 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.
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.
Un binaire plus petit signifie :
- Des images Docker plus légères (critique pour le déploiement cloud).
- Un chargement du module plus rapide (
importen 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).
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.
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.
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.
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 signaturesnanobind 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.
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>());pip install nanobindcmake_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.
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.
// 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.
// pybind11 // nanobind
PYBIND11_MODULE(mymod, m) { NB_MODULE(mymod, m) {
// ... // ...
} }// 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.
// 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.
// 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);// pybind11
m.def("compute", py::overload_cast<double>(&compute));
// nanobind
m.def("compute", nb::overload_cast<double>(&compute));// 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.
// pybind11
.def("__iter__", ..., py::keep_alive<0, 1>())
// nanobind
.def("__iter__", ..., nb::keep_alive<0, 1>())// 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.
// 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);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.
#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 |
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)) 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.
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érer les stubs .pyi
python -m nanobind.stubgen -m mymodule -o mymodule.pyiOu 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.
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.
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.
| 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) |
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 ...) 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.
py::args/py::kwargsde 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.
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.
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.
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
| 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.