Skip to content

perf(gfql/polars): stop deduplicating semi-join key sides - #1784

Merged
lmeyerov merged 4 commits into
masterfrom
perf/gfql-polars-semi-no-dedup
Jul 27, 2026
Merged

perf(gfql/polars): stop deduplicating semi-join key sides#1784
lmeyerov merged 4 commits into
masterfrom
perf/gfql-polars-semi-no-dedup

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

perf(gfql/polars): stop deduplicating semi-join key sides

Stacked on #1783 (perf/gfql-lean-empty-left). Review the top commit only.

The bug

The native polars chain applied .unique() to every frame it fed into a how="semi" join:

def _semi(df, ids_df, df_col, id_col):
    return df.join(ids_df.select(id_col).unique(), left_on=df_col, right_on=id_col, how="semi")

def _idframe_lf(lf, col):
    return lf.select(pl.col(col).cast(node_dtype).alias(NID)).unique()

A semi-join emits a left row iff at least one matching right row exists. Duplicate keys
on the right cannot change which rows come back, and — unlike an inner join — cannot
multiply them either. So the deduplication is a full hash pass over the key column bought
for no observable effect.

That would be merely wasteful if the key side were small. It isn't: on an unfiltered hop
the key side IS the node table
, so this put O(N) work inside a query whose answer is
O(degree). The seeded single-hop planner builds two such key frames per hop.

How it was found

Bisected, not guessed — and the first two hypotheses died:

step result
profile the seeded polars query collect_all = 108.8 ms of 140 ms, 2 calls
time the semi-joins those calls perform 3.17 ms (edges) + 1.07 ms (nodes) — not the cost
typed vs untyped edge −0.58 ms — the edge predicate is not the cost either
edges 1.75M → 14M 108 → 137 ms — only ~30 ms scales with E
nodes 400k → 3.18M (E fixed) 40 → 129 ms — linear in N
node width 5 → 31 columns 136 → 136 ms — not attribute materialization
nodes.select(key).unique() alone, N=3.18M 52.99 ms ≈ half of the 105 ms

Dumping the optimized plans confirmed it: UNIQUE[keep_strategy: Any] BY None over
DF["key",…] PROJECT 1/32 COLUMNS, repeatedly, feeding SEMI JOINs.

Isolated on raw polars, the rewrite is worth 7× on this shape:

with .unique() without
3.18M-key side, tiny probe 60.67 ms 8.64 ms (7.02×)
same, with the cast _idframe emits 65.12 ms 8.98 ms (7.25×)

The fix

.unique() dropped only where the frame is provably a semi key side and nothing else:

  • chain.py _semi (the helper hardcodes how="semi")
  • chain.py alias hop-window gate, and the next-edge endpoint gate
  • chain.py endpoint gate in the two-hop fast path
  • pattern_apply.py start_nodes gate
  • hop_eager.py _idframe_lf — all four consumers (allowed_source_lf, allowed_dest_lf,
    target_final_lf, frontier_lf) are semi key sides

Deliberately kept: named in the alias pass. It feeds a how="left" join, where
duplicates genuinely would multiply rows. The eager multi-hop loop's _idframe is also
untouched — its frames flow into concat/anti-join bookkeeping, which is a separate argument
and a separate change.

Correctness

  • 380 differential comparisons, 0 divergences. Two suites: the 108-case polars-path
    sweep, plus a new adversarial one built specifically because the usual synthetic graphs
    have unique node keys and would make this rewrite trivially equivalent — so it exercises
    duplicate node keys, null ids, dangling edges, duplicate start_nodes, across 11
    traversal shapes and 4 graph variants.
  • graphistry/tests/compute/gfql + test_compute_chain.py: identical failure sets
    before and after (1 pre-existing GPU-driver collection error on this host, both sides).
  • Row counts identical on every measured query.

Tests

test_engine_polars_semi_key_dedup.py pins the boundary, not the speed (pandas is the
oracle throughout, so a divergence fails as a parity break rather than a guess):

  • duplicates reaching a semi key side must not change results — 8 shapes × 3 graph variants
  • duplicate node keys must not multiply output rows — the regression a wrongly-removed
    .unique() on the how="left" side would cause
  • a dangling endpoint must still be excluded — pinning that the gate is load-bearing,
    not vacuous, so a future "this gate accepts everything, drop it" shortcut fails here
  • duplicate start_nodes must be inert — the one key side a caller controls

Not a benchmark hack

The rewrite keys on a structural property of the operator — a frame used only as a
how="semi" key side — and helps every traversal on every schema, engine and label. No
query, constant, dataset or column name is recognized anywhere. It removes work rather than
adding a special case, so there is no path it can make slower.

Measured

Synthetic LDBC SNB SF1-shaped graph (3.18M nodes / 14M edges), engine='polars',
seeded typed hop, min of 5:

before (#1783) after
E = 14M 127.44 ms 57.12 ms 2.23×
E = 1.75M 102.83 ms 31.35 ms 3.28×

The larger speedup at fewer edges is the signature of the fix: the removed cost scaled
with node count, so it dominates more when there is less edge work to hide it.

Real-lane numbers to follow — this is a draft until the position-balanced SF1 A/B lands.

@lmeyerov

Copy link
Copy Markdown
Contributor Author

Measured on the real LDBC SNB SF1 polars lane

Position-balanced ABBA/BAAB — 4 samples per build, each build in each slot position exactly
once, because this host has a ~5% slot effect that makes naive A,B,A,B uninterpretable.
Quiet host, host-wide perf lock held for the whole experiment, rows_returned validated
identical on every query. Pooled mean of per-slot medians.

query A = #1783 B = this branch delta
IS1 seed-lookup 77.05 ms 38.16 ms −50.5%
IS5 message-creator 69.52 ms 33.19 ms −52.3%
IS2 expand-order-limit 91.84 ms 52.72 ms −42.6%
IS7 message-replies 14.10 ms 11.66 ms −17.3%
IS4 message-content 3.70 ms 3.45 ms −6.7%

4 faster, 0 slower, every result value-identical.

A discarded run, disclosed

The first A/B of this branch is thrown away, not reported: I committed the
engine_arrays.py change to the B worktree while slots 3–8 were still running, and
--graphistry-repo-path copies the local tree at each slot start — so "B" was not one build.
The numbers happened to look fine (−51%/−54%/−44%), which is exactly why they are not being
used. The table above is a full re-run on frozen trees.

That discarded run also showed IS4 at +14.0%, which would have been a regression. It did
not reproduce (−6.7% here). IS4 is seed-only — MATCH (message:Message {id: X}) RETURN …,
no hop — so no code path this PR touches is even reachable from it; the +14% was host noise
at a 3.5 ms scale.

@lmeyerov
lmeyerov force-pushed the perf/gfql-lean-empty-left branch from 77e84b0 to 7a72a4c Compare July 26, 2026 23:24
@lmeyerov
lmeyerov force-pushed the perf/gfql-polars-semi-no-dedup branch 2 times, most recently from 975235e to 5d01b91 Compare July 26, 2026 23:28
@lmeyerov

Copy link
Copy Markdown
Contributor Author

Status: keeping this draft for one reason only — it does not yet have a pyg-bench lock-in
lane of its own. The lane in pyg-bench #101 pins #1782 and #1783; extending it to cover the
semi-join key-dedup invariant is the remaining item. Applying the same bar here that was set
for the PRs below it.

Everything else is in place: CHANGELOG entry, SF1 lane evidence (4 faster / 0 slower, rows
identical), 380 differential parity cases with 0 divergences, and tests that were
mutation-checked rather than merely run — removing the .unique() that must stay (the
alias frame feeding a how='left' join, where duplicates genuinely multiply rows) fails 7 of
them.

One correction to my own test hygiene, since it is visible in the history: I committed
test_engine_polars_semi_key_dedup.py without running pytest on it — the container-side
parity probes had covered the same ground, so the gap only surfaced at rebase. Three cases
failed because start_nodes stands in for the node frame, so a key-only frame made the seed's
id predicate raise before reaching the semi-join under test. Fixed in 604e37d2.

Comment thread CHANGELOG.md Outdated
- **GFQL secondary node property indexes (`create_index('node_prop', column=...)` / `g.gfql_index_node_props([...])`)**: a seed predicate on a NON-key column — `MATCH (m {id: 42})` where the graph's node id binding is some other column — previously cost a full node scan, because the registry only indexed the node-id binding and the CSR adjacencies. A property index is the same pay-as-you-go sidecar as the existing kinds: sorted distinct values over node **row positions** (CSR, so duplicate values are indexable), never reorders `.nodes`, fingerprint-validated so a `.nodes()` rebind is treated as absent (safe miss, never a wrong answer), engine-polymorphic (numpy host / cupy on-device), and policy-gated (`off`/`use`/`auto`/`force`). The seeded fixed-hop planner picks the **most selective** indexed scalar predicate in the seed filter using a free CSR-offset estimate, gathers those candidates, and applies every remaining predicate to them — so results are identical whether the index is present, absent, stale, or cost-gated out. `show_indexes()` lists property indexes; `drop_index('node_prop', column=...)` drops one. Only integer columns are indexable today (float NaN ordering, strings on cupy, and nulls all decline to the scan); widening that is additive. **Perf (dgx-spark, official LDBC SNB SF1, 3.18M nodes, warm median, value-identical 19-row result):** interactive-short IS7 `71.6 ms -> 19.5 ms` (**3.7x**), with a one-time `112 ms` build — the seed lookup itself goes from a `51.2 ms` scan to `0.096 ms`.

### Performance
- **GFQL polars chain stops deduplicating semi-join key sides**: the native polars executor applied `.unique()` to every frame it fed into a `how="semi"` join. A semi-join emits a left row iff at least one matching right row exists, so duplicate keys can neither change which rows come back nor multiply them the way an inner join would — the deduplication was a full hash pass over the key column bought for no observable effect. On an unfiltered hop the key side **is** the node table, so this put **O(N) work inside a query whose answer is O(degree)**: the seeded single-hop plan built two such key frames per hop, each costing ~53 ms at 3.18M nodes — more than the rest of the query combined. The `.unique()` is now dropped everywhere the frame is provably a semi key side only (the `_semi` helper, the alias hop-window and next-edge endpoint gates, the two-hop fast path's endpoint gate, the `start_nodes` gate, and the single-hop planner's id frames). It is deliberately **kept** on the alias frame that feeds a `how="left"` join, where duplicates genuinely would multiply rows, and the eager multi-hop loop is untouched (its frames also flow into concat/anti-join bookkeeping, a separate argument). Measured on a 3.18M-node / 14M-edge polars graph (LDBC SNB SF1-shaped), a seeded typed hop goes **127.4 → 57.1 ms (2.23×)** with identical row counts; at 1.75M edges **102.8 → 31.4 ms (3.28×)**, confirming the removed cost scales with node count, not edge count. Parity held across 380 differential comparisons covering duplicate node keys, null ids, dangling edges, duplicate `start_nodes`, and 11 traversal shapes; the gfql and chain suites show identical failure sets before and after. Pinned by tests that assert the boundary rather than the speed: duplicates reaching a semi key side must not change results or multiply rows, a dangling endpoint must still be excluded (the gate is load-bearing, not vacuous), and duplicate `start_nodes` must be inert.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

once benefit proven & locked in pyg-bench, and happy with postive/negative tests on either side of opt boundary, and ci still green, can land both sides

@lmeyerov

Copy link
Copy Markdown
Contributor Author

[1] cleared — the lock-in lane was run, per-probe, per-build

pyg-bench #101 is merged (a672a46) and the lane was then run from merged main against
five frozen read-only git archive exports, perf lock held externally for the whole sweep on
a quiet host.

probe pins master #1782 #1783 #1784 #1785
indexed-typed-edge-match-hop[polars] #1782 regression pass pass pass pass
empty-left-typed-edge-miss[pandas] #1783 regression regression pass pass pass
polars-edge-order-materializations #1783 regression regression pass pass pass
semi-join-key-dedup-node-ladder[polars] #1784 regression regression regression pass pass
indexed-typed-edge-match-hop[pandas] #1782 pass pass pass pass pass

A monotone staircase: each PR flips exactly its own probes, nothing regresses, and #1785
holds all five. Growth over an 8× ladder:

Note the config-level exit code is cumulative — it is the stack gate, so on its own it
reads as "master/#1782/#1783 fail". The per-probe verdicts above are what [1] actually asks
for.

One probe does not discriminate — flagging rather than burying

indexed-typed-edge-match-hop[pandas] reports pass on master. It is not blind — it
measures 5.294× growth there. It passes because the verdict ANDs the ratio gate with a
5.0 ms min_absolute_delta_ms floor, and that variant's absolute delta at this ladder size is
only 2.11 ms. So the floor, which exists to stop flaky sub-noise failures, is suppressing a
real 5.3× growth.

The [polars] variant is what actually pins #1782; the pandas one currently rides along.
It should be fixed by raising base_edges for that probe until the delta clears the floor, or
by giving it a smaller per-probe floor — not by deleting it. It is one config change away from
being load-bearing, and it is a live instance of exactly the failure mode the lane's own README
warns about.

Two run traps worth recording

uv is not on the non-interactive ssh PATH (exit 127 — use $HOME/.local/bin/uv), and the
pyg-bench uv env has no polars (--with polars). Both produced silent-looking failures first.

lmeyerov added a commit that referenced this pull request Jul 27, 2026
…fix two claims

Review skill found no correctness defect — every site whose `.unique()` was removed
is a genuine semi key side (audited one by one), and the "does an undeduped key side
make the hash build worse" objection was measured and REJECTED: at a 24M-row key
side, no-dedup is 150-186 ms / 0.66 GB against 470-486 ms / 1.12 GB with `.unique()`,
because dedup must hash the same rows before it can shrink anything. These are the
quality items.

1. `test_duplicate_node_keys_do_not_multiply_result_rows` was weaker than it read.
   It bounded per-key multiplicity at <=2, which is exactly the duplication the
   fixture introduces — so the bound ABSORBED the bug. Mutation-checked: dropping the
   deliberately-kept `.unique()` on the alias frame left all 8 parameterizations
   GREEN. Now compares against the pandas oracle exactly, and the same mutation fails
   7 of 8. A test that cannot fail for its stated reason is worse than no test.
2. The `start_nodes` test docstring claimed it exercises `pattern_apply.py:70`.
   Instrumenting shows `rows_binding_ops_polars` is called ZERO times from this file
   — it goes through chain.py's `_semi` / node-only fast path. Claim corrected rather
   than left to imply coverage that is not there.
3. CHANGELOG enumerated six sites and omitted the seventh (`select_by_ids`, commit
   06972b4). Added, with the reason it is safe: the cuDF and pandas branches of that
   same function already use `isin` with no dedup, so this makes the engines agree.

lint clean; 36 semi-dedup tests pass.
@lmeyerov
lmeyerov force-pushed the perf/gfql-polars-semi-no-dedup branch from 7d508ab to 29e8c41 Compare July 27, 2026 02:49
@lmeyerov
lmeyerov marked this pull request as ready for review July 27, 2026 03:58
@lmeyerov

Copy link
Copy Markdown
Contributor Author

Ready to merge — all three bars met

bar evidence
[1] benefit proven in a lane actually run pyg-bench #101 merged, then run per-probe per-build from merged main against frozen exports. Monotone staircase: each PR flips exactly its own probes, nothing regresses, #1785 holds all five.
[2] pos + neg tests either side of the opt boundary On all four layers, and every new test mutation-checked — reintroduce the bug, confirm it fails, report what is not caught.
[3] CI green All heads green, 0 fail, after the review fixes.

Beyond the bar

  • Review skill on all four layers: no correctness defects. Adversarial targets confirmed
    clean — how='left' is provably the only consumer of _lean_prefilter_right; every
    .unique() removed in perf(gfql/polars): stop deduplicating semi-join key sides #1784 is a genuine semi key side; perf(gfql/polars): make the chain combine proportional to the traversal result #1785's "known empty" is never
    wrong and the dropped gate is genuinely vacuous.
  • Regression sweep, both engines vs master, rows identical: 0 slower. polars 3 faster,
    pandas 5 faster (IS4 −71.0%, IS1 −53.4%, IS5 −41.4%, IS6 −31.6%, IS2 −27.7%). Running
    pandas mattered — perf(gfql): empty-left merge shrink + polars EORD/EID row-index dedup #1783's lean-combine is pandas-gated, so a polars-only sweep would have
    been structurally blind to it.
  • OLAP/aggregate: zero surviving regressions. Three cells crossed +10% at 20k and all
    three were refuted on re-run (polars q5 +13.7% → −11.7%, a sign flip). IC4 −40.0%
    polars / −33.3% pandas, IC6 pandas −65.8%.
  • GPU cross-platform closed on 26.02-gfql-polars: null-bearing gathered cudf columns
    across 7 dtypes, the empty device batch, and the eager-mask switch — all against the
    pandas oracle, engagement proven rather than assumed. New in-repo gated test file that
    skips cleanly without a GPU and passes 12/12 with one.

Competitive result

Second flip, and this one needs no asterisk: IS5 message-creator LOSE 1.22× → WIN 1.32×
(Neo4j 27.28 vs 20.60, non-overlapping reps, rows=1 both sides). Unlike IS2 — which is
adapter_workaround on both sides and must always be disclosed as reduced semantics — IS5 is
official_query_text with exactly-equal rows. IS1 widened to 5.14×, IS2 to 1.58×.

Four weak tests of my own that mutation-checking caught, now fixed

  1. perf(gfql): empty-left merge shrink + polars EORD/EID row-index dedup #1783right[0:0] label-slices on a float index and returns 1 row, so the
    112→17 ms win silently never fired. The result stayed correct, which is exactly why it was
    invisible. Now .iloc[0:0].
  2. perf(gfql/polars): stop deduplicating semi-join key sides #1784 — the worst <= 2 bound absorbed precisely the duplication it claimed to catch:
    dropping the deliberately-kept .unique() left all 8 params green. Now exact pandas-oracle
    equality; the same mutation fails 7 of 8.
  3. perf(gfql/polars): make the chain combine proportional to the traversal result #1785sort(EORD) was called "probably dead" by an engine-blind probe that only
    ran the in-memory collect. Under streaming, deleting it breaks row order. Test now
    parametrized over the collect engine.
  4. Two over-broad claims scoped (a maintain_order guarantee that only holds in-memory; a
    start_nodes test that never reaches pattern_apply), and two dead guards removed — one
    being the original spelling of the bug perf(gfql/polars): make the chain combine proportional to the traversal result #1785 fixes, sitting one function below it.

Known limits, stated rather than buried

  • indexed-typed-edge-match-hop[pandas] in the lock-in lane does not discriminate — it
    measures 5.29× growth on master but passes, because the 5 ms absolute-delta floor suppresses
    a 2.11 ms delta. The [polars] variant is what actually pins perf(gfql): evaluate indexed edge_match on candidate rows, not the whole edge frame #1782.
  • Deleting the fillna(False) before .values in the cudf branch leaves the new GPU tests
    green on cudf 26.02 — (sub == val) already yields non-null booleans there, so that
    fillna is defensive, not load-bearing on this version.
  • cudf-polars cannot execute Categorical at all; the baseline scan raises before any index
    code. Pre-existing, identical on master.

Not merging per your instruction — this is yours to land. #1785 stays draft pending its
own SF1 A/B.

@lmeyerov
lmeyerov force-pushed the perf/gfql-lean-empty-left branch from c08afb5 to 24ce01f Compare July 27, 2026 05:52
lmeyerov added a commit that referenced this pull request Jul 27, 2026
…fix two claims

Review skill found no correctness defect — every site whose `.unique()` was removed
is a genuine semi key side (audited one by one), and the "does an undeduped key side
make the hash build worse" objection was measured and REJECTED: at a 24M-row key
side, no-dedup is 150-186 ms / 0.66 GB against 470-486 ms / 1.12 GB with `.unique()`,
because dedup must hash the same rows before it can shrink anything. These are the
quality items.

1. `test_duplicate_node_keys_do_not_multiply_result_rows` was weaker than it read.
   It bounded per-key multiplicity at <=2, which is exactly the duplication the
   fixture introduces — so the bound ABSORBED the bug. Mutation-checked: dropping the
   deliberately-kept `.unique()` on the alias frame left all 8 parameterizations
   GREEN. Now compares against the pandas oracle exactly, and the same mutation fails
   7 of 8. A test that cannot fail for its stated reason is worse than no test.
2. The `start_nodes` test docstring claimed it exercises `pattern_apply.py:70`.
   Instrumenting shows `rows_binding_ops_polars` is called ZERO times from this file
   — it goes through chain.py's `_semi` / node-only fast path. Claim corrected rather
   than left to imply coverage that is not there.
3. CHANGELOG enumerated six sites and omitted the seventh (`select_by_ids`, commit
   06972b4). Added, with the reason it is safe: the cuDF and pandas branches of that
   same function already use `isin` with no dedup, so this makes the engines agree.

lint clean; 36 semi-dedup tests pass.
@lmeyerov
lmeyerov force-pushed the perf/gfql-polars-semi-no-dedup branch from 29e8c41 to 57f739e Compare July 27, 2026 05:52
@lmeyerov

Copy link
Copy Markdown
Contributor Author

x-platform: clean

Full graphistry/tests/compute/gfql/ on the GPU box —
graphistry/test-rapids-official:26.02-gfql-polars, --gpus all, so the cuDF and
polars-GPU lanes actually execute rather than skip:

build passed skipped xfailed failed
master 4061e7ce 5414 25 15 0
#1783 5414 25 15 0
#1784 5450 (+36 = its own new tests) 25 15 0

Skip counts identical across all three, so nothing was silently skipped into looking green.

Two scoping notes so the numbers aren't over-read:

@lmeyerov
lmeyerov changed the base branch from perf/gfql-lean-empty-left to master July 27, 2026 06:37
lmeyerov added 4 commits July 26, 2026 23:37
A semi-join emits a left row iff at least one matching right row exists, so
duplicate keys on the right cannot change the result -- and unlike an inner
join it cannot multiply rows either. Every `.unique()` applied to a frame that
is used *only* as a `how="semi"` key side is therefore a full hash pass bought
for no observable effect.

On an unfiltered hop the key side IS the node table, so this put O(N) work
inside a query whose answer is O(degree): the seeded single-hop plan built two
such frames per hop, and each cost ~53 ms at 3.18M nodes -- more than the rest
of the query put together.

Removed at the sites where the frame is provably a semi key side only:
  - chain.py `_semi` (the helper hardcodes how="semi")
  - chain.py alias hop-window gate, and the next-edge endpoint gate
  - chain.py endpoint gate in the two-hop fast path
  - pattern_apply.py start-nodes gate
  - hop_eager.py `_idframe_lf` -- all four consumers are semi key sides

Deliberately NOT removed: `named` in the alias pass keeps its `.unique()`
because it feeds a how="left" join, where duplicates WOULD multiply rows. The
eager multi-hop loop's `_idframe` is untouched -- its frames also flow into
concat/anti-join bookkeeping, which is a separate argument.

Measured, 3.18M nodes / 14M edges, seeded typed hop, engine=polars:
  127.44 -> 57.12 ms (2.23x), row counts identical.
At E=1.75M: 102.83 -> 31.35 ms (3.28x) -- the removed cost scales with N, not E.

Parity: 380 differential cases, 0 divergences, over duplicate node keys, null
ids, dangling edges, duplicate start_nodes, and 11 traversal shapes. gfql +
chain test suites: identical failure sets before and after.
…s semi key side

Same argument as the chain-side change: a semi-join emits a left row iff at
least one match exists, so repeated ids can neither change the result nor
multiply rows. The cudf/pandas branches of this same function already use
`isin` with no dedup, so this also makes the three engines agree.
…lumn

I committed this test file without running pytest on it — the container parity
probes covered the same ground, so the gap went unnoticed until the rebase. Three
cases failed: start_nodes stands in for the node frame, so a key-only frame made the
seed's own `id` predicate raise before reaching the semi-join under test.

Mutation-checked now: dropping the `.unique()` that must STAY (the alias frame
feeding a how='left' join) fails 7 of these tests, which is the row-multiplication
regression they exist to catch. Restoring the removed `.unique()` on the start_nodes
key side is correctly inert — it is a no-op by construction.
…fix two claims

Review skill found no correctness defect — every site whose `.unique()` was removed
is a genuine semi key side (audited one by one), and the "does an undeduped key side
make the hash build worse" objection was measured and REJECTED: at a 24M-row key
side, no-dedup is 150-186 ms / 0.66 GB against 470-486 ms / 1.12 GB with `.unique()`,
because dedup must hash the same rows before it can shrink anything. These are the
quality items.

1. `test_duplicate_node_keys_do_not_multiply_result_rows` was weaker than it read.
   It bounded per-key multiplicity at <=2, which is exactly the duplication the
   fixture introduces — so the bound ABSORBED the bug. Mutation-checked: dropping the
   deliberately-kept `.unique()` on the alias frame left all 8 parameterizations
   GREEN. Now compares against the pandas oracle exactly, and the same mutation fails
   7 of 8. A test that cannot fail for its stated reason is worse than no test.
2. The `start_nodes` test docstring claimed it exercises `pattern_apply.py:70`.
   Instrumenting shows `rows_binding_ops_polars` is called ZERO times from this file
   — it goes through chain.py's `_semi` / node-only fast path. Claim corrected rather
   than left to imply coverage that is not there.
3. CHANGELOG enumerated six sites and omitted the seventh (`select_by_ids`, commit
   06972b4). Added, with the reason it is safe: the cuDF and pandas branches of that
   same function already use `isin` with no dedup, so this makes the engines agree.

lint clean; 36 semi-dedup tests pass.
@lmeyerov
lmeyerov force-pushed the perf/gfql-polars-semi-no-dedup branch from 57f739e to b883011 Compare July 27, 2026 06:37
@lmeyerov lmeyerov closed this Jul 27, 2026
@lmeyerov lmeyerov reopened this Jul 27, 2026
@lmeyerov
lmeyerov merged commit 8483598 into master Jul 27, 2026
98 of 138 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.

1 participant