Skip to content

tstypes: partition the streamed-apply fact spool by class - #530

Merged
zzet merged 7 commits into
zzet:mainfrom
pbednarcik:perf/spool-class-partition
Aug 11, 2026
Merged

tstypes: partition the streamed-apply fact spool by class#530
zzet merged 7 commits into
zzet:mainfrom
pbednarcik:perf/spool-class-partition

Conversation

@pbednarcik

Copy link
Copy Markdown
Contributor

Problem

On a cold index of my production C# repo (≈117k graph nodes), the csharp-types provider dominated the enrichment phase: 527.7 s of a 532.3 s phase. The streamed apply walks its fact spool four times — once per phase (supers, calls, aliases, coverage) — and each walk decoded every file's full fact payload, regardless of which facts the phase actually consumes. Fact classes are heavily skewed (calls dominate every file, inheritance facts exist in a fraction of files, aliases are absent entirely in C#), so most of that decode volume was thrown away.

Change

Partition the per-pass temp spool by fact class:

  • Staging writes one files row per file plus one file_facts row per non-empty class — a file with no aliases contributes no aliases row at all, which is what lets a phase skip files wholesale rather than decode-and-discard.
  • Each apply phase pages over its own class only, with a bounded side-fetch of the page's imports rows (buildIndex needs the import map in every phase).
  • Coverage counting no longer rides on the supers phase (which now legitimately doesn't see inheritance-less files); it moved to a decode-free walk over file stubs that also pre-warms the per-file node groups for all four phases.
  • The apply hot cache's funnel counters — previously write-only — are logged at pass end (tstypes: apply hot cache), so the cache's behavior is visible in production runs, not just tests.

The spool is per-pass and never outlives it; class ids are stable row keys, nothing persisted.

Numbers

Microbench (committed, fact_spool_bench_test.go, class-skew shaped like the production repo): four-phase walk 296 → 32 ms, allocations 131 → 33 MB. Caveat honored in the bench comment: the legacy model queries per file where the old code scanned 32-file pages, so read the gap as decode volume (~4×) plus point-query overhead, not decode alone.

Production A/B (idle machine, from-scratch index, same repo):

before after
csharp-types provider 527.7 s 154.1 s (3.4×)
enrichment phase 532.3 s 160.2 s

Outcome counters digit-identical across runs: confirmed 9798 / added 114 / nodes enriched 41234 / coverage identical to 14 decimal places. Apply hot cache at pass end: 87% node / 86% adjacency hit.

Disclosures

  • Synthesis delta ±4: repo-wide synthesized edges moved 44,597 → 44,600 — two order-sensitive Go-side synthesizers (fn-value-callback −1, value-ref +4); csharp-iface-dispatch identical at 32,912. The order-sensitivity pre-exists; the partitioned walk changes apply order within phases.
  • Golden snapshot: apply_golden_test.go pins the full streamed pipeline (real extractors, partial-type fixture in the Razor-codebehind shape) against a committed snapshot; regenerate only deliberately with -update. It deliberately pins a pre-existing partial-class cross-part resolution gap as-is — that's a separate correctness item, not something this PR touches.
  • The coverage walk is a behavior-preserving relocation, guarded by the golden plus a streamed-vs-whole parity oracle.

Tests

Full tstypes suite green, including the new golden and the streamed-vs-whole parity oracle — re-verified after rebasing onto the sqlite-only backend transition (#473), since the storage substrate changed underneath. Repo-wide failure set vs clean main is identical on my Windows box (pre-existing platform bucket only, zero new). Five consecutive production cold runs with this code produced digit-identical outcome counters.

@zzet zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

@pbednarcik thank you for the performce fix.

A few points work notice (at least for follow up):

  • pageClass (fact_spool.go:341-382) never calls rows.Err(). The pre-PR page() did, and both sibling functions added in this same PR (pageFiles, attachImports) do. database/sql's Rows.Close() returns the driver's close error, not the iteration error — so a mid-iteration failure is swallowed. An error on the first Next() yields an empty page, which the driver loop in provider_stream.go reads as "phase complete," silently skipping that phase's remaining facts with no error surfaced. Low probability (local temp SQLite, single connection), but it's a silent-data-loss shape and a one-line fix. CI couldn't catch it: .golangci.yml uses default: standard (errcheck/govet/ineffassign/staticcheck/unused) — rowserrcheck isn't enabled.

  • partial progress under the pass deadline. applyStagedFacts runs under applyCtx, which carries the movable deadline. The new coverage walk is now the first thing under that context and mutates nothing; pre-PR the first work was the supers phase, which flushed edges page by page. So a deadline expiry that previously left some supers edges applied now yields EdgesAdded == 0. The comment at provider.go:100-107 records a prior incident in exactly that shape ("a partial with zero coverage"), which is what the movable deadline exists to prevent. The walk is decode-free, pre-warms the hot cache all four phases hit, and the pass got 3.3× faster overall — so hitting the deadline at all is now much less likely. What degrades is only the quality of the partial when it does.

  • file_facts's PK is (class, file_path) WITHOUT ROWID, so WHERE file_path = ? has no usable index prefix. I confirmed with EXPLAIN QUERY PLAN:

  legacy-arm  : SCAN file_facts                                   (full table scan)
  partitioned : SEARCH file_facts USING PRIMARY KEY (class=? AND file_path>?)

At 512 files × 4 phases that's ~2048 full table scans. The 296→32 ms headline is dominated by O(N²) scan cost, not decode volume. The PR body discloses only "point-query overhead," which materially understates it. The production A/B (527.7s → 154.1s) is measured on the real path and stands.

  • The byte cap counts only the class payload, not side-fetched imports, so peak page bytes can modestly exceed the stated 4 MiB.

  • appendFiles sizes its slices for 4 classes per file when there are 5 — capacity hint only. Bound params per chunk rise 192 → ~1280, still far under SQLite's 32766.

@zzet
zzet merged commit 9aa52e5 into zzet:main Aug 11, 2026
10 checks passed
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