You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Optional pre-compilation of pybind11 (nanobind-style static library mode)
Context
pybind11 is header-only; every consumer TU re-parses and re-generates ~1,800 lines of
genuinely non-template implementation code (cpp_function::dispatcher alone is 444 lines, initialize_generic 214, error_fetch_and_normalize 222, essentially all of detail/class.h). Goal: an opt-in mode where that code is compiled once into a static library built inside the consumer's own project (nanobind's model), while
header-only stays the default and byte-for-byte unchanged. Easy from CMake; possible from
Meson/setuptools/any build system.
Prior art: PR #2445 (full fmtlib-style split; gem5 25→15 min; stalled on process, not
design), PR #4001 (minimal pybind11::static target, common.h only; abandoned;
rwgk's blockers — smart_holder in flight, pre-3.0 file org — are now moot), nanobind
(lazy EXCLUDE_FROM_ALL static lib, nb_combined.cpp amalgam for non-CMake builds), CLI11 (shipping the identical mechanism in production: CLI11_INLINE empty under CLI11_COMPILE, include/CLI/impl/*_inl.hpp, #ifndef CLI11_COMPILE #include "impl/X_inl.hpp" at each header's bottom, src/Precompile.cpp amalgam, PUBLIC compile
definition on the lib target — validates every core choice here).
henryiii on record in #4001: "break this up into incremental stand-alone chunks."
Why static-only is safe: per-DSO identity is load-bearing in 4 places
(get_local_internals_key() address-of-local-static key, module_local_load == &local_load self-check, libc++ per-DSO exception classes, loader_life_support
thread_local). A static lib linked into each module preserves all of them per-module; no
export/import macros, no versioned namespace, wheel stays py3-none-any (ships sources).
Decisions (user-confirmed)
Scope: high-payoff subset (~1,800 lines), not a full split.
Form: static only, built per-consumer-project. Never shared.
One user-facing switch: PYBIND11_PRECOMPILED ("definitions were compiled
elsewhere") — accurate for both library TUs and consumer TUs; single macro suffices
because there is no DLL import/export asymmetry.
All three current PYBIND11_NOINLINE expansions are reproduced exactly; no new GCC
inline+noinline warning exposure. PYBIND11_INLINE is unused today (verified).
File layout (fmtlib style, per header)
include/pybind11/<name>-inl.h next to its header: out-of-line definitions, every one
tagged PYBIND11_INLINE (or PYBIND11_NOINLINE_ATTR PYBIND11_INLINE). Starts with #pragma once + #include "<name>.h".
Header keeps class definitions with member declarations; at the very bottom: #ifndef PYBIND11_PRECOMPILED → #include "<name>-inl.h" // IWYU pragma: export → #endif (the IWYU pragma is CLI11's touch — keeps include-what-you-use quiet).
Included at the same TU point the definitions sit today ⇒ default mode unchanged.
src/<name>.cpp one-liners (#include <pybind11/<name>-inl.h>), plus src/pybind11_combined.cpp including all -inl.h in pybind11.h order (non-CMake
single-TU entry point).
Rule: only non-template, non-constexpr functions move. Templates
(load_impl<>, internals_pp_manager<T>, atomic_get_or_create_in_state_dict<T>)
stay in headers — the latter two need no explicit instantiation: their only
instantiation points move into the library TU. (If a template body ever must move,
CLI11's escape hatch is explicit instantiation in the .cpp — needed there because
LTO can strip implicitly-instantiated weak symbols from a shared lib; static-only
mostly dodges that, but prefer keeping templates in headers regardless.)
Review rule (grep-able): every function definition in a -inl.h begins with PYBIND11_INLINE. Watch decl/def drift: default args stay on the declaration; noexcept must match.
Link-time config guard
A function whose name encodes PYBIND11_INTERNALS_VERSION, Py_GIL_DISABLED, PYBIND11_SIMPLE_GIL_MANAGEMENT, PYBIND11_DETAILED_ERROR_MESSAGES (Debug/Release
mismatch catcher — it defaults on !NDEBUG): declared in detail/internals.h, defined
in internals-inl.h, odr-used from the PYBIND11_MODULE expansion only when PYBIND11_PRECOMPILED is set. Mismatch or forgot-to-link ⇒ one readable undefined
symbol instead of pages of link errors. (CLI11 skips this and relies purely on the
PUBLIC compile definition propagating; pybind11 warrants the extra guard because PYBIND11_DETAILED_ERROR_MESSAGES silently keys off NDEBUG and the non-CMake
paths hand-define the macro.)
CMake (tools/pybind11Common.cmake — included in both config & subdirectory modes)
UX: pybind11_add_module(target PRECOMPILE ...) keyword + PYBIND11_PRECOMPILE cache
option that flips the default for all calls + public pybind11_precompile() for
raw-target users (target_link_libraries(t PRIVATE pybind11::precompiled)).
Lazy builder (nanobind model — works in installed-config mode, the gap in #4001):
function(pybind11_precompile)
if(TARGET pybind11_precompiled)
return()
endif()
# FATAL_ERROR under PYBIND11_NOPYTHON (needs Python headers)file(GLOB_srcs"${pybind11_SRC_DIR}/*.cpp")
list(REMOVE_ITEM _srcs "${pybind11_SRC_DIR}/pybind11_combined.cpp")
add_library(pybind11_precompiledSTATICEXCLUDE_FROM_ALL${_srcs})
add_library(pybind11::precompiledALIASpybind11_precompiled)
target_compile_definitions(pybind11_precompiledPUBLICPYBIND11_PRECOMPILED)
target_link_libraries(pybind11_precompiledPUBLICpybind11::headersPRIVATEpybind11::pybind11)
set_target_properties(pybind11_precompiled PROPERTIES POSITION_INDEPENDENT_CODEON
CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON) # only if user unset# MSVC: pybind11::windows_extras (/bigobj)endfunction()
Hook in both tools/pybind11NewTools.cmake (options at :258-259, after the pybind11::headers/module/embed links at :277-282) and classic tools/pybind11Tools.cmake (options at :142-143, after :161) — classic works free since pybind11::pybind11 carries Python headers in both modes:
if(ARG_PRECOMPILE OR PYBIND11_PRECOMPILE)
pybind11_precompile()
target_link_libraries(${target_name}PRIVATEpybind11::precompiled)
endif()
(PRIVATE link still applies the lib's PUBLIC PYBIND11_PRECOMPILED to the module's TUs.)
Deliberate choices: no variant suffixing (one Python per build tree; multi-config
generators handle Debug/Release natively; must-match macros ride pybind11_headers
INTERFACE); no LTO/OPT_SIZE on the lib (defeats compile-once; per-module keywords
still affect module TUs); lib inherits consumer's directory flags and CMAKE_CXX_STANDARD at first use (warn if a PRECOMPILE module's standard differs);
not installed/exported.
Packaging / non-CMake
install(DIRECTORY src/ DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/pybind11/src) →
wheel path pybind11/share/pybind11/src/*.cpp, sibling of share/cmake; wheel stays
pure. pybind11_SRC_DIR cache var (subdir mode) + @pybind11_SRCDIR@ in tools/pybind11Config.cmake.in.
setup_helpers.py: Pybind11Extension(..., precompile=True) appends <srcdir>/pybind11_combined.cpp + defines the macro; hard error if sources absent
(no silent header-only fallback); guarded so the standalone-copied file still works
without the kwarg. Per-extension copy; no cross-extension sharing (documented).
Cleanup: move PYBIND11_DISABLE_HANDLE_TYPE_NAME_DEFAULT_IMPLEMENTATION / PYBIND11_SIMPLE_GIL_MANAGEMENT from global add_compile_definitions
(CMakeLists.txt:113/:116) onto pybind11_headers INTERFACE (pattern of PYBIND11_INTERNALS_VERSION at :306-309).
Macro infra (~20 lines, pure refactor): PYBIND11_NOINLINE_ATTR + PYBIND11_INLINE in detail/common.h.
First split + full build/packaging infra: pytypes.h
(error_fetch_and_normalize, error_string, raise_from, memoryview::from_buffer) + guard symbol + pybind11_precompile() + keyword/option
hooks + install rules + tests/extra_python_package/test_files.py updates (new -inl.h and src/ sets for wheel/sdist/global-wheel + pkg-config template) + two tests/test_cmake_build/ cases (subdirectory_precompile, installed_precompile,
cloned from *_target; the installed one catches the @PACKAGE_INIT@ srcdir path
computation, the main config-mode risk) + PYBIND11_TEST_PRECOMPILE option in tests/CMakeLists.txt (when ON: set(PYBIND11_PRECOMPILE ON) before the module
loops — link errors give full symbol coverage of the split) + 3 CI jobs (Linux GCC,
Windows MSVC, macOS; not a matrix dimension) + PYBIND11_TEST_PRECOMPILE: ON in
the tidy preset so clang-tidy sees the -inl.h TUs. Doxygen: add *-inl.h to EXCLUDE_PATTERNS.
detail/class.h — cleanest large win (~850 lines, whole file is non-template
plumbing; slot functions used by address, transparent to move).
detail/type_caster_base.h non-template subset — type_caster_generic::cast, all_type_info_populate, get_type_info, instance::{de,}allocate_layout, loader_life_support, cpp_conduit_method. load_impl<> and all type_caster<T>
members stay.
detail/internals.h + detail/exception_translation.h + common.h pybind11_fail — translate_exception, get_internals and non-template
accessors. Document per-DSO semantics: linking the lib into a shared "core" lib
shared by modules shares local internals — identical to today's behavior with
inline code in a core lib, not a regression.
CLI/pkg-config/setup_helpers/Meson (any time after PR 2) + extend tests/extra_setuptools/test_setuphelper.py with a precompile=True build.
Docs + benchmark numbers: docs/compiling.rst new subsection (keyword, option,
function, caveats: must-match macros, static-only, set standard/defines before first
use, NOPYTHON unsupported), docs/faq.rst "reduce build time" first recommendation, pybind11Config.cmake.in doc header, changelog entry.
Out of scope: embed.h (PyPy), gil.h/attr.h (low payoff), numpy.h/eigen
(template-dominated), shared libpybind11, Meson WrapDB (community, like nanobind).
Future direction (not in this plan): C++20 named module
CLI11 shows the split is the exact prerequisite for a cheap module later: src/modules/CLI11.cppm is a thin export shim — headers in the global module
fragment, export namespace CLI { using CLI::App; ... }, CMake ≥3.28 FILE_SET CXX_MODULES — and it always builds with CLI11_COMPILE defined, so
the BMI carries declarations only and the definitions come from the same Precompile.cpp/library this plan produces. A future pybind11.cppm could copy
that shape verbatim. Known extra friction for pybind11 (why it's future work, not
PR 9): the API is far more macro-driven — PYBIND11_MODULE, PYBIND11_OVERRIDE*, PYBIND11_DECLARE_HOLDER_TYPE cannot be exported from a module (CLI11 has the same
issue with CLI11_PARSE and just documents "include the headers too" / use the
non-macro API; pybind11 has no non-macro spelling of PYBIND11_MODULE), and the
template-heavy casters would sit in the global module fragment anyway. Design
constraint on this plan: nothing here may block that shim — which the
declaration/definition split satisfies by construction. (pybind11 is C++17+, so
CLI11's other trick — inline namespace-scope variables for singletons — is
available if ever needed.)
Verification
Every PR: cmake --workflow venv (or default preset) both with and without -DPYBIND11_TEST_PRECOMPILE=ON; full pytest must pass in both modes. The
cross-module tests (cross_module_gil_utils, cross_module_interleaved_error_already_set, subinterpreter modules) exercise
exactly the per-DSO claims.
ODR safety: grep rule (all -inl.h definitions start with PYBIND11_INLINE);
precompiled CI mode turns any missed definition into a link error across ~50 test
modules.
Benchmark (produce the numbers reviewers demanded in Create a static library mode for pybind11 #4001): A/B clean build of pybind11_tests (~50 TUs) in Debug (MinSizeRel+LTO understates the win — link
dominates) and MinSizeRel; incremental touch one test .cpp rebuild; -ftime-trace on one TU; existing sosize-*.txt hook watches binary size (expect
slight shrink). Small driver script or nox session, run on all three platforms;
optional manual workflow_dispatch CI job, non-blocking.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Draft for maintainer discussion.
🤖 AI text below 🤖
Optional pre-compilation of pybind11 (nanobind-style static library mode)
Context
pybind11 is header-only; every consumer TU re-parses and re-generates ~1,800 lines of
genuinely non-template implementation code (
cpp_function::dispatcheralone is 444 lines,initialize_generic214,error_fetch_and_normalize222, essentially all ofdetail/class.h). Goal: an opt-in mode where that code is compiled once into astatic library built inside the consumer's own project (nanobind's model), while
header-only stays the default and byte-for-byte unchanged. Easy from CMake; possible from
Meson/setuptools/any build system.
Prior art: PR #2445 (full fmtlib-style split; gem5 25→15 min; stalled on process, not
design), PR #4001 (minimal
pybind11::statictarget,common.honly; abandoned;rwgk's blockers — smart_holder in flight, pre-3.0 file org — are now moot), nanobind
(lazy
EXCLUDE_FROM_ALLstatic lib,nb_combined.cppamalgam for non-CMake builds),CLI11 (shipping the identical mechanism in production:
CLI11_INLINEempty underCLI11_COMPILE,include/CLI/impl/*_inl.hpp,#ifndef CLI11_COMPILE #include "impl/X_inl.hpp"at each header's bottom,src/Precompile.cppamalgam, PUBLIC compiledefinition on the lib target — validates every core choice here).
henryiii on record in #4001: "break this up into incremental stand-alone chunks."
Why static-only is safe: per-DSO identity is load-bearing in 4 places
(
get_local_internals_key()address-of-local-static key,module_local_load == &local_loadself-check, libc++ per-DSO exception classes,loader_life_supportthread_local). A static lib linked into each module preserves all of them per-module; no
export/import macros, no versioned namespace, wheel stays
py3-none-any(ships sources).Decisions (user-confirmed)
.cpp+--srcdirCLI + pkg-config var +Pybind11Extension(precompile=True)+ Meson docs snippet.Design
Macros (
include/pybind11/detail/common.h)One user-facing switch:
PYBIND11_PRECOMPILED("definitions were compiledelsewhere") — accurate for both library TUs and consumer TUs; single macro suffices
because there is no DLL import/export asymmetry.
All three current
PYBIND11_NOINLINEexpansions are reproduced exactly; no new GCCinline+noinline warning exposure.
PYBIND11_INLINEis unused today (verified).File layout (fmtlib style, per header)
include/pybind11/<name>-inl.hnext to its header: out-of-line definitions, every onetagged
PYBIND11_INLINE(orPYBIND11_NOINLINE_ATTR PYBIND11_INLINE). Starts with#pragma once+#include "<name>.h".#ifndef PYBIND11_PRECOMPILED→#include "<name>-inl.h" // IWYU pragma: export→#endif(the IWYU pragma is CLI11's touch — keeps include-what-you-use quiet).Included at the same TU point the definitions sit today ⇒ default mode unchanged.
src/<name>.cppone-liners (#include <pybind11/<name>-inl.h>), plussrc/pybind11_combined.cppincluding all-inl.hinpybind11.horder (non-CMakesingle-TU entry point).
(
load_impl<>,internals_pp_manager<T>,atomic_get_or_create_in_state_dict<T>)stay in headers — the latter two need no explicit instantiation: their only
instantiation points move into the library TU. (If a template body ever must move,
CLI11's escape hatch is explicit instantiation in the
.cpp— needed there becauseLTO can strip implicitly-instantiated weak symbols from a shared lib; static-only
mostly dodges that, but prefer keeping templates in headers regardless.)
-inl.hbegins withPYBIND11_INLINE. Watch decl/def drift: default args stay on the declaration;noexceptmust match.Link-time config guard
A function whose name encodes
PYBIND11_INTERNALS_VERSION,Py_GIL_DISABLED,PYBIND11_SIMPLE_GIL_MANAGEMENT,PYBIND11_DETAILED_ERROR_MESSAGES(Debug/Releasemismatch catcher — it defaults on
!NDEBUG): declared indetail/internals.h, definedin
internals-inl.h, odr-used from thePYBIND11_MODULEexpansion only whenPYBIND11_PRECOMPILEDis set. Mismatch or forgot-to-link ⇒ one readable undefinedsymbol instead of pages of link errors. (CLI11 skips this and relies purely on the
PUBLIC compile definition propagating; pybind11 warrants the extra guard because
PYBIND11_DETAILED_ERROR_MESSAGESsilently keys offNDEBUGand the non-CMakepaths hand-define the macro.)
CMake (
tools/pybind11Common.cmake— included in both config & subdirectory modes)UX:
pybind11_add_module(target PRECOMPILE ...)keyword +PYBIND11_PRECOMPILEcacheoption that flips the default for all calls + public
pybind11_precompile()forraw-target users (
target_link_libraries(t PRIVATE pybind11::precompiled)).Lazy builder (nanobind model — works in installed-config mode, the gap in #4001):
Hook in both
tools/pybind11NewTools.cmake(options at :258-259, after thepybind11::headers/module/embedlinks at :277-282) and classictools/pybind11Tools.cmake(options at :142-143, after :161) — classic works free sincepybind11::pybind11carries Python headers in both modes:(PRIVATE link still applies the lib's PUBLIC
PYBIND11_PRECOMPILEDto the module's TUs.)Deliberate choices: no variant suffixing (one Python per build tree; multi-config
generators handle Debug/Release natively; must-match macros ride
pybind11_headersINTERFACE); no LTO/OPT_SIZE on the lib (defeats compile-once; per-module keywords
still affect module TUs); lib inherits consumer's directory flags and
CMAKE_CXX_STANDARDat first use (warn if a PRECOMPILE module's standard differs);not installed/exported.
Packaging / non-CMake
install(DIRECTORY src/ DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/pybind11/src)→wheel path
pybind11/share/pybind11/src/*.cpp, sibling ofshare/cmake; wheel stayspure.
pybind11_SRC_DIRcache var (subdir mode) +@pybind11_SRCDIR@intools/pybind11Config.cmake.in.commands.py:get_source_dir()(installed-then-repo fallback likeget_include);__main__.py:--srcdir;tools/pybind11.pc.in:srcdir=variable.setup_helpers.py:Pybind11Extension(..., precompile=True)appends<srcdir>/pybind11_combined.cpp+ defines the macro; hard error if sources absent(no silent header-only fallback); guarded so the standalone-copied file still works
without the kwarg. Per-extension copy; no cross-extension sharing (documented).
dependency('pybind11').get_variable('srcdir')/pybind11_combined.cpp+-DPYBIND11_PRECOMPILED.PR sequence (each independently mergeable)
PYBIND11_DISABLE_HANDLE_TYPE_NAME_DEFAULT_IMPLEMENTATION/PYBIND11_SIMPLE_GIL_MANAGEMENTfrom globaladd_compile_definitions(CMakeLists.txt:113/:116) onto
pybind11_headersINTERFACE (pattern ofPYBIND11_INTERNALS_VERSIONat :306-309).PYBIND11_NOINLINE_ATTR+PYBIND11_INLINEindetail/common.h.pytypes.h(
error_fetch_and_normalize,error_string,raise_from,memoryview::from_buffer) + guard symbol +pybind11_precompile()+ keyword/optionhooks + install rules +
tests/extra_python_package/test_files.pyupdates (new-inl.handsrc/sets for wheel/sdist/global-wheel + pkg-config template) + twotests/test_cmake_build/cases (subdirectory_precompile,installed_precompile,cloned from
*_target; the installed one catches the@PACKAGE_INIT@srcdir pathcomputation, the main config-mode risk) +
PYBIND11_TEST_PRECOMPILEoption intests/CMakeLists.txt(when ON:set(PYBIND11_PRECOMPILE ON)before the moduleloops — link errors give full symbol coverage of the split) + 3 CI jobs (Linux GCC,
Windows MSVC, macOS; not a matrix dimension) +
PYBIND11_TEST_PRECOMPILE: ONinthe
tidypreset so clang-tidy sees the-inl.hTUs. Doxygen: add*-inl.htoEXCLUDE_PATTERNS.detail/class.h— cleanest large win (~850 lines, whole file is non-templateplumbing; slot functions used by address, transparent to move).
detail/type_caster_base.hnon-template subset —type_caster_generic::cast,all_type_info_populate,get_type_info,instance::{de,}allocate_layout,loader_life_support,cpp_conduit_method.load_impl<>and alltype_caster<T>members stay.
detail/internals.h+detail/exception_translation.h+common.hpybind11_fail—translate_exception,get_internalsand non-templateaccessors. Document per-DSO semantics: linking the lib into a shared "core" lib
shared by modules shares local internals — identical to today's behavior with
inline code in a core lib, not a regression.
pybind11.h— the headline:dispatcher,initialize_generic,destruct,generate_function_signature,generic_type::initialize,enum_base,get_type_override,keep_alive_impl, module-cache helpers.descr.h-constexprmachinery stays.
tests/extra_setuptools/test_setuphelper.pywith aprecompile=Truebuild.docs/compiling.rstnew subsection (keyword, option,function, caveats: must-match macros, static-only, set standard/defines before first
use, NOPYTHON unsupported),
docs/faq.rst"reduce build time" first recommendation,pybind11Config.cmake.indoc header, changelog entry.Out of scope:
embed.h(PyPy),gil.h/attr.h(low payoff),numpy.h/eigen(template-dominated), shared libpybind11, Meson WrapDB (community, like nanobind).
Future direction (not in this plan): C++20 named module
CLI11 shows the split is the exact prerequisite for a cheap module later:
src/modules/CLI11.cppmis a thin export shim — headers in the global modulefragment,
export namespace CLI { using CLI::App; ... }, CMake ≥3.28FILE_SET CXX_MODULES— and it always builds withCLI11_COMPILEdefined, sothe BMI carries declarations only and the definitions come from the same
Precompile.cpp/library this plan produces. A futurepybind11.cppmcould copythat shape verbatim. Known extra friction for pybind11 (why it's future work, not
PR 9): the API is far more macro-driven —
PYBIND11_MODULE,PYBIND11_OVERRIDE*,PYBIND11_DECLARE_HOLDER_TYPEcannot be exported from a module (CLI11 has the sameissue with
CLI11_PARSEand just documents "include the headers too" / use thenon-macro API; pybind11 has no non-macro spelling of
PYBIND11_MODULE), and thetemplate-heavy casters would sit in the global module fragment anyway. Design
constraint on this plan: nothing here may block that shim — which the
declaration/definition split satisfies by construction. (pybind11 is C++17+, so
CLI11's other trick —
inlinenamespace-scope variables for singletons — isavailable if ever needed.)
Verification
cmake --workflow venv(ordefaultpreset) both with and without-DPYBIND11_TEST_PRECOMPILE=ON; full pytest must pass in both modes. Thecross-module tests (
cross_module_gil_utils,cross_module_interleaved_error_already_set, subinterpreter modules) exerciseexactly the per-DSO claims.
test_cmake_buildcovers keyword + config-mode lazy creation;test_files.py(nox -s tests_packaging) covers wheel/sdist file sets.
-inl.hdefinitions start withPYBIND11_INLINE);precompiled CI mode turns any missed definition into a link error across ~50 test
modules.
pybind11_tests(~50 TUs) in Debug (MinSizeRel+LTO understates the win — linkdominates) and MinSizeRel; incremental
touch one test .cpprebuild;-ftime-traceon one TU; existingsosize-*.txthook watches binary size (expectslight shrink). Small driver script or nox session, run on all three platforms;
optional manual
workflow_dispatchCI job, non-blocking.prek -a --quietbefore each commit.All reactions