Skip to content

Commit a710d5f

Browse files
committed
review: keep prefix pruning off collated columns, free compressed windows, measure Spark reads
Spark prunes StartsWith by cutting each bound to the prefix's length in characters, which a collation can break: under UTF8_LCASE, U+0130 is one character that lowercases to two, so a batch of values beginning with it was pruned against a two-character prefix they all match. buildFilter now leaves out any predicate holding a StartsWith over a non-default collation, and keeps equality and range pruning on those columns. Arrow returns a buffer it stored raw as a slice of whatever holds it, so with every selected window in one allocation, the smallest raw buffer kept the batch's compressed bytes alive as long as the decoded vectors. Projection.load now reads each window's uncompressed-length prefix and copies the raw ones into a second allocation, and gives empty windows Arrow's shared empty buffer. The value tests collected their reference after caching, which Spark then answered from the cache too; they, and the codec round-trip test, now collect it first. New tests cover the collated prefix case, the raw-buffer case, a projection of only NullType columns, and a batch with no rows. CometInMemoryCacheBenchmark gains an axis comparing reads by Spark operators against Spark's own cache format, which is what the docs' limitation now quotes, in place of a figure measured on the previous format. The docs also no longer say the format ignores every runtime config, since the codec is one.
1 parent 20dfd08 commit a710d5f

6 files changed

Lines changed: 512 additions & 95 deletions

File tree

‎docs/source/user-guide/latest/in-memory-cache.md‎

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,11 @@ With Comet's serializer installed as `spark.sql.cache.serializer`:
4747
expects, so Spark can prune whole cached batches on a predicate before any of them is decoded.
4848

4949
Relations whose schema Comet's Arrow writer cannot store — interval types, most notably — are
50-
delegated in full to Spark's default cache format, per relation. Nothing about the format depends
51-
on a runtime config, because `spark.sql.cache.serializer` is a static setting and a relation whose
52-
format could change mid-session could not be read back reliably. Turning
50+
delegated in full to Spark's default cache format, per relation. Which format a relation uses does
51+
not depend on a runtime config, because `spark.sql.cache.serializer` is a static setting and a
52+
relation whose format could change mid-session could not be read back reliably. The compression
53+
codec is a runtime config, but each batch records the codec it was written with, so data cached
54+
under one setting stays readable after the setting changes. Turning
5355
`spark.comet.exec.inMemoryCache.enabled` off at runtime only sends cached scans back to Spark's
5456
execution path; the cached data stays readable either way.
5557

@@ -78,8 +80,8 @@ longer. A narrow projection pays far less, because it only inflates the columns
7880

7981
| Codec | Materialize | Footprint | Read 1 of 6 | Read 6 of 6 |
8082
| ------ | ----------: | --------: | ----------: | ----------: |
81-
| `zstd` | 1503 ms | 55 MiB | 47 ms | 296 ms |
82-
| `none` | 1091 ms | 315 MiB | 36 ms | 63 ms |
83+
| `zstd` | 1507 ms | 55 MiB | 45 ms | 295 ms |
84+
| `none` | 1081 ms | 315 MiB | 35 ms | 64 ms |
8385

8486
`none` is the better setting for a relation that fits in memory uncompressed and is read at close
8587
to full width. The default is the other way round because a cache that does not fit costs more than
@@ -115,20 +117,20 @@ On a 5M-row relation of six flat columns:
115117

116118
| Query shape | Spark cache scan + convert | `CometInMemoryTableScan` | Relative |
117119
| ------------------------------ | -------------------------: | -----------------------: | -------: |
118-
| Repeated scan (3 of 6 columns) | 180 ms | 147 ms | 1.2x |
119-
| Selective filter | 58 ms | 50 ms | 1.2x |
120-
| Row count only (0 of 6) | 37 ms | 34 ms | 1.1x |
121-
| Narrow projection (1 of 6) | 52 ms | 48 ms | 1.1x |
122-
| Full projection (6 of 6) | 547 ms | 291 ms | 1.9x |
120+
| Repeated scan (3 of 6 columns) | 209 ms | 172 ms | 1.2x |
121+
| Selective filter | 72 ms | 61 ms | 1.2x |
122+
| Row count only (0 of 6) | 46 ms | 38 ms | 1.2x |
123+
| Narrow projection (1 of 6) | 70 ms | 58 ms | 1.2x |
124+
| Full projection (6 of 6) | 566 ms | 324 ms | 1.7x |
123125

124126
And on a 1M-row relation of six columns whose middle three are structs, one of them nested two
125127
levels deep:
126128

127129
| Query shape | Spark cache scan + convert | `CometInMemoryTableScan` | Relative |
128130
| -------------------------- | -------------------------: | -----------------------: | -------: |
129-
| Row count only (0 of 6) | 30 ms | 29 ms | 1.0x |
130-
| Narrow projection (1 of 6) | 98 ms | 53 ms | 1.8x |
131-
| Full projection (6 of 6) | 250 ms | 125 ms | 2.0x |
131+
| Row count only (0 of 6) | 38 ms | 32 ms | 1.2x |
132+
| Narrow projection (1 of 6) | 109 ms | 58 ms | 1.9x |
133+
| Full projection (6 of 6) | 275 ms | 130 ms | 2.1x |
132134

133135
Both columns read the cache at the default codec, `zstd`. The codec table above shows what `none`
134136
changes, and it is the full projection that moves most: nothing has to be inflated, so it runs
@@ -147,10 +149,9 @@ Read what this compares carefully. Comet execution is on in both columns, so the
147149
on Comet either way and only the cache-scan boundary moves: on the left, Spark's
148150
`InMemoryTableScanExec` feeds those same Comet operators through a `CometSparkColumnarToColumnar`
149151
bridge; on the right, `CometInMemoryTableScan` feeds them directly. Both columns read the same
150-
Comet-written `CometCachedBatch` — `spark.sql.cache.serializer` is static, so one session cannot
151-
also materialize Spark's format to compare against. These numbers are therefore "keep the cached
152-
scan native" against "fall back to a Spark cache scan and convert", not Comet against Spark
153-
execution, and not a comparison with Spark's own cache format.
152+
Comet-written `CometCachedBatch`. These numbers are therefore "keep the cached scan native" against
153+
"fall back to a Spark cache scan and convert", not Comet against Spark execution, and not a
154+
comparison with Spark's own cache format. That comparison is under [Limitations](#limitations).
154155

155156
## Kryo
156157

@@ -174,10 +175,19 @@ registrator.
174175

175176
## Limitations
176177

177-
Reads that feed **Spark** operators rather than Comet ones are still slower than Spark's own cache
178-
format, by roughly 1.7x to 2.5x depending on how wide the projection is. Those reads pay a row
179-
conversion that Spark's format avoids with generated code over its own layout. This is why the
180-
feature is off by default.
178+
Reads that feed **Spark** operators rather than Comet ones are slower than Spark's own cache
179+
format, and the narrower the read, the wider the gap. Measured by the same benchmark over the same
180+
5M-row relation, with Comet off so that Spark operators consume the cached data:
181+
182+
| Read shape | Spark's cache format | Comet's cache format | Slowdown |
183+
| ----------------------- | -------------------: | -------------------: | -------: |
184+
| Row count only (0 of 6) | 35 ms | 183 ms | 5.2x |
185+
| 1 of 6 columns | 54 ms | 257 ms | 4.8x |
186+
| 3 of 6 columns | 98 ms | 331 ms | 3.4x |
187+
| 6 of 6 columns | 410 ms | 623 ms | 1.5x |
188+
189+
This is why the feature is off by default. The cause is not yet established;
190+
[#5485](https://github.com/apache/datafusion-comet/issues/5485) tracks it.
181191

182192
Comet's serializer exists because Spark's own Arrow cache format
183193
([SPARK-57268](https://issues.apache.org/jira/browse/SPARK-57268)) is only available from Spark

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import scala.collection.JavaConverters._
2626
import org.apache.spark.TaskContext
2727
import org.apache.spark.rdd.RDD
2828
import org.apache.spark.sql.catalyst.InternalRow
29-
import org.apache.spark.sql.catalyst.expressions.{Attribute, Expression, GenericInternalRow, IsNotNull, IsNull, UnsafeProjection}
29+
import org.apache.spark.sql.catalyst.expressions.{Attribute, Expression, GenericInternalRow, IsNotNull, IsNull, StartsWith, UnsafeProjection}
3030
import org.apache.spark.sql.catalyst.util.TypeUtils
3131
import org.apache.spark.sql.columnar.{CachedBatch, SimpleMetricsCachedBatch, SimpleMetricsCachedBatchSerializer}
3232
import org.apache.spark.sql.comet.util.Utils
@@ -456,6 +456,8 @@ class ArrowCachedBatchSerializer extends SimpleMetricsCachedBatchSerializer {
456456
// makes a comparison against them evaluate to null and therefore prune the batch. That would
457457
// silently drop rows, so predicates over columns without bounds are not pushed down at all.
458458
// Null counts and row counts are recorded for every column, so IsNull and IsNotNull stay safe.
459+
// A column that does have bounds can still be unsafe to prune on through StartsWith; see
460+
// prunesOnCollatedPrefix.
459461
override def buildFilter(
460462
predicates: Seq[Expression],
461463
cachedAttributes: Seq[Attribute]): (Int, Iterator[CachedBatch]) => Iterator[CachedBatch] = {
@@ -465,12 +467,25 @@ class ArrowCachedBatchSerializer extends SimpleMetricsCachedBatchSerializer {
465467

466468
val prunablePredicates = predicates.filter {
467469
case _: IsNull | _: IsNotNull => true
468-
case p => p.references.forall(a => prunable.contains(a.exprId))
470+
case p =>
471+
p.references.forall(a => prunable.contains(a.exprId)) && !prunesOnCollatedPrefix(p)
469472
}
470473

471474
super.buildFilter(prunablePredicates, cachedAttributes)
472475
}
473476

477+
// Spark prunes StartsWith by cutting each bound to the prefix's length in characters, which
478+
// assumes a matching value begins with as many characters as the prefix has. That holds under
479+
// binary comparison but not under every collation: under UTF8_LCASE, U+0130 is one character
480+
// that lowercases to two, so a batch of values beginning with it is pruned against a
481+
// two-character prefix they all match. Spark's own cache prunes the same way. A predicate that
482+
// holds such a StartsWith anywhere is left out whole, since leaving out a conjunct only prunes
483+
// less. Equality and range predicates compare with the collation itself, so they still prune.
484+
private def prunesOnCollatedPrefix(predicate: Expression): Boolean = predicate.exists {
485+
case StartsWith(left, _) => left.dataType != StringType
486+
case _ => false
487+
}
488+
474489
// Comet's Arrow writer only handles the types listed in supportsSchema. Reporting false here
475490
// sends the relation down the row path, where it is delegated to Spark's default serializer,
476491
// instead of failing at cache materialization inside Utils.serializeBatches.

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

Lines changed: 80 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -317,48 +317,71 @@ private[comet] object CachedBatchIpc {
317317
nodes.add(new ArrowFieldNode(node.length(), node.nullCount()))
318318
}
319319

320+
// Under a codec, a buffer that compressing would not have shrunk is stored as it was, behind
321+
// a -1 length prefix, and decompressing hands it back as a slice of whatever holds it rather
322+
// than as an allocation of its own. Those windows are therefore copied into an allocation of
323+
// their own: sharing one with the compressed windows, the smallest of them would keep every
324+
// compressed byte of the batch alive for as long as the decoded vectors.
325+
val compressedBody = compression.getCodec != NoCompressionCodec.COMPRESSION_TYPE
320326
val offsets = new Array[Long](bufferIndices.length)
321327
val lengths = new Array[Long](bufferIndices.length)
322-
var total = 0L
328+
val storedRaw = new Array[Boolean](bufferIndices.length)
329+
var packedTotal = 0L
330+
var rawTotal = 0L
323331
var k = 0
324332
while (k < bufferIndices.length) {
325333
val buffer = batch.buffers(bufferIndices(k))
326334
offsets(k) = buffer.offset()
327335
lengths(k) = buffer.length()
328-
total += DataSizeRoundingUtil.roundUpTo8Multiple(lengths(k))
336+
storedRaw(k) = compressedBody &&
337+
lengths(k) >= CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH &&
338+
chunks.readLong(bodyStart + offsets(k)) == CompressionUtil.NO_COMPRESSION_LENGTH
339+
val size = DataSizeRoundingUtil.roundUpTo8Multiple(lengths(k))
340+
if (storedRaw(k)) rawTotal += size else packedTotal += size
329341
k += 1
330342
}
331343

332-
// allocator.buffer(0) is legal but yields a buffer no window can be sliced from, and an
333-
// all-empty projection (every selected column a NullVector, say) would ask for exactly that.
334-
val body = allocator.buffer(math.max(total, 1L))
344+
var packed: ArrowBuf = null
345+
var raw: ArrowBuf = null
335346
val compressedBatch =
336347
try {
348+
if (packedTotal > 0) packed = allocator.buffer(packedTotal)
349+
if (rawTotal > 0) raw = allocator.buffer(rawTotal)
350+
351+
def window(into: ArrowBuf, at: Long, i: Int): ArrowBuf = {
352+
chunks.copyTo(into, at, bodyStart + offsets(i), lengths(i))
353+
val slice = into.slice(at, lengths(i))
354+
slice.writerIndex(lengths(i))
355+
slice
356+
}
357+
337358
val buffers = new java.util.ArrayList[ArrowBuf](bufferIndices.length)
338-
var position = 0L
359+
var packedPosition = 0L
360+
var rawPosition = 0L
339361
var i = 0
340362
while (i < bufferIndices.length) {
341-
val length = lengths(i)
342-
if (length > 0) {
343-
chunks.copyTo(body, position, bodyStart + offsets(i), length)
363+
val size = DataSizeRoundingUtil.roundUpTo8Multiple(lengths(i))
364+
if (lengths(i) == 0) {
365+
// Empty, so there is nothing to copy and no allocation for it to keep alive.
366+
buffers.add(allocator.getEmpty)
367+
} else if (storedRaw(i)) {
368+
buffers.add(window(raw, rawPosition, i))
369+
rawPosition += size
370+
} else {
371+
buffers.add(window(packed, packedPosition, i))
372+
packedPosition += size
344373
}
345-
val window = body.slice(position, length)
346-
window.writerIndex(length)
347-
buffers.add(window)
348-
position += DataSizeRoundingUtil.roundUpTo8Multiple(length)
349374
i += 1
350375
}
351376
new ArrowRecordBatch(batch.length().toInt, nodes, buffers, compression, false)
352-
} catch {
353-
case NonFatal(e) =>
354-
body.close()
355-
throw e
377+
} finally {
378+
// The constructor retained each window; slice() alone does not. Dropping these
379+
// references leaves the batch as sole owner of both allocations, so closing the batch
380+
// is what frees them -- and if anything above threw, this is what frees them instead.
381+
if (packed != null) packed.close()
382+
if (raw != null) raw.close()
356383
}
357384

358-
// The constructor retained each window; slice() alone does not. Dropping `body`'s own
359-
// reference leaves the batch as sole owner of the one allocation, so closing the batch is
360-
// what frees it -- and closing `body` again would drive its reference count negative.
361-
body.close()
362385
val plainBatch =
363386
try decompressed(compressedBatch, allocator)
364387
finally compressedBatch.close()
@@ -394,18 +417,8 @@ private[comet] object CachedBatchIpc {
394417

395418
/** Copy `length` bytes starting at `from` in the payload into `dst` at `dstIndex`. */
396419
def copyTo(dst: ArrowBuf, dstIndex: Long, from: Long, length: Long): Unit = {
397-
// The flatbuffer accessors behind `from` and `length` are unchecked, so a window reaching
398-
// past the payload is a corrupt or mismatched batch, and is reported as one rather than as
399-
// an index error from inside the copy.
400-
if (from < 0 || length < 0 || from + length > starts.last) {
401-
throw new SparkException(
402-
s"Comet cached batch records a buffer at [$from, ${from + length}) outside its " +
403-
s"${starts.last}-byte payload")
404-
}
405-
// The last chunk whose start is at or before `from`. Empty chunks share a start with the
406-
// chunk after them and are skipped by the loop below.
407-
val found = java.util.Arrays.binarySearch(starts, 0, chunks.length, from)
408-
var c = if (found >= 0) found else -found - 2
420+
checkRange(from, length)
421+
var c = chunkAt(from)
409422
var copied = 0L
410423
while (copied < length) {
411424
val chunk = chunks(c)
@@ -421,6 +434,40 @@ private[comet] object CachedBatchIpc {
421434
c += 1
422435
}
423436
}
437+
438+
/** The little-endian long at `from` in the payload, which may straddle two chunks. */
439+
def readLong(from: Long): Long = {
440+
checkRange(from, java.lang.Long.BYTES)
441+
var value = 0L
442+
var i = java.lang.Long.BYTES - 1
443+
while (i >= 0) {
444+
val at = from + i
445+
var c = chunkAt(at)
446+
// Skip the empty chunks that share a start with the one holding `at`.
447+
while (starts(c + 1) <= at) c += 1
448+
val chunk = chunks(c)
449+
value = (value << 8) | (chunk.get(chunk.position() + (at - starts(c)).toInt) & 0xffL)
450+
i -= 1
451+
}
452+
value
453+
}
454+
455+
// The flatbuffer accessors behind a window's position and length are unchecked, so a window
456+
// reaching past the payload is a corrupt or mismatched batch, and is reported as one rather
457+
// than as an index error from inside a read.
458+
private def checkRange(from: Long, length: Long): Unit =
459+
if (from < 0 || length < 0 || from + length > starts.last) {
460+
throw new SparkException(
461+
s"Comet cached batch records a buffer at [$from, ${from + length}) outside its " +
462+
s"${starts.last}-byte payload")
463+
}
464+
465+
// The last chunk whose start is at or before `from`. Empty chunks share a start with the
466+
// chunk after them; callers step past those.
467+
private def chunkAt(from: Long): Int = {
468+
val found = java.util.Arrays.binarySearch(starts, 0, chunks.length, from)
469+
if (found >= 0) found else -found - 2
470+
}
424471
}
425472

426473
/**

0 commit comments

Comments
 (0)