Skip to content

perf: per-ident :introduced-by / lineage point queries are 1.33M graph reads — 33.6% of ingestion wall clock #239

Description

@adityamukho

Summary

With #236's rowid delete landed, _retract is no longer the bottleneck: it fell from
4,137.8 s / 73.5% of the run to 32.7 s / 2.1%. The new dominant cost is
graph read queries: _db_execute on (query ...) is 1,328,250 calls costing
523.0 s — 33.6% of a 1,558.6 s run
.

91% of that sits in two call sites, and both issue the same shape of query — a
single-entity point lookup, one entity at a time, in a loop over every candidate ident
of every file of every commit:

[:find ?c :where [<ident> :introduced-by ?c]]

This is the facts_dedup story again in a different table: a per-item lookup issued in a
loop where a set-at-a-time form is available, and the codebase already contains the
preload pattern that fixes it (_preload_provisional_idents, whose own docstring says it
exists because "this set is consulted where a per-ident check is too expensive").

Measurements

Full instrumented at-scale ingestion of this repo, master, 568 commits, 1,558.61 s,
inclusive/exclusive per-function attribution. Reproduced across three runs of the same
build — plain 1,600.55 s, leaf-instrumented 1,622.50 s, inclusive/exclusive 1,558.61 s
(±2%).

D1 — where the 1,558.61 s goes (exclusive, no double-counting)

component calls seconds % of run
_db_execute (query ...) 1,328,250 523.02 33.6%
_forward_apply own Python (incl. _build_code_triples) 568 369.70 23.7%
_reverse_apply own Python 284 162.22 10.4%
_db_execute (retract ...) 88,917 21.81 1.4%
_db_execute (transact ...) 10,191 9.93 0.6%
fact_index.delete_facts (234,928 triples) 88,902 8.79 0.6%
_lineage_is_provisional own code 603,486 5.08 0.3%
_correction_sweep_apply own Python 284 4.82 0.3%
fact_index.insert_facts (314,091 triples) 10,191 3.42 0.2%
_entity_introduced_by_query own code 464,377 3.88 0.2%
_resolved_facts_triples 99,108 1.70 0.1%
all other wrapped functions 1.97 0.1%
attributed total 1,117.14 71.7%
unattributed (_extract_commit on the process pool, _run_ingestion orchestration, thread hops) 441.47 28.3%

Stage A 623.25 s (40.0%), Stage B 933.88 s (59.9%).

D2 — the 1.33M queries, by calling function

query call site count seconds % of run avg
_correction_sweep_apply (inline :introduced-by + :modified-in) 258,074 255.48 16.4% 0.99 ms
_entity_introduced_by_query (from _reverse_apply) 464,377 215.44 13.8% 0.46 ms
_lineage_is_provisional 603,486 50.28 3.2% 0.08 ms
everything else combined 2,313 1.82 0.1%
total 1,328,250 523.02 33.6%

The first two are 30.2% of the entire run on their own.

D3 — what #236 actually bought

before (553 commits, 5,627 s) after (568 commits, 1,558.6 s)
all _retract 79,414 calls / 4,137.8 s / 73.5% 88,917 calls / 32.7 s / 2.1%
avg per _retract 52.1 ms 0.37 ms
fact_index.delete_facts (not separated; ~all of the above) 8.79 s / 0.6%
_entity_introduced_by_set_provisional_batch retract 204 calls / 2,181.4 s / 38.8% 208 calls / 6.79 s / 0.4%
_correction_sweep_apply retract 72,971 calls / 1,629.1 s / 29.0% 82,210 calls / 21.5 s / 1.4%

Wall clock: 5,133.19 s → 1,600.55 s against the plain harness, i.e. 20.3x the 78.87 s
forward-only baseline, down from 65x.

The code

mcp_server.py:8967-9001, inside _correction_sweep_apply's per-file loop:

for ident in candidate_idents:
    raw = _db_execute(db, f"(query [:find ?c :where [{ident} :introduced-by ?c]])")
    ...
    if _lineage_is_provisional(db, ident):        # a second point query
        ...
    else:
        raw2 = _db_execute(db, f"(query [:find ?c :where [{ident} :modified-in ?c]])")

mcp_server.py:7914-7942, inside _reverse_apply's per-file loop — the same ident is
queried up to twice per pass:

known_before = {ident: "known" for ident in candidate_idents
                if _entity_introduced_by_query(db, ident) is not None}
...
for ident in set(candidate_idents) & known_before_snapshot:
    if _lineage_is_provisional(db, ident):
        superseded_ident = _entity_introduced_by_query(db, ident)
    else:
        already_authoritative_touched.append((ident, _entity_introduced_by_query(db, ident)))

_entity_introduced_by_query (mcp_server.py:5378) and _lineage_is_provisional
(mcp_server.py:5210) are both one-line single-entity _db_execute wrappers with no
caching.

Suggested direction

The set-at-a-time precedent already exists in this file: _preload_known_deps,
_preload_provisional_idents, _preload_known_entities, and
_rebuild_index_from_graph's ident_map all bind ?e as an output variable and
retrieve the whole relation in one query.

The design question this needs to answer is cache maintenance, not whether the batch
query works.
:introduced-by is written during the run by the very walks that read
it, so a run-start snapshot goes stale — unlike state.provisional_idents, which is
already a preload snapshot precisely because its staleness is tolerable there and
_forward_apply documents at length (mcp_server.py:8306-8356) that it is "not the
authority". The writers are bounded and enumerable:

  • _entity_introduced_by_set_provisional_batch (mcp_server.py:5457-5458)
  • _re_date_structural_facts' supersede path (mcp_server.py:5568)
  • _build_code_triples (mcp_server.py:6677-6736), via _forward_apply/_reverse_apply
  • _forward_apply's external-entity path (mcp_server.py:8586)

so a write-through in-run cache is feasible. Whether it should be that, or a per-commit
batch query scoped to the commit's candidate idents, is a spec decision — the per-commit
form avoids the staleness problem entirely at the cost of one query per commit over
~1,200 idents, and should be measured against the write-through form before either is
built.

The three sites are not equally worth it: at 0.99 ms and 0.46 ms per call the two
:introduced-by sites carry 30.2% of the run, while _lineage_is_provisional at 0.08 ms
carries 3.2% despite being the most-called of the three. Do the first two.

What NOT to do — checked against the post-#236 numbers

Do not batch _correction_sweep_apply's case-3 :modified-in retract. #236 said this
was worthless before the rowid fix and that the post-fix table should decide it. The table
has now decided: that site is 82,210 calls costing 21.52 s — 1.4% of the run, down
from 1,629 s / 29.0%. The entire (retract ...) cost across every call site in the run is
21.81 s. Collapsing 82,210 calls into ~284 therefore has a hard ceiling of about 1.4% of
wall clock and a realistic recovery well under that. Still not worth doing — but note
the reason has changed: it is no longer "the cost follows facts, not calls", it is simply
that the whole line item is now small.

Do not target _resolved_facts_triples' _query_ident calls. #236's spec named this
as the expected largest residual inside _retract. Measured: _resolved_facts_triples is
1.70 s — 0.1% of the run, and _query_ident is never called at all during
ingestion, because it only fires for #uuid-tagged entities and every ingestion entity is
already a keyword ident. The expectation was wrong; this is a dead end.

Notes on the measurement

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions