Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pr_build_linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ jobs:
org.apache.comet.exec.CometAggregateSuite
org.apache.comet.exec.CometExec3_4PlusSuite
org.apache.comet.exec.CometExecSuite
org.apache.comet.exec.CometInMemoryCacheSuite
org.apache.comet.exec.CometGenerateExecSuite
org.apache.comet.exec.CometWindowExecSuite
org.apache.comet.exec.CometJoinSuite
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pr_build_macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ jobs:
org.apache.comet.exec.CometAggregateSuite
org.apache.comet.exec.CometExec3_4PlusSuite
org.apache.comet.exec.CometExecSuite
org.apache.comet.exec.CometInMemoryCacheSuite
org.apache.comet.exec.CometGenerateExecSuite
org.apache.comet.exec.CometWindowExecSuite
org.apache.comet.exec.CometJoinSuite
Expand Down
14 changes: 14 additions & 0 deletions spark/src/main/scala/org/apache/comet/CometConf.scala
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,20 @@ object CometConf extends ShimCometConf {
val COMET_EXEC_LOCAL_TABLE_SCAN_ENABLED: ConfigEntry[Boolean] =
createExecEnabledConfig("localTableScan", defaultValue = false)

val COMET_EXEC_IN_MEMORY_CACHE_ENABLED: ConfigEntry[Boolean] =
conf("spark.comet.exec.inMemoryCache.enabled")
.category(CATEGORY_EXEC)
.doc(
"Whether to enable Comet native execution for in-memory cached tables. Its value at " +
"startup also decides whether CometDriverPlugin installs Comet's cache serializer, " +
"which stores cached data in Arrow format. Because spark.sql.cache.serializer is a " +
"static config, the cached format is fixed for the application, and disabling this " +
"at runtime only sends cached scans back to Spark's execution path. Relations whose " +
"schema Comet's Arrow writer does not support are always cached in Spark's default " +
"format.")
.booleanConf
.createWithDefault(false)

val COMET_NATIVE_COLUMNAR_TO_ROW_ENABLED: ConfigEntry[Boolean] =
conf(s"$COMET_EXEC_CONFIG_PREFIX.columnarToRow.native.enabled")
.category(CATEGORY_EXEC)
Expand Down
45 changes: 45 additions & 0 deletions spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ import org.apache.spark.sql.catalyst.rules.Rule
import org.apache.spark.sql.catalyst.trees.TreeNodeTag
import org.apache.spark.sql.catalyst.util.sideBySide
import org.apache.spark.sql.comet._
import org.apache.spark.sql.comet.execution.arrow.ArrowCachedBatchSerializer
import org.apache.spark.sql.comet.execution.shuffle.{CometColumnarShuffle, CometNativeShuffle, CometShuffleExchangeExec}
import org.apache.spark.sql.comet.util.Utils
import org.apache.spark.sql.execution._
import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec, AQEShuffleReadExec, BroadcastQueryStageExec, ShuffleQueryStageExec}
import org.apache.spark.sql.execution.aggregate.{BaseAggregateExec, HashAggregateExec, ObjectHashAggregateExec}
import org.apache.spark.sql.execution.columnar.InMemoryTableScanExec
import org.apache.spark.sql.execution.command.{DataWritingCommandExec, ExecutedCommandExec}
import org.apache.spark.sql.execution.datasources.WriteFilesExec
import org.apache.spark.sql.execution.datasources.csv.CSVFileFormat
Expand Down Expand Up @@ -86,6 +88,7 @@ object CometExecRule {
classOf[SortMergeJoinExec] -> CometSortMergeJoinExec,
classOf[SortExec] -> CometSortExec,
classOf[LocalTableScanExec] -> CometLocalTableScanExec,
classOf[InMemoryTableScanExec] -> CometInMemoryTableScanExec,
classOf[WindowExec] -> CometWindowExec)

/**
Expand Down Expand Up @@ -283,6 +286,48 @@ case class CometExecRule(session: SparkSession)
case op if isCometScan(op) =>
convertToComet(op, CometScanWrapper).getOrElse(op)

case scan: InMemoryTableScanExec =>
val serializer = scan.relation.cacheBuilder.serializer
val usesCometCacheSerializer = serializer.isInstanceOf[ArrowCachedBatchSerializer]
// The serializer only stores Comet's Arrow format for schemas it supports and delegates
// everything else to Spark's default cache format, which the native scan cannot read.
val cometCacheFormat = usesCometCacheSerializer &&
ArrowCachedBatchSerializer.supportsSchema(scan.relation.output)
val nativeCacheEnabled = CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED.get(conf)

if (nativeCacheEnabled && cometCacheFormat) {
convertToComet(scan, CometInMemoryTableScanExec).getOrElse(scan)
} else {
// The native cache scan is not available for this relation. Record why, then take the
// same SparkToColumnar fallback that any other unsupported operator would take, so
// that turning the feature on is never worse for a scan than leaving it off.
if (nativeCacheEnabled && !usesCometCacheSerializer) {
withFallbackReason(
scan,
s"Comet in-memory cache requires ${classOf[ArrowCachedBatchSerializer].getName} " +
s"but this relation was cached with ${serializer.getClass.getName}")
} else if (nativeCacheEnabled) {
val unsupported = scan.relation.output
.filterNot(a => ArrowCachedBatchSerializer.supportsType(a.dataType))
.map(a => s"${a.name}: ${a.dataType.simpleString}")
withFallbackReason(
scan,
"Comet in-memory cache does not support the type of these cached columns, so the " +
s"relation was cached in Spark's default format: ${unsupported.mkString(", ")}")
} else if (usesCometCacheSerializer) {
withFallbackReason(
scan,
"Native support for operator InMemoryTableScanExec is disabled. " +
s"Set ${CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED.key}=true to enable it.")
}

if (shouldApplySparkToColumnar(conf, scan)) {
convertToComet(scan, CometSparkToColumnarExec).getOrElse(scan)
} else {
scan
}
}

case op if shouldApplySparkToColumnar(conf, op) =>
convertToComet(op, CometSparkToColumnarExec).getOrElse(op)

Expand Down
30 changes: 29 additions & 1 deletion spark/src/main/scala/org/apache/spark/Plugins.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import org.apache.spark.internal.config.{EXECUTOR_MEMORY, EXECUTOR_MEMORY_OVERHE
import org.apache.spark.sql.internal.StaticSQLConf

import org.apache.comet.{CometSparkSessionExtensions, NativeBase}
import org.apache.comet.CometConf
import org.apache.comet.CometConf.{COMET_METRICS_ENABLED, COMET_ONHEAP_ENABLED}

/**
Expand All @@ -54,6 +55,10 @@ class CometDriverPlugin extends DriverPlugin with Logging with ShimCometDriverPl
return Collections.emptyMap[String, String]
}

val extraConfs = new ju.HashMap[String, String]()

CometDriverPlugin.maybeSetCacheSerializer(sc.conf, extraConfs)

// register CometSparkSessionExtensions if it isn't already registered
CometDriverPlugin.registerCometSessionExtension(sc.conf)

Expand Down Expand Up @@ -87,7 +92,7 @@ class CometDriverPlugin extends DriverPlugin with Logging with ShimCometDriverPl
logInfo("Comet is running in unified memory mode and sharing off-heap memory with Spark")
}

Collections.emptyMap[String, String]
extraConfs
}

override def receive(message: Any): AnyRef = super.receive(message)
Expand All @@ -106,6 +111,29 @@ class CometDriverPlugin extends DriverPlugin with Logging with ShimCometDriverPl
}

object CometDriverPlugin extends Logging {
// Use Comet's cache serializer only for the native in-memory cache path.
// If the application already set spark.sql.cache.serializer, leave that value
// unchanged so Comet does not replace a user-selected cache format.
private[apache] def maybeSetCacheSerializer(
conf: SparkConf,
extraConfs: ju.HashMap[String, String]): Unit = {
if (conf.getBoolean(CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED.key, false)) {
val serializerKey = StaticSQLConf.SPARK_CACHE_SERIALIZER.key
val serializerValue =
"org.apache.spark.sql.comet.execution.arrow.ArrowCachedBatchSerializer"
val defaultSerializer = StaticSQLConf.SPARK_CACHE_SERIALIZER.defaultValueString
val currentSerializer = conf.get(serializerKey, defaultSerializer)

if (currentSerializer == defaultSerializer) {
extraConfs.put(serializerKey, serializerValue)
conf.set(serializerKey, serializerValue)
logInfo(s"Auto-set $serializerKey=$serializerValue")
} else {
logInfo(s"Not overriding user-provided $serializerKey=$currentSerializer")
}
}
}

def registerCometMetrics(sc: SparkContext): Unit = {
if (sc.getConf.getBoolean(
COMET_METRICS_ENABLED.key,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* 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.
*/

package org.apache.spark.sql.comet

import scala.collection.JavaConverters._

import org.apache.spark.rdd.RDD
import org.apache.spark.sql.catalyst.expressions.Attribute
import org.apache.spark.sql.columnar.{CachedBatch, CachedBatchSerializer}
import org.apache.spark.sql.execution.LeafExecNode
import org.apache.spark.sql.execution.columnar.InMemoryTableScanExec
import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
import org.apache.spark.sql.vectorized.ColumnarBatch

import org.apache.comet.CometConf
import org.apache.comet.serde.CometOperatorSerde
import org.apache.comet.serde.OperatorOuterClass
import org.apache.comet.serde.OperatorOuterClass.Operator
import org.apache.comet.serde.QueryPlanSerde.serializeDataType

/**
* Reads Spark cached table data when the cache was written by Comet's cache serializer.
*
* Spark stores cached data through `CachedBatchSerializer`. This node keeps the scan inside Comet
* by asking the serializer to decode cached batches directly into `ColumnarBatch` output,
* avoiding the extra Spark columnar-to-Comet columnar conversion used by the default path.
*
* `relationOutput` is the full schema stored in the cache. `scanOutput` is the subset requested
* by this scan after pruning.
*/
case class CometInMemoryTableScanExec(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add AQE test:

  • SPARK-42101: leaves a cached join cold, first-touches it through an AQE aggregation, and checks cold/warm materialization and plan rewrites.
  • Table-cache stage in an AQE join: verifies TableCacheQueryStageExec and shuffle behavior.
  • SPARK-37742: verifies AQE does not choose joins using invalid cache runtime statistics.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not done, and I would rather flag that than half-do it. Porting the three AQE scenarios you linked (SPARK-42101 cold/warm materialization, the TableCacheQueryStageExec join, SPARK-37742) is a meaningful chunk of work and touches AQE plan-shape assertions rather than the cache format this PR changes, so I have left it out of this round rather than rushing it alongside the four correctness fixes.

You said your comments could be follow-ups, so unless you would rather block on it, my suggestion is a tracking issue for AQE coverage of CometInMemoryTableScanExec. I have not filed one yet — say the word and I will, or feel free to open it yourself if you have a preference for how it should be scoped.

originalPlan: InMemoryTableScanExec,
serializer: CachedBatchSerializer,
cachedBuffers: RDD[CachedBatch],
relationOutput: Seq[Attribute],
scanOutput: Seq[Attribute])
extends CometExec
with LeafExecNode {

override lazy val metrics: Map[String, SQLMetric] = Map(
"numOutputRows" -> SQLMetrics.createMetric(sparkContext, "number of output rows"))

// For an empty-projection scan (`SELECT count(*)`) this is empty while `scanOutput` holds the
// full cache schema, so the emitted batches are wider than the declared output. That is safe
// because the only consumer of an empty-output scan is a count-style aggregate, which reads
// the row count rather than any column; `convert` and `createExec` deliberately fall back to
// the cache schema in that case because the native plan still needs a non-empty scan schema.
override def output: Seq[Attribute] = originalPlan.output

// Use the serializer's vector types because the cached batch layout is owned by the serializer.
override def vectorTypes: Option[Seq[String]] =
serializer.vectorTypes(scanOutput, conf)

// Apply Spark's cache batch filter before decoding. Spark's InMemoryTableScanExec does this in
// filteredCachedBatches(), but that method is private. Reusing the serializer's buildFilter here
// keeps Comet on the same stats-based pruning path instead of decoding every cached batch.
//
// Gated on conf.inMemoryPartitionPruning the same way Spark's filteredCachedBatches is, so
// spark.sql.inMemoryColumnarStorage.partitionPruning=false disables pruning here too. Pruning is
// normally a win, but the config exists to be able to turn it off -- for debugging a suspected
// stats bug, for instance -- and silently ignoring it would make Comet diverge from Spark on a
// knob a user reaching for it is specifically trying to control.
override def doExecuteColumnar(): RDD[ColumnarBatch] = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pruning configuration ignored: still prunes when spark.sql.inMemoryColumnarStorage.partitionPruning=false.

not sure enable partitionPruning is always better than disable? if true, then nvm.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real bug, fixed in 1367665 — thanks. Spark's InMemoryTableScanExec.filteredCachedBatches gates on conf.inMemoryPartitionPruning and Comet was applying the filter unconditionally, so the config was silently ignored.

To your "not sure enable partitionPruning is always better than disable": pruning is normally the win, but that is not really the point — the config exists so a user can turn it off, typically to rule out a stats bug when results look wrong. Ignoring it means the one knob they are reaching for does nothing, and Comet diverges from Spark precisely when someone is debugging. So honoring it is right regardless of which default is faster.

Covered by a new test that observes the scan's numOutputRows, which counts rows in the batches actually decoded: 100 with pruning on (a single 100-row batch) and 1000 with it off (every batch decoded). One wrinkle worth noting for anyone writing a similar test: the metric has to be read after forcing that exact df to run, because checkSparkAnswer executes its own copies and leaves the collected plan instance at zero — my first version passed vacuously with 0 on both sides.

val numOutputRows = longMetric("numOutputRows")

val filteredBuffers =
if (originalPlan.predicates.nonEmpty && conf.inMemoryPartitionPruning) {
val filter = serializer.buildFilter(originalPlan.predicates, relationOutput)
cachedBuffers.mapPartitionsWithIndex(filter)
} else {
cachedBuffers
}

serializer
.convertCachedBatchToColumnarBatch(filteredBuffers, relationOutput, scanOutput, conf)
.map { cb =>
numOutputRows += cb.numRows()
cb
}
}
}

object CometInMemoryTableScanExec extends CometOperatorSerde[InMemoryTableScanExec] {

override def enabledConfig: Option[org.apache.comet.ConfigEntry[Boolean]] =
Some(CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED)

override def convert(
op: InMemoryTableScanExec,
builder: OperatorOuterClass.Operator.Builder,
childOp: Operator*): Option[Operator] = {

// Empty-output scans still need a schema for native planning, so fall back to the cache schema.
val actualOutput =
if (op.output.nonEmpty) op.output
else op.relation.output

val scanTypes = actualOutput.flatMap(attr => serializeDataType(attr.dataType))

val scanBuilder = OperatorOuterClass.Scan
.newBuilder()
.setSource(op.getClass.getSimpleName)
.addAllFields(scanTypes.asJava)

Some(builder.setScan(scanBuilder).build())
}

// Reuse Spark's InMemoryRelation metadata so cache materialization, pruning, and storage
// behavior remain controlled by Spark's cache manager.
override def createExec(nativeOp: Operator, op: InMemoryTableScanExec): CometNativeExec = {
val relation = op.relation

val actualOutput =
if (op.output.nonEmpty) op.output
else relation.output

CometScanWrapper(
nativeOp,
CometInMemoryTableScanExec(
op,
relation.cacheBuilder.serializer,
relation.cacheBuilder.cachedColumnBuffers,
relation.output,
actualOutput))
}
}
Loading