Repository navigation
Conversation
… keys Comet runs a Spark PartialMerge as DataFusion Single so that it spills rather than emit a key twice (apache#6857). When the input is sorted on a prefix of the grouping keys, DataFusion 55.2 runs Single with the legacy GroupedHashAggregateStream, whose spill asks for more than its share of the pool to sort the batch it writes, and the task fails. Plan that PartialMerge as DataFusion Final instead. Final runs an OrderedFinalAggregateStream there, which spills and reads its spill files back, and MergeAsPartial evaluates to its state under Final as it does under Single. Final reads each state from the columns after the keys and spills its groups in the input's types, so the planner switches only when the input is the grouping keys followed by native-typed states. A Spark partial's binary collect buffers keep the Single plan. Closes apache#6880.
Read the input order from the child's equivalence properties, as AggregateExec does, instead of building the aggregate as Single and again as Final. Simplify the state type guard, note when the switch can be rechecked, and cover input sorted on every key in the planner test.
viirya
left a comment
There was a problem hiding this comment.
Thanks for this. Planning a pure PartialMerge as Final when its input is sorted on a key prefix is a neat way to land on OrderedFinalAggregateStream in DataFusion 55.2. Its spill doesn't reserve extra memory to sort what it writes, and MergeAsPartial evaluates to its state there just as it does under Single. I checked the guard against how Final reads its input. merge_expressions takes the states from column group_by.expr.len() on, as_final() reads the keys from columns 0..n, and the ordered final table spills with the input schema. So both checks in input_is_keys_then_states are needed, and they look right to me. The {Partial, PartialMerge} mix is sent with proto mode Partial, so it can't reach this path either.
A few things I'd like to sort out before merge.
The PartialMerge is no longer held to its fair share. limited_to_its_share in fair_pool.rs exempts every consumer whose name starts with OrderedFinalAggregateStream, partially sorted ones included. Once this PartialMerge plans as Final, it is checked only against the pool and Spark's grant. The issue's error was the fair limit refusing the legacy stream's spill, so this exemption is part of why the task now succeeds, along with the new stream's spill path. I think the PR description and the comment in aggregate.rs should say so. The exemption paragraph in docs/source/contributor-guide/memory_management.md and the limited_to_its_share doc both describe it as a workaround for the fully sorted CometSortAggregateExec case. Could they mention that a PartialMerge over partially sorted input now gets it too? Then the next person tuning the fair pool knows which consumers it covers.
The tests don't exercise the path the fix depends on. With g constant, the ordered stream never sees the prefix advance. So nothing exercises groups emitted early before a spill, or the rule that every group waits for the replay once a run has spilled. A key emitted twice across a prefix boundary is the #6857 failure, so I'd like the answer check to be able to catch it. The repro also needs EliminateSorts excluded, so it isn't a shape users get with the default rules. Details are inline.
| // One over Spark's binary collect buffers keeps Single and can still fail this way. | ||
| // DataFusion 56 replaces the legacy stream with an OrderedSingleAggregateStream, so | ||
| // recheck this on that upgrade (#6410). | ||
| let input_sorted_on_a_key = !child |
There was a problem hiding this comment.
input_sorted_on_a_key is computed for every aggregate, before the proto_mode == ProtoAggregateMode::PartialMerge check. So every Partial and Final aggregate now runs find_longest_permutation and gains a new ? error path, only for the result to be thrown away. Could it be evaluated only inside the PartialMerge condition, for example as the second operand of the &&?
There was a problem hiding this comment.
Good catch. The sort check now runs last in the && chain, so only a PartialMerge whose input is its keys followed by native states pays for it. Partial and Final aggregates skip it.
| .find_longest_permutation(&group_by.input_exprs())? | ||
| .1 | ||
| .is_empty(); | ||
| let mode = if proto_mode == ProtoAggregateMode::PartialMerge |
There was a problem hiding this comment.
Planning this as Final also gives the consumer the OrderedFinalAggregateStream name, which limited_to_its_share in fair_pool.rs exempts by prefix whether the input is fully or partially sorted. So this PartialMerge is no longer checked against its fair limit, the limit the issue's error came from. Could the comment here, and the PR description, say that this is part of why the task now succeeds? Could the exemption paragraph in memory_management.md and the limited_to_its_share doc mention this case too? Both describe the exemption as being for a CometSortAggregateExec over fully sorted input.
There was a problem hiding this comment.
Done. The comment here now says the task succeeds because of the new stream's spill path and because limited_to_its_share exempts the stream from the fair limit. The limited_to_its_share doc, the exemption paragraph in memory_management.md and the PR description say it too.
| // MergeAsPartial evaluates to its state under Final too. Final reads each state from the | ||
| // columns after the keys and spills its groups in the input's types, so plan the | ||
| // PartialMerge as Final only when its input is its keys followed by native states. | ||
| // One over Spark's binary collect buffers keeps Single and can still fail this way. |
There was a problem hiding this comment.
Is there a real plan where a PartialMerge over Spark's binary collect buffers sees input sorted on only some of its keys? A Spark partial's output reaches native code through a ScanExec that reports no ordering, so it would run as Linear on SingleHashAggregateStream. A sort aggregate sorts on every key, which is Sorted, and the legacy stream doesn't spill there anyway. If there isn't one, could the comment say this case isn't expected in practice, rather than that it can still fail this way? Otherwise a reader may go looking for a failure that can't happen.
There was a problem hiding this comment.
I couldn't construct one. A Spark partial's output arrives through a ScanExec that reports no ordering, so it runs as Linear on the hash stream. The comment now says that input isn't expected to be sorted on a key, and the description no longer says it can still fail.
| val path = dir.getCanonicalPath | ||
| spark | ||
| .range(0, 125000, 1, 1) | ||
| .selectExpr("0L AS g", "lpad(cast(id % 62500 AS STRING), 128, 'k') AS k", "id AS v") |
There was a problem hiding this comment.
With 0L AS g, the input is sorted on a key that never changes. So the ordered final stream never emits a finished group before it spills, and the test never exercises the rule that every group waits for the replay once a run has spilled. Could the data use a few g values, for example id % 4 AS g, and check one row per g? A key emitted twice across a prefix boundary would then show up in the answer, and that's the failure #6857 was about.
There was a problem hiding this comment.
Changed. The data now has four values of g (id % 4), and the test checks one row per g with max(v) and count_distinct(k). Both pool sizes still spill and fail with the issue's error when I turn the planner change off.
| // spill asked for more than its share before it planned as an ordered final aggregate. | ||
| for (poolMiB <- Seq(2, 8)) { | ||
| withSQLConf( | ||
| SQLConf.OPTIMIZER_EXCLUDED_RULES.key -> EliminateSorts.ruleName, |
There was a problem hiding this comment.
This shape needs EliminateSorts excluded, because the optimizer otherwise drops the sortWithinPartitions under the aggregate. I'd expect users to hit it after a sort-merge join on g. The join's HashPartitioning(g) satisfies the PartialMerge's clustered distribution on (g, k), so the join, the partial and the PartialMerge run in one native plan, and the PartialMerge sees its input sorted on g. Could the test use that shape with the default rules, or add it as a second case?
There was a problem hiding this comment.
Added as a second test: a sort-merge join on g with the default rules, which plans the join, the partial and the PartialMerge in one native plan. It needs AQE off, or it becomes a broadcast join, and a 16 MiB pool, because the sorts under the join can't get what they need from a 2 or 8 MiB pool's share. It fails with the issue's error without the planner change.
… changing g and a sort-merge join
comphead
left a comment
There was a problem hiding this comment.
Two points on the head after the first round. One is about the new guard in aggregate.rs, the other about the new planner test.
| let state_fields = aggr_expr | ||
| .iter() | ||
| .map(|expr| expr.state_fields()) | ||
| .collect::<datafusion::common::Result<Vec<_>>>()? | ||
| .concat(); | ||
| let input_types = input_schema | ||
| .fields() | ||
| .iter() | ||
| .skip(num_keys) | ||
| .map(|f| f.data_type()); | ||
| Ok(input_types.eq(state_fields.iter().map(|f| f.data_type()))) |
There was a problem hiding this comment.
This collects every aggregate's state fields into a Vec<Vec<_>>, concatenates them, and only then compares. Walking the input types once returns on the first mismatch and builds no vectors (not compiled):
let mut input_types = input_schema.fields().iter().skip(num_keys).map(|f| f.data_type());
for expr in aggr_expr {
for field in expr.state_fields()? {
if input_types.next() != Some(field.data_type()) {
return Ok(false);
}
}
}
Ok(input_types.next().is_none())There was a problem hiding this comment.
Done. The guard walks the input types once against each aggregate's state fields and returns on the first mismatch, then checks that no input columns are left over. It builds no vectors.
| let input: Arc<dyn ExecutionPlan> = | ||
| MemorySourceConfig::try_new_exec(&[vec![batch]], input_schema, None).unwrap(); | ||
| let native_plan = Arc::clone(&planned.native_plan) | ||
| .transform_up(|node| { |
There was a problem hiding this comment.
The same ScanExec to MemorySourceConfig swap is already at lines 4529, 4632 and 4829, and the int64, v, max and count builders above repeat the ones in partial_merge_runs_as_single_and_emits_merged_states. A helper that takes the planned plan and the input batches and returns the collected output, plus shared builders for the two PartialMerge tests, would leave this test with only what it checks: the sort keys, the mode and input order it expects, and the rows.
There was a problem hiding this comment.
Done. A collect_over_batches helper takes the planned plan and the input batches and returns the collected output. The keyless, list-key, PartialMerge and ordered Final tests use it now. create_int64_datatype, create_unbound_int64, create_max_agg and create_count_agg replace the builders the two PartialMerge tests each repeated.
|
|
||
| val plan = df.queryExecution.executedPlan | ||
| assert( | ||
| collect(plan) { case join: CometSortMergeJoinExec => join }.nonEmpty, |
There was a problem hiding this comment.
Thanks for adding this case. The assert only checks that a native sort-merge join exists somewhere in the plan. If an exchange ever lands between the join and the PartialMerge, or the join's ordering stops reaching native code, the PartialMerge would run as Single over unsorted input. It would still spill, so the test would keep passing without covering #6880. Could it check the shape, as the first test does with partial.child.isInstanceOf[CometSortExec]? For example, assert that the spilled PartialMerge's child is the partial aggregate and that the partial's subtree reaches the CometSortMergeJoinExec without a CometShuffleExchangeExec in between.
| // by (g, k), so the join, the partial aggregate and the PartialMerge run in one native plan, | ||
| // and the PartialMerge sees its input sorted on g. AQE would turn the join into a broadcast | ||
| // hash join, because the keys are tiny. | ||
| withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") { |
There was a problem hiding this comment.
The helper already sets AUTO_BROADCASTJOIN_THRESHOLD to -1. At runtime, AQE falls back to that value unless spark.sql.adaptive.autoBroadcastJoinThreshold is set (JoinSelectionHelper, plan.stats.isRuntime). So I wouldn't expect AQE to turn this join into a broadcast join. Does the test pass with AQE on? AQE is on by default, so that would make it closer to the shape users get. If AQE does need to be off, could the comment give the reason you saw?
mbutrovich
left a comment
There was a problem hiding this comment.
First pass, pretty minor stuff, thanks @andygrove!
| // DataFusion 56 replaces the legacy stream with an OrderedSingleAggregateStream, so | ||
| // recheck this on that upgrade (#6410). |
There was a problem hiding this comment.
As I read DataFusion main, there is no OrderedSingleAggregateStream there anymore. apache/datafusion#24697 renamed the ordered streams to ClusteredSingleAggregateStream and ClusteredFinalAggregateStream, and those are also their memory consumer names. This fix depends on the OrderedFinalAggregateStream[ prefix in spill_replay.rs and the fair pool exemption that reuses it (fair_pool.rs). On 56, neither would match the stream this PartialMerge runs on. #6404 pins a DataFusion revision from before the rename, so its CI hasn't seen this yet.
Could the comment name the stream as it is on main? And since this PR closes #6880, could #6410 get an item for the 56 upgrade that covers both removing this Final switch and updating the consumer name prefix? Its current #6880 entry only says to recheck the failure.
| let keys_lead = agg.initial_input_buffer_offset as usize == num_keys | ||
| && agg.grouping_exprs.iter().enumerate().all(|(idx, expr)| { | ||
| matches!(&expr.expr_struct, Some(ExprStruct::Bound(bound)) if bound.index as usize == idx) | ||
| }); | ||
| if !keys_lead { | ||
| return Ok(false); | ||
| } | ||
| let mut input_types = input_schema | ||
| .fields() | ||
| .iter() | ||
| .skip(num_keys) | ||
| .map(|f| f.data_type()); | ||
| for expr in aggr_expr { | ||
| for field in expr.state_fields()? { | ||
| if input_types.next() != Some(field.data_type()) { | ||
| return Ok(false); | ||
| } | ||
| } | ||
| } | ||
| Ok(input_types.next().is_none()) |
There was a problem hiding this comment.
What do you think about adding cases to partial_merge_over_sorted_input_runs_as_final for the inputs this guard rejects? I mutated the guard on the head commit and ran cargo test -p datafusion-comet --lib execution::. Replacing if !keys_lead with a check that never returns, or replacing the last line with Ok(true), leaves every test passing. Only the type comparison is checked today, by the collect_list case. Each rejected input is one where Final would read the wrong columns or spill with a schema that doesn't match its groups, so I think each needs a case that expects Single. For example, the grouping keys in swapped order (key(1), key(0)), initial_input_buffer_offset of 1, and one extra int64 input column after the states.
Which issue does this PR close?
Closes #6880.
Rationale for this change
#6867 runs a native
PartialMergeas DataFusionSingleso that it spills rather than emit a key twice. When thePartialMerge's input is sorted on a prefix of its grouping keys, DataFusion 55.2 doesn't runSingleHashAggregateStreamforSingle. It falls back to the legacyGroupedHashAggregateStream, whose spill asks for more than its share of the pool to sort the batch it writes, and the task fails withFailed to reserve memory for sort during spill. This is a regression since 1.1.0, which ran this shape asPartialand returned a wrongcount(DISTINCT)instead (#6857).What changes are included in this PR?
PartialMergeas DataFusionFinalwhen DataFusion sees its input sorted on some of its keys (InputOrderModeis notLinear).FinalrunsOrderedFinalAggregateStreamthere, which spills and reads its spill files back.MergeAsPartialevaluates to its state underFinalas it does underSingle, so the projection that unpacks the states doesn't change.Finalreads each state from the columns right after the grouping keys, and it writes its spill files with the input's column types. So the planner switches only when the grouping keys are the first input columns,initial_input_buffer_offsetequals the number of keys, and the input's state columns have the native state types. Otherwise it keepsSingle. For example, a Spark partial'scollect_listbuffers arrive as binary and keepSingle. That input reaches native code through aScanExecwith no ordering, so it isn't expected to be sorted on a key.limited_to_its_shareexempts every consumer namedOrderedFinalAggregateStreamfrom the fair limit, the limit the legacy stream's spill failed against, so thisPartialMergeis checked only against the pool and Spark's grant. Thelimited_to_its_sharedoc andmemory_management.mdnow say the exemption covers this case.SpillReplayPoolalready coversOrderedFinalAggregateStream, so reading the spill files back past the stream's share works as it does for the A native final aggregate that has spilled can fail the task during its replay #6254 final aggregates. Doc comments inmerge_as_partial.rsandspill_replay.rsnow mentionFinal.When Comet moves to DataFusion 56 (#6410), which replaces this fallback with
OrderedSingleAggregateStream, the switch can probably be dropped.How are these changes tested?
CometAggregateSuitetests write 125,000 rows over four values ofgand check one row perg(max(v)andcount_distinct(k)), so a key emitted twice across a change ofgshows up in the answer. Both check that thePartialMergespilled.repartition(1, g),sortWithinPartitions(g)withEliminateSortsexcluded, in 2 MiB and 8 MiB pools.gwith the default rules and AQE off, in a 16 MiB pool, which is the shape users get. The sorts under the join need more than a 2 or 8 MiB pool's share.Without the planner change, every case fails with the issue's error. With a 32 MiB pool the ordered final aggregate fits in memory and doesn't spill, so the tests don't use it.
PartialMergeover a native sort on its first key, or on both keys, plans asFinalwithPartiallySortedorSortedinput and emits the merged max and count states. It also checks that acollect_listPartialMergeover binary Spark buffers staysSingle.CometAggregateSuite(164 tests) and the native core library tests pass locally.