Repository navigation
bench: exact Top-K aggregate pruning research, second spike and Go recommendation (#386) - #387
iceboundrock wants to merge 7 commits into
Conversation
Research for #386: is Pivot-style slot-bound pruning of a grouped COUNT ... ORDER BY count DESC LIMIT K worth building? - `topk_research` cargo feature (no shipped build carries it): `_topk_analyze` returns a plan's per-operator DataFusion metrics, and `_topk_set_prune` plans `SlotPrunedCountExec`, an exact slot-bound pruner in place of the final aggregate of the eligible shape, with a control mode that keeps its waves but never prunes. - benchmarks/topk_agg/: synthetic fixtures, a tie-aware DuckDB check, the Phase 1 baseline matrix with stage CPU shares, a pruning simulation and its ceiling, the baseline/spike/control comparison, randomized invariant checks, results and REPORT.md. - py-ltseq/tests/test_topk_research.py holds the spike to the unpruned engine across layouts, ties, NULL/float/string keys and COUNT(col). - CI runs clippy on the feature. REPORT.md recommends No-Go: the spike was 25.7% faster on ClickBench Q16 and 17.8% on Q34 at K=10, but 6-13% slower on comparable queries it cannot prune, with up to 3.8x the peak memory, and has no memory-pool integration, spilling or fallback.
…probe (#386) The first spike recommended No-Go: 6-13% slower and up to 3.8x the memory on queries it could not prune, 38% slower at 32 partitions. The owner's interim assessment traced that to the operator's mechanics and proposed a threshold before any merge, an early bail-out and slot counts sized from the data. The second spike implements them: - T0, the K-th largest per-slot maximum partial count, bounds the K-th count before any merge; one merge of the K slots with the largest such maximum gives T1; the kept rows are gathered once, so at most three merges replace up to fourteen doubling waves. - A partition bails out to one merge of every row when the kept rows exceed half its input, and its probe bails out after 131,072 rows, streaming the rest, when 90% of them cannot be pruned. Bailing out only after buffering still cost 4-10% (results/late_bail/). - One slot per N buffered partial rows (rows_per_slot) instead of a fixed count. The rerun (44 cells, all checked against DuckDB) gains 23.7% on Q16 and 18.2% on Q34 at 8 partitions and 18-32% at 16 and 32 against today's plan at the same count; no cell where nothing prunes regresses by more than 3.9% outside the 40 ms 1K-key cells. REPORT.md now recommends Go for an off-by-default implementation of this shape, and lists what production still needs: memory-pool accounting and spilling, a slot count and probe robust to arrival order, plan-shape tests. selfcheck.py checks the new thresholds on 20,000 random partial-row inputs; the differential tests cover data-sized slots, bail-outs and the probe. The first spike's comparison moves to results/v1/.
AI Code Review — Round 1Reviewed HEAD: FindingsR1-F1 [blocked]
|
…F1, #386) The rule followed the count column through projections between the Top-K sort and the final aggregate but checked only that one expression. Any other computed column of such a projection is evaluated on every group the aggregate emits, so pruning a group hid its errors: a derive of 1 // (k - 20000) raised division by zero on today's plan and returned a top-1 with the spike on. The pruner is now planned only when every expression of every intervening projection is a column or a literal. A test reproduces the review's case at fixed and data-sized slot counts, and REPORT.md's eligibility argument says why the projection case differs from fallible expressions below the aggregate.
…386) A partition that bailed out early charged its streaming merge with the elapsed time from the probe on, minus the time spent inside its input's polls. The intervals it spent suspended after Poll::Pending stayed in, so its elapsed_compute was mostly waiting (uniform_1000000 at 8 partitions showed a 101.1% final-stage share), and the probe's slotting was left out on every path. The operator now times each poll of its whole run and subtracts the time spent inside its input's polls, which it records as input_time. Merges run one-partition plans inside that future, so this is all of its work, and suspended intervals fall between polls. A Rust test feeds it an input that returns Pending between batches and fails under the old measurement. The comparison cells were not rerun: compare.py leaves the final-stage share blank for spike results whose partitions bailed out early without input_time, the tables are regenerated from the unchanged raw results, and REPORT.md drops the claims that relied on those shares. End-to-end latencies are measured separately and are unaffected.
…386) The eligibility correction placed the projection case between 'COUNT's finalization cannot fail' and the sentence that draws the SUM/AVG/MIN/MAX exclusion from it. The projection case is now its own paragraph, and it says the Phase 1 projected cells ran today's plan, so no measured spike cell was affected.
AI Code Review — Round 2Reviewed HEAD: FindingsNone. SpecThe research artifacts provide the benchmark matrix, correctness argument, architecture assessment, comparison and scoped recommendation. The final findings were posted back to the issue with the requested cross-references. The report discloses that the related issue threads were unavailable during the research run. StandardsThe feature-gated research implementation and report are consistent with the applicable repository guidance. The review was read-only and publication is left to the controller. AssessmentThe revised projection eligibility avoids the previously reproduced error suppression, and early-bail stage shares from the affected measurements are explicitly withheld. The recommendation is appropriately limited by the reported single-machine evidence and the unmeasured production memory and spill work. ObservationsR1-F1 is resolved by rejecting computed intervening projections and adding the error-preservation regression. R1-F2 is resolved by poll-based timing, a delayed-input regression, and withdrawal of affected historical shares. R1-F3 is resolved by the final findings comment on issue #386. The PR snapshot matches the bound HEAD and base. The feature-specific Python tests are not run by the default-build Python CI step. VerificationInspected the bound diff, issue and PR context, prior review, report and verification artifacts. All three head checks were pending in the controller snapshot; no controller validation commands were configured. Feature-enabled offline clippy passed. The delayed-input Rust regression passed. The research Python suite passed with 208 passed and 1 skipped, and both 20,000-case randomized self-checks passed. Diff whitespace check passed; the worktree remains clean. The full performance matrix and full test suite were not rerun. SummaryNo findings require another fix round. The earlier three findings are resolved, and focused verification passed. Needs another fix round: NO |
|
Explainer: what "Go for exactly the shape the spike covers, off by default" means The report's recommendation is easy to read as wider than it is. This spells out the eligible query shape as the rule at The shapeSELECT key, COUNT(*) AS cnt -- or COUNT(col)
FROM t
[WHERE ...] -- filtering before the aggregate is fine
GROUP BY key -- one or more grouping columns
ORDER BY cnt DESC
LIMIT KIn LTSeq: In the physical plan, every one of these must hold:
Key types are unrestricted; the tests cover integer, string, float and NULL keys. Anything else keeps today's plan, and Why each limit
"Off by default"Today the spike exists only in a measurement build ( What that implementation would still owe is listed in the report and in the findings comment on #386: registering the buffer with the memory pool and spilling; slot sizing and the bail-out probe not depending on row arrival order; and plan-shape tests that pin the rule to DataFusion's aggregate and repartition operators across upgrades. If those cost more than the measured margin, or another machine doesn't reproduce the gains, the recommendation goes back to No-Go. This PR stays open for now and is not to be merged yet. |
Both sides added a feature-gated spike (topk_research here, stream_spike on main); keep both. The topk_research clippy step joins the other Rust steps in running only on the Python 3.13 matrix entry.
Research for #386 (planning: #281): is Pivot-style exact slot-bound pruning worth building for
group by key, count, sort desc, first K? This PR holds the measurement build, the benchmarks, two spikes andbenchmarks/topk_agg/REPORT.md. Nothing here changes a shipped build: the hook and the spike compile only with thetopk_researchcargo feature.Why there are two spikes
The first spike (
96a3b17, results inresults/v1/) pruned well on ClickBench but was 6-13% slower, with up to 3.8x the peak memory, on queries it could not prune, and 38% slower at 32 partitions on 8 cores. It recommended No-Go. The interim assessment on the issue traced those regressions to the operator's mechanics, not to the algorithm, and proposed three changes. The second spike implements them:T0is the K-th largest per-slot maximum partial count. One merge of the K slots with the largest such maximum then gives a tighterT1. The kept rows are gathered once, so at most three merges replace up to fourteen doubling waves.A first rerun showed that bailing out only after buffering still cost 4-10% on unprunable data, because the final merge can no longer overlap the partial aggregation (
results/late_bail/). So the spike also gained a probe: after 131,072 rows, if 90% of them lie in slots that cannot be pruned, the partition streams the rest of its input into one merge. Bailing out is always exact, so the probe only affects speed.Results (one machine, i7-8700K, 8 CPUs; 44 cells, every result checked against DuckDB)
Decision needed
REPORT.md now recommends Go for an off-by-default implementation of exactly this shape (a single COUNT, descending, LIMIT K, over a hash repartition). The decision is the owner's. Before production, the report says it still needs:
Hashing each key a second time is most of what remains of the final stage's cost on URL, about 3% of CPU. The report advises against replacing the repartition operator to avoid it. No follow-up issue is opened.
Review focus
T0/T1and the keep rule (REPORT.md, Phase 2;src/topk_research/prune.rs).How it was tested
cargo clippy --all-targets --features topk_research -- -D warningsandcargo clippy --all-targets -- -D warningspass.py-ltseq/tests/test_topk_research.pyon a release measurement build: 206 passed. They hold the spike to the unpruned engine across engine and input layouts, ties, NULL, float and string keys, COUNT(col), and fixed and data-sized slots, and check the bail-out and the probe.top_ktest files pass with the spike forced on for every eligible plan, at 1 and at 128 rows per slot.benchmarks/topk_agg/selfcheck.pypasses: 20,000 random inputs for the wave rule and 20,000 for the new thresholds.benchmarks/topk_agg/compare.py: 44 cells, all 138 results correct against DuckDB.Closes #386