Repository navigation
Conversation
Pin today's Partial + hash RepartitionExec + Final plan for GROUP BY key, date_bin(timestamp) on a table that is already Range([timestamp]) and sorted on (key, timestamp), so a follow-up can remove the shuffle. Co-authored-by: Cursor <[email protected]>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #24501 +/- ##
==========================================
+ Coverage 81.32% 81.63% +0.30%
==========================================
Files 1117 1123 +6
Lines 396269 412385 +16116
Branches 396269 412385 +16116
==========================================
+ Hits 322260 336638 +14378
- Misses 55186 55954 +768
- Partials 18823 19793 +970 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Use generic column and table names so the coverage does not expose a metrics-specific schema. Co-authored-by: Cursor <[email protected]>
Accept RecordBatches and optional file sort order so the time-bin table reuses the same registration path. Co-authored-by: Cursor <[email protected]>
Treat Range([x]) as a subset of KeyPartitioned([..., f(x), ...]) when f is monotonic and bins do not straddle split points, so GROUP BY key, date_bin/date_trunc can stream without a hash RepartitionExec. Co-authored-by: Cursor <[email protected]>
05705f6 to
b26846d
Compare
Drop the local order-preserving walk and scalar predecessor so date_bin/date_trunc range projection uses EquivalenceProperties and interval_arithmetic instead of duplicating them. Co-authored-by: Cursor <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Cursor <[email protected]>
…che#24500 Co-authored-by: Cursor <[email protected]>
Co-authored-by: Cursor <[email protected]>
|
Tag @gene-bordegaray and @jayshrivastava for review |
jayshrivastava
left a comment
There was a problem hiding this comment.
Looks good. Far better than my approach in #23536.
I left some comments re. completeness.
gene-bordegaray
left a comment
There was a problem hiding this comment.
all my comments largely ditto @jayshrivastava I can take a review when these are addressed 👍
…atisfaction Require matching SortOptions so -x is not treated as monotonic, try every monotonic projection candidate, and check date_bin disjointness even when subset satisfaction is disabled. Co-authored-by: Cursor <[email protected]>
NGA-TRAN
left a comment
There was a problem hiding this comment.
@jayshrivastava & @gene-bordegaray
I have addressed all Jayant's comments
Range([x]) grouping by a split-aligned date_bin/date_trunc of x is Exact when that transform is the whole key, and Subset only when extra keys remain and subset satisfaction is allowed. Co-authored-by: Cursor <[email protected]>
Add unit coverage for empty keys, multi-split disjointness, and evaluate/project error paths, plus SLTs for GROUP BY date_bin/date_trunc without extra grouping columns. Co-authored-by: Cursor <[email protected]>
|
Thanks @NGA-TRAN! I have two suggestions, the 2nd one is non-blocking
Partition membership for a multi-key Setup
Project Where the claim breaks
The second row is above the split in reality. After the transform it ties with the split on the first key, the comparison falls through to Both rows are the same group under Why the last key is safe and an earlier key is notThe boundary survives only if this equivalence holds: The check Transform on the last key. Earlier keys were projected identity, so ties on them are exact and the comparison always terminates at this key. It reduces to comparing That is the full equivalence. Sound. Transform on an earlier key. The comparison can continue past it, s be strict: That is injectivity around the split, which flooring transforms like `dnever have. Nothing in the check tests this direction. Fix
) -> Option<Self> {
let mut split_points = self.split_points.clone();
let mut sort_exprs = Vec::with_capacity(self.ordering.len());
+ let last_key_idx = self.ordering.len().checked_sub(1)?;
for (key_idx, sort_expr) in self.ordering.iter().enumerate() {
if let Some(projected) =
input_eq_properties.project_expr(&sort_expr.expr, mapp
{
sort_exprs.push(PhysicalSortExpr::new(projected, sort_
continue;
}
+ // A non-injective transform preserves the partition bound
+ // only for the final range key. For an earlier key, rows above
+ // the split can collapse onto `f(split)` and are then ord
+ // by the trailing keys, crossing the projected boundary.
+ if key_idx != last_key_idx {
+ return None;
+ }
+
let (target, source) =
monotonic_range_key_projection(sort_expr, mapping, input_eq_properties)If you would rather keep non-final keys working, the precise condition codebase as Either way, a regression test mirroring |
|
Satisfaction is granted in the transformed key domain, but the advertised split points stay in the source domain Not a live bug as far as I can tell, but it is an invariant that other code already depends on, so it is worth pinning down before this lands. The mismatch
After For the disjointness question this does not matter, and the aggregation case in this PR is sound. It matters for consumers that read split-point values and compare them across two partitionings: // datafusion/physical-plan/src/distribution_requirements.rs:348
(Partitioning::Range(left), Partitioning::Range(right)) => {
left.split_points() == right.split_points() && ...
Why it does not bite todayI probed the transforms that actually clear
Two things save it:
The cast row is the one that already breaks the invariant: What I would doCheapest option, document the assumption where it is relied upon: // Callers may compare `RangePartitioning::split_points()` across two
// partitionings (see `compatible_co_partitioning_layout`). Those values
// stay in the range-key domain, while satisfaction here is granted in the
// transformed domain. That is only consistent because every transform
// reaching this point is a flooring function, i.e. disjointness at `split`
// implies `f(split) == split`. A monotone transform without that fixed
// point would make the advertised boundary wrong for those callers.Conservative option, enforce it, at the cost of dropping the narrowing-cast case: let Some(at_split) = evaluate_expr_on_key(fn_expr, range_key, split_value) else {
return false;
};
+ // The split must be a fixed point of `f`. Otherwise the boundary in
+ // the transformed domain is `f(split)`, not `split`, and callers that
+ // compare raw split points are reasoning about the wrong value.
+ if at_split != *split_value {
+ return false;
+ }
let Some(below_split) = evaluate_expr_on_key(fn_expr, range_key, &predecessor)
else {
return false;
};
at_split != below_splitEvery test in the PR passes with that guard, since the 60s Either way, a test asserting the current |
|
Thanks so much, @jayzhan211, for taking the time to verify the feature and suggest improvements to avoid regressions — really appreciate the effort ❤️. I’d also love to hear from @gene-bordegaray, given his pioneering work on range partitioning, superset/subset group‑by keys, and multi‑key partitioning. With input from @jayshrivastava, @jayzhan211 and @gene-bordegaray, I’m hopeful we can avoid any regressions. Thanks in advance, everyone. |
read through this and yes, can confirm that this is possible and correctness bug. We could get duplicate group rows or partial sums as final results. I also agree with @jayzhan211 suggested approach for a solution thank you @jayzhan211 🙇 |
gene-bordegaray
left a comment
There was a problem hiding this comment.
agreed with @jayzhan211 points and also found some edge cases that are worth looking at 👍
Restrict monotonic Range projection to the last key, compare DESC splits against the successor, and fail closed on non-finite floats and unprojectable split points. Co-authored-by: Cursor <[email protected]>
|
Thanks @jayzhan211 and @gene-bordegaray — review comments from both of you are addressed in 4b73736. Jay (@jayzhan211)
Gene (@gene-bordegaray)
|
| expr: &Arc<dyn PhysicalExpr>, | ||
| range_key: &Arc<dyn PhysicalExpr>, | ||
| value: &ScalarValue, | ||
| ) -> Option<ScalarValue> { |
There was a problem hiding this comment.
We might need type check at the beginning of the evaluation
// The substituted literal must carry exactly the range key's type: a
// different time unit or timezone silently changes what date_bin /
// date_trunc compute, and would make the disjointness check answer a
// question about a function the data was never partitioned by.
if range_key.data_type(schema).ok()? != value.data_type() {
return None;
}
gene-bordegaray
left a comment
There was a problem hiding this comment.
I am flushing some comments. I think these cahnges mght be a bit too large for one PR. There is a lot to keep track of and touching lots of code with small nuances
| k2 2024-01-01T01:30:00 30 | ||
| k2 2024-01-01T01:45:00 5 | ||
|
|
||
| ########## |
There was a problem hiding this comment.
thanks for tehse diagrams, these are awesome
|
|
||
| if matching == 0 { | ||
| PartitioningSatisfaction::NotSatisfied | ||
| } else if matching == required_exprs.len() { |
There was a problem hiding this comment.
this may not be safe for co-partitioning joins. is this being used for this? If so I think it should be diabled in this PR
There was a problem hiding this comment.
left: Range(x), split 10
right: Range(y), split 10
join: x + 1 = y
The left input independently satisfies KeyPartitioned(x + 1), but its join is 11. Then the right boundary is 10. compatible_co_partitioning_layout still compares the original split tuples (10 == 10), so the partitioned join is accepted. Then left.x = 9 and right.y = 10 have the same join key but occur in partitions 0 and 1 and never meet.
There was a problem hiding this comment.
I dont think using exact for transformed expressions is a good practice. This might be time to introduce a new type of satisfaction, like transformation or something
| if transformed_expr.eq(source_expr) { | ||
| return false; | ||
| } | ||
| let options = SortOptions::default(); |
There was a problem hiding this comment.
I dont belive sort options should be deafult in all cases here
|
I may create a stack of PRs that implments this gradually 👍 @NGA-TRAN |
|
@gene-bordegaray and I talked, and we may need to break this into sub‑tasks since it touches many different areas. |
|
Ok here is a more composable stack of work to introduce this feature in a way that is more digesteable and allows more of the community to have eyes and an opinion on this as there are many hidden implications in this change: 1. date_trunc correctness fix (pre-requisite)There is unchecked arithmetic in This is an independent correctness and we need to fix this unless there is a motivation for this in the fast-path I am unaware of. 2. Audit Range transformation sand projectionsI believe that preservation for functions should be default-deny. An ordinary I think we should:
3. Distinguish transformed satisfaction from Exact, then add the first aggregation consumer
Now, a transformed key is not as strong. Its weaker guarantee is that equal For example: The effective boundaries are 11 and 10. Without repartitioning, x = 9 and y = 10 are placed in different partition indexes even though x + 1 = y. Co-partitioning is quite tricky and we need to be careful when eliminating shuffles on opertors that require it, it is much stricter than just satisfying So there may be need for discussion around if this needs a new variant of satisfaction. Single-input aggregates may accept it, while co-partitioned joins should continue to require Exact layout compatibility. I also think this PR should also add the first consumer, which can be the no-repartition aggregate optimization as I have found it is usually better to have a direct consumer of an API change rather than writing speculative code. cc: @NGA-TRAN @jayzhan211 |
|
👋 hey! I spoke offline with @NGA-TRAN and I am interested in taking over this one. Does that work? @gene-bordegaray I will proceed with the path suggested above. |
|
that would be great thank you @benbellick 🙇 |
|
making an issuie to track this progress would be great too 👍 |
|
also much of this can be reused and is great work. Just smalle scoped changes and ensure we cross our t's and dot our i's |
|
Issue for the first item above here: #25129 |
|
And here is a PR for that first issue: #25131 very small fix! |
|
What is the status of this PR? Shall we resolve conflicts and get it merged? |
@alamb There is still some work to be done. This version of the PR currently opts queries in liberally. I am working on another PR which instead focuses on just adding I am working on it in a separate PR, however much of the implementation will be similar. The work for it is tracked here #25344 I have not yet opened the PR but will soon. Let me know if anything is unclear 🙂 |
Great -- thank you @benbellick -- I was going through open and approved PRs trying to work through the list that needs to be merged. Marking this one as Draft as it sounds like it is being reworked into smaller pieces Thank you 🙏 |
## Which issue does this PR close? Closes apache#26448. Related to apache#23569, apache#25344, and apache#25345. ## Rationale for this change This PR implements a focused subset of the optimization in [Nga's PR apache#24501](apache#24501) for a known-safe case. The plan is to iteratively expand support to more safe cases, following [Gene's review feedback](apache#24501 (comment)). Suppose `events` is range-partitioned on `timestamp`, with partitions split at hourly boundaries. ```sql SELECT key, date_trunc('hour', timestamp) AS hour, SUM(value) FROM events GROUP BY key, date_trunc('hour', timestamp); ``` Each `(key, hour)` group belongs entirely to one input partition. The simplified physical plans below omit projections and use `hour` for the grouping expression. **Without the optimization** ```text AggregateExec: mode=FinalPartitioned, gby=[key, hour], aggr=[sum(value)] RepartitionExec: Hash([key, hour]) AggregateExec: mode=Partial, gby=[key, hour], aggr=[sum(value)] Scan: Range(timestamp), hourly splits ``` **With the optimization** ```text AggregateExec: mode=SinglePartitioned, gby=[key, hour], aggr=[sum(value)] Scan: Range(timestamp), hourly splits ``` The new locality check is specific to aggregates. General distribution satisfaction and join-layout checks are unchanged. ## What changes are included in this PR? Add a default-deny capability that identifies functions and argument types safe for range-key analysis. Check expression ordering and arguments, then evaluate both sides of each split to prove groups stay within partitions. The optimizer calls it through this path. ```mermaid flowchart TD A["EnsureRequirements checks aggregate input"] --> B["AggregateExec::input_keeps_groups_local"] B --> C["Partitioning::keeps_keys_local"] C --> D{"Function admitted, expression supported, and split boundaries safe?"} D -->|Yes| E["Can skip the aggregate hash shuffle"] D -->|No or unknown| F["Retain normal repartitioning"] ``` **Support is intentionally limited to timezone-less `date_trunc` over single-key timestamp ranges, with literal non-range arguments.** `date_bin` remains excluded pending its safety audit, including null behavior. Compound range keys remain follow-up work in apache#24644. Keep the shuffle when buckets cross partitions or the checks fail. Grouping sets still use the existing distribution checks. ## What is the testing strategy for this PR? Added multiple unit and integration tests. I'm happy to add a benchmark if maintainers would find it useful. ## Are there any user-facing changes? Eligible `date_trunc` aggregations can skip a hash shuffle. Results are unchanged. --- Note: This PR was developed with AI assistance. All changes have been reviewed, and I take full responsibility for this contribution. --------- Co-authored-by: Jay Zhan <[email protected]>
|
@NGA-TRAN Following the safety-first stack discussed above, these PRs have now merged:
These cover the single-key aggregate shuffle optimization using a separate locality proof, without changing general range projection or join satisfaction. Compound-range support remains tracked in #24644. I think we can close this PR in favor of the merged approach and track any broader transformed-range projection work separately. Does that sound reasonable? |
|
@benbellick : Thanks for all the work. Yes, please close this PR |
Which issue does this PR close?
Rationale for this change
A table that is range-partitioned on
timestamp(for example by hour) and grouped bydate_bin/date_truncof that timestamp is already partition-disjoint when bins do not straddle split points. DataFusion still planned Partial → hashRepartitionExec→ Final.After this change,
Range([timestamp])subset-satisfiesKeyPartitioned([key, f(timestamp)])whenfis monotonic (date_bin,date_trunc) and evaluatingfat each split vs. its predecessor shows the bins are disjoint. Aggregation then runs as one streamingSinglePartitionedstep withordering_mode=Sorted.Bins that do straddle the split (for example
date_trunc('day')on hour-partitioned data) still hash-repartition.#24500's expected plan changes from:
to:
What changes are included in this PR?
Rangethrough monotonic grouping expressions when bins stay disjoint.SanityCheckPlanacceptsSinglePartitionedoverRange([timestamp]).range_sorted_time_bin_agg.sltfordate_bin(60s)anddate_trunc('hour')(no shuffle) anddate_trunc('day')(shuffle remains).Are these changes tested?
partitioning.rsfor aligneddate_bin/date_trunc('hour')vs unaligned split anddate_trunc('day').cargo test --test sqllogictests -- range_sorted_time_bin_agg.sltAre there any user-facing changes?
Queries that group by
date_bin/date_truncof a range-partitioned timestamp may skip a hash shuffle when bins do not cross file-group boundaries. Results are unchanged.Test plan
cargo test -p datafusion-physical-expr --lib -- partitioningcargo test --test sqllogictests -- range_sorted_time_bin_agg.sltCo-author with Cursor