Skip to content

perf(kuzu): skip empty label pairs in write_inheritance_links (-80% queries) - #1617

Merged
Shashankss1205 merged 1 commit into
CodeGraphContext:mainfrom
rrodriguesNutrium:upstream/inheritance-label-fanout
Aug 13, 2026
Merged

Shashankss1205 merged 1 commit into
CodeGraphContext:mainfrom
rrodriguesNutrium:upstream/inheritance-label-fanout

Conversation

@rrodriguesNutrium

Copy link
Copy Markdown
Contributor

write_inheritance_links iterates a 12-element label tuple as both child and parent — 144 combinations — and issues one UNWIND query 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:

Class 279   Interface 118   Module 715   Object 93   Variable 3218
Trait 0   Struct 0   Enum 0   Union 0   Record 0   Mixin 0   Extension 0

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 _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, falling back to current behaviour — a failed probe can never drop an edge.

Measured

Indexing a 47-file Kotlin module with session.run instrumented:

before after
write_inheritance_links calls 2,748 562 (−80%)
its time 42.5 s 9.4 s
total queries 6,186 4,000 (−35%)
whole index 63.9 s 49.7 s (−22%)
INHERITS edges 120 120

Edge count identical before and after — behaviour-preserving.

The cost model is mechanistic rather than fitted: the Kùzu compat layer forces UNWIND relationship writes into a per-row fallback (database_embedded_kuzu.py, the deliberate unordered_map::at workaround), so each pair costs 1 + rows, not 1. 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 UNWIND workaround 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 row only) 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 (Class inheriting from Interface) 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 if Interface is wrongly excluded.

writer.py +22 lines, plus tests. No schema change.

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]>
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

@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 Shashankss1205 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@Shashankss1205
Shashankss1205 merged commit 2ea6832 into CodeGraphContext:main Aug 13, 2026
18 of 19 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog tasks to Done in CGC Progress Board Aug 13, 2026
Shashankss1205 added a commit to rrodriguesNutrium/CodeGraphContext that referenced this pull request Aug 13, 2026
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]>
Shashankss1205 added a commit to rrodriguesNutrium/CodeGraphContext that referenced this pull request Aug 13, 2026
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]>
Shashankss1205 added a commit to rrodriguesNutrium/CodeGraphContext that referenced this pull request Aug 13, 2026
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]>
@Shashankss1205 Shashankss1205 added gssoc:approved GSSoC validation: counts toward scoring level:advanced GSSoC difficulty: 55 pts contributor / 30 mentor mentor:Shashankss1205 GSSoC mentor attribution: credits reviewing mentor quality:clean GSSoC quality: x1.2 contributor / +5 mentor type:performance GSSoC type bonus: performance labels Aug 15, 2026
mollyjester added a commit to mollyjester/CodeGraphContext that referenced this pull request Aug 19, 2026
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]>
Shashankss1205 pushed a commit that referenced this pull request Aug 19, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gssoc:approved GSSoC validation: counts toward scoring level:advanced GSSoC difficulty: 55 pts contributor / 30 mentor mentor:Shashankss1205 GSSoC mentor attribution: credits reviewing mentor quality:clean GSSoC quality: x1.2 contributor / +5 mentor type:performance GSSoC type bonus: performance

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants