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:

Activity

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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestperformanceMake DataFusion faster

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions