Conversation
This was referenced Aug 11, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A replacement for #42, which was rightly rejected for copying pybind11 internals.
Same goal: bound methods, fields and enum elements 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 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 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:.textxz -9(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 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.