Skip to content

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

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

[no merge] Python: table-driven registration to shrink generated modules, without touching pybind11 internals#44
Fedr wants to merge 5 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 now come from the row, with one shared lambda taking the offset as data, rather than a captureless lambda per field. Correction: I originally listed this as a size win. Measured in isolation on top of master it is not - a shared capturing lambda costs +0.29% of .text, and a per-field capturing lambda +1.1%, because master's +[] decays to a plain function pointer and hits pybind11's lean initialize overload while any closure takes the generic one. It is kept here because the table needs the offset as data anyway, not because it saves space.

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

Real bindings first, since a synthetic benchmark turned out to mispredict the details.

MeshLib mrmeshpy.so, same MeshLib commit (e435a34c9), default MODE=release (so -Oz -flto=thin and -Wl,--icf=all), varying only the mrbind gitlink. Baseline is current master 232ff331, which now includes #47:

vs master 232ff331 x86_64 aarch64
mrmeshpy.so compressed (in wheel) -13.69% -9.85%
mrmeshpy.so .text -15.89% -11.81%
mrmeshpy.so uncompressed -2.49% +1.33%
meshlib-core wheel -3.92% -2.69%

Absolute compressed bytes: x86_64 15,451,707 -> 13,336,080; aarch64 13,873,469 -> 12,507,391.

Note the aarch64 uncompressed row: this PR now makes that module slightly larger than master. That is a change from when this PR was opened, and it is #47's fault in a good way - #47 gave master a pure code reduction with no data cost, so the cheap win is already banked, and what remains here is a trade. The section deltas show it plainly:

section (MB) x86_64 aarch64
.text -4.39 -1.96
.rela.dyn +2.03 +2.03
.data.rel.ro +1.24 +1.22
.gcc_except_table -0.28 -0.56
all sections -1.26 +0.47

The tables cost a near-constant ~3.25 MB of relocations and pointer data on both arches, while the .text they remove scales with code size - 4.39 MB on x86_64 against only 1.96 MB on aarch64, where the encoding is denser. x86_64 has enough code to cover the tables; aarch64 no longer does.

So: clearly good for wheel download size on both arches, good for installed size on x86_64, and a ~1.3% installed-size regression on aarch64. If installed footprint on arm64 matters more than download size, that is a reason to prefer #47 alone. I have not measured whether the extra ~2 MB of .rela.dyn costs anything at import time, which is the other place this trade could bite.

mrmeshpy.pyi is byte-identical to master's on both arches, so nothing about the generated API changes.

For completeness, a synthetic module (40 classes x 20 methods + 240 fields + 40 enums + 200 free functions) built locally at plain -Oz on MinGW - no LTO, no ICF, so not comparable to the release numbers above - gives .text -22.6% vs be828fee and -17.6% vs 5d6f332e, and compiles ~20% faster (single run each). #42 gives -24.5% on that same input, so giving up the cross-class registrar sharing costs about two percentage points there; on real code, per the comment below, it costs less. Treat .text as the reliable local metric - stripped + xz on Windows is not reproducible across rebuilds of identical source (I measured 639,352 and 644,060 from the same tree, ~0.8% apart, presumably a PE timestamp shifting the compressor).

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.

@Fedr

Fedr commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Four-way size comparison on real bindings, now that master has 5d6f332e. All eight builds are the same MeshLib commit (e435a34c9) with the default MODE=release, so -Oz -flto=thin plus -Wl,--icf=all; the only difference is the thirdparty/mrbind gitlink.

mrbind
A be828fee - master before 5d6f332e
B 5d6f332e - master now
C bbf27025 - this PR before it merged master
D d3ba8380 - this PR now

mrmeshpy.so compressed (what ships in the wheel)

variant x86_64 vs A vs B aarch64 vs A vs B
A 17,012,967 - +9.2% 15,242,176 - +8.4%
B 15,573,029 -8.5% - 14,056,202 -7.8% -
C 13,306,215 -21.8% -14.6% 12,512,275 -17.9% -11.0%
D 13,300,064 -21.8% -14.6% 12,509,129 -17.9% -11.0%

mrmeshpy.so uncompressed

variant x86_64 vs A vs B aarch64 vs A vs B
A 56,985,889 - +7.3% 42,142,065 - +4.7%
B 53,102,881 -6.8% - 40,241,521 -4.5% -
C 51,475,473 -9.7% -3.1% 40,244,313 -4.5% +0.0%
D 51,459,089 -9.7% -3.1% 40,244,313 -4.5% +0.0%

.text, for reference: x86_64 32,118,474 / 29,345,354 / 24,343,470 / 24,332,462 (D vs B -17.1%); aarch64 19,059,184 / 17,687,800 / 15,326,620 / 15,319,364 (D vs B -13.4%). meshlib-core wheel: D vs B is -4.2% x86_64 and -3.0% aarch64.

Reading the two tables together

Uncompressed is where this PR looks weakest, and on aarch64 it currently buys nothing over master - 40,244,313 vs 40,241,521, i.e. 2,792 bytes larger. The section deltas from B to C explain it:

section (MB) x86_64 aarch64
.text -4.77 -2.25
.rela.dyn +1.95 +1.94
.data.rel.ro +1.20 +1.18
.gcc_except_table -0.28 -0.58
.eh_frame +0.33 -0.25
all sections -1.56 +0.02

The tables cost a near-constant ~3.1 MB of .data.rel.ro + .rela.dyn on both arches, while the .text they save scales with code size - 4.77 MB on x86_64 but only 2.25 MB on aarch64, where the encoding is denser. So on aarch64 the two roughly cancel. Compressed size behaves completely differently because relocations and pointer tables are far more repetitive than the machine code they displace.

So the remaining value of this PR over current master is compressed size and .text, not uncompressed bytes. If installed footprint rather than download size is what matters, 5d6f332e already captured most of the aarch64 win on its own. I also haven't measured whether the extra ~1.9 MB of .rela.dyn costs anything noticeable at import time - worth a look before deciding, since that is the one place this trade could be a regression.

Two other things these builds settle

5d6f332e slightly beats its own headline: -8.5% / -7.8% compressed and -8.6% / -7.2% of .text, holding up under LTO and ICF on real bindings rather than only on a benchmark.

The overlap is near-total, and D is a hair better than C. B alone is -8.5%, C alone -21.8%, both together -21.8%; merging master into this PR moves the compressed figure by 0.05%. And D beats C by 6,151 bytes on x86_64 and 3,146 on aarch64, so the merge plus sharing AdjustName()/DisambiguatePythonName() is a small net win - which retires the doubt I raised earlier from a synthetic benchmark that suggested the dedupe cost a few hundred bytes.

@adalisk-emikhaylov

Copy link
Copy Markdown
Collaborator

I'm hesitant to merge this because this is vibecoded.
I've made some size improvement in 5d6f332, which according to my measurements moves us two thirds of the way to what Claude did:

  • Old size of mrmeshpy.so: 53847464
  • My commit linked above: 50029992
  • This PR: 48469416

Though if Claude is to be trusted, my improvements are less important when zipped.


Not merging this for now, but leaving it for posterity.

@adalisk-emikhaylov adalisk-emikhaylov changed the title Python: table-driven registration to shrink generated modules, without touching pybind11 internals [no merge] Python: table-driven registration to shrink generated modules, without touching pybind11 internals Aug 25, 2026
# Conflicts:
#	include/mrbind/targets/pybind11/core.h
@Fedr

Fedr commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Merged master in (3c232aad) and re-measured against current master 232ff331, which now contains #47. The description is updated; posting the numbers here too since the earlier comment's baseline (5d6f332e) is now stale.

Three conflicts from the merge, all the same shape - master edited the TryAddFunc / TryAddMemberVar call sites in MB_FUNC, DISPATCH_MEMBER_method and DISPATCH_MEMBER_field, which this PR deletes because those are table-driven. Resolved in favour of this branch. #47's improvement survives at all four of its sites: three are inherent to the row design (MakeFuncRow / MemberVarRow always pass a comment pointer) and the constructor site auto-merged from master, along with master's (const char *) cast inside DETAIL_MB_PB11_COMMENT_PTR_.

Same MeshLib commit (e435a34c9), MODE=release, only the mrbind gitlink varies. Baseline run 32858782210, this branch 32858228817.

vs master 232ff331 x86_64 aarch64
mrmeshpy.so compressed (in wheel) -13.69% -9.85%
mrmeshpy.so .text -15.89% -11.81%
mrmeshpy.so uncompressed -2.49% +1.33%
meshlib-core wheel -3.92% -2.69%

Both numbers moved against this PR compared with the previous baseline (compressed was -14.60% / -11.01%, uncompressed -3.10% / +0.01%), which is expected: #47 landing in master banked part of the win.

The aarch64 uncompressed row is the one worth a decision. This PR now makes that module ~1.3% larger uncompressed than master. Section deltas, master -> this branch:

section (MB) x86_64 aarch64
.text -4.39 -1.96
.rela.dyn +2.03 +2.03
.data.rel.ro +1.24 +1.22
.gcc_except_table -0.28 -0.56
all sections -1.26 +0.47

The tables cost a near-constant ~3.25 MB of relocations and pointer data on both arches, while the .text they remove scales with code size: 4.39 MB on x86_64 versus 1.96 MB on aarch64, where the encoding is denser. x86_64 has enough code to cover the tables, aarch64 no longer does now that #47 has taken the cheap code win.

So the trade is: clearly good for wheel download size everywhere, good for installed size on x86_64, ~1.3% worse for installed size on arm64. I also have not measured whether the extra ~2 MB of .rela.dyn costs measurable import time, which is the other place this could bite.

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

(Methodology note: I built the real master baseline rather than reusing my #47 branch build. The two agree to within 0.005% compressed and 0.000% uncompressed, so the proxy would have been fine, but the numbers above are from the real thing.)

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