Description
The DISJOIN breakpoint join scales quadratically. The __giql_dj_cuts CTE (src/giql/expanders/disjoin.py, _build_disjoin_sql) finds every reference breakpoint strictly inside each target interval with:
... FROM __giql_dj_tgt AS t JOIN __giql_dj_bp AS bp
ON bp.chrom = t.chrom AND bp.pos > t.start AND bp.pos < t.end
DuckDB plans the chrom equality as a HASH_JOIN and applies the two position inequalities as a residual filter — effectively a nested loop within each chromosome bucket. This is the same pathology intersect had before #208, but DISJOIN is a separate expander and never received the IEJoin treatment. Because coverage-via-DISJOIN and subtract build on DISJOIN, they inherit the slowdown.
Measured (1 thread), found by the benchmark suite:
| Query |
1e5 |
1e6 |
DISJOIN(a) self-coverage |
1.75 s |
> 20 s (timeout) |
DISJOIN(a, reference := b) |
3.0 s |
> 20 s (timeout) |
Expected Behavior
DISJOIN should scale roughly log-linearly like the fixed intersect/count_overlaps paths, not quadratically.
Root Cause
DuckDB selects its fast IE_JOIN range-join operator only for INNER pure-inequality joins with no equality key. The breakpoint join carries the bp.chrom = t.chrom equality, so it falls to HASH_JOIN + filter.
Proposed fix
Partition the breakpoint join per chromosome so each partition is a pure-inequality INNER join DuckDB plans through IE_JOIN — the approach #208 used for column-to-column INTERSECTS. Validated prototype (per-chromosome UNION ALL of ... FROM (tgt WHERE chrom=c) t JOIN (bp WHERE chrom=c) bp ON bp.pos > t.start AND bp.pos < t.end):
|
1e5 |
1e6 |
| current |
1.70 s |
> 20 s |
| per-chrom IEJoin |
0.067 s |
0.58 s |
Identical output (row counts match), EXPLAIN confirms IE_JOIN and no BLOCKWISE_NL_JOIN. Because GIQL does not know the data's chromosomes at transpile time, the per-chromosome UNION must be built at runtime (a string_agg over the distinct chromosomes, executed via a session-variable / query() indirection), which means DISJOIN's dialect="duckdb" output becomes a multi-statement form as the INTERSECTS override already is. Reusing the #208 per-chromosome machinery is the natural path.
Description
The DISJOIN breakpoint join scales quadratically. The
__giql_dj_cutsCTE (src/giql/expanders/disjoin.py,_build_disjoin_sql) finds every reference breakpoint strictly inside each target interval with:DuckDB plans the
chromequality as aHASH_JOINand applies the two position inequalities as a residual filter — effectively a nested loop within each chromosome bucket. This is the same pathologyintersecthad before #208, but DISJOIN is a separate expander and never received the IEJoin treatment. Because coverage-via-DISJOIN and subtract build on DISJOIN, they inherit the slowdown.Measured (1 thread), found by the benchmark suite:
DISJOIN(a)self-coverageDISJOIN(a, reference := b)Expected Behavior
DISJOIN should scale roughly log-linearly like the fixed
intersect/count_overlapspaths, not quadratically.Root Cause
DuckDB selects its fast
IE_JOINrange-join operator only for INNER pure-inequality joins with no equality key. The breakpoint join carries thebp.chrom = t.chromequality, so it falls toHASH_JOIN+ filter.Proposed fix
Partition the breakpoint join per chromosome so each partition is a pure-inequality INNER join DuckDB plans through
IE_JOIN— the approach #208 used for column-to-column INTERSECTS. Validated prototype (per-chromosomeUNION ALLof... FROM (tgt WHERE chrom=c) t JOIN (bp WHERE chrom=c) bp ON bp.pos > t.start AND bp.pos < t.end):Identical output (row counts match),
EXPLAINconfirmsIE_JOINand noBLOCKWISE_NL_JOIN. Because GIQL does not know the data's chromosomes at transpile time, the per-chromosome UNION must be built at runtime (astring_aggover the distinct chromosomes, executed via a session-variable /query()indirection), which means DISJOIN'sdialect="duckdb"output becomes a multi-statement form as the INTERSECTS override already is. Reusing the #208 per-chromosome machinery is the natural path.