Split mode

Since version 3.0, nanobind extensions can be built in split mode. It exists to fix the wheel distribution problem, where users must build binary wheels for a frustratingly large matrix of Python versions and platforms.

Motivation

The author of this project maintains an open source renderer distributed via PyPI. To make it broadly accessible, it targets

  • 3 operating systems: macOS, Linux, Windows.

  • 2 processors: aarch64, x86_64.

  • All non-EOL Python versions, e.g., 3.10-3.14 (usually 5 releases).

That’s 30 wheels, each of which is quite big (~60MiB). Shipped this way, a single release takes up 1.8 GiB. With the 10 GiB storage limit of PyPI, there is only enough space for 5 releases!

While compiling per OS and processor is unavoidable, it would be nice to at least reduce the matrix by a factor of 5 by not targeting each Python minor version.

Stable ABI

The Python stable ABI (supported by nanobind via the STABLE_ABI option) is in principle designed to fix this problem. But it is not a perfect solution:

  • Python’s stable ABI makes nanobind slower. Performance-critical code like nanobind’s function dispatcher needs access to Python internals to work efficiently, and such low-level access is prohibited by the stable ABI.

  • Meaningfully reducing the wheel count only works when targeting a low stable ABI version. The earliest version usable by nanobind was 3.12, which means that users still have to ship separate wheels for Python 3.10 and 3.11.

  • A Python 3.12 stable ABI floor would leave nanobind “forever frozen” at the stagnant 3.12 feature set, which lacks many important features and improvements shipped since then.

The solution

Python extensions consist of two parts: the user binding code (“frontend”), and the backend bits (libnanobind). Normally, they are both linked into a combined binary. This can be very efficient when targeting a specific Python version, but performance of the libnanobind backend suffers when targeting the stable ABI.

Split mode addresses the problems by splitting the frontend and the backend into separate Python modules, as illustrated below.

_images/split-light.svg _images/split-dark.svg

This split also changes how extensions are distributed:

  • The backend is separately compiled for each Python version and contains the complex and performance-critical parts that benefit from this tight coupling. It is tiny and available on PyPI for relevant platforms and Python versions, so users do not have to worry about it (though it is possible for them to also ship their own backend).

  • The frontend part delegates complex steps to the backend. As a result, a single binary per platform covers all Python versions from 3.10 upwards. Extensions become slightly smaller, since they do not need to ship the backend (~150-200 KiB).

  • Backend improvements (bug fixes, performance work, support for new Python versions) reach already-released extensions through a backend upgrade, without recompiling the extensions.

Split mode is an opt-in feature. The default static and shared modes continue to work exactly as before.

Using split mode

To use split mode, pass the BACKEND_MODULE parameter to nanobind_add_module().

nanobind_add_module(my_ext my_ext.cpp BACKEND_MODULE nanobind_backend)

As with regular builds, you may specify NB_DOMAIN to isolate your extension from others even when using a shared backend.

Next, declare the backend package as a runtime dependency in pyproject.toml.

[project]
dependencies = ["nanobind-backend>=1.0"]

The >= constraint names the backend ABI version of the nanobind release used for building (nanobind-backend>=1.0 for this release). CMake also prints it when configuring a split-mode extension.

Warning

Do not specify an upper bound (<), and do not pin a specific version (==). Newer backends can always serve older extensions. If two hypothetical projects using nanobind were to pin different versions of nanobind-backend, they could not be installed at the same time.

Limitations

The frontend and backend must agree on a platform ABI, which includes the compiler family, C++ standard library, CRT flavor on Windows, debug mode, and free-threading. The official wheel targets the “mainstream” ABI for each platform:

Platform

Architectures

Toolchain

Windows

x86_64, arm64 (Python 3.11 and newer)

MSVC with the /MD runtime (release, shared CRT)

Linux

x86_64, aarch64, riscv64

GCC or Clang with libstdc++ as provided by manylinux

macOS

x86_64, arm64

AppleClang with libc++

Projects using a different toolchain or standard library cannot use the nanobind-backend package. They can usually still use split mode but must ship their own custom backend module.

Finally, statically linking the C++ library into an extension or a backend module is unsupported in split mode, as the C++ runtime must be shared to correctly propagate exceptions. The same goes for a private copy bundled into the wheel, which is what auditwheel does on musllinux. There are therefore no musllinux backend wheels.

Free-threading

Split mode supports free-threaded Python 3.15 and newer via the provisional abi3t stable ABI (see PEP 803). To do so, specify both FREE_THREADED and BACKEND_MODULE. Older Python versions lack a stable ABI for free-threading and are unsupported.

Free-threaded Python cannot load classic abi3 stable ABI modules. Therefore, to support both regular and free-threaded Python, you must build your extension once for abi3 and once for abi3t.

Extensions without NB_MODULE

In split-mode extensions, the macro NB_MODULE imports the backend and connects the extension to it. The nanobind API cannot be safely used before these steps have completed.

Certain unusual workflows may not be compatible with this (an example would be a piecemeal conversion of a pybind11 module to nanobind). In this case, you must call register_module() before using the nanobind API. Please refer to the documentation of this function for an example.

Choosing the stable ABI floor

Split mode targets the Python 3.10 stable ABI by default, which is the oldest version supported by nanobind. Developers who directly use the “raw” CPython API and require access to functionality not available in Python 3.10 can raise the floor to a higher version. The downside is that such extensions no longer load on older interpreters.

When using scikit-build-core, the floor follows the declared wheel tag.

[tool.scikit-build]
wheel.py-api = "cp313"     # extension targets the Python 3.13 stable ABI

To override the wheel tag for a specific extension, pass STABLE_ABI_VERSION to nanobind_add_module():

nanobind_add_module(
  my_ext
  my_ext.cpp
  BACKEND_MODULE nanobind_backend
  STABLE_ABI_VERSION 3.13
)

The backend is unaffected by this choice. It ships as a separate wheel per Python version and does not use the limited API at all.

Compiling a custom backend

To compile a custom backend module, use the nanobind_add_backend() command. It accepts a subset of the options of nanobind_add_module(). The reference documentation provides more details.

nanobind_add_backend(my_backend)

A backend module serves any number of extensions and type visibility domains at once; the NB_DOMAIN parameter of nanobind_add_module() works in split mode exactly as it does in the linked modes.

Backend modules are always compiled with nanobind’s detailed assertion messages. In a linked build, an internal check that fails in an optimized build only prints a generic message and asks the user to rebuild in Debug mode. That advice is useless in split mode, where the backend arrives as a prebuilt wheel, so backends report what actually went wrong.

The BACKEND_MODULE argument of nanobind_add_module() accepts any importable module name. Use this to point the extension to your own backend:

nanobind_add_module(
  my_ext
  my_ext.cpp
  BACKEND_MODULE my_backend
  BACKEND_PYPI my-backend
)

The optional BACKEND_PYPI parameter specifies the associated PyPI package name. In the case where my_backend is not available, a better error message will then advise the user to pip install the PyPI package my-backend.

Backend versioning

The MAJOR.MINOR.REVISION version of the nanobind-backend package has the following interpretation:

  • MAJOR.MINOR encodes the newest supported backend ABI contract. The minor version advances whenever the package adds a new feature to the ABI (e.g., a function slot, bit flag, etc.) without breaking the ABI itself. The major version advances when the ABI changes in a fundamental way, in which case the package ships a separate backend module per major version. Since only one version of nanobind-backend can be installed at a time, the package can never drop support for an older major version. This is very painful, which is why such changes are avoided at all costs.

  • REVISION covers non-contractual changes like backend bug fixes, performance improvements, and support for newly released CPython versions.

nanobind also has a separately versioned internals ABI (mentioned periodically in the release notes). This number versions the internal data structures and broadly expresses which nanobind releases can communicate with each other. Two nanobind extensions with different internals ABIs will not be aware of each other’s C++ type bindings. The internals ABI of nanobind-backend can change even in a patch release. However, this is transparent to split mode extensions, since all extensions will move to the new backend version together.