This is an automated email from the ASF dual-hosted git repository.
zhztheplayer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git
The following commit(s) were added to refs/heads/main by this push:
new 21cfeddd06 [VL] Defer Delta deletion vector reads to executors (#12836)
21cfeddd06 is described below
commit 21cfeddd060d7547b9dc7a79a9ab985a42fd18de
Author: Mohammad Linjawi <[email protected]>
AuthorDate: Mon Aug 31 05:58:17 2026 +0300
[VL] Defer Delta deletion vector reads to executors (#12836)
---
.../util/delta-spark-ut/known-failures.txt | 1 -
.../DeltaDeletionVectorDeferredReadTests.scala | 258 +++++++++++++++++++++
.../delta/DeltaDeletionVectorScanInfoSuite.scala | 62 ++++-
.../delta/DeltaDeletionVectorHandoffSuite.scala | 9 +-
.../benchmark/DeltaPlanningBenchmark.scala | 30 ++-
.../delta/DeltaDeletionVectorScanInfoSuite.scala | 62 ++++-
.../delta/DeltaDeletionVectorHandoffSuite.scala | 9 +-
.../gluten/delta/DeltaDeletionVectorScanInfo.scala | 8 +
.../gluten/delta/DeltaDeletionVectorScanInfo.scala | 8 +
.../gluten/delta/DeltaDeletionVectorScanInfo.scala | 190 +++++++++++----
.../gluten/delta/DeltaDeletionVectorScanInfo.scala | 179 +++++++++++---
.../gluten/delta/TaskAccumulatorRegistry.java} | 29 +--
.../gluten/delta/DeletionVectorReadMetrics.scala | 61 +++++
.../gluten/execution/DeltaScanTransformer.scala | 39 +++-
.../gluten/substrait/rel/DeltaLocalFilesNode.java | 71 +++++-
.../execution/FileSourceScanExecTransformer.scala | 14 +-
16 files changed, 906 insertions(+), 124 deletions(-)
diff --git a/.github/workflows/util/delta-spark-ut/known-failures.txt
b/.github/workflows/util/delta-spark-ut/known-failures.txt
index ba9f191a56..88a825490f 100644
--- a/.github/workflows/util/delta-spark-ut/known-failures.txt
+++ b/.github/workflows/util/delta-spark-ut/known-failures.txt
@@ -92,7 +92,6 @@
org.apache.spark.sql.delta.DeltaDataFrameHadoopOptionsSuite#SC-86916: invalidate
org.apache.spark.sql.delta.DeltaDataFrameHadoopOptionsSuite#SC-86916:
read/write Delta paths using DataFrame should pick up Hadoop file system options
org.apache.spark.sql.delta.DeltaDataFrameHadoopOptionsSuite#all operations
should propagate Hadoop file system options
org.apache.spark.sql.delta.DeltaDataFrameHadoopOptionsSuite#operations without
Hadoop options should fail for fake:// filesystem
-org.apache.spark.sql.delta.DeltaFastDropFeatureSuite#Vacuum does not delete
deletion vector files.generateDVTombstones: false
org.apache.spark.sql.delta.DeltaGenerateSymlinkManifestSuite#incremental
manifest: failure to generate manifest throws exception
org.apache.spark.sql.delta.DeltaGenerateSymlinkManifestSuite#special partition
column values
org.apache.spark.sql.delta.DeltaHistoryManagerSuite#data skipping still works
with time travel
diff --git
a/backends-velox/src-delta/test/scala/org/apache/gluten/delta/DeltaDeletionVectorDeferredReadTests.scala
b/backends-velox/src-delta/test/scala/org/apache/gluten/delta/DeltaDeletionVectorDeferredReadTests.scala
new file mode 100644
index 0000000000..aa53e80c73
--- /dev/null
+++
b/backends-velox/src-delta/test/scala/org/apache/gluten/delta/DeltaDeletionVectorDeferredReadTests.scala
@@ -0,0 +1,258 @@
+/*
+ * 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.gluten.delta
+
+import
org.apache.gluten.substrait.rel.DeltaLocalFilesNode.{DeltaFileReadOptions,
InMemoryDeletionVectorPayload}
+
+import org.apache.spark.sql.QueryTest
+import org.apache.spark.sql.delta.actions.DeletionVectorDescriptor
+import org.apache.spark.sql.delta.deletionvectors.{RoaringBitmapArray,
RoaringBitmapArrayFormat}
+import org.apache.spark.sql.execution.datasources.PartitionedFile
+import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
+import org.apache.spark.sql.test.SharedSparkSession
+
+import org.apache.hadoop.fs.Path
+
+import java.io.{ByteArrayInputStream, ByteArrayOutputStream,
ObjectInputStream, ObjectOutputStream}
+import java.util.concurrent.{CountDownLatch, Executors}
+
+import scala.concurrent.{Await, ExecutionContext, Future}
+import scala.concurrent.duration._
+
+final private[delta] case class TestDeletionVectorFile(
+ relativePath: String,
+ fileSize: Long,
+ encodedDescriptor: String,
+ storageType: String,
+ absolutePath: String,
+ offset: Long,
+ payloadSize: Long,
+ cardinality: Long)
+
+/** Shared executor-side DV payload tests for the Delta 3.3 and Delta 4.0
source profiles. */
+trait DeltaDeletionVectorDeferredReadTests {
+ self: QueryTest with SharedSparkSession =>
+
+ import testImplicits._
+
+ protected def loadDeletionVectorFile(tablePath: Path): TestDeletionVectorFile
+
+ protected def deletionVectorMetadata(encodedDescriptor: String): Map[String,
Object]
+
+ protected def encodeDeletionVectorDescriptor(descriptor:
DeletionVectorDescriptor): String
+
+ protected def partitionedFileWithMetadata(
+ tablePath: String,
+ relativeFilePath: String,
+ fileSize: Long,
+ metadata: Map[String, Object]): PartitionedFile
+
+ protected def normalizeDeletionVectorOptions(
+ partitionedFile: PartitionedFile,
+ tablePath: Path,
+ readTime: SQLMetric,
+ readBytes: SQLMetric,
+ readAttempts: SQLMetric): DeltaFileReadOptions
+
+ protected def normalizeDeletionVectorOptions(
+ partitionedFile: PartitionedFile,
+ tablePath: Path): DeltaFileReadOptions
+
+ test("eager DV payload owns its input bytes") {
+ val input = Array[Byte](1, 2, 3)
+ val payload = new InMemoryDeletionVectorPayload(input)
+
+ input(0) = 9
+
+ assert(payload.materialize().sameElements(Array[Byte](1, 2, 3)))
+ }
+
+ test("defers on-disk DV reads through serialization and coalesces concurrent
materialization") {
+ withTempDir {
+ tempDir =>
+ val tablePath = new Path(tempDir.getCanonicalPath, "table")
+ val unrelatedPath = new Path(tempDir.getCanonicalPath, "unrelated")
+ Seq((1, "a"), (2, "b"), (3, "c"), (4, "d"))
+ .toDF("id", "value")
+ .coalesce(1)
+ .write
+ .format("delta")
+ .save(tablePath.toString)
+
+ spark.sql(
+ s"ALTER TABLE delta.`$tablePath` SET TBLPROPERTIES
('delta.enableDeletionVectors' = true)")
+ spark.sql(s"DELETE FROM delta.`$tablePath` WHERE id IN (3, 4)")
+
+ val dataFile = loadDeletionVectorFile(tablePath)
+ assert(dataFile.storageType == "u")
+ val partitionedFile = partitionedFileWithMetadata(
+ unrelatedPath.toString,
+ dataFile.relativePath,
+ dataFile.fileSize,
+ deletionVectorMetadata(dataFile.encodedDescriptor)
+ )
+
+ val readTime = SQLMetrics.createNanoTimingMetric(spark.sparkContext,
"DV read time")
+ val readBytes = SQLMetrics.createSizeMetric(spark.sparkContext, "DV
read bytes")
+ val readAttempts = SQLMetrics.createMetric(spark.sparkContext, "DV
read attempts")
+ val options = normalizeDeletionVectorOptions(
+ partitionedFile,
+ tablePath,
+ readTime,
+ readBytes,
+ readAttempts)
+ assert(!options.isDeletionVectorPayloadMaterialized)
+
+ val executorCopy = javaRoundTrip(options)
+ assert(!executorCopy.isDeletionVectorPayloadMaterialized)
+ assert(executorCopy.serializedDeletionVector.nonEmpty)
+ assert(executorCopy.isDeletionVectorPayloadMaterialized)
+ assert(!options.isDeletionVectorPayloadMaterialized)
+
+ val start = new CountDownLatch(1)
+ val pool = Executors.newFixedThreadPool(8)
+ implicit val executionContext: ExecutionContext =
+ ExecutionContext.fromExecutorService(pool)
+ val reads = (1 to 16).map {
+ _ =>
+ Future {
+ start.await()
+ options.serializedDeletionVector
+ }
+ }
+ start.countDown()
+ val payloads =
+ try {
+ Await.result(Future.sequence(reads), 30.seconds)
+ } finally {
+ pool.shutdownNow()
+ }
+
+ assert(payloads.head.nonEmpty)
+ assert(payloads.forall(_ eq payloads.head))
+ assert(options.isDeletionVectorPayloadMaterialized)
+ assert(readAttempts.value == 1L)
+ assert(readBytes.value == payloads.head.length.toLong)
+ assert(readTime.value > 0L)
+ }
+ }
+
+ test("keeps inline DV payloads eager without filesystem access") {
+ val bitmap = new RoaringBitmapArray()
+ bitmap.add(3L)
+ bitmap.add(7L)
+ val expectedPayload =
bitmap.serializeAsByteArray(RoaringBitmapArrayFormat.Portable)
+ val descriptor = DeletionVectorDescriptor.inlineInLog(expectedPayload,
cardinality = 2L)
+ val tablePath = new Path("unsupported-inline-dv-test://authority/table")
+ val partitionedFile = partitionedFileWithMetadata(
+ tablePath.toString,
+ "data.parquet",
+ fileSize = 0L,
+ metadata =
deletionVectorMetadata(encodeDeletionVectorDescriptor(descriptor)))
+
+ val readTime = SQLMetrics.createNanoTimingMetric(spark.sparkContext, "DV
read time")
+ val readBytes = SQLMetrics.createSizeMetric(spark.sparkContext, "DV read
bytes")
+ val readAttempts = SQLMetrics.createMetric(spark.sparkContext, "DV read
attempts")
+ val options = normalizeDeletionVectorOptions(
+ partitionedFile,
+ tablePath,
+ readTime,
+ readBytes,
+ readAttempts)
+
+ assert(options.isDeletionVectorPayloadMaterialized)
+ assert(options.serializedDeletionVector.sameElements(expectedPayload))
+ assert(readAttempts.value == 0L)
+ assert(readBytes.value == 0L)
+ assert(readTime.value == 0L)
+ }
+
+ test("does not cache failed deferred DV reads") {
+ withTempDir {
+ tempDir =>
+ val tablePath = new Path(tempDir.getCanonicalPath, "table")
+ Seq((1, "a"), (2, "b"), (3, "c"), (4, "d"))
+ .toDF("id", "value")
+ .coalesce(1)
+ .write
+ .format("delta")
+ .save(tablePath.toString)
+
+ spark.sql(
+ s"ALTER TABLE delta.`$tablePath` SET TBLPROPERTIES
('delta.enableDeletionVectors' = true)")
+ spark.sql(s"DELETE FROM delta.`$tablePath` WHERE id IN (3, 4)")
+
+ val dataFile = loadDeletionVectorFile(tablePath)
+ val partitionedFile = partitionedFileWithMetadata(
+ tablePath.toString,
+ dataFile.relativePath,
+ dataFile.fileSize,
+ deletionVectorMetadata(dataFile.encodedDescriptor)
+ )
+
+ val readTime = SQLMetrics.createNanoTimingMetric(spark.sparkContext,
"DV read time")
+ val readBytes = SQLMetrics.createSizeMetric(spark.sparkContext, "DV
read bytes")
+ val readAttempts = SQLMetrics.createMetric(spark.sparkContext, "DV
read attempts")
+ val options = normalizeDeletionVectorOptions(
+ partitionedFile,
+ tablePath,
+ readTime,
+ readBytes,
+ readAttempts)
+
+ val dvPath = new Path(dataFile.absolutePath)
+ val backupPath = new Path(dvPath.toString + ".retry-test-backup")
+ val fs = dvPath.getFileSystem(spark.sessionState.newHadoopConf())
+ assert(fs.rename(dvPath, backupPath))
+ try {
+ intercept[Exception] {
+ options.serializedDeletionVector
+ }
+ assert(!options.isDeletionVectorPayloadMaterialized)
+ assert(readAttempts.value == 1L)
+ assert(readBytes.value == 0L)
+ } finally {
+ assert(fs.rename(backupPath, dvPath))
+ }
+
+ val payload = options.serializedDeletionVector
+ assert(payload.nonEmpty)
+ assert(options.isDeletionVectorPayloadMaterialized)
+ assert(readAttempts.value == 2L)
+ assert(readBytes.value == payload.length.toLong)
+ assert(readTime.value > 0L)
+ }
+ }
+
+ private def javaRoundTrip(options: DeltaFileReadOptions):
DeltaFileReadOptions = {
+ val bytes = new ByteArrayOutputStream()
+ val output = new ObjectOutputStream(bytes)
+ try {
+ output.writeObject(options)
+ } finally {
+ output.close()
+ }
+
+ val input = new ObjectInputStream(new
ByteArrayInputStream(bytes.toByteArray))
+ try {
+ input.readObject().asInstanceOf[DeltaFileReadOptions]
+ } finally {
+ input.close()
+ }
+ }
+
+}
diff --git
a/backends-velox/src-delta33/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
b/backends-velox/src-delta33/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
index 94c4bd2193..448bde4f84 100644
---
a/backends-velox/src-delta33/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
+++
b/backends-velox/src-delta33/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
@@ -17,15 +17,18 @@
package org.apache.gluten.delta
import org.apache.gluten.delta.DeltaDeletionVectorScanInfo.RowIndexFilterType
+import org.apache.gluten.substrait.rel.DeltaLocalFilesNode.DeltaFileReadOptions
import org.apache.spark.SparkConf
import org.apache.spark.paths.SparkPath
import org.apache.spark.sql.QueryTest
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.delta.{DeltaLog, GlutenDeltaParquetFileFormat}
+import org.apache.spark.sql.delta.actions.DeletionVectorDescriptor
import org.apache.spark.sql.delta.catalog.DeltaCatalog
import org.apache.spark.sql.delta.test.DeltaSQLTestUtils
import org.apache.spark.sql.execution.datasources.PartitionedFile
+import org.apache.spark.sql.execution.metric.SQLMetric
import org.apache.spark.sql.internal.{SQLConf, StaticSQLConf}
import org.apache.spark.sql.test.SharedSparkSession
import org.apache.spark.tags.ExtendedSQLTest
@@ -37,7 +40,8 @@ import org.apache.hadoop.fs.Path
class DeltaDeletionVectorScanInfoSuite
extends QueryTest
with SharedSparkSession
- with DeltaSQLTestUtils {
+ with DeltaSQLTestUtils
+ with DeltaDeletionVectorDeferredReadTests {
import testImplicits._
@@ -181,7 +185,61 @@ class DeltaDeletionVectorScanInfoSuite
}
}
- private def partitionedFileWithMetadata(
+ override protected def loadDeletionVectorFile(tablePath: Path):
TestDeletionVectorFile = {
+ val dataFile = DeltaLog
+ .forTable(spark, tablePath)
+ .update()
+ .allFiles
+ .collect()
+ .find(_.deletionVector != null)
+ .get
+ val descriptor = dataFile.deletionVector
+ TestDeletionVectorFile(
+ relativePath = dataFile.path,
+ fileSize = dataFile.size,
+ encodedDescriptor = descriptor.serializeToBase64(),
+ storageType = descriptor.storageType,
+ absolutePath = descriptor.absolutePath(tablePath).toString,
+ offset = descriptor.offset.get.toLong,
+ payloadSize = descriptor.sizeInBytes.toLong,
+ cardinality = descriptor.cardinality
+ )
+ }
+
+ override protected def deletionVectorMetadata(
+ encodedDescriptor: String): Map[String, Object] = {
+ Map(
+ GlutenDeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_ID_ENCODED ->
encodedDescriptor,
+ GlutenDeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_TYPE -> "IF_CONTAINED"
+ )
+ }
+
+ override protected def encodeDeletionVectorDescriptor(
+ descriptor: DeletionVectorDescriptor): String =
descriptor.serializeToBase64()
+
+ override protected def normalizeDeletionVectorOptions(
+ partitionedFile: PartitionedFile,
+ tablePath: Path,
+ readTime: SQLMetric,
+ readBytes: SQLMetric,
+ readAttempts: SQLMetric): DeltaFileReadOptions = {
+ DeltaDeletionVectorScanInfo
+ .normalize(
+ Seq(partitionedFile),
+ tablePath,
+ Some(DeletionVectorReadMetrics(readTime, readBytes, readAttempts)))
+ .get
+ ._2
+ .head
+ }
+
+ override protected def normalizeDeletionVectorOptions(
+ partitionedFile: PartitionedFile,
+ tablePath: Path): DeltaFileReadOptions = {
+ DeltaDeletionVectorScanInfo.normalize(Seq(partitionedFile),
tablePath).get._2.head
+ }
+
+ override protected def partitionedFileWithMetadata(
tablePath: String,
relativeFilePath: String,
fileSize: Long,
diff --git
a/backends-velox/src-delta33/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
b/backends-velox/src-delta33/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
index f5510a9525..6b44941b45 100644
---
a/backends-velox/src-delta33/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
+++
b/backends-velox/src-delta33/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
@@ -58,11 +58,18 @@ class DeltaDeletionVectorHandoffSuite
val df = spark.read.format("delta").load(path)
val executedPlan = df.queryExecution.executedPlan
- assert(executedPlan.collect { case _: DeltaScanTransformer => true
}.nonEmpty)
+ val nativeScans = executedPlan.collect { case scan:
DeltaScanTransformer => scan }
+ assert(nativeScans.nonEmpty)
val planText = executedPlan.toString()
assert(!planText.contains("__delta_internal_is_row_deleted"))
assert(!planText.contains("__delta_internal_row_index"))
checkAnswer(df, Seq((1, "a"), (2, "b")).toDF())
+
+ val metrics = nativeScans.head.metrics
+ assert(metrics("dvDescriptorCount").value == 1L)
+ assert(metrics("dvPayloadReadAttempts").value == 1L)
+ assert(metrics("dvPayloadReadBytes").value > 0L)
+ assert(metrics("dvPayloadReadTime").value > 0L)
}
}
}
diff --git
a/backends-velox/src-delta33/test/scala/org/apache/spark/sql/execution/benchmark/DeltaPlanningBenchmark.scala
b/backends-velox/src-delta33/test/scala/org/apache/spark/sql/execution/benchmark/DeltaPlanningBenchmark.scala
index 68a2332f76..41db7b57ab 100644
---
a/backends-velox/src-delta33/test/scala/org/apache/spark/sql/execution/benchmark/DeltaPlanningBenchmark.scala
+++
b/backends-velox/src-delta33/test/scala/org/apache/spark/sql/execution/benchmark/DeltaPlanningBenchmark.scala
@@ -30,9 +30,9 @@ import org.apache.hadoop.fs.Path
*
* Measures two hot paths that our performance optimizations target:
*
- * 1. '''DV Materialization''' (`DeltaDeletionVectorScanInfo.normalize`):
loads DV bitmaps from
- * storage and serializes them into split metadata. Our optimizations
(reusing the Hadoop conf
- * and DV store across files) target this path.
+ * 1. '''DV descriptor handoff''' (`DeltaDeletionVectorScanInfo.normalize`):
parses descriptors
+ * and creates executor-materialized payload sources without loading
on-disk DV bytes on the
+ * driver.
* 2. '''Post-transform rule application'''
(`DeltaPostTransformRules.rules`): traverses the
* physical plan to strip DV synthetic columns, push down
input_file_name, and apply column
* mapping. Our optimizations (early-exit guard, shallow child check,
pre-computed names,
@@ -78,29 +78,41 @@ object DeltaPlanningBenchmark extends SqlBasedBenchmark {
spark.sparkContext.conf.getInt("spark.gluten.benchmark.iterations", 5)
override def runBenchmarkSuite(mainArgs: Array[String]): Unit = {
- runDvMaterializationBenchmark()
+ runDvDescriptorHandoffBenchmark()
runPostTransformRulesBenchmark()
runNonDeltaRulesOverheadBenchmark()
}
/**
- * Benchmarks DeltaDeletionVectorScanInfo.normalize() -- the critical path
that loads DVs from
- * storage on the driver. Measures how reusing the DV store across files
reduces overhead.
+ * Benchmarks DeltaDeletionVectorScanInfo.normalize() -- the planning path
that constructs
+ * executor-deferred DV descriptors. This deliberately does not access
serialized payload bytes,
+ * which would model executor work rather than driver planning.
*/
- private def runDvMaterializationBenchmark(): Unit = {
+ private def runDvDescriptorHandoffBenchmark(): Unit = {
val benchmark = new Benchmark(
- s"DV Materialization (normalize) - $numFiles files",
+ s"DV Descriptor Handoff (normalize) - $numFiles files",
numFiles.toLong,
minNumIters = benchmarkIters,
output = output)
withDeltaTableWithDVs(numFiles, rowsPerFile) {
(path, partitionedFiles) =>
+ var latestResult =
+ DeltaDeletionVectorScanInfo.normalize(partitionedFiles, new
Path(path))
+ assert(
+ latestResult.exists(
+ _._2.forall(options =>
!options.isDeletionVectorPayloadMaterialized)))
+
benchmark.addCase(s"normalize() - $numFiles DV files", benchmarkIters)
{
- _ => DeltaDeletionVectorScanInfo.normalize(partitionedFiles, new
Path(path))
+ _ =>
+ latestResult =
+ DeltaDeletionVectorScanInfo.normalize(partitionedFiles, new
Path(path))
}
benchmark.run()
+ assert(
+ latestResult.exists(
+ _._2.forall(options =>
!options.isDeletionVectorPayloadMaterialized)))
}
}
diff --git
a/backends-velox/src-delta40/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
b/backends-velox/src-delta40/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
index 94c4bd2193..448bde4f84 100644
---
a/backends-velox/src-delta40/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
+++
b/backends-velox/src-delta40/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
@@ -17,15 +17,18 @@
package org.apache.gluten.delta
import org.apache.gluten.delta.DeltaDeletionVectorScanInfo.RowIndexFilterType
+import org.apache.gluten.substrait.rel.DeltaLocalFilesNode.DeltaFileReadOptions
import org.apache.spark.SparkConf
import org.apache.spark.paths.SparkPath
import org.apache.spark.sql.QueryTest
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.delta.{DeltaLog, GlutenDeltaParquetFileFormat}
+import org.apache.spark.sql.delta.actions.DeletionVectorDescriptor
import org.apache.spark.sql.delta.catalog.DeltaCatalog
import org.apache.spark.sql.delta.test.DeltaSQLTestUtils
import org.apache.spark.sql.execution.datasources.PartitionedFile
+import org.apache.spark.sql.execution.metric.SQLMetric
import org.apache.spark.sql.internal.{SQLConf, StaticSQLConf}
import org.apache.spark.sql.test.SharedSparkSession
import org.apache.spark.tags.ExtendedSQLTest
@@ -37,7 +40,8 @@ import org.apache.hadoop.fs.Path
class DeltaDeletionVectorScanInfoSuite
extends QueryTest
with SharedSparkSession
- with DeltaSQLTestUtils {
+ with DeltaSQLTestUtils
+ with DeltaDeletionVectorDeferredReadTests {
import testImplicits._
@@ -181,7 +185,61 @@ class DeltaDeletionVectorScanInfoSuite
}
}
- private def partitionedFileWithMetadata(
+ override protected def loadDeletionVectorFile(tablePath: Path):
TestDeletionVectorFile = {
+ val dataFile = DeltaLog
+ .forTable(spark, tablePath)
+ .update()
+ .allFiles
+ .collect()
+ .find(_.deletionVector != null)
+ .get
+ val descriptor = dataFile.deletionVector
+ TestDeletionVectorFile(
+ relativePath = dataFile.path,
+ fileSize = dataFile.size,
+ encodedDescriptor = descriptor.serializeToBase64(),
+ storageType = descriptor.storageType,
+ absolutePath = descriptor.absolutePath(tablePath).toString,
+ offset = descriptor.offset.get.toLong,
+ payloadSize = descriptor.sizeInBytes.toLong,
+ cardinality = descriptor.cardinality
+ )
+ }
+
+ override protected def deletionVectorMetadata(
+ encodedDescriptor: String): Map[String, Object] = {
+ Map(
+ GlutenDeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_ID_ENCODED ->
encodedDescriptor,
+ GlutenDeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_TYPE -> "IF_CONTAINED"
+ )
+ }
+
+ override protected def encodeDeletionVectorDescriptor(
+ descriptor: DeletionVectorDescriptor): String =
descriptor.serializeToBase64()
+
+ override protected def normalizeDeletionVectorOptions(
+ partitionedFile: PartitionedFile,
+ tablePath: Path,
+ readTime: SQLMetric,
+ readBytes: SQLMetric,
+ readAttempts: SQLMetric): DeltaFileReadOptions = {
+ DeltaDeletionVectorScanInfo
+ .normalize(
+ Seq(partitionedFile),
+ tablePath,
+ Some(DeletionVectorReadMetrics(readTime, readBytes, readAttempts)))
+ .get
+ ._2
+ .head
+ }
+
+ override protected def normalizeDeletionVectorOptions(
+ partitionedFile: PartitionedFile,
+ tablePath: Path): DeltaFileReadOptions = {
+ DeltaDeletionVectorScanInfo.normalize(Seq(partitionedFile),
tablePath).get._2.head
+ }
+
+ override protected def partitionedFileWithMetadata(
tablePath: String,
relativeFilePath: String,
fileSize: Long,
diff --git
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
index dda547b015..159e848df0 100644
---
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
+++
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
@@ -88,11 +88,18 @@ class DeltaDeletionVectorHandoffSuite
val df = spark.read.format("delta").load(path)
val executedPlan = df.queryExecution.executedPlan
- assert(executedPlan.collect { case _: DeltaScanTransformer => true
}.nonEmpty)
+ val nativeScans = executedPlan.collect { case scan:
DeltaScanTransformer => scan }
+ assert(nativeScans.nonEmpty)
val planText = executedPlan.toString()
assert(!planText.contains("__delta_internal_is_row_deleted"))
assert(!planText.contains("__delta_internal_row_index"))
checkAnswer(df, Seq((1, "a"), (2, "b")).toDF())
+
+ val metrics = nativeScans.head.metrics
+ assert(metrics("dvDescriptorCount").value == 1L)
+ assert(metrics("dvPayloadReadAttempts").value == 1L)
+ assert(metrics("dvPayloadReadBytes").value > 0L)
+ assert(metrics("dvPayloadReadTime").value > 0L)
}
}
}
diff --git
a/gluten-delta/src-delta23/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
b/gluten-delta/src-delta23/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
index 903a206663..084f3d3319 100644
---
a/gluten-delta/src-delta23/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
+++
b/gluten-delta/src-delta23/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
@@ -29,5 +29,13 @@ object DeltaDeletionVectorScanInfo {
def normalize(
partitionFiles: Seq[PartitionedFile],
tablePath: Path)
+ : Option[(Seq[JMap[String, Object]], Seq[DeltaFileReadOptions])] = {
+ normalize(partitionFiles, tablePath, None)
+ }
+
+ def normalize(
+ partitionFiles: Seq[PartitionedFile],
+ tablePath: Path,
+ readMetrics: Option[DeletionVectorReadMetrics])
: Option[(Seq[JMap[String, Object]], Seq[DeltaFileReadOptions])] = None
}
diff --git
a/gluten-delta/src-delta24/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
b/gluten-delta/src-delta24/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
index 903a206663..084f3d3319 100644
---
a/gluten-delta/src-delta24/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
+++
b/gluten-delta/src-delta24/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
@@ -29,5 +29,13 @@ object DeltaDeletionVectorScanInfo {
def normalize(
partitionFiles: Seq[PartitionedFile],
tablePath: Path)
+ : Option[(Seq[JMap[String, Object]], Seq[DeltaFileReadOptions])] = {
+ normalize(partitionFiles, tablePath, None)
+ }
+
+ def normalize(
+ partitionFiles: Seq[PartitionedFile],
+ tablePath: Path,
+ readMetrics: Option[DeletionVectorReadMetrics])
: Option[(Seq[JMap[String, Object]], Seq[DeltaFileReadOptions])] = None
}
diff --git
a/gluten-delta/src-delta33/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
b/gluten-delta/src-delta33/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
index e5c4d8590b..71a2cd265a 100644
---
a/gluten-delta/src-delta33/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
+++
b/gluten-delta/src-delta33/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
@@ -18,7 +18,7 @@ package org.apache.gluten.delta
import org.apache.gluten.sql.shims.SparkShimLoader
import org.apache.gluten.substrait.rel.DeltaLocalFilesNode
-import org.apache.gluten.substrait.rel.DeltaLocalFilesNode.DeltaFileReadOptions
+import
org.apache.gluten.substrait.rel.DeltaLocalFilesNode.{DeletionVectorPayload,
DeltaFileReadOptions, InMemoryDeletionVectorPayload}
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.delta.DeltaParquetFileFormat
@@ -26,10 +26,12 @@ import
org.apache.spark.sql.delta.actions.DeletionVectorDescriptor
import org.apache.spark.sql.delta.deletionvectors.{RoaringBitmapArrayFormat,
StoredBitmap}
import org.apache.spark.sql.delta.storage.dv.{DeletionVectorStore,
HadoopFileSystemDVStore}
import org.apache.spark.sql.execution.datasources.PartitionedFile
+import org.apache.spark.util.SerializableConfiguration
import org.apache.hadoop.conf.Configuration
import org.apache.hadoop.fs.Path
+import java.io.DataInputStream
import java.util.{Map => JMap}
import scala.collection.JavaConverters._
@@ -47,7 +49,9 @@ object DeltaDeletionVectorScanInfo {
hasDeletionVector: Boolean,
rowIndexFilterType: RowIndexFilterType,
cardinality: Long,
- serializedDeletionVector: Array[Byte])
+ deletionVectorPayload: DeletionVectorPayload) {
+ def serializedDeletionVector: Array[Byte] =
deletionVectorPayload.materialize()
+ }
final case class PartitionFileScanInfo(
normalizedOtherMetadataColumns: Map[String, Object],
@@ -63,22 +67,33 @@ object DeltaDeletionVectorScanInfo {
* the DV bookkeeping keys stripped. Returns None when no file in the split
carries a deletion
* vector, so callers can keep the generic split representation.
*
- * `tablePath` is the Delta table root, supplied by the caller from
`TahoeFileIndex.path`, and is
- * used to resolve on-disk DV locations. A single Hadoop Configuration is
reused across all files
- * in the partition.
+ * `tablePath` is the authoritative Delta table root supplied by
`TahoeFileIndex.path`. On-disk DV
+ * descriptors retain a shared serializable Hadoop configuration but do not
open their sidecar
+ * until executor-side split serialization. Inline DVs remain eager because
their bytes are
+ * already present in Delta metadata.
*/
def normalize(
partitionFiles: Seq[PartitionedFile],
tablePath: Path)
: Option[(Seq[JMap[String, Object]], Seq[DeltaFileReadOptions])] = {
+ normalize(partitionFiles, tablePath, None)
+ }
+
+ def normalize(
+ partitionFiles: Seq[PartitionedFile],
+ tablePath: Path,
+ readMetrics: Option[DeletionVectorReadMetrics])
+ : Option[(Seq[JMap[String, Object]], Seq[DeltaFileReadOptions])] = {
if (partitionFiles.isEmpty) {
return None
}
val spark = activeSparkSession
- // Create a single Hadoop Configuration for the entire partition.
val hadoopConf = spark.sessionState.newHadoopConf()
+ val serializableHadoopConf = new SerializableConfiguration(hadoopConf)
- val scanInfos = partitionFiles.map(file => extract(file, hadoopConf,
tablePath))
+ val scanInfos = partitionFiles.map {
+ file => extract(file, hadoopConf, serializableHadoopConf, tablePath,
readMetrics)
+ }
if (scanInfos.exists(_.deletionVectorInfo.hasDeletionVector)) {
Some(
(
@@ -95,16 +110,24 @@ object DeltaDeletionVectorScanInfo {
file: PartitionedFile,
tablePath: Path): PartitionFileScanInfo = {
val hadoopConf = spark.sessionState.newHadoopConf()
- extract(file, hadoopConf, tablePath)
+ val serializableHadoopConf = new SerializableConfiguration(hadoopConf)
+ extract(file, hadoopConf, serializableHadoopConf, tablePath, None)
}
private def extract(
file: PartitionedFile,
hadoopConf: Configuration,
- tablePath: Path): PartitionFileScanInfo = {
+ serializableHadoopConf: SerializableConfiguration,
+ tablePath: Path,
+ readMetrics: Option[DeletionVectorReadMetrics]): PartitionFileScanInfo =
{
val metadata = otherMetadataColumns(file)
val normalizedMetadata = metadata -- Seq(RowIndexFilterIdEncoded,
RowIndexFilterTypeKey)
- val dvInfo = extractDeletionVectorInfo(metadata, hadoopConf, tablePath)
+ val dvInfo = extractDeletionVectorInfo(
+ metadata,
+ hadoopConf,
+ serializableHadoopConf,
+ tablePath,
+ readMetrics)
PartitionFileScanInfo(normalizedMetadata, dvInfo)
}
@@ -113,7 +136,7 @@ object DeltaDeletionVectorScanInfo {
toSubstraitRowIndexFilterType(dvInfo.rowIndexFilterType),
dvInfo.hasDeletionVector,
dvInfo.cardinality,
- dvInfo.serializedDeletionVector)
+ dvInfo.deletionVectorPayload)
}
private def toSubstraitRowIndexFilterType(
@@ -137,21 +160,32 @@ object DeltaDeletionVectorScanInfo {
private def extractDeletionVectorInfo(
metadata: Map[String, Object],
hadoopConf: Configuration,
- tablePath: Path): DeletionVectorInfo = {
+ serializableHadoopConf: SerializableConfiguration,
+ tablePath: Path,
+ readMetrics: Option[DeletionVectorReadMetrics]): DeletionVectorInfo = {
val descriptorValue = metadata.get(RowIndexFilterIdEncoded)
val filterTypeValue = metadata.get(RowIndexFilterTypeKey)
(descriptorValue, filterTypeValue) match {
case (None, None) =>
- DeletionVectorInfo(false, KEEP_ALL, 0L, Array.emptyByteArray)
+ DeletionVectorInfo(
+ false,
+ KEEP_ALL,
+ 0L,
+ new InMemoryDeletionVectorPayload(Array.emptyByteArray))
case (Some(encodedDescriptor), Some(filterType)) =>
val descriptor = parseDescriptor(encodedDescriptor.toString)
- val serializedPayload = serializePayload(hadoopConf, tablePath,
descriptor)
+ val payload = deletionVectorPayload(
+ hadoopConf,
+ serializableHadoopConf,
+ tablePath,
+ descriptor,
+ readMetrics)
DeletionVectorInfo(
true,
parseRowIndexFilterType(filterType.toString),
descriptor.cardinality,
- serializedPayload)
+ payload)
case _ =>
throw new IllegalStateException(
s"Both $RowIndexFilterIdEncoded and $RowIndexFilterTypeKey must
either be present or absent")
@@ -187,17 +221,43 @@ object DeltaDeletionVectorScanInfo {
}
}
+ /** Selects a deferred source for on-disk DVs and eager bytes for inline
DVs. */
+ private def deletionVectorPayload(
+ hadoopConf: Configuration,
+ serializableHadoopConf: SerializableConfiguration,
+ tablePath: Path,
+ descriptor: DeletionVectorDescriptor,
+ readMetrics: Option[DeletionVectorReadMetrics]): DeletionVectorPayload =
{
+ if (tablePath == null) {
+ throw new IllegalStateException(
+ "Unable to resolve Delta table path while preparing deletion vector
payload")
+ }
+ if (descriptor.storageType != "i") {
+ val dvPath = descriptor.absolutePath(tablePath)
+ new OnDiskDeletionVectorPayload(
+ serializableHadoopConf,
+ dvPath.toString,
+ requiredOffset(descriptor),
+ descriptor.sizeInBytes,
+ readMetrics)
+ } else {
+ new InMemoryDeletionVectorPayload(serializeInlinePayload(hadoopConf,
tablePath, descriptor))
+ }
+ }
+
+ private def requiredOffset(descriptor: DeletionVectorDescriptor): Long = {
+ descriptor.offset
+ .map(_.toLong)
+ .getOrElse {
+ throw new IllegalStateException(
+ s"On-disk Delta deletion vector '${descriptor.storageType}' is
missing its offset")
+ }
+ }
+
/**
- * Reads the DV payload bytes for the native engine. For on-disk DVs, reads
the raw bytes directly
- * from the DV file using Delta's `DeletionVectorStore.readRangeFromStream`,
which includes
- * checksum verification. The on-disk format is already Portable Roaring
Bitmap Array (the format
- * the native Velox side expects), so this skips the expensive
- * deserialize-into-Java-Roaring-objects + re-serialize round-trip.
- *
- * Falls back to the standard load+serialize path for inline DVs (small
payloads embedded in Delta
- * metadata) which don't have a file to read from.
+ * Decodes an inline DV already embedded in Delta metadata into Velox's
portable bitmap format.
*/
- private def serializePayload(
+ private def serializeInlinePayload(
hadoopConf: Configuration,
tablePath: Path,
descriptor: DeletionVectorDescriptor): Array[Byte] = {
@@ -205,17 +265,11 @@ object DeltaDeletionVectorScanInfo {
throw new IllegalStateException(
"Unable to resolve Delta table path while materializing deletion
vector payload")
}
- if (descriptor.storageType != "i") {
- // On-disk DV (storageType "u" for UUID or "p" for path): read raw bytes
directly.
- readRawDvBytes(hadoopConf, tablePath, descriptor)
- } else {
- // Inline DV (storageType "i"): bytes are in the descriptor metadata.
- val dvStore = new HadoopFileSystemDVStore(hadoopConf)
- StoredBitmap
- .create(descriptor, tablePath)
- .load(dvStore)
- .serializeAsByteArray(RoaringBitmapArrayFormat.Portable)
- }
+ val dvStore = new HadoopFileSystemDVStore(hadoopConf)
+ StoredBitmap
+ .create(descriptor, tablePath)
+ .load(dvStore)
+ .serializeAsByteArray(RoaringBitmapArrayFormat.Portable)
}
/**
@@ -226,25 +280,67 @@ object DeltaDeletionVectorScanInfo {
*/
private def readRawDvBytes(
hadoopConf: Configuration,
- tablePath: Path,
- descriptor: DeletionVectorDescriptor): Array[Byte] = {
- val dvPath = descriptor.absolutePath(tablePath)
+ dvPath: Path,
+ offset: Long,
+ sizeInBytes: Int): Array[Byte] = {
val fs = dvPath.getFileSystem(hadoopConf)
// Positioned absolute seek, matching Delta's own
`HadoopFileSystemDVStore.read`. `seek` is a
// single positioned reposition (a ranged read on object stores), whereas
`DataInputStream.
// skipBytes` is best-effort -- it can skip fewer bytes than requested
without error, which would
- // then fail the CRC check in `readRangeFromStream`. `FSDataInputStream`
is a `DataInputStream`,
- // so it is passed through directly.
- val stream = fs.open(dvPath)
+ // then fail the CRC check in `readRangeFromStream`.
+ val fileStream = fs.open(dvPath)
try {
- val offset = descriptor.offset.getOrElse(0)
- if (offset > 0) {
- stream.seek(offset.toLong)
- }
- DeletionVectorStore.readRangeFromStream(stream, descriptor.sizeInBytes)
+ fileStream.seek(offset)
+ DeletionVectorStore.readRangeFromStream(new DataInputStream(fileStream),
sizeInBytes)
} finally {
- stream.close()
+ fileStream.close()
+ }
+ }
+
+ /**
+ * Executor-side on-disk payload source. Successful materialization is
memoized for repeated split
+ * serialization; failed reads remain retryable.
+ */
+ @SerialVersionUID(1L)
+ final private class OnDiskDeletionVectorPayload(
+ serializableHadoopConf: SerializableConfiguration,
+ absolutePath: String,
+ offset: Long,
+ sizeInBytes: Int,
+ readMetrics: Option[DeletionVectorReadMetrics])
+ extends DeletionVectorPayload {
+ require(offset >= 0, s"Deletion vector offset must be non-negative:
$offset")
+ require(sizeInBytes >= 0, s"Deletion vector size must be non-negative:
$sizeInBytes")
+
+ @transient @volatile private var cachedPayload: Array[Byte] = _
+
+ override def materialize(): Array[Byte] = {
+ var payload = cachedPayload
+ if (payload == null) {
+ this.synchronized {
+ payload = cachedPayload
+ if (payload == null) {
+ val startedAt = System.nanoTime()
+ readMetrics.foreach(_.registerForCurrentTask())
+ readMetrics.foreach(_.readAttempts.add(1L))
+ try {
+ payload = readRawDvBytes(
+ serializableHadoopConf.value,
+ new Path(absolutePath),
+ offset,
+ sizeInBytes)
+ readMetrics.foreach(_.readBytes.add(payload.length.toLong))
+ cachedPayload = payload
+ } finally {
+ readMetrics.foreach(_.readTimeNanos.add(System.nanoTime() -
startedAt))
+ }
+ }
+ }
+ }
+ payload
}
+
+ override def isMaterialized(): Boolean = cachedPayload != null
}
}
diff --git
a/gluten-delta/src-delta40/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
b/gluten-delta/src-delta40/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
index 11530d665a..e27d10774f 100644
---
a/gluten-delta/src-delta40/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
+++
b/gluten-delta/src-delta40/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
@@ -18,7 +18,7 @@ package org.apache.gluten.delta
import org.apache.gluten.sql.shims.SparkShimLoader
import org.apache.gluten.substrait.rel.DeltaLocalFilesNode
-import org.apache.gluten.substrait.rel.DeltaLocalFilesNode.DeltaFileReadOptions
+import
org.apache.gluten.substrait.rel.DeltaLocalFilesNode.{DeletionVectorPayload,
DeltaFileReadOptions, InMemoryDeletionVectorPayload}
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.delta.DeltaParquetFileFormat
@@ -26,10 +26,12 @@ import
org.apache.spark.sql.delta.actions.DeletionVectorDescriptor
import org.apache.spark.sql.delta.deletionvectors.{RoaringBitmapArrayFormat,
StoredBitmap}
import org.apache.spark.sql.delta.storage.dv.{DeletionVectorStore,
HadoopFileSystemDVStore}
import org.apache.spark.sql.execution.datasources.PartitionedFile
+import org.apache.spark.util.SerializableConfiguration
import org.apache.hadoop.conf.Configuration
import org.apache.hadoop.fs.Path
+import java.io.DataInputStream
import java.util.{Map => JMap}
import scala.collection.JavaConverters._
@@ -48,7 +50,9 @@ object DeltaDeletionVectorScanInfo {
hasDeletionVector: Boolean,
rowIndexFilterType: RowIndexFilterType,
cardinality: Long,
- serializedDeletionVector: Array[Byte])
+ deletionVectorPayload: DeletionVectorPayload) {
+ def serializedDeletionVector: Array[Byte] =
deletionVectorPayload.materialize()
+ }
final case class PartitionFileScanInfo(
normalizedOtherMetadataColumns: Map[String, Object],
@@ -64,21 +68,33 @@ object DeltaDeletionVectorScanInfo {
* the DV bookkeeping keys stripped. Returns None when no file in the split
carries a deletion
* vector, so callers can keep the generic split representation.
*
- * `tablePath` is the Delta table root, supplied by the caller from
`TahoeFileIndex.path`, and is
- * used to resolve on-disk DV locations. A single Hadoop Configuration is
reused across all files
- * in the partition.
+ * `tablePath` is the authoritative Delta table root supplied by
`TahoeFileIndex.path`. On-disk DV
+ * descriptors retain a shared serializable Hadoop configuration but do not
open their sidecar
+ * until executor-side split serialization. Inline DVs remain eager because
their bytes are
+ * already present in Delta metadata.
*/
def normalize(
partitionFiles: Seq[PartitionedFile],
tablePath: Path)
: Option[(Seq[JMap[String, Object]], Seq[DeltaFileReadOptions])] = {
+ normalize(partitionFiles, tablePath, None)
+ }
+
+ def normalize(
+ partitionFiles: Seq[PartitionedFile],
+ tablePath: Path,
+ readMetrics: Option[DeletionVectorReadMetrics])
+ : Option[(Seq[JMap[String, Object]], Seq[DeltaFileReadOptions])] = {
if (partitionFiles.isEmpty) {
return None
}
val spark = activeSparkSession
val hadoopConf = spark.sessionState.newHadoopConf()
+ val serializableHadoopConf = new SerializableConfiguration(hadoopConf)
- val scanInfos = partitionFiles.map(file => extract(file, hadoopConf,
tablePath))
+ val scanInfos = partitionFiles.map {
+ file => extract(file, hadoopConf, serializableHadoopConf, tablePath,
readMetrics)
+ }
if (scanInfos.exists(_.deletionVectorInfo.hasDeletionVector)) {
Some(
(
@@ -95,16 +111,24 @@ object DeltaDeletionVectorScanInfo {
file: PartitionedFile,
tablePath: Path): PartitionFileScanInfo = {
val hadoopConf = spark.sessionState.newHadoopConf()
- extract(file, hadoopConf, tablePath)
+ val serializableHadoopConf = new SerializableConfiguration(hadoopConf)
+ extract(file, hadoopConf, serializableHadoopConf, tablePath, None)
}
private def extract(
file: PartitionedFile,
hadoopConf: Configuration,
- tablePath: Path): PartitionFileScanInfo = {
+ serializableHadoopConf: SerializableConfiguration,
+ tablePath: Path,
+ readMetrics: Option[DeletionVectorReadMetrics]): PartitionFileScanInfo =
{
val metadata = otherMetadataColumns(file)
val normalizedMetadata = metadata -- Seq(RowIndexFilterIdEncoded,
RowIndexFilterTypeKey)
- val dvInfo = extractDeletionVectorInfo(metadata, hadoopConf, tablePath)
+ val dvInfo = extractDeletionVectorInfo(
+ metadata,
+ hadoopConf,
+ serializableHadoopConf,
+ tablePath,
+ readMetrics)
PartitionFileScanInfo(normalizedMetadata, dvInfo)
}
@@ -113,7 +137,7 @@ object DeltaDeletionVectorScanInfo {
toSubstraitRowIndexFilterType(dvInfo.rowIndexFilterType),
dvInfo.hasDeletionVector,
dvInfo.cardinality,
- dvInfo.serializedDeletionVector)
+ dvInfo.deletionVectorPayload)
}
private def toSubstraitRowIndexFilterType(
@@ -137,21 +161,32 @@ object DeltaDeletionVectorScanInfo {
private def extractDeletionVectorInfo(
metadata: Map[String, Object],
hadoopConf: Configuration,
- tablePath: Path): DeletionVectorInfo = {
+ serializableHadoopConf: SerializableConfiguration,
+ tablePath: Path,
+ readMetrics: Option[DeletionVectorReadMetrics]): DeletionVectorInfo = {
val descriptorValue = metadata.get(RowIndexFilterIdEncoded)
val filterTypeValue = metadata.get(RowIndexFilterTypeKey)
(descriptorValue, filterTypeValue) match {
case (None, None) =>
- DeletionVectorInfo(false, KEEP_ALL, 0L, Array.emptyByteArray)
+ DeletionVectorInfo(
+ false,
+ KEEP_ALL,
+ 0L,
+ new InMemoryDeletionVectorPayload(Array.emptyByteArray))
case (Some(encodedDescriptor), Some(filterType)) =>
val descriptor = parseDescriptor(encodedDescriptor.toString)
- val serializedPayload = serializePayload(hadoopConf, tablePath,
descriptor)
+ val payload = deletionVectorPayload(
+ hadoopConf,
+ serializableHadoopConf,
+ tablePath,
+ descriptor,
+ readMetrics)
DeletionVectorInfo(
true,
parseRowIndexFilterType(filterType.toString),
descriptor.cardinality,
- serializedPayload)
+ payload)
case _ =>
throw new IllegalStateException(
s"Both $RowIndexFilterIdEncoded and $RowIndexFilterTypeKey must
either be present or absent")
@@ -209,48 +244,120 @@ object DeltaDeletionVectorScanInfo {
}
}
- private def serializePayload(
+ /** Selects a deferred source for on-disk DVs and eager bytes for inline
DVs. */
+ private def deletionVectorPayload(
hadoopConf: Configuration,
+ serializableHadoopConf: SerializableConfiguration,
tablePath: Path,
- descriptor: DeletionVectorDescriptor): Array[Byte] = {
+ descriptor: DeletionVectorDescriptor,
+ readMetrics: Option[DeletionVectorReadMetrics]): DeletionVectorPayload =
{
if (tablePath == null) {
throw new IllegalStateException(
- "Unable to resolve Delta table path while materializing deletion
vector payload")
+ "Unable to resolve Delta table path while preparing deletion vector
payload")
}
if (descriptor.storageType != "i") {
- // On-disk DV: read raw bytes directly (already in Portable Roaring
format).
- readRawDvBytes(hadoopConf, tablePath, descriptor)
+ val dvPath = descriptor.absolutePath(tablePath)
+ new OnDiskDeletionVectorPayload(
+ serializableHadoopConf,
+ dvPath.toString,
+ requiredOffset(descriptor),
+ descriptor.sizeInBytes,
+ readMetrics)
} else {
- // Inline DV: bytes are in the descriptor metadata.
- val dvStore = new HadoopFileSystemDVStore(hadoopConf)
- StoredBitmap
- .create(descriptor, tablePath)
- .load(dvStore)
- .serializeAsByteArray(RoaringBitmapArrayFormat.Portable)
+ new InMemoryDeletionVectorPayload(serializeInlinePayload(hadoopConf,
tablePath, descriptor))
}
}
- private def readRawDvBytes(
+ private def requiredOffset(descriptor: DeletionVectorDescriptor): Long = {
+ descriptor.offset
+ .map(_.toLong)
+ .getOrElse {
+ throw new IllegalStateException(
+ s"On-disk Delta deletion vector '${descriptor.storageType}' is
missing its offset")
+ }
+ }
+
+ /**
+ * Decodes an inline DV already embedded in Delta metadata into Velox's
portable bitmap format.
+ */
+ private def serializeInlinePayload(
hadoopConf: Configuration,
tablePath: Path,
descriptor: DeletionVectorDescriptor): Array[Byte] = {
- val dvPath = descriptor.absolutePath(tablePath)
+ if (tablePath == null) {
+ throw new IllegalStateException(
+ "Unable to resolve Delta table path while materializing deletion
vector payload")
+ }
+ val dvStore = new HadoopFileSystemDVStore(hadoopConf)
+ StoredBitmap
+ .create(descriptor, tablePath)
+ .load(dvStore)
+ .serializeAsByteArray(RoaringBitmapArrayFormat.Portable)
+ }
+
+ private def readRawDvBytes(
+ hadoopConf: Configuration,
+ dvPath: Path,
+ offset: Long,
+ sizeInBytes: Int): Array[Byte] = {
val fs = dvPath.getFileSystem(hadoopConf)
// Positioned absolute seek, matching Delta's own
`HadoopFileSystemDVStore.read`. `seek` is a
// single positioned reposition (a ranged read on object stores), whereas
`DataInputStream.
// skipBytes` is best-effort -- it can skip fewer bytes than requested
without error, which would
- // then fail the CRC check in `readRangeFromStream`. `FSDataInputStream`
is a `DataInputStream`,
- // so it is passed through directly.
- val stream = fs.open(dvPath)
+ // then fail the CRC check in `readRangeFromStream`.
+ val fileStream = fs.open(dvPath)
try {
- val offset = descriptor.offset.getOrElse(0)
- if (offset > 0) {
- stream.seek(offset.toLong)
- }
- DeletionVectorStore.readRangeFromStream(stream, descriptor.sizeInBytes)
+ fileStream.seek(offset)
+ DeletionVectorStore.readRangeFromStream(new DataInputStream(fileStream),
sizeInBytes)
} finally {
- stream.close()
+ fileStream.close()
+ }
+ }
+
+ /**
+ * Executor-side on-disk payload source. Successful materialization is
memoized for repeated split
+ * serialization; failed reads remain retryable.
+ */
+ @SerialVersionUID(1L)
+ final private class OnDiskDeletionVectorPayload(
+ serializableHadoopConf: SerializableConfiguration,
+ absolutePath: String,
+ offset: Long,
+ sizeInBytes: Int,
+ readMetrics: Option[DeletionVectorReadMetrics])
+ extends DeletionVectorPayload {
+ require(offset >= 0, s"Deletion vector offset must be non-negative:
$offset")
+ require(sizeInBytes >= 0, s"Deletion vector size must be non-negative:
$sizeInBytes")
+
+ @transient @volatile private var cachedPayload: Array[Byte] = _
+
+ override def materialize(): Array[Byte] = {
+ var payload = cachedPayload
+ if (payload == null) {
+ this.synchronized {
+ payload = cachedPayload
+ if (payload == null) {
+ val startedAt = System.nanoTime()
+ readMetrics.foreach(_.registerForCurrentTask())
+ readMetrics.foreach(_.readAttempts.add(1L))
+ try {
+ payload = readRawDvBytes(
+ serializableHadoopConf.value,
+ new Path(absolutePath),
+ offset,
+ sizeInBytes)
+ readMetrics.foreach(_.readBytes.add(payload.length.toLong))
+ cachedPayload = payload
+ } finally {
+ readMetrics.foreach(_.readTimeNanos.add(System.nanoTime() -
startedAt))
+ }
+ }
+ }
+ }
+ payload
}
+
+ override def isMaterialized(): Boolean = cachedPayload != null
}
}
diff --git
a/gluten-delta/src-delta23/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
b/gluten-delta/src/main/java/org/apache/gluten/delta/TaskAccumulatorRegistry.java
similarity index 57%
copy from
gluten-delta/src-delta23/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
copy to
gluten-delta/src/main/java/org/apache/gluten/delta/TaskAccumulatorRegistry.java
index 903a206663..e6e31a84a8 100644
---
a/gluten-delta/src-delta23/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
+++
b/gluten-delta/src/main/java/org/apache/gluten/delta/TaskAccumulatorRegistry.java
@@ -14,20 +14,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.gluten.delta
+package org.apache.gluten.delta;
-import org.apache.gluten.substrait.rel.DeltaLocalFilesNode.DeltaFileReadOptions
+import org.apache.spark.TaskContext;
+import org.apache.spark.util.AccumulatorV2;
-import org.apache.spark.sql.execution.datasources.PartitionedFile
+/** Registers accumulators that Spark deserialized before installing the task
context. */
+final class TaskAccumulatorRegistry {
+ private TaskAccumulatorRegistry() {}
-import org.apache.hadoop.fs.Path
-
-import java.util.{Map => JMap}
-
-/** Reading deletion vectors natively requires Delta 3.3+, so there is nothing
to materialize. */
-object DeltaDeletionVectorScanInfo {
- def normalize(
- partitionFiles: Seq[PartitionedFile],
- tablePath: Path)
- : Option[(Seq[JMap[String, Object]], Seq[DeltaFileReadOptions])] = None
+ static boolean registerForCurrentTask(AccumulatorV2<?, ?>... accumulators) {
+ TaskContext taskContext = TaskContext.get();
+ if (taskContext == null) {
+ return false;
+ }
+ for (AccumulatorV2<?, ?> accumulator : accumulators) {
+ taskContext.registerAccumulator(accumulator);
+ }
+ return true;
+ }
}
diff --git
a/gluten-delta/src/main/scala/org/apache/gluten/delta/DeletionVectorReadMetrics.scala
b/gluten-delta/src/main/scala/org/apache/gluten/delta/DeletionVectorReadMetrics.scala
new file mode 100644
index 0000000000..91c24de508
--- /dev/null
+++
b/gluten-delta/src/main/scala/org/apache/gluten/delta/DeletionVectorReadMetrics.scala
@@ -0,0 +1,61 @@
+/*
+ * 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.gluten.delta
+
+import org.apache.spark.TaskContext
+import org.apache.spark.sql.execution.metric.SQLMetric
+
+import java.io.ObjectInputStream
+
+/** Metrics updated while an executor materializes an on-disk deletion-vector
payload. */
+final case class DeletionVectorReadMetrics(
+ readTimeNanos: SQLMetric,
+ readBytes: SQLMetric,
+ readAttempts: SQLMetric) {
+
+ @transient @volatile private var registeredInTask = false
+
+ /**
+ * Spark can deserialize an input partition before installing `TaskContext`,
so accumulators
+ * nested in that partition cannot register from `AccumulatorV2.readObject`.
Register them when
+ * deferred I/O first runs inside the task instead. The shared metrics
object makes this
+ * once-per-task even when a partition contains multiple deletion vectors.
+ */
+ def registerForCurrentTask(): Unit = {
+ if (!registeredInTask && TaskContext.get() != null) {
+ this.synchronized {
+ if (!registeredInTask) {
+ registeredInTask = TaskAccumulatorRegistry.registerForCurrentTask(
+ readTimeNanos,
+ readBytes,
+ readAttempts)
+ }
+ }
+ }
+ }
+
+ /**
+ * `defaultReadObject` deserializes the nested SQL metrics first. Spark's
+ * `AccumulatorV2.readObject` registers each one when a task context exists,
so mirror that state
+ * here to avoid registering them a second time. Without a task context the
metrics remain
+ * unregistered and `registerForCurrentTask` handles them when
materialization begins.
+ */
+ private def readObject(input: ObjectInputStream): Unit = {
+ input.defaultReadObject()
+ registeredInTask = TaskContext.get() != null
+ }
+}
diff --git
a/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
b/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
index 50073cd197..3d80c582d7 100644
---
a/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
+++
b/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
@@ -16,7 +16,7 @@
*/
package org.apache.gluten.execution
-import org.apache.gluten.delta.DeltaDeletionVectorScanInfo
+import org.apache.gluten.delta.{DeletionVectorReadMetrics,
DeltaDeletionVectorScanInfo}
import org.apache.gluten.sql.shims.SparkShimLoader
import org.apache.gluten.substrait.rel.{DeltaLocalFilesBuilder,
LocalFilesNode, SplitInfo}
import org.apache.gluten.substrait.rel.LocalFilesNode.ReadFileFormat
@@ -30,6 +30,7 @@ import org.apache.spark.sql.delta.{DeltaParquetFileFormat,
NoMapping}
import org.apache.spark.sql.delta.files.{CdcAddFileIndex, TahoeFileIndex,
TahoeRemoveFileIndex}
import org.apache.spark.sql.execution.FileSourceScanExec
import org.apache.spark.sql.execution.datasources.{FilePartition,
HadoopFsRelation}
+import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
import org.apache.spark.sql.types.StructType
import org.apache.spark.util.collection.BitSet
@@ -62,6 +63,27 @@ case class DeltaScanTransformer(
override lazy val fileFormat: ReadFileFormat =
ReadFileFormat.ParquetReadFormat
+ override protected def additionalScanMetrics: Map[String, SQLMetric] = Map(
+ "dvDescriptorPreparationTime" ->
+ SQLMetrics.createNanoTimingMetric(
+ sparkContext,
+ "Delta deletion vector descriptor preparation time"),
+ "dvDescriptorCount" ->
+ SQLMetrics.createMetric(sparkContext, "Delta deletion vector descriptor
count"),
+ "dvPayloadReadTime" ->
+ SQLMetrics.createNanoTimingMetric(sparkContext, "Delta deletion vector
payload read time"),
+ "dvPayloadReadBytes" ->
+ SQLMetrics.createSizeMetric(sparkContext, "Delta deletion vector payload
bytes read"),
+ "dvPayloadReadAttempts" ->
+ SQLMetrics.createMetric(sparkContext, "Delta deletion vector payload
read attempts")
+ )
+
+ @transient private lazy val deletionVectorReadMetrics =
+ DeletionVectorReadMetrics(
+ metrics("dvPayloadReadTime"),
+ metrics("dvPayloadReadBytes"),
+ metrics("dvPayloadReadAttempts"))
+
// Delta CDF over a deletion-vector-enabled table needs DV-aware, row-level
reconciliation that
// the native scan path does not do yet: it would surface rows that are
still live (not covered
// by the DV) as CDF `delete` change rows. Fall back to Spark for both CDF
scan sides -- the add
@@ -129,10 +151,21 @@ case class DeltaScanTransformer(
val tableRootPath = tahoe.path
splitInfos.zip(partitions).map {
case (localFiles: LocalFilesNode, (filePartition: FilePartition, _))
=>
- DeltaDeletionVectorScanInfo
- .normalize(filePartition.files.toSeq, tableRootPath)
+ val startedAt = System.nanoTime()
+ val normalized =
+ try {
+ DeltaDeletionVectorScanInfo.normalize(
+ filePartition.files.toSeq,
+ tableRootPath,
+ Some(deletionVectorReadMetrics))
+ } finally {
+ metrics("dvDescriptorPreparationTime").add(System.nanoTime() -
startedAt)
+ }
+ normalized
.map {
case (otherMetadataColumns, deltaReadOptions) =>
+ metrics("dvDescriptorCount")
+ .add(deltaReadOptions.count(_.hasDeletionVector()).toLong)
DeltaLocalFilesBuilder.makeDeltaLocalFiles(
localFiles,
otherMetadataColumns.asJava,
diff --git
a/gluten-substrait/src/main/java/org/apache/gluten/substrait/rel/DeltaLocalFilesNode.java
b/gluten-substrait/src/main/java/org/apache/gluten/substrait/rel/DeltaLocalFilesNode.java
index a95f676951..ae69b8723f 100644
---
a/gluten-substrait/src/main/java/org/apache/gluten/substrait/rel/DeltaLocalFilesNode.java
+++
b/gluten-substrait/src/main/java/org/apache/gluten/substrait/rel/DeltaLocalFilesNode.java
@@ -79,24 +79,76 @@ public class DeltaLocalFilesNode extends LocalFilesNode {
IF_NOT_CONTAINED
}
+ /**
+ * Serializable source for a deletion-vector payload.
+ *
+ * <p>The source travels inside a Spark input partition. Implementations may
therefore defer
+ * remote I/O until {@link #materialize()} is called while the split is
converted to protobuf on
+ * an executor. The returned byte array must not be modified: protobuf wraps
it without copying.
+ */
+ public interface DeletionVectorPayload extends Serializable {
+ byte[] materialize();
+
+ /** Returns whether the payload bytes are already resident in this object.
*/
+ boolean isMaterialized();
+ }
+
+ /** A payload source for inline DVs whose bytes are already present in Delta
metadata. */
+ public static final class InMemoryDeletionVectorPayload implements
DeletionVectorPayload {
+ private static final long serialVersionUID = 1L;
+
+ private final byte[] payload;
+
+ public InMemoryDeletionVectorPayload(byte[] payload) {
+ this.payload = payload == null ? new byte[0] : payload.clone();
+ }
+
+ @Override
+ public byte[] materialize() {
+ return payload;
+ }
+
+ @Override
+ public boolean isMaterialized() {
+ return true;
+ }
+ }
+
public static class DeltaFileReadOptions implements Serializable {
private static final long serialVersionUID = 1L;
private final RowIndexFilterType rowIndexFilterType;
private final boolean hasDeletionVector;
private final long deletionVectorCardinality;
- private final byte[] serializedDeletionVector;
+ private final DeletionVectorPayload deletionVectorPayload;
public DeltaFileReadOptions(
RowIndexFilterType rowIndexFilterType,
boolean hasDeletionVector,
long deletionVectorCardinality,
byte[] serializedDeletionVector) {
+ this(
+ rowIndexFilterType,
+ hasDeletionVector,
+ deletionVectorCardinality,
+ new InMemoryDeletionVectorPayload(serializedDeletionVector));
+ }
+
+ public DeltaFileReadOptions(
+ RowIndexFilterType rowIndexFilterType,
+ boolean hasDeletionVector,
+ long deletionVectorCardinality,
+ DeletionVectorPayload deletionVectorPayload) {
+ if (rowIndexFilterType == null) {
+ throw new IllegalArgumentException("rowIndexFilterType must not be
null");
+ }
+ if (deletionVectorPayload == null) {
+ throw new IllegalArgumentException("deletionVectorPayload must not be
null");
+ }
this.rowIndexFilterType = rowIndexFilterType;
this.hasDeletionVector = hasDeletionVector;
this.deletionVectorCardinality = deletionVectorCardinality;
- this.serializedDeletionVector =
- serializedDeletionVector == null ? new byte[0] :
serializedDeletionVector;
+ this.deletionVectorPayload = deletionVectorPayload;
}
public RowIndexFilterType rowIndexFilterType() {
@@ -111,8 +163,19 @@ public class DeltaLocalFilesNode extends LocalFilesNode {
return deletionVectorCardinality;
}
+ /**
+ * Materializes and returns the serialized deletion-vector bytes.
+ *
+ * <p>For an on-disk deletion vector this may perform blocking filesystem
I/O and is intended to
+ * run during executor-side split-to-protobuf conversion. The returned
array must not be
+ * modified because protobuf wraps it without copying.
+ */
public byte[] serializedDeletionVector() {
- return serializedDeletionVector;
+ return deletionVectorPayload.materialize();
+ }
+
+ public boolean isDeletionVectorPayloadMaterialized() {
+ return deletionVectorPayload.isMaterialized();
}
}
}
diff --git
a/gluten-substrait/src/main/scala/org/apache/gluten/execution/FileSourceScanExecTransformer.scala
b/gluten-substrait/src/main/scala/org/apache/gluten/execution/FileSourceScanExecTransformer.scala
index 83023f027a..088c5f2d6d 100644
---
a/gluten-substrait/src/main/scala/org/apache/gluten/execution/FileSourceScanExecTransformer.scala
+++
b/gluten-substrait/src/main/scala/org/apache/gluten/execution/FileSourceScanExecTransformer.scala
@@ -116,15 +116,19 @@ abstract class FileSourceScanExecTransformerBase(
disableBucketedScan)
with DatasourceScanTransformer {
- // Executor-side metrics only (excludes driverMetricsAlias).
- @transient private lazy val executorSideScanMetrics: Map[String, SQLMetric] =
+ /** Format-specific metrics that should be displayed with the native file
scan. */
+ protected def additionalScanMetrics: Map[String, SQLMetric] = Map.empty
+
+ // Metrics attached to the native file scan. Format-specific additions may
be updated on the
+ // driver or executors; driver-only aliases are excluded.
+ @transient private lazy val nativeScanMetrics: Map[String, SQLMetric] =
BackendsApiManager.getMetricsApiInstance
.genFileSourceScanTransformerMetrics(sparkContext)
- .filter(m => !driverMetricsAlias.contains(m._1))
+ .filter(m => !driverMetricsAlias.contains(m._1)) ++ additionalScanMetrics
// Note: "metrics" is made transient to avoid sending driver-side metrics to
tasks.
@transient override lazy val metrics: Map[String, SQLMetric] =
- executorSideScanMetrics ++ driverMetricsAlias
+ nativeScanMetrics ++ driverMetricsAlias
override def scanFilters: Seq[Expression] = dataFilters
@@ -189,7 +193,7 @@ abstract class FileSourceScanExecTransformerBase(
override def metricsUpdater(): MetricsUpdater =
BackendsApiManager.getMetricsApiInstance
- .genFileSourceScanTransformerMetricsUpdater(executorSideScanMetrics)
+ .genFileSourceScanTransformerMetricsUpdater(nativeScanMetrics)
override val nodeName: String = {
s"${getClass.getSimpleName} $relation
${tableIdentifier.map(_.unquotedString).getOrElse("")}"
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]