sunchao commented on code in PR #7155:
URL: https://github.com/apache/datafusion-comet/pull/7155#discussion_r4367710634
##########
spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala:
##########
@@ -1178,10 +1178,96 @@ case class CometExecRule(session: SparkSession,
queryStagePrep: Boolean = false)
serde.convert(op, builder)
}
}
- return nativeOp.map { nativeOp =>
- val exec = serde.createExec(nativeOp, op)
- rollUpInfoMessages(op, exec)
- exec
+ return nativeOp
+ .filter { proto =>
+ // A serde may emit several native nodes for one Spark operator.
Inspect those nodes,
+ // but stop at real Spark children, whose consumption context was
tagged independently.
+ def containsOwnAssertion(node: Operator): Boolean =
+
CometAssertNotNull.containsNativeAssertion(node.toBuilder.clearChildren().build())
||
+ node.getChildrenList.asScala.exists { child =>
+ child.getPlanId == proto.getPlanId &&
containsOwnAssertion(child)
+ }
+
+ def hasNativeAssertion: Boolean = op match {
+ // TopK's sink proto omits its runtime projection over the
selected rows.
+ case topK: TakeOrderedAndProjectExec =>
+ QueryPlanSerde.withSharedSubexpressions(topK.projectList) {
+ topK.projectList.exists { expression =>
+ QueryPlanSerde
+ .exprToProto(expression, topK.child.output)
+ .exists(CometAssertNotNull.containsNativeAssertion)
+ }
+ }
+ case _: HashAggregateExec | _: ObjectHashAggregateExec =>
+ // Input/grouping expressions consume all rows; only the result
projection is lazy.
+ proto.hasProjection &&
CometAssertNotNull.containsNativeAssertion(
+ proto.getProjection)
+ case _ => containsOwnAssertion(proto)
+ }
+ val fallback = nativeAssertionJoinFallback(proto).orElse {
+ op.getTagValue(CometExecRule.PARTIAL_READER)
+ .filter(_ => hasNativeAssertion)
+ .map(reader =>
+ s"Native AssertNotNull below ${reader.description} could
change which values " +
+ "Spark checks")
+ }
+ fallback.foreach(reason => withFallbackReason(op, reason))
+ fallback.isEmpty
+ }
+ .map { nativeOp =>
+ val exec = serde.createExec(nativeOp, op)
+ rollUpInfoMessages(op, exec)
+ exec
+ }
+ }
+ None
+ }
+
+ private def nativeAssertionJoinFallback(proto: Operator): Option[String] = {
+ import OperatorOuterClass.JoinType
+
+ // These consumers can skip candidates even without a LIMIT above the
join. Inspect only
+ // the serialized condition. Child projections use the partial-reader tags
independently;
+ // assertions captured inside opaque JVM dispatch keep their existing
execution path.
+ val condition = if (proto.hasHashJoin) {
+ val join = proto.getHashJoin
+ Some(join.getJoinType -> join.getCondition)
+ } else if (proto.hasSortMergeJoin) {
+ val join = proto.getSortMergeJoin
+ Some(join.getJoinType -> join.getCondition)
+ } else if (proto.hasBroadcastNestedLoopJoin) {
+ val join = proto.getBroadcastNestedLoopJoin
+ Some(join.getJoinType -> join.getCondition)
+ } else None
+
+ if (condition.exists {
+ case (JoinType.LeftSemi | JoinType.LeftAnti, expression) =>
+ CometAssertNotNull.containsNativeAssertion(expression)
+ case _ => false
+ }) {
+ return Some(
+ "Native AssertNotNull in an early-exit join condition could check
candidates Spark skips")
+ }
+
+ if (proto.hasHashJoin) {
+ val join = proto.getHashJoin
+ val leftKeys = join.getLeftJoinKeysList.asScala
+ val rightKeys = join.getRightJoinKeysList.asScala
+ // Empty and all-NULL build relations can skip different streamed keys
in Spark and
+ // DataFusion. Null-aware anti joins also stop building at the first
NULL key in Spark,
+ // while native execution builds on the opposite side and evaluates keys
in batches.
+ val keys = if (join.getNullAwareAntiJoin) {
+ leftKeys ++ rightKeys
+ } else {
+ join.getJoinType match {
+ case JoinType.Inner | JoinType.LeftSemi | JoinType.LeftAnti =>
+ if (join.getBuildSide == OperatorOuterClass.BuildSide.BuildRight)
leftKeys
Review Comment:
[P2] Validate asserted broadcast build keys when materializing the
broadcast. For `BuildRight`, this guard checks only the left keys. Spark
evaluates the right key to build its hashed relation. Comet broadcasts batches
and evaluates the key inside a join task. If the streamed input has zero
partitions, there is no task to raise the required error. Keep asserted build
keys on Spark or validate them at broadcast materialization with Spark’s
structured error.
Evidence: Read `z(k INT)` from an empty Parquet directory using an explicit
schema. Broadcast `b(k INT)` containing one null and run `SELECT /*+
BROADCAST(b) */ * FROM z JOIN b ON z.k=ann(b.k)`, where `ann` is Catalyst
`AssertNotNull`. Spark 4.1.3 and the base raised `NOT_NULL_ASSERT_VIOLATION`.
The head returned zero rows with codegen on and off. A nonempty streamed-side
control using a left semi join raised a generic `CometNativeException` on the
head and retained SQLSTATE `42000` on the references.
##########
native/spark-expr/src/predicate_funcs/assert_not_null.rs:
##########
@@ -0,0 +1,213 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::fmt::{Display, Formatter};
+use std::hash::Hash;
+use std::sync::Arc;
+
+use arrow::array::new_empty_array;
+use arrow::datatypes::{DataType, Schema};
+use arrow::record_batch::RecordBatch;
+use datafusion::common::Result;
+use datafusion::logical_expr::ColumnarValue;
+use datafusion::physical_expr::PhysicalExpr;
+use datafusion_comet_common::SparkError;
+
+/// Spark's runtime nullability check. Only the value itself is checked, not
its nested fields.
+#[derive(Debug, Eq)]
+pub struct AssertNotNull {
+ child: Arc<dyn PhysicalExpr>,
+ walked_type_path: String,
+}
+
+impl AssertNotNull {
+ pub fn new(child: Arc<dyn PhysicalExpr>, walked_type_path: String) -> Self
{
+ Self {
+ child,
+ walked_type_path,
+ }
+ }
+}
+
+impl PartialEq for AssertNotNull {
+ fn eq(&self, other: &Self) -> bool {
+ self.child.eq(&other.child) && self.walked_type_path ==
other.walked_type_path
+ }
+}
+
+impl Hash for AssertNotNull {
+ fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+ self.child.hash(state);
+ self.walked_type_path.hash(state);
+ }
+}
+
+impl Display for AssertNotNull {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(
+ f,
+ "AssertNotNull({}, {:?})",
+ self.child, self.walked_type_path
+ )
+ }
+}
+
+impl PhysicalExpr for AssertNotNull {
+ fn fmt_sql(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ Display::fmt(self, f)
+ }
+
+ fn data_type(&self, input_schema: &Schema) -> Result<DataType> {
+ self.child.data_type(input_schema)
+ }
+
+ fn nullable(&self, _input_schema: &Schema) -> Result<bool> {
+ Ok(false)
+ }
+
+ fn evaluate(&self, batch: &RecordBatch) -> Result<ColumnarValue> {
+ // There is no row to check, including when a conditional filters out
every row.
+ if batch.num_rows() == 0 {
+ return Ok(ColumnarValue::Array(new_empty_array(
+ &self.data_type(batch.schema().as_ref())?,
+ )));
+ }
+ let value = self.child.evaluate(batch)?;
Review Comment:
[P2] Preserve the first error when an asserted child can throw.
`self.child.evaluate(batch)` evaluates later rows before checking whether the
first result is null. In a required-field write, an arithmetic error on row two
can therefore bypass the application’s handler for the null on row one.
Evaluate the child and assertion in row order for this case, or keep assertions
over potentially throwing children in Spark.
Evidence: On Spark 4.1.3 with ANSI enabled, insert `SELECT 10L DIV den FROM
src` into an Iceberg table with one `BIGINT NOT NULL` column. Use one ordered
Parquet batch with denominators `[NULL, 0]`. Spark and the base raised
`NOT_NULL_ASSERT_VIOLATION` (`42000`), and a `42000` exit handler returned
`caught`. The head used `CometProject` feeding `CometIcebergWrite` and raised
`DIVIDE_BY_ZERO` (`22012`) past the handler. All three left zero snapshots.
##########
spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala:
##########
@@ -1178,10 +1178,96 @@ case class CometExecRule(session: SparkSession,
queryStagePrep: Boolean = false)
serde.convert(op, builder)
}
}
- return nativeOp.map { nativeOp =>
- val exec = serde.createExec(nativeOp, op)
- rollUpInfoMessages(op, exec)
- exec
+ return nativeOp
+ .filter { proto =>
+ // A serde may emit several native nodes for one Spark operator.
Inspect those nodes,
+ // but stop at real Spark children, whose consumption context was
tagged independently.
+ def containsOwnAssertion(node: Operator): Boolean =
+
CometAssertNotNull.containsNativeAssertion(node.toBuilder.clearChildren().build())
||
+ node.getChildrenList.asScala.exists { child =>
+ child.getPlanId == proto.getPlanId &&
containsOwnAssertion(child)
+ }
+
+ def hasNativeAssertion: Boolean = op match {
+ // TopK's sink proto omits its runtime projection over the
selected rows.
+ case topK: TakeOrderedAndProjectExec =>
+ QueryPlanSerde.withSharedSubexpressions(topK.projectList) {
+ topK.projectList.exists { expression =>
+ QueryPlanSerde
+ .exprToProto(expression, topK.child.output)
+ .exists(CometAssertNotNull.containsNativeAssertion)
+ }
+ }
+ case _: HashAggregateExec | _: ObjectHashAggregateExec =>
+ // Input/grouping expressions consume all rows; only the result
projection is lazy.
+ proto.hasProjection &&
CometAssertNotNull.containsNativeAssertion(
+ proto.getProjection)
+ case _ => containsOwnAssertion(proto)
+ }
+ val fallback = nativeAssertionJoinFallback(proto).orElse {
+ op.getTagValue(CometExecRule.PARTIAL_READER)
+ .filter(_ => hasNativeAssertion)
+ .map(reader =>
+ s"Native AssertNotNull below ${reader.description} could
change which values " +
+ "Spark checks")
+ }
+ fallback.foreach(reason => withFallbackReason(op, reason))
+ fallback.isEmpty
+ }
+ .map { nativeOp =>
+ val exec = serde.createExec(nativeOp, op)
+ rollUpInfoMessages(op, exec)
+ exec
+ }
+ }
+ None
+ }
+
+ private def nativeAssertionJoinFallback(proto: Operator): Option[String] = {
+ import OperatorOuterClass.JoinType
+
+ // These consumers can skip candidates even without a LIMIT above the
join. Inspect only
+ // the serialized condition. Child projections use the partial-reader tags
independently;
+ // assertions captured inside opaque JVM dispatch keep their existing
execution path.
+ val condition = if (proto.hasHashJoin) {
+ val join = proto.getHashJoin
+ Some(join.getJoinType -> join.getCondition)
+ } else if (proto.hasSortMergeJoin) {
+ val join = proto.getSortMergeJoin
+ Some(join.getJoinType -> join.getCondition)
+ } else if (proto.hasBroadcastNestedLoopJoin) {
+ val join = proto.getBroadcastNestedLoopJoin
+ Some(join.getJoinType -> join.getCondition)
+ } else None
+
+ if (condition.exists {
+ case (JoinType.LeftSemi | JoinType.LeftAnti, expression) =>
+ CometAssertNotNull.containsNativeAssertion(expression)
+ case _ => false
+ }) {
+ return Some(
+ "Native AssertNotNull in an early-exit join condition could check
candidates Spark skips")
+ }
+
+ if (proto.hasHashJoin) {
+ val join = proto.getHashJoin
+ val leftKeys = join.getLeftJoinKeysList.asScala
+ val rightKeys = join.getRightJoinKeysList.asScala
+ // Empty and all-NULL build relations can skip different streamed keys
in Spark and
+ // DataFusion. Null-aware anti joins also stop building at the first
NULL key in Spark,
+ // while native execution builds on the opposite side and evaluates keys
in batches.
+ val keys = if (join.getNullAwareAntiJoin) {
+ leftKeys ++ rightKeys
+ } else {
+ join.getJoinType match {
+ case JoinType.Inner | JoinType.LeftSemi | JoinType.LeftAnti =>
+ if (join.getBuildSide == OperatorOuterClass.BuildSide.BuildRight)
leftKeys
+ else rightKeys
+ case _ => Seq.empty
Review Comment:
[P2] Preserve short-circuit evaluation of packed outer-join keys. `case _ =>
Seq.empty` admits every asserted outer hash-join key. Spark’s
`HashJoin.rewriteKeyExpr` packs two `INT` keys into one null-propagating `LONG`
expression. A null first key skips the second. Comet evaluates the component
keys independently and can fail a left outer join that should return its
unmatched row. Decline assertions in skippable packed-key components or
preserve Spark’s packed evaluation.
Evidence: Use Parquet inputs `l(a,b)=[(NULL,NULL)]` and `r(c,d)=[(1,2)]`,
all `INT`. Run `SELECT /*+ BROADCAST(r) */ * FROM l LEFT JOIN r ON l.a=r.c AND
ann(l.b)=r.d`, with `ann` bound to Catalyst `AssertNotNull`. Spark 4.1.3 and
the base returned `[NULL,NULL,NULL,NULL]`. The head selected
`CometBroadcastHashJoin` and raised `NOT_NULL_ASSERT_VIOLATION` with
whole-stage codegen both on and off.
##########
spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala:
##########
@@ -1328,37 +1414,74 @@ case class CometExecRule(session: SparkSession,
queryStagePrep: Boolean = false)
allExecs.get(op.getClass).exists(_.enabledConfig.forall(_.get(op.conf)))
/**
- * Tags each `SerializeFromObjectExec` whose output an operator above it can
stop reading early
- * with that operator, so `transform` does not convert it. Spark computes
the rows of a typed
- * Dataset operation one at a time, as the operator above reads them, while
the conversion fills
- * a whole Arrow batch first. Below a limit, a `mapPartitions` function such
as `_.take(1)`, or
- * code reading `Dataset.rdd`, the conversion would run the user function on
rows Spark never
- * reaches, and a function that throws on one of them would fail a query
that succeeds in Spark.
- * An operator that reads all of its input before it returns a row ends the
search, since Spark
- * computes every row below it anyway: an exchange, a sort, a hash
aggregate, or a top-k over
- * input that is not already sorted.
+ * Tags operators whose output a consumer can skip evaluating. Spark
computes rows as the
+ * consumer reads them, while native expressions and typed Dataset
conversion fill a whole Arrow
+ * batch first. Below a limit, a `mapPartitions` function such as
`_.take(1)`, or code reading
+ * `Dataset.rdd`, an assertion or user function could throw on rows Spark
never reaches. Joins
+ * can also skip an input, and generated projections, filters, samples and
joins can defer child
+ * fields until an expression needs them. Conversely, native joins can skip
input Spark would
+ * evaluate. An operator that reads all of its input before it returns a row
ends the search,
+ * since Spark computes every row below it anyway: an exchange, a sort, a
hash aggregate, or a
+ * top-k over input that is not already sorted.
*
* Conversion is bottom-up, so this runs first. TreeNode tags survive the
child copies made
- * during transformUp, while an identity set would not.
+ * during transformUp, while an identity set would not. Retain established
tags when AQE visits
+ * a fragment without its limiting ancestor.
*/
- private def tagPartiallyReadTypedDatasetOutputs(plan: SparkPlan): Unit = {
- def visit(op: SparkPlan, partialReader: Option[String]): Unit = {
- val childReader = op match {
- case serialize: SerializeFromObjectExec =>
- partialReader.foreach(
- serialize.setTagValue(CometExecRule.TYPED_DATASET_PARTIAL_READER,
_))
- partialReader
- case _: CollectLimitExec | _: LocalLimitExec | _: GlobalLimitExec =>
Some("a limit")
+ private def tagPartiallyReadOutputs(plan: SparkPlan): Unit = {
+ import CometExecRule.PartialReader
+
+ def visit(op: SparkPlan, partialReader: Option[PartialReader]): Unit = {
+ // AQE can revisit a fragment without the ancestor that could skip whole
rows. A new
+ // field-only reader must not weaken that established context or its
child propagation.
+ val inheritedReader = op
+ .getTagValue(CometExecRule.PARTIAL_READER)
+ .filter(_.canSkipInput)
+ .orElse(partialReader)
+ // A repeated transform can already contain Comet limits and consumption
barriers.
+ val original = op match {
+ case comet: CometExec => comet.originalPlan
+ case other => other
+ }
+ val childReader = original match {
+ case _: CollectLimitExec | _: LocalLimitExec | _: GlobalLimitExec =>
Review Comment:
[P2] Carry the scalar-subquery two-row bound into its child plan. Spark
prepares that child separately and calls `executeTake(2)` to detect a
cardinality violation. The child has no explicit limit for this match to
recognize. A native projection can check row three and replace the cardinality
error, changing which SQL handler runs. Propagate the bound during subquery
preparation or keep the affected projection in Spark.
Evidence: With native range enabled, run `SELECT (SELECT
ann(IF(id<2,id,CAST(NULL AS BIGINT))) FROM range(0,3,1,1))`, using Catalyst
`AssertNotNull` as `ann`. Spark 4.1.3 and the base raised
`SCALAR_SUBQUERY_TOO_MANY_ROWS` (`21000`). The head’s `CometProject` raised
`NOT_NULL_ASSERT_VIOLATION` (`42000`). A `21000` exit handler returned `caught`
on both references and failed on the head.
##########
spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala:
##########
@@ -1178,10 +1178,96 @@ case class CometExecRule(session: SparkSession,
queryStagePrep: Boolean = false)
serde.convert(op, builder)
}
}
- return nativeOp.map { nativeOp =>
- val exec = serde.createExec(nativeOp, op)
- rollUpInfoMessages(op, exec)
- exec
+ return nativeOp
+ .filter { proto =>
+ // A serde may emit several native nodes for one Spark operator.
Inspect those nodes,
+ // but stop at real Spark children, whose consumption context was
tagged independently.
+ def containsOwnAssertion(node: Operator): Boolean =
+
CometAssertNotNull.containsNativeAssertion(node.toBuilder.clearChildren().build())
||
+ node.getChildrenList.asScala.exists { child =>
+ child.getPlanId == proto.getPlanId &&
containsOwnAssertion(child)
+ }
+
+ def hasNativeAssertion: Boolean = op match {
+ // TopK's sink proto omits its runtime projection over the
selected rows.
+ case topK: TakeOrderedAndProjectExec =>
+ QueryPlanSerde.withSharedSubexpressions(topK.projectList) {
+ topK.projectList.exists { expression =>
+ QueryPlanSerde
+ .exprToProto(expression, topK.child.output)
+ .exists(CometAssertNotNull.containsNativeAssertion)
+ }
+ }
+ case _: HashAggregateExec | _: ObjectHashAggregateExec =>
+ // Input/grouping expressions consume all rows; only the result
projection is lazy.
+ proto.hasProjection &&
CometAssertNotNull.containsNativeAssertion(
+ proto.getProjection)
+ case _ => containsOwnAssertion(proto)
+ }
+ val fallback = nativeAssertionJoinFallback(proto).orElse {
+ op.getTagValue(CometExecRule.PARTIAL_READER)
+ .filter(_ => hasNativeAssertion)
Review Comment:
[P2] Apply the assertion guard when Parquet predicates are appended after
planning. `hasNativeAssertion` examines the current protobuf.
`CometNativeScanExec.serializedPartitionData` later resolves scalar subqueries
and appends their filters. A pushed assertion can then inspect a later row even
though the residual Spark filter under `LIMIT` stops at the first row. Leave
asserted late predicates to the residual filter.
Evidence: In one Parquet partition put `p(id,flag)=[(0,true),(1,NULL)]`. Run
`SELECT id FROM p WHERE IF((SELECT max(id) FROM p)>0,ann(flag),true) LIMIT 1`,
with `ann` bound to Catalyst `AssertNotNull`. Spark 4.1.3 and the base returned
`[0]`. With `spark.comet.parquet.rowFilterPushdown.enabled=true`, the head
raised `CometNativeException` in the scan. Turning pushdown off restored `[0]`.
Without the limit, a `42000` handler caught the expected assertion on Spark and
the base and failed on the head.
##########
spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala:
##########
@@ -109,6 +109,7 @@ object QueryPlanSerde extends Logging with CometExprShim
with CometTypeShim {
classOf[LessThanOrEqual] -> CometLessThanOrEqual,
classOf[In] -> CometIn,
classOf[IsNotNull] -> CometIsNotNull,
+ classOf[AssertNotNull] -> CometAssertNotNull,
Review Comment:
[P2] Preserve hash-key evaluation when column pruning leaves a shuffle with
zero data columns. Registering `AssertNotNull` makes an asserted partition
expression eligible for native shuffle. Even with multiple reducers,
`EmptySchemaShufflePartitioner` counts rows and sends them to partition zero
without evaluating the key. Evaluate throwing keys before counting rows, or
decline this native exchange.
Evidence: With AQE off, native range enabled, and two shuffle partitions,
run `SELECT count(*) FROM (SELECT 1 FROM range(0,3,1,1) DISTRIBUTE BY
ann(CAST(NULL AS INT))) q`, using Catalyst `AssertNotNull` as `ann`. Spark
4.1.3 and the base raised the assertion. The head planned `CometExchange
hashpartitioning(assertnotnull(null), 2)` above an empty projection and
returned `3`. Replacing the null with `1` returned `3` on all three.
##########
spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala:
##########
@@ -109,6 +109,7 @@ object QueryPlanSerde extends Logging with CometExprShim
with CometTypeShim {
classOf[LessThanOrEqual] -> CometLessThanOrEqual,
classOf[In] -> CometIn,
classOf[IsNotNull] -> CometIsNotNull,
+ classOf[AssertNotNull] -> CometAssertNotNull,
Review Comment:
[P2] Preserve hash-key evaluation when a nonempty-schema exchange has one
reducer. The new registration admits an asserted key, but
`CometNativeShuffleWriter` serializes this physical `HashPartitioning` as
`SinglePartition` and drops the key expression. Spark still evaluates
`partitionIdExpression`. Keep this exchange on Spark or evaluate the asserted
keys before routing the batch to partition zero.
Evidence: With AQE off, use `l(a,b)=[(NULL,NULL)]` and `r(c,d)=[(1,2)]`, all
`INT`. Run `SELECT /*+ SHUFFLE_HASH(r) */ l.a FROM l LEFT JOIN (SELECT c,d FROM
r WHERE c<0) r ON l.a=r.c AND ann(l.b)=r.d`, where `ann` is Catalyst
`AssertNotNull`. At one reducer, Spark 4.1.3 and the base threw. The head
returned `[NULL]` through a Spark join above `CometExchange
hashpartitioning(a,assertnotnull(b),1)`. At two reducers the head raised the
expected assertion.
##########
spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala:
##########
@@ -1178,10 +1178,96 @@ case class CometExecRule(session: SparkSession,
queryStagePrep: Boolean = false)
serde.convert(op, builder)
}
}
- return nativeOp.map { nativeOp =>
- val exec = serde.createExec(nativeOp, op)
- rollUpInfoMessages(op, exec)
- exec
+ return nativeOp
+ .filter { proto =>
+ // A serde may emit several native nodes for one Spark operator.
Inspect those nodes,
+ // but stop at real Spark children, whose consumption context was
tagged independently.
+ def containsOwnAssertion(node: Operator): Boolean =
+
CometAssertNotNull.containsNativeAssertion(node.toBuilder.clearChildren().build())
||
Review Comment:
[P2] Skip the operator protobuf walk when the source expressions contain no
assertion. A `LIMIT` tags the filter beneath it, causing `containsOwnAssertion`
to inspect every literal in an ordinary `IN` filter. The scalar serializer’s
`NULL_CHECK` fast path does not cover this call. Check for a Catalyst assertion
candidate first or retain its presence during serialization.
Evidence: On Spark 4.1.3 with AQE off and native range enabled, plan
`spark.range(200001).filter(col("id").isin(ids: _*)).limit(1)` with 100,000
distinct `Long` values. After forcing `sparkPlan`, warmed `ThreadMXBean`
measurements for `executedPlan` were 266.6 MB on the base and 360.2 MB on the
head. Both serialized all 100,000 values. Calling the production assertion
search on the filter protobuf alone allocated 89.6 MB and returned `false`.
##########
spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala:
##########
@@ -1372,7 +1495,9 @@ case class CometExecRule(session: SparkSession,
queryStagePrep: Boolean = false)
case _: FilterExec | _: ProjectExec => producesObjects(op.children.head)
case _ => false
}
- visit(plan, if (producesObjects(plan)) Some("code reading Dataset.rdd")
else None)
+ visit(
+ plan,
+ if (producesObjects(plan)) Some(PartialReader("code reading
Dataset.rdd")) else None)
Review Comment:
[P2] Protect partial reads through PySpark `DataFrame.rdd`.
`producesObjects(plan)` recognizes the object-producing root used by Scala
`Dataset.rdd`. PySpark’s `javaToPython` consumes `queryExecution.toRdd`
directly, so an ordinary native projection can check a whole batch before
`rdd.take(1)` returns its first row. Carry partial-consumption information into
that export or restrict native assertions at an externally consumed root.
Evidence: In PySpark 4.1.3, read IDs `0..2047` from one Parquet partition.
Select `ann(IF(id<2047,id,CAST(NULL AS BIGINT))) AS v` and `concat(CAST(id AS
STRING),repeat('p',65536)) AS pad`, with `ann` bound to Catalyst
`AssertNotNull`. The wide payload allows the Python reader to consume a prefix.
`df.rdd.take(1)` returned `v=0` and a 65,537-character payload on Spark and the
base. The head raised `NOT_NULL_ASSERT_VIOLATION` on the last input row.
##########
spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala:
##########
@@ -900,7 +898,22 @@ object QueryPlanSerde extends Logging with CometExprShim
with CometTypeShim {
extractQueryContext(fn).foreach { ctx =>
builder.setQueryContext(ctx)
}
- Some(builder.build())
+ val result = builder.build()
+ // Aggregate arguments are serialized as independent roots, so the
scalar parent guard
+ // cannot see their consumer. Spark can skip them in lazy updates (for
example First),
+ // filtered updates or null checks, while native aggregates can evaluate
whole arrays.
+ if (CometAssertNotNull.containsNativeAssertion(result)) {
Review Comment:
[P2] Gate the aggregate protobuf walk before traversing assertion-free
arguments and filters. `aggExprToProto` calls `containsNativeAssertion(result)`
for every successful aggregate conversion, including fully consumed plans with
no partial reader. A large conditional aggregate pays for reflective
enumeration of every literal during planning. Check the Catalyst aggregate and
filter for assertion candidates before inspecting their native representation.
Evidence: On Spark 4.1.3, plan
`spark.range(200001).agg(sum(when(col("id").isin(ids:
_*),lit(1L)).otherwise(lit(0L))))` with 100,000 distinct `Long` values, native
range enabled, and AQE off. Both revisions produced a native aggregate. The
head’s production assertion search on one 1.68 MB aggregate protobuf allocated
115.2 MB on the driver thread and returned `false`. In the same warmed run,
obtaining `executedPlan` allocated 533.4 MB on the base and 992.6 MB on the
head.
##########
spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala:
##########
@@ -1328,37 +1414,74 @@ case class CometExecRule(session: SparkSession,
queryStagePrep: Boolean = false)
allExecs.get(op.getClass).exists(_.enabledConfig.forall(_.get(op.conf)))
/**
- * Tags each `SerializeFromObjectExec` whose output an operator above it can
stop reading early
- * with that operator, so `transform` does not convert it. Spark computes
the rows of a typed
- * Dataset operation one at a time, as the operator above reads them, while
the conversion fills
- * a whole Arrow batch first. Below a limit, a `mapPartitions` function such
as `_.take(1)`, or
- * code reading `Dataset.rdd`, the conversion would run the user function on
rows Spark never
- * reaches, and a function that throws on one of them would fail a query
that succeeds in Spark.
- * An operator that reads all of its input before it returns a row ends the
search, since Spark
- * computes every row below it anyway: an exchange, a sort, a hash
aggregate, or a top-k over
- * input that is not already sorted.
+ * Tags operators whose output a consumer can skip evaluating. Spark
computes rows as the
+ * consumer reads them, while native expressions and typed Dataset
conversion fill a whole Arrow
+ * batch first. Below a limit, a `mapPartitions` function such as
`_.take(1)`, or code reading
+ * `Dataset.rdd`, an assertion or user function could throw on rows Spark
never reaches. Joins
+ * can also skip an input, and generated projections, filters, samples and
joins can defer child
+ * fields until an expression needs them. Conversely, native joins can skip
input Spark would
+ * evaluate. An operator that reads all of its input before it returns a row
ends the search,
+ * since Spark computes every row below it anyway: an exchange, a sort, a
hash aggregate, or a
+ * top-k over input that is not already sorted.
*
* Conversion is bottom-up, so this runs first. TreeNode tags survive the
child copies made
- * during transformUp, while an identity set would not.
+ * during transformUp, while an identity set would not. Retain established
tags when AQE visits
+ * a fragment without its limiting ancestor.
*/
- private def tagPartiallyReadTypedDatasetOutputs(plan: SparkPlan): Unit = {
- def visit(op: SparkPlan, partialReader: Option[String]): Unit = {
- val childReader = op match {
- case serialize: SerializeFromObjectExec =>
- partialReader.foreach(
- serialize.setTagValue(CometExecRule.TYPED_DATASET_PARTIAL_READER,
_))
- partialReader
- case _: CollectLimitExec | _: LocalLimitExec | _: GlobalLimitExec =>
Some("a limit")
+ private def tagPartiallyReadOutputs(plan: SparkPlan): Unit = {
+ import CometExecRule.PartialReader
+
+ def visit(op: SparkPlan, partialReader: Option[PartialReader]): Unit = {
+ // AQE can revisit a fragment without the ancestor that could skip whole
rows. A new
+ // field-only reader must not weaken that established context or its
child propagation.
+ val inheritedReader = op
+ .getTagValue(CometExecRule.PARTIAL_READER)
+ .filter(_.canSkipInput)
+ .orElse(partialReader)
+ // A repeated transform can already contain Comet limits and consumption
barriers.
+ val original = op match {
+ case comet: CometExec => comet.originalPlan
+ case other => other
+ }
+ val childReader = original match {
+ case _: CollectLimitExec | _: LocalLimitExec | _: GlobalLimitExec =>
+ Some(PartialReader("a limit"))
// A top-k reads only its first rows when its input is already sorted.
case topK: TakeOrderedAndProjectExec
if SortOrder.orderingSatisfies(topK.child.outputOrdering,
topK.sortOrder) =>
- Some("a limit")
- case _: MapPartitionsExec => Some("a mapPartitions function")
+ Some(PartialReader("a limit"))
+ // Global window limits can stop below an exchange, without an upper
reader tag.
+ case w if
ShimCometWindowGroupLimit.extract(w).exists(_.partitionSpec.isEmpty) =>
+ Some(PartialReader("a window group limit"))
+ case _: MapPartitionsExec => Some(PartialReader("a mapPartitions
function"))
+ // These consumers defer projected fields, but still read every input
object. Keep
+ // typed Dataset conversion eligible unless an ancestor can skip whole
input rows.
+ case _: ProjectExec =>
+ inheritedReader.orElse(Some(PartialReader("a projection",
canSkipInput = false)))
+ case _: FilterExec =>
+ inheritedReader.orElse(Some(PartialReader("a filter", canSkipInput =
false)))
+ case _: SampleExec =>
+ inheritedReader.orElse(Some(PartialReader("a sample", canSkipInput =
false)))
+ case _: BroadcastHashJoinExec | _: ShuffledHashJoinExec | _:
SortMergeJoinExec |
+ _: BroadcastNestedLoopJoinExec =>
+ Some(PartialReader("a join"))
Review Comment:
[P2] Keep typed Dataset conversion eligible on the preserved streamed side
of a fully consumed broadcast left outer join. `PartialReader("a join")` marks
both children as able to skip whole input rows and disables
`SerializeFromObjectExec` conversion. Spark’s `HashJoin.outerJoin` consumes
every left row, including unmatched rows. Use a field-only reader for this side
while retaining any ancestor that can genuinely stop consumption.
Evidence: On Spark 4.1.3 `local[1]`, enable typed Dataset conversion and
native range, disable AQE, and use one shuffle partition. Map 1.2 million typed
rows to `(id+1,(id%500000).toString)`, left-outer-join broadcast `range(2048)`
on `k%2048=id`, then group by the string and sum the build-side `id+7`. Both
revisions returned the same grand total, `1236479040`. The base planned
`CometSparkRowToColumnar`, `CometBroadcastHashJoin`, and native aggregates. The
head used Spark for the join and aggregates. With whole-stage codegen off, five
warmed runs had medians of 743 ms on the base and 1,220 ms on the head.
--
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]