Skip to content

feat: support native aggregate function mode#4782

Open
andygrove wants to merge 1 commit into
apache:mainfrom
andygrove:feature/native-mode-agg
Open

feat: support native aggregate function mode#4782
andygrove wants to merge 1 commit into
apache:mainfrom
andygrove:feature/native-mode-agg

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #3970.

Rationale for this change

The mode aggregate (the most frequent value in a group) is a mainstream statistical aggregate that previously fell back to Spark, preventing native execution of any query using it. Adding native support keeps these queries in Comet's pipeline.

What changes are included in this PR?

This PR was scaffolded with the implement-comet-expression project skill.

  • Native Rust implementation in native/spark-expr/src/agg_funcs/mode.rs: a Mode aggregate UDF with both a global Accumulator and a vectorized GroupsAccumulator. State is a frequency map keyed by ScalarValue, serialized as a single struct<values: array<T>, counts: array<bigint>> buffer column so partial/final buffer schemas stay aligned with Spark's single-attribute TypedImperativeAggregate buffer.
  • Protobuf Mode message and AggExpr oneof entry, plus the planner arm in planner.rs.
  • CometMode serde and registration in QueryPlanSerde.aggrSerdeMap.
  • A Mode branch in adjustOutputForNativeState (operators.scala) mapping the Spark binary buffer type to the native struct state type.
  • A modeHasUnsupportedOrdering shim in CometTypeShim (spark-3.x / spark-4.x) because Mode.reverseOpt only exists on Spark 4.0+.
  • Docs: mode marked supported in the expressions guide.

Scope and compatibility:

  • Only the plain mode(col) form is supported. The mode(col, deterministic) and mode() WITHIN GROUP (ORDER BY col) forms (Spark 4.0+, which set reverseOpt) fall back to Spark.
  • Registered as Incompatible (opt-in via spark.comet.expression.Mode.allowIncompatible=true): Spark breaks ties non-deterministically based on JVM hash-map iteration order, which a native hash map cannot reproduce bit-for-bit. Comet instead returns the smallest tied value deterministically.
  • NULLs are ignored, empty input returns NULL, and float keys are normalized (-0.0 to 0.0, canonical NaN) to match Spark's counting. Supported input types are numeric, boolean, decimal, date, timestamp, timestamp_ntz, and default-collation string; other types fall back.

How are these changes tested?

  • Rust unit tests in mode.rs covering most-frequent value, tie-break to smallest, NULL handling, empty input, float normalization, and partial/final merge equivalence for both the accumulator and the groups accumulator.
  • A mode.sql file test exercising global and grouped aggregation, NULLs, all-NULL groups, mixed aggregates, HAVING, and boolean/integer/double/decimal/string/date/timestamp inputs, plus an unsupported-type fallback assertion. Verified on Spark 3.5 and Spark 4.1.

@andygrove
andygrove marked this pull request as draft July 1, 2026 15:44
Add native support for the Spark `mode` aggregate, the most frequent
value within a group.

Spark breaks ties on the default `mode(col)` form non-deterministically
(the chosen value depends on JVM hash-map iteration order), so the
function is registered as Incompatible and opt-in via allowIncompatible;
Comet resolves ties deterministically by returning the smallest tied
value. NULLs are ignored, empty input returns NULL, and float keys are
normalized to match Spark. The deterministic-flag and WITHIN GROUP forms
fall back to Spark.

Closes apache#3970
@andygrove
andygrove force-pushed the feature/native-mode-agg branch from b6cf253 to fb2f52b Compare July 1, 2026 15:54
@andygrove andygrove changed the title feat: support native mode aggregate function feat: support native aggregate function mode Jul 1, 2026
@andygrove andygrove added this to the 1.0.0 milestone Jul 3, 2026
@andygrove
andygrove marked this pull request as ready for review July 3, 2026 22:16
@andygrove andygrove mentioned this pull request Jul 6, 2026
27 tasks

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

First pass, thanks @andygrove! I verified null handling, empty-group-returns-NULL, the tie-break rationale, ordering via ScalarValue::partial_cmp, collation and type gating, and the state-schema rewrite against apple-spark .../aggregate/Mode.scala. Those all match. One item is a genuine semantic divergence from Spark that is not covered by the non-determinism label: the -0.0 normalization. Details and a test below. The rest are reuse/efficiency/cleanup items.


/// Normalize a scalar key so that Spark's floating-point normalization is honoured: `-0.0` and
/// `0.0` collapse to the same key and all `NaN` bit patterns collapse to a canonical `NaN`.
fn normalize_key(value: ScalarValue) -> ScalarValue {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

normalize_key collapses both NaN and -0.0 before keying the frequency map. The NaN half is correct, but collapsing -0.0 into 0.0 diverges from Spark. The Spark-side semantics are verified:

  • Spark's Mode.update keys an OpenHashMap[AnyRef, Long] via buffer.changeValue(InternalRow.copyValue(key), ...) with no normalization (Mode.scala:63).
  • OpenHashMap/OpenHashSet key comparison is _data(pos) equals k (core/.../util/collection/OpenHashSet.scala:122), and that file carries an explicit comment that equals distinguishes 0.0/-0.0 and collapses NaN/NaN (OpenHashSet.scala:116). So Spark treats -0.0 and 0.0 as distinct keys and all NaN as one key.
  • NormalizeFloatingNumbers does not touch aggregate arguments. Its apply only rewrites WINDOW and JOIN patterns (sql/catalyst/.../optimizer/NormalizeFloatingNumbers.scala:62). There is no Aggregate case, so a mode(v) argument reaches the aggregate un-normalized.

So the doc comment at mode.rs:40-41 claiming this matches NormalizeFloatingNumbers does not hold for aggregate inputs. The Spark-equivalent key is doubleToLongBits: canonicalize NaN, keep -0.0 distinct from 0.0. Suggested fix: drop the -0.0 branch from normalize_key, keep only NaN canonicalization, then key on bit-equality (arrow's Hashable/total_cmp keeps -0.0 and 0.0 distinct once NaN is canonical).

One thing I could not confirm without running: whether this divergence is observable end to end, which depends on two harness details. First, whether the Parquet write/read round-trip preserves -0.0 on the Spark baseline side. Second, how the row comparison treats -0.0 vs 0.0. The existing group b fixture at mode.sql:115-120 uses -0.0 and passed CI, which suggests one of those is masking the difference today. A test that flips the winner to a different magnitude sidesteps both, so it is unambiguous. Does this hold up if you run it?

statement
CREATE TABLE mode_neg_zero(v double, grp string) USING parquet

statement
INSERT INTO mode_neg_zero VALUES
  (CAST(-0.0 AS DOUBLE), 'a'), (CAST(0.0 AS DOUBLE), 'a'),
  (CAST(5.0 AS DOUBLE), 'a'), (CAST(5.0 AS DOUBLE), 'a')

-- Hypothesis: Spark keeps -0.0 and 0.0 separate (counts -0.0:1, 0.0:1, 5.0:2) so mode = 5.0,
-- while Comet collapses to 0.0:2, 5.0:2, a tie it breaks to the smaller value 0.0.
-- 5.0 vs 0.0 is caught regardless of how -0.0/0.0 compare.
query
SELECT grp, mode(v) FROM mode_neg_zero GROUP BY grp ORDER BY grp

Keep a NaN case to lock in that NaN must still collapse:

statement
CREATE TABLE mode_nan(v double, grp string) USING parquet

statement
INSERT INTO mode_nan VALUES
  (CAST('NaN' AS DOUBLE), 'a'), (CAST('NaN' AS DOUBLE), 'a'), (CAST(1.0 AS DOUBLE), 'a')

query
SELECT grp, mode(v) FROM mode_nan GROUP BY grp ORDER BY grp

If the fix lands, the existing group b fixture (mode.sql:115) and the Rust test float_zero_and_nan_normalized (mode.rs:454) encode the collapsed result, so they would need updating.

}

/// Add each non-null value in `array` to `map`, normalizing float keys.
fn count_values(map: &mut HashMap<ScalarValue, i64>, array: &ArrayRef, idx: usize) -> Result<()> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The frequency map keys on ScalarValue with per-row ScalarValue::try_from_array (see also line 195). DataFusion's idiomatic pattern for a primitive frequency/dedup map is a monomorphized HashMap<Hashable<T::Native>, _> (datafusion/functions-aggregate/src/median.rs:313, .../approx_distinct and count_distinct/native.rs). Hashable alone does not reproduce Spark's float key semantics (it keeps -0.0/0.0 distinct, which is correct per the finding above, but does not collapse NaN, so a canonicalization step is still needed). Given mode supports many types, the type-generic ScalarValue map is a defensible simplicity choice. Either keep it and add a one-line comment that it is intentionally type-generic, or monomorphize the hot primitive paths for speed. Not a defect.

eval_mode(&self.counts, &self.data_type)
}

fn size(&self) -> usize {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(see also line 348) size() counts only capacity * size_of::<(ScalarValue, i64)>(), which omits the heap bytes of Utf8(String) / Binary(Vec<u8>) / boxed decimal keys. For string modes this under-reports memory to the pool that drives spill decisions. Compare count_distinct/native.rs, which includes element bytes via estimate_memory_size. Fix: sum key.size() per entry (ScalarValue has a size() helper) instead of a flat per-slot size.

Ok(self.data_type.clone())
}

fn default_value(&self, _data_type: &DataType) -> Result<ScalarValue> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

default_value duplicates the AggregateUDFImpl trait default (udaf.rs, both ScalarValue::try_from(&data_type)). Redundant, can be removed.

for map in &emitted {
results.push(eval_mode(map, &self.data_type)?);
}
ScalarValue::iter_to_array(results)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(and line 345) evaluate/state call ScalarValue::iter_to_array, which errors on an empty iterator. This is currently unreachable because the grouped-aggregate stream short-circuits when there are zero groups, but the dependency is implicit. To make the invariant explicit and self-documenting, add debug_assert!(!emitted.is_empty()) (or an early return of an empty array) at the emit site.

-- Config: spark.comet.expression.Mode.allowIncompatible=true

-- ============================================================
-- Setup: tables

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Beyond the correctness tests above: add a timestamp_ntz compared query (declared supported in isSupportedType, only timestamp is exercised), and a Spark-4.x fallback assertion for mode(col, true) and mode() WITHIN GROUP (ORDER BY col) to lock in modeHasUnsupportedOrdering (only the type-fallback path is asserted today).

case _ => false
}

override def getSupportLevel(expr: Mode): SupportLevel = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Spark ASC WITHIN GROUP (reverseOpt = Some(false)) returns the smallest tied value, identical to Comet's deterministic tie-break, so that specific form could be Compatible rather than a fallback. Optional, phrased as a change if you want it: add a TODO referencing it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add native support for MODE aggregate function

2 participants