Conversation
…aterializing inverses remove_deep2 decides whether a subelement may be purged by checking that every instance referencing it lies inside the subgraph. It did so with set(ifc_file.get_inverse(subelement)) - subgraph_set, which wraps every referencing instance into a Python object first. For shared instances that is enormous: a representation context is referenced once per representation in the file, so purging one representation wrapped ~100k instances just to conclude "not exclusively mine". The same question was asked once more for the start element against also_consider, answered by traversing each considered element and scanning the result with entity_instance.__eq__. Add inverse_index::all_sources(id, pred), which visits the live records referencing id and stops at the first source pred rejects, and two wrapper helpers on file built on it: _all_inverses_within(e, ids) for one instance, and _ids_referenced_only_within(ids) which returns the subset of ids referenced only from within ids. remove_deep2 calls the latter once per invocation, before the loop: clearing large aggregates inside the loop only removes references whose source is inside the subgraph, so "referenced only from inside" cannot change while it runs. Both call sites are decision-identical to the old checks: the set of surviving instances after root.remove_product on 300 products of a 155 MB model is unchanged, and so is the element/api test suite. root.remove_product per call, 300 products, on top of open-perf-v2: see the pull request for the per-model table. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HNrXDmR88wKPCYwGE21SyH
| return allowed.count(source_id) != 0; | ||
| }); | ||
| } else { | ||
| throw ifcopenshell::exception("_all_inverses_within is only implemented for in-memory storage"); |
There was a problem hiding this comment.
This is not an option and also not necessary. There are plenty of variants/adapters around these maps so that compatibility is possible.
There was a problem hiding this comment.
Agreed and fixed in ba070e0: the dispatch now lives in file::all_referencing_instances(instance_id, pred), implemented for both storage backends the same way instances_by_reference is (the in-memory index via inverse_index::all_sources, RocksDB via the same v|<id>| prefix seek, early-exiting on the first rejected source). The two SWIG helpers just build the id set and call it — no std::visit or storage internals in the .i, no throw. I could only run the in-memory path here (WITH_ROCKSDB=OFF); the RocksDB branch mirrors instances_by_reference line for line but is untested.
Dion edit: I've told AI to build with rocksdb on and test properly
…ckend Review: the wrapper helpers visited the storage variant themselves and threw for anything but in-memory storage. Move the dispatch into file::all_referencing_instances(instance_id, pred), implemented for both backends the way instances_by_reference is: the in-memory index through inverse_index::all_sources, RocksDB through the same "v|<id>|" prefix seek, stopping at the first source pred rejects. The SWIG helpers only build the id set and call it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HNrXDmR88wKPCYwGE21SyH
Stacked on #9492 (base branch
open-perf-v2; the diff is this one commit). It is independent of that PR's parsing work — it only reads the inverse index — but is measured against it so the numbers reflect the tree it would land in.Problem
root.remove_productspends most of its time inutil.element.remove_deep2, and most of that in deciding whether a subelement may be purged: "is every instance that references it inside the subgraph?". The check waswhich wraps every referencing instance into a Python
entity_instancebefore comparing. For shared instances that is enormous: a representation context is referenced once per representation in the file, so purging one representation wrapped ~100k instances (9.5M__hash__calls over 300 products on a 155 MB model) to conclude "not exclusively mine". The start element is checked the same way againstalso_consider, by traversing each considered element and scanning the result withentity_instance.__eq__(990k calls in the same run).Change
inverse_index::all_sources(id, pred)visits the live records referencingid(base tier and delta) and stops at the first sourcepredrejects.file::all_referencing_instances(instance_id, pred)dispatches that per storage backend the wayinstances_by_referencedoes: the in-memory index throughall_sources, RocksDB through the samev|<id>|prefix seek, stopping at the first sourcepredrejects.%extendhelpers onfilebuild the id set and call it:_all_inverses_within(e, ids)for one instance, and_ids_referenced_only_within(ids), which returns the subset ofidsreferenced only from withinids— one crossing in, one out, early exit per id in C++.remove_deep2calls_ids_referenced_only_withinonce per invocation, before the loop, and the per-subelement test becomes a set lookup. That is exact for the whole loop: the only mutation inside it (clearing large aggregates of instances about to be deleted, When i try and delete this particular object, BB freezes. #3052) removes references whose source is inside the subgraph, and "referenced only from inside" can't change by removing inside references. The start-element check uses_all_inverses_withinagainst thealso_considerids.Why once per call and not per candidate: the first version called the predicate per candidate, rebuilding the C++ id set from the Python list each time — O(subgraph²). On models with very large representation subgraphs that was slower than the baseline (a 243 MB model 340→400 ms/call, an 85 MB one 432→1220), while winning 25× elsewhere. The once-per-call form has no regression on any of the fourteen models below.
The
also_considercheck is now "every reference into the start element comes from a considered element", where the old code counted considered elements that reference it and compared with the record count — so an element referenced twice by one considered element was previously not purged. On every model and test below the decisions are identical; flagging it in case a reviewer knows of a case that relied on the old behaviour.No magic numbers: the decision compares record sources against an id set, not counts against a threshold.
Verification
root.remove_producton 300 products of the 155 MB model the set of surviving instance ids is the same (2,720,623 ids, same SHA-1 of the sorted list) on this branch and onopen-perf-v2, and the same as onv0.9.0(243f0f3) without [AI-generated, unverified] ifcparse: faster, smaller open(): one tokenizer, references in the slots, lazy loading, parallel and paged parsing #9492.util/test_element.py,test_file.py,test_file_inverse.py,test/api/{root,geometry,pset,feature,material,type,spatial,aggregate}: 875 passed on this branch and onopen-perf-v2.Benchmark
root.remove_productper call over the first 300IfcProducts (spatial elements last), Python 3.13 source-tree Release builds, single thread, one run each on a quiet 12-core Linux box. Private models supplied for benchmarking, identified by size only.open-perf-v2Removing every product of a 51 MB IFC4 model (5,597 products, 1,876 of them already gone as cascaded ports/openings by the time the loop reaches them): 260 s → 30 s, 46.5 → 5.4 ms/call.
The remaining cost of
remove_productafter this change isfile.remove()itself — of which ~85% isremove_type_ref's linear scan of the per-type instance vector (a separate PR) — and generic SWIG attribute access. OKgate22 and the 366 MB model stay expensive in absolute terms for those reasons, not this check.RocksDB: built with
WITH_ROCKSDB=ON(RocksDB 10.4.2). On a 51 MB model converted withconvert_path_to_rocksdb,_all_inverses_withinand_ids_referenced_only_withinreturn the same answers through the RocksDB branch as through the in-memory index for 400 random instances × three id sets each plus 40 real subgraphs (1,240 checks, 0 mismatches), andtest_validate_rocksdb.pypasses. Note thatroot.remove_producton a RocksDB-backed file segfaults insidefile.remove()onopen-perf-v2without this PR — the crash is in the deletion itself, after the containment decisions — so end-to-end removal could not be compared there; that is a pre-existing problem this PR neither causes nor fixes.Not tested: lazy loading (
open(lazy=True); the helpers only read the inverse index, which the lazy index builds, but no run was made), Windows/macOS, and Bonsai.🤖 Generated with Claude Code
https://claude.ai/code/session_01HNrXDmR88wKPCYwGE21SyH