This is an automated email from the ASF dual-hosted git repository.
andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new eb5b7615ae feat: support approx_percentile / percentile_approx
aggregate (#4801)
eb5b7615ae is described below
commit eb5b7615ae0d9b662bf03e15f5f5de666d527d2f
Author: Andy Grove <[email protected]>
AuthorDate: Thu Jul 9 12:47:17 2026 -0600
feat: support approx_percentile / percentile_approx aggregate (#4801)
---
.../expression-audits/agg_funcs.md | 5 +
docs/source/user-guide/latest/expressions.md | 3 +-
native/core/src/execution/planner.rs | 15 +-
native/proto/src/proto/expr.proto | 17 +
.../spark-expr/src/agg_funcs/approx_percentile.rs | 343 ++++++++++++++
native/spark-expr/src/agg_funcs/mod.rs | 3 +
.../spark-expr/src/agg_funcs/quantile_summaries.rs | 505 +++++++++++++++++++++
.../org/apache/comet/rules/CometExecRule.scala | 67 +--
.../org/apache/comet/serde/QueryPlanSerde.scala | 1 +
.../scala/org/apache/comet/serde/aggregates.scala | 77 +++-
.../org/apache/spark/sql/comet/operators.scala | 20 +-
.../expressions/aggregate/approx_percentile.sql | 88 ++++
.../approved-plans-v1_4/q10/extended.txt | 2 +-
.../approved-plans-v1_4/q35/extended.txt | 2 +-
.../approved-plans-v2_7/q35/extended.txt | 2 +-
.../apache/comet/exec/CometAggregateSuite.scala | 25 +
.../apache/comet/rules/CometExecRuleSuite.scala | 52 +++
.../CometAggregateExpressionBenchmark.scala | 30 +-
.../spark/sql/benchmark/CometBenchmarkBase.scala | 9 +
19 files changed, 1219 insertions(+), 47 deletions(-)
diff --git a/docs/source/contributor-guide/expression-audits/agg_funcs.md
b/docs/source/contributor-guide/expression-audits/agg_funcs.md
index fb27662b49..8888776d85 100644
--- a/docs/source/contributor-guide/expression-audits/agg_funcs.md
+++ b/docs/source/contributor-guide/expression-audits/agg_funcs.md
@@ -27,6 +27,11 @@
- Spark 3.5.8 (audited 2026-05-26): identical to 3.4.3.
- Spark 4.0.1 (audited 2026-05-26): identical to 3.4.3.
+## approx_percentile
+
+- Spark 3.4.3, 3.5.8, 4.0.1, 4.1.1 (audited 2026-07-02):
`ApproximatePercentile(child, percentageExpression, accuracyExpression)` is a
`TypedImperativeAggregate` backed by a Greenwald-Khanna `PercentileDigest`
quantile summary with relative error `1.0 / accuracy`. `child` accepts
`NumericType`, `DateType`, `TimestampType`, `TimestampNTZType`, and interval
types (all cast to `double` internally); `percentage` is a single literal or
literal array in `[0.0, 1.0]`; `accuracy` is a positive l [...]
+- `CometApproxPercentile` maps the byte, short, int, long, float, and double
input forms to a native Greenwald-Khanna quantile summary port with the same
insert/compress/merge/query algorithm and relative error, casting the result
back to the input type. `percentage` and `accuracy` must be foldable literals,
matching Spark. Date, timestamp, interval, and decimal inputs fall back to
Spark.
+
## avg
- Spark 3.4.3 (2026-05-26)
diff --git a/docs/source/user-guide/latest/expressions.md
b/docs/source/user-guide/latest/expressions.md
index 10d0e16a77..4ad4d9afe3 100644
--- a/docs/source/user-guide/latest/expressions.md
+++ b/docs/source/user-guide/latest/expressions.md
@@ -60,7 +60,7 @@ expressions. The following function families are **not
currently planned** for n
The file-metadata functions `input_file_name`, `input_file_block_start`, and
`input_file_block_length` depend on scan-internal per-row file information
rather than the expression layer; their support status is covered in the [scan
compatibility guide](compatibility/scans.md).
-Note that `approx_count_distinct`, `median`, and `mode` are planned: they are
mainstream (`median` and `mode` are exact aggregates). `approx_percentile` /
`percentile_approx` are not currently planned because their approximate results
cannot be made bit-identical to Spark.
+Note that `approx_count_distinct`, `median`, and `mode` are planned: they are
mainstream (`median` and `mode` are exact aggregates).
The tables below list every Spark built-in expression with its current status.
@@ -71,6 +71,7 @@ The tables below list every Spark built-in expression with
its current status.
| `any` | ✅ | |
| `any_value` | ✅ | |
| `approx_count_distinct` | 🔜 | tracking
[#4098](https://github.com/apache/datafusion-comet/issues/4098) |
+| `approx_percentile` | ✅ | Byte, short, int, long, float, and double input;
other input types fall back to Spark |
| `array_agg` | 🔜 | Array aggregate (related to `collect_list`,
[#2524](https://github.com/apache/datafusion-comet/issues/2524)) |
| `avg` | ✅ | Interval types fall back |
| `bit_and` | ✅ | |
diff --git a/native/core/src/execution/planner.rs
b/native/core/src/execution/planner.rs
index 4f5d3ceaab..7ed2b3331c 100644
--- a/native/core/src/execution/planner.rs
+++ b/native/core/src/execution/planner.rs
@@ -127,8 +127,8 @@ use datafusion_comet_proto::{
spark_partitioning::{partitioning::PartitioningStruct, Partitioning as
SparkPartitioning},
};
use datafusion_comet_spark_expr::{
- jvm_udf::JvmScalarUdfExpr, ArrayInsert, Avg, AvgDecimal, Cast,
CheckOverflow, Correlation,
- Covariance, CreateNamedStruct, DecimalRescaleCheckOverflow,
GetArrayStructFields,
+ jvm_udf::JvmScalarUdfExpr, ApproxPercentile, ArrayInsert, Avg, AvgDecimal,
Cast, CheckOverflow,
+ Correlation, Covariance, CreateNamedStruct, DecimalRescaleCheckOverflow,
GetArrayStructFields,
GetStructField, IfExpr, ListExtract, NormalizeNaNAndZero,
SparkCastOptions, Stddev, SumDecimal,
ToJson, UnboundColumn, Variance, WideDecimalBinaryExpr, WideDecimalOp,
};
@@ -2628,6 +2628,17 @@ impl PhysicalPlanner {
.build()
.map_err(|e| e.into())
}
+ AggExprStruct::ApproxPercentile(expr) => {
+ let child = self.create_expr(expr.child.as_ref().unwrap(),
Arc::clone(&schema))?;
+ let input_type =
to_arrow_datatype(expr.input_type.as_ref().unwrap());
+ let func = AggregateUDF::new_from_impl(ApproxPercentile::new(
+ expr.percentiles.clone(),
+ expr.accuracy,
+ input_type,
+ expr.return_array,
+ ));
+ Self::create_aggr_func_expr("approx_percentile", schema,
vec![child], func)
+ }
AggExprStruct::BloomFilterAgg(expr) => {
let child = self.create_expr(expr.child.as_ref().unwrap(),
Arc::clone(&schema))?;
let num_items =
diff --git a/native/proto/src/proto/expr.proto
b/native/proto/src/proto/expr.proto
index 32adc16b72..e4efae963b 100644
--- a/native/proto/src/proto/expr.proto
+++ b/native/proto/src/proto/expr.proto
@@ -146,6 +146,7 @@ message AggExpr {
BloomFilterAgg bloomFilterAgg = 16;
CollectSet collectSet = 17;
Percentile percentile = 18;
+ ApproxPercentile approxPercentile = 19;
}
// Optional filter expression for SQL FILTER (WHERE ...) clause.
@@ -254,6 +255,22 @@ message Percentile {
DataType datatype = 3;
}
+message ApproxPercentile {
+ // Child value expression, already cast to Float64 by the serde.
+ Expr child = 1;
+ // The percentiles and accuracy are carried as resolved scalars rather than
+ // child Exprs (unlike Percentile/BloomFilterAgg) because they are needed at
+ // UDAF construction time to drive return_type and accumulator shape.
+ // One or more percentiles in [0.0, 1.0].
+ repeated double percentiles = 2;
+ // Spark's accuracy argument; relative_error = 1.0 / accuracy.
+ int64 accuracy = 3;
+ // True when the percentile argument was an array (output is a list).
+ bool return_array = 4;
+ // Spark's input/output type, used to cast results back from Float64.
+ DataType input_type = 5;
+}
+
message BloomFilterAgg {
Expr child = 1;
Expr numItems = 2;
diff --git a/native/spark-expr/src/agg_funcs/approx_percentile.rs
b/native/spark-expr/src/agg_funcs/approx_percentile.rs
new file mode 100644
index 0000000000..cb67a1b6d0
--- /dev/null
+++ b/native/spark-expr/src/agg_funcs/approx_percentile.rs
@@ -0,0 +1,343 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::quantile_summaries::QuantileSummaries;
+use arrow::array::{Array, ArrayRef, BinaryArray, Float64Array, ListArray};
+use arrow::datatypes::{DataType, Field, FieldRef};
+use datafusion::common::utils::SingleRowListArrayBuilder;
+use datafusion::common::{downcast_value, Result, ScalarValue};
+use datafusion::logical_expr::function::{AccumulatorArgs, StateFieldsArgs};
+use datafusion::logical_expr::Volatility::Immutable;
+use datafusion::logical_expr::{Accumulator, AggregateUDFImpl, Signature};
+use datafusion::physical_expr::expressions::format_state_name;
+use std::sync::Arc;
+
+/// Native implementation of Spark's `approx_percentile` / `percentile_approx`,
+/// backed by a bit-for-bit `QuantileSummaries` (Greenwald-Khanna) port. The
+/// child value is cast to Float64 by the serde; the original `input_type` is
+/// carried so results can be cast back to Spark's output type.
+#[derive(Debug)]
+pub struct ApproxPercentile {
+ name: String,
+ signature: Signature,
+ percentiles: Vec<f64>,
+ accuracy: i64,
+ input_type: DataType,
+ return_array: bool,
+}
+
+impl PartialEq for ApproxPercentile {
+ fn eq(&self, other: &Self) -> bool {
+ self.name == other.name
+ && self.percentiles == other.percentiles
+ && self.accuracy == other.accuracy
+ && self.input_type == other.input_type
+ && self.return_array == other.return_array
+ }
+}
+impl Eq for ApproxPercentile {}
+
+impl std::hash::Hash for ApproxPercentile {
+ fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+ self.name.hash(state);
+ self.percentiles
+ .iter()
+ .for_each(|p| p.to_bits().hash(state));
+ self.accuracy.hash(state);
+ self.input_type.hash(state);
+ self.return_array.hash(state);
+ }
+}
+
+impl ApproxPercentile {
+ pub fn new(
+ percentiles: Vec<f64>,
+ accuracy: i64,
+ input_type: DataType,
+ return_array: bool,
+ ) -> Self {
+ Self {
+ name: "approx_percentile".to_string(),
+ signature: Signature::numeric(1, Immutable),
+ percentiles,
+ accuracy,
+ input_type,
+ return_array,
+ }
+ }
+}
+
+impl AggregateUDFImpl for ApproxPercentile {
+ fn name(&self) -> &str {
+ &self.name
+ }
+
+ fn signature(&self) -> &Signature {
+ &self.signature
+ }
+
+ fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType> {
+ if self.return_array {
+ Ok(DataType::List(Arc::new(Field::new(
+ "item",
+ self.input_type.clone(),
+ false,
+ ))))
+ } else {
+ Ok(self.input_type.clone())
+ }
+ }
+
+ fn accumulator(&self, _acc_args: AccumulatorArgs) -> Result<Box<dyn
Accumulator>> {
+ Ok(Box::new(ApproxPercentileAccumulator::new(
+ self.percentiles.clone(),
+ self.accuracy,
+ self.input_type.clone(),
+ self.return_array,
+ )))
+ }
+
+ fn state_fields(&self, _args: StateFieldsArgs) -> Result<Vec<FieldRef>> {
+ Ok(vec![Arc::new(Field::new(
+ format_state_name(&self.name, "digest"),
+ DataType::Binary,
+ true,
+ ))])
+ }
+
+ fn groups_accumulator_supported(&self, _args: AccumulatorArgs) -> bool {
+ false
+ }
+}
+
+#[derive(Debug)]
+struct ApproxPercentileAccumulator {
+ summary: QuantileSummaries,
+ percentiles: Vec<f64>,
+ input_type: DataType,
+ return_array: bool,
+}
+
+impl ApproxPercentileAccumulator {
+ fn new(percentiles: Vec<f64>, accuracy: i64, input_type: DataType,
return_array: bool) -> Self {
+ let relative_error = 1.0 / accuracy as f64;
+ Self {
+ summary: QuantileSummaries::new(
+ QuantileSummaries::DEFAULT_COMPRESS_THRESHOLD,
+ relative_error,
+ ),
+ percentiles,
+ input_type,
+ return_array,
+ }
+ }
+
+ /// Cast a double quantile back to Spark's output type. GK always returns
an
+ /// actual inserted value (never an interpolation), so for the supported
+ /// numeric types this round-trips exactly and is always in range.
+ fn cast_back(&self, d: f64) -> ScalarValue {
+ match &self.input_type {
+ DataType::Int8 => ScalarValue::Int8(Some(d as i8)),
+ DataType::Int16 => ScalarValue::Int16(Some(d as i16)),
+ DataType::Int32 => ScalarValue::Int32(Some(d as i32)),
+ DataType::Int64 => ScalarValue::Int64(Some(d as i64)),
+ DataType::Float32 => ScalarValue::Float32(Some(d as f32)),
+ DataType::Float64 => ScalarValue::Float64(Some(d)),
+ // The serde only marks byte/short/int/long/float/double as
+ // supported, so no other type reaches the accumulator.
+ other => unreachable!("unsupported approx_percentile input type:
{other}"),
+ }
+ }
+
+ /// The null Spark produces for an empty result: a typed null scalar, or a
+ /// null list when the call returns an array of percentiles.
+ fn null_result(&self) -> Result<ScalarValue> {
+ if self.return_array {
+ Ok(ScalarValue::List(Arc::new(ListArray::new_null(
+ Arc::new(Field::new("item", self.input_type.clone(), false)),
+ 1,
+ ))))
+ } else {
+ Ok(ScalarValue::try_from(&self.input_type)?)
+ }
+ }
+}
+
+impl Accumulator for ApproxPercentileAccumulator {
+ fn update_batch(&mut self, values: &[ArrayRef]) -> Result<()> {
+ let arr = downcast_value!(&values[0], Float64Array);
+ self.summary.reserve(arr.len() - arr.null_count());
+ if arr.null_count() == 0 {
+ // Fast path: no validity checks needed, iterate the raw values.
+ for &v in arr.values() {
+ self.summary.insert(v);
+ }
+ } else {
+ for v in arr.iter().flatten() {
+ self.summary.insert(v);
+ }
+ }
+ Ok(())
+ }
+
+ fn merge_batch(&mut self, states: &[ArrayRef]) -> Result<()> {
+ let digests = downcast_value!(&states[0], BinaryArray);
+ self.summary.compress();
+ for i in 0..digests.len() {
+ if digests.is_null(i) {
+ continue;
+ }
+ let peer = QuantileSummaries::from_bytes(
+ QuantileSummaries::DEFAULT_COMPRESS_THRESHOLD,
+ digests.value(i),
+ );
+ if self.summary.count() == 0 {
+ // Empty self: `merge` would return a clone of the (potentially
+ // large) peer, so move the owned peer in and skip the clone.
+ self.summary = peer;
+ } else {
+ self.summary = self.summary.merge(&peer);
+ }
+ }
+ Ok(())
+ }
+
+ fn state(&mut self) -> Result<Vec<ScalarValue>> {
+ self.summary.compress();
+ Ok(vec![ScalarValue::Binary(Some(self.summary.to_bytes()))])
+ }
+
+ fn evaluate(&mut self) -> Result<ScalarValue> {
+ self.summary.compress();
+ // Spark returns null whenever the result would be empty, i.e. no rows
+ // were aggregated (`query` returns `None`) or the percentage argument
+ // was an empty array (`query` returns `Some([])`).
+ let results = match self.summary.query(&self.percentiles) {
+ Some(r) if !r.is_empty() => r,
+ _ => return self.null_result(),
+ };
+ let scalars: Vec<ScalarValue> = results.into_iter().map(|d|
self.cast_back(d)).collect();
+ if self.return_array {
+ let values = ScalarValue::iter_to_array(scalars)?;
+ Ok(SingleRowListArrayBuilder::new(values)
+ .with_nullable(false)
+ .build_list_scalar())
+ } else {
+ Ok(scalars.into_iter().next().unwrap())
+ }
+ }
+
+ fn size(&self) -> usize {
+ std::mem::size_of_val(self)
+ + self.summary.heap_size()
+ + self.percentiles.capacity() * std::mem::size_of::<f64>()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn f64_array(v: Vec<f64>) -> ArrayRef {
+ Arc::new(Float64Array::from(v))
+ }
+
+ #[test]
+ fn scalar_median_of_int_column() {
+ let mut acc = ApproxPercentileAccumulator::new(vec![0.5], 10000,
DataType::Int32, false);
+ acc.update_batch(&[f64_array((1..=100).map(|i| i as f64).collect())])
+ .unwrap();
+ match acc.evaluate().unwrap() {
+ ScalarValue::Int32(Some(v)) => assert!((49..=51).contains(&v)),
+ other => panic!("unexpected {other:?}"),
+ }
+ }
+
+ #[test]
+ fn array_of_percentiles() {
+ let mut acc =
+ ApproxPercentileAccumulator::new(vec![0.25, 0.5, 0.75], 10000,
DataType::Float64, true);
+ acc.update_batch(&[f64_array((1..=1000).map(|i| i as f64).collect())])
+ .unwrap();
+ match acc.evaluate().unwrap() {
+ ScalarValue::List(arr) => assert_eq!(arr.value_length(0), 3),
+ other => panic!("unexpected {other:?}"),
+ }
+ }
+
+ #[test]
+ fn empty_input_is_null() {
+ let mut acc = ApproxPercentileAccumulator::new(vec![0.5], 10000,
DataType::Int64, false);
+ assert!(acc.evaluate().unwrap().is_null());
+ }
+
+ #[test]
+ fn array_output_empty_input_is_null() {
+ let mut acc =
+ ApproxPercentileAccumulator::new(vec![0.25, 0.5, 0.75], 10000,
DataType::Float64, true);
+ assert!(acc.evaluate().unwrap().is_null());
+ }
+
+ #[test]
+ fn empty_percentiles_is_null() {
+ // An empty percentage array yields null in Spark even with data
present.
+ let mut acc = ApproxPercentileAccumulator::new(vec![], 10000,
DataType::Float64, true);
+ acc.update_batch(&[f64_array((1..=1000).map(|i| i as f64).collect())])
+ .unwrap();
+ assert!(acc.evaluate().unwrap().is_null());
+ }
+
+ #[test]
+ fn state_then_merge_matches_single_shot() {
+ let mut single =
+ ApproxPercentileAccumulator::new(vec![0.5], 10000,
DataType::Float64, false);
+ single
+ .update_batch(&[f64_array((1..=1000).map(|i| i as f64).collect())])
+ .unwrap();
+ let single_val = single.evaluate().unwrap();
+
+ let mut left = ApproxPercentileAccumulator::new(vec![0.5], 10000,
DataType::Float64, false);
+ left.update_batch(&[f64_array((1..=500).map(|i| i as f64).collect())])
+ .unwrap();
+ let left_state = left.state().unwrap();
+
+ let mut right =
+ ApproxPercentileAccumulator::new(vec![0.5], 10000,
DataType::Float64, false);
+ right
+ .update_batch(&[f64_array((501..=1000).map(|i| i as
f64).collect())])
+ .unwrap();
+ let right_state = right.state().unwrap();
+
+ let mut merged =
+ ApproxPercentileAccumulator::new(vec![0.5], 10000,
DataType::Float64, false);
+ merged
+ .merge_batch(&[ScalarValue::iter_to_array(left_state).unwrap()])
+ .unwrap();
+ merged
+ .merge_batch(&[ScalarValue::iter_to_array(right_state).unwrap()])
+ .unwrap();
+ let merged_val = merged.evaluate().unwrap();
+
+ // Both within the same accuracy bound of the true median (~500).
+ for v in [single_val, merged_val] {
+ match v {
+ ScalarValue::Float64(Some(x)) =>
assert!((450.0..=550.0).contains(&x)),
+ other => panic!("unexpected {other:?}"),
+ }
+ }
+ }
+}
diff --git a/native/spark-expr/src/agg_funcs/mod.rs
b/native/spark-expr/src/agg_funcs/mod.rs
index 70100c1a31..79020dead2 100644
--- a/native/spark-expr/src/agg_funcs/mod.rs
+++ b/native/spark-expr/src/agg_funcs/mod.rs
@@ -15,17 +15,20 @@
// specific language governing permissions and limitations
// under the License.
+mod approx_percentile;
mod avg;
mod avg_decimal;
mod correlation;
mod covariance;
mod percentile;
+mod quantile_summaries;
mod stddev;
mod sum_decimal;
mod sum_int;
mod variance;
mod welford;
+pub use approx_percentile::ApproxPercentile;
pub use avg::Avg;
pub use avg_decimal::AvgDecimal;
pub use correlation::Correlation;
diff --git a/native/spark-expr/src/agg_funcs/quantile_summaries.rs
b/native/spark-expr/src/agg_funcs/quantile_summaries.rs
new file mode 100644
index 0000000000..2ed6b32004
--- /dev/null
+++ b/native/spark-expr/src/agg_funcs/quantile_summaries.rs
@@ -0,0 +1,505 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! A faithful port of Spark's `QuantileSummaries` (Greenwald-Khanna), the
+//! sketch behind `approx_percentile` / `percentile_approx`. Kept free of any
+//! DataFusion dependency so it can be unit-tested in isolation.
+//!
+//! Reference: `org.apache.spark.sql.catalyst.util.QuantileSummaries`.
+//!
+//! Results are bit-identical to Spark for deterministic plans. What is
+//! load-bearing for that identity, and must not change:
+//! - the 50000-value head-buffer flush boundary (`DEFAULT_HEAD_SIZE`),
+//! - the integer arithmetic: `floor(2 * relative_error * count)` for
`delta`,
+//! `2 * relative_error * count` for the compress/merge threshold, `/ 2`
for
+//! `target_error`, and `ceil(percentile * count)` for the query rank,
+//! - the backward compress traversal with the `< merge_threshold` test that
+//! always preserves the minimum,
+//! - the non-commutative merge interleave and its asymmetric delta
adjustment,
+//! - the ascending multi-percentile query sweep with its ceil-rank search.
+//!
+//! The data-structure choices (Vec vs deque, copy vs clone, rebuild vs
in-place)
+//! do not affect results and are free to be optimized.
+
+/// A single sampled statistic: the value, its minimum rank jump `g`, and the
+/// maximum span of the rank `delta`.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Stats {
+ pub value: f64,
+ pub g: i64,
+ pub delta: i64,
+}
+
+#[derive(Debug, Clone)]
+pub struct QuantileSummaries {
+ compress_threshold: usize,
+ relative_error: f64,
+ sampled: Vec<Stats>,
+ count: i64,
+ compressed: bool,
+ head_sampled: Vec<f64>,
+}
+
+impl QuantileSummaries {
+ pub const DEFAULT_COMPRESS_THRESHOLD: usize = 10000;
+ pub const DEFAULT_HEAD_SIZE: usize = 50000;
+
+ /// Mirrors Spark's `PercentileDigest` ctor which builds a summary with
+ /// `compressed = true`.
+ pub fn new(compress_threshold: usize, relative_error: f64) -> Self {
+ Self {
+ compress_threshold,
+ relative_error,
+ sampled: Vec::new(),
+ count: 0,
+ compressed: true,
+ head_sampled: Vec::new(),
+ }
+ }
+
+ pub fn count(&self) -> i64 {
+ self.count
+ }
+
+ /// Heap bytes held by this summary (excluding the struct itself).
+ pub fn heap_size(&self) -> usize {
+ self.sampled.capacity() * std::mem::size_of::<Stats>()
+ + self.head_sampled.capacity() * std::mem::size_of::<f64>()
+ }
+
+ /// Reserve space in the head buffer for `additional` incoming values, so a
+ /// batch of inserts does not repeatedly grow it.
+ pub fn reserve(&mut self, additional: usize) {
+ self.head_sampled.reserve(additional);
+ }
+
+ pub fn insert(&mut self, x: f64) {
+ self.head_sampled.push(x);
+ self.compressed = false;
+ if self.head_sampled.len() >= Self::DEFAULT_HEAD_SIZE {
+ self.with_head_buffer_inserted();
+ if self.sampled.len() >= self.compress_threshold {
+ self.compress();
+ }
+ }
+ }
+
+ fn with_head_buffer_inserted(&mut self) {
+ if self.head_sampled.is_empty() {
+ return;
+ }
+ let mut current_count = self.count;
+ let mut sorted = std::mem::take(&mut self.head_sampled);
+ // Spark relies on `Array[Double].sorted`. The sort key is the value
+ // itself, so equal keys are bit-equal elements and stability is
+ // irrelevant; `total_cmp` gives a deterministic total order.
+ sorted.sort_unstable_by(|a, b| a.total_cmp(b));
+
+ let mut new_samples: Vec<Stats> =
Vec::with_capacity(self.sampled.len() + sorted.len());
+ let mut sample_idx = 0usize;
+ let mut ops_idx = 0usize;
+ while ops_idx < sorted.len() {
+ let current_sample = sorted[ops_idx];
+ while sample_idx < self.sampled.len()
+ && self.sampled[sample_idx].value <= current_sample
+ {
+ new_samples.push(self.sampled[sample_idx]);
+ sample_idx += 1;
+ }
+ current_count += 1;
+ let delta = if new_samples.is_empty()
+ || (sample_idx == self.sampled.len() && ops_idx ==
sorted.len() - 1)
+ {
+ 0
+ } else {
+ // Spark: `math.floor(2 * relativeError * currentCount).toLong`
+ // (verified `.toLong` in 3.4/3.5/4.0/4.1), matching our i64.
+ (2.0 * self.relative_error * current_count as f64).floor() as
i64
+ };
+ new_samples.push(Stats {
+ value: current_sample,
+ g: 1,
+ delta,
+ });
+ ops_idx += 1;
+ }
+ while sample_idx < self.sampled.len() {
+ new_samples.push(self.sampled[sample_idx]);
+ sample_idx += 1;
+ }
+ self.sampled = new_samples;
+ self.count = current_count;
+ }
+
+ pub fn compress(&mut self) {
+ // Already compressed and the head buffer is empty (insert clears the
+ // flag whenever it stages a value), so there is nothing to do. This
+ // mirrors Spark's `PercentileDigest.isCompressed` guard, which also
+ // compresses at most once.
+ if self.compressed {
+ return;
+ }
+ self.with_head_buffer_inserted();
+ let merge_threshold = 2.0 * self.relative_error * self.count as f64;
+ self.sampled = Self::compress_immut(&self.sampled, merge_threshold);
+ self.compressed = true;
+ }
+
+ fn compress_immut(current_samples: &[Stats], merge_threshold: f64) ->
Vec<Stats> {
+ if current_samples.is_empty() {
+ return Vec::new();
+ }
+ // Spark prepends into a `ListBuffer`; we push in the same order and
+ // reverse once, which yields an identical sequence.
+ let mut res: Vec<Stats> = Vec::with_capacity(current_samples.len());
+ let mut head = current_samples[current_samples.len() - 1];
+ // Traverse backward from size-2 down to index 1 (index 0 is preserved
+ // separately so the minimum is always kept).
+ let mut i = current_samples.len() as isize - 2;
+ while i >= 1 {
+ let sample1 = current_samples[i as usize];
+ if ((sample1.g + head.g + head.delta) as f64) < merge_threshold {
+ head.g += sample1.g;
+ } else {
+ res.push(head);
+ head = sample1;
+ }
+ i -= 1;
+ }
+ res.push(head);
+ let curr_head = current_samples[0];
+ if curr_head.value <= head.value && current_samples.len() > 1 {
+ res.push(curr_head);
+ }
+ res.reverse();
+ res
+ }
+
+ pub fn merge(&self, other: &QuantileSummaries) -> QuantileSummaries {
+ debug_assert!(self.head_sampled.is_empty(), "compress before merge");
+ debug_assert!(other.head_sampled.is_empty(), "compress before merge");
+ if other.count == 0 {
+ return self.clone();
+ }
+ if self.count == 0 {
+ return other.clone();
+ }
+ let merged_relative_error =
self.relative_error.max(other.relative_error);
+ let merged_count = self.count + other.count;
+ let additional_self_delta =
+ (2.0 * other.relative_error * other.count as f64).floor() as i64;
+ let additional_other_delta = (2.0 * self.relative_error * self.count
as f64).floor() as i64;
+
+ let mut merged_sampled: Vec<Stats> =
+ Vec::with_capacity(self.sampled.len() + other.sampled.len());
+ let mut self_idx = 0usize;
+ let mut other_idx = 0usize;
+ while self_idx < self.sampled.len() && other_idx < other.sampled.len()
{
+ let self_sample = &self.sampled[self_idx];
+ let other_sample = &other.sampled[other_idx];
+ let (mut next_sample, additional_delta) = if self_sample.value <
other_sample.value {
+ self_idx += 1;
+ (
+ *self_sample,
+ if other_idx > 0 {
+ additional_self_delta
+ } else {
+ 0
+ },
+ )
+ } else {
+ other_idx += 1;
+ (
+ *other_sample,
+ if self_idx > 0 {
+ additional_other_delta
+ } else {
+ 0
+ },
+ )
+ };
+ next_sample.delta += additional_delta;
+ merged_sampled.push(next_sample);
+ }
+ while self_idx < self.sampled.len() {
+ merged_sampled.push(self.sampled[self_idx]);
+ self_idx += 1;
+ }
+ while other_idx < other.sampled.len() {
+ merged_sampled.push(other.sampled[other_idx]);
+ other_idx += 1;
+ }
+ let comp = Self::compress_immut(
+ &merged_sampled,
+ 2.0 * merged_relative_error * merged_count as f64,
+ );
+ QuantileSummaries {
+ compress_threshold: other.compress_threshold,
+ relative_error: merged_relative_error,
+ sampled: comp,
+ count: merged_count,
+ compressed: true,
+ head_sampled: Vec::new(),
+ }
+ }
+
+ pub fn query(&self, percentiles: &[f64]) -> Option<Vec<f64>> {
+ debug_assert!(self.head_sampled.is_empty(), "compress before query");
+ if self.sampled.is_empty() {
+ return None;
+ }
+ let target_error = self
+ .sampled
+ .iter()
+ .fold(i64::MIN, |m, s| m.max(s.delta + s.g))
+ / 2;
+
+ let mut indexed: Vec<(f64, usize)> = percentiles
+ .iter()
+ .enumerate()
+ .map(|(i, p)| (*p, i))
+ .collect();
+ indexed.sort_by(|a, b| a.0.total_cmp(&b.0));
+
+ let mut result = vec![0.0f64; percentiles.len()];
+ let mut index = 0usize;
+ let mut min_rank = self.sampled[0].g;
+ for (percentile, pos) in indexed {
+ if percentile <= self.relative_error {
+ result[pos] = self.sampled[0].value;
+ } else if percentile >= 1.0 - self.relative_error {
+ result[pos] = self.sampled[self.sampled.len() - 1].value;
+ } else {
+ let (new_index, new_min_rank, approx) =
+ self.find_approx_quantile(index, min_rank, target_error,
percentile);
+ index = new_index;
+ min_rank = new_min_rank;
+ result[pos] = approx;
+ }
+ }
+ Some(result)
+ }
+
+ fn find_approx_quantile(
+ &self,
+ index: usize,
+ min_rank_at_index: i64,
+ target_error: i64,
+ percentile: f64,
+ ) -> (usize, i64, f64) {
+ let mut cur_sample = &self.sampled[index];
+ let rank = (percentile * self.count as f64).ceil() as i64;
+ let mut i = index;
+ let mut min_rank = min_rank_at_index;
+ while i < self.sampled.len() - 1 {
+ let max_rank = min_rank + cur_sample.delta;
+ if max_rank - target_error <= rank && rank <= min_rank +
target_error {
+ return (i, min_rank, cur_sample.value);
+ } else {
+ i += 1;
+ cur_sample = &self.sampled[i];
+ min_rank += cur_sample.g;
+ }
+ }
+ (
+ self.sampled.len() - 1,
+ 0,
+ self.sampled[self.sampled.len() - 1].value,
+ )
+ }
+
+ /// Comet-internal little-endian layout (NOT Spark's big-endian
serializer):
+ /// count(i64) | relative_error(f64) | n(u32) | n * [value(f64) g(i64)
delta(i64)].
+ /// Callers must `compress()` first.
+ pub fn to_bytes(&self) -> Vec<u8> {
+ let mut buf = Vec::with_capacity(8 + 8 + 4 + self.sampled.len() * 24);
+ buf.extend_from_slice(&self.count.to_le_bytes());
+ buf.extend_from_slice(&self.relative_error.to_le_bytes());
+ buf.extend_from_slice(&(self.sampled.len() as u32).to_le_bytes());
+ for s in &self.sampled {
+ buf.extend_from_slice(&s.value.to_le_bytes());
+ buf.extend_from_slice(&s.g.to_le_bytes());
+ buf.extend_from_slice(&s.delta.to_le_bytes());
+ }
+ buf
+ }
+
+ pub fn from_bytes(compress_threshold: usize, bytes: &[u8]) -> Self {
+ let mut off = 0usize;
+ let take = |off: &mut usize, n: usize| {
+ let s = &bytes[*off..*off + n];
+ *off += n;
+ s
+ };
+ let count = i64::from_le_bytes(take(&mut off, 8).try_into().unwrap());
+ let relative_error = f64::from_le_bytes(take(&mut off,
8).try_into().unwrap());
+ let n = u32::from_le_bytes(take(&mut off, 4).try_into().unwrap()) as
usize;
+ let mut sampled = Vec::with_capacity(n);
+ for _ in 0..n {
+ let value = f64::from_le_bytes(take(&mut off,
8).try_into().unwrap());
+ let g = i64::from_le_bytes(take(&mut off, 8).try_into().unwrap());
+ let delta = i64::from_le_bytes(take(&mut off,
8).try_into().unwrap());
+ sampled.push(Stats { value, g, delta });
+ }
+ QuantileSummaries {
+ compress_threshold,
+ relative_error,
+ sampled,
+ count,
+ compressed: true,
+ head_sampled: Vec::new(),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const EPS: f64 = 1.0 / 10000.0;
+
+ fn summary_of(values: &[f64]) -> QuantileSummaries {
+ let mut qs =
QuantileSummaries::new(QuantileSummaries::DEFAULT_COMPRESS_THRESHOLD, EPS);
+ for &v in values {
+ qs.insert(v);
+ }
+ qs.compress();
+ qs
+ }
+
+ /// Brute-force Spark-equivalent exact rank used to bound the
approximation.
+ fn exact_percentile(sorted: &[f64], p: f64) -> f64 {
+ let rank = (p * sorted.len() as f64).ceil() as usize;
+ let idx = rank.saturating_sub(1).min(sorted.len() - 1);
+ sorted[idx]
+ }
+
+ #[test]
+ fn empty_summary_queries_none() {
+ let qs =
QuantileSummaries::new(QuantileSummaries::DEFAULT_COMPRESS_THRESHOLD, EPS);
+ assert_eq!(qs.query(&[0.5]), None);
+ }
+
+ #[test]
+ fn query_returns_actual_inserted_values() {
+ let values: Vec<f64> = (1..=1000).map(|i| i as f64).collect();
+ let qs = summary_of(&values);
+ // GK returns an actual inserted value, never an interpolation.
+ for p in [0.1, 0.25, 0.5, 0.75, 0.9] {
+ let got = qs.query(&[p]).unwrap()[0];
+ assert!(values.contains(&got), "p={p} produced non-member {got}");
+ }
+ }
+
+ #[test]
+ fn query_within_relative_error_bound() {
+ let values: Vec<f64> = (1..=10000).map(|i| i as f64).collect();
+ let mut sorted = values.clone();
+ sorted.sort_by(|a, b| a.total_cmp(b));
+ let qs = summary_of(&values);
+ for p in [0.01, 0.1, 0.5, 0.9, 0.99] {
+ let got = qs.query(&[p]).unwrap()[0];
+ let exact = exact_percentile(&sorted, p);
+ // rank error bounded by relativeError * count.
+ let rank_err = (got - exact).abs();
+ assert!(
+ rank_err <= EPS * values.len() as f64 + 1.0,
+ "p={p} got={got} exact={exact}"
+ );
+ }
+ }
+
+ #[test]
+ fn multi_percentile_matches_single() {
+ let values: Vec<f64> = (1..=5000).map(|i| i as f64).collect();
+ let qs = summary_of(&values);
+ let ps = [0.9, 0.1, 0.5, 0.99, 0.01];
+ let batch = qs.query(&ps).unwrap();
+ for (i, &p) in ps.iter().enumerate() {
+ assert_eq!(batch[i], qs.query(&[p]).unwrap()[0]);
+ }
+ }
+
+ #[test]
+ fn merge_is_within_bound() {
+ let left: Vec<f64> = (1..=5000).map(|i| i as f64).collect();
+ let right: Vec<f64> = (5001..=10000).map(|i| i as f64).collect();
+ let a = summary_of(&left);
+ let b = summary_of(&right);
+ let merged = a.merge(&b);
+ let mut all: Vec<f64> =
left.iter().chain(right.iter()).cloned().collect();
+ all.sort_by(|x, y| x.total_cmp(y));
+ let got = merged.query(&[0.5]).unwrap()[0];
+ let exact = exact_percentile(&all, 0.5);
+ assert!((got - exact).abs() <= EPS * all.len() as f64 + 1.0);
+ }
+
+ #[test]
+ fn extreme_percentiles_hit_short_circuits() {
+ let values: Vec<f64> = (1..=1000).map(|i| i as f64).collect();
+ let qs = summary_of(&values);
+ // 0.0 <= relative_error short-circuits to the minimum, 1.0 >= 1 -
+ // relative_error to the maximum.
+ assert_eq!(qs.query(&[0.0]).unwrap()[0], 1.0);
+ assert_eq!(qs.query(&[1.0]).unwrap()[0], 1000.0);
+ }
+
+ #[test]
+ fn negative_values_are_ordered() {
+ let values: Vec<f64> = (-500..500).map(|i| i as f64).collect();
+ let mut sorted = values.clone();
+ sorted.sort_by(|a, b| a.total_cmp(b));
+ let qs = summary_of(&values);
+ let got = qs.query(&[0.5]).unwrap()[0];
+ let exact = exact_percentile(&sorted, 0.5);
+ assert!((got - exact).abs() <= EPS * values.len() as f64 + 1.0);
+ }
+
+ #[test]
+ fn duplicate_heavy_column_accumulates_g() {
+ // Five distinct values repeated 200 times each; forces `g`
accumulation
+ // in `compress_immut`.
+ let values: Vec<f64> = (0..1000).map(|i| (i % 5) as f64).collect();
+ let qs = summary_of(&values);
+ for &p in &[0.1, 0.5, 0.9] {
+ let got = qs.query(&[p]).unwrap()[0];
+ assert!((0.0..=4.0).contains(&got), "p={p} produced {got}");
+ }
+ }
+
+ #[test]
+ fn signed_zero_ordering_is_deterministic() {
+ // `total_cmp` orders -0.0 before 0.0; inserting both must not panic
and
+ // must produce a value drawn from the input.
+ let qs = summary_of(&[-0.0, 0.0, -0.0, 0.0, 1.0]);
+ let got = qs.query(&[0.5]).unwrap()[0];
+ assert!(
+ got == 0.0 || got == 1.0,
+ "median of signed zeros produced {got}"
+ );
+ }
+
+ #[test]
+ fn serde_round_trips() {
+ let qs = summary_of(&(1..=2000).map(|i| i as f64).collect::<Vec<_>>());
+ let bytes = qs.to_bytes();
+ let back =
+
QuantileSummaries::from_bytes(QuantileSummaries::DEFAULT_COMPRESS_THRESHOLD,
&bytes);
+ assert_eq!(qs.count(), back.count());
+ assert_eq!(qs.query(&[0.5]), back.query(&[0.5]));
+ }
+}
diff --git a/spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala
b/spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala
index 52f39c59ad..dc8268950e 100644
--- a/spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala
+++ b/spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala
@@ -23,7 +23,7 @@ import scala.collection.mutable.ListBuffer
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.expressions.{Divide, DoubleLiteral,
EqualNullSafe, EqualTo, Expression, FloatLiteral, GreaterThan,
GreaterThanOrEqual, KnownFloatingPointNormalized, LessThan, LessThanOrEqual,
NamedExpression, Remainder}
-import org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateMode,
Final, Partial}
+import org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateMode,
Final, Partial, PartialMerge}
import org.apache.spark.sql.catalyst.optimizer.NormalizeNaNAndZero
import org.apache.spark.sql.catalyst.rules.Rule
import org.apache.spark.sql.catalyst.trees.TreeNodeTag
@@ -837,19 +837,21 @@ case class CometExecRule(session: SparkSession)
private def tagUnsafePartialAggregates(plan: SparkPlan): Unit = {
plan.foreach {
case agg: BaseAggregateExec =>
- // Only consider single-mode Final aggregates. Multi-mode Finals come
from Spark's
- // distinct-aggregate rewrite, where the Comet partial (if any) feeds
into a Spark
- // PartialMerge rather than directly into a Final, which is a
different code path
- // than the Comet-Partial → Spark-Final crash scenario from issue
#1389.
+ // A single-mode Final that consumes an incompatible intermediate
buffer and cannot itself
+ // be converted to Comet must not sit above a Comet aggregate that
produces that buffer,
+ // otherwise Spark's Final would try to read a Comet-encoded buffer
and crash. Tagging the
+ // bottom Partial so it falls back is enough: once it is Spark, the
missingCometProducer
+ // guard in CometBaseAggregate.doConvert cascades the fallback up
through any intermediate
+ // PartialMerge stages of a distinct-aggregate rewrite. See issues
#1389 and #4813.
val modes = agg.aggregateExpressions.map(_.mode).distinct
if (modes == Seq(Final) &&
!QueryPlanSerde.allAggsSupportMixedExecution(agg.aggregateExpressions) &&
!canAggregateBeConverted(agg, Final)) {
findPartialAggInPlan(agg.child).foreach { partial =>
- // Only tag if the Partial would otherwise have been converted. If
the Partial
- // itself cannot be converted (e.g. the aggregate function is
incompatible for the
- // input type), there is no buffer-format mismatch to guard
against, and tagging
- // would mask the natural, more specific fallback reason.
+ // Only tag if the Partial would otherwise have been converted. If
the Partial itself
+ // cannot be converted (e.g. an incompatible input type or a
map-typed grouping key),
+ // there is no buffer-format mismatch to guard against, and
tagging would mask the
+ // natural, more specific fallback reason.
if (canAggregateBeConverted(partial, Partial)) {
partial.setTagValue(
CometExecRule.COMET_UNSAFE_PARTIAL,
@@ -862,6 +864,32 @@ case class CometExecRule(session: SparkSession)
}
}
+ /**
+ * Look for the bottom Partial-mode aggregate that feeds into the given plan
(the child of a
+ * Final). Walks through exchanges and AQE stages, and continues down
through intermediate
+ * aggregate stages whose modes are all Partial / PartialMerge - these are
the PartialMerge (and
+ * mixed Partial/PartialMerge) stages that Spark's distinct-aggregate
rewrite inserts between
+ * the Partial and the Final. Stops at anything else. Requires
`aggregateExpressions.nonEmpty`
+ * so that group-by-only dedup stages are traversed rather than mistaken for
the partial.
+ */
+ private def findPartialAggInPlan(plan: SparkPlan): Option[BaseAggregateExec]
= plan match {
+ case agg: BaseAggregateExec
+ if agg.aggregateExpressions.nonEmpty &&
+ agg.aggregateExpressions.forall(e => e.mode == Partial) =>
+ Some(agg)
+ case agg: BaseAggregateExec
+ if agg.aggregateExpressions.forall(e => e.mode == Partial || e.mode ==
PartialMerge) =>
+ // Intermediate PartialMerge / mixed stage of a distinct-aggregate
rewrite, or a group-by
+ // only dedup stage; keep walking down towards the bottom Partial.
+ findPartialAggInPlan(agg.child)
+ case a: AQEShuffleReadExec => findPartialAggInPlan(a.child)
+ case s: ShuffleQueryStageExec => findPartialAggInPlan(s.plan)
+ case e: ShuffleExchangeExec => findPartialAggInPlan(e.child)
+ case other =>
+ logDebug(s"findPartialAggInPlan: stopping at ${other.nodeName}; not a
known passthrough")
+ None
+ }
+
/**
* Conservative check for whether an aggregate could be converted to Comet.
Checks operator
* enablement, grouping expressions, aggregate expressions, and result
expressions.
@@ -933,25 +961,4 @@ case class CometExecRule(session: SparkSession)
}
}
- /**
- * Look for a Partial-mode aggregate that feeds directly into the given plan
(the child of a
- * Final). Walks through exchanges and AQE stages only, stopping at anything
else including
- * other aggregate stages. This avoids tagging unrelated Partials found
deeper in the plan (e.g.
- * the non-distinct Partial in a distinct-aggregate rewrite, which is
separated from the Final
- * by intermediate PartialMerge stages). Requires
`aggregateExpressions.nonEmpty` so that
- * group-by-only dedup stages are not mistaken for the partial we want to
tag.
- */
- private def findPartialAggInPlan(plan: SparkPlan): Option[BaseAggregateExec]
= plan match {
- case agg: BaseAggregateExec
- if agg.aggregateExpressions.nonEmpty &&
- agg.aggregateExpressions.forall(e => e.mode == Partial) =>
- Some(agg)
- case a: AQEShuffleReadExec => findPartialAggInPlan(a.child)
- case s: ShuffleQueryStageExec => findPartialAggInPlan(s.plan)
- case e: ShuffleExchangeExec => findPartialAggInPlan(e.child)
- case other =>
- logDebug(s"findPartialAggInPlan: stopping at ${other.nodeName}; not a
known passthrough")
- None
- }
-
}
diff --git a/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
b/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
index 82874a7f19..5eee0c5cad 100644
--- a/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
+++ b/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
@@ -387,6 +387,7 @@ object QueryPlanSerde extends Logging with CometExprShim
with CometTypeShim {
* Mapping of Spark aggregate expression class to Comet expression handler.
*/
val aggrSerdeMap: Map[Class[_], CometAggregateExpressionSerde[_]] = Map(
+ classOf[ApproximatePercentile] -> CometApproxPercentile,
classOf[Average] -> CometAverage,
classOf[BitAndAgg] -> CometBitAndAgg,
classOf[BitOrAgg] -> CometBitOrAgg,
diff --git a/spark/src/main/scala/org/apache/comet/serde/aggregates.scala
b/spark/src/main/scala/org/apache/comet/serde/aggregates.scala
index b6cbecc3c3..1fb676366c 100644
--- a/spark/src/main/scala/org/apache/comet/serde/aggregates.scala
+++ b/spark/src/main/scala/org/apache/comet/serde/aggregates.scala
@@ -22,9 +22,10 @@ package org.apache.comet.serde
import scala.jdk.CollectionConverters._
import org.apache.spark.sql.catalyst.expressions.{Attribute, Cast, Literal}
-import
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression,
Average, BitAndAgg, BitOrAgg, BitXorAgg, BloomFilterAggregate,
CentralMomentAgg, CollectSet, Corr, Count, Covariance, CovPopulation,
CovSample, First, Last, Max, Min, Percentile, StddevPop, StddevSamp, Sum,
VariancePop, VarianceSamp}
+import
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression,
ApproximatePercentile, Average, BitAndAgg, BitOrAgg, BitXorAgg,
BloomFilterAggregate, CentralMomentAgg, CollectSet, Corr, Count, Covariance,
CovPopulation, CovSample, First, Last, Max, Min, Percentile, StddevPop,
StddevSamp, Sum, VariancePop, VarianceSamp}
+import org.apache.spark.sql.catalyst.util.ArrayData
import org.apache.spark.sql.internal.SQLConf
-import org.apache.spark.sql.types.{ByteType, DecimalType, DoubleType,
IntegerType, LongType, NumericType, ShortType, StringType}
+import org.apache.spark.sql.types.{ByteType, DecimalType, DoubleType,
FloatType, IntegerType, LongType, NumericType, ShortType, StringType}
import org.apache.comet.CometConf.COMET_EXEC_STRICT_FLOATING_POINT
import org.apache.comet.CometSparkSessionExtensions.{isSpark41Plus,
withFallbackReason}
@@ -676,6 +677,78 @@ object CometPercentile extends
CometAggregateExpressionSerde[Percentile] {
}
}
+object CometApproxPercentile extends
CometAggregateExpressionSerde[ApproximatePercentile] {
+
+ private val nonLiteralPercentageReason =
+ "The percentage argument must be a foldable literal."
+ private val nonLiteralAccuracyReason =
+ "The accuracy argument must be a foldable literal."
+ private val inputTypeReason =
+ "Only byte, short, int, long, float, and double input types are supported."
+
+ override def getUnsupportedReasons(): Seq[String] =
+ Seq(nonLiteralPercentageReason, nonLiteralAccuracyReason, inputTypeReason)
+
+ override def getSupportLevel(expr: ApproximatePercentile): SupportLevel = {
+ if (!expr.percentageExpression.foldable) {
+ return Unsupported(Some(nonLiteralPercentageReason))
+ }
+ if (!expr.accuracyExpression.foldable) {
+ return Unsupported(Some(nonLiteralAccuracyReason))
+ }
+ expr.child.dataType match {
+ case ByteType | ShortType | IntegerType | LongType | FloatType |
DoubleType =>
+ Compatible(None)
+ case _ => Unsupported(Some(inputTypeReason))
+ }
+ }
+
+ override def convert(
+ aggExpr: AggregateExpression,
+ expr: ApproximatePercentile,
+ inputs: Seq[Attribute],
+ binding: Boolean,
+ conf: SQLConf): Option[ExprOuterClass.AggExpr] = {
+ // Spark accumulates values as doubles; cast the child up front so the
+ // accumulator sees a single Float64 column, then cast results back to the
+ // input type natively via input_type.
+ val childExpr = exprToProto(Cast(expr.child, DoubleType), inputs, binding)
+ val inputType = serializeDataType(expr.child.dataType)
+
+ val (percentiles, returnArray) = expr.percentageExpression.eval() match {
+ case d: Double => (Seq(d), false)
+ case arr: ArrayData => (arr.toDoubleArray().toSeq, true)
+ case other =>
+ withFallbackReason(aggExpr, s"Unsupported percentage literal: $other",
expr.child)
+ return None
+ }
+ val accuracy = expr.accuracyExpression.eval() match {
+ case i: Int => i.toLong
+ case l: Long => l
+ case other =>
+ withFallbackReason(aggExpr, s"Unsupported accuracy literal: $other",
expr.child)
+ return None
+ }
+
+ if (childExpr.isDefined && inputType.isDefined) {
+ val builder = ExprOuterClass.ApproxPercentile.newBuilder()
+ builder.setChild(childExpr.get)
+ percentiles.foreach(builder.addPercentiles(_))
+ builder.setAccuracy(accuracy)
+ builder.setReturnArray(returnArray)
+ builder.setInputType(inputType.get)
+ Some(
+ ExprOuterClass.AggExpr
+ .newBuilder()
+ .setApproxPercentile(builder)
+ .build())
+ } else {
+ withFallbackReason(aggExpr, expr.child)
+ None
+ }
+ }
+}
+
object CometCorr extends CometAggregateExpressionSerde[Corr] {
override def convert(
aggExpr: AggregateExpression,
diff --git a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
index 2ee742ec36..a8c674ca30 100644
--- a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
+++ b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
@@ -1526,10 +1526,14 @@ trait CometBaseAggregate {
// In distinct aggregates there can be a combination of modes.
// We support {Partial, PartialMerge} mix; other combinations are rejected.
val multiMode = modes.size > 1 && modeSet != Set(Partial, PartialMerge)
- // For a final mode HashAggregate, we only need to transform the
HashAggregate
- // if there is Comet partial aggregation, unless all aggregates have
compatible
- // intermediate buffer formats (safe for mixed Spark/Comet execution).
- val sparkFinalMode = modes.contains(Final) &&
findCometPartialAgg(aggregate.child).isEmpty
+ // An aggregate that consumes intermediate buffers (Final, or the
PartialMerge stages of a
+ // distinct-aggregate rewrite) must have a Comet aggregate producing those
buffers below it.
+ // Otherwise Comet would try to read a Spark partial's buffer, which is
only safe when every
+ // aggregate has a buffer format compatible between Spark and Comet. This
guards the
+ // Spark-Partial to Comet-Merge direction; the Comet-Partial to
Spark-Final direction is
+ // guarded by the COMET_UNSAFE_PARTIAL tagging pass in CometExecRule. See
issues #1389, #4813.
+ val consumesBuffers = modes.contains(Final) || modes.contains(PartialMerge)
+ val missingCometProducer = consumesBuffers &&
findCometPartialAgg(aggregate.child).isEmpty
if (multiMode) {
withFallbackReason(
@@ -1538,16 +1542,16 @@ trait CometBaseAggregate {
return None
}
- if (sparkFinalMode) {
+ if (missingCometProducer) {
val incompatibleAggs =
QueryPlanSerde.aggsNotSupportingMixedExecution(aggregate.aggregateExpressions)
if (incompatibleAggs.nonEmpty) {
val names =
incompatibleAggs.map(_.prettyName).distinct.sorted.mkString(", ")
withFallbackReason(
aggregate,
- "Spark Final aggregate without Comet Partial requires compatible " +
- "intermediate buffer formats, but the following aggregate
function(s) " +
- s"have incompatible buffers: $names")
+ "Comet aggregate that merges intermediate buffers requires a Comet
child aggregate " +
+ "when the intermediate buffer formats are incompatible with Spark.
" +
+ s"Incompatible aggregate function(s): $names")
return None
}
}
diff --git
a/spark/src/test/resources/sql-tests/expressions/aggregate/approx_percentile.sql
b/spark/src/test/resources/sql-tests/expressions/aggregate/approx_percentile.sql
new file mode 100644
index 0000000000..6cbc5d77c0
--- /dev/null
+++
b/spark/src/test/resources/sql-tests/expressions/aggregate/approx_percentile.sql
@@ -0,0 +1,88 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements. See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership. The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License. You may obtain a copy of the License at
+--
+-- http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied. See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+-- Native approx_percentile via a GK (Greenwald-Khanna) quantile summary port,
+-- matching Spark's algorithm and default relative error, so results are
+-- bit-identical. Only byte, short, int, long, float, and double inputs are
+-- supported. Every query below uses the default `query` mode, which asserts
+-- native execution, so an all-fallback run of this file cannot vacuously pass.
+
+-- scalar percentile over a bigint (long) column
+query
+SELECT approx_percentile(id, 0.5) FROM range(1000)
+
+-- int input type
+query
+SELECT approx_percentile(cast(id AS int), 0.5) FROM range(1000)
+
+-- extreme percentiles 0.0 and 1.0 exercise the query short-circuits
+query
+SELECT approx_percentile(id, 0.0), approx_percentile(id, 1.0) FROM range(1000)
+
+-- negative values
+query
+SELECT approx_percentile(cast(id AS int) - 500, 0.5) FROM range(1000)
+
+-- low-cardinality / duplicate-heavy column exercises `g` accumulation
+query
+SELECT approx_percentile(id % 5, 0.5) FROM range(1000)
+
+-- explicit, non-default accuracy
+query
+SELECT approx_percentile(id, 0.9, 100) FROM range(1000)
+
+-- array of percentiles
+query
+SELECT approx_percentile(id, array(0.25, 0.5, 0.75)) FROM range(1000)
+
+-- group by
+query
+SELECT id % 3 AS g, approx_percentile(id, 0.5) FROM range(1000) GROUP BY g
ORDER BY g
+
+-- doubles
+query
+SELECT approx_percentile(cast(id AS double) / 7.0, 0.5) FROM range(1000)
+
+-- floats
+query
+SELECT approx_percentile(cast(id AS float), 0.5) FROM range(1000)
+
+-- byte input type
+query
+SELECT approx_percentile(cast(id % 100 AS byte), 0.5) FROM range(1000)
+
+-- short input type
+query
+SELECT approx_percentile(cast(id AS short), 0.5) FROM range(1000)
+
+statement
+CREATE TABLE test_approx_percentile_nulls(v int) USING parquet
+
+statement
+INSERT INTO test_approx_percentile_nulls VALUES (1), (2), (null), (3), (4)
+
+-- nulls are ignored
+query
+SELECT approx_percentile(v, 0.5) FROM test_approx_percentile_nulls
+
+-- empty input yields null
+query
+SELECT approx_percentile(v, 0.5) FROM (SELECT id AS v FROM range(1000) WHERE
id < 0)
+
+-- array output with empty input yields null
+query
+SELECT approx_percentile(v, array(0.25, 0.5, 0.75)) FROM (SELECT id AS v FROM
range(1000) WHERE id < 0)
diff --git
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q10/extended.txt
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q10/extended.txt
index fcb5e0003d..0354bdd3b2 100644
---
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q10/extended.txt
+++
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q10/extended.txt
@@ -1,5 +1,5 @@
TakeOrderedAndProject
-+- HashAggregate [COMET: Spark Final aggregate without Comet Partial requires
compatible intermediate buffer formats, but the following aggregate function(s)
have incompatible buffers: count]
++- HashAggregate [COMET: Comet aggregate that merges intermediate buffers
requires a Comet child aggregate when the intermediate buffer formats are
incompatible with Spark. Incompatible aggregate function(s): count]
+- Exchange
+- HashAggregate
+- Project
diff --git
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q35/extended.txt
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q35/extended.txt
index fcb5e0003d..0354bdd3b2 100644
---
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q35/extended.txt
+++
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q35/extended.txt
@@ -1,5 +1,5 @@
TakeOrderedAndProject
-+- HashAggregate [COMET: Spark Final aggregate without Comet Partial requires
compatible intermediate buffer formats, but the following aggregate function(s)
have incompatible buffers: count]
++- HashAggregate [COMET: Comet aggregate that merges intermediate buffers
requires a Comet child aggregate when the intermediate buffer formats are
incompatible with Spark. Incompatible aggregate function(s): count]
+- Exchange
+- HashAggregate
+- Project
diff --git
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7/q35/extended.txt
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7/q35/extended.txt
index fcb5e0003d..0354bdd3b2 100644
---
a/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7/q35/extended.txt
+++
b/spark/src/test/resources/tpcds-plan-stability/approved-plans-v2_7/q35/extended.txt
@@ -1,5 +1,5 @@
TakeOrderedAndProject
-+- HashAggregate [COMET: Spark Final aggregate without Comet Partial requires
compatible intermediate buffer formats, but the following aggregate function(s)
have incompatible buffers: count]
++- HashAggregate [COMET: Comet aggregate that merges intermediate buffers
requires a Comet child aggregate when the intermediate buffer formats are
incompatible with Spark. Incompatible aggregate function(s): count]
+- Exchange
+- HashAggregate
+- Project
diff --git
a/spark/src/test/scala/org/apache/comet/exec/CometAggregateSuite.scala
b/spark/src/test/scala/org/apache/comet/exec/CometAggregateSuite.scala
index e597e6e6e4..aaa27d2533 100644
--- a/spark/src/test/scala/org/apache/comet/exec/CometAggregateSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/exec/CometAggregateSuite.scala
@@ -108,6 +108,31 @@ class CometAggregateSuite extends CometTestBase with
AdaptiveSparkPlanHelper {
}
}
+ // Regression test for the approx_percentile distinct-aggregate crash: an
aggregate with an
+ // incompatible intermediate buffer (percentile_approx) combined with a
distinct aggregate is
+ // rewritten by Spark into a multi-stage plan. If part of that chain runs in
Comet and part in
+ // Spark, the incompatible buffer crosses the boundary and crashes. Here we
force the split with
+ // the partial/final debug configs and assert results still match Spark (the
whole chain must
+ // fall back to Spark). See
https://github.com/apache/datafusion-comet/issues/4813.
+ test("approx_percentile with distinct aggregate does not split across Comet
and Spark") {
+ val data = (0 until 500).map(i => (i % 10, i % 100, i % 37))
+ withParquetTable(data, "tbl", false) {
+ for (disablePartial <- Seq(false, true);
+ disableFinal <- Seq(false, true);
+ groupBy <- Seq("", " GROUP BY _1")) {
+ withSQLConf(
+ CometConf.COMET_EXEC_SHUFFLE_ENABLED.key -> "true",
+ CometConf.COMET_SHUFFLE_MODE.key -> "native",
+ SQLConf.USE_OBJECT_HASH_AGG.key -> "true",
+ CometConf.COMET_ENABLE_PARTIAL_HASH_AGGREGATE.key ->
(!disablePartial).toString,
+ CometConf.COMET_ENABLE_FINAL_HASH_AGGREGATE.key ->
(!disableFinal).toString) {
+ checkSparkAnswer(
+ s"SELECT percentile_approx(_2, 0.5), count(DISTINCT _3) FROM
tbl$groupBy")
+ }
+ }
+ }
+ }
+
test("stddev_pop should return NaN for some cases") {
withSQLConf(CometConf.COMET_EXEC_SHUFFLE_ENABLED.key -> "true") {
Seq(true, false).foreach { nullOnDivideByZero =>
diff --git
a/spark/src/test/scala/org/apache/comet/rules/CometExecRuleSuite.scala
b/spark/src/test/scala/org/apache/comet/rules/CometExecRuleSuite.scala
index 5135d8483c..603e9033a4 100644
--- a/spark/src/test/scala/org/apache/comet/rules/CometExecRuleSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/rules/CometExecRuleSuite.scala
@@ -440,6 +440,58 @@ class CometExecRuleSuite extends CometTestBase {
}
}
+ // Regression tests for
https://github.com/apache/datafusion-comet/issues/4813. An aggregate with
+ // an incompatible intermediate buffer (percentile_approx) combined with a
distinct aggregate is
+ // rewritten by Spark into a multi-stage plan whose partial is separated
from the final by
+ // intermediate PartialMerge stages. If part of that chain runs in Comet and
part in Spark the
+ // incompatible buffer crosses the boundary and crashes, so the whole chain
must fall back.
+ test(
+ "CometExecRule should not split distinct aggregate with incompatible
buffer (Spark final)") {
+ withTempView("test_data") {
+ createTestDataFrame.createOrReplaceTempView("test_data")
+
+ val sparkPlan = createSparkPlan(
+ spark,
+ "SELECT percentile_approx(id, 0.5), COUNT(DISTINCT name) FROM
test_data")
+
+ // The distinct rewrite produces a multi-stage ObjectHashAggregate chain.
+ assert(countOperators(sparkPlan, classOf[ObjectHashAggregateExec]) > 1)
+
+ withSQLConf(
+ CometConf.COMET_ENABLE_FINAL_HASH_AGGREGATE.key -> "false",
+ CometConf.COMET_EXEC_LOCAL_TABLE_SCAN_ENABLED.key -> "true") {
+ val transformedPlan = applyCometExecRule(sparkPlan)
+
+ // percentile_approx has an incompatible buffer, so with the final
forced to Spark the
+ // entire partial/merge chain must also stay in Spark.
+ assert(countOperators(transformedPlan,
classOf[CometHashAggregateExec]) == 0)
+ }
+ }
+ }
+
+ test(
+ "CometExecRule should not split distinct aggregate with incompatible
buffer (Spark part)") {
+ withTempView("test_data") {
+ createTestDataFrame.createOrReplaceTempView("test_data")
+
+ val sparkPlan = createSparkPlan(
+ spark,
+ "SELECT percentile_approx(id, 0.5), COUNT(DISTINCT name) FROM
test_data")
+
+ assert(countOperators(sparkPlan, classOf[ObjectHashAggregateExec]) > 1)
+
+ withSQLConf(
+ CometConf.COMET_ENABLE_PARTIAL_HASH_AGGREGATE.key -> "false",
+ CometConf.COMET_EXEC_LOCAL_TABLE_SCAN_ENABLED.key -> "true") {
+ val transformedPlan = applyCometExecRule(sparkPlan)
+
+ // With the partial/merge stages forced to Spark, no Comet aggregate
may consume their
+ // incompatible buffers either.
+ assert(countOperators(transformedPlan,
classOf[CometHashAggregateExec]) == 0)
+ }
+ }
+ }
+
test("CometExecRule should not convert hash aggregate when grouping key
contains map type") {
// Spark 3.4/3.5 reject `array<map<...>>` as a grouping key in the
analyzer (not orderable),
// so the plan never reaches CometExecRule on those versions. The guard
we're exercising
diff --git
a/spark/src/test/scala/org/apache/spark/sql/benchmark/CometAggregateExpressionBenchmark.scala
b/spark/src/test/scala/org/apache/spark/sql/benchmark/CometAggregateExpressionBenchmark.scala
index 256ba6c421..ab7c731a6c 100644
---
a/spark/src/test/scala/org/apache/spark/sql/benchmark/CometAggregateExpressionBenchmark.scala
+++
b/spark/src/test/scala/org/apache/spark/sql/benchmark/CometAggregateExpressionBenchmark.scala
@@ -125,6 +125,34 @@ object CometAggregateExpressionBenchmark extends
CometBenchmarkBase {
"percentile_double_high_card",
"SELECT percentile(c_double, 0.5) FROM parquetV1Table GROUP BY
high_card_grp"))
+ // Approximate percentile (Greenwald-Khanna). All numeric input types and the
+ // scalar, array, and explicit-accuracy forms run natively.
+ private val approxPercentileAggregates = List(
+ AggExprConfig(
+ "approx_percentile_int_median",
+ "SELECT approx_percentile(c_int, 0.5) FROM parquetV1Table GROUP BY grp"),
+ AggExprConfig(
+ "approx_percentile_long_median",
+ "SELECT approx_percentile(c_long, 0.5) FROM parquetV1Table GROUP BY
grp"),
+ AggExprConfig(
+ "approx_percentile_double_median",
+ "SELECT approx_percentile(c_double, 0.5) FROM parquetV1Table GROUP BY
grp"),
+ AggExprConfig(
+ "approx_percentile_double_p90",
+ "SELECT approx_percentile(c_double, 0.9) FROM parquetV1Table GROUP BY
grp"),
+ AggExprConfig(
+ "approx_percentile_double_array",
+ "SELECT approx_percentile(c_double, array(0.25, 0.5, 0.75)) FROM
parquetV1Table GROUP BY grp"),
+ AggExprConfig(
+ "approx_percentile_double_accuracy",
+ "SELECT approx_percentile(c_double, 0.5, 100) FROM parquetV1Table GROUP
BY grp"),
+ AggExprConfig(
+ "approx_percentile_double_global",
+ "SELECT approx_percentile(c_double, 0.5) FROM parquetV1Table"),
+ AggExprConfig(
+ "approx_percentile_double_high_card",
+ "SELECT approx_percentile(c_double, 0.5) FROM parquetV1Table GROUP BY
high_card_grp"))
+
override def runCometBenchmark(mainArgs: Array[String]): Unit = {
val values = 1024 * 1024
@@ -148,7 +176,7 @@ object CometAggregateExpressionBenchmark extends
CometBenchmarkBase {
val allAggregates = basicAggregates ++ statisticalAggregates ++
bitwiseAggregates ++
multiKeyAggregates ++ multiAggregates ++ decimalAggregates ++
- highCardinalityAggregates ++ percentileAggregates
+ highCardinalityAggregates ++ percentileAggregates ++
approxPercentileAggregates
allAggregates.foreach { config =>
runExpressionBenchmark(config.name, v, config.query,
config.extraCometConfigs)
diff --git
a/spark/src/test/scala/org/apache/spark/sql/benchmark/CometBenchmarkBase.scala
b/spark/src/test/scala/org/apache/spark/sql/benchmark/CometBenchmarkBase.scala
index 71ff2000b3..42dba5ba30 100644
---
a/spark/src/test/scala/org/apache/spark/sql/benchmark/CometBenchmarkBase.scala
+++
b/spark/src/test/scala/org/apache/spark/sql/benchmark/CometBenchmarkBase.scala
@@ -51,6 +51,15 @@ trait CometBenchmarkBase
.set("spark.master", "local[1]")
.setIfMissing("spark.driver.memory", "3g")
.setIfMissing("spark.executor.memory", "3g")
+ // Use Comet's shuffle manager so operators that require Comet shuffle
can
+ // run natively, notably aggregates planned as ObjectHashAggregate such
as
+ // percentile and approx_percentile. `spark.shuffle.manager` is static
and
+ // must be set before the context starts. CometShuffleManager falls back
to
+ // Spark's shuffle when Comet is disabled, so the Spark baseline cases
are
+ // unaffected.
+ .set(
+ "spark.shuffle.manager",
+ "org.apache.spark.sql.comet.execution.shuffle.CometShuffleManager")
val sparkSession = SparkSession.builder
.config(conf)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]