Repository navigation
Trade a low-cardinality equality residual for a wider partition key so the per-chrom join reaches IE_JOIN — Closes #98 - #244
Draft
conradbzura wants to merge 3 commits into
Draft
conradbzura wants to merge 3 commits into
conradbzura wants to merge 3 commits into
Conversation
conradbzura
force-pushed
the
98-widen-partition-equality-residual
branch
from
September 4, 2026 14:46
573f2e7 to
9926be1
Compare
DuckDB plans any join with an equality among its conditions as a HASH_JOIN on that key and demotes the position inequalities to a residual filter. Inside a per-chromosome subquery a strand key has two values, so each hash bucket is half the partition and the join is quadratic again: the bedtools intersect -s shape, whose residual is a.strand = b.strand, took 45.6s at 1e6 intervals per side single-threaded against 0.86s once the join reaches IE_JOIN. IE_JOIN needs no equality among the join conditions, sorts on the first two inequalities in ON order, and evaluates the rest as a per-candidate filter. The pair is the equality spelled without the equals sign: over a totally ordered domain x = y holds exactly when x >= y and x <= y both hold, strand values are strings DuckDB orders by collation, and NULL is NULL in both forms. A strand equality, spelled as =, NOT (<>), or a single-element IN, whose two operands are bare columns naming each side's strand column is emitted as that pair after the two positional overlap predicates. The order carries the result: the strand pair placed first makes the join sort on strand and enumerate nearly every pair (10.4s against 0.17s at 2e5 rows). The rewrite stands down when any other cross-side equality remains among the residuals, because the join would hash on that key regardless. The rewrite is gated on the strand column because it is the one cardinality signal the transpiler has without statistics. A key more selective than the positional overlap prunes more pairs as a hash key than the pair does as a filter: a 10,000-value key over a dense span runs in 0.32s as a hash join and 7.1s as a pair. Every other equality is emitted as written, which also keeps expression, quantified, volatile, and cross-type operands out of the duplicated pair; the per-key choice belongs to the cost-based optimizer (#67). The pair is emitted in every branch, including the unpartitioned query the chromosome-count ceiling selects, so a query binds the same way regardless of its chromosome count. There the chromosome hash join carries the pair as a residual filter at roughly 1.8x the cost of the composite key (INNER 1.48s against 2.71s at 300 chromosomes and 1e6 intervals per side). Strand columns of unlike types bind under the equality and raise DuckDB's binder error under the pair; an operand that is not a bare column is emitted as written, which is the escape hatch. Widening the partition key to (chrom, strand), the alternative the issue proposed, was measured and rejected: its 48 branches each full-scan both tables, and the widened plan ran 1.3 to 1.6x slower than the inequality reference single-threaded. The rewrite keeps the 24 chromosome branches and the shared per-chromosome scaffolding.
Structure tests read the dynamic SQL back out of the session variable and parse it, asserting on the conjunct AST rather than on rendered text: the join predicate is the two positional overlap predicates followed by the strand pair, on both sides of the chromosome-count ceiling; every shape the rewrite claims (=, NOT (<>), single-element IN, either operand order, parenthesized, beside an inequality) yields the pair; every shape it leaves alone (another column, an expression, one-sided, under NOT or OR, mixed operands, collated, NULL-safe, quantified, volatile, or a strand pair beside another equality) is emitted as written; the rewrite follows each table's strand_col through custom, asymmetric, case-folded, defaulted, and disabled registrations; and a LEFT JOIN or count_overlaps shape carrying a strand equality declines the dialect. Plan tests read EXPLAIN (FORMAT JSON) over the dynamic SQL at a size where DuckDB picks a range join: the INNER form plans through IE_JOIN with no HASH_JOIN for all three spellings, the SEMI and ANTI EXISTS forms avoid BLOCKWISE_NL_JOIN, in each case with the positional conditions ahead of the strand pair; a non-strand equality still plans a HASH_JOIN, a strand equality beside another equality stays in the hash key, and the unpartitioned query hashes on the chromosome with the pair as a filter. Execution tests compare the dialect against the naive plan in rows and result schema over a stranded fixture in which one strand value exists on the left only, NULL strands exist on both sides, and one chromosome exists on the left only, across INNER, SEMI and ANTI joins and sixteen residual shapes drawn from one table; a property test draws random stranded tables, shapes and join kinds and asserts the same equivalence. A cross-type equality on a non-strand column agrees with the naive plan, and strand columns of unlike types raise DuckDB's binder error on either side of the ceiling.
The performance guide gains a bullet explaining why a cross-side equality would force a hash join inside each per-chromosome subquery, what the >= / <= pair means and why it keeps the join on IE_JOIN, that the pair is emitted in every branch and what it costs above the partition ceiling, which shapes are rewritten and which are emitted as written, how the strand column is registered, the bind-time failure on strand columns of unlike types and its escape hatch, the INNER / SEMI / ANTI boundary, why the gate is the strand column, and the measurements on DuckDB 1.5.5. The dialect's two-bucket taxonomy names the bind-time case as the one supported shape outside both buckets. The bedtools strand flag links to the migration recipe instead of restating its predicates.
conradbzura
force-pushed
the
98-widen-partition-equality-residual
branch
from
September 7, 2026 22:01
9926be1 to
e98bbd1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A residual is a non-INTERSECTS predicate the DuckDB IEJoin dialect inlines into each per-chromosome subquery's
ON. A cross-side equality residual is an equi-join key, and DuckDB plans any join carrying one as aHASH_JOINon that key with the position inequalities demoted to a residual filter. Fora.strand = b.strand, the residual ofbedtools intersect -s, the key has two values, so each hash bucket is half the partition and the join is quadratic again: 45.6s at 1e6 intervals per side single-threaded on DuckDB 1.5.5.Emit the equality between the two tables' registered strand columns as the pair
a.strand >= b.strand AND a.strand <= b.strand, placed after the two positional overlap predicates. The pair is the equality spelled without=: over any totally ordered domain,x = yholds exactly whenx >= yandx <= yboth hold, and strand values are strings that DuckDB orders by collation, so the conjunction admits a pair of rows only when the two strings are identical. NULL behaves the same in both forms, sinceNULL >= '+' AND NULL <= '+'is NULL, hence false, asNULL = '+'is. Nothing about the result changes; the spelling exists for the planner. DuckDB classifies anyleft = rightcondition as a hash key, whileIE_JOINrequires no equality among the join conditions, sorts on the first two inequalities inONorder, and evaluates the rest as a filter over each candidate pair. Written as two inequalities after the positional predicates, the strand condition reaches the range join as a per-candidate filter rather than a hash key. The same query runs in 0.86s single-threaded and 0.37s on eight threads.The rewrite is gated on the registered
strand_colbecause it is the one cardinality signal the transpiler has without statistics. A key more selective than the positional overlap prunes more pairs as a hash key than the pair does as a per-candidate filter, and the loss grows with overlap density: a 10,000-value key over a dense 100,000-position span runs in 0.32s as the hash join the dialect keeps and 7.1s as a pair. Every other equality is emitted as written. That also keeps expression, quantified (= ANY), volatile, and cross-type operands out of the duplicated pair, where duplication would change results or fail at bind time. The per-key choice belongs to the cost-based optimizer (#67).The per-chromosome branches carry the pair. The unpartitioned query the chromosome-count ceiling selects is a hash join on the chromosome, so its strand equality stays in the composite key and is emitted as written.
The issue proposed widening the partition key to
(chrom, strand)behind a runtime cardinality gate, and asked that two unexplained probe signals be understood first. Both are explained in the investigation comment: the survivingHASH_JOINis the decorrelation scaffolding of the correlatedEXISTSand appears identically in the fast-Splan, and the negative thread scaling does not reproduce on DuckDB 1.4.3 or 1.5.5. Widening is the wrong trade: its 48 branches each full-scan both tables, and those scans alone cost more than the join work they save, so the widened plan runs 1.3 to 1.6x slower than the inequality reference single-threaded. The rewrite keeps the 24 chromosome branches, leaves the shared per-chromosome scaffolding untouched, and needs no cardinality gate, so the gate and its test are replaced by plan-structure and predicate-order tests.Closes #98
Proposed changes
Rewrite a strand equality as an inequality pair
Add
_strand_equality_as_range_pairtosrc/giql/expanders/intersects_duckdb.py. It walks a copy of a residual's top-levelANDtree, descending parentheses, and replaces eachEQwhose two operands are bare columns naming their side's registered strand column withGTE AND LTE. The alias-to-strand-column map comes from theTableregistry, so a table withstrand_col=Noneis never rewritten and an unqualified column never matches. The docstring states the contract and the ordering precondition, and carries the planner mechanics and the gate rationale under anImplementation notesrubric.Build two predicate strings in
_build_sqlfrom the same residual ASTs. The rewritten one feeds the INNERONand the SEMI and ANTIEXISTSforms; the as-written one feeds the unpartitioned query, which already hashes on the chromosome.Cover the rewrite on the AST and the plan
Read the dynamic SQL back out of the session variable and parse it with sqlglot, so structure tests assert on the conjunct AST rather than on rendered text. Read
EXPLAIN (FORMAT JSON)for plan tests, whoseIE_JOINnode lists its conditions in order. Extend_make_planner_scale_tableswithstrandandchromsparameters, move the stranded rows into a fixture, and keep the residual shapes in one_EQUALITY_RESIDUAL_SHAPEStable shared by the parametrized and property tests.Document the rewrite
Add a strand-equality bullet to the DuckDB IEJoin section of
docs/transpilation/performance.rst: why the equality forces a hash join, how the pair after the positional keys keeps the join onIE_JOIN, that the unpartitioned fallback keeps the equality, which shapes are rewritten and which are emitted as written, why the gate is the registered strand column, and the measurements on DuckDB 1.5.5. Link the bedtools strand flags to the migration recipe instead of restating its predicates.Test cases
TestTranspileDuckDBIEJoinSQLStructurea.strand = b.strandLT, GT, GTE, LTEwith the strand pair last and no equalityTestTranspileDuckDBIEJoinSQLStructure>=/<=pairTestTranspileDuckDBIEJoinSQLStructureNOT, collated, quantified withANY, or carryingrandom()ornextval()>=/<=pairTestTranspileDuckDBIEJoinSQLStructureTestTranspileDuckDBIEJoinSQLStructurestrand_colorstrand_col=NoneTestTranspileDuckDBIEJoinExecutiona.strand = b.strandEXPLAIN (FORMAT JSON)IE_JOIN, noHASH_JOIN, and the positional conditions ahead of the strand pairTestTranspileDuckDBIEJoinExecutiona.strand = b.strandEXPLAIN (FORMAT JSON)IE_JOIN, noBLOCKWISE_NL_JOIN, and the positional conditions ahead of the strand pairTestTranspileDuckDBIEJoinExecutiona.name = b.nameEXPLAIN (FORMAT JSON)HASH_JOINkeyed on the name equalityTestTranspileDuckDBIEJoinExecutiona.strand = b.strandEXPLAIN (FORMAT JSON)HASH_JOINkey carries both the chromosome and the strand equalityTestTranspileDuckDBIEJoinExecutionTestTranspileDuckDBIEJoinExecution_EQUALITY_RESIDUAL_SHAPESTestTranspileDuckDBIEJoinExecutiona.strand = b.strandsits in the WHERE>=/<=pairTestTranspileDuckDBIEJoinExecutiongene_idon one side and an INTEGERgene_idon the other, joined ona.gene_id = b.gene_idTestTranspileDuckDBIEJoinExecutionstrandon one side and an INTEGERstrandon the other, joined ona.strand = b.stranddialect='duckdb'TestTranspileDuckDBIEJoinExecution