Repository navigation
perf(kuzu): skip empty label pairs in write_inheritance_links (-80% queries) - #1617
Conversation
write_inheritance_links iterated a 12-element label tuple as both child and parent -- 144 combinations -- issuing one UNWIND query per pair regardless of whether any row could match it. Most pairs match nothing and still pay a full Kuzu parse -> bind -> plan -> operator-init cycle. Probe each label once per _work call with MATCH (n:Label) RETURN n LIMIT 1 and skip pairs whose table is empty. A probe that raises treats the label as populated, so a failure can never drop an edge. Measured on a 47-file Kotlin module with session.run instrumented: write_inheritance_links 2748 -> 562 calls (-80%), 42.5s -> 9.4s; whole index 63.9s -> 49.7s. INHERITS edge count identical (120) before and after. On a real repo only 5 of the 12 tables are populated, so ~83% of the pairs were pure waste. Co-Authored-By: Claude Opus 5 <[email protected]>
|
@rrodriguesNutrium is attempting to deploy a commit to the shashankss1205's projects Team on Vercel. A member of the Team first needs to authorize it. |
Shashankss1205
left a comment
There was a problem hiding this comment.
Verified the correctness claim independently, which is the part that matters for a perf change. Indexed tests/fixtures/sample_projects on main and on this branch with KùzuDB and compared every relationship count:
main #1617
INHERITS 143 143
CONTAINS 4608 4608
CALLS 664 664
IMPORTS 301 301
HAS_PARAMETER 1257 1257
Identical. Behaviour-preserving, as claimed.
Two things I checked that support merging:
The fail-safe direction is right. _label_is_populated returning True on exception means a broken probe degrades to today's behaviour rather than silently dropping edges. That's the correct way round for this optimisation.
The probe cost is paid once, not per file. write_inheritance_links is invoked once per indexing run from all three call sites (graph_builder.py:860, scip_pipeline.py:272, pipeline.py:253) and _work executes once via execute_write_operation, so it's 12 probes per index total. I wanted to be sure this wasn't 12 × file-count.
One observation worth recording for anyone reading the numbers later: the win is proportional to how single-language the repo is. On our polyglot fixture set all 12 label tables are populated —
Class 260 Trait 32 Interface 58 Struct 116 Enum 19 Union 1
Record 2 Mixin 3 Extension 2 Module 183 Object 15 Variable 1488
— so nothing is skipped and the change is a wash there (I measured 176s → 180s serialization, within noise). Your −80%/−22% on a Kotlin-only module is entirely consistent with that: 5 of 12 populated is where the fan-out actually hurts, and single-language repos are the common case for real users.
Your cost model reproduces both cases exactly, which is why I'm confident in it rather than just in the measurement.
Unit suite green (1203 passed). Merging — thanks, this is exactly the kind of change I like reviewing.
Conflict was additive on both sides of tests/unit/core/test_database_kuzu_kotlin_metadata.py: this branch's four visibility/modifiers tests vs main's three write_inheritance_links probe tests (CodeGraphContext#1617). Kept all seven, plus the module-level 'import kuzu' this branch's migration tests need. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Conflict in tests/unit/core/test_database_kuzu_kotlin_metadata.py: main is a strict superset (this branch's tests all landed via CodeGraphContext#1620, plus main has CodeGraphContext#1617's three probe tests), so main's version is taken whole. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Conflict in test_database_kuzu_kotlin_metadata.py was additive: this branch's two write_binds_links tests vs main's three write_inheritance_links probe tests (CodeGraphContext#1617). Kept all five. Also regenerates the Kotlin golden added in CodeGraphContext#1618, since this branch adds AndroidHilt.kt to that fixture project. The diff is purely additive -- 24 new nodes, zero missing -- and the refreshed golden now pins both BINDS edges (@BINDS and @provides). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
write_inheritance_links iterates a 12x12 (child_label, parent_label) grid and passes the FULL resolved batch to every surviving pair. CodeGraphContext#1617 made the grid sparse by probing each label table once and skipping pairs whose table is empty -- but every pair that survives the probe still receives the whole batch, and the number of survivors is a property of the repo's label inventory, not of the edges being written. 1. Resolution now attaches the exact child/parent node label to each row. build_inheritance_and_csharp_files builds a path -> {name -> set(labels)} index over the 12 inheritance-eligible construct kinds and pins a row's child_label / parent_label only when that (path, name) maps to exactly one kind. The writer then runs one query per (child_label, parent_label) pair that actually occurs. 2. Rows whose (path, name) is ambiguous carry no label and fall back to the original enumeration. This is not hypothetical: as write_binds_links' docstring already records, Kotlin does not qualify `name` by enclosing scope, so a top-level interface and an unrelated nested class can legitimately share name+path. CodeGraphContext#1617's populated_labels set is retained and applied to exactly those fallback paths. It is deliberately NOT applied to the grouped pairs: those pairs come from the rows themselves, so they are never empty, and probing them would be pure overhead while a stale or lazily-created label table would let the probe silently drop a real INHERITS edge. 3. batch_size = 500 was declared and never used; the batch is now chunked. This is a separate robustness fix -- it keeps a five-figure UNWIND payload out of a single driver round-trip -- and it is not what produces the numbers below. On a repo with few populated labels it slightly *raises* the raw query count while cutting total work. Relationship to CodeGraphContext#1617: complementary, not overlapping. CodeGraphContext#1617's saving is a function of how many of the 12 label tables are empty; this change's saving is a function of how many (child, parent) pairs the data actually contains. On a repo where all 12 tables are non-empty, CodeGraphContext#1617 removes no pairs at all and the full fan-out survives. Measured, Neo4j, the pass in isolation on a 27,530-file Python/JavaScript repo (756k nodes, 20,719 resolved rows), same store and page cache on both sides, INHERITS deleted between runs so each starts identical: upstream/main this change resolution 1.05 s 1.77 s write_inheritance_links 7433.5 s 620.4 s 12.0x batch-rows processed 207,340 21,487 9.7x queries 32 80 0.4x Query count goes UP because batch_size chunking splits the one large pair into ~21 round-trips; the work per round-trip is what falls. Upstream's 32 queries are 12 probes + 16 internal + 4 external, and solving 16*I + 4*E = 207,340 against I + E = 20,719 gives I = 10,372, E = 10,347 -- i.e. the internal batch really is passed 16 times and the external batch 4 times, where this change passes each once. Resolution costs 0.72 s more, for the path -> {name -> set(labels)} index over all 27,530 files. These figures were measured against 810ea8a, before CodeGraphContext#1648 added row deduplication to this function. Dedupe shrinks the batch identically on both sides, so the batch-pass structure and the ratios above are unaffected; only the absolute row counts would be smaller today. Qualification: that database was provisioned by upstream's own schema, which has no composite (name, path) index, so every pair query scans rather than seeks and the absolute times are dominated by that scan cost. The quantity this change controls is the number of batch passes, and that is unaffected by indexing. On Kuzu the same structure shows up without any index question, because the Kuzu adapter unrolls `UNWIND $batch` into one query per row to sidestep a relationship planner bug: with all 12 label tables populated, the added test's 3-row input issues 468 session.run calls before this change and 18 after. The batch-pass count is structural: with P of the 12 labels populated the old code passes the internal batch P^2 times and the external batch P times. P was 4 here; at P = 12, CodeGraphContext#1617 skips nothing and the factor is 78x. Output is unchanged: the produced INHERITS edge set is identical. On the corpus above both sides produced the same 20,963 INHERITS edges with an identical per-(child_label, parent_label) breakdown across all seven pairs that occur. Also verified by (a) the 21-project parser golden suite (tests/fixtures/goldens), whose node/edge exports are unchanged, (b) upstream's three existing write_inheritance_links tests, which feed label-less rows and so exercise the fallback enumeration unmodified -- including CodeGraphContext#1617's probe-failure invariant, and (c) the added test, whose exact-edge-set assertion passes identically before and after the change while the query count collapses. On that corpus the ambiguity guard fired on 74 of 20,719 rows (0.36%), which took the enumeration fallback. Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
…1652) write_inheritance_links iterates a 12x12 (child_label, parent_label) grid and passes the FULL resolved batch to every surviving pair. #1617 made the grid sparse by probing each label table once and skipping pairs whose table is empty -- but every pair that survives the probe still receives the whole batch, and the number of survivors is a property of the repo's label inventory, not of the edges being written. 1. Resolution now attaches the exact child/parent node label to each row. build_inheritance_and_csharp_files builds a path -> {name -> set(labels)} index over the 12 inheritance-eligible construct kinds and pins a row's child_label / parent_label only when that (path, name) maps to exactly one kind. The writer then runs one query per (child_label, parent_label) pair that actually occurs. 2. Rows whose (path, name) is ambiguous carry no label and fall back to the original enumeration. This is not hypothetical: as write_binds_links' docstring already records, Kotlin does not qualify `name` by enclosing scope, so a top-level interface and an unrelated nested class can legitimately share name+path. #1617's populated_labels set is retained and applied to exactly those fallback paths. It is deliberately NOT applied to the grouped pairs: those pairs come from the rows themselves, so they are never empty, and probing them would be pure overhead while a stale or lazily-created label table would let the probe silently drop a real INHERITS edge. 3. batch_size = 500 was declared and never used; the batch is now chunked. This is a separate robustness fix -- it keeps a five-figure UNWIND payload out of a single driver round-trip -- and it is not what produces the numbers below. On a repo with few populated labels it slightly *raises* the raw query count while cutting total work. Relationship to #1617: complementary, not overlapping. #1617's saving is a function of how many of the 12 label tables are empty; this change's saving is a function of how many (child, parent) pairs the data actually contains. On a repo where all 12 tables are non-empty, #1617 removes no pairs at all and the full fan-out survives. Measured, Neo4j, the pass in isolation on a 27,530-file Python/JavaScript repo (756k nodes, 20,719 resolved rows), same store and page cache on both sides, INHERITS deleted between runs so each starts identical: upstream/main this change resolution 1.05 s 1.77 s write_inheritance_links 7433.5 s 620.4 s 12.0x batch-rows processed 207,340 21,487 9.7x queries 32 80 0.4x Query count goes UP because batch_size chunking splits the one large pair into ~21 round-trips; the work per round-trip is what falls. Upstream's 32 queries are 12 probes + 16 internal + 4 external, and solving 16*I + 4*E = 207,340 against I + E = 20,719 gives I = 10,372, E = 10,347 -- i.e. the internal batch really is passed 16 times and the external batch 4 times, where this change passes each once. Resolution costs 0.72 s more, for the path -> {name -> set(labels)} index over all 27,530 files. These figures were measured against 810ea8a, before #1648 added row deduplication to this function. Dedupe shrinks the batch identically on both sides, so the batch-pass structure and the ratios above are unaffected; only the absolute row counts would be smaller today. Qualification: that database was provisioned by upstream's own schema, which has no composite (name, path) index, so every pair query scans rather than seeks and the absolute times are dominated by that scan cost. The quantity this change controls is the number of batch passes, and that is unaffected by indexing. On Kuzu the same structure shows up without any index question, because the Kuzu adapter unrolls `UNWIND $batch` into one query per row to sidestep a relationship planner bug: with all 12 label tables populated, the added test's 3-row input issues 468 session.run calls before this change and 18 after. The batch-pass count is structural: with P of the 12 labels populated the old code passes the internal batch P^2 times and the external batch P times. P was 4 here; at P = 12, #1617 skips nothing and the factor is 78x. Output is unchanged: the produced INHERITS edge set is identical. On the corpus above both sides produced the same 20,963 INHERITS edges with an identical per-(child_label, parent_label) breakdown across all seven pairs that occur. Also verified by (a) the 21-project parser golden suite (tests/fixtures/goldens), whose node/edge exports are unchanged, (b) upstream's three existing write_inheritance_links tests, which feed label-less rows and so exercise the fallback enumeration unmodified -- including #1617's probe-failure invariant, and (c) the added test, whose exact-edge-set assertion passes identically before and after the change while the query count collapses. On that corpus the ambiguity guard fired on 74 of 20,719 rows (0.36%), which took the enumeration fallback. Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
write_inheritance_linksiterates a 12-element label tuple as both child and parent — 144 combinations — and issues oneUNWINDquery per pair, whether or not any row could match that pair.The batching itself is right; the waste is the fan-out. On a Kotlin repo only 5 of those 12 node tables are populated:
So ~83% of the pairs match nothing and still pay a full Kùzu parse → bind → plan → operator-init cycle.
The fix
Probe each label once per
_workcall withMATCH (n:Label) RETURN n LIMIT 1and skip pairs whose table is empty. A probe that raises treats the label as populated, falling back to current behaviour — a failed probe can never drop an edge.Measured
Indexing a 47-file Kotlin module with
session.runinstrumented:write_inheritance_linkscallsINHERITSedgesEdge count identical before and after — behaviour-preserving.
The cost model is mechanistic rather than fitted: the Kùzu compat layer forces
UNWINDrelationship writes into a per-row fallback (database_embedded_kuzu.py, the deliberateunordered_map::atworkaround), so each pair costs1 + rows, not1. That gives before = 144×17 + 12×25 = 2,748 and after = 12 probes + 25×17 + 5×25 = 562 — both matching the measurements exactly.Why only −22% overall
Because that same per-row fallback taxes every other writer too. This change removes the amplifier; the underlying
UNWINDworkaround is the larger lever and is out of scope here. Worth a separate look at whether the Kùzu planner bug it works around still reproduces on current versions — the guard is narrowly scoped enough (UNWIND $param AS rowonly) that an escape hatch would be cheap.Tests
Three added: the skip actually happens, a probe failure degrades gracefully to old behaviour, and a cross-label hierarchy (
Classinheriting fromInterface) still resolves when empty labels are skipped. That last one guards the real risk — the optimisation gates the child and parent loops independently, so a same-label-only test would miss a broken cross-pair case. Verified it fails ifInterfaceis wrongly excluded.writer.py+22 lines, plus tests. No schema change.