Skip to content

Python: table-driven registration to shrink generated modules, without touching pybind11 internals - #44

Open
Fedr wants to merge 4 commits into
masterfrom
py-tables
Open

Python: table-driven registration to shrink generated modules, without touching pybind11 internals#44
Fedr wants to merge 4 commits into
masterfrom
py-tables

Conversation

@Fedr

@Fedr Fedr commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

A replacement for #42, which was rightly rejected for copying pybind11 internals.

Same goal: bound methods and fields are described by constexpr data tables instead of one straight-line chain of template instantiations per entity. That long tail of tiny near-duplicate instantiations is what dominates the size of large generated modules (they differ only by embedded function pointers and name strings, so linker ICF can't fold them).

This version reimplements nothing from pybind11

The set of pybind11::detail:: names used by core.h is exactly the same as on master:

pybind11::detail::get_global_type_info
pybind11::detail::is_copy_assignable
pybind11::detail::is_copy_constructible
pybind11::detail::str_attr_accessor
pybind11::detail::type_info

No add_class_method, no def_property_static_impl, no get_function_record, no process_attribute, no capsule poking. pybind11::class_::def(), def_property(), def_property_readonly[_static](), def_property_static() and enum_::value() are called in exactly the places master calls them, with the same arguments. So a pybind11 update can't quietly break this in a way it wouldn't also break master.

No change to our pybind11 fork is needed either. This builds unchanged against upstream pybind11 and against the MeshInspector fork under the limited API.

What makes that possible: the only reason #42 hand-rolled those functions was that the shared code no longer knew the pybind11::class_<...> specialization. Threading that type through the registrar and thunk template arguments fixes it — the shared code can then do the same static_cast<PybindClass *>(m.handle)->def(...) downcast that TryAddFunc() already does today. For non-static member functions this costs nothing, because the class is already part of the signature shape key as the self parameter type, so the number of instantiations doesn't grow. Static member functions don't take the class at all (they go through ModuleOrClassRef like free functions), which lets them share registrars with equally-shaped free functions.

What changes

Per bound function the macros now emit a FuncRow (plain data: names, comment, parameter table, arity, flags) instead of a TryAddFunc<...>() call. What is left per function is only:

  • FuncRowThunk<...>::Call — the call thunk carrying the target function pointer and the parameter/return adjustments (same body as the old TryAddFunc lambda);
  • FuncRowThunk<...>::Register — a tiny wrapper handing the typed &Call to the registrar. Needed because casting function pointers isn't allowed in constant expressions, so the row can't store a type-erased thunk directly.

Everything that depends only on the signature runs once per distinct signature shape (FuncRowRegistrar), and everything that depends on neither the signature nor the class runs once in total (RegisterFuncRow(): the two-pass overload bookkeeping, ambiguous-overload renaming, operator injection including the reversed __r*__ forms, alias registration). That last part is where most of the win comes from: on master it gets inlined into every per-member lambda.

Fields follow the same shape — a MemberVarRow table plus a per-field MemberVarThunk that forwards to the existing TryAddMemberVar[Static](), which is now shared by all fields of the same class and type. Two smaller things fall out of that:

  • TryAddMemberVar[Static]() take the comment as a possibly-null const char * instead of a variadic pybind11 extra. pybind11 treats a null doc pointer exactly like an absent one (process_attribute<const char *>::init just assigns it), so this halves the shapes without changing behavior.
  • the _offsetof_* static properties used to bake the offset into a captureless lambda, which gave every field in the module its own cpp_function instantiation. Capturing the offset instead lets one lambda serve all of them.

Enum elements were table-driven too at first, but that was rolled back in bbf27025 after it measured larger (+24,576 bytes on MeshLib), so MB_ENUM still calls enum_::value() per element.

Constructors are intentionally not migrated: measured on MeshLib, their instantiation shapes are 1:1 unique (a constructor's shape is its class plus its parameter types), so tables win nothing there. Conversion operators, TryAddFuncSimple() and custom bindings keep calling TryAddFunc() directly.

Behavior is unchanged

Validated against a purpose-built feature-test input covering kwargs, default arguments (including the pretty default strings), same-in-Python overloads that force name qualification, static methods, member and non-member operators, operator injection into the operand types, reversed binary operators, deprecation warnings, GIL call guards, unique_ptr returns, reference-returning getters, mutable / static / read-only fields, pointer fields (setter keep_alive), _offsetof_* values, classes with no fields or no methods, derived classes, nested namespaces, and 64-bit unsigned / negative / empty enums.

A dump of pydoc.render_doc(), every attribute's repr and __doc__, every property's fget/fset/fdel with their __doc__ and __name__, plus the results of ~50 live calls and field round-trips, is byte-identical between master and this branch. Same for a second input covering the standard containers and smart pointers. Both also pass -fsyntax-only against the MeshInspector pybind11 fork with MeshLib's limited-API defines, to catch version skew.

Worth noting for anyone comparing against #42: that PR needed a def_property record post-processing dance to keep the property docstrings identical (otherwise the setter's parameter rendered as arg0 instead of arg1, and the comment leaked into the setter docstring). That problem was entirely self-inflicted by constructing the cpp_functions by hand — calling def_property() the way master does makes it disappear.

Results

Synthetic module (40 classes x 20 methods + 240 fields + 40 enums + 200 free functions), Clang 22, -Oz, same generated .cpp, measured against be828fee (master before 5d6f332e):

master this branch delta
.text 4,688,614 3,630,294 -22.6%
stripped module 6,369,792 5,470,720 -14.1%
compile time ~129 s ~100 s ~-20%

.text is the metric to trust here: it is deterministic to the byte. stripped + xz is not - rebuilding identical source gives figures ~0.8% apart (639,352 vs 644,060), presumably a PE timestamp shifting the compressor - and compile time is a single run each. Both are quoted only where the delta dwarfs the noise.

Master has since gained its own size reduction in 5d6f332e, which is worth -6.0% of .text on this same benchmark. It overlaps almost entirely with this PR: merging it in leaves .text at exactly 3,630,294, i.e. unchanged. See the comment below for the four-way comparison. Relative to current master this PR is a further -17.6% of .text.

For reference, #42 on this input gives .text -24.5%, so giving up the cross-class registrar sharing costs about two percentage points here. On real code it costs much less - see below.

MeshLib manylinux wheel A/B, same MeshLib commit (4e5f11fd8), Clang 21, publish=dry. Baseline run 31465130784, this branch 31465420437 - both fully green, including all 14 manylinux-pip-test legs (Python 3.8-3.14 x both arches).

x86_64 master this branch delta
mrmeshpy.so compressed (in wheel) 16,973,091 13,241,499 -22.0%
mrmeshpy.so .text 30.55 MB 23.14 MB -24.2%
mrmeshpy.so unpacked 56,797,097 51,311,249 -9.7%
meshlib-core wheel 55,768,483 52,021,381 -6.7%
aarch64 master this branch delta
mrmeshpy.so compressed (in wheel) 15,203,113 12,461,448 -18.0%
mrmeshpy.so .text 18.11 MB 14.55 MB -19.7%
mrmeshpy.so unpacked 41,932,721 40,100,513 -4.4%
meshlib-core wheel 52,360,860 49,605,999 -5.3%

mrmeshpy.pyi is byte-identical on both arches (107,927 and 107,924 lines). mrcudapy.so shrinks too (-6.0% / -5.1% compressed), since it's generated as well; mrmeshnumpy.so is unchanged to the byte, as expected for hand-written bindings. The only .pyi difference anywhere is one import line at a different position in mrcudapy.pyi, which is the known pre-existing registration-order nondeterminism (type_entries is pointer-hash ordered, so two builds of pure master differ the same way) and not something this PR introduces.

.data.rel.ro grows ~1.2 MB and .rela.dyn ~1.95 MB - the tables and their relocations - but both compress far better than the code they replace, which is why the compressed figure moves much more than the unpacked one.

This lands essentially on top of #42, which measured -23.4% (x86_64) and -18.3% (aarch64) compressed. So on real code the cost of dropping the pybind11 copypaste is about 1.4pp on x86_64 and 0.3pp on aarch64 - much less than the synthetic fixture suggested, because that fixture had no static methods and little field-type sharing between classes, which are exactly the two cases where threading the class type recovers the sharing. .text is actually slightly better here than in #42 (-24.2% vs -23.6%).

As a side effect the CI "Generate and build MRBind bindings" step got faster: 17m36s -> 15m35s on x86_64, 11m11s -> 10m14s on aarch64. Less than the local -14%, because that step is parsing-dominated in CI.

Companion MeshLib PR: MeshInspector/MeshLib#6574.

Bound functions, fields and enum elements are described by `constexpr` data
tables instead of one straight-line chain of template instantiations each.
The long tail of near-duplicate per-entity instantiations is what dominates
the size of large generated modules, so this shrinks them a lot.

Per function the macros now emit a `FuncRow` (names, comment, parameter
table, arity, flags) instead of a `TryAddFunc<...>()` call. What remains
per function is just `FuncRowThunk::Call` (the call thunk holding the target
function pointer) and `FuncRowThunk::Register` (a wrapper that hands the
typed `&Call` to the registrar; needed because casting function pointers
isn't allowed in constant expressions, so the row can't hold a type-erased
thunk). Everything that depends only on the signature runs once per distinct
signature shape in `FuncRowRegistrar`, and everything that depends on
neither runs once in total in `RegisterFuncRow()`.

All the pybind11 calls are the same ones `TryAddFunc()` and
`TryAddMemberVar[Static]()` make; no pybind11 internals are reimplemented.
The `pybind11::class_<...>` specialization is carried through the registrar
and thunk template arguments so that `class_::def()` and
`class_::def_property*()` can still be called without knowing the class type
at the call site. For non-static member functions this costs nothing: the
class is already part of the shape key as the self parameter type.

Constructors are deliberately left alone: their instantiation shapes are 1:1
unique (a constructor's shape is its class plus its parameter types), so
tables buy nothing there. Conversion operators, `TryAddFuncSimple()` and
custom bindings keep calling `TryAddFunc()` directly.
adalisk-emikhaylov and others added 3 commits August 24, 2026 03:43
…sterFuncRow

Master extracted the signature-independent parts of `TryAddFunc()` into
`TryAddFuncPieces`. Two of them, `AdjustName()` and `DisambiguatePythonName()`,
are the same logic `RegisterFuncRow()` had inlined, so call them instead of
keeping a second copy. `AdjustName()` now takes the signature by value so that
both an `initializer_list` and a table-built one can be passed.

Costs +288 bytes of .text out of 3.63 MB on the synthetic benchmark.
@Fedr

Fedr commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Merged master (5d6f332e) in, and folded the duplicated logic together. Also some measurements on how the two changes interact, because they turn out to overlap almost completely.

5d6f332e and this PR attack the same thing from different distances: master hoists the signature-independent parts of TryAddFunc() into TryAddFuncPieces and force-inlines the rest, keeping one TryAddFunc instantiation per bound function; this PR stops instantiating it for methods and free functions at all. So once this lands, master's extraction only still pays for the remaining callers (conversion operators, TryAddFuncSimple(), custom bindings).

Synthetic module (40 classes x 20 methods + 240 fields + 40 enums + 200 free functions), Clang 22, -Oz, same generated .cpp:

variant .text vs be828fee
be828fee (master before 5d6f332e) 4,688,614 -
5d6f332e (master now) 4,405,590 -6.0%
this PR at bbf27025 3,630,294 -22.6%
this PR merged with 5d6f332e 3,630,294 -22.6%

The last two rows are the point: merging master in leaves .text at exactly the same size. The two changes are substitutes on the methods/free-functions path rather than additive, so please don't expect 6% and 22% to compound. Relative to current master this PR is a further -17.6% of .text.

A caveat on my earlier numbers in the description: the stripped + xz column is not reproducible across rebuilds of identical source - I measured the same tree twice and got 639,352 and 644,060, about 0.8% apart, presumably from a timestamp in the PE header shifting the compressor. .text is deterministic to the byte. The deltas in the description are 15-20% so the noise doesn't change any conclusion there, and the MeshLib wheel numbers are real CI builds, but for anything at the low-single-digit level .text is the metric to use.

The last commit removes the duplication the merge created: RegisterFuncRow() had its own inlined copies of what are now TryAddFuncPieces::AdjustName() and DisambiguatePythonName(), so it calls them instead. AdjustName() takes the signature by value now, so TryAddFunc()'s initializer_list and a table-built one both work. That costs +288 bytes of .text out of 3.63 MB, which seemed a fair price for -21 lines.

Agreed on rolling back the table-driven enums - I had 40 enums in the benchmark but never isolated their contribution, so there was no evidence they were pulling their weight.

Re-verified after the merge and the dedupe: the pydoc / attribute / property-docstring / live-call dumps are still byte-identical to master on both test inputs, and it still passes -fsyntax-only against the pybind11 fork with MeshLib's limited-API defines.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants