mbutrovich commented on code in PR #6095:
URL: https://github.com/apache/datafusion-comet/pull/6095#discussion_r4075724191
##########
native/core/src/execution/planner.rs:
##########
@@ -3652,15 +3652,26 @@ impl PhysicalPlanner {
}
PartitioningStruct::SinglePartition(_) =>
Ok(CometPartitioning::SinglePartition),
PartitioningStruct::RoundRobinPartition(rr_partition) => {
- // Treat negative max_hash_columns as 0 (no limit)
- let max_hash_columns = if rr_partition.max_hash_columns <= 0 {
- 0
+ let strategy = if rr_partition.positional {
+ // The Spark map partition id, not the DataFusion one:
`jni_api` runs every
+ // native root plan with partition 0 (one Comet execution
per Spark task), so
+ // `ShuffleWriterExec::execute` cannot supply it. See
+ // `RoundRobinStrategy::RowGroups` for why it has to be
this value.
+ RoundRobinStrategy::RowGroups {
+ start_partition: self.partition.max(0) as usize,
Review Comment:
Using the map partition id as the start keeps retries reproducible, but
consecutive tasks start on consecutive partitions. That's the correlation Spark
fixed in SPARK-21782, which is why Spark scrambles the start with `new
XORShiftRandom(partitionId).nextInt(numPartitions)`
([Spark](https://github.com/apache/spark/blob/582e28bf7074598ac85a0b7de9c5916c44cc5661/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/ShuffleExchangeExec.scala#L428-L442)).
Comet's JVM path does the same
([`CometShuffleExchangeExec.scala`](https://github.com/apache/datafusion-comet/blob/e99307b9df2620a743239abfc032f539f21e7042/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometShuffleExchangeExec.scala#L1116-L1129)).
With adjacent starts, every task's run of partitions overlaps its neighbours',
and the partitions past `numMapTasks + groupsPerTask` get nothing. Ten map
tasks of 5,000 rows into 200 partitions at the auto group of 64 leave 112
reducers empty with adjacent start
s and none with scrambled starts.
What do you think about computing the start in
`CometNativeShuffleWriter.buildUnifiedPlan`, which already runs per task with
`context` in scope, and passing it in the proto? `new
XORShiftRandom(context.partitionId()).nextInt(numPartitions)` is still a pure
function of the map partition, so retries stay safe. The planner would also
stop depending on `self.partition`, which removes the `jni_api` partition-0
caveat in the comment above. Spark increments before its first use, so a start
of `nextInt(numPartitions) + 1` would make `groupRows = 1` place rows the way
Spark does for the same row order. A native test that runs several map tasks
with a small number of groups each and asserts the stage-wide spread would
cover this.
##########
native/shuffle/src/comet_partitioning.rs:
##########
@@ -19,6 +19,132 @@ use arrow::row::{OwnedRow, RowConverter};
use datafusion::physical_expr::{LexOrdering, PhysicalExpr};
use std::sync::Arc;
+/// How [`CometPartitioning::RoundRobin`] decides which output partition a row
belongs to.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RoundRobinStrategy {
+ /// Hash each row over its leading `max_hash_columns` columns (`0` meaning
all of them) and
+ /// place it at `pmod(hash, num_partitions)`.
+ ///
+ /// Placement is a pure function of a row's contents, so a re-executed map
task reproduces it
+ /// no matter what its input does. The price is a murmur3 pass per row
that recurses into
+ /// every struct child, plus a per-row gather on flush because adjacent
rows scatter across
+ /// every partition. It is also not really round robin: identical rows
always hash to the same
+ /// partition, so low-cardinality input skews where Spark's round robin
spreads evenly.
+ HashAll { max_hash_columns: usize },
+
+ /// Place rows positionally, in contiguous groups of `group_rows` rows,
counting rows across
+ /// input batch boundaries: the row at task-global ordinal `i` goes to
output partition
+ /// `(start_partition + i / group_rows) % num_partitions`.
+ ///
+ /// This is Spark's own round robin at a coarser granularity — Spark seeds
a counter with
+ /// `XORShiftRandom(partitionId)` and bumps it per row, which is the
`group_rows == 1` case —
+ /// and it inherits Spark's determinism condition exactly: placement is
reproducible when the
+ /// upstream operator replays rows in the same *order*. It deliberately
does not depend on how
+ /// those rows are framed into batches, because no Spark contract covers
framing;
+ /// `DeterministicLevel::DETERMINATE` promises the same rows in the same
order and says
+ /// nothing about how a downstream operator chunks them, so an operator
that spills can reframe
+ /// under different memory pressure while still honouring it. Keying on a
row ordinal rather
+ /// than a batch ordinal is what lets this strategy rely on the level
Spark already publishes
+ /// instead of an assumption nothing checks.
+ ///
+ /// `start_partition` must be the Spark map partition id. It has to be
distinct across mappers,
+ /// or every task starts at partition 0 and a task emitting fewer groups
than there are output
+ /// partitions leaves the tail empty stage-wide; and it has to be a pure
function of the map
+ /// partition, or a re-executed task does not reproduce its own placement.
Spark seeds
+ /// `XORShiftRandom(partitionId)` for the same two reasons.
+ ///
Review Comment:
The doc comment says a distinct start per mapper prevents an empty tail, and
that Spark seeds `XORShiftRandom(partitionId)` for the same reason. Distinct
isn't enough. Adjacent starts still leave the tail empty whenever the number of
map tasks plus the groups per task is less than the partition count (see the
comment on `planner.rs`). Spark's reason for the random seed is to decorrelate
the starts. Could this say that, whichever start function you end up with? The
same text is in `native_shuffle.md` lines 351-356.
##########
spark/src/main/scala/org/apache/comet/CometConf.scala:
##########
@@ -475,6 +475,42 @@ object CometConf extends ShimCometConf {
"The maximum number of columns to hash for round robin partitioning
must be non-negative.")
.createWithDefault(0)
+ val COMET_SHUFFLE_NATIVE_ROUND_ROBIN_PARTITIONING_POSITIONAL_ENABLED:
ConfigEntry[Boolean] =
+
conf("spark.comet.shuffle.native.partitioning.roundrobin.positional.enabled")
+ .category(CATEGORY_SHUFFLE)
+ .doc(
+ "When true, Comet's native round-robin shuffle places rows by position
rather than by " +
+ "hashing their contents, the way Spark's own round robin does: the
row at " +
+ "task-global ordinal i goes to output partition " +
+ "(mapPartitionId + i / groupRows) % numPartitions. This skips a
murmur3 pass over " +
+ "every column of every row and replaces the per-row gather on flush
with a bulk copy " +
+ "per run, which is what dominates the shuffle write on wide nested
schemas. It also " +
+ "spreads duplicate rows evenly, where hashing sends them all to one
partition. " +
+ "Positional placement is only reproducible when the map task replays
rows in the " +
+ "same order, so it is used only where Comet can establish that from
the plan: a " +
+ "native scan under nothing but projections and filters. Any other
plan silently " +
+ "keeps content-hash placement. " +
+ s"Has no effect unless
${COMET_SHUFFLE_NATIVE_ROUND_ROBIN_PARTITIONING_ENABLED.key} " +
+ "is also true.")
+ .booleanConf
+ .createWithDefault(false)
+
+ val COMET_SHUFFLE_NATIVE_ROUND_ROBIN_PARTITIONING_POSITIONAL_GROUP_ROWS:
ConfigEntry[Int] =
+
conf("spark.comet.shuffle.native.partitioning.roundrobin.positional.groupRows")
+ .category(CATEGORY_SHUFFLE)
+ .doc(
+ "Rows per contiguous group under positional round robin. Imbalance
between any two " +
+ "output partitions is bounded by this many rows however the reader
frames its " +
+ "batches, so smaller groups balance better while larger groups
produce fewer, longer " +
Review Comment:
"Imbalance between any two output partitions is bounded by this many rows"
is true for the rows one map task writes, but a reducer sees the sum over all
map tasks. With 50 map tasks and `groupRows = 8192`, the simulation in the
review body gives one reducer 409,600 rows and another 0. This is user-facing
documentation, so could it say "within one map task", and say that stage-wide
balance needs each task to emit many more groups than there are output
partitions? The same sentence is in `comet_partitioning.rs` lines 56-58 and
`native_shuffle.md` lines 358-359.
##########
native/shuffle/src/comet_partitioning.rs:
##########
@@ -19,6 +19,132 @@ use arrow::row::{OwnedRow, RowConverter};
use datafusion::physical_expr::{LexOrdering, PhysicalExpr};
use std::sync::Arc;
+/// How [`CometPartitioning::RoundRobin`] decides which output partition a row
belongs to.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RoundRobinStrategy {
+ /// Hash each row over its leading `max_hash_columns` columns (`0` meaning
all of them) and
+ /// place it at `pmod(hash, num_partitions)`.
+ ///
+ /// Placement is a pure function of a row's contents, so a re-executed map
task reproduces it
+ /// no matter what its input does. The price is a murmur3 pass per row
that recurses into
+ /// every struct child, plus a per-row gather on flush because adjacent
rows scatter across
+ /// every partition. It is also not really round robin: identical rows
always hash to the same
+ /// partition, so low-cardinality input skews where Spark's round robin
spreads evenly.
+ HashAll { max_hash_columns: usize },
+
+ /// Place rows positionally, in contiguous groups of `group_rows` rows,
counting rows across
+ /// input batch boundaries: the row at task-global ordinal `i` goes to
output partition
+ /// `(start_partition + i / group_rows) % num_partitions`.
+ ///
+ /// This is Spark's own round robin at a coarser granularity — Spark seeds
a counter with
+ /// `XORShiftRandom(partitionId)` and bumps it per row, which is the
`group_rows == 1` case —
+ /// and it inherits Spark's determinism condition exactly: placement is
reproducible when the
+ /// upstream operator replays rows in the same *order*. It deliberately
does not depend on how
+ /// those rows are framed into batches, because no Spark contract covers
framing;
+ /// `DeterministicLevel::DETERMINATE` promises the same rows in the same
order and says
+ /// nothing about how a downstream operator chunks them, so an operator
that spills can reframe
+ /// under different memory pressure while still honouring it. Keying on a
row ordinal rather
+ /// than a batch ordinal is what lets this strategy rely on the level
Spark already publishes
+ /// instead of an assumption nothing checks.
+ ///
+ /// `start_partition` must be the Spark map partition id. It has to be
distinct across mappers,
+ /// or every task starts at partition 0 and a task emitting fewer groups
than there are output
+ /// partitions leaves the tail empty stage-wide; and it has to be a pure
function of the map
+ /// partition, or a re-executed task does not reproduce its own placement.
Spark seeds
+ /// `XORShiftRandom(partitionId)` for the same two reasons.
+ ///
+ /// `group_rows` trades balance against copying. Imbalance between any two
output partitions is
+ /// bounded by `group_rows` rows regardless of how the reader frames
batches, so small groups
+ /// balance better; large groups produce fewer, longer runs to copy on
flush, and a group as
+ /// large as the batch size lets a whole input batch pass through to one
partition untouched.
+ /// [`Self::AUTO_GROUP_ROWS`] picks a value from the batch size and
partition count.
+ RowGroups {
+ start_partition: usize,
+ group_rows: usize,
+ },
+}
+
+impl Default for RoundRobinStrategy {
+ /// Hashing every column, which is what Comet's round robin did before
`RowGroups` existed.
+ fn default() -> Self {
+ Self::HashAll {
+ max_hash_columns: 0,
+ }
+ }
+}
+
+impl RoundRobinStrategy {
+ /// `group_rows` sentinel asking for a value derived from the batch size
and partition count.
+ pub const AUTO_GROUP_ROWS: usize = 0;
+
+ /// Smallest automatically chosen group. A multiple of 8 so that a run
starts on a byte
+ /// boundary of a validity bitmap, which keeps the per-run copy a memcpy
rather than a
+ /// bit-shift for every column.
+ const MIN_AUTO_GROUP_ROWS: usize = 64;
Review Comment:
The multiple-of-8 rationale only holds when every input batch starts on a
group boundary. `row_seq` counts rows across batches, so after a
`CometFilterExec` under the exchange, which the allowlist admits, a batch
starts at an arbitrary ordinal and its first run ends at an arbitrary offset.
Every run after it in that batch starts off a byte boundary. The same happens
with any explicit `groupRows` that isn't a multiple of 8. Is the 64-row floor
there to cap the run count instead? If so, could the comment say that?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]