Skip to content

Commit 7e19dff

Browse files
committed
fix: register Comet's serialized payloads with Kryo, and correct the cache benchmark framing
Under spark.kryo.registrationRequired=true, Kryo rejects any class it has not been told about. Two Comet payloads reach it: * CometCachedBatch, whenever Spark serializes a cached block. That is not only DISK_ONLY: the _SER levels, replication, cross-executor fetches, and the disk half of the default MEMORY_AND_DISK all serialize, so a plain df.cache() that spills is enough. * The Array[ChunkedByteBuffer] a native broadcast broadcasts. Spark registers ChunkedByteBuffer but not an array of them, so CometBroadcastExchangeExec fails here regardless of which Comet features are enabled. That one predates the cache work. Spark registers its own ArrowCachedBatch in KryoSerializer.loadableSparkClasses; Comet cannot add to that list, and spark.kryo.registrator is read when SparkEnv builds the serializer, before any plugin runs, so Comet cannot set it either. So: provide CometKryoRegistrator, document it on the cache config, and have CometDriverPlugin warn at startup when the combination is unsafe. DefaultCachedBatch is registered too, because a schema this serializer cannot store is delegated to Spark's, and Spark only registers that class itself from 4.1 onwards. Separately, correct what CometInMemoryCacheBenchmark claims to measure. Both cases run the aggregation on Comet; only the cache-scan boundary moves. The case labels and comment said "Spark execution over CometCachedBatch", which reads as a Spark-execution baseline it never was. verifyPlan now asserts the CometSparkColumnarToColumnar bridge in the disabled case so the framing is pinned by the benchmark itself.
1 parent 70f046a commit 7e19dff

7 files changed

Lines changed: 353 additions & 11 deletions

File tree

‎spark/src/main/scala/org/apache/comet/CometConf.scala‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,10 @@ object CometConf extends ShimCometConf {
271271
"format. Each cached column is stored as its own compressed Arrow IPC stream, so a " +
272272
"scan decodes only the columns it projected. Reads that feed Spark operators rather " +
273273
"than Comet ones still pay a row conversion the default format avoids, and can be " +
274-
"slower than Spark's cache.")
274+
"slower than Spark's cache. With spark.kryo.registrationRequired=true, also set " +
275+
"spark.kryo.registrator=org.apache.comet.CometKryoRegistrator before creating the " +
276+
"SparkContext, otherwise caching fails as soon as a block is serialized, including " +
277+
"the disk half of the default MEMORY_AND_DISK storage level.")
275278
.booleanConf
276279
.createWithDefault(false)
277280

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.comet
21+
22+
import org.apache.spark.serializer.KryoRegistrator
23+
import org.apache.spark.sql.comet.execution.arrow.ArrowCachedBatchSerializer
24+
import org.apache.spark.sql.comet.util.Utils
25+
26+
import com.esotericsoftware.kryo.Kryo
27+
28+
/**
29+
* Registers the classes Comet hands to Spark's serializer with Kryo.
30+
*
31+
* This is only needed when `spark.kryo.registrationRequired=true`, which makes Kryo reject any
32+
* unregistered class rather than writing its name. Set it alongside Comet's own configuration:
33+
*
34+
* {{{
35+
* spark.serializer org.apache.spark.serializer.KryoSerializer
36+
* spark.kryo.registrator org.apache.comet.CometKryoRegistrator
37+
* }}}
38+
*
39+
* `spark.kryo.registrator` has to be set before the `SparkContext` is created, because
40+
* `KryoSerializer` reads it when `SparkEnv` builds it. That is earlier than `CometDriverPlugin`
41+
* runs, so Comet cannot add this for you the way it can add `spark.sql.cache.serializer`;
42+
* `CometDriverPlugin` logs a warning instead when the combination looks unsafe.
43+
*
44+
* Two payloads need it: the `Array[ChunkedByteBuffer]` a native broadcast broadcasts, and
45+
* `CometCachedBatch`. The first applies whether or not the in-memory cache feature is enabled.
46+
*/
47+
class CometKryoRegistrator extends KryoRegistrator {
48+
override def registerClasses(kryo: Kryo): Unit = {
49+
CometKryoRegistrator.classes.foreach(kryo.register)
50+
}
51+
}
52+
53+
object CometKryoRegistrator {
54+
val CLASS_NAME: String = classOf[CometKryoRegistrator].getName
55+
56+
def classes: Seq[Class[_]] =
57+
Utils.arrowBytesKryoClasses ++ ArrowCachedBatchSerializer.kryoClasses
58+
}

‎spark/src/main/scala/org/apache/spark/Plugins.scala‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import org.apache.spark.sql.internal.StaticSQLConf
3131
import org.apache.comet.{COMET_VERSION, CometSparkSessionExtensions, NativeBase}
3232
import org.apache.comet.CometConf
3333
import org.apache.comet.CometConf.{COMET_METRICS_ENABLED, COMET_ONHEAP_ENABLED}
34+
import org.apache.comet.CometKryoRegistrator
3435
import org.apache.comet.annotation.Public
3536

3637
/**
@@ -65,6 +66,7 @@ class CometDriverPlugin extends DriverPlugin with Logging with ShimCometDriverPl
6566
val extraConfs = new ju.HashMap[String, String]()
6667

6768
CometDriverPlugin.maybeSetCacheSerializer(sc.conf, extraConfs)
69+
CometDriverPlugin.warnIfKryoRegistratorMissing(sc.conf)
6870

6971
// register CometSparkSessionExtensions if it isn't already registered
7072
CometDriverPlugin.registerCometSessionExtension(sc.conf)
@@ -145,6 +147,35 @@ object CometDriverPlugin extends Logging {
145147
}
146148
}
147149

150+
// Comet hands Spark's serializer classes that Kryo has not been told about, so with
151+
// spark.kryo.registrationRequired=true it rejects them with "Class is not registered", which
152+
// names neither Comet nor the operation that failed. Two paths reach it: a native broadcast,
153+
// which broadcasts an Array[ChunkedByteBuffer], and any cached block Spark serializes -- the
154+
// disk half of MEMORY_AND_DISK, the _SER levels, replication, a cross-executor fetch.
155+
// CometKryoRegistrator covers both, but spark.kryo.registrator is read when SparkEnv builds the
156+
// serializer, before any plugin runs, so it cannot be set from here. Say so while the
157+
// application is still starting up rather than leaving the user to attribute the failure later.
158+
private[apache] def warnIfKryoRegistratorMissing(conf: SparkConf): Unit = {
159+
val usingKryo =
160+
conf.get("spark.serializer", "") == "org.apache.spark.serializer.KryoSerializer"
161+
val registrationRequired = conf.getBoolean("spark.kryo.registrationRequired", false)
162+
val registered = conf
163+
.get("spark.kryo.registrator", "")
164+
.split(',')
165+
.map(_.trim)
166+
.contains(CometKryoRegistrator.CLASS_NAME)
167+
168+
if (usingKryo && registrationRequired && !registered) {
169+
logWarning(
170+
"spark.kryo.registrationRequired=true but spark.kryo.registrator does not include " +
171+
s"${CometKryoRegistrator.CLASS_NAME}. Comet's native broadcast and its in-memory " +
172+
"cache format will fail with Kryo's \"Class is not registered\" as soon as their " +
173+
"payloads are serialized. Add " +
174+
s"spark.kryo.registrator=${CometKryoRegistrator.CLASS_NAME} before creating the " +
175+
"SparkContext; it cannot be set later.")
176+
}
177+
}
178+
148179
def registerCometMetrics(sc: SparkContext): Unit = {
149180
if (sc.getConf.getBoolean(
150181
COMET_METRICS_ENABLED.key,

‎spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/ArrowCachedBatchSerializer.scala‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import org.apache.spark.sql.catalyst.InternalRow
2828
import org.apache.spark.sql.catalyst.expressions.{Attribute, Expression, GenericInternalRow, IsNotNull, IsNull, UnsafeProjection}
2929
import org.apache.spark.sql.columnar.{CachedBatch, SimpleMetricsCachedBatch, SimpleMetricsCachedBatchSerializer}
3030
import org.apache.spark.sql.comet.util.Utils
31-
import org.apache.spark.sql.execution.columnar.DefaultCachedBatchSerializer
31+
import org.apache.spark.sql.execution.columnar.{DefaultCachedBatch, DefaultCachedBatchSerializer}
3232
import org.apache.spark.sql.internal.SQLConf
3333
import org.apache.spark.sql.types._
3434
import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}
@@ -536,4 +536,35 @@ object ArrowCachedBatchSerializer {
536536

537537
def supportsSchema(schema: Seq[Attribute]): Boolean =
538538
schema.forall(a => supportsType(a.dataType))
539+
540+
/**
541+
* The classes a `CometCachedBatch` adds on top of [[org.apache.comet.CometKryoRegistrator]]'s
542+
* shared Arrow-bytes classes.
543+
*
544+
* Spark serializes a `CachedBatch` with `spark.serializer` whenever the block leaves the heap:
545+
* the disk half of `MEMORY_AND_DISK`, the `_SER` levels, replication, and cross-executor
546+
* fetches. Under `spark.kryo.registrationRequired=true` Kryo rejects any class it has not been
547+
* told about, so an ordinary `df.cache()` that spills would fail with "Class is not registered"
548+
* rather than anything naming this feature. Spark registers its own `ArrowCachedBatch` in
549+
* `KryoSerializer.loadableSparkClasses` for the same reason; Comet cannot add to that list, so
550+
* `CometKryoRegistrator` registers these instead.
551+
*/
552+
def kryoClasses: Seq[Class[_]] = Seq(
553+
classOf[CometCachedBatch],
554+
// The statistics row, whose values are bounds in Spark's internal representation: boxed
555+
// primitives, which Kryo registers by default, plus UTF8String and Decimal, which it does not.
556+
// A Decimal above Long precision holds a scala.math.BigDecimal, which Chill's Scala registrar
557+
// already covers with a serializer that writes the java.math.BigDecimal inside it as a
558+
// class-and-object, so that one has to be registered here.
559+
classOf[GenericInternalRow],
560+
classOf[Array[Any]],
561+
classOf[UTF8String],
562+
classOf[Decimal],
563+
classOf[java.math.BigDecimal],
564+
classOf[java.math.BigInteger],
565+
// A relation whose schema this serializer cannot store is delegated to Spark's
566+
// DefaultCachedBatchSerializer, so its payload has to survive Kryo too. Spark registers
567+
// DefaultCachedBatch itself only from 4.1 onwards, so on 3.4, 3.5 and 4.0 the delegated path
568+
// fails without this. Registering it twice on 4.1 is a no-op.
569+
classOf[DefaultCachedBatch])
539570
}

‎spark/src/main/scala/org/apache/spark/sql/comet/util/Utils.scala‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,22 @@ object Utils extends CometTypeShim with Logging {
301301
}.toArray
302302
}
303303

304+
/**
305+
* The classes that carry the output of [[serializeBatches]] and [[serializeBatchColumns]] out
306+
* of Comet, for Kryo registration by [[org.apache.comet.CometKryoRegistrator]].
307+
*
308+
* Spark registers `ChunkedByteBuffer` itself but not an array of them, and
309+
* `CometBroadcastExchangeExec` broadcasts exactly that array, so a native broadcast fails under
310+
* `spark.kryo.registrationRequired=true` whichever Comet features are enabled. Comet's cache
311+
* format stores one buffer per column and so needs the same registrations.
312+
*/
313+
def arrowBytesKryoClasses: Seq[Class[_]] = Seq(
314+
classOf[ChunkedByteBuffer],
315+
classOf[Array[ChunkedByteBuffer]],
316+
// A ChunkedByteBuffer's own chunks. ChunkedByteBufferOutputStream allocates them on heap.
317+
classOf[Array[ByteBuffer]],
318+
ByteBuffer.allocate(1).getClass)
319+
304320
/**
305321
* Decodes the byte arrays back to ColumnarBatchs and put them into buffer.
306322
*
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.comet.exec
21+
22+
import org.apache.spark.SparkConf
23+
import org.apache.spark.sql.CometTestBase
24+
import org.apache.spark.sql.execution.columnar.CometInMemoryRelationHelper
25+
import org.apache.spark.sql.internal.SQLConf
26+
import org.apache.spark.storage.StorageLevel
27+
28+
import org.apache.comet.{CometConf, CometKryoRegistrator}
29+
30+
/**
31+
* Covers Comet's cached batch format under `spark.kryo.registrationRequired=true`.
32+
*
33+
* Kryo then rejects any class it has not been told about, and Spark serializes a `CachedBatch`
34+
* whenever a cached block leaves the heap: the disk half of the default `MEMORY_AND_DISK`, the
35+
* `_SER` levels, replication, and cross-executor fetches. So this is not a `DISK_ONLY`-only
36+
* concern -- a plain `df.cache()` that spills is enough to reach it. Spark registers its own
37+
* `ArrowCachedBatch` in `KryoSerializer.loadableSparkClasses`; Comet cannot add to that list, so
38+
* [[CometKryoRegistrator]] has to be set explicitly, and this suite is what proves it is
39+
* sufficient.
40+
*
41+
* This needs its own suite because `spark.serializer` and `spark.kryo.registrator` are read when
42+
* `SparkEnv` builds the serializer, so they cannot be changed per test.
43+
*/
44+
class CometInMemoryCacheKryoSuite extends CometTestBase {
45+
46+
import testImplicits._
47+
48+
override protected def beforeAll(): Unit = {
49+
CometInMemoryRelationHelper.clearSerializer()
50+
super.beforeAll()
51+
}
52+
53+
override protected def afterAll(): Unit = {
54+
try {
55+
super.afterAll()
56+
} finally {
57+
CometInMemoryRelationHelper.clearSerializer()
58+
}
59+
}
60+
61+
override protected def sparkConf: SparkConf = {
62+
val conf = super.sparkConf
63+
conf.set("spark.plugins", "org.apache.spark.CometPlugin")
64+
conf.set(
65+
"spark.sql.cache.serializer",
66+
"org.apache.spark.sql.comet.execution.arrow.ArrowCachedBatchSerializer")
67+
conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
68+
conf.set("spark.kryo.registrationRequired", "true")
69+
conf.set("spark.kryo.registrator", CometKryoRegistrator.CLASS_NAME)
70+
conf
71+
}
72+
73+
private def cachedBatchTypes(table: String): Array[String] = {
74+
val cached = spark.sharedState.cacheManager.lookupCachedData(spark.table(table)).get
75+
cached.cachedRepresentation.cacheBuilder.cachedColumnBuffers
76+
.map(_.getClass.getName)
77+
.distinct()
78+
.collect()
79+
}
80+
81+
// Every type whose bounds gatherColumnStats records, so the statistics row carries one of each
82+
// internal representation Kryo has to write: boxed primitives, UTF8String, and Decimal at both
83+
// sides of the long/BigDecimal split.
84+
private val statsColumns = Seq(
85+
"id AS c_long",
86+
"cast(id % 2 = 0 as boolean) AS c_bool",
87+
"cast(id % 100 as byte) AS c_byte",
88+
"cast(id % 100 as short) AS c_short",
89+
"cast(id as int) AS c_int",
90+
"cast(id as float) AS c_float",
91+
"cast(id as double) AS c_double",
92+
"cast(id as decimal(9,2)) AS c_dec_short",
93+
"cast(id as decimal(30,4)) AS c_dec_long",
94+
"cast(id as string) AS c_string",
95+
"cast(date '2020-01-01' + cast(id as int) as date) AS c_date",
96+
"timestamp '2020-01-01 00:00:00' + make_interval(0, 0, 0, 0, 0, 0, id) AS c_ts")
97+
98+
// DISK_ONLY and MEMORY_AND_DISK_SER both serialize the block on put, so each one reaches Kryo
99+
// deterministically in local mode. The default MEMORY_AND_DISK reaches it only once a partition
100+
// spills, which is the case that makes this more than a DISK_ONLY concern but is not something a
101+
// test can force cheaply.
102+
Seq(StorageLevel.DISK_ONLY, StorageLevel.MEMORY_AND_DISK_SER)
103+
.foreach { level =>
104+
test(s"Comet in-memory cache round-trips through Kryo at $level") {
105+
withSQLConf(
106+
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
107+
CometConf.COMET_SHUFFLE_MODE.key -> "jvm",
108+
CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED.key -> "true",
109+
"spark.comet.sparkToColumnar.enabled" -> "true") {
110+
111+
spark.catalog.clearCache()
112+
try {
113+
spark
114+
.range(0, 200, 1, 4)
115+
.selectExpr(statsColumns: _*)
116+
.createOrReplaceTempView("kryo_cache")
117+
118+
spark.catalog.cacheTable("kryo_cache", level)
119+
assert(spark.table("kryo_cache").count() == 200)
120+
121+
assert(
122+
cachedBatchTypes("kryo_cache").sameElements(
123+
Array("org.apache.spark.sql.comet.execution.arrow.CometCachedBatch")),
124+
"the payload Kryo serialized must be Comet's cached batch format")
125+
126+
// Read the payload back rather than only the row count, so a Kryo round trip that
127+
// silently mangles the Arrow bytes fails too. The predicate also exercises the
128+
// statistics row, which is what carries UTF8String and Decimal through Kryo.
129+
checkSparkAnswer(
130+
spark.sql("SELECT c_long, c_string, c_dec_long, c_ts FROM kryo_cache " +
131+
"WHERE c_dec_short >= 100 AND c_string > '1'"))
132+
} finally {
133+
spark.catalog.clearCache()
134+
}
135+
}
136+
}
137+
}
138+
139+
test("Comet broadcast exchange survives Kryo with registration required") {
140+
// Not about the cache: CometBroadcastExchangeExec broadcasts an Array[ChunkedByteBuffer], and
141+
// Spark registers ChunkedByteBuffer but not an array of them, so this fails on main today
142+
// under registrationRequired=true. The registrator this suite installs covers it because the
143+
// cache write path hands back the same type. Kept here rather than split out because that
144+
// registration is the thing under test.
145+
withSQLConf(
146+
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
147+
CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED.key -> "false",
148+
CometConf.COMET_SHUFFLE_MODE.key -> "jvm",
149+
CometConf.COMET_EXEC_BROADCAST_EXCHANGE_ENABLED.key -> "true",
150+
SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB") {
151+
withParquetTable((0 until 100).map(i => (i, i.toString)), "kryo_bcast_a") {
152+
withParquetTable((0 until 10).map(i => (i, i.toString)), "kryo_bcast_b") {
153+
val df = spark.sql(
154+
"SELECT /*+ BROADCAST(b) */ a._1, b._2 " +
155+
"FROM kryo_bcast_a a JOIN kryo_bcast_b b ON a._1 = b._1")
156+
assert(
157+
df.queryExecution.executedPlan.toString().contains("CometBroadcastExchange"),
158+
"the broadcast has to run through Comet for this to test anything")
159+
checkSparkAnswer(df)
160+
}
161+
}
162+
}
163+
}
164+
165+
test("Comet in-memory cache falls back to Spark's format under Kryo for unsupported types") {
166+
// A relation Comet cannot store is delegated to DefaultCachedBatch, which Spark registers
167+
// itself. Pins that the fallback path is not collateral damage of the registration work.
168+
withSQLConf(
169+
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
170+
CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED.key -> "true") {
171+
172+
spark.catalog.clearCache()
173+
try {
174+
spark
175+
.range(0, 100, 1, 2)
176+
.selectExpr("id", "make_interval(0, 0, 0, 0, 0, 0, id) AS iv")
177+
.createOrReplaceTempView("kryo_cache_fallback")
178+
179+
spark.catalog.cacheTable("kryo_cache_fallback", StorageLevel.DISK_ONLY)
180+
assert(spark.table("kryo_cache_fallback").count() == 100)
181+
assert(
182+
cachedBatchTypes("kryo_cache_fallback").sameElements(
183+
Array("org.apache.spark.sql.execution.columnar.DefaultCachedBatch")))
184+
185+
checkSparkAnswer(spark.sql("SELECT id FROM kryo_cache_fallback WHERE id > 90"))
186+
} finally {
187+
spark.catalog.clearCache()
188+
}
189+
}
190+
}
191+
}

0 commit comments

Comments
 (0)