pybind11과 비교해 nanobind가 다른 점, 성능 개선 사항, 주요/소소한 추가 기능, 그리고 마이그레이션 권고를 설명합니다.
기초
중급
N차원 배열- [x]
nb::ndarray<..> 클래스고급
API 레퍼런스
저는 2015년에 제가 작업하던 프로젝트를 위해 더 나은 C++/Python 바인딩을 생성하고자 pybind11 프로젝트를 시작했습니다. 다른 많은 분들의 놀라운 기여 덕분에 pybind11은 그 이후 PyTorch와 Tensorflow 같은 대표 프로젝트를 포함해 전 세계에서 사용되는 소프트웨어의 핵심 의존성이 되었습니다. 매일 40만 회 이상 다운로드됩니다. 이 다양한 사용자층의 요구를 충족하기 위해 수백 개의 확장과 일반화가 기여되었습니다. 하지만 이런 성공에는 비용도 따랐습니다. 라이브러리의 복잡성이 엄청나게 증가했고, 이는 효율성에 부정적인 영향을 주었습니다.
흥미롭게도 현재 상황은 2015년과 닮아 있습니다. 기존 도구(Boost.Python, pybind11)를 사용한 바인딩 생성은 느리고, 런타임 성능에 오버헤드를 더하면서도 거대한 바이너리를 만들어냅니다. 동시에 C++17과 Python 3.8의 핵심 개선은 과감한 단순화를 가능하게 합니다. 그래서 저는 또 다른 바인딩 프로젝트를 시작합니다. 이번에는 범위를 의도적으로 제한하여 끝없는 반복 사이클로 이어지지 않도록 하려 합니다.
nanobind는 pybind11과 매우 밀접한 관련이 있으며, 그 관례와 문법 대부분을 계승합니다. 가장 큰 차이는 철학의 변화입니다. pybind11은 레거시 코드베이스를 바인딩하기 위해 _C++ 전부_를 다뤄야 하는 반면, nanobind는 더 작은 C++ 부분집합을 대상으로 합니다. 즉, 바인딩 도구가 코드베이스에 맞춰지는 것이 아니라 코드베이스가 바인딩 도구에 맞춰져야 합니다. 이 덕분에 nanobind는 더 단순하고 더 빠를 수 있습니다. pybind11에서는 미묘한 가장자리(프린지) 사례를 처리하기 위한 확장/일반화 PR을 환영했지만, 이 프로젝트에서는 아마도 거절될 것입니다.
제거된 기능의 개요는 별도 섹션에 제공되어 있습니다. 기능 제거 외에도, 이번 재작성은 오랜 성능 문제를 해결하고 여러 중요한 개발자 편의성 개선 및 소소한 기능들을 추가할 기회이기도 했습니다.
벤치마크 섹션에서는 아래 성능 개선들의 영향을 평가합니다.
컴팩트 객체(Compact objects): 가능할 때마다 C++ 객체를 Python 객체와 같은 위치에 배치합니다(pybind11 대비 포인터 추적 감소). C++ 타입을 Python 객체로 감쌀 때 인스턴스당 오버헤드는 2.3배 감소합니다. (pybind11: 56바이트, nanobind: 24바이트.)
컴팩트 함수(Compact functions): C++ 함수 바인딩 정보가 Python 함수 객체와 같은 위치에 배치됩니다(포인터 추적 감소).
컴팩트 타입(Compact types): C++ 타입 바인딩 정보가 Python 타입 객체와 같은 위치에 배치됩니다(포인터 추적 감소, 해시테이블 조회 감소).
빠른 해시 테이블(Fast hash table): nanobind는 내부의 중요한 연관 자료구조 중 기존에 std::unordered_map을 쓰던 여러 부분을 더 효율적인 대안으로 업그레이드합니다(tsl::robin_map, git 서브모듈로 포함).
벡터 호출(Vector calls): Python에서/으로의 함수 호출은 PEP 590 벡터 호출을 사용해 구현되어 상당한 속도 향상을 제공합니다. 메인 함수 디스패치 루프는 더 이상 힙 메모리를 할당하지 않습니다.
라이브러리 구성 요소(Library component): pybind11은 헤더-온리 라이브러리로 설계되었고, 이는 컴파일 워크플로를 단순화한다는 점에서 일반적으로 좋은 선택입니다. 하지만 큰 단점 하나는 각 바인딩 파일마다 많은 중복 코드(예: 함수 디스패치 루프와 관련 내부 자료구조들)를 컴파일해야 한다는 점입니다. nanobind는 별도의 공유 또는 정적 지원 라이브러리(“libnanobind”)를 컴파일한 뒤 바인딩 코드와 링크하여 중복 컴파일을 피합니다. CMake 인터페이스 nanobind_add_module()은 이 추가 단계를 완전히 자동화합니다.
더 작은 헤더(Smaller headers): #include <pybind11/pybind11.h>는 STL의 큰 부분을 끌어옵니다(Clang과 libc++ 기준 약 2.1MiB 헤더). nanobind는 이 문제를 피하기 위해 STL 사용을 최소화합니다. std::string 같은 기본 타입에 대한 타입 캐스터조차 추가 헤더(예: #include <nanobind/stl/string.h>)를 포함하여 명시적으로 opt-in 해야 합니다.
더 단순한 컴파일(Simpler compilation): pybind11은 합리적인 크기의 바인딩을 만들기 위해 링크 타임 최적화(LTO)에 의존했으며, 이는 링크 과정을 빌드 시간 병목으로 만들었습니다. nanobind는 미리 컴파일된 라이브러리와 최소한의 메타템플릿으로 분리되어 있어, LTO는 더 이상 필수적이지 않으며 생략할 수 있습니다.
프리-스레딩(Free-threading): Python 3.13+는 전역 인터프리터 락(GIL)을 제거하는 프리-스레드 모드를 지원합니다. pybind11과 nanobind 모두 최근 프리-스레딩을 지원합니다. 두 프로젝트를 비교하면 nanobind는 국소적 잠금 방식(localized locking scheme)을 사용하여 더 나은 멀티코어 스케일링을 제공합니다. pybind11에서는 모든 바인딩 연산에 사용되는 중앙 internals 자료구조에 대한 락 경합이 실제로 병목이 됩니다.
수명 관리(Lifetime management): nanobind는 수명 관리를 위한 효율적인 내부 자료구조를 유지합니다(nb::keep_alive, nb::rv_policy::reference_internal, std::shared_ptr 인터페이스 등). 이러한 변경으로 바인딩된 타입은 더 이상 weak-reference 가능할 필요가 없으며, 그 결과 인스턴스당 포인터 하나를 절약합니다.
nanobind에는 개발자 삶의 질(QoL)을 높이는 여러 개선이 포함되어 있습니다.
N차원 배열(N-dimensional arrays): nanobind는 최신 배열 프로그래밍 프레임워크와 데이터를 교환할 수 있습니다. DLPack 또는 버퍼 프로토콜을 사용하여 NumPy, PyTorch, TensorFlow, JAX 등과 CPU/GPU 배열을 제로-카피(zero-copy) 로 교환합니다. 자세한 내용은 N차원 배열 섹션을 참고하세요.
Stable ABI: nanobind는 Python 3.12부터 Python의 stable ABI 인터페이스를 대상으로 할 수 있습니다. 이는 인터프리터 버전마다 별도의 바이너리를 컴파일하지 않아도 확장 모듈이 이후 Python 버전과 호환됨을 의미합니다.
스텁 생성(Stub generation): nanobind는 커스텀 스텁 생성기와 CMake 통합을 함께 제공하여, 빌드 과정의 일부로 고품질 타입 스텁을 자동 생성합니다. 스텁은 컴파일된 확장 코드를 Visual Studio Code 같은 편집기의 자동완성 및 MyPy, PyRight, PyType 같은 정적 타입 체커와 호환되게 합니다.
스마트 포인터, 소유권 등(Smart pointers, ownership, etc.): pybind11에서 스마트/유니크 포인터와 콜백 관련 코너 케이스는 정의되지 않은 동작(undefined behavior)으로 이어질 수 있었습니다. 이후 pybind11 재설계(smart_holder)가 이 문제를 해결했지만, 바이너리 크기 및 런타임 오버헤드가 더 증가하는 대가를 치렀습니다. nanobind의 객체 소유권 모델은 성능을 희생하지 않고 이러한 undefined behavior를 피합니다.
누수 경고(Leak warnings): Python 인터프리터가 종료될 때, nanobind는 바인딩과 관련된 인스턴스/타입/함수 누수를 보고하여 레퍼런스 카운팅 문제를 추적하는 데 도움을 줍니다. 이러한 경고가 필요 없다면 nb::set_leak_warnings(false)를 호출하세요. 또한 nanobind는 인터프리터 종료 시 내부 자료구조를 완전히 삭제하므로, valgrind 같은 도구에서 메모리 누수로 보고되는 것을 방지합니다.
더 나은 docstring(Better docstrings): pybind11은 바인딩 코드가 실행되는 동안 docstring을 미리 렌더링합니다. 즉, 함수를 바인딩하기 위해 .def(...)를 호출할 때마다 즉시 docstring이 생성됩니다. 어떤 함수가 아직 pybind11에 등록되지 않은 C++ 타입을 매개변수로 받는다면 docstring에는 C++ 타입 이름(예: std::vector<int, std::allocator<int>>)이 들어가 보기 좋지 않을 수 있습니다. 이를 피하려면 pybind11 바인딩 선언의 순서를 신중히 배치해야 합니다.
nanobind는 docstring을 미리 렌더링하지 않음으로써 이 문제를 근본적으로 피합니다. docstring은 질의될 때(on the fly) 생성됩니다. 또한 nanobind는 Sphinx 같은 문서 생성 도구와의 기본 호환성도 더 좋습니다.
아래는 pybind11 대비 작지만 유용한 추가 기능들입니다.
nb::sig 속성을 지정하여 nanobind가 제공하는 기본 시그니처를 오버라이드할 수 있습니다.예를 들어 아래 함수 시그니처 어노테이션은 값이 1인 정수 리터럴로만 호출되어야 하는 오버로드를 생성합니다. 함수에는 런타임 검사도 포함되어 있지만, 이제 정적 타입 체커는 특정 코드 조각이 이 오류 조건을 절대로 유발하지 않음을 보장할 수 있습니다.
cppm.def("f", [](int arg) { if (arg != 1) nb::raise("invalid input"); return arg; }, nb::sig("def f(arg: typing.Literal[1], /) -> int"));
자세한 내용은 함수 시그니처 커스터마이징과 클래스 시그니처 섹션을 참고하세요.
nb::handle_t<T> 타입은 nb::handle 클래스와 마찬가지로 PyObject * 포인터를 감쌉니다. 하지만 이런 인자를 받는 함수를 바인딩할 때, nanobind는 내부 Python 객체가 타입 T의 C++ 인스턴스를 감싸고 있을 때에만 해당 함수 오버로드를 호출합니다.비슷하게, nb::type_object_t<T> 타입은 nb::type_object 클래스처럼 PyTypeObject * 포인터를 감쌉니다. 하지만 이런 인자를 받는 함수를 바인딩할 때, nanobind는 내부 Python 타입 객체가 C++ 타입 T의 서브타입일 때에만 해당 함수 오버로드를 호출합니다.
마지막으로, nb::typed<T, Ts...> 어노테이션은 다른 어떤 타입이든 매개변수화할 수 있습니다. 이 기능은 타입 시그니처의 표현력을 개선하기 위해 존재합니다(예: list를 list[int]로). 다만 이 경우 nanobind는 추가 런타임 검사를 수행하지 않는다는 점에 유의하세요. 자세한 내용은 제네릭 매개변수화 섹션을 참고하세요.
nb::rv_policy::none이라는 정책을 하나 더 제공합니다. 이 정책은 반환값이 이미 알려진/등록된 Python 객체일 때에만 성공 합니다. 즉, 새 Python 객체를 생성하여 C++ 인스턴스를 이동/복사/참조하려는 시도는 절대 하지 않습니다.새로운 nb::find() 함수는 이 동작을 캡슐화합니다. 이는 C++ 인스턴스에 연결된 Python 객체를 반환한다는 점에서 nb::cast()와 유사합니다. 하지만 nb::cast()는 아직 존재하지 않으면 해당 Python 객체를 생성하는 반면, nb::find()는 nullptr 객체를 반환합니다. 이 함수는 Python의 순환 가비지 컬렉터와 연동하는 데 유용합니다.
제 권고는 현재 pybind11 사용자가 nanobind로 마이그레이션을 검토하는 것입니다. pybind11의 오랜 문제들(위 목록 참조)을 모두 고치려면 상당한 재설계와, C++ 메타프로그래밍 전문가 팀이 수년에 걸쳐 신중히 작업해야 합니다. 동시에 pybind11에서 어떤 것이든 변경하는 일은, 수많은 다운스트림 사용자와 그들의 API/ABI 안정성 요구 때문에 극도로 어렵습니다. 개인적으로 저는 pybind11을 고칠 시간과 에너지가 없으며, 초점을 이 프로젝트로 옮겼습니다. 사용 후기 섹션에는 전환을 수행한 여러 대형 프로젝트들의 경험이 정리되어 있습니다.
Copyright © 2023, Wenzel Jakob
Sphinx 및 @pradyunsg의 Furo로 제작
이 페이지에서