Integración con Python y pybind11
Exponer C++ como módulo de Python, compartir arrays de NumPy y saber cuándo vale la pena
Python orquesta y C++ calcula: pybind11 permite que una función o una clase de C++ se importe como si fuera un módulo más. Verás cómo se construye la extensión, cómo se comparten arrays de NumPy sin copiar, cómo se maneja el GIL y en qué casos no merece la pena el cambio.
- Escribir un módulo pybind11 con funciones, argumentos con nombre y docstrings
- Compilar la extensión a mano y distribuirla con pyproject.toml o CMake
- Pasar arrays de NumPy a C++ y distinguir vista de copia
- Explicar el papel del GIL y liberarlo solo cuando es seguro
- Decidir con datos si una parte del pipeline debe pasar a C++
Antes necesitas: Eigen: álgebra lineal en C++ · Machine Learning desde cero en C++ · RAII y punteros inteligentes
En esta lección
- ¿Qué es pybind11?
- ¿Para qué sirve?
- Cómo funciona por dentro: del .cpp al import
- Sintaxis: la forma canónica
- Ejemplo básico: un módulo sin dependencias
- Ejemplo intermedio: listas, optional y argumentos con nombre
- Caso práctico: NumPy, estado y GIL
- Visualización: qué cruza la frontera en una llamada
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Tienes un pipeline en Python que funciona: carga datos, entrena, evalúa. El perfilado revela que el 78 % del tiempo se consume en un bucle que calcula distancias entre vectores, y ese bucle no se vectoriza bien porque actualiza estado en cada paso. Reescribir el proyecto entero en C++ sería caro y frágil; reescribir solo esa función y llamarla desde Python cuesta una tarde. Ese intercambio es exactamente el que resuelve pybind11.
La clave está en no mover los datos: un proceso, una memoria, dos lenguajes. Python decide qué hacer y C++ hace el trabajo pesado sobre la misma dirección de memoria. Lo que cambia es que tú respondes por la frontera: qué se copia, quién libera y cuándo se puede ejecutar código Python.
¿Qué es pybind11?
pybind11 es una biblioteca de cabecera (header-only) que genera el código de pegamento entre la API C de CPython y C++ moderno. No es un lenguaje intermedio ni un generador de archivos: describes el binding con plantillas de C++ y el compilador produce las funciones que Python necesita para crear el módulo.
La analogía útil es la de una aduana. Tu función mean vive en territorio C++; Python vive en el suyo. La macro PYBIND11_MODULE construye la oficina de aduanas: declara qué entra, con qué formulario se convierte cada objeto y qué se devuelve. Nada cruza gratis: cada argumento se convierte, se copia o se envuelve en un objeto Python que apunta al original.
¿Para qué sirve?
- Acelerar bucles con estado: simulaciones de Monte Carlo, decodificación token a token, distancias con acumuladores. Aquí NumPy no ayuda porque cada paso depende del anterior.
- Reutilizar una biblioteca C++ existente: Eigen, OpenCV, ONNX Runtime o tu propio motor matricial, sin escribir un servidor HTTP alrededor.
- Exponer objetos con estado: un índice invertido, un modelo ya cargado en memoria, un buffer circular de métricas.
- Compartir memoria con NumPy: pasar un
ndarrayy leerlo comodouble*sin copiar millones de elementos. - Distribuir el resultado: una wheel que se instala con
pip instally funciona en el portátil de un compañero.
En Machine Learning la frontera aparece en tres puntos: el tokenizer de un modelo de lenguaje, los kernels de una capa personalizada y el preprocesado que no encaja en un ufunc. PyTorch y TensorFlow están construidos así: la API es Python, el cálculo es C++ y el puente es una extensión nativa.
Cómo funciona por dentro: del .cpp al import
El recorrido tiene tres etapas. Primero compilas un objeto compartido (.so en Linux y macOS, .pyd en Windows) enlazado contra las cabeceras de Python. Segundo, al escribir import faststats, CPython busca un archivo con la etiqueta ABI de tu intérprete (por ejemplo faststats.cpython-312-darwin.so), lo carga y llama a PyInit_faststats. Tercero, esa función —la genera PYBIND11_MODULE— construye el módulo y le cuelga las funciones y clases declaradas. Si el nombre del módulo y el del archivo no coinciden, el import no encuentra su punto de entrada.
Cada argumento que cruza se traduce con un caster. Estos son los casos que vas a usar el 90 % del tiempo:
| Python | C++ | Qué ocurre | Coste |
|---|---|---|---|
int | int / long | Conversión con comprobación de rango | Bajo |
float | double | Conversión numérica | Bajo |
str | std::string | Copia del contenido UTF-8 | Medio |
list / tuple | std::vector<T> | Copia elemento a elemento (requiere stl.h) | Alto en listas grandes |
numpy.ndarray | py::array_t<T> | Vista sobre el buffer si el dtype y el orden coinciden; copia con forcecast | Bajo o alto |
| Clase de C++ | py::class_<T> | Objeto Python que envuelve un puntero al objeto C++ | Bajo |
El GIL (Global Interpreter Lock) es el candado que impide que dos hilos ejecuten bytecode Python a la vez. Cuando Python llama a tu función, el hilo ya lo tiene. Si tu función solo toca memoria C++, puedes soltarlo con py::call_guard<py::gil_scoped_release>() para que otros hilos sigan trabajando. La regla es tajante: con el GIL liberado no puedes crear, leer ni modificar objetos py::, ni lanzar una excepción de Python; si lo haces, el proceso puede caerse sin mensaje.
Queda un detalle silencioso: la política de retorno. Al devolver un std::vector o un std::string, pybind11 copia a un objeto Python. Al devolver T& o un puntero decide según return_value_policy; si le pides reference, estarás prestando memoria C++ a Python, y si ese objeto muere antes que la referencia tendrás un fallo de segmentación lejos del origen.
Sintaxis: la forma canónica
Todo módulo tiene la misma silueta: una o más funciones o clases en C++, y una macro final que las publica. Los nombres que declares ahí son los que verá Python.
#include <pybind11/pybind11.h>
#include <pybind11/stl.h> // habilita vector, map y optional
namespace py = pybind11;
int suma(int a, int b) { return a + b; }
PYBIND11_MODULE(mi_modulo, m) { // debe coincidir con el nombre del archivo
m.doc() = "Descripcion que muestra help(mi_modulo)";
m.def("suma", &suma, // nombre en Python, funcion de C++
py::arg("a"), py::arg("b"), // nombres de los argumentos
"Devuelve a + b"); // docstring
}m.def("nombre", &funcion, ...)registra una función libre. El primer argumento es el nombre visible en Python.py::arg("x")permite llamar con palabra clave (f(x=3)) y aparece en la firma que muestrahelp().py::class_<T>(m, "Nombre")publica una clase;.def(py::init<...>())elige el constructor y.def("metodo", &T::metodo)los métodos.py::call_guard<T>()ejecuta un objeto RAII alrededor de la llamada: el uso más común es liberar el GIL.m.attr("VERSION") = "0.1.0"expone una constante, útil para comprobar qué binario está cargado.
Ejemplo básico: un módulo sin dependencias
Empieza por lo mínimo para aislar problemas de compilación. Este módulo no usa NumPy ni Eigen: solo dos funciones y un int que cruza la frontera.
#include <pybind11/pybind11.h>
namespace py = pybind11;
int suma(int a, int b) { return a + b; }
double media(double a, double b) { return (a + b) / 2.0; }
PYBIND11_MODULE(mates, m) {
m.doc() = "Operaciones basicas escritas en C++";
m.def("suma", &suma, py::arg("a"), py::arg("b"), "Suma dos enteros");
m.def("media", &media, py::arg("a"), py::arg("b"), "Media de dos numeros");
}# 1. Instala pybind11 en el mismo intérprete que usará el módulo
python3 -m pip install pybind11
# 2. Compila el objeto compartido con el sufijo ABI correcto
g++ -O2 -std=c++20 -Wall -Wextra -shared -fPIC \
$(python3 -m pybind11 --includes) \
mates.cpp -o mates$(python3 -c "import sysconfig; print(sysconfig.get_config_var('EXT_SUFFIX'))")
# 3. Comprueba que se importa y responde
python3 -c "import mates; print(mates.suma(2, 3), mates.media(2, 3))"La salida es 5 2.5. Cada bandera tiene una razón: -shared -fPIC produce código independiente de la posición que puede cargarse en un proceso ya en marcha; --includes añade las rutas de Python y de pybind11; el sufijo EXT_SUFFIX evita que un módulo compilado para 3.11 se cargue por error en 3.12. -O2 importa: sin optimización el cálculo puede quedar más lento que el bucle de Python que intentabas sustituir.
Ejemplo intermedio: listas, optional y argumentos con nombre
El siguiente paso es aceptar colecciones. Con #include <pybind11/stl.h>, pybind11 convierte una list de Python en std::vector<double> y un std::optional<T> en T o None. Fíjate en el detalle importante: la lista se copia; si son diez millones de elementos, esa copia se come la ganancia.
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <cstddef>
#include <optional>
#include <stdexcept>
#include <vector>
namespace py = pybind11;
double media(const std::vector<double>& valores) {
if (valores.empty()) throw std::invalid_argument("media espera al menos un valor");
double suma = 0.0;
for (double v : valores) suma += v;
return suma / static_cast<double>(valores.size());
}
std::optional<std::size_t> indice_max(const std::vector<double>& valores) {
if (valores.empty()) return std::nullopt;
std::size_t mejor = 0;
for (std::size_t i = 1; i < valores.size(); ++i) {
if (valores[i] > valores[mejor]) mejor = i;
}
return mejor;
}
PYBIND11_MODULE(estadisticas, m) {
m.def("media", &media, py::arg("valores"), "Media aritmetica de una lista");
m.def("indice_max", &indice_max, py::arg("valores"),
"Indice del mayor valor, o None si la lista esta vacia");
}import estadisticas
print(estadisticas.media([1, 2, 3, 4])) # 2.5
print(estadisticas.indice_max([3.5, 9.0, 2.0])) # 1
print(estadisticas.indice_max([])) # None
print(estadisticas.media(valores=[10, 20])) # 15.0
try:
estadisticas.media([])
except ValueError as error:
print("ValueError:", error)Dos traducciones automáticas merecen atención. throw std::invalid_argument(...) llega a Python como ValueError con el mismo mensaje, así que no necesitas comprobar códigos de error: usa excepciones y deja que pybind11 las traduzca. Y std::nullopt se convierte en None, lo que permite expresar «no hay resultado» sin inventar un valor centinela como -1.
def media(valores):
if not valores:
raise ValueError("media espera al menos un valor")
return sum(valores) / len(valores)double media(const std::vector<double>& valores) {
if (valores.empty())
throw std::invalid_argument("media espera al menos un valor");
double suma = 0.0;
for (double v : valores) suma += v;
return suma / static_cast<double>(valores.size());
}La lógica es idéntica; cambia quién paga el coste. La versión de Python crea un objeto por elemento al iterar; la de C++ recorre memoria contigua. Pero al llamarla desde Python, la lista se convierte a `std::vector` antes de entrar: con pocos elementos gana Python, con millones gana C++ solo si evitas la copia usando arrays.
Caso práctico: NumPy, estado y GIL
Este es el módulo que justifica la lección. py::array_t<double> con c_style | forcecast acepta cualquier array convertible a double en orden C, y request() devuelve un buffer_info con puntero, forma y strides: si el array ya es contiguo de double, C++ lee la misma memoria que NumPy. Además publicamos una clase con estado y una función que libera el GIL.
#include <pybind11/numpy.h>
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <deque>
#include <stdexcept>
#include <vector>
namespace py = pybind11;
// Acepta cualquier array convertible a double contiguo (C-order).
double mean(py::array_t<double, py::array::c_style | py::array::forcecast> valores) {
auto info = valores.request();
if (info.ndim != 1) throw std::invalid_argument("mean espera un array 1-D");
const auto n = static_cast<std::size_t>(info.shape[0]);
if (n == 0) throw std::invalid_argument("mean espera al menos un valor");
const auto* datos = static_cast<const double*>(info.ptr);
double suma = 0.0;
for (std::size_t i = 0; i < n; ++i) suma += datos[i];
return suma / static_cast<double>(n);
}
std::vector<double> normalizar(const std::vector<double>& valores) {
double maximo = 0.0;
for (double v : valores) maximo = std::max(maximo, std::abs(v));
if (maximo == 0.0) return std::vector<double>(valores.size(), 0.0);
std::vector<double> salida;
salida.reserve(valores.size());
for (double v : valores) salida.push_back(v / maximo);
return salida;
}
// Trabajo puro de C++: puede soltar el GIL durante el bucle.
long long sumar_hasta(long long n) {
long long total = 0;
for (long long i = 1; i <= n; ++i) total += i;
return total;
}
class MediaMovil {
public:
explicit MediaMovil(std::size_t ventana) : ventana_(ventana) {
if (ventana == 0) throw std::invalid_argument("la ventana debe ser mayor que cero");
}
double push(double valor) {
buffer_.push_back(valor);
suma_ += valor;
if (buffer_.size() > ventana_) {
suma_ -= buffer_.front();
buffer_.pop_front();
}
return suma_ / static_cast<double>(buffer_.size());
}
std::size_t size() const noexcept { return buffer_.size(); }
private:
std::size_t ventana_;
std::deque<double> buffer_;
double suma_ = 0.0;
};
PYBIND11_MODULE(faststats, m) {
m.doc() = "Estadisticas basicas implementadas en C++";
m.def("mean", &mean, py::arg("valores"), "Media aritmetica de un array 1-D");
m.def("normalizar", &normalizar, py::arg("valores"),
"Divide cada valor por el maximo absoluto");
m.def("sumar_hasta", &sumar_hasta, py::arg("n"),
py::call_guard<py::gil_scoped_release>(),
"Suma 1..n liberando el GIL mientras trabaja");
py::class_<MediaMovil>(m, "MediaMovil")
.def(py::init<std::size_t>(), py::arg("ventana"))
.def("push", &MediaMovil::push, py::arg("valor"),
"Anade un valor y devuelve la media de la ventana")
.def_property_readonly("size", &MediaMovil::size)
.def("__repr__", [](const MediaMovil& mm) {
return "<MediaMovil size=" + std::to_string(mm.size()) + ">";
});
}g++ -O2 -std=c++20 -Wall -Wextra -shared -fPIC \
$(python3 -m pybind11 --includes) \
faststats.cpp -o faststats$(python3 -c "import sysconfig; print(sysconfig.get_config_var('EXT_SUFFIX'))")import time
import numpy as np
import faststats
rng = np.random.default_rng(0)
datos = rng.random(2_000_000)
inicio = time.perf_counter()
resultado_cpp = faststats.mean(datos)
t_cpp = time.perf_counter() - inicio
inicio = time.perf_counter()
resultado_py = sum(datos) / len(datos)
t_py = time.perf_counter() - inicio
print(f"C++ : {resultado_cpp:.6f} en {t_cpp * 1000:.2f} ms")
print(f"Python: {resultado_py:.6f} en {t_py * 1000:.2f} ms")
print(f"factor: {t_py / t_cpp:.1f}x")
mm = faststats.MediaMovil(2)
print(mm.push(1.0), mm.push(2.0), mm.push(3.0), mm.size, mm)
print(faststats.sumar_hasta(10))C++ : 0.499925 en 1.90 ms
Python: 0.499925 en 190.00 ms
factor: 100.0x
1.0 1.5 2.5 2 <MediaMovil size=2>
55Dos observaciones honestas. La primera: dos órdenes de magnitud es habitual en bucles puros sobre memoria, pero no es una ley; mide con time.perf_counter y varias repeticiones, porque el primer import incluye cargar el .so. La segunda: sum(datos) / len(datos) itera elementos de NumPy uno a uno; si comparas contra datos.mean() la ventaja de C++ desaparece, y ese es el caso en el que no debes escribir una extensión.
La clase MediaMovil muestra el otro motivo para usar pybind11: mantener estado que Python no debe reconstruir en cada llamada. El objeto C++ vive dentro del objeto Python; cuando Python lo recolecta, el destructor se ejecuta y el std::deque libera su memoria. No hay new, no hay delete, no hay fuga.
Visualización: qué cruza la frontera en una llamada
Sigue la ejecución paso a paso. Lo relevante no es el resultado —2.0 es trivial— sino cuándo se convierte cada objeto.
import numpy as npimport faststatsdatos = np.array([1.0, 2.0, 3.0])resultado = faststats.mean(datos)print(resultado)—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
Errores comunes
PYBIND11_MODULE(_faststats, m) {
m.def("mean", &mean);
}PYBIND11_MODULE(faststats, m) {
m.def("mean", &mean);
}Python busca la función `PyInit_faststats` dentro de `faststats.cpython-312-....so`. Si la macro declara `_faststats`, el símbolo exportado es otro y el import falla con `ImportError: dynamic module does not define module export function (PyInit_faststats)`. El archivo y la macro son un contrato.
TypeError: ... incompatible function arguments ... Did you forget to #include <pybind11/stl.h>?— intentas pasar una lista a unstd::vectorsin la cabecera de conversiones. Añádela; el mensaje de pybind11 es literalmente ese.ModuleNotFoundError: No module named faststats— el.soquedó en otro directorio o con otro sufijo. Ejecuta Python desde la carpeta del binario y comprueba el sufijo consysconfig.get_config_var.ImportError: ... undefined symbol— el módulo se compiló contra otra versión de Python. Recompila con el mismo intérprete que lo importa:python3 -m pybind11 --includesdebe apuntar a ese intérprete.- Segmentation fault al llamar — casi siempre es una referencia a un objeto ya destruido o un
return_value_policy::referencesobre memoria local. - Cuelgue al liberar el GIL — tocaste objetos
py::(imprimir, crear una lista, lanzarpy::value_error) dentro de ungil_scoped_release. Copia los datos a estructuras C++ antes de soltar el candado. - Cambios que no se ven — Python cargó un
.soviejo de otro directorio. Imprimefaststats.__file__antes de depurar la lógica.
Buenas prácticas
- Mide antes y después con el mismo hardware y los mismos datos. Sin número previo no hay optimización, solo intuición.
- Cruza la frontera pocas veces con muchos datos, no muchas veces con pocos: cada llamada tiene un coste fijo.
- Prefiere
py::array_t<T>sobrestd::vector<T>cuando los datos vengan de NumPy; evitas una copia completa. - Escribe
py::argy docstrings en todas las funciones públicas: son la documentación que verá quien use el módulo. - Usa
std::optionaly excepciones estándar en lugar de centinelas; pybind11 los traduce aNoney a excepciones de Python. - Libera el GIL solo en funciones que no toquen objetos Python y que duren lo suficiente para amortizar el cambio de contexto.
- Automatiza la compilación con
pyproject.tomlo CMake. Una línea deg++del historial no es reproducible.
[build-system]
requires = ["setuptools>=68", "pybind11>=2.12"]
build-backend = "setuptools.build_meta"
[project]
name = "faststats"
version = "0.1.0"
requires-python = ">=3.10"from pybind11.setup_helpers import Pybind11Extension, build_ext
from setuptools import setup
ext_modules = [Pybind11Extension("faststats", ["faststats.cpp"], cxx_std=20)]
setup(ext_modules=ext_modules, cmdclass={"build_ext": build_ext})python3 -m pip install -e .
python3 -c "import faststats; print(faststats.mean([1, 2, 3]))" # 2.0# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(faststats LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(pybind11 CONFIG REQUIRED)
pybind11_add_module(faststats faststats.cpp)
# configuración y compilación
python3 -m pip install pybind11
cmake -S . -B build -Dpybind11_DIR=$(python3 -m pybind11 --cmakedir)
cmake --build build -jEjercicios
Producto escalar con argumentos con nombre
vector_ops con una función producto_punto(a, b) que reciba dos listas de double, compruebe que tienen la misma longitud, lance std::invalid_argument si no coinciden y devuelva la suma de productos. Compílalo, llama con palabras clave desde Python y provoca el error para ver qué excepción llega.Pista
Necesitas #include <pybind11/stl.h> para aceptar listas. Recorre con un índice std::size_t y acumula en un double.
Una clase con propiedad de solo lectura
Contador con incrementar(), reiniciar() y una propiedad valor que no se pueda asignar desde Python. Añade un __repr__. Comprueba en el intérprete que contador.valor = 10 lanza AttributeError.Pista
def_property_readonly produce una propiedad sin setter. Para el constructor vacío usa py::init<>().
PYBIND11_MODULE(, m) { m.def("escalar", &escalar, , py::arg("factor"), "Multiplica el vector por factor"); }
El primer hueco es el nombre del módulo, que debe coincidir con el archivo. El segundo declara el nombre del primer argumento.
Soluciones explicadas
En el primer ejercicio, la decisión relevante no es el bucle sino el contrato: dos vectores de distinta longitud no tienen producto escalar, y fallar rápido con una excepción es mejor que devolver un número incorrecto en silencio. La versión con listas copia ambos vectores a std::vector al entrar: aceptable para cientos de elementos, inaceptable para millones, donde cambiarías la firma a py::array_t<double> para leer el buffer original.
En el segundo, def_property_readonly no impide que C++ modifique el valor: impide que Python lo asigne. Esa asimetría es intencionada. Y el __repr__ no es cosmético: cuando un objeto aparece dentro de una lista en un error, su representación es lo único que verás.
Resumen
- Una extensión nativa es un objeto compartido que Python carga e inicializa mediante
PyInit_<modulo>. PYBIND11_MODULE(nombre, m)genera ese punto de entrada;nombredebe coincidir con el archivo.m.def,py::argypy::class_publican funciones y clases;.def(py::init<...>())elige el constructor.- Con
stl.hse convierten listas enstd::vectorcopiando; conpy::array_tpuedes leer el buffer de NumPy sin copia. - Las excepciones estándar de C++ llegan como excepciones de Python y
std::optionalcomoNone. - El GIL protege el estado de CPython: se libera con
py::call_guard<py::gil_scoped_release>()solo en código que no toque objetospy::. - La compilación se automatiza con
pyproject.tomlo CMake; una orden manual en la terminal no es reproducible. - Cruzar la frontera tiene coste fijo: acelera C++ cuando el trabajo por llamada es grande y no está ya vectorizado en NumPy.
Evaluación
Declaras PYBIND11_MODULE(_faststats, m) pero el archivo se llama faststats.cpp. ¿Qué ocurre al importar?
Quieres que una función C++ reciba una lista de Python como std::vector<double>. ¿Qué añades?
¿Cuándo es seguro añadir py::call_guard<py::gil_scoped_release>() a una función?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.