Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Binding tools

Slides

Let’s take a deeper look at binding tools. We’ll focus on pybind11 and nanobind. These are:

Intro to pybind11

Before we tackle a real library, let’s see the binding patterns on a couple of toy classes.

pybind11 is similar to Boost::Python, but much easier to build: it’s pure C++11 with no dependencies, no preprocessing step, and no new language to learn. It’s used in projects like SciPy, PyTorch, boost-histogram, and GooFit (including for CUDA). The main downside is that it’s a little verbose -- but that verbosity buys you a highly customizable interface.

A simple class

Here’s a minimal C++ class:

SimpleClass.hpp
#pragma once

class Simple {
    int x;

  public:
    Simple(int x) : x(x) {}

    int get() const { return x; }
};

Binding it takes just a few lines:

simpleclass.cpp
#include <pybind11/pybind11.h>

#include "SimpleClass.hpp"

namespace py = pybind11;

PYBIND11_MODULE(simpleclass, m) {
    py::class_<Simple>(m, "Simple")
        .def(py::init<int>())
        .def("get", &Simple::get);
}

The PYBIND11_MODULE macro defines the module -- its name must match the compiled file. py::class_<Simple> exposes the class, py::init<int>() binds the constructor, and each .def(...) binds a method.

More binding situations

A slightly richer class shows off more features -- properties and operators:

VectorClass.hpp
#pragma once

class Vector2D {
    double x;
    double y;

  public:
    Vector2D(double x, double y) : x(x), y(y) {}

    double get_x() const { return x; }
    double get_y() const { return y; }

    void set_x(double val) { x = val; }
    void set_y(double val) { y = val; }

    Vector2D &operator+=(const Vector2D &other) {
        x += other.x;
        y += other.y;
        return *this;
    }

    Vector2D operator+(const Vector2D &other) const {
        return Vector2D(x + other.x, y + other.y);
    }
};

And the binding code:

vectorclass.cpp
#include <pybind11/operators.h>
#include <pybind11/pybind11.h>

#include "VectorClass.hpp"

namespace py = pybind11;
using namespace pybind11::literals;

PYBIND11_MODULE(vectorclass, m) {
    py::class_<Vector2D>(m, "Vector2D")
        .def(py::init<double, double>(), "x"_a, "y"_a)
        .def_property("x", &Vector2D::get_x, &Vector2D::set_x)
        .def_property("y", &Vector2D::get_y, &Vector2D::set_y)
        .def(py::self += py::self)
        .def(py::self + py::self)
        .def("__repr__", [](py::object self) {
            return py::str("{0.__class__.__name__}({0.x}, {0.y})").format(self);
        });
}

A few new points:

Intro to nanobind

nanobind is a newer library from the same original author, Wenzel Jakob. It keeps pybind11’s design but expects the code to conform to nanobind, rather than trying to support all of C++, and in exchange it compiles quicker, produces much smaller binaries, and has lower call overhead.

The API is deliberately close to pybind11, so most of what you just learned carries straight over -- nb:: instead of py::, NB_MODULE instead of PYBIND11_MODULE. The main differences you’ll hit when we wrap Minuit2:

We are going to try a non-trivial project! Let’s wrap Minuit2 -- and since the two libraries are so similar, we’ll show both side by side.

Intro to Minuit2

Before we see it in Python, let’s see what Minuit2 is.

You should know what the C++ looks like, and know what you want the Python to look like. For now, let’s replicate the C++ experience.

For example: a simple minimizer for f(x)=x2f(x) = x^2 (should quickly find 0 as minimum), the procedure should be:

Define the FCN

We define the function to minimize by subclassing FCNBase:

SimpleFCN.hpp
#pragma once

#include <iostream>
#include <vector>

#include <Minuit2/FCNBase.h>
#include <Minuit2/FunctionMinimum.h>
#include <Minuit2/MnPrint.h>
#include <Minuit2/MnMigrad.h>

using namespace ROOT::Minuit2;

class SimpleFCN : public FCNBase {
    // Always 0.5 for these sorts of fits
    double Up() const override {return 0.5;}

    // This computes whatever you are going to minimize
    double operator()(const std::vector<double> &v) const override {
        std::cout << "val = " << v.at(0) << std::endl;
        return v.at(0)*v.at(0);
    }
};

Run the minimizer

Then we set up the parameters, minimize, and print the result:

simpleminuit.cpp
#include "SimpleFCN.hpp"

int main() {
    SimpleFCN fcn;
    MnUserParameters upar;
    upar.Add("x", 1., 0.1);
    MnMigrad migrad(fcn, upar);
    FunctionMinimum min = migrad();
    std::cout << min << std::endl;
}

Build configuration

We use CMake with FetchContent to grab Minuit2:

CMakeLists.txt
cmake_minimum_required(VERSION 3.26...4.4)
project(Minuit2SimpleExample LANGUAGES CXX)

include(FetchContent)
FetchContent_Declare(
  Minuit2
  GIT_REPOSITORY https://github.com/GooFit/Minuit2.git
  GIT_TAG        v6-40-02
  GIT_SHALLOW    TRUE
  FIND_PACKAGE_ARGS
)
FetchContent_MakeAvailable(Minuit2)

add_executable(simpleminuit simpleminuit.cpp SimpleFCN.hpp)
target_link_libraries(simpleminuit PRIVATE Minuit2::Minuit2)

Build and run

To build it:

cmake -S . -B build
cmake --build build

And run:

./build/simpleminuit

You should see something like this:

val = 1
val = 1.001
val = 0.999
val = 1.0006
val = 0.999402
val = -8.23008e-11
val = 0.000345267
val = -0.000345267
val = -8.23008e-11
val = 0.000345267
val = -0.000345267
val = 6.90533e-05
val = -6.90535e-05

  Valid         : yes
  Function calls: 13
  Minimum value : 6.773427082e-21
  Edm           : 6.773427082e-21
  Internal parameters: 	[ -8.230083282e-11]
  Internal covariance matrix:
[[              1]]]
  External parameters:
  Pos |    Name    |  type   |      Value       |    Error +/-
    0 |          x |  free   | -8.230083282e-11 | 0.7071067812

Binding Minuit2

The great thing about pybind11 and nanobind is that we can just bind the parts we need. There’s a lot more to Minuit2, but we don’t care. If we used an auto-binding tool (like SWIG), we’d have to work out issues for all the parts we aren’t using first.

We’ll build the same module both ways -- pick a tab to see each tool.

The main module

These programs are best split into a main module, which allows you to build the parts separately, with minimal header overlap, and then link it all at the end.

pybind11
nanobind
pyminuit2.cpp
#include <pybind11/pybind11.h>

namespace py = pybind11;

void init_FCNBase(py::module &);
void init_MnUserParameters(py::module &);
void init_MnMigrad(py::module &);
void init_FunctionMinimum(py::module &);

PYBIND11_MODULE(minuit2, m) {
    init_FCNBase(m);
    init_MnUserParameters(m);
    init_MnMigrad(m);
    init_FunctionMinimum(m);
}

Each init_* function fills in one piece of the module. We forward-declare them here and call them in the module macro (PYBIND11_MODULE / NB_MODULE); the definitions live in their own files.

Binding the FCN

The FCN is an abstract base class in C++. To let Python subclass it, we use a “trampoline” class that routes the virtual calls back into Python:

pybind11
nanobind
FCNBase.cpp
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>  // std::vector<double> <-> Python list conversion

#include <Minuit2/FCNBase.h>

namespace py = pybind11;
using namespace ROOT::Minuit2;

class PyFCNBase : public FCNBase {
   public:
     using FCNBase::FCNBase;

     double operator()(const std::vector<double> &v) const override {
         PYBIND11_OVERRIDE_PURE_NAME(
             double, FCNBase, "__call__", operator(), v);}

     double Up() const override {
         PYBIND11_OVERRIDE_PURE(double, FCNBase, Up, );}
 };
void init_FCNBase(py::module &m) {
    py::class_<FCNBase, PyFCNBase>(m, "FCNBase")
         .def(py::init<>())
         .def("__call__", &FCNBase::operator())
         .def("Up", &FCNBase::Up);
}

PYBIND11_OVERRIDE_PURE_NAME maps C++'s operator() to Python’s __call__; nanobind’s equivalent is NB_OVERRIDE_PURE_NAME, after declaring the trampoline with NB_TRAMPOLINE. Including <pybind11/stl.h> -- or, for nanobind, the per-type <nanobind/stl/vector.h> -- gives us automatic std::vector<double> ↔ list conversion.

Binding the parameters

MnUserParameters holds the parameters to minimize. We bind the constructor and the two Add overloads (fixed and with an error) using py::overload_cast (nb::overload_cast for nanobind, which also needs <nanobind/stl/string.h> for the parameter name):

pybind11
nanobind
MnUserParameters.cpp
#include <pybind11/pybind11.h>

#include <Minuit2/MnUserParameters.h>

namespace py = pybind11;
using namespace ROOT::Minuit2;

void init_MnUserParameters(py::module &m) {
    py::class_<MnUserParameters>(m, "MnUserParameters")
        .def(py::init<>())
        .def("Add", py::overload_cast<const std::string &, double>(&MnUserParameters::Add))
        .def("Add", py::overload_cast<const std::string &, double, double>(&MnUserParameters::Add))
    ;
}

Binding the minimizer

MnMigrad runs the minimization. It inherits from MnApplication, so we bind both and declare the relationship (py::class_<MnMigrad, MnApplication>, or the nb:: equivalent). We use a lambda for the constructor so we can take a plain unsigned int strategy instead of an MnStrategy object: pybind11 wraps it in py::init(...), while nanobind binds __init__ directly and placement-news into the object. The same _a literals give named arguments with defaults, as we saw with Vector2D:

pybind11
nanobind
MnApplication.cpp
#include <pybind11/pybind11.h>

#include <Minuit2/MnMigrad.h>
#include <Minuit2/MnApplication.h>
#include <Minuit2/MnUserParameters.h>
#include <Minuit2/FCNBase.h>
#include <Minuit2/FunctionMinimum.h>

namespace py = pybind11;
using namespace pybind11::literals;
using namespace ROOT::Minuit2;

void init_MnMigrad(py::module &m) {
    py::class_<MnApplication>(m, "MnApplication")
        .def("__call__",
             &MnApplication::operator(),
             "Minimize the function, returns a function minimum",
             "maxfcn"_a = 0,
             "tolerance"_a = 0.1);

    py::class_<MnMigrad, MnApplication>(m, "MnMigrad")
        .def(py::init([](const FCNBase &fcn, const MnUserParameters &par, unsigned int stra) {
                 return MnMigrad(fcn, par, MnStrategy(stra));
             }),
             "fcn"_a, "par"_a, "stra"_a = 1)
    ;
}

Binding the result

Finally, FunctionMinimum is the result. We only need to print it, so we bind __str__ to stream the C++ object into a string (nanobind needs <nanobind/stl/string.h> to return it):

pybind11
nanobind
FunctionMinimum.cpp
#include <pybind11/pybind11.h>

#include <sstream>

#include <Minuit2/MnPrint.h>
#include <Minuit2/FunctionMinimum.h>

namespace py = pybind11;
using namespace ROOT::Minuit2;

void init_FunctionMinimum(py::module &m) {
    py::class_<FunctionMinimum>(m, "FunctionMinimum")
        .def("__str__", [](const FunctionMinimum &self) {
            std::stringstream os;
            os << self;
            return os.str();
        })
    ;
}

Build configuration

The CMake is much like before, but we use pybind11_add_module (or nanobind_add_module) instead of a plain executable, glob the source files together, and install the resulting module. nanobind also wants an explicit find_package(Python ...):

pybind11
nanobind
CMakeLists.txt
cmake_minimum_required(VERSION 3.26...4.4)
project(pyminuit2 LANGUAGES CXX)

set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(CMAKE_CXX_STANDARD 17 CACHE STRING "The C++ standard to use")
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

include(FetchContent)
FetchContent_Declare(
  Minuit2
  GIT_REPOSITORY https://github.com/GooFit/Minuit2.git
  GIT_TAG        v6-40-02
  GIT_SHALLOW    TRUE
  FIND_PACKAGE_ARGS
)
FetchContent_MakeAvailable(Minuit2)

find_package(pybind11 CONFIG REQUIRED)

file(GLOB SOURCES CONFIGURE_DEPENDS pyminuit2/*.cpp)
pybind11_add_module(minuit2 ${SOURCES})
target_link_libraries(minuit2 PRIVATE Minuit2::Minuit2)
install(TARGETS minuit2 DESTINATION .)

We drive the build with scikit-build-core, so we need a pyproject.toml -- just list the right binding tool in build-system.requires:

pybind11
nanobind
pyproject.toml
[build-system]
requires = ["scikit-build-core>=1", "pybind11>=3"]
build-backend = "scikit_build_core.build"

[project]
name = "pyminuit2"
version = "0.0.1"
requires-python = ">=3.14"

# Rebuild the editable install when the build config or C++ sources change
[tool.uv]
cache-keys = [
    { file = "pyproject.toml" },
    { file = "CMakeLists.txt" },
    { file = "pyminuit2/**/*.cpp" },
    { file = "pyminuit2/**/*.hpp" },
]

Build and run

Install and run it in one step with uv:

uv run sample.py

The Python API is identical either way, so the same sample script -- a mirror of the C++ program -- runs against both modules:

sample.py
import minuit2


class SimpleFCN(minuit2.FCNBase):
    def Up(self):
        return 0.5

    def __call__(self, v):
        print("val =", v[0])
        return v[0] ** 2


fcn = SimpleFCN()
upar = minuit2.MnUserParameters()
upar.Add("x", 1.0, 0.1)
migrad = minuit2.MnMigrad(fcn, upar)
minimum = migrad()
print(minimum)

You should see the same minimization output as the C++ version, ending with the printed FunctionMinimum.