alamb commented on code in PR #26069:
URL: https://github.com/apache/datafusion/pull/26069#discussion_r4194420295


##########
datafusion/sqllogictest/test_files/aggregate.slt:
##########
@@ -7639,9 +7639,8 @@ logical_plan
 03)----TableScan: distinct_limit_combine projection=[a, b]
 physical_plan
 01)GlobalLimitExec: skip=0, fetch=2
-02)--AggregateExec: mode=Final, gby=[b@0 as b, a@1 as a], aggr=[], lim=[2]
-03)----AggregateExec: mode=Partial, gby=[b@1 as b, a@0 as a], aggr=[]
-04)------DataSourceExec: partitions=1, partition_sizes=[1]
+02)--AggregateExec: mode=Single, gby=[b@1 as b, a@0 as a], aggr=[], lim=[2]

Review Comment:
   this does seem a better plan (use only the final single partition execution 
plan)



##########
datafusion/sqllogictest/test_files/aggregate.slt:
##########
@@ -7553,7 +7553,7 @@ physical_plan
 02)--GlobalLimitExec: skip=0, fetch=3
 03)----AggregateExec: mode=Final, gby=[c2@0 as c2, c3@1 as c3, __grouping_id@2 
as __grouping_id], aggr=[], lim=[3]
 04)------CoalescePartitionsExec
-05)--------AggregateExec: mode=Partial, gby=[(NULL as c2, NULL as c3), (c2@0 
as c2, NULL as c3), (c2@0 as c2, c3@1 as c3)], aggr=[]
+05)--------AggregateExec: mode=Partial, gby=[(NULL as c2, NULL as c3), (c2@0 
as c2, NULL as c3), (c2@0 as c2, c3@1 as c3)], aggr=[], lim=[3]

Review Comment:
   This looks correct to me -- to push the limit down into each partial 
aggregate / partition



##########
datafusion/physical-optimizer/src/limited_distinct_aggregation.rs:
##########
@@ -44,95 +86,70 @@ impl LimitedDistinctAggregation {
         Self {}
     }
 
-    fn transform_agg(
-        aggr: &AggregateExec,
-        limit: usize,
-    ) -> Option<Transformed<Arc<dyn ExecutionPlan>>> {
-        let new_aggr = aggr.clone().try_optimize_distinct_soft_limit(limit)?;
-        // An already limited aggregate still permits optimizing its partial 
child.
-        Some(new_aggr.update_data(|aggr| Arc::new(aggr) as Arc<dyn 
ExecutionPlan>))
-    }
+    /// Rewrite a limit and its immediately adjacent final/partial aggregate 
pair.
+    fn transform_limit(
+        plan: Arc<dyn ExecutionPlan>,
+    ) -> Result<Transformed<Arc<dyn ExecutionPlan>>> {
+        // Step 1: Identify the plan shape,
+        //
+        // Limit
+        //   Aggregate(final)
+        //     Aggregate(partial)
+
+        // Check the current plan is limit, and extract limit value, input 
plan.
+        let (limit, input) = match (
+            plan.downcast_ref::<LocalLimitExec>(),
+            plan.downcast_ref::<GlobalLimitExec>(),
+        ) {
+            (Some(local), _) => (local.fetch(), local.input()),
+            (_, Some(global)) => match global.fetch() {
+                Some(fetch) => (global.skip() + fetch, global.input()),
+                None => return Ok(Transformed::no(plan)),
+            },
+            _ => return Ok(Transformed::no(plan)),
+        };
 
-    /// transform_limit matches an `AggregateExec` as the child of a 
`LocalLimitExec`
-    /// or `GlobalLimitExec` and pushes the limit into the aggregation as a 
soft limit when
-    /// there is a group by, but no sorting, no aggregate expressions, and no 
filters in the
-    /// aggregation
-    fn transform_limit(plan: Arc<dyn ExecutionPlan>) -> Option<Arc<dyn 
ExecutionPlan>> {
-        let limit: usize;
-        let mut global_fetch: Option<usize> = None;
-        let mut global_skip: usize = 0;
-        let children: Vec<Arc<dyn ExecutionPlan>>;
-        let mut is_global_limit = false;
-        if let Some(local_limit) = plan.downcast_ref::<LocalLimitExec>() {
-            limit = local_limit.fetch();
-            children = local_limit.children().into_iter().cloned().collect();
-        } else {
-            let global_limit = plan.downcast_ref::<GlobalLimitExec>()?;
-            global_fetch = global_limit.fetch();
-            global_fetch?;
-            global_skip = global_limit.skip();
-            // the aggregate must read at least fetch+skip number of rows
-            limit = global_fetch.unwrap() + global_skip;
-            children = global_limit.children().into_iter().cloned().collect();
-            is_global_limit = true
-        }
-        let child = children.iter().exactly_one().ok()?;
-        // ensure there is no output ordering; can this rule be relaxed?
-        if plan.output_ordering().is_some() {
-            return None;
-        }
-        // ensure no ordering is required on the input
-        if plan.required_input_ordering()[0].is_some() {
-            return None;
+        if plan.output_ordering().is_some() || 
plan.required_input_ordering()[0].is_some()
+        {
+            return Ok(Transformed::no(plan));
         }
 
-        // if found_match_aggr is true, match_aggr holds a parent aggregation 
whose group_by
-        // must match that of a child aggregation in order to rewrite the 
child aggregation
-        let mut match_aggr: Arc<dyn ExecutionPlan> = plan;
-        let mut found_match_aggr = false;
-
-        let mut rewrite_applicable = true;
-        let closure = |plan: Arc<dyn ExecutionPlan>| {
-            if !rewrite_applicable {
-                return Ok(Transformed::no(plan));
-            }
-            if let Some(aggr) = plan.downcast_ref::<AggregateExec>() {
-                if found_match_aggr
-                    && let Some(parent_aggr) = 
match_aggr.downcast_ref::<AggregateExec>()
-                    && !parent_aggr.group_expr().eq(aggr.group_expr())
-                {
-                    // a partial and final aggregation with different 
groupings disqualifies
-                    // rewriting the child aggregation
-                    rewrite_applicable = false;
-                    return Ok(Transformed::no(plan));
-                }
-                // either we run into an Aggregate and transform it, or 
disable the rewrite
-                // for subsequent children
-                match Self::transform_agg(aggr, limit) {
-                    None => {}
-                    Some(new_aggr) => {
-                        match_aggr = plan;
-                        found_match_aggr = true;
-                        return Ok(new_aggr);
-                    }
-                }
-            }
-            rewrite_applicable = false;
-            Ok(Transformed::no(plan))
+        let Some(final_agg) = input.downcast_ref::<AggregateExec>() else {
+            return Ok(Transformed::no(plan));
         };
-        let child = child.to_owned().transform_down(closure).ok()?;
-        if !child.transformed {
-            return None;
+        let Some(partial_agg) = 
final_agg.input().downcast_ref::<AggregateExec>() else {
+            return Ok(Transformed::no(plan));
+        };
+
+        // Step 2: Verify partial/final aggregate is compatible, then apply 
optimization
+        match (final_agg.mode(), partial_agg.mode()) {
+            (
+                AggregateMode::Final | AggregateMode::FinalPartitioned,
+                AggregateMode::Partial,
+            ) if final_agg.group_expr() == 
&partial_agg.group_expr().as_final() => {}

Review Comment:
   Maybe this check could be a method on AggregateExec -- mostly so that it 
could be given a name and better documented
   
   Something like:
   ```rust
   if final_agg.matches_partial(partial_agg) {
   ...
   }
   ```
   
   (not sure if that is a good name for it)



##########
datafusion/physical-optimizer/src/limited_distinct_aggregation.rs:
##########
@@ -15,26 +15,68 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! A special-case optimizer rule that pushes limit into a grouped aggregation
-//! which has no aggregate expressions or sorting requirements
+//! Stop unordered DISTINCT aggregation once enough groups have been found.
 
 use std::sync::Arc;
 
-use datafusion_physical_plan::aggregates::AggregateExec;
+use datafusion_physical_plan::aggregates::{AggregateExec, AggregateMode};
 use datafusion_physical_plan::limit::{GlobalLimitExec, LocalLimitExec};
-use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties};
+use datafusion_physical_plan::{
+    ChildrenPropertiesMode, ExecutionPlan, ExecutionPlanProperties,
+    ReplaceChildrenOptions,
+};
 
 use datafusion_common::Result;
 use datafusion_common::config::ConfigOptions;
 use datafusion_common::tree_node::{Transformed, TransformedResult, TreeNode};
 
 use crate::PhysicalOptimizerRule;
-use itertools::Itertools;
 
-/// An optimizer rule that passes a `limit` hint into grouped aggregations 
which don't require all
-/// rows in the group to be processed for correctness. Example queries fitting 
this description are:
-/// - `SELECT distinct l_orderkey FROM lineitem LIMIT 10;`
-/// - `SELECT l_orderkey FROM lineitem GROUP BY l_orderkey LIMIT 10;`
+/// Pushes a soft limit into unordered DISTINCT aggregations. For
+/// `SELECT DISTINCT a FROM t LIMIT 10`, any ten distinct values are 
sufficient:
+/// later rows cannot change the groups already found. The same reasoning 
applies
+/// to `GROUP BY a` without aggregate expressions. Aggregates such as `SUM` 
need
+/// every row in each group.
+///
+/// Each partial stage can also stop after finding ten distinct keys: those 
keys
+/// remain distinct in the final stage, even if other partitions contribute
+/// duplicates.
+///
+/// Before:
+///
+/// ```txt
+/// Limit(10)
+///   Aggregate(Final, a)
+///     Aggregate(Partial, a)
+///       Scan
+/// ```
+///
+/// After:
+///
+/// ```txt
+/// Limit(10)
+///   Aggregate(Final, a, soft_limit=10)
+///     Aggregate(Partial, a, soft_limit=10)
+///       Scan
+/// ```
+///
+/// # Invariants before and after the rewrite

Review Comment:
   I am not sure I would use the term "invariant" here though it is technically 
accurate
   
   This comment mostly explains the effect of this optimizer rule (what it 
does). 
   
   I normally think of Invariant as a property that will not change as any 
operation is applied to it. I don't think there is any reason to require that 
some future optimizer rule preserves the same property 
   
   For example, what  if a future rule (or a user defined rule) has some 
additional special operator that requires the full intermediate results (aka 
undoes the soft limit on the Partial AggregateExec)? I don't see any reason to 
try and prevent that 🤔 



##########
datafusion/physical-optimizer/src/limited_distinct_aggregation.rs:
##########
@@ -44,95 +86,70 @@ impl LimitedDistinctAggregation {
         Self {}
     }
 
-    fn transform_agg(
-        aggr: &AggregateExec,
-        limit: usize,
-    ) -> Option<Transformed<Arc<dyn ExecutionPlan>>> {
-        let new_aggr = aggr.clone().try_optimize_distinct_soft_limit(limit)?;
-        // An already limited aggregate still permits optimizing its partial 
child.
-        Some(new_aggr.update_data(|aggr| Arc::new(aggr) as Arc<dyn 
ExecutionPlan>))
-    }
+    /// Rewrite a limit and its immediately adjacent final/partial aggregate 
pair.
+    fn transform_limit(
+        plan: Arc<dyn ExecutionPlan>,
+    ) -> Result<Transformed<Arc<dyn ExecutionPlan>>> {
+        // Step 1: Identify the plan shape,

Review Comment:
   thank you -- these comments really make the code much easier to follow



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to