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 |
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 used to bake the offset into a captureless lambda, which gave every field in the module its owncpp_functioninstantiation. 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
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
Synthetic module (40 classes x 20 methods + 240 fields + 40 enums + 200 free functions), Clang 22,
-Oz, same generated.cpp, measured againstbe828fee(master before5d6f332e):.text.textis the metric to trust here: it is deterministic to the byte.stripped + xzis 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.texton this same benchmark. It overlaps almost entirely with this PR: merging it in leaves.textat 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 14manylinux-pip-testlegs (Python 3.8-3.14 x both arches).mrmeshpy.socompressed (in wheel)mrmeshpy.so.textmrmeshpy.sounpackedmeshlib-corewheelmrmeshpy.socompressed (in wheel)mrmeshpy.so.textmrmeshpy.sounpackedmeshlib-corewheelmrmeshpy.pyiis byte-identical on both arches (107,927 and 107,924 lines).mrcudapy.soshrinks too (-6.0% / -5.1% compressed), since it's generated as well;mrmeshnumpy.sois unchanged to the byte, as expected for hand-written bindings. The only.pyidifference anywhere is one import line at a different position inmrcudapy.pyi, which is the known pre-existing registration-order nondeterminism (type_entriesis pointer-hash ordered, so two builds of pure master differ the same way) and not something this PR introduces..data.rel.rogrows ~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.
.textis 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.