Conversation
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.
11fe293 to
55993a1
Compare
… to 48454712 on my machine.
…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.
|
Merged master (
Synthetic module (40 classes x 20 methods + 240 fields + 40 enums + 200 free functions), Clang 22,
The last two rows are the point: merging master in leaves A caveat on my earlier numbers in the description: the The last commit removes the duplication the merge created: 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 |
|
Four-way size comparison on real bindings, now that master has
|
| 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.
|
I'm hesitant to merge this because this is vibecoded.
Though if Claude is to be trusted, my improvements are less important when zipped. Not merging this for now, but leaving it for posterity. |
# Conflicts: # include/mrbind/targets/pybind11/core.h
|
Merged master in ( Three conflicts from the merge, all the same shape - master edited the Same MeshLib commit (
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:
The tables cost a near-constant ~3.25 MB of relocations and pointer data on both arches, while the 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 Re-verified after the merge against a freshly built current-master baseline: (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.) |
A replacement for #42, which was rightly rejected for copying pybind11 internals.
Same goal: bound methods and fields are described by
constexprdata 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 bycore.his exactly the same as on master:No
add_class_method, nodef_property_static_impl, noget_function_record, noprocess_attribute, no capsule poking.pybind11::class_::def(),def_property(),def_property_readonly[_static](),def_property_static()andenum_::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 samestatic_cast<PybindClass *>(m.handle)->def(...)downcast thatTryAddFunc()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 throughModuleOrClassReflike 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 aTryAddFunc<...>()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 oldTryAddFunclambda);FuncRowThunk<...>::Register— a tiny wrapper handing the typed&Callto 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
MemberVarRowtable plus a per-fieldMemberVarThunkthat forwards to the existingTryAddMemberVar[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-nullconst char *instead of a variadic pybind11 extra. pybind11 treats a null doc pointer exactly like an absent one (process_attribute<const char *>::initjust assigns it), so this halves the shapes without changing behavior._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 leaninitializeoverload 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
bbf27025after it measured larger (+24,576 bytes on MeshLib), soMB_ENUMstill callsenum_::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 callingTryAddFunc()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_ptrreturns, reference-returning getters, mutable / static / read-only fields, pointer fields (setterkeep_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'sreprand__doc__, every property'sfget/fset/fdelwith 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-onlyagainst 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_propertyrecord post-processing dance to keep the property docstrings identical (otherwise the setter's parameter rendered asarg0instead ofarg1, and the comment leaked into the setter docstring). That problem was entirely self-inflicted by constructing thecpp_functions by hand — callingdef_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), defaultMODE=release(so-Oz -flto=thinand-Wl,--icf=all), varying only the mrbind gitlink. Baseline is current master232ff331, which now includes #47:232ff331mrmeshpy.socompressed (in wheel)mrmeshpy.so.textmrmeshpy.souncompressedmeshlib-corewheelAbsolute 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:
.text.rela.dyn.data.rel.ro.gcc_except_tableThe tables cost a near-constant ~3.25 MB of relocations and pointer data on both arches, while the
.textthey 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.dyncosts anything at import time, which is the other place this trade could bite.mrmeshpy.pyiis 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
-Ozon MinGW - no LTO, no ICF, so not comparable to the release numbers above - gives.text-22.6% vsbe828feeand -17.6% vs5d6f332e, 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.textas the reliable local metric -stripped + xzon 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.