Skip to content

Extend first_value / last_value GroupsAccumulator to nested types (List, Struct, Map) #23601

Description

@zhuqi-lucas

Part of #23600.

Is your feature request related to a problem or challenge?

first_value(x ORDER BY o) and last_value(x ORDER BY o) fall back to the per-group Accumulator path whenever x is a nested type. groups_accumulator_supported() in first_last.rs whitelists only scalar primitives (Int, Float, Decimal, Timestamp, Utf8, Binary).

The per-group Accumulator stores state as Vec<ScalarValue>. For List<Struct<...>> payloads a ScalarValue::List is a heap-allocated deep clone. update_batch clones on every candidate row and compares against stored best. Cost scales as #rows × #groups × sizeof(wide_ScalarValue), which explodes on wide payload.

Two observable consequences:

  1. SELECT DISTINCT ON (id) * ORDER BY ts LIMIT 10 runs at 1.4 GB peak and is ~4× slower than SELECT DISTINCT * on the same dataset (Performance of distinct on (columns) #16620).
  2. A production batch pipeline running SELECT ..., FIRST_VALUE(list_col ORDER BY o DESC), ... GROUP BY p over 11M rows × ~2.5 KB payload hits an OS memory kill at ~132 GB. The equivalent hand-written two-step (MAX GROUP BY p + JOIN + FIRST_VALUE without ORDER BY) finishes at ~3.6 GB peak. The gap is entirely the slow Accumulator path.

Describe the solution you'd like

Extend groups_accumulator_supported() for first_value / last_value to accept nested types:

  • List(_), LargeList(_), ListView(_), LargeListView(_)
  • FixedSizeList(_, _)
  • Struct(_)
  • Map(_, _)

Implement a columnar GroupsAccumulator state:

  • One ArrayRef per accumulator expression, indexed by group index — holds the current winner value per group
  • One or more ArrayRef for the ordering columns (also indexed by group index)
  • update_batch(values, group_indices, ...):
    1. Compare incoming ordering values against stored per-group ordering (vectorized)
    2. Build an indices array of winning positions
    3. Use arrow::compute::take to gather values from the input arrays into the new state array (scatter into group slots)
  • merge_batch follows the same pattern for cross-partition merges
  • state() / evaluate() return the accumulated ArrayRef directly, no ScalarValue conversion

Expected memory: #groups × wide_row × #expressions — for the production case, ~125 MB instead of ~132 GB.

Describe alternatives you've considered

  • Keep the Accumulator path but cache and reuse ScalarValue allocations. Reduces churn but still O(#rows × sizeof(wide)) allocation work, and cannot reach vectorized update throughput.
  • Force users to hand-write a two-step aggregate + JOIN. Works today (production has done it) but is not discoverable and does not generalize to DISTINCT ON or ROW_NUMBER = 1 patterns.

Additional context

Companion epic: #23600

Companion sub-issues (this ticket ships value on its own; the rest amplify):

  • Coalesce peer FIRST_VALUE(... ORDER BY o) expressions into a single struct accumulator
  • Logical rewrite Filter(row_number() = 1) → Aggregate(FIRST_VALUE(... ORDER BY o)) — depends on this ticket to emit the fast path

Related:

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestperformanceMake DataFusion faster

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions