Repository navigation
fix: stop gating columnar shuffle on native-serde checks it never uses #6110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f4050db
72db56b
400afff
bfb9600
7a34a5f
a7b75e1
d620618
57e637a
a40b21a
1924b31
aa00ed0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -819,11 +819,13 @@ object CometShuffleExchangeExec | |
| val partitioning = s.outputPartitioning | ||
| partitioning match { | ||
| case HashPartitioning(expressions, _) => | ||
| for (expr <- expressions) { | ||
| if (QueryPlanSerde.exprToProto(expr, inputs).isEmpty) { | ||
| reasons += s"unsupported hash partitioning expression: $expr" | ||
| } | ||
| } | ||
| // Not a shuffle-correctness check: the partition id here comes from Spark's own | ||
| // collation-aware Murmur3Hash, evaluated on the JVM. The rejection keeps the stage off | ||
| // Comet, and CometCollationSuite treats it as the primary line of defense for collated | ||
| // keys and pins the reasons it emits (#1947). It is not the only guard any more: since | ||
| // #6206 supportedSortType declines a non-default collation anywhere in a sort key, and | ||
| // the aggregate serdes decline one as well. It covers hash and range partitioning only; | ||
| // SinglePartition and round robin have no such check. | ||
| for (dt <- expressions.map(_.dataType).distinct) { | ||
| if (isStringCollationType(dt)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for tracing this through so carefully. The rationale really helped. One question about the collation checks you kept. The argument for dropping the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right that nothing native sees the key: the partition id comes from Spark's Murmur3Hash / LazilyGeneratedOrdering on the JVM, and both are collation-aware. But I believe the checks still can't go. Rejecting the exchange is what moves the whole stage off Comet, and that also keeps CometSort off the collated key. supportedSortType only type-checks single-column sorts (QueryPlanSerde.scala:1287), so a multi-column collated sort gets past it. I removed both checks and ran CometCollationSuite: 4 failures. Three only change the reason string the test pins, since the query still falls back via the sort check. The fourth is a wrong answer:
The plan keeps CometColumnarExchange and CometSort over a two-column collated sort key, so Comet dedups a/A on raw bytes and #1947 is back. Kept them, and rewrote the rationale in the description – my "unreachable" claim there was wrong. Also added a test for the fallback, which the suite didn't have. I'll file a separate issue for the single-column limit in supportedSortType; fixing that is what would make this check redundant. |
||
| reasons += s"unsupported hash partitioning data type for columnar shuffle: $dt" | ||
|
|
@@ -834,11 +836,8 @@ object CometShuffleExchangeExec | |
| case RoundRobinPartitioning(_) => | ||
| // we already checked that the input types are supported | ||
| case RangePartitioning(orderings, _) => | ||
| for (o <- orderings) { | ||
| if (QueryPlanSerde.exprToProto(o, inputs).isEmpty) { | ||
| reasons += s"unsupported range partitioning sort order: $o" | ||
| } | ||
| } | ||
| // Same as the hash branch above: LazilyGeneratedOrdering already orders collated strings | ||
| // correctly here, and the rejection is the shuffle-level guard CometCollationSuite pins. | ||
| for (dt <- orderings.map(_.dataType).distinct) { | ||
| if (isStringCollationType(dt)) { | ||
| reasons += s"unsupported range partitioning data type for columnar shuffle: $dt" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,7 +34,7 @@ import org.apache.spark.sql.execution.SQLExecution | |
| import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanHelper, AQEShuffleReadExec, ShuffleQueryStageExec} | ||
| import org.apache.spark.sql.execution.exchange.{ReusedExchangeExec, ShuffleExchangeExec} | ||
| import org.apache.spark.sql.execution.joins.SortMergeJoinExec | ||
| import org.apache.spark.sql.functions.col | ||
| import org.apache.spark.sql.functions.{col, spark_partition_id, udf} | ||
| import org.apache.spark.sql.internal.SQLConf | ||
| import org.apache.spark.sql.types._ | ||
|
|
||
|
|
@@ -166,10 +166,11 @@ abstract class CometColumnarShuffleSuite extends CometTestBase with AdaptiveSpar | |
| } | ||
|
|
||
| test("columnar shuffle on array/struct map key/value") { | ||
| // Spark 4.0 normalizes maps used as shuffle keys with mapsort(...). Comet's map_sort | ||
| // relies on Arrow's sort_to_indices, which only supports scalar key types, so a map | ||
| // with array or struct keys cannot be sorted natively and the shuffle falls back. | ||
| val complexKeyShuffles = if (isSpark40Plus) 0 else 1 | ||
| // Spark 4.0 normalizes maps used as shuffle keys with mapsort(...), which Comet cannot | ||
| // serialize for array or struct map keys. That used to disqualify columnar shuffle, but the | ||
| // columnar path computes partition ids on the JVM from h.partitionIdExpression, mapsort and | ||
| // all, so the verdict never applied to it (#5971). Partition assignment is pinned below. | ||
| val complexKeyShuffles = 1 | ||
| Seq("false", "true").foreach { execEnabled => | ||
| Seq(10, 201).foreach { numPartitions => | ||
| Seq("1.0", "10.0").foreach { ratio => | ||
|
|
@@ -817,12 +818,9 @@ abstract class CometColumnarShuffleSuite extends CometTestBase with AdaptiveSpar | |
| * exchange operators. | ||
| */ | ||
| test("range partitioning on floating-point uses columnar shuffle under strictFloatingPoint") { | ||
| // The columnar path partitions on the JVM with Spark's own RangePartitioner, but it still | ||
| // probes whether Comet can serialize the sort order. Before #5506 that probe reported | ||
| // Incompatible for scalar float and double under strict mode, so this exchange fell back to | ||
| // Spark's shuffle for no compatibility reason. Consulting a native-serde gate on a path that | ||
| // never goes native is tracked separately in #5971; this test pins the floating-point half of | ||
| // it, which #5506 fixed. | ||
| // Before #5506 the columnar path reported Incompatible for scalar float and double under | ||
| // strict mode, so this exchange fell back to Spark's shuffle for no compatibility reason. | ||
| // #5971 removed the native-serde probe that caused it; the nested case is covered below. | ||
| withSQLConf( | ||
| CometConf.COMET_EXEC_STRICT_FLOATING_POINT.key -> "true", | ||
| CometConf.getExprAllowIncompatConfigKey("SortOrder") -> "false") { | ||
|
|
@@ -838,6 +836,107 @@ abstract class CometColumnarShuffleSuite extends CometTestBase with AdaptiveSpar | |
| } | ||
| } | ||
|
|
||
| // The struct is orderable by the JVM, but CometSortOrder reports Incompatible for it: its | ||
| // fields are nullable, so canHoldNestedNull holds, and with the default null ordering the rule | ||
| // falls through to the strict floating-point case (#6476). The columnar path never sends the | ||
| // sort order to native code, so that verdict must not gate it (#5971). | ||
| test("range partitioning on a nested floating-point key uses columnar shuffle") { | ||
| withSQLConf( | ||
| CometConf.COMET_EXEC_STRICT_FLOATING_POINT.key -> "true", | ||
| CometConf.getExprAllowIncompatConfigKey("SortOrder") -> "false") { | ||
| withParquetTable((0 until 20).map(i => (i.toDouble, i)), "range_struct_tbl") { | ||
| val df = sql("SELECT struct(_1 AS a, _2 AS b) AS c FROM range_struct_tbl") | ||
| .repartitionByRange(4, col("c")) | ||
| checkShuffleAnswer(df, 1) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // A Scala UDF has no serde once the codegen dispatcher is off, so exprToProto returns None for | ||
| // it. The columnar path evaluates partition keys on the JVM through UnsafeProjection, so that | ||
| // verdict must not gate the exchange (#5971). Turning the dispatcher off is only a cheap and | ||
| // stable way to obtain an unserializable partition key; any expression without a serde reaches | ||
| // the same gate, and unlike the strictFloatingPoint cases above nothing here depends on strict | ||
| // mode. | ||
| test("range partitioning on an unserializable expression uses columnar shuffle") { | ||
| withSQLConf(CometConf.COMET_SCALA_UDF_CODEGEN_ENABLED.key -> "false") { | ||
| withParquetTable((0 until 20).map(i => (i, i.toString)), "range_udf_tbl") { | ||
| val bump = udf((x: Int) => x + 1) | ||
| val df = sql("SELECT _1, _2 FROM range_udf_tbl").repartitionByRange(4, bump(col("_1"))) | ||
| checkShuffleAnswer(df, 1) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| test("hash partitioning on an unserializable expression uses columnar shuffle") { | ||
| withSQLConf(CometConf.COMET_SCALA_UDF_CODEGEN_ENABLED.key -> "false") { | ||
| withParquetTable((0 until 20).map(i => (i, i.toString)), "hash_udf_tbl") { | ||
| val bump = udf((x: Int) => x + 1) | ||
| val df = sql("SELECT _1, _2 FROM hash_udf_tbl").repartition(4, bump(col("_1"))) | ||
| checkShuffleAnswer(df, 1) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| test("collation introduced above the scan still falls back to Spark's shuffle") { | ||
| assume(isSpark40Plus, "string collation requires Spark 4.0+") | ||
| withParquetTable((0 until 20).map(i => (i, (i % 4).toString)), "coll_above_tbl") { | ||
| // The stored column is a plain string, so CometScanRule keeps the scan native and the | ||
| // collation is applied in a Project above it. The shuffle itself would be fine -- the | ||
| // partition id comes from Spark's collation-aware Murmur3Hash on the JVM -- but the | ||
| // columnar gate declines a collated hash key, and CometCollationSuite pins the reasons it | ||
| // emits (#1947). | ||
| val df = sql("SELECT _1, _2 COLLATE UTF8_LCASE AS c FROM coll_above_tbl") | ||
| .repartition(4, col("c")) | ||
| checkShuffleAnswer(df, 0) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * checkShuffleAnswer only compares the query answer, which is order-insensitive and so would | ||
| * pass even if Comet routed rows to different partitions than Spark. Compare | ||
| * spark_partition_id() per row instead, and pin the Comet run to one CometShuffleExchangeExec: | ||
| * without that, a future fallback would leave both sides on plain Spark and the comparison | ||
| * would pass while testing nothing. | ||
| */ | ||
| private def checkPartitionAssignmentMatchesSpark(df: => DataFrame, clue: String): Unit = { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This helper only compares the Comet run with the Spark run. If a future change makes these queries fall back, both sides are plain Spark and the test still passes. Could the helper also assert one
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, that would have passed while testing nothing. Added |
||
| checkCometExchange(df, 1, false) | ||
|
|
||
| def pids: Array[(Int, Int)] = | ||
| df.select(col("_1"), spark_partition_id().as("pid")) | ||
| .collect() | ||
| .map(r => (r.getInt(0), r.getInt(1))) | ||
| .sorted | ||
|
|
||
| val cometRows = pids | ||
| var sparkRows: Array[(Int, Int)] = Array.empty | ||
| withSQLConf(CometConf.COMET_ENABLED.key -> "false") { | ||
| sparkRows = pids | ||
| } | ||
| assert(sparkRows.nonEmpty, "Spark produced no rows; the comparison would be vacuous") | ||
| assert(cometRows === sparkRows, s"partition assignment differs from Spark for $clue") | ||
| } | ||
|
|
||
| test("columnar shuffle partition assignment matches Spark for keys Comet cannot serialize") { | ||
| withSQLConf(CometConf.COMET_SCALA_UDF_CODEGEN_ENABLED.key -> "false") { | ||
| withParquetTable((0 until 100).map(i => (i, i % 7)), "assign_udf_tbl") { | ||
| val bump = udf((x: Int) => x % 5) | ||
| checkPartitionAssignmentMatchesSpark( | ||
| sql("SELECT * FROM assign_udf_tbl").repartition(8, bump(col("_2"))), | ||
| "a UDF hash key") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| test("columnar shuffle partition assignment matches Spark for an array/struct map key") { | ||
| assume(isSpark40Plus, "mapsort normalization of map shuffle keys requires Spark 4.0+") | ||
| withParquetTable((0 until 100).map(i => (Map(Seq(i % 9, i % 4) -> i), i)), "assign_map_tbl") { | ||
| checkPartitionAssignmentMatchesSpark( | ||
| sql("SELECT _2 AS _1, _1 AS m FROM assign_map_tbl").repartition(8, col("m")), | ||
| "an array/struct map hash key") | ||
| } | ||
| } | ||
|
|
||
| private def checkShuffleAnswer(df: DataFrame, expectedNum: Int): Unit = { | ||
| checkCometExchange(df, expectedNum, false) | ||
| checkSparkAnswer(df) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the doc updates. One gap I noticed. This new case 4 says native shuffle declines a key expression Comet can't serialize. The "When Native Shuffle is Used" list in
native_shuffle.mdsays native is chosen "when all of the following conditions are met", but it has no such condition. Could you add a fifth item there saying every hash partitioning expression and range sort order has to convert throughexprToProto, with the samemapsortexample? Then the two docs agree on when native is picked.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch. Added item 5 to "When Native Shuffle is Used": native shuffle serializes the partitioning into its protobuf plan, so every HashPartitioning expression and every RangePartitioning sort order has to convert through QueryPlanSerde.exprToProto.
For the mapsort example I named CometMapSort explicitly, since item 4 mentions mapsort as what admits map keys and the two could otherwise look contradictory: the type is admissible with nested hash keys enabled, but CometMapSort supports scalar map keys only, so a map with array or struct keys fails the expression check. That matches the comment already in supportedHashPartitioningDataType. Cross-linked to jvm_shuffle.md so the two lists agree.
The workflows will need approving again.