Context
eudsl-llvmpy currently binds Value and its subclasses (Instruction, BasicBlock, Argument, ...) as thin, non-owning, un-deduped pointer views: EUDSL_CAST_CTOR wraps a raw llvm::Value* with rv_policy::reference + keep_alive<0,1>, and there is no per-value registry. Only Context and Module have real lifetime tracking (what assert_no_leaks() counts). Everything below rides on keep_alive tying a value's lifetime to its parent.
This is a deliberate, cheap design that matches the "mirror the LLVM C++ API 1:1" goal. But it means the Python layer has no notion of which wrapper corresponds to a given C++ entity, so it cannot dedup wrappers, cannot offer stable identity, and cannot invalidate a wrapper when the underlying object goes away — except the one narrow case we've already patched.
This issue is a design note to decide whether to build a canonical live-value registry, analogous to MLIR's PyOperation + liveOperations system (mlir/lib/Bindings/Python/IRCore.cpp: PyOperation::forOperation, the valid/checkValid()/setInvalid() flag, and transitive invalidation on erase).
What we already fixed (for reference)
Instruction.erase_from_parent / BasicBlock.erase_from_parent now poison the called wrapper via nb::inst_set_state(self, ready=false, destruct=false) after freeing the C++ object, so a use-after-erase on that handle raises TypeError ("attempted to access an uninitialized instance") instead of dereferencing freed memory (PR #608). This handles the common footgun (erase then touch the same handle).
Gaps that remain (what a registry would close)
| Gap |
Severity |
Needs a registry? |
| erase-then-touch the same handle |
common |
already fixed (poison) |
erase-then-touch an aliased handle (a second wrapper for the same value, e.g. via maybe_downcast re-wrap) |
rare |
yes |
stable identity (inst is inst across two lookups) |
quality-of-life |
yes |
transitive invalidation (erase a BasicBlock -> wrappers for its instructions dangle) |
real but narrow |
yes |
Only the bottom three need the machinery, and all three are lower-frequency than the one already handled.
What MLIR does (the model to consider)
- Canonical dedup: one
PyOperation per C++ operation, via a liveOperations map keyed by raw pointer (PyOperation::forOperation). Guarantees identity and gives a single place to invalidate that reaches every alias.
- Validity flag:
PyOperation::valid + checkValid() throws on any access after invalidation; erase() does checkValid(); setInvalid(); mlirOperationDestroy(...).
- Transitive invalidation: erasing/attaching invalidates dependent wrappers (subtree, symbol-table erase, etc.).
MLIR pays for this with a map lookup+insert on every wrapper creation and per-wrapper context-ref + validity + attachment state.
Options
- Do nothing more. Keep the documented caveat: poison covers the common case; aliasing/transitive are rare and documented. Lowest cost; matches the thin-mirror design.
- Add a scoped-down
liveValues registry keyed by llvm::Value*, giving dedup + is identity + a single poison point on erase (and a hook for transitive invalidation). This is the "do it properly" option.
- Full MLIR-style system with attachment tracking and transitive subtree invalidation. Largest change.
Cost / risks of a registry (options 2-3)
Recommendation (for discussion)
Lean option 1 now; pursue option 2 only if real usage surfaces identity/aliasing/transitive bugs. If we do option 2, co-design it with #614 so the registry is synchronized from the start.
References
Context
eudsl-llvmpy currently binds
Valueand its subclasses (Instruction,BasicBlock,Argument, ...) as thin, non-owning, un-deduped pointer views:EUDSL_CAST_CTORwraps a rawllvm::Value*withrv_policy::reference+keep_alive<0,1>, and there is no per-value registry. OnlyContextandModulehave real lifetime tracking (whatassert_no_leaks()counts). Everything below rides onkeep_alivetying a value's lifetime to its parent.This is a deliberate, cheap design that matches the "mirror the LLVM C++ API 1:1" goal. But it means the Python layer has no notion of which wrapper corresponds to a given C++ entity, so it cannot dedup wrappers, cannot offer stable identity, and cannot invalidate a wrapper when the underlying object goes away — except the one narrow case we've already patched.
This issue is a design note to decide whether to build a canonical live-value registry, analogous to MLIR's
PyOperation+liveOperationssystem (mlir/lib/Bindings/Python/IRCore.cpp:PyOperation::forOperation, thevalid/checkValid()/setInvalid()flag, and transitive invalidation on erase).What we already fixed (for reference)
Instruction.erase_from_parent/BasicBlock.erase_from_parentnow poison the called wrapper vianb::inst_set_state(self, ready=false, destruct=false)after freeing the C++ object, so a use-after-erase on that handle raisesTypeError("attempted to access an uninitialized instance") instead of dereferencing freed memory (PR #608). This handles the common footgun (erase then touch the same handle).Gaps that remain (what a registry would close)
maybe_downcastre-wrap)inst is instacross two lookups)BasicBlock-> wrappers for its instructions dangle)Only the bottom three need the machinery, and all three are lower-frequency than the one already handled.
What MLIR does (the model to consider)
PyOperationper C++ operation, via aliveOperationsmap keyed by raw pointer (PyOperation::forOperation). Guarantees identity and gives a single place to invalidate that reaches every alias.PyOperation::valid+checkValid()throws on any access after invalidation;erase()doescheckValid(); setInvalid(); mlirOperationDestroy(...).MLIR pays for this with a map lookup+insert on every wrapper creation and per-wrapper context-ref + validity + attachment state.
Options
liveValuesregistry keyed byllvm::Value*, giving dedup +isidentity + a single poison point on erase (and a hook for transitive invalidation). This is the "do it properly" option.Cost / risks of a registry (options 2-3)
Instruction/BasicBlockobjects far more than MLIR churns ops, so a registry keyed on every value adds lookup/insert overhead on a hot path.casterMap()+ Python-pass registries). If we build a live-value registry it should be designed together with the [eudsl-llvmpy] Free-threaded data races in unsynchronized static registries (casterMap + Python-pass registries) #614 synchronization work — same shape of problem, same solution surface.moveBefore,splitBasicBlockall mutate the graph under existing wrappers.Recommendation (for discussion)
Lean option 1 now; pursue option 2 only if real usage surfaces identity/aliasing/transitive bugs. If we do option 2, co-design it with #614 so the registry is synchronized from the start.
References
projects/eudsl-llvmpy/src/IR/Common.h—EUDSL_CAST_CTOR(thin pointer-view wrappers)projects/eudsl-llvmpy/src/IR/Casters.cpp—casterMap()/maybe_downcast(the only re-wrap path, a source of aliases)projects/eudsl-llvmpy/src/IR/Values.cpp—erase_from_parentpoisoning (PR [eudsl-llvmpy] Bind object-level IR mutation/inspection APIs #608)mlir/lib/Bindings/Python/IRCore.cpp—PyOperation::forOperation,checkValid/setInvalid,liveOperations