Skip to content

[AI-generated, unverified] ifcopenshell-python: decide remove_deep2 containment in C++ without materializing inverses - #9502

Open
Moult wants to merge 2 commits into
open-perf-v2from
remove-deep2-containment
Open

Moult wants to merge 2 commits into
open-perf-v2from
remove-deep2-containment

Conversation

@Moult

@Moult Moult commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

This PR was written entirely by an AI coding tool (Claude Code) and has not been verified by a human. The maintainer who opened it has not reviewed the code, the design, or the benchmark methodology. Treat every claim below as unverified until a person has checked it.

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_product spends most of its time in util.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 was

len(set(ifc_file.get_inverse(subelement)) - subgraph_set) == 0

which wraps every referencing instance into a Python entity_instance before 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 against also_consider, by traversing each considered element and scanning the result with entity_instance.__eq__ (990k calls in the same run).

Change

  • inverse_index::all_sources(id, pred) visits the live records referencing id (base tier and delta) and stops at the first source pred rejects.
  • file::all_referencing_instances(instance_id, pred) dispatches that per storage backend the way instances_by_reference does: the in-memory index through all_sources, RocksDB through the same v|<id>| prefix seek, stopping at the first source pred rejects.
  • Two thin %extend helpers on file build the id set and call 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 — one crossing in, one out, early exit per id in C++.
  • remove_deep2 calls _ids_referenced_only_within once 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_within against the also_consider ids.

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_consider check 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

Benchmark

root.remove_product per call over the first 300 IfcProducts (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.

model open-perf-v2 this PR
TXG 61 MB 52.1 ms 23.3 ms (-55%)
210_King 155 MB 51.0 ms 17.7 ms (-65%)
OKgate22 243 MB 314.6 ms 323.2 ms (+3%)
10 MB (private) 9.5 ms 5.8 ms (-39%)
78 MB (private) 1285 ms 62.5 ms (-95%)
85 MB (private) 377.5 ms 204.1 ms (-46%)
112 MB (private) 319.2 ms 66.9 ms (-79%)
262 MB (private) 50.3 ms 27.2 ms (-46%)
292 MB (private) 3.3 ms 2.5 ms (-24%)
330 MB (private) 3299 ms 171.7 ms (-95%)
366 MB (private) 4059 ms 1257 ms (-69%)
548 MB (private) 5.8 ms 4.3 ms (-26%)
632 MB (private) 201.0 ms 27.7 ms (-86%)
825 MB (private) 403.8 ms 95.4 ms (-76%)

Removing 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_product after this change is file.remove() itself — of which ~85% is remove_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 with convert_path_to_rocksdb, _all_inverses_within and _ids_referenced_only_within return 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), and test_validate_rocksdb.py passes. Note that root.remove_product on a RocksDB-backed file segfaults inside file.remove() on open-perf-v2 without 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

…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
Comment thread src/ifcwrap/IfcParseWrapper.i Outdated
return allowed.count(source_id) != 0;
});
} else {
throw ifcopenshell::exception("_all_inverses_within is only implemented for in-memory storage");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not an option and also not necessary. There are plenty of variants/adapters around these maps so that compatibility is possible.

@Moult Moult Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants