Skip to content

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

Open
Fedr wants to merge 1 commit into
masterfrom
py-tables
Open

Python: table-driven registration to shrink generated modules, without touching pybind11 internals#44
Fedr wants to merge 1 commit 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, fields and enum elements 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 become a table plus a loop over enum_::value().

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:

master this branch delta
.text 4,688,614 3,627,974 -22.6%
stripped module 6,369,792 5,473,792 -14.1%
stripped + xz -9 782,108 644,552 -17.6%
compile time 118 s 101 s -14%

(Compile time is a single run, so treat it as indicative rather than precise.)

For reference, #42 on the same input gives .text -24.5% and stripped+xz -19.2%, so giving up the cross-class registrar sharing looks like it costs about two percentage points here. On real code it costs much less than that - 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.
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.

1 participant