Skip to content

Python: table-driven registration to shrink generated modules - #42

Closed
Fedr wants to merge 5 commits into
masterfrom
table-driven-py-registration
Closed

Python: table-driven registration to shrink generated modules#42
Fedr wants to merge 5 commits into
masterfrom
table-driven-py-registration

Conversation

@Fedr

@Fedr Fedr commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Reworks the pybind11 target to register methods, free functions, fields, and enum elements through constexpr data tables instead of per-entity straight-lined template instantiation chains. This is aimed at large generated modules: MeshLib's mrmeshpy.so had ~19.7 MB of its 32.9 MB .text in mrbind-generated registration code, almost all of it a long tail of tiny near-duplicate instantiations that differ only by embedded function pointers and name strings (so linker ICF can't fold them).

What changes

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

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

Everything else runs once per distinct signature shape (FuncRowRegistrar) or is fully shared runtime code (RegisterFuncRow: the two-pass overload bookkeeping, ambiguous-overload renaming, operator injection incl. the reversed __r*__ forms, alias registration). Fields follow the same pattern (MemberVarRow; the _offsetof_* static properties become data served by one shared lambda), and enum elements become a table + loop.

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

Behavior is unchanged

  • pydoc output and property fget/fset docstrings byte-identical on the test bindings and on a purpose-built feature test (kwargs, default arguments incl. the pretty default strings, overloads, static methods, member operators, operator injection into operand types, reversed binary operators, deprecation warnings, GIL call guards, fields, _offsetof_* values, enum values incl. 64-bit unsigned).
  • MeshLib's mrmeshpy.pyi is identical modulo a known pre-existing nondeterminism (registration order of conversion-operator-injected ctors varies between any two builds of master as well; caused by type_entries being pointer-hash ordered — worth fixing separately).
  • One subtlety worth calling out: property getter/setter extras must be applied to the pybind11 function records after construction (like class_::def_property_static() does), not passed to the cpp_function constructor — otherwise the rendered signature text changes (arg1arg0) and comments leak into setter docstrings. The record retrieval is written against the raw capsule API so it works with upstream pybind11 and MeshLib's limited-API fork alike.

Results

MeshLib manylinux (Clang 21, same-commit A/B against master, full Python test suite green on both arches × Python 3.8–3.14). On x86_64 the resulting mrmeshpy.pyi is byte-identical to master's.

x86_64 master this branch delta
mrmeshpy.so compressed (in wheel) 19.32 MB 14.80 MB −23.4%
mrmeshpy.so .text 32.93 MB 25.14 MB −23.6%
mrmeshpy.so unpacked 65.76 MB 58.95 MB −10.4%
whole wheel 81.26 MB 76.72 MB −5.6%
aarch64 master this branch delta
mrmeshpy.so compressed (in wheel) 17.79 MB 14.53 MB −18.3%
mrmeshpy.so .text 20.94 MB 16.94 MB −19.1%
mrmeshpy.so unpacked 52.63 MB 49.55 MB −5.8%
whole wheel 77.65 MB 74.38 MB −4.2%

(.data.rel.ro grows by ~1.2 MB and .rela.dyn by ~1.9 MB — the tables and their relocations — but both compress far better than the code they replace.)

On a synthetic 800-method module (Clang 22, -Oz): whole module −17%, .text −37%, compile time −20% (one small instantiation per method instead of the TryAddFunc chain). On MeshLib's CI the "Generate and build MRBind bindings" step is neutral-to-slightly-faster (18.8→18.9 min x86_64, 11.1→10.4 min aarch64) — that step is dominated by parsing, not compilation.

Fedr added 5 commits August 6, 2026 21:43
Replace the per-function `TryAddFunc<...>()` calls that the `MB_FUNC` and
`MB_CLASS` macros used to emit with constexpr `FuncRow` tables plus a shared
runtime driver (`RegisterFuncRow()`). Names, comments, parameters, and default
arguments become plain data; the only remaining per-function templates are a
small call thunk carrying the target function pointer (`FuncRowThunk`) and one
`FuncRowRegistrar` instantiation per distinct signature shape.

This shrinks large generated modules considerably and speeds up their
compilation (a synthetic 800-method module: -17% binary size, -20% compile
time with clang at -Oz), while keeping the resulting bindings identical
(pydoc output is byte-for-byte the same, overload resolution, default
arguments, operator injection incl. the reversed forms, deprecation warnings,
and the GIL handling all behave as before).

Conversion operators, `TryAddFuncSimple()`, and custom bindings still go
through `TryAddFunc()` directly.
Same treatment as the methods in the previous commit: `MemberVarRow` tables
plus a shared driver replace the per-field inlined
`TryAddMemberVar[Static]()` -> `def_property...()` chains. Per field only the
typed accessor thunks survive; the pybind11 property construction is done once
per (class, field type) shape, and `def_property_static()`'s tail is
replicated type-erased in `DefPropertyLow()`. The `_offsetof_*` static
properties become plain data served by one shared lambda instead of one lambda
per field (this also required replacing the statement-expression
`MB_PB11_OFFSETOF` with a declaration-level warning suppression, since
statement expressions aren't allowed in constant expressions on GCC).

`TryAddMemberVar[Static]()` remain available for custom bindings.
One shared `.value()` loop per enum instead of a straight-lined call per
element. Values are bit-cast through `std::int64_t` (well-defined in C++20),
verified to round-trip 64-bit unsigned enumerators.
Passing `is_method`/policy/comment to the `cpp_function` constructor changed
the rendered signature and docstring texts (the setter's value parameter
became `arg0` instead of `arg1`, and the comment leaked into the setter's
docstring), which showed up as ~9k changed lines in MeshLib's mrmeshpy.pyi.
Instead, construct the getter/setter bare and then apply the extras to the
already-rendered records, exactly like `class_::def_property_static()` does
(`get_function_record()` is private there, so a copy of it is included).
The previous commit copied upstream master's `get_function_record()`, but
different pybind11 versions/forks spell the helpers differently (and the
`PyCFunction_GET_SELF` macro doesn't exist under the limited API at all).
Use the raw capsule API instead, passing the capsule's own name back to
`PyCapsule_GetPointer` to sidestep the version-specific name checks.
@adalisk-emikhaylov

adalisk-emikhaylov commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Too much copypaste from pybind internals, I'm not a fan. If Pybind updates something internally, this could break. Also it's quite hard to read. Can discuss further when Fedor is back from vacation.

@Fedr

Fedr commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Probably this code is not of good quality, but it shows that binaries produced by mrbind can be shrunk a lot. Can you achieve the same goal with a better code? Don't we have a fork of pybind11, which we can modify as necessary?

@Fedr

Fedr commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Redone as #44, which reimplements nothing from pybind11 — the set of pybind11::detail:: names core.h uses is identical to master's, and class_::def() / def_property*() / enum_::value() are called exactly where master calls them.

It turned out the fork doesn't need changing either. The only reason this PR hand-rolled def() and def_property_static() was that the shared code didn't know the pybind11::class_<...> specialization; threading that through the registrar's template arguments fixes it, and for non-static member functions it's free, since the class is already part of the signature shape key as the self parameter type. The property-record post-processing dance disappears with it.

The cost is that a few registrars are now per-class instead of shared across classes. On a synthetic module that's about two percentage points of .text (−22.6% here vs −24.5% here-in-#42), which seems a good trade for the copypaste.

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