Skip to content

stack: complete pre-compile stack - #6146

Draft
henryiii wants to merge 14 commits into
masterfrom
feat/precompile-docs
Draft

henryiii wants to merge 14 commits into
masterfrom
feat/precompile-docs

Conversation

@henryiii

@henryiii henryiii commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

This is the full stack, to show all changes at once. GH stacked PRs don't seem to have that option.

We'll actually merge #6138 as a stack (#6139 is the stack, but doesn't link).

  • chore(cmake): carry option-driven defines on pybind11_headers INTERFACE
  • feat: add PYBIND11_NOINLINE_ATTR and PYBIND11_INLINE macros
  • feat(cmake): opt-in precompiled pybind11 static library, starting with pytypes.h
  • fix(tidy): keep the tidy preset header-only
  • fix(cmake): compile the precompiled library with the interpreter ABI macros
  • feat: move detail/class.h definitions to class-inl.h
  • fix: suppress -Wredundant-decls for class.h declarations
  • feat: move type_caster_base.h non-template definitions to type_caster_base-inl.h
  • fix: make gil.h self-contained for PyPy/GraalPy and silence -Wredundant-decls
  • feat: move internals.h, exception_translation.h, and pybind11_fail out of line
  • fix: drop duplicate translate_exception declaration
  • feat: move pybind11.h non-template definitions to pybind11-inl.h
  • fix: drop redundant redeclarations in pybind11.h
  • feat: expose the precompiled-mode sources to non-CMake builds
  • docs: document the opt-in precompiled mode

📚 Documentation preview 📚: https://pybind11--6146.org.readthedocs.build/

Decompose PYBIND11_NOINLINE into the attribute part plus inline, and
add PYBIND11_INLINE, which becomes empty under PYBIND11_PRECOMPILED.
Groundwork for optional pre-compilation; all current expansions are
unchanged and PYBIND11_INLINE is not used yet.

Assisted-by: ClaudeCode:claude-fable-5
…h pytypes.h

Split the out-of-line pytypes.h definitions into pytypes-inl.h (fmtlib/
CLI11 style): inline by default, compiled once into a per-project static
library when PYBIND11_PRECOMPILED is defined. Infrastructure:

- pybind11_precompile() creates the lazy pybind11::precompiled STATIC
  library from the installed or in-tree src/ sources; PRECOMPILE /
  NO_PRECOMPILE keywords on pybind11_add_module and a global
  PYBIND11_PRECOMPILE switch select it per target.
- A link-time guard symbol encodes PYBIND11_INTERNALS_VERSION,
  Py_GIL_DISABLED, PYBIND11_SIMPLE_GIL_MANAGEMENT, and
  PYBIND11_DETAILED_ERROR_MESSAGES, so a configuration mismatch is one
  readable undefined symbol.
- src/ is installed to share/pybind11/src (wheel stays pure);
  src/pybind11_combined.cpp is a single-TU build for non-CMake use.
- Tests: PYBIND11_TEST_PRECOMPILE builds the whole suite against the
  library, two new test_cmake_build cases, packaging file lists, tidy
  preset, and a 3-platform CI job.

Assisted-by: ClaudeCode:claude-fable-5
In precompiled mode the -inl.h definitions are intentionally
non-inline, so misc-definitions-in-headers fires on every one. The
header-only tidy build already analyzes all -inl.h bodies via the
bottom-of-header includes.

Assisted-by: ClaudeCode:claude-fable-5
…macros

pybind11::pybind11 only carries headers; Py_GIL_DISABLED lives on
Python::Module via pybind11::module. Without it the library is
ABI-mismatched on free-threaded builds, and on Windows the pyconfig.h
autolink pragma in its objects requests pythonXY.lib instead of
pythonXYt.lib.

Assisted-by: ClaudeCode:claude-fable-5
All 32 functions in detail/class.h are non-template plumbing (type/slot
machinery); move them out of line for the precompiled mode. Forward
declarations of these functions in other headers lose their inline
keyword to stay ODR-consistent in both modes.

Assisted-by: ClaudeCode:claude-fable-5
Several functions are forward-declared in headers that cannot include
class.h; GCC's -Wredundant-decls (used in CI cxx_flags) flags the
second declaration.

Assisted-by: ClaudeCode:claude-fable-5
…_base-inl.h

Moves the free functions (type-info lookup and registration, instance
layout, isinstance_generic, cpp_conduit_method, type_info_description),
the loader_life_support members (the function-local thread_local stack
stays per-module in both modes), and the two heavy type_caster_generic
members (the type_info constructor and the main cast overload).
Templates, including load_impl<>, stay in the header.

Assisted-by: ClaudeCode:claude-fable-5
…nt-decls

gil.h evaluated PYBIND11_SIMPLE_GIL_MANAGEMENT before including common.h,
which defines it on PyPy/GraalPy. Every existing TU included common.h
first through pybind11.h, so this only surfaced when src/type_caster_base.cpp
reached gil.h directly. Also suppress GCC -Wredundant-decls for the
isinstance_generic declaration duplicated in pytypes.h.

Assisted-by: ClaudeCode:claude-fable-5
…t of line

The internals accessor family (get_internals, ensure_internals, the
local-internals key and capsules, exception translators) moves into
internals-inl.h; per-module identity is unchanged because the
function-local statics move with their functions into whatever binary
each module links. Also adds common-inl.h (pybind11_fail) and
exception_translation-inl.h. Tiny hot accessors and all templates stay
in the headers.

Assisted-by: ClaudeCode:claude-fable-5
Assisted-by: ClaudeCode:claude-fable-5
The headline of the split: cpp_function's make_function_record,
initialize_generic, destruct, and the 440-line dispatcher move out of
line, along with generic_type::initialize, enum_base, the function
signature generators, module cache helpers, keep_alive_impl,
get_type_override, error_already_set::what, and detail::print. The
templated initialize(), descr.h machinery, and the module/class_ API
stay in the header.

Assisted-by: ClaudeCode:claude-fable-5
These functions are already declared in function_record_pyobject.h,
attr.h, and type_caster_base.h; GCC 13 -Wredundant-decls rejects the
repeats.

Assisted-by: ClaudeCode:claude-fable-5
Adds pybind11.get_source_dir() / python -m pybind11 --srcdir and a
srcdir variable in pybind11.pc, so build systems such as Meson can
compile src/pybind11_combined.cpp with PYBIND11_PRECOMPILED defined.

Assisted-by: ClaudeCode:claude-fable-5
Assisted-by: ClaudeCode:claude-fable-5
@rwgk

rwgk commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Below is a complete transcript of a short codex gpt-5.6-sol medium session.

@henryiii codex has five ideas for things to look into. After those are reviewed/resolved, merging the stack would seem fine to me.


Codex transcript

  • Exported: 2026-09-13 14:47 PDT
  • Session last activity: 2026-09-13 14:44 PDT
  • Session ID: 01a09ca0-570d-7642-93b3-255664a3fe34

User — 2026-09-13T14:23:37-07:00 (PDT)

Could you please help me get a basic understanding of PR 6146 (actually: the stack under 6138)?

Local branch here for easy reference: upstream→feat/precompile-docs

I see there are 7 new -inl.h files:

./detail/exception_translation-inl.h
./detail/internals-inl.h
./detail/class-inl.h
./detail/type_caster_base-inl.h
./detail/common-inl.h
./pytypes-inl.h
./pybind11-inl.h

So for those files, we'd have to maintain a pair of files, instead of just one, in the the future.

My long-standing bias has been:
* pros: gains in compile time and binary sizes
* cons: loss of human time

Before LLMs, I considered the loss of human time too high a cost.

But where do we stand now? I hardly ever still modify source code manually anymore.

Codex — 2026-09-13T14:23:43-07:00 (PDT) · +5s after User

I’ll trace the stack from the local refs, map what moved into each -inl.h, and look for measured compile-time/binary-size evidence. Then I’ll separate the architectural tradeoff from the “LLMs reduce editing cost” question, since review and API/ABI reasoning may still dominate maintenance time.

Codex — 2026-09-13T14:23:58-07:00 (PDT) · +14s after Codex · +20s since User

The branch is a 14-commit stack, not one monolithic patch. The central trick is already clear: header-only mode still includes every -inl.h, while PYBIND11_PRECOMPILED suppresses those includes and supplies the same definitions from a consumer-built static library. I’m now checking the individual PR boundaries and benchmark claims, because that determines whether the added maintenance surface is justified.

Codex — 2026-09-13T14:26:48-07:00 (PDT) · +2m 50s after Codex · +3m 10s since User

Your understanding is essentially right, with one important qualification: this creates declaration/definition pairs, not two independently maintained implementations.

What the stack does

In normal mode, nothing changes for consumers:

translation unit
  └─ pybind11.h
       └─ pybind11-inl.h   // definitions remain inline

With PYBIND11_PRECOMPILED:

translation unit                 per-build static library
  └─ pybind11.h                    └─ src/pybind11.cpp
       declarations only               └─ pybind11-inl.h
                                           non-inline definitions

PYBIND11_INLINE expands to inline in header-only mode and to nothing in precompiled mode; see common.h. CMake constructs the static library once and links a private copy into each extension; see pybind11Common.cmake.

Thus:

  • Changing a function body generally touches only its -inl.h.
  • Changing its signature touches both declaration and definition.
  • Adding a function requires deciding whether it is templated/hot/config-sensitive and therefore stays in the header, or can move out of line.
  • Header-only remains the default and is intended to behave identically.
  • This is not a distributed pybind11 binary library. The consumer builds it locally with its own compiler and flags.

The merge stack is #6132#6138: infrastructure and pytypes, then class, type_caster_base, internals/exception handling, pybind11.h, non-CMake tooling, and finally documentation. #6146 is merely the full-stack view; #6138 is the top of the merge stack. #6146 overview, #6138 top PR.

What actually moved

The split is selective:

  • class-inl.h: the non-template Python type/slot machinery.
  • type_caster_base-inl.h: type lookup, instance layout, and the heavy non-template caster paths.
  • internals-inl.h: internals accessors and their function-local state.
  • pybind11-inl.h: notably the large function dispatcher and generic registration machinery.
  • pytypes-inl.h: exception formatting and a few Python object helpers.
  • common-inl.h and exception_translation-inl.h: small supporting pieces.

Templates and deliberately hot functions remain in the original headers. So this is not an attempt to turn pybind11 into a conventional compiled library; it extracts the substantial non-template subset.

Are the benefits real?

Yes, though binary size is not an unconditional win.

The stack reports:

Workload Compile result Binary result
pybind11 tests, ~50 TUs −26% CPU, −15% clean wall, −22% incremental wall about −1.6 MB in Debug
Boost.Histogram clean CPU approximately unchanged; −5% wall; project TUs −13% +33 KB without LTO
PyTorch, 165 TUs, with LTO −20% CPU, −14% wall −9.9%, or 2.69 MB

The Boost.Histogram experiment is especially informative: precompilation removes a roughly fixed cost from each TU, but template-heavy application code can dominate enough that the one-time library compilation cancels the clean-build CPU saving. Incremental rebuilds still benefit. Boost.Histogram measurements, PyTorch measurements.

These are promising author-run measurements, not yet a broad independent benchmark corpus.

My view on the human-time tradeoff

I would now favor the architecture, but not primarily because LLMs can edit both files.

The strongest argument is aggregate human time: a modest maintainer burden can save repeated compiler time for thousands of downstream developers and CI jobs. The split is also ordinary C++ structure, and the canonical implementation still exists in exactly one place.

LLMs substantially reduce:

  • Moving definitions and synchronizing signatures.
  • Finding the corresponding declaration.
  • Adding the repetitive PYBIND11_INLINE annotations and source wrappers.
  • Updating mechanical tests and packaging lists.

They do not substantially reduce—and may increase—the expensive parts:

  • Deciding whether a function is safe to compile once.
  • Auditing configuration macros that affect its body.
  • Preserving per-DSO and function-local-static identity.
  • Detecting runtime-performance regressions from lost inlining.
  • Reviewing generated changes across two operating modes.
  • Supporting CMake, setuptools/Meson/manual builds, Python ABIs, compilers, and platforms.

So I would reframe the cost as:

The seven pairs are a small navigational cost. The real cost is permanently supporting two compilation models.

That cost is material, but the measurements are large enough to justify it—especially because the feature is opt-in.

What I would want resolved before merging

A few things stand out on the current head:

  1. The custom PYBIND11_NAMESPACE case appears unresolved. A review comment notes that target-specific namespace definitions are not propagated to the precompiled library, leading to undefined symbols. Namespace review finding.

  2. The PR metadata is stale: [feat/precompile-tooling] docs: document the opt-in precompiled mode #6138’s proposed changelog and [feat/precompile-pybind11-h] feat: expose the precompiled-mode sources to non-CMake builds #6137’s description mention Pybind11Extension(precompile=True), but the local final branch has no such setup_helpers.py implementation.

  3. “Every -inl.h definition has PYBIND11_INLINE” is currently largely a documented convention. I would add a mechanical check for that invariant.

  4. Configuration sensitivity remains the biggest long-term trap. The link guard helpfully encodes several important macros in a symbol name, but future macros affecting -inl.h code must be added manually; see internals.h.

  5. I would want at least a small runtime benchmark around dispatch/casting and Linux GCC results. The current evidence concentrates on compile time and AppleClang.

My bottom line: conditional yes. LLMs move your old cost calculation in favor of accepting the source split, but the decisive point is that this concentrates implementation rather than duplicating it and produces credible downstream savings. I would approve the direction while treating configuration correctness and dual-mode CI—not the seven extra filenames—as the main review burden.

User — 2026-09-13T14:43:42-07:00 (PDT) · +16m 53s after Codex · prior Codex turn: 3m 10s

Excellent, thanks. Another thought that has been in the back of my mind in the past:

Does the split make future structural refactoring work more difficult?

How relevant is that concern in this new LLM world?

Codex — 2026-09-13T14:44:11-07:00 (PDT) · +29s after User

Yes—this is a more substantial concern than the routine cost of maintaining two files. LLMs reduce it, but do not eliminate it.

The effect depends strongly on the kind of refactoring.

Local implementation changes are nearly unaffected. Renaming variables, restructuring a function body, extracting an internal helper, or changing an algorithm happens mostly inside the -inl.h.

Interface-level refactoring becomes moderately harder:

  • Renaming or moving functions requires coordinating declarations, definitions, forward declarations, and callers.
  • Changing a function from templated to non-templated—or vice versa—moves it across the header/-inl.h boundary.
  • Changing class structure may require edits far apart in the two files.
  • Code navigation no longer presents the class and its implementation together.

Dependency-level refactoring is the serious case. The split introduces constraints that do not exist in a purely header-only implementation:

  • The declarations must compile without their definitions.
  • Each src/*.cpp must encounter definitions in a workable include order.
  • Configuration macros affecting a definition must agree between the extension and static library.
  • Function-local state must retain the intended per-module identity.
  • A helper formerly available merely because it appeared earlier in a large header may now need a declaration or a real dependency edge.

The stack itself demonstrates this. Splitting the code exposed a non-self-contained gil.h, several redundant/incorrect forward declarations, and missing interpreter ABI definitions for the static library. Those are partly implementation friction, but also useful architectural discoveries: the old monolithic include path had been concealing dependencies.

There is therefore an interesting counterpoint: the split can make some structural refactoring easier by imposing a clearer interface/implementation boundary. A 2,600-line pybind11.h with order-dependent definitions is not necessarily more refactorable just because everything is physically together. The new 1,500-line pybind11-inl.h makes the non-template implementation recognizable as a distinct layer.

What LLMs change

LLMs are very good at the graph-rewriting part:

  • Find all declarations, definitions, and forward declarations.
  • Move functions while preserving conditionals and namespaces.
  • Update signatures consistently.
  • Diagnose common include-order and undefined-symbol failures.
  • Compare behavior in the two modes.

But they do not remove the state-space expansion. After the split, every structural change still has to be correct under:

header-only × precompiled
× compiler/platform
× Python ABI
× configuration macros
× one-module/multiple-module ownership

An LLM can generate the patch rapidly; it cannot infer that all combinations are safe without suitable tests. In fact, cheap generation can make this worse psychologically: a large structural rewrite looks effortless even though its verification cost has not fallen proportionally.

My updated assessment would be:

LLMs reduce structural-refactoring friction from “potentially prohibitive” to “manageable,” but they do not reduce the architectural risk nearly as much as they reduce the editing cost.

I would not reject the stack for this reason, provided the project keeps a simple, stable partitioning rule:

  • Templates and genuinely hot/config-sensitive code stay in .h.
  • Ordinary non-template implementation goes in -inl.h.
  • Moving code across that boundary is treated as a distinct architectural change.
  • Both modes remain first-class CI configurations.
  • The project resists creating still finer-grained splits.

The last point matters. Seven fairly coarse implementation files are manageable. If this evolves into dozens of small -inl.h files with complicated mutual dependencies, structural work becomes meaningfully worse even with excellent LLM assistance.

So my concern level is moderate but not blocking. I would count it as a real continuing cost of the proposal—not merely an obsolete pre-LLM concern—but the current coarse split looks below the threshold where it should outweigh the measured benefits.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants