Skip to content

Fix GenesByGenericSpliceSites performance (6.5min -> 0.6s) - #232

Open
jbrestel wants to merge 1 commit into
masterfrom
feat/splice-site-partition-pruning
Open

Fix GenesByGenericSpliceSites performance (6.5min -> 0.6s)#232
jbrestel wants to merge 1 commit into
masterfrom
feat/splice-site-partition-pruning

Conversation

@jbrestel

Copy link
Copy Markdown
Member

The differential splice-site search (GenesByDifferentialSpliceSites<dataset>, one
question per dataset, all sharing GeneId.GenesByGenericSpliceSites) did not complete
on T. brucei. Four independent problems; the last one accounts for almost all the time.

1. The sample filter never reached the scans

loc_counts aggregated the full splicesitegenes ⋈ splicesitefeature join for all 48
protocol_app_node_ids, and stats repeated the identical join and aggregate — Postgres
does not share a CTE it inlines. The chosen samples were applied afterwards as a filter on
the CTE scan. Restricting loc_counts to the two samples up front lets the existing
ssf_revix1 index apply.

2. Two float aggregations compared for equality

WHERE this.sum_cpm = stats.max_cpm compared double precision sums from two separate
aggregation passes. Not guaranteed bit-identical, so a gene's max row could silently drop.
Replaced with a single window pass (sum_cpm = max(sum_cpm) OVER w), where both values
come from the same pass.

3. No partition key on the webready _p tables

webready.SpliceSiteTranscript_p is partitioned on org_abbrev and the query supplied no
predicate, so it scanned all 831 partitions, twice (1,662 partition scans, a 3,369-line
plan). The question is per-dataset and therefore per-organism, so the abbrev is known at
model-build time: spliceSitesDifferentialQuestion now declares prop=organismAbbrev and
passes it as a hidden paramRef, the same arrangement GenesByGoTermCLDataset uses. No
injector change needed — SpliceSites.java already calls setOrganismAbbrevFromDatasetName().
Also switched apidbtuning.TranscriptAttributes to webready.TranscriptAttributes_p.

4. The pairing CTE was inlined into a cross product

This was the actual cost. A CTE referenced once is inlined, so Postgres dissolved the
one/two pairing and instead joined each sample's transcripts independently, then paired
every row with every row:

Nested Loop  (actual time=694..376968, rows=266)
  Join Filter: ((two.location <> one.location) AND (one.source_id = two.source_id))
  Rows Removed by Join Filter: 35101699
  ->  Hash Join (t1 × one)  rows=6155
  ->  Hash Join (t2 × two)  rows=5700

35.1M rows generated and discarded — 377 s of the 390 s. It chose this because a CTE has no
statistics, so it estimated rows=1 per side and believed the cross product was one row.
AS MATERIALIZED on pairs (and gene_loc_stats) fences it.

Verification

Timed against genomicsdb_071n, all six sample pairs — every organism with a differential
question. Results verified identical to the pre-change query where it completed at all.

organism samples rows time
lmajFriedlin 44244/44245 8 1.01 s
tbruTREU927 (Gowthaman) 44234/44236 7 0.79 s
tbruTREU927 (Nilsson) 44307/44308 30 0.49 s
tcruCLBrener 44202/44204 10 0.21 s
tcruCLBrenerEsmeraldo-like 44276/44279 113 0.76 s
tcruCLBrenerNon-Esmeraldo-like 44253/44254 112 0.72 s

tbruTREU927/44316+44307 went from 390 s to 0.60 s. Also run end-to-end in the app on a
dev instance, which exercises WDK's CREATE TABLE … AS result-cache wrapper.

Notes for the reviewer

  • Planner cost is misleading on this query: a 106k-cost plan ran 8 minutes while a
    144k-cost plan ran 1 second. Please judge changes here by timing, not EXPLAIN cost.
  • Adding a redundant g.protocol_app_node_id IN (...) to loc_counts is logically safe
    (0 disagreeing rows of 3.7M) but takes the query from 1 s to 500 s — it switches
    splicesitegenes off its index-only scan and collapses the row estimates. Deliberately
    not included.
  • Testing this from wdkQuery -showQuery is not currently possible: GeneVQ.SpliceSiteExperiment
    returns external_database_release_id as internal, while every per-dataset override
    returns ed.name, which is what the dependent sample vocab needs. Dormant (the override
    always wins) and left alone here to keep the diff focused.

🤖 Generated with Claude Code

Restrict the sample aggregation to the two chosen samples up front so the
indexes on protocol_app_node_id apply. Previously all 48 samples were
aggregated -- twice, since the stats subquery repeated the same join -- and
filtered afterwards.

Compute the predominant-site max in a single window pass. The old self-join
on sum_cpm = max_cpm compared double precision values produced by two
separate aggregations, which are not guaranteed bit-identical and could drop
a gene's max row.

Pass organismAbbrev from the injected question (the injector already calls
setOrganismAbbrevFromDatasetName) so the partitioned webready _p tables prune
to one organism instead of scanning 800+ partitions, and read
webready.TranscriptAttributes_p rather than the unpartitioned view.

Materialize the pairing CTE. Inlined, Postgres flattens it into a cross
product joined by filter -- 35M rows generated and discarded on
tbruTREU927 -- because a CTE has no statistics for it to estimate from.

Timed on all six sample pairs across every organism with a differential
question: worst case 1.0s, previously unusable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant