[feat/precompile-tooling] docs: document the opt-in precompiled mode (#6138)
* docs: document the opt-in precompiled mode
Assisted-by: ClaudeCode:claude-fable-5
* chore: lint the -inl.h invariants of the precompiled mode
tools/check_inl_headers.py checks that each namespace-scope definition
in an -inl.h file has PYBIND11_INLINE, and that each macro in a
preprocessor condition is known: either the same for the library and
the module, or encoded in the precompiled config guard. Also document
that a custom PYBIND11_NAMESPACE must reach the library.
Assisted-by: ClaudeCode:claude-opus-5-5
* docs: note the call overhead of a non-LTO precompiled library
Release build, trivial bound functions, against an LTO header-only
module: AppleClang arm64 adds 2-4 ns per call (7-11%), GCC 15 Linux
aarch64 adds 1-2 ns (1-7%). With LTO on the library, both are within a
few percent.
Assisted-by: ClaudeCode:claude-opus-5-5
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6251152..d61e642 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -141,6 +141,11 @@
language: pygrep
entry: PyBind|\bNumpy\b|Cmake|CCache|PyTest
exclude: ^\.pre-commit-config.yaml$
+ - id: check-inl-headers
+ name: Check -inl.h files for the precompiled mode
+ language: python
+ entry: python tools/check_inl_headers.py
+ files: -inl\.h$
# PyLint has native support - not always usable, but works for us
- repo: https://github.com/PyCQA/pylint
diff --git a/docs/compiling.rst b/docs/compiling.rst
index a6bee86..a81b16f 100644
--- a/docs/compiling.rst
+++ b/docs/compiling.rst
@@ -348,7 +348,8 @@
.. code-block:: cmake
pybind11_add_module(<name> [MODULE | SHARED] [EXCLUDE_FROM_ALL]
- [NO_EXTRAS] [THIN_LTO] [OPT_SIZE] source1 [source2 ...])
+ [NO_EXTRAS] [THIN_LTO] [OPT_SIZE] [PRECOMPILE | NO_PRECOMPILE]
+ source1 [source2 ...])
This function behaves very much like CMake's builtin ``add_library`` (in fact,
it's a wrapper function around that command). It will add a library target
@@ -404,6 +405,104 @@
.. _ThinLTO: http://clang.llvm.org/docs/ThinLTO.html
+.. _precompile-mode:
+
+Pre-compiling part of pybind11
+------------------------------
+
+pybind11 is header-only by default: every translation unit compiles its own
+copy of the non-template implementation. The opt-in *precompiled* mode
+compiles that implementation once, into a static library built inside your
+own project with your own flags. This reduces the build time, most of all for
+projects with many translation units or many modules in one build.
+
+.. code-block:: cmake
+
+ pybind11_add_module(example PRECOMPILE example.cpp)
+
+The first ``PRECOMPILE`` target creates the library target
+``pybind11::precompiled``; further targets reuse it. Set the CMake variable
+``PYBIND11_PRECOMPILE`` to make it the default for all
+``pybind11_add_module`` calls; use ``NO_PRECOMPILE`` on a target to opt back
+out. For targets you create yourself, call the ``pybind11_precompile()``
+function and link ``pybind11::precompiled`` PRIVATE; the target carries the
+required ``PYBIND11_PRECOMPILED`` compile definition PUBLIC, so your sources
+also get it.
+
+Requirements and caveats:
+
+* The library and every module linking it must agree on the configuration
+ macros ``PYBIND11_INTERNALS_VERSION``, ``Py_GIL_DISABLED``,
+ ``PYBIND11_SIMPLE_GIL_MANAGEMENT``,
+ ``PYBIND11_DETAILED_ERROR_MESSAGES`` (defaults on in debug builds),
+ ``PYBIND11_HAS_SUBINTERPRETER_SUPPORT``, and
+ ``PYBIND11_BACKWARD_COMPATIBILITY_TP_DICTOFFSET``. A mismatch produces one
+ readable undefined symbol at link time referencing
+ ``pybind11_precompiled_config``.
+* Configuration macros that only change code inside the library (for example
+ ``PYBIND11_DISABLE_NEW_STYLE_INIT_WARNING``) must be defined when the
+ library is compiled; a definition only on your module has no effect. This
+ also applies to a custom ``PYBIND11_NAMESPACE`` visibility attribute: set
+ it at the directory level (``add_compile_definitions``) so that the library
+ gets it too.
+* The library picks up your directory-level flags and C++ standard when it is
+ first created, so set those before the first ``PRECOMPILE`` target. A
+ status message reports the directory that created the library.
+* The library is not compiled with link-time optimization, and the per-target
+ ``THIN_LTO`` and ``OPT_SIZE`` options of ``pybind11_add_module`` do not
+ apply to it. In a Release build this adds a few nanoseconds to each call of
+ a bound function (up to about 10% for a function that does nothing,
+ depending on the compiler; a few percent with LTO on the library). To change this, call ``pybind11_precompile()``
+ yourself and
+ set the properties on the created target, ``pybind11_precompiled`` (the
+ real target behind the ``pybind11::precompiled`` alias; CMake does not let
+ you set properties through an alias):
+
+ .. code-block:: cmake
+
+ pybind11_precompile()
+ set_target_properties(pybind11_precompiled PROPERTIES
+ INTERPROCEDURAL_OPTIMIZATION ON)
+
+* The library is static and per-build-tree; it is never installed or shared
+ between projects. Each extension module links its own copy, which keeps
+ pybind11's per-module state the same as in header-only mode.
+* Not available with ``PYBIND11_NOPYTHON`` (the library needs Python
+ headers).
+
+For build systems other than CMake, the same sources ship with the pybind11
+package: compile ``pybind11_combined.cpp`` from the directory reported by
+``python -m pybind11 --srcdir`` (also available as
+``pybind11.get_source_dir()`` and the ``srcdir`` pkg-config variable) into
+a static library or into your extension, and define
+``PYBIND11_PRECOMPILED`` for every translation unit.
+
+With Meson, build the library once per build tree and link it into each
+extension module, the same as the CMake path:
+
+.. code-block:: meson
+
+ pybind11_dep = dependency('pybind11')
+ pybind11_src = run_command(py, ['-m', 'pybind11', '--srcdir'],
+ check : true).stdout().strip()
+
+ pybind11_precompiled = static_library('pybind11_precompiled',
+ pybind11_src / 'pybind11_combined.cpp',
+ cpp_args : ['-DPYBIND11_PRECOMPILED'],
+ gnu_symbol_visibility : 'hidden',
+ dependencies : [pybind11_dep, py.dependency()])
+
+ py.extension_module('example', 'example.cpp',
+ cpp_args : ['-DPYBIND11_PRECOMPILED'],
+ link_with : pybind11_precompiled,
+ dependencies : [pybind11_dep])
+
+The configuration-macro rules above apply here too: the static library and
+every module that links it must be compiled with the same configuration
+macros, and ``-DPYBIND11_PRECOMPILED`` must appear in both ``cpp_args``
+lists. (``pybind11_dep.get_variable('srcdir')`` also reports the source
+directory when Meson finds pybind11 through pkg-config.)
+
Configuration variables
-----------------------
diff --git a/docs/faq.rst b/docs/faq.rst
index 2b89d20..8a061c8 100644
--- a/docs/faq.rst
+++ b/docs/faq.rst
@@ -79,7 +79,12 @@
How can I reduce the build time?
================================
-It's good practice to split binding code over multiple files, as in the
+First, consider the opt-in precompiled mode: it compiles the non-template
+part of pybind11 once per project instead of once for each translation unit.
+In CMake, this is one keyword on ``pybind11_add_module``. See
+:ref:`precompile-mode`.
+
+It's also good practice to split binding code over multiple files, as in the
following example:
:file:`example.cpp`:
diff --git a/include/pybind11/detail/internals.h b/include/pybind11/detail/internals.h
index 224198c..164da39 100644
--- a/include/pybind11/detail/internals.h
+++ b/include/pybind11/detail/internals.h
@@ -52,7 +52,8 @@
// the library and the modules linking it. PYBIND11_MODULE calls it, so a mismatch (or a
// missing library) surfaces as one readable undefined symbol at link time instead of many
// unrelated ones at run time. If you add a configuration macro that changes the code in the
-// -inl.h files, encode it here and add it to the list in docs/compiling.rst.
+// -inl.h files, encode it here, add it to the list in docs/compiling.rst, and add it to
+// tools/check_inl_headers.py.
# if defined(Py_GIL_DISABLED)
# define PYBIND11_PRECOMPILED_CFG_GD 1
# else
diff --git a/tools/check_inl_headers.py b/tools/check_inl_headers.py
new file mode 100755
index 0000000..6787fb7
--- /dev/null
+++ b/tools/check_inl_headers.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+"""
+Check the invariants of the -inl.h files used by the precompiled mode:
+
+* Every namespace-scope function definition is marked PYBIND11_INLINE.
+* Every macro in a preprocessor condition is known. A macro that changes the
+ code must either be the same for the library and the modules (platform,
+ compiler, Python version), or be encoded in PYBIND11_PRECOMPILED_CONFIG_CHECK
+ (detail/internals.h) and listed in docs/compiling.rst.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+# Same in the library and in the modules that link it.
+ENVIRONMENT_MACROS = {
+ "GRAALVM_PYTHON",
+ "NDEBUG",
+ "PYBIND11_BUILTIN_QUALNAME",
+ "PYBIND11_HAS_STRING_VIEW",
+ "PYBIND11_PRECOMPILED",
+ "PYPY_VERSION",
+ "PY_MAJOR_VERSION",
+ "PY_MINOR_VERSION",
+ "PY_VERSION_HEX",
+ "Py_REF_DEBUG",
+ "_MSC_VER",
+ "_WIN32",
+ "__GLIBCXX__",
+ "__GNUC__",
+ "__cpp_lib_unordered_map_try_emplace",
+ "__clang__",
+}
+
+# Encoded in PYBIND11_PRECOMPILED_CONFIG_CHECK.
+GUARDED_MACROS = {
+ "PYBIND11_BACKWARD_COMPATIBILITY_TP_DICTOFFSET",
+ "PYBIND11_DETAILED_ERROR_MESSAGES",
+ "PYBIND11_HAS_SUBINTERPRETER_SUPPORT",
+ "PYBIND11_INTERNALS_VERSION",
+ "PYBIND11_SIMPLE_GIL_MANAGEMENT",
+ "Py_GIL_DISABLED",
+}
+
+# Only change code inside the library; documented in docs/compiling.rst.
+LIBRARY_ONLY_MACROS = {
+ "PYBIND11_DISABLE_NEW_STYLE_INIT_WARNING",
+}
+
+KNOWN_MACROS = ENVIRONMENT_MACROS | GUARDED_MACROS | LIBRARY_ONLY_MACROS
+
+TOKENS = re.compile(
+ r"""
+ (?P<raw>R"(?P<delim>[^(\s]*)\(.*?\)(?P=delim)")
+ | (?P<str>"(?:\\.|[^"\\\n])*")
+ | (?P<chr>'(?:\\.|[^'\\\n])*')
+ | (?P<line_comment>//[^\n]*)
+ | (?P<block_comment>/\*.*?\*/)
+ """,
+ re.VERBOSE | re.DOTALL,
+)
+PP_CONDITION = re.compile(r"^[ \t]*#\s*(?:if|elif|ifdef|ifndef)\b(.*)$", re.MULTILINE)
+PP_LINE = re.compile(r"^[ \t]*#.*$", re.MULTILINE)
+NAMESPACE_MACRO = re.compile(
+ r"^[ \t]*PYBIND11_(?:NAMESPACE_BEGIN|NAMESPACE_END|WARNING_\w+)\(.*\)[ \t]*$",
+ re.MULTILINE,
+)
+IDENTIFIER = re.compile(r"\b[A-Za-z_]\w*\b")
+NON_FUNCTION_BLOCK = re.compile(r"^(?:struct|class|union|enum)\b|=$")
+
+
+def strip(text: str) -> str:
+ """Blank out comments and string literals, keeping the line numbers."""
+
+ def blank(match: re.Match[str]) -> str:
+ if match.group("line_comment") or match.group("block_comment"):
+ return re.sub(r"[^\n]", " ", match.group())
+ return '""' + "\n" * match.group().count("\n")
+
+ return TOKENS.sub(blank, text)
+
+
+def check(path: Path) -> list[str]:
+ text = strip(path.read_text(encoding="utf-8"))
+ errors: list[str] = []
+
+ for match in PP_CONDITION.finditer(text):
+ line = text.count("\n", 0, match.start()) + 1
+ errors.extend(
+ f"{path}:{line}: unknown configuration macro {name}; see {Path(__file__).name}"
+ for name in IDENTIFIER.findall(match.group(1))
+ if name != "defined" and name not in KNOWN_MACROS
+ )
+
+ code = PP_LINE.sub(lambda m: " " * len(m.group()), text)
+ code = NAMESPACE_MACRO.sub(lambda m: " " * len(m.group()), code)
+
+ # One entry per open brace: True for namespace and extern "C" blocks, whose
+ # contents are still at namespace scope.
+ scopes: list[bool] = []
+ start = 0
+ for pos, char in enumerate(code):
+ at_namespace_scope = all(scopes)
+ if char == "{":
+ if not at_namespace_scope:
+ scopes.append(False)
+ continue
+ header = " ".join(code[start:pos].split())
+ transparent = header == 'extern "C"' or header.startswith("namespace")
+ if (
+ not transparent
+ and "(" in header
+ and not NON_FUNCTION_BLOCK.search(header)
+ and "PYBIND11_INLINE" not in header.split()
+ ):
+ offset = start + len(code[start:pos]) - len(code[start:pos].lstrip())
+ line = code.count("\n", 0, offset) + 1
+ errors.append(f"{path}:{line}: definition without PYBIND11_INLINE")
+ scopes.append(transparent)
+ if transparent:
+ start = pos + 1
+ elif char == "}":
+ scopes.pop()
+ if all(scopes):
+ start = pos + 1
+ elif char == ";" and at_namespace_scope:
+ start = pos + 1
+
+ return errors
+
+
+def main(argv: list[str]) -> int:
+ errors = [error for arg in argv for error in check(Path(arg))]
+ for error in errors:
+ print(error)
+ return 1 if errors else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/tools/pybind11Config.cmake.in b/tools/pybind11Config.cmake.in
index abcd43e..d666e9c 100644
--- a/tools/pybind11Config.cmake.in
+++ b/tools/pybind11Config.cmake.in
@@ -18,6 +18,9 @@
Directories where pybind11 and python headers are located.
``pybind11_INCLUDE_DIR``
Directory where pybind11 headers are located.
+``pybind11_SRC_DIR``
+ Directory where the library sources for the opt-in precompiled mode are
+ located (used by ``pybind11_precompile``).
``pybind11_DEFINITIONS``
Definitions necessary to use pybind11, namely USING_pybind11.
``pybind11_LIBRARIES``
@@ -147,6 +150,7 @@
pybind11_add_module(<target>
[STATIC|SHARED|MODULE]
[THIN_LTO] [OPT_SIZE] [NO_EXTRAS] [WITHOUT_SOABI]
+ [PRECOMPILE|NO_PRECOMPILE]
<files>...
)
@@ -162,6 +166,22 @@
Disable the SOABI component (``PYBIND11_FINDPYTHON`` mode only).
``NO_EXTRAS``
Disable all extras, exit immediately after making the module.
+``PRECOMPILE``
+ Link the target against the ``pybind11::precompiled`` static library
+ (created on first use); ``NO_PRECOMPILE`` opts a target out when the
+ ``PYBIND11_PRECOMPILE`` variable enables it globally.
+
+pybind11_precompile
+^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: cmake
+
+ pybind11_precompile()
+
+Create the ``pybind11::precompiled`` static library from the shipped sources
+(once per build tree). ``pybind11_add_module(... PRECOMPILE)`` calls this for
+you; call it directly to link ``pybind11::precompiled`` into your own
+targets.
pybind11_strip
^^^^^^^^^^^^^^