From a3d1918b144055c25e8e05c4ea53dd881001ebcd Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Wed, 12 Aug 2026 09:36:00 +0800 Subject: [PATCH 01/18] Support Iceberg v3 deletion vectors on GPU Signed-off-by: Ray Liu --- .../rapids/iceberg/IcebergShimUtils.java | 23 +++ .../spark/rapids/iceberg/ShimUtils.java | 23 +++ .../rapids/iceberg/data/GpuDeleteFilter.scala | 110 ++++++++++- .../rapids/iceberg/data/GpuDeleteLoader.scala | 60 +++++- .../source/GpuIcebergPartitionReader.scala | 3 +- .../iceberg/iceberg110x/ShimUtilsImpl.java | 58 ++++++ .../iceberg/iceberg111x/ShimUtilsImpl.java | 58 ++++++ .../iceberg/iceberg16x/ShimUtilsImpl.java | 29 +++ .../iceberg/iceberg19x/ShimUtilsImpl.java | 59 ++++++ .../iceberg/iceberg_merge_on_read_test.py | 44 ++++- .../rapids/GpuBatchScanExecMetrics.scala | 8 + .../com/nvidia/spark/rapids/GpuMetrics.scala | 8 + .../iceberg/data/GpuDeleteFilterSuite.scala | 181 +++++++++++++++++- .../iceberg/DeletionVectorReaderSuite.scala | 116 +++++++++++ 14 files changed, 758 insertions(+), 22 deletions(-) create mode 100644 tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java index 339b1073cd0..05f1b67cb18 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java @@ -22,10 +22,12 @@ import com.nvidia.spark.rapids.fileio.iceberg.IcebergInputFile; import org.apache.hadoop.fs.Path; import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.Schema; import org.apache.iceberg.Table; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; import org.apache.iceberg.parquet.GpuParquetIO; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; @@ -56,6 +58,27 @@ public interface IcebergShimUtils { */ String locationOf(ContentFile f); + /** Returns whether a positional delete is an Iceberg Puffin deletion vector. */ + boolean isDeletionVector(DeleteFile deleteFile); + + /** Returns the data-file location referenced by a deletion vector. */ + String referencedDataFile(DeleteFile deleteFile); + + /** Returns the byte offset of the deletion-vector blob. */ + Long contentOffset(DeleteFile deleteFile); + + /** Returns the byte length of the deletion-vector blob. */ + Long contentSizeInBytes(DeleteFile deleteFile); + + /** + * Reads exactly the recorded deletion-vector byte range and returns its 64-bit row positions. + * + *

This is version-dispatched because Iceberg 1.6 does not expose the Puffin file format or + * deletion-vector manifest fields, and its {@code PositionDeleteIndex} lacks the decode and + * iteration APIs available in later releases. + */ + long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) throws IOException; + /** * Builds the constants map for a file scan task. Constants include partition values, * metadata columns, and any other fields that are constant for the entire scan task. diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java index ac429d80aea..ee5abc254c9 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java @@ -23,10 +23,12 @@ import org.apache.hadoop.fs.Path; import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.Schema; import org.apache.iceberg.Table; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.source.GpuSparkScan; @@ -62,6 +64,27 @@ public static int formatVersion(Table table) { return IMPL.formatVersion(table); } + public static boolean isDeletionVector(DeleteFile deleteFile) { + return IMPL.isDeletionVector(deleteFile); + } + + public static String referencedDataFile(DeleteFile deleteFile) { + return IMPL.referencedDataFile(deleteFile); + } + + public static Long contentOffset(DeleteFile deleteFile) { + return IMPL.contentOffset(deleteFile); + } + + public static Long contentSizeInBytes(DeleteFile deleteFile) { + return IMPL.contentSizeInBytes(deleteFile); + } + + public static long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + throws IOException { + return IMPL.readDeletionVector(deleteFile, inputFile); + } + public static Map constantsMap(FileScanTask task, Schema readSchema, Table table) { return IMPL.constantsMap(task, readSchema, table); diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala index 7dd5065d4f3..6d3455b4cda 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala @@ -16,17 +16,20 @@ package com.nvidia.spark.rapids.iceberg.data +import java.util.Objects + import scala.collection.JavaConverters._ import scala.collection.mutable.ArrayBuffer import com.nvidia.spark.rapids._ import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource} -import com.nvidia.spark.rapids.GpuMetric.{JOIN_TIME, OP_TIME_LEGACY} +import com.nvidia.spark.rapids.GpuMetric.{ICEBERG_DV_FILTER_TIME, JOIN_TIME, OP_TIME_LEGACY} import com.nvidia.spark.rapids.fileio.iceberg.{IcebergFileIO, IcebergInputFile} +import com.nvidia.spark.rapids.iceberg.ShimUtils import com.nvidia.spark.rapids.iceberg.data.GpuDeleteFilter2._ import com.nvidia.spark.rapids.iceberg.fieldIndex import com.nvidia.spark.rapids.iceberg.parquet.GpuIcebergParquetReaderConf -import org.apache.iceberg.{DeleteFile, FileContent, MetadataColumns, Schema} +import org.apache.iceberg.{DataFile, DeleteFile, FileContent, MetadataColumns, Schema, StructLike} import org.apache.iceberg.spark.GpuTypeToSparkType.toSparkType import org.apache.iceberg.types.Types import org.apache.iceberg.types.Types.NestedField @@ -35,7 +38,7 @@ import org.apache.iceberg.types.TypeUtil.getProjectedIds import org.apache.spark.internal.Logging import org.apache.spark.sql.catalyst.expressions.ExprId import org.apache.spark.sql.rapids.execution.HashedExistenceJoinIterator -import org.apache.spark.sql.types.{BooleanType, DataType} +import org.apache.spark.sql.types.{BooleanType, DataType, LongType} import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector} class GpuDeleteFilter( @@ -44,19 +47,37 @@ class GpuDeleteFilter( val inputFiles: Map[String, IcebergInputFile], val parquetConf: GpuIcebergParquetReaderConf, private val deletes: Seq[DeleteFile], - deleteLoaderProvider: => Option[GpuDeleteLoader] = None) extends Logging with AutoCloseable { + deleteLoaderProvider: => Option[GpuDeleteLoader] = None, + private val dataFile: Option[DataFile] = None) extends Logging with AutoCloseable { private lazy val readSchema = parquetConf.expectedSchema private lazy val deleteLoader = deleteLoaderProvider.getOrElse( new DefaultDeleteLoader(rapidsFileIO, inputFiles, parquetConf)) - private lazy val (eqDeleteFiles, posDeleteFiles) = { + private lazy val (eqDeleteFiles, posDeleteFiles, deletionVectorFiles) = { deletes.find(d => d.content() != FileContent.EQUALITY_DELETES && d.content() != FileContent.POSITION_DELETES) .foreach(d => { throw new UnsupportedOperationException(s"Unsupported delete content: ${d.content()}") }) - deletes.partition(_.content() == FileContent.EQUALITY_DELETES) + val (equalityDeletes, positionDeletes) = + deletes.partition(_.content() == FileContent.EQUALITY_DELETES) + val (deletionVectors, legacyPositionDeletes) = + positionDeletes.partition(ShimUtils.isDeletionVector) + + // Iceberg v3 planning gives a DV precedence over every matching legacy position-delete file. + // Apply the same rule defensively in case a mixed upgraded-table task reaches this filter. + val effectiveLegacyPositionDeletes = if (deletionVectors.nonEmpty) { + if (legacyPositionDeletes.nonEmpty) { + logDebug(s"Ignoring ${legacyPositionDeletes.size} legacy position-delete file(s) because " + + "the data file has a deletion vector") + } + Seq.empty + } else { + legacyPositionDeletes + } + + (equalityDeletes, effectiveLegacyPositionDeletes, deletionVectors) } /** @@ -66,8 +87,8 @@ class GpuDeleteFilter( * 1. Add all the fields in the [[GpuIcebergParquetReaderConf.expectedSchema]]. * 2. Add all missing fields which are required by the equality delete files, but not in the * [[GpuIcebergParquetReaderConf.expectedSchema]], if any. - * 3. Add [[MetadataColumns.ROW_POSITION]] and [[MetadataColumns.FILE_PATH]] if there are - * position delete files, and they are not in the schema. + * 3. Add [[MetadataColumns.ROW_POSITION]] for deletion vectors, or it and + * [[MetadataColumns.FILE_PATH]] for legacy position-delete files, if not already projected. */ lazy val requiredSchema: Schema = computeRequiredSchema() @@ -99,6 +120,7 @@ class GpuDeleteFilter( } private lazy val posDeleteContext = loadPosDeletesContext() + private lazy val deletionVectorContext = loadDeletionVectorContext() private lazy val eqDeleteContexts = loadEqDeleteContexts() @@ -162,7 +184,7 @@ class GpuDeleteFilter( val mergeFunc = (delCol1: GpuColumnVector, delCol2: GpuColumnVector) => { GpuColumnVector.from(delCol1.getBase.or(delCol2.getBase), BooleanType) } - (eqDeleteContexts ++ posDeleteContext) + (eqDeleteContexts ++ posDeleteContext ++ deletionVectorContext) .zipWithIndex .foldLeft(input) { case (inputBatches, (ctx, idx)) => @@ -196,6 +218,8 @@ class GpuDeleteFilter( if (posDeleteFiles.nonEmpty) { eqDeleteIds ++ DELETE_EXTRA_METADATA_COLUMN_IDS + } else if (deletionVectorFiles.nonEmpty) { + eqDeleteIds + MetadataColumns.ROW_POSITION.fieldId() } else { eqDeleteIds } @@ -266,6 +290,70 @@ class GpuDeleteFilter( parquetConf.metrics(JOIN_TIME))) } + private def loadDeletionVectorContext(): Option[DeleteFilterContext] = { + if (deletionVectorFiles.isEmpty) { + return None + } + + require(deletionVectorFiles.size == 1, + s"Expected one deletion vector per data file, found ${deletionVectorFiles.size}") + val deletionVector = deletionVectorFiles.head + validateDeletionVectorScope(deletionVector) + + val referencedDataFile = ShimUtils.referencedDataFile(deletionVector) + require(referencedDataFile != null, + s"Deletion vector ${ShimUtils.locationOf(deletionVector)} has no referenced data file") + val positions = deleteLoader.loadDeletionVector(deletionVector, referencedDataFile) + + val positionField = toSparkType(DV_DELETE_SCHEMA).fields.head + val buildKeys = Seq(GpuBoundReference(0, + positionField.dataType, + positionField.nullable)(ExprId(0), positionField.name)) + val positionColumnIndex = fieldIndex(requiredSchema, MetadataColumns.ROW_POSITION.fieldId()) + val probeKeys = Seq(GpuBoundReference(positionColumnIndex, + LongType, + nullable = false)(ExprId(0), MetadataColumns.ROW_POSITION.name())) + + Some(DeleteFilterContext(positions, + buildKeys, + probeKeys, + parquetConf.metrics(OP_TIME_LEGACY), + parquetConf.metrics.getOrElse(ICEBERG_DV_FILTER_TIME, NoopMetric))) + } + + private def validateDeletionVectorScope(deletionVector: DeleteFile): Unit = { + dataFile.foreach { file => + val dataFilePath = ShimUtils.locationOf(file) + val referencedDataFile = ShimUtils.referencedDataFile(deletionVector) + require(dataFilePath == referencedDataFile, + s"Deletion vector ${ShimUtils.locationOf(deletionVector)} references " + + s"$referencedDataFile, not $dataFilePath") + + val deleteSequenceNumber = deletionVector.dataSequenceNumber() + val dataSequenceNumber = file.dataSequenceNumber() + require(deleteSequenceNumber != null && dataSequenceNumber != null && + deleteSequenceNumber >= dataSequenceNumber, + s"Deletion vector sequence number $deleteSequenceNumber must be greater than or equal " + + s"to data file sequence number $dataSequenceNumber") + require(deletionVector.specId() == file.specId(), + s"Deletion vector spec ${deletionVector.specId()} does not match data file spec " + + s"${file.specId()}") + require(samePartition(deletionVector.partition(), file.partition()), + s"Deletion vector partition ${deletionVector.partition()} does not match data file " + + s"partition ${file.partition()}") + } + } + + private def samePartition(left: StructLike, right: StructLike): Boolean = { + if (left == null || right == null) { + return left == right + } + + left.size() == right.size() && (0 until left.size()).forall { index => + Objects.equals(left.get(index, classOf[Object]), right.get(index, classOf[Object])) + } + } + private def loadEqDeleteContexts(): Seq[DeleteFilterContext] = { if (eqDeleteFiles.isEmpty) { return Seq.empty @@ -339,6 +427,9 @@ object GpuDeleteFilter2 { MetadataColumns.DELETE_FILE_PATH, MetadataColumns.DELETE_FILE_POS) + private[iceberg] val DV_DELETE_SCHEMA: Schema = new Schema( + MetadataColumns.DELETE_FILE_POS) + private[iceberg] def mergeColumn( batch: ColumnarBatch, srcColIdx: Int, destColIdx: Int) @@ -415,4 +506,3 @@ private case class DeleteFilterContext( joinTime) } } - diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteLoader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteLoader.scala index 6e8221624c1..595784f59b0 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteLoader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteLoader.scala @@ -19,22 +19,29 @@ package com.nvidia.spark.rapids.iceberg.data import scala.collection.JavaConverters._ import scala.collection.mutable.ArrayBuffer -import ai.rapids.cudf.{Table => CudfTable} -import com.nvidia.spark.rapids.{GpuColumnVector, LazySpillableColumnarBatch} -import com.nvidia.spark.rapids.Arm.withResource +import ai.rapids.cudf.{ColumnVector => CudfColumnVector, Table => CudfTable} +import com.nvidia.spark.rapids.{GpuColumnVector, LazySpillableColumnarBatch, NoopMetric} +import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource} +import com.nvidia.spark.rapids.GpuMetric.{ICEBERG_DV_BYTES, ICEBERG_DV_DECODE_TIME, + ICEBERG_DV_POSITIONS} +import com.nvidia.spark.rapids.RmmRapidsRetryIterator.withRetryNoSplit import com.nvidia.spark.rapids.fileio.iceberg.{IcebergFileIO, IcebergInputFile} +import com.nvidia.spark.rapids.iceberg.ShimUtils import com.nvidia.spark.rapids.iceberg.ShimUtils.locationOf import com.nvidia.spark.rapids.iceberg.parquet._ import org.apache.iceberg.{DeleteFile, MetadataColumns, Schema} -import org.apache.spark.sql.types.DataType -import org.apache.spark.sql.vectorized.ColumnarBatch +import org.apache.spark.sql.types.{DataType, LongType} +import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector} trait GpuDeleteLoader { def loadDeletes(deletes: Seq[DeleteFile], schema: Schema, sparkTypes: Array[DataType]): LazySpillableColumnarBatch + + def loadDeletionVector(delete: DeleteFile, + dataFilePath: String): LazySpillableColumnarBatch } class DefaultDeleteLoader( @@ -42,7 +49,46 @@ class DefaultDeleteLoader( private val inputFiles: Map[String, IcebergInputFile], private val parquetConf: GpuIcebergParquetReaderConf) extends GpuDeleteLoader { - def loadDeletes(deletes: Seq[DeleteFile], + override def loadDeletionVector(delete: DeleteFile, + dataFilePath: String): LazySpillableColumnarBatch = { + require(ShimUtils.isDeletionVector(delete), + s"Expected a Puffin deletion vector, found ${delete.format()}") + + val referencedDataFile = ShimUtils.referencedDataFile(delete) + require(referencedDataFile != null, + s"Deletion vector ${locationOf(delete)} has no referenced data file") + require(dataFilePath == referencedDataFile, + s"Deletion vector ${locationOf(delete)} references $referencedDataFile, not $dataFilePath") + + val contentOffset = ShimUtils.contentOffset(delete) + val contentSize = ShimUtils.contentSizeInBytes(delete) + require(contentOffset != null && contentOffset >= 0, + s"Deletion vector ${locationOf(delete)} has invalid offset $contentOffset") + require(contentSize != null && contentSize >= 0 && contentSize <= Int.MaxValue, + s"Deletion vector ${locationOf(delete)} has invalid size $contentSize") + + val inputFile = inputFiles.getOrElse(locationOf(delete), + throw new IllegalArgumentException( + s"No decrypted input file was provided for deletion vector ${locationOf(delete)}")) + val decodeTime = parquetConf.metrics.getOrElse(ICEBERG_DV_DECODE_TIME, NoopMetric) + val positions = decodeTime.ns { + ShimUtils.readDeletionVector(delete, inputFile.getDelegate) + } + + parquetConf.metrics.getOrElse(ICEBERG_DV_BYTES, NoopMetric) += contentSize.longValue() + parquetConf.metrics.getOrElse(ICEBERG_DV_POSITIONS, NoopMetric) += positions.length.toLong + + withRetryNoSplit { + closeOnExcept(CudfColumnVector.fromLongs(positions: _*)) { positionsColumn => + val columns = Array[ColumnVector](GpuColumnVector.from(positionsColumn, LongType)) + withResource(new ColumnarBatch(columns, positions.length)) { batch => + LazySpillableColumnarBatch(batch, "Iceberg deletion vector") + } + } + } + } + + override def loadDeletes(deletes: Seq[DeleteFile], schema: Schema, sparkTypes: Array[DataType]): LazySpillableColumnarBatch = { val files = deletes.map(f => IcebergPartitionedFile(inputFiles(locationOf(f)))) @@ -117,4 +163,4 @@ class DefaultDeleteLoader( case SingleFile => SingleFile } } -} \ No newline at end of file +} diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala index 42db423c1ca..e239efdfa93 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala @@ -50,7 +50,8 @@ class GpuIcebergPartitionReader(private val task: GpuSparkInputPartition, case (file, task) => val filter = if (task.deletes().asScala.nonEmpty) { Some(new GpuDeleteFilter(rapidsFileIO, table.schema(), - inputFiles, conf, task.deletes().asScala.toSeq)) + inputFiles, conf, task.deletes().asScala.toSeq, + dataFile = Some(task.file()))) } else { None } diff --git a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java index f4768ceb251..dba8e33f8bf 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java @@ -22,7 +22,10 @@ import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import org.apache.hadoop.fs.Path; import org.apache.iceberg.*; +import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.IOUtil; +import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; @@ -51,6 +54,61 @@ public String locationOf(ContentFile f) { return f.location(); } + @Override + public boolean isDeletionVector(DeleteFile deleteFile) { + return deleteFile.format() == FileFormat.PUFFIN; + } + + @Override + public String referencedDataFile(DeleteFile deleteFile) { + return deleteFile.referencedDataFile(); + } + + @Override + public Long contentOffset(DeleteFile deleteFile) { + return deleteFile.contentOffset(); + } + + @Override + public Long contentSizeInBytes(DeleteFile deleteFile) { + return deleteFile.contentSizeInBytes(); + } + + @Override + public long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + throws IOException { + Long offset = deleteFile.contentOffset(); + Long size = deleteFile.contentSizeInBytes(); + if (offset == null || offset < 0) { + throw new IllegalArgumentException("Invalid deletion vector offset: " + offset); + } + if (size == null || size < 0 || size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Invalid deletion vector size: " + size); + } + + byte[] bytes = new byte[size.intValue()]; + try (org.apache.iceberg.io.SeekableInputStream stream = inputFile.newStream()) { + stream.seek(offset); + IOUtil.readFully(stream, bytes, 0, bytes.length); + } + + PositionDeleteIndex index = PositionDeleteIndex.deserialize(bytes, deleteFile); + long cardinality = index.cardinality(); + if (cardinality > Integer.MAX_VALUE) { + throw new IllegalArgumentException( + "Cannot materialize deletion vector with more than 2^31-1 positions: " + + cardinality); + } + long[] positions = new long[(int) cardinality]; + int[] next = new int[] {0}; + index.forEach(position -> positions[next[0]++] = position); + if (next[0] != positions.length) { + throw new IllegalStateException( + "Deletion vector cardinality changed while materializing positions"); + } + return positions; + } + @Override public Map constantsMap(FileScanTask task, Schema readSchema, Table table) { if (readSchema.findField(MetadataColumns.PARTITION_COLUMN_ID) != null) { diff --git a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java index 9eac937dc20..bda1417021e 100644 --- a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java @@ -22,7 +22,10 @@ import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import org.apache.hadoop.fs.Path; import org.apache.iceberg.*; +import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.IOUtil; +import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; @@ -51,6 +54,61 @@ public String locationOf(ContentFile f) { return f.location(); } + @Override + public boolean isDeletionVector(DeleteFile deleteFile) { + return deleteFile.format() == FileFormat.PUFFIN; + } + + @Override + public String referencedDataFile(DeleteFile deleteFile) { + return deleteFile.referencedDataFile(); + } + + @Override + public Long contentOffset(DeleteFile deleteFile) { + return deleteFile.contentOffset(); + } + + @Override + public Long contentSizeInBytes(DeleteFile deleteFile) { + return deleteFile.contentSizeInBytes(); + } + + @Override + public long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + throws IOException { + Long offset = deleteFile.contentOffset(); + Long size = deleteFile.contentSizeInBytes(); + if (offset == null || offset < 0) { + throw new IllegalArgumentException("Invalid deletion vector offset: " + offset); + } + if (size == null || size < 0 || size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Invalid deletion vector size: " + size); + } + + byte[] bytes = new byte[size.intValue()]; + try (org.apache.iceberg.io.SeekableInputStream stream = inputFile.newStream()) { + stream.seek(offset); + IOUtil.readFully(stream, bytes, 0, bytes.length); + } + + PositionDeleteIndex index = PositionDeleteIndex.deserialize(bytes, deleteFile); + long cardinality = index.cardinality(); + if (cardinality > Integer.MAX_VALUE) { + throw new IllegalArgumentException( + "Cannot materialize deletion vector with more than 2^31-1 positions: " + + cardinality); + } + long[] positions = new long[(int) cardinality]; + int[] next = new int[] {0}; + index.forEach(position -> positions[next[0]++] = position); + if (next[0] != positions.length) { + throw new IllegalStateException( + "Deletion vector cardinality changed while materializing positions"); + } + return positions; + } + @Override public Map constantsMap(FileScanTask task, Schema readSchema, Table table) { if (readSchema.findField(MetadataColumns.PARTITION_COLUMN_ID) != null) { diff --git a/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java b/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java index 5d344300528..ec0890df4b2 100644 --- a/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java @@ -20,6 +20,7 @@ import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import org.apache.iceberg.*; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.spark.source.GpuBaseReader; import org.apache.iceberg.spark.source.GpuSparkCopyOnWriteV1Scan; @@ -28,6 +29,7 @@ import org.apache.iceberg.util.PartitionUtil; import org.apache.spark.sql.connector.read.Scan; +import java.io.IOException; import java.util.Collections; import java.util.Map; @@ -59,6 +61,33 @@ public String locationOf(ContentFile f) { return f.path().toString(); } + @Override + public boolean isDeletionVector(DeleteFile deleteFile) { + return false; + } + + @Override + public String referencedDataFile(DeleteFile deleteFile) { + return null; + } + + @Override + public Long contentOffset(DeleteFile deleteFile) { + return null; + } + + @Override + public Long contentSizeInBytes(DeleteFile deleteFile) { + return null; + } + + @Override + public long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + throws IOException { + throw new UnsupportedOperationException( + "Iceberg 1.6 does not support Puffin deletion vectors"); + } + @Override public Map constantsMap(FileScanTask task, Schema readSchema, Table table) { if (readSchema.findField(MetadataColumns.PARTITION_COLUMN_ID) != null) { diff --git a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java index 9321e75ed51..c476c521db3 100644 --- a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java @@ -19,7 +19,10 @@ import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import org.apache.iceberg.*; +import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.IOUtil; +import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; import org.apache.iceberg.spark.SparkUtil; @@ -29,6 +32,7 @@ import org.apache.iceberg.util.PartitionUtil; import org.apache.spark.sql.connector.read.Scan; +import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -45,6 +49,61 @@ public String locationOf(ContentFile f) { return f.location(); } + @Override + public boolean isDeletionVector(DeleteFile deleteFile) { + return deleteFile.format() == FileFormat.PUFFIN; + } + + @Override + public String referencedDataFile(DeleteFile deleteFile) { + return deleteFile.referencedDataFile(); + } + + @Override + public Long contentOffset(DeleteFile deleteFile) { + return deleteFile.contentOffset(); + } + + @Override + public Long contentSizeInBytes(DeleteFile deleteFile) { + return deleteFile.contentSizeInBytes(); + } + + @Override + public long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + throws IOException { + Long offset = deleteFile.contentOffset(); + Long size = deleteFile.contentSizeInBytes(); + if (offset == null || offset < 0) { + throw new IllegalArgumentException("Invalid deletion vector offset: " + offset); + } + if (size == null || size < 0 || size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Invalid deletion vector size: " + size); + } + + byte[] bytes = new byte[size.intValue()]; + try (org.apache.iceberg.io.SeekableInputStream stream = inputFile.newStream()) { + stream.seek(offset); + IOUtil.readFully(stream, bytes, 0, bytes.length); + } + + PositionDeleteIndex index = PositionDeleteIndex.deserialize(bytes, deleteFile); + long cardinality = index.cardinality(); + if (cardinality > Integer.MAX_VALUE) { + throw new IllegalArgumentException( + "Cannot materialize deletion vector with more than 2^31-1 positions: " + + cardinality); + } + long[] positions = new long[(int) cardinality]; + int[] next = new int[] {0}; + index.forEach(position -> positions[next[0]++] = position); + if (next[0] != positions.length) { + throw new IllegalStateException( + "Deletion vector cardinality changed while materializing positions"); + } + return positions; + } + @Override public Map constantsMap(FileScanTask task, Schema readSchema, Table table) { if (readSchema.findField(MetadataColumns.PARTITION_COLUMN_ID) != null) { diff --git a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py index 1584ed1948e..660ff88f06c 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py @@ -23,9 +23,10 @@ setup_base_iceberg_table, _add_eq_deletes, _change_table, \ representative_eq_column_combinations, eq_reader_canary_pairs, \ iceberg_unsupported_mark, create_iceberg_table, \ - iceberg_base_table_cols, iceberg_gens_list, get_full_table_name + iceberg_base_table_cols, iceberg_gens_list, get_full_table_name, \ + supports_iceberg_v3, ICEBERG_V3_UNSUPPORTED_REASON from data_gen import gen_df, get_datagen_seed, int_gen, long_gen, string_gen -from marks import iceberg, ignore_order +from marks import iceberg, ignore_order, validate_execs_in_gpu_plan from spark_session import with_gpu_session, with_cpu_session pytestmark = iceberg_unsupported_mark @@ -97,6 +98,44 @@ def test_iceberg_v2_position_delete(spark_tmp_table_factory, reader_type): lambda spark: spark.table(table_name), conf={'spark.rapids.sql.format.parquet.reader.type': reader_type}) + +@iceberg +@ignore_order(local=True) +@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) +@pytest.mark.parametrize('reader_type', rapids_reader_types) +@validate_execs_in_gpu_plan('GpuBatchScanExec') +def test_iceberg_v3_deletion_vector(spark_tmp_table_factory, reader_type): + table_name = get_full_table_name(spark_tmp_table_factory) + create_iceberg_table( + table_name, + table_prop={ + 'format-version': '3', + 'write.delete.mode': 'merge-on-read', + }, + df_gen=lambda spark: spark.range(256).selectExpr( + 'id', 'CAST(id % 11 AS INT) AS value')) + + def setup_deletion_vector(spark): + spark.range(256).selectExpr( + 'id', 'CAST(id % 11 AS INT) AS value').writeTo(table_name).append() + spark.sql(f'DELETE FROM {table_name} WHERE id % 5 = 0') + spark.sql(f'REFRESH TABLE {table_name}') + delete_formats = { + row.file_format for row in + spark.sql(f'SELECT file_format FROM {table_name}.delete_files').collect() + } + assert 'PUFFIN' in delete_formats, \ + f'Expected a Puffin deletion vector, found delete formats {delete_formats}' + + with_cpu_session(setup_deletion_vector) + + assert_gpu_and_cpu_are_equal_collect( + lambda spark: spark.sql(f'SELECT id, value FROM {table_name}'), + conf={ + 'spark.rapids.sql.format.iceberg.v3.enabled': 'true', + 'spark.rapids.sql.format.parquet.reader.type': reader_type, + }) + @iceberg @ignore_order(local=True) @pytest.mark.parametrize('reader_type', rapids_reader_types) @@ -272,4 +311,3 @@ def setup_table(spark): assert_gpu_and_cpu_are_equal_collect( lambda spark: spark.table(table_name), conf={'spark.rapids.sql.format.parquet.reader.type': reader_type}) - diff --git a/sql-plugin/src/main/scala/com/nvidia/spark/rapids/GpuBatchScanExecMetrics.scala b/sql-plugin/src/main/scala/com/nvidia/spark/rapids/GpuBatchScanExecMetrics.scala index f2a0327c652..ccf34ac6776 100644 --- a/sql-plugin/src/main/scala/com/nvidia/spark/rapids/GpuBatchScanExecMetrics.scala +++ b/sql-plugin/src/main/scala/com/nvidia/spark/rapids/GpuBatchScanExecMetrics.scala @@ -42,6 +42,14 @@ trait GpuBatchScanExecMetrics extends GpuExec { createNanoTimingMetric(MODERATE_LEVEL, DESCRIPTION_ICEBERG_BUILD_ACTION_TIME), ICEBERG_POST_PROCESS_TIME -> createNanoTimingMetric(MODERATE_LEVEL, DESCRIPTION_ICEBERG_POST_PROCESS_TIME), + ICEBERG_DV_BYTES -> + createSizeMetric(MODERATE_LEVEL, DESCRIPTION_ICEBERG_DV_BYTES), + ICEBERG_DV_POSITIONS -> + createMetric(MODERATE_LEVEL, DESCRIPTION_ICEBERG_DV_POSITIONS), + ICEBERG_DV_DECODE_TIME -> + createNanoTimingMetric(MODERATE_LEVEL, DESCRIPTION_ICEBERG_DV_DECODE_TIME), + ICEBERG_DV_FILTER_TIME -> + createNanoTimingMetric(MODERATE_LEVEL, DESCRIPTION_ICEBERG_DV_FILTER_TIME), ) ++ fileCacheMetrics ++ scanCustomMetrics lazy val fileCacheMetrics: Map[String, GpuMetric] = createFileCacheMetrics() diff --git a/sql-plugin/src/main/scala/com/nvidia/spark/rapids/GpuMetrics.scala b/sql-plugin/src/main/scala/com/nvidia/spark/rapids/GpuMetrics.scala index c8e66d62483..bce16780e67 100644 --- a/sql-plugin/src/main/scala/com/nvidia/spark/rapids/GpuMetrics.scala +++ b/sql-plugin/src/main/scala/com/nvidia/spark/rapids/GpuMetrics.scala @@ -143,6 +143,10 @@ object GpuMetric extends Logging { val ASYNC_READ_TIME = "shuffleAsyncReadTime" val ICEBERG_BUILD_ACTION_TIME = "icebergBuildActionTime" val ICEBERG_POST_PROCESS_TIME = "icebergPostProcessTime" + val ICEBERG_DV_BYTES = "icebergDvBytes" + val ICEBERG_DV_POSITIONS = "icebergDvPositions" + val ICEBERG_DV_DECODE_TIME = "icebergDvDecodeTime" + val ICEBERG_DV_FILTER_TIME = "icebergDvFilterTime" // Metric Descriptions. val DESCRIPTION_BUFFER_TIME = "buffer time" @@ -201,6 +205,10 @@ object GpuMetric extends Logging { val DESCRIPTION_ASYNC_READ_TIME = "async read time" val DESCRIPTION_ICEBERG_BUILD_ACTION_TIME = "iceberg build action tree time" val DESCRIPTION_ICEBERG_POST_PROCESS_TIME = "iceberg post process time" + val DESCRIPTION_ICEBERG_DV_BYTES = "Iceberg deletion vector bytes loaded" + val DESCRIPTION_ICEBERG_DV_POSITIONS = "Iceberg deletion vector positions loaded" + val DESCRIPTION_ICEBERG_DV_DECODE_TIME = "Iceberg deletion vector decode time" + val DESCRIPTION_ICEBERG_DV_FILTER_TIME = "Iceberg deletion vector filtering time" /** * Determine if a GpuMetric wraps a TimingMetric or NanoTimingMetric. diff --git a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilterSuite.scala b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilterSuite.scala index 71eccc21711..cfb229bfaec 100644 --- a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilterSuite.scala +++ b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilterSuite.scala @@ -186,6 +186,146 @@ class GpuDeleteFilterSuite extends AnyFunSuite with BeforeAndAfterAll { } } + test("Filter with Puffin deletion vector") { + if (!supportsDeletionVectors) { + cancel("Iceberg runtime does not expose Puffin deletion vectors") + } + + val dataFilePath = PooledTableGen.PooledFilePaths.head + val deletedPositions = Seq(1L, 3L, 5L) + val deleteFile = deletionVectorFile(dataFilePath, deletedPositions.size) + val tableGen = new PooledTableGen(tableGenSchema(TABLE_SCHEMA, Seq(deleteFile))) + val deleteLoader = new TestGpuDeleteLoader(tableGen, + rows = 57, + deletionVectors = Some(Map(dataFilePath -> deletedPositions))) + val deleteFilter = gpuDeleteFilterOf(TABLE_SCHEMA, Seq(deleteFile), Some(deleteLoader)) + + assert(fieldIndex(deleteFilter.requiredSchema, + MetadataColumns.ROW_POSITION.fieldId()) >= 0) + assert(!deleteFilter.requiredSchema.columns().asScala + .exists(_.fieldId() == MetadataColumns.FILE_PATH.fieldId())) + + val positionColumnIndex = fieldIndex(deleteFilter.requiredSchema, + MetadataColumns.ROW_POSITION.fieldId()) + val isDeletedColumnIndex = deleteFilter.requiredSchema.columns().size() + val input = Iterator(tableGen.toColumnarBatch(NUM_ROWS)) + + deleteFilter.filter(input).foreach { resultBatch => + withResource(resultBatch) { _ => + val bases = GpuColumnVector.extractBases(resultBatch) + withResource(bases.safeMap(_.copyToHost())) { hostColumns => + for (row <- 0 until resultBatch.numRows()) { + val position = hostColumns(positionColumnIndex).getLong(row) + assert(hostColumns(isDeletedColumnIndex).getBoolean(row) == + deletedPositions.contains(position)) + } + } + } + } + } + + test("Deletion vector supersedes legacy position deletes") { + if (!supportsDeletionVectors) { + cancel("Iceberg runtime does not expose Puffin deletion vectors") + } + + val dataFilePath = PooledTableGen.PooledFilePaths.head + val deletionVector = deletionVectorFile(dataFilePath, cardinality = 1) + val legacyPositionDelete = posDeleteFile() + val deleteFiles = Seq(legacyPositionDelete, deletionVector) + val tableGen = new PooledTableGen(tableGenSchema(TABLE_SCHEMA, deleteFiles)) + val deleteLoader = new TestGpuDeleteLoader(tableGen, + rows = 57, + posDeletes = Map(dataFilePath -> Seq(2L)), + deletionVectors = Some(Map(dataFilePath -> Seq(1L)))) + val deleteFilter = gpuDeleteFilterOf(TABLE_SCHEMA, deleteFiles, Some(deleteLoader)) + + val positionColumnIndex = fieldIndex(deleteFilter.requiredSchema, + MetadataColumns.ROW_POSITION.fieldId()) + val isDeletedColumnIndex = deleteFilter.requiredSchema.columns().size() + val input = Iterator(tableGen.toColumnarBatch(4)) + + deleteFilter.filter(input).foreach { resultBatch => + withResource(resultBatch) { _ => + val bases = GpuColumnVector.extractBases(resultBatch) + withResource(bases.safeMap(_.copyToHost())) { hostColumns => + for (row <- 0 until resultBatch.numRows()) { + val position = hostColumns(positionColumnIndex).getLong(row) + assert(hostColumns(isDeletedColumnIndex).getBoolean(row) == (position == 1L)) + } + } + } + } + } + + test("Empty deletion vector") { + if (!supportsDeletionVectors) { + cancel("Iceberg runtime does not expose Puffin deletion vectors") + } + + val dataFilePath = PooledTableGen.PooledFilePaths.head + val deletionVector = deletionVectorFile(dataFilePath, cardinality = 0) + val tableGen = new PooledTableGen(tableGenSchema(TABLE_SCHEMA, Seq(deletionVector))) + val deleteLoader = new TestGpuDeleteLoader(tableGen, + rows = 57, + deletionVectors = Some(Map(dataFilePath -> Seq.empty))) + val deleteFilter = gpuDeleteFilterOf(TABLE_SCHEMA, Seq(deletionVector), Some(deleteLoader)) + val isDeletedColumnIndex = deleteFilter.requiredSchema.columns().size() + + deleteFilter.filter(Iterator(tableGen.toColumnarBatch(4))).foreach { resultBatch => + withResource(resultBatch) { _ => + val bases = GpuColumnVector.extractBases(resultBatch) + withResource(bases.safeMap(_.copyToHost())) { hostColumns => + for (row <- 0 until resultBatch.numRows()) { + assert(!hostColumns(isDeletedColumnIndex).getBoolean(row)) + } + } + } + } + } + + test("Deletion vector composes with equality deletes and IS_DELETED") { + if (!supportsDeletionVectors) { + cancel("Iceberg runtime does not expose Puffin deletion vectors") + } + + val dataFilePath = PooledTableGen.PooledFilePaths.head + val deletedPositions = Seq(1L, 3L, 5L) + val equalityFieldIds = Seq(1, 3) + val equalityDelete = eqDeleteFile(equalityFieldIds) + val deletionVector = deletionVectorFile(dataFilePath, deletedPositions.size) + val deleteFiles = Seq(equalityDelete, deletionVector) + val tableSchema = tableSchemaWithIsDeletedColumn() + val tableGen = new PooledTableGen(tableGenSchema(tableSchema, deleteFiles)) + val deleteLoader = new TestGpuDeleteLoader(tableGen, + rows = 57, + deletionVectors = Some(Map(dataFilePath -> deletedPositions))) + val deleteFilter = gpuDeleteFilterOf(tableSchema, deleteFiles, Some(deleteLoader)) + val equalityColumnIndices = equalityFieldIds.map(fieldIndex(deleteFilter.requiredSchema, _)) + val equalityDeleteValues = deleteLoader.loadEqDeletes(equalityFieldIds) + val positionColumnIndex = fieldIndex(deleteFilter.requiredSchema, + MetadataColumns.ROW_POSITION.fieldId()) + val isDeletedColumnIndex = fieldIndex(deleteFilter.requiredSchema, + MetadataColumns.IS_DELETED.fieldId()) + + deleteFilter.filter(Iterator(tableGen.toColumnarBatch(NUM_ROWS))).foreach { resultBatch => + withResource(resultBatch) { _ => + val bases = GpuColumnVector.extractBases(resultBatch) + withResource(bases.safeMap(_.copyToHost())) { hostColumns => + for (row <- 0 until resultBatch.numRows()) { + val equalityValues = equalityColumnIndices.map { index => + valueOf(hostColumns(index), Integer.valueOf(row)) + } + val position = hostColumns(positionColumnIndex).getLong(row) + val expectedDeleted = deletedPositions.contains(position) || + equalityDeleteValues.exists(_.sameElements(equalityValues)) + assert(hostColumns(isDeletedColumnIndex).getBoolean(row) == expectedDeleted) + } + } + } + } + } + test("Filter with eq deletes and position deletes") { val eqFieldIdSets = Seq(Seq(1, 3), Seq(2, 6)) val f = fixture(eqFieldIdSets.map(eqDeleteFile) :+ posDeleteFile()) @@ -335,9 +475,26 @@ class GpuDeleteFilterSuite extends AnyFunSuite with BeforeAndAfterAll { class TestGpuDeleteLoader(private val tableGen: PooledTableGen, private val rows: Int, - private val posDeletes: Map[String, Seq[Long]] = Map.empty + private val posDeletes: Map[String, Seq[Long]] = Map.empty, + private val deletionVectors: Option[Map[String, Seq[Long]]] = None ) extends GpuDeleteLoader { + override def loadDeletionVector(delete: DeleteFile, + dataFilePath: String): LazySpillableColumnarBatch = { + val rowPositions = deletionVectors.getOrElse(posDeletes).getOrElse(dataFilePath, Seq.empty) + .map(java.lang.Long.valueOf) + val hostVectors = Seq(HostColumnVector.fromBoxedLongs(rowPositions: _*)) + + withResource(hostVectors) { _ => + val columns = hostVectors.safeMap(_.copyToDevice()) + .safeMap(cv => GpuColumnVector.from(cv, LongType)) + .toArray[ColumnVector] + withResource(new ColumnarBatch(columns, rowPositions.length)) { batch => + LazySpillableColumnarBatch(batch, "DV deletes build") + } + } + } + override def loadDeletes(deletes: Seq[DeleteFile], schema: Schema, sparkTypes: Array[DataType]): LazySpillableColumnarBatch = { @@ -458,6 +615,28 @@ private object TestGpuDeleteLoader { .build() } + def supportsDeletionVectors: Boolean = { + FileFormat.values().exists(_.name() == "PUFFIN") + } + + def deletionVectorFile(referencedDataFile: String, cardinality: Int): DeleteFile = { + val builder = FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofPositionDeletes() + .withPath("/tmp/delete-vectors.puffin") + .withFormat(FileFormat.valueOf("PUFFIN")) + .withRecordCount(cardinality) + .withFileSizeInBytes(1024) + + val builderClass = builder.getClass + builderClass.getMethod("withReferencedDataFile", classOf[CharSequence]) + .invoke(builder, referencedDataFile) + builderClass.getMethod("withContentOffset", java.lang.Long.TYPE) + .invoke(builder, Long.box(64L)) + builderClass.getMethod("withContentSizeInBytes", java.lang.Long.TYPE) + .invoke(builder, Long.box(128L)) + builder.build() + } + def gpuDeleteFilterOf( tableSchema: Schema, deleteFiles: Seq[DeleteFile], diff --git a/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala b/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala new file mode 100644 index 00000000000..8b50cec52d7 --- /dev/null +++ b/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * Licensed 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. + */ + +/*** spark-rapids-shim-json-lines +{"spark": "354"} +{"spark": "355"} +{"spark": "356"} +{"spark": "357"} +{"spark": "358"} +{"spark": "359"} +spark-rapids-shim-json-lines ***/ +package com.nvidia.spark.rapids.iceberg + +import java.nio.ByteBuffer +import java.nio.file.Files + +import scala.collection.JavaConverters._ + +import org.apache.hadoop.conf.Configuration +import org.apache.hadoop.fs.{Path => HadoopPath} +import org.apache.iceberg.{DeleteFile, FileFormat, FileMetadata, PartitionSpec} +import org.apache.iceberg.deletes.Deletes +import org.apache.iceberg.hadoop.HadoopInputFile +import org.apache.iceberg.io.CloseableIterable +import org.scalatest.funsuite.AnyFunSuite + +class DeletionVectorReaderSuite extends AnyFunSuite { + test("read only the deletion vector manifest byte range") { + val expectedPositions = Seq(0L, 17L, (1L << 40) + 3L) + val prefixBlob = serializePositions(Seq(4L, 8L)) + val targetBlob = serializePositions(expectedPositions) + val suffixBlob = serializePositions(Seq(99L)) + val fileBytes = prefixBlob ++ targetBlob ++ suffixBlob + val path = Files.createTempFile("iceberg-dv-range", ".puffin") + + try { + Files.write(path, fileBytes) + val deleteFile = deletionVectorFile(path.toUri.toString, + referencedDataFile = "/tmp/data.parquet", + offset = prefixBlob.length, + size = targetBlob.length, + cardinality = expectedPositions.size, + fileSize = fileBytes.length) + val inputFile = HadoopInputFile.fromPath(new HadoopPath(path.toUri), new Configuration()) + + assert(ShimUtils.readDeletionVector(deleteFile, inputFile).toSeq == expectedPositions) + } finally { + Files.deleteIfExists(path) + } + } + + test("read an empty deletion vector") { + val blob = serializePositions(Seq.empty) + val path = Files.createTempFile("iceberg-empty-dv", ".puffin") + + try { + Files.write(path, blob) + val deleteFile = deletionVectorFile(path.toUri.toString, + referencedDataFile = "/tmp/data.parquet", + offset = 0, + size = blob.length, + cardinality = 0, + fileSize = blob.length) + val inputFile = HadoopInputFile.fromPath(new HadoopPath(path.toUri), new Configuration()) + + assert(ShimUtils.readDeletionVector(deleteFile, inputFile).isEmpty) + } finally { + Files.deleteIfExists(path) + } + } + + private def serializePositions(positions: Seq[Long]): Array[Byte] = { + val boxedPositions = positions.map(Long.box).asJava + val index = Deletes.toPositionIndex(CloseableIterable.withNoopClose(boxedPositions)) + copyBytes(index.serialize()) + } + + private def copyBytes(buffer: ByteBuffer): Array[Byte] = { + val copy = buffer.duplicate() + val bytes = new Array[Byte](copy.remaining()) + copy.get(bytes) + bytes + } + + private def deletionVectorFile( + location: String, + referencedDataFile: String, + offset: Long, + size: Long, + cardinality: Long, + fileSize: Long): DeleteFile = { + FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofPositionDeletes() + .withPath(location) + .withFormat(FileFormat.PUFFIN) + .withReferencedDataFile(referencedDataFile) + .withContentOffset(offset) + .withContentSizeInBytes(size) + .withRecordCount(cardinality) + .withFileSizeInBytes(fileSize) + .build() + } +} From 15d4185f0bf1da9704e1cd9a39b25941bec7e20a Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Mon, 17 Aug 2026 15:14:25 +0800 Subject: [PATCH 02/18] Use native cuDF Parquet deletion vectors for Iceberg Signed-off-by: Ray Liu --- .../rapids/iceberg/IcebergDeletionVector.java | 35 +++ .../rapids/iceberg/IcebergShimUtils.java | 5 +- .../spark/rapids/iceberg/ShimUtils.java | 3 +- .../rapids/iceberg/data/GpuDeleteFilter.scala | 111 +-------- .../rapids/iceberg/data/GpuDeleteLoader.scala | 35 +-- .../iceberg/data/GpuDeletionVector.scala | 86 +++++++ .../parquet/GpuIcebergDeletionVector.scala | 230 ++++++++++++++++++ .../GpuMultiThreadIcebergParquetReader.scala | 99 +++++++- .../GpuParquetReaderPostProcessor.scala | 23 +- .../GpuSingleThreadIcebergParquetReader.scala | 55 ++++- .../spark/rapids/iceberg/parquet/reader.scala | 25 +- .../source/GpuIcebergPartitionReader.scala | 28 ++- .../iceberg/iceberg110x/ShimUtilsImpl.java | 49 ++-- .../iceberg/iceberg111x/ShimUtilsImpl.java | 49 ++-- .../iceberg/iceberg16x/ShimUtilsImpl.java | 3 +- .../iceberg/iceberg19x/ShimUtilsImpl.java | 49 ++-- .../iceberg/GpuPostProcessorSuite.scala | 46 ++++ .../iceberg/data/GpuDeleteFilterSuite.scala | 184 +------------- .../iceberg/DeletionVectorReaderSuite.scala | 16 +- 19 files changed, 750 insertions(+), 381 deletions(-) create mode 100644 iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java create mode 100644 iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeletionVector.scala create mode 100644 iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuIcebergDeletionVector.scala diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java new file mode 100644 index 00000000000..29f2b39dc89 --- /dev/null +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * Licensed 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 com.nvidia.spark.rapids.iceberg; + +/** + * A validated Iceberg deletion vector kept in its compressed Roaring-bitmap representation. + * + *

The serialized bytes use the portable 64-bit Roaring format expected by cuDF. Range + * counting is provided by the version-specific Iceberg implementation so the common module does + * not depend on deletion-index APIs that are absent from Iceberg 1.6. + */ +public interface IcebergDeletionVector { + /** Returns the portable serialized 64-bit Roaring bitmap expected by cuDF. */ + byte[] serializedBitmap(); + + /** Returns the number of positions in the deletion vector. */ + long cardinality(); + + /** Returns the number of deleted positions contained in the supplied file-row ranges. */ + long countDeletedRows(long[] rowGroupOffsets, int[] rowGroupNumRows); +} diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java index 05f1b67cb18..d6172e32ddb 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java @@ -71,13 +71,14 @@ public interface IcebergShimUtils { Long contentSizeInBytes(DeleteFile deleteFile); /** - * Reads exactly the recorded deletion-vector byte range and returns its 64-bit row positions. + * Reads exactly the recorded deletion-vector byte range and returns its compressed bitmap. * *

This is version-dispatched because Iceberg 1.6 does not expose the Puffin file format or * deletion-vector manifest fields, and its {@code PositionDeleteIndex} lacks the decode and * iteration APIs available in later releases. */ - long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) throws IOException; + IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + throws IOException; /** * Builds the constants map for a file scan task. Constants include partition values, diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java index ee5abc254c9..649d846a229 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java @@ -80,7 +80,8 @@ public static Long contentSizeInBytes(DeleteFile deleteFile) { return IMPL.contentSizeInBytes(deleteFile); } - public static long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + public static IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, + InputFile inputFile) throws IOException { return IMPL.readDeletionVector(deleteFile, inputFile); } diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala index 6d3455b4cda..f7dcbd43619 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, NVIDIA CORPORATION. + * Copyright (c) 2025-2026, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,20 +16,18 @@ package com.nvidia.spark.rapids.iceberg.data -import java.util.Objects - import scala.collection.JavaConverters._ import scala.collection.mutable.ArrayBuffer import com.nvidia.spark.rapids._ import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource} -import com.nvidia.spark.rapids.GpuMetric.{ICEBERG_DV_FILTER_TIME, JOIN_TIME, OP_TIME_LEGACY} +import com.nvidia.spark.rapids.GpuMetric.{JOIN_TIME, OP_TIME_LEGACY} import com.nvidia.spark.rapids.fileio.iceberg.{IcebergFileIO, IcebergInputFile} import com.nvidia.spark.rapids.iceberg.ShimUtils import com.nvidia.spark.rapids.iceberg.data.GpuDeleteFilter2._ import com.nvidia.spark.rapids.iceberg.fieldIndex import com.nvidia.spark.rapids.iceberg.parquet.GpuIcebergParquetReaderConf -import org.apache.iceberg.{DataFile, DeleteFile, FileContent, MetadataColumns, Schema, StructLike} +import org.apache.iceberg.{DeleteFile, FileContent, MetadataColumns, Schema} import org.apache.iceberg.spark.GpuTypeToSparkType.toSparkType import org.apache.iceberg.types.Types import org.apache.iceberg.types.Types.NestedField @@ -38,7 +36,7 @@ import org.apache.iceberg.types.TypeUtil.getProjectedIds import org.apache.spark.internal.Logging import org.apache.spark.sql.catalyst.expressions.ExprId import org.apache.spark.sql.rapids.execution.HashedExistenceJoinIterator -import org.apache.spark.sql.types.{BooleanType, DataType, LongType} +import org.apache.spark.sql.types.{BooleanType, DataType} import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector} class GpuDeleteFilter( @@ -47,14 +45,13 @@ class GpuDeleteFilter( val inputFiles: Map[String, IcebergInputFile], val parquetConf: GpuIcebergParquetReaderConf, private val deletes: Seq[DeleteFile], - deleteLoaderProvider: => Option[GpuDeleteLoader] = None, - private val dataFile: Option[DataFile] = None) extends Logging with AutoCloseable { + deleteLoaderProvider: => Option[GpuDeleteLoader] = None) extends Logging with AutoCloseable { private lazy val readSchema = parquetConf.expectedSchema private lazy val deleteLoader = deleteLoaderProvider.getOrElse( new DefaultDeleteLoader(rapidsFileIO, inputFiles, parquetConf)) - private lazy val (eqDeleteFiles, posDeleteFiles, deletionVectorFiles) = { + private lazy val (eqDeleteFiles, posDeleteFiles) = { deletes.find(d => d.content() != FileContent.EQUALITY_DELETES && d.content() != FileContent.POSITION_DELETES) .foreach(d => { @@ -62,22 +59,9 @@ class GpuDeleteFilter( }) val (equalityDeletes, positionDeletes) = deletes.partition(_.content() == FileContent.EQUALITY_DELETES) - val (deletionVectors, legacyPositionDeletes) = - positionDeletes.partition(ShimUtils.isDeletionVector) - - // Iceberg v3 planning gives a DV precedence over every matching legacy position-delete file. - // Apply the same rule defensively in case a mixed upgraded-table task reaches this filter. - val effectiveLegacyPositionDeletes = if (deletionVectors.nonEmpty) { - if (legacyPositionDeletes.nonEmpty) { - logDebug(s"Ignoring ${legacyPositionDeletes.size} legacy position-delete file(s) because " + - "the data file has a deletion vector") - } - Seq.empty - } else { - legacyPositionDeletes - } - - (equalityDeletes, effectiveLegacyPositionDeletes, deletionVectors) + require(!positionDeletes.exists(ShimUtils.isDeletionVector), + "Deletion vectors must be applied by the native Parquet reader") + (equalityDeletes, positionDeletes) } /** @@ -87,8 +71,8 @@ class GpuDeleteFilter( * 1. Add all the fields in the [[GpuIcebergParquetReaderConf.expectedSchema]]. * 2. Add all missing fields which are required by the equality delete files, but not in the * [[GpuIcebergParquetReaderConf.expectedSchema]], if any. - * 3. Add [[MetadataColumns.ROW_POSITION]] for deletion vectors, or it and - * [[MetadataColumns.FILE_PATH]] for legacy position-delete files, if not already projected. + * 3. Add [[MetadataColumns.ROW_POSITION]] and [[MetadataColumns.FILE_PATH]] for legacy + * position-delete files, if not already projected. */ lazy val requiredSchema: Schema = computeRequiredSchema() @@ -120,7 +104,6 @@ class GpuDeleteFilter( } private lazy val posDeleteContext = loadPosDeletesContext() - private lazy val deletionVectorContext = loadDeletionVectorContext() private lazy val eqDeleteContexts = loadEqDeleteContexts() @@ -184,7 +167,7 @@ class GpuDeleteFilter( val mergeFunc = (delCol1: GpuColumnVector, delCol2: GpuColumnVector) => { GpuColumnVector.from(delCol1.getBase.or(delCol2.getBase), BooleanType) } - (eqDeleteContexts ++ posDeleteContext ++ deletionVectorContext) + (eqDeleteContexts ++ posDeleteContext) .zipWithIndex .foldLeft(input) { case (inputBatches, (ctx, idx)) => @@ -218,8 +201,6 @@ class GpuDeleteFilter( if (posDeleteFiles.nonEmpty) { eqDeleteIds ++ DELETE_EXTRA_METADATA_COLUMN_IDS - } else if (deletionVectorFiles.nonEmpty) { - eqDeleteIds + MetadataColumns.ROW_POSITION.fieldId() } else { eqDeleteIds } @@ -290,70 +271,6 @@ class GpuDeleteFilter( parquetConf.metrics(JOIN_TIME))) } - private def loadDeletionVectorContext(): Option[DeleteFilterContext] = { - if (deletionVectorFiles.isEmpty) { - return None - } - - require(deletionVectorFiles.size == 1, - s"Expected one deletion vector per data file, found ${deletionVectorFiles.size}") - val deletionVector = deletionVectorFiles.head - validateDeletionVectorScope(deletionVector) - - val referencedDataFile = ShimUtils.referencedDataFile(deletionVector) - require(referencedDataFile != null, - s"Deletion vector ${ShimUtils.locationOf(deletionVector)} has no referenced data file") - val positions = deleteLoader.loadDeletionVector(deletionVector, referencedDataFile) - - val positionField = toSparkType(DV_DELETE_SCHEMA).fields.head - val buildKeys = Seq(GpuBoundReference(0, - positionField.dataType, - positionField.nullable)(ExprId(0), positionField.name)) - val positionColumnIndex = fieldIndex(requiredSchema, MetadataColumns.ROW_POSITION.fieldId()) - val probeKeys = Seq(GpuBoundReference(positionColumnIndex, - LongType, - nullable = false)(ExprId(0), MetadataColumns.ROW_POSITION.name())) - - Some(DeleteFilterContext(positions, - buildKeys, - probeKeys, - parquetConf.metrics(OP_TIME_LEGACY), - parquetConf.metrics.getOrElse(ICEBERG_DV_FILTER_TIME, NoopMetric))) - } - - private def validateDeletionVectorScope(deletionVector: DeleteFile): Unit = { - dataFile.foreach { file => - val dataFilePath = ShimUtils.locationOf(file) - val referencedDataFile = ShimUtils.referencedDataFile(deletionVector) - require(dataFilePath == referencedDataFile, - s"Deletion vector ${ShimUtils.locationOf(deletionVector)} references " + - s"$referencedDataFile, not $dataFilePath") - - val deleteSequenceNumber = deletionVector.dataSequenceNumber() - val dataSequenceNumber = file.dataSequenceNumber() - require(deleteSequenceNumber != null && dataSequenceNumber != null && - deleteSequenceNumber >= dataSequenceNumber, - s"Deletion vector sequence number $deleteSequenceNumber must be greater than or equal " + - s"to data file sequence number $dataSequenceNumber") - require(deletionVector.specId() == file.specId(), - s"Deletion vector spec ${deletionVector.specId()} does not match data file spec " + - s"${file.specId()}") - require(samePartition(deletionVector.partition(), file.partition()), - s"Deletion vector partition ${deletionVector.partition()} does not match data file " + - s"partition ${file.partition()}") - } - } - - private def samePartition(left: StructLike, right: StructLike): Boolean = { - if (left == null || right == null) { - return left == right - } - - left.size() == right.size() && (0 until left.size()).forall { index => - Objects.equals(left.get(index, classOf[Object]), right.get(index, classOf[Object])) - } - } - private def loadEqDeleteContexts(): Seq[DeleteFilterContext] = { if (eqDeleteFiles.isEmpty) { return Seq.empty @@ -427,10 +344,6 @@ object GpuDeleteFilter2 { MetadataColumns.DELETE_FILE_PATH, MetadataColumns.DELETE_FILE_POS) - private[iceberg] val DV_DELETE_SCHEMA: Schema = new Schema( - MetadataColumns.DELETE_FILE_POS) - - private[iceberg] def mergeColumn( batch: ColumnarBatch, srcColIdx: Int, destColIdx: Int) (mergeOp: (GpuColumnVector, GpuColumnVector) => GpuColumnVector): ColumnarBatch = { diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteLoader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteLoader.scala index 595784f59b0..ee9718b640d 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteLoader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteLoader.scala @@ -19,29 +19,25 @@ package com.nvidia.spark.rapids.iceberg.data import scala.collection.JavaConverters._ import scala.collection.mutable.ArrayBuffer -import ai.rapids.cudf.{ColumnVector => CudfColumnVector, Table => CudfTable} +import ai.rapids.cudf.{Table => CudfTable} import com.nvidia.spark.rapids.{GpuColumnVector, LazySpillableColumnarBatch, NoopMetric} -import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource} +import com.nvidia.spark.rapids.Arm.withResource import com.nvidia.spark.rapids.GpuMetric.{ICEBERG_DV_BYTES, ICEBERG_DV_DECODE_TIME, ICEBERG_DV_POSITIONS} -import com.nvidia.spark.rapids.RmmRapidsRetryIterator.withRetryNoSplit import com.nvidia.spark.rapids.fileio.iceberg.{IcebergFileIO, IcebergInputFile} -import com.nvidia.spark.rapids.iceberg.ShimUtils +import com.nvidia.spark.rapids.iceberg.{IcebergDeletionVector, ShimUtils} import com.nvidia.spark.rapids.iceberg.ShimUtils.locationOf import com.nvidia.spark.rapids.iceberg.parquet._ import org.apache.iceberg.{DeleteFile, MetadataColumns, Schema} -import org.apache.spark.sql.types.{DataType, LongType} -import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector} +import org.apache.spark.sql.types.DataType +import org.apache.spark.sql.vectorized.ColumnarBatch trait GpuDeleteLoader { def loadDeletes(deletes: Seq[DeleteFile], schema: Schema, sparkTypes: Array[DataType]): LazySpillableColumnarBatch - - def loadDeletionVector(delete: DeleteFile, - dataFilePath: String): LazySpillableColumnarBatch } class DefaultDeleteLoader( @@ -49,8 +45,8 @@ class DefaultDeleteLoader( private val inputFiles: Map[String, IcebergInputFile], private val parquetConf: GpuIcebergParquetReaderConf) extends GpuDeleteLoader { - override def loadDeletionVector(delete: DeleteFile, - dataFilePath: String): LazySpillableColumnarBatch = { + def loadDeletionVector(delete: DeleteFile, + dataFilePath: String): IcebergDeletionVector = { require(ShimUtils.isDeletionVector(delete), s"Expected a Puffin deletion vector, found ${delete.format()}") @@ -71,21 +67,14 @@ class DefaultDeleteLoader( throw new IllegalArgumentException( s"No decrypted input file was provided for deletion vector ${locationOf(delete)}")) val decodeTime = parquetConf.metrics.getOrElse(ICEBERG_DV_DECODE_TIME, NoopMetric) - val positions = decodeTime.ns { + val deletionVector = decodeTime.ns { ShimUtils.readDeletionVector(delete, inputFile.getDelegate) } parquetConf.metrics.getOrElse(ICEBERG_DV_BYTES, NoopMetric) += contentSize.longValue() - parquetConf.metrics.getOrElse(ICEBERG_DV_POSITIONS, NoopMetric) += positions.length.toLong - - withRetryNoSplit { - closeOnExcept(CudfColumnVector.fromLongs(positions: _*)) { positionsColumn => - val columns = Array[ColumnVector](GpuColumnVector.from(positionsColumn, LongType)) - withResource(new ColumnarBatch(columns, positions.length)) { batch => - LazySpillableColumnarBatch(batch, "Iceberg deletion vector") - } - } - } + parquetConf.metrics.getOrElse(ICEBERG_DV_POSITIONS, NoopMetric) += + deletionVector.cardinality() + deletionVector } override def loadDeletes(deletes: Seq[DeleteFile], @@ -131,6 +120,7 @@ class DefaultDeleteLoader( files, _ => Map.empty[Integer, Any].asJava, _ => None, + _ => None, newConf) case _: MultiThread => new GpuMultiThreadIcebergParquetReader( @@ -138,6 +128,7 @@ class DefaultDeleteLoader( files, _ => Map.empty[Integer, Any].asJava, _ => None, + _ => None, newConf) case _: MultiFile => new GpuCoalescingIcebergParquetReader(rapidsFileIO, files, diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeletionVector.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeletionVector.scala new file mode 100644 index 00000000000..a3aa4efb298 --- /dev/null +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeletionVector.scala @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * Licensed 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 com.nvidia.spark.rapids.iceberg.data + +import java.util.Objects + +import com.nvidia.spark.rapids.iceberg.ShimUtils +import org.apache.iceberg.{DataFile, DeleteFile, FileContent, StructLike} + +/** Delete files split by the phase that applies them. */ +case class GpuDeleteFileInfo( + deletionVector: Option[DeleteFile], + postReadDeletes: Seq[DeleteFile]) + +object GpuDeleteFileInfo { + /** + * Validates task delete files and gives a Puffin deletion vector precedence over legacy + * position-delete files. Equality deletes remain post-read filters. + */ + def apply(dataFile: DataFile, deletes: Seq[DeleteFile]): GpuDeleteFileInfo = { + deletes.find(d => d.content() != FileContent.EQUALITY_DELETES && + d.content() != FileContent.POSITION_DELETES).foreach { delete => + throw new UnsupportedOperationException(s"Unsupported delete content: ${delete.content()}") + } + + val (equalityDeletes, positionDeletes) = + deletes.partition(_.content() == FileContent.EQUALITY_DELETES) + val (deletionVectors, legacyPositionDeletes) = + positionDeletes.partition(ShimUtils.isDeletionVector) + require(deletionVectors.size <= 1, + s"Expected at most one deletion vector per data file, found ${deletionVectors.size}") + + deletionVectors.headOption.foreach(validateScope(dataFile, _)) + val effectivePositionDeletes = + if (deletionVectors.nonEmpty) Seq.empty else legacyPositionDeletes + new GpuDeleteFileInfo(deletionVectors.headOption, + equalityDeletes ++ effectivePositionDeletes) + } + + private def validateScope(dataFile: DataFile, deletionVector: DeleteFile): Unit = { + val dataFilePath = ShimUtils.locationOf(dataFile) + val referencedDataFile = ShimUtils.referencedDataFile(deletionVector) + require(referencedDataFile != null, + s"Deletion vector ${ShimUtils.locationOf(deletionVector)} has no referenced data file") + require(dataFilePath == referencedDataFile, + s"Deletion vector ${ShimUtils.locationOf(deletionVector)} references " + + s"$referencedDataFile, not $dataFilePath") + + val deleteSequenceNumber = deletionVector.dataSequenceNumber() + val dataSequenceNumber = dataFile.dataSequenceNumber() + require(deleteSequenceNumber != null && dataSequenceNumber != null && + deleteSequenceNumber >= dataSequenceNumber, + s"Deletion vector sequence number $deleteSequenceNumber must be greater than or equal " + + s"to data file sequence number $dataSequenceNumber") + require(deletionVector.specId() == dataFile.specId(), + s"Deletion vector spec ${deletionVector.specId()} does not match data file spec " + + s"${dataFile.specId()}") + require(samePartition(deletionVector.partition(), dataFile.partition()), + s"Deletion vector partition ${deletionVector.partition()} does not match data file " + + s"partition ${dataFile.partition()}") + } + + private def samePartition(left: StructLike, right: StructLike): Boolean = { + if (left == null || right == null) { + return left == right + } + + left.size() == right.size() && (0 until left.size()).forall { index => + Objects.equals(left.get(index, classOf[Object]), right.get(index, classOf[Object])) + } + } +} diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuIcebergDeletionVector.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuIcebergDeletionVector.scala new file mode 100644 index 00000000000..40a95e718c4 --- /dev/null +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuIcebergDeletionVector.scala @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * Licensed 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 com.nvidia.spark.rapids.iceberg.parquet + +import java.io.IOException + +import ai.rapids.cudf.{DeletionVector, DType, HostMemoryBuffer, ParquetOptions, Table} +import com.nvidia.spark.rapids._ +import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource} +import com.nvidia.spark.rapids.GpuMetric._ +import com.nvidia.spark.rapids.RapidsPluginImplicits._ +import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector +import com.nvidia.spark.rapids.parquet.{GpuParquetScan, ParquetSchemaUtils} +import com.nvidia.spark.rapids.shims.parquet.GpuParquetUtilsShims +import org.apache.hadoop.conf.Configuration +import org.apache.parquet.hadoop.metadata.BlockMetaData +import org.apache.parquet.schema.MessageType + +import org.apache.spark.internal.Logging +import org.apache.spark.sql.execution.datasources.PartitionedFile +import org.apache.spark.sql.types.StructType + +/** Native cuDF Parquet deletion-vector support shared by the Iceberg readers. */ +object GpuIcebergDeletionVector extends Logging { + def rowGroupMetadata(blocks: collection.Seq[BlockMetaData]): (Array[Long], Array[Int]) = { + val offsets = blocks.map(GpuParquetUtilsShims.getRowIndexOffset) + require(!offsets.exists(_ < 0), "Found invalid deletion-vector row-group offset") + val rowCounts = blocks.map(_.getRowCount) + require(rowCounts.forall(_.isValidInt), "Found invalid deletion-vector row-group row count") + (offsets.toArray, rowCounts.map(_.toInt).toArray) + } + + def computeNumRowsAlive( + totalNumRows: Long, + blocks: collection.Seq[BlockMetaData], + deletionVector: IcebergDeletionVector): Int = { + val (offsets, rowCounts) = rowGroupMetadata(blocks) + val deletedRows = deletionVector.countDeletedRows(offsets, rowCounts) + require(deletedRows <= totalNumRows, + s"Deletion vector cardinality in selected row groups ($deletedRows) exceeds " + + s"selected row count ($totalNumRows)") + Math.toIntExact(totalNumRows - deletedRows) + } + + /** + * Creates a cuDF table producer. The returned tables contain a leading INT64 file-row-index + * column followed by the evolved Parquet columns. + */ + def makeProducer( + useChunkedReader: Boolean, + maxChunkedReaderMemoryUsageSizeBytes: Long, + conf: Configuration, + chunkSizeByteLimit: Long, + opts: ParquetOptions, + buffers: Array[HostMemoryBuffer], + metrics: Map[String, GpuMetric], + dateRebaseMode: DateTimeRebaseMode, + timestampRebaseMode: DateTimeRebaseMode, + isSchemaCaseSensitive: Boolean, + useFieldId: Boolean, + readDataSchema: StructType, + clippedParquetSchema: MessageType, + splits: Array[PartitionedFile], + debugDumpPrefix: Option[String], + debugDumpAlways: Boolean, + deletionVector: IcebergDeletionVector, + blocks: collection.Seq[BlockMetaData]): GpuDataProducer[Table] = { + require(buffers.length == 1, + s"Iceberg deletion-vector reads require one Parquet buffer, found ${buffers.length}") + debugDumpPrefix.foreach { prefix => + if (debugDumpAlways) { + val path = DumpUtils.dumpBuffer(conf, buffers, prefix, ".parquet") + logWarning(s"Wrote data for ${splits.mkString(", ")} to $path") + } + } + val dvInfo = makeInfo(deletionVector, blocks) + if (useChunkedReader) { + closeOnExcept(dvInfo.serializedBitmap) { _ => + new ChunkedDeletionVectorProducer( + maxChunkedReaderMemoryUsageSizeBytes, conf, chunkSizeByteLimit, opts, buffers, metrics, + dateRebaseMode, timestampRebaseMode, isSchemaCaseSensitive, useFieldId, + readDataSchema, clippedParquetSchema, splits, debugDumpPrefix, debugDumpAlways, dvInfo) + } + } else { + withResource(buffers) { _ => + withResource(dvInfo.serializedBitmap) { _ => + val rawTable = decodeWithErrorContext(conf, buffers, metrics, splits, + debugDumpPrefix, debugDumpAlways) { + RmmRapidsRetryIterator.withRetryNoSplit[Table] { + DeletionVector.readParquet(opts, buffers, Array(dvInfo)) + } + } + new SingleGpuDataProducer(processTable(rawTable, readDataSchema, clippedParquetSchema, + dateRebaseMode, timestampRebaseMode, isSchemaCaseSensitive, useFieldId, splits, + metrics)) + } + } + } + } + + private def makeInfo( + deletionVector: IcebergDeletionVector, + blocks: collection.Seq[BlockMetaData]): DeletionVector.DeletionVectorInfo = { + val bytes = deletionVector.serializedBitmap() + val bitmap = closeOnExcept(HostMemoryBuffer.allocate(bytes.length)) { buffer => + buffer.setBytes(0, bytes, 0, bytes.length) + buffer + } + closeOnExcept(bitmap) { _ => + val (offsets, rowCounts) = rowGroupMetadata(blocks) + new DeletionVector.DeletionVectorInfo(bitmap, false, offsets, rowCounts) + } + } + + private def decodeWithMetrics[T](metrics: Map[String, GpuMetric])(decode: => T): T = { + NvtxIdWithMetrics(NvtxRegistry.PARQUET_DECODE, metrics(GPU_DECODE_TIME)) { + metrics.getOrElse(ICEBERG_DV_FILTER_TIME, NoopMetric).ns(decode) + } + } + + private def decodeWithErrorContext[T]( + conf: Configuration, + buffers: Array[HostMemoryBuffer], + metrics: Map[String, GpuMetric], + splits: Array[PartitionedFile], + debugDumpPrefix: Option[String], + debugDumpAlways: Boolean)(decode: => T): T = { + try { + decodeWithMetrics(metrics)(decode) + } catch { + case e: Exception => + val dumpMessage = debugDumpPrefix.map { prefix => + if (!debugDumpAlways) { + val path = DumpUtils.dumpBuffer(conf, buffers, prefix, ".parquet") + s", data dumped to $path" + } else { + "" + } + }.getOrElse("") + throw new IOException(s"Error when processing ${splits.mkString("; ")}$dumpMessage", e) + } + } + + private def processTable( + rawTable: Table, + readDataSchema: StructType, + clippedParquetSchema: MessageType, + dateRebaseMode: DateTimeRebaseMode, + timestampRebaseMode: DateTimeRebaseMode, + isSchemaCaseSensitive: Boolean, + useFieldId: Boolean, + splits: Array[PartitionedFile], + metrics: Map[String, GpuMetric]): Table = { + require(rawTable.getNumberOfColumns > 0, + "cuDF deletion-vector output did not contain the file-row-index column") + withResource(rawTable) { table => + withResource(table.getColumn(0).castTo(DType.INT64)) { rowIndex => + val dataColumns = (1 until table.getNumberOfColumns).map(table.getColumn).toArray + val dataTable = new Table(dataColumns: _*) + closeOnExcept(dataTable) { _ => + GpuParquetScan.throwIfRebaseNeededInExceptionMode( + dataTable, dateRebaseMode, timestampRebaseMode) + if (readDataSchema.length < dataTable.getNumberOfColumns) { + throw new IOException(s"Expected ${readDataSchema.length} columns but read " + + s"${dataTable.getNumberOfColumns} from ${splits.mkString("; ")}") + } + metrics(NUM_OUTPUT_BATCHES) += 1 + val evolved = ParquetSchemaUtils.evolveSchemaIfNeededAndClose( + dataTable, clippedParquetSchema, readDataSchema, + isSchemaCaseSensitive, useFieldId) + withResource(GpuParquetScan.rebaseDateTime( + evolved, dateRebaseMode, timestampRebaseMode)) { rebased => + new Table((Array(rowIndex) ++ + (0 until rebased.getNumberOfColumns).map(rebased.getColumn)): _*) + } + } + } + } + } + + private class ChunkedDeletionVectorProducer( + maxChunkedReaderMemoryUsageSizeBytes: Long, + conf: Configuration, + chunkSizeByteLimit: Long, + opts: ParquetOptions, + buffers: Array[HostMemoryBuffer], + metrics: Map[String, GpuMetric], + dateRebaseMode: DateTimeRebaseMode, + timestampRebaseMode: DateTimeRebaseMode, + isSchemaCaseSensitive: Boolean, + useFieldId: Boolean, + readDataSchema: StructType, + clippedParquetSchema: MessageType, + splits: Array[PartitionedFile], + debugDumpPrefix: Option[String], + debugDumpAlways: Boolean, + dvInfo: DeletionVector.DeletionVectorInfo) extends GpuDataProducer[Table] { + private val reader = DeletionVector.newParquetChunkedReader( + chunkSizeByteLimit, maxChunkedReaderMemoryUsageSizeBytes, opts, buffers, Array(dvInfo)) + + override def hasNext: Boolean = reader.hasNext + + override def next: Table = { + val rawTable = decodeWithErrorContext(conf, buffers, metrics, splits, + debugDumpPrefix, debugDumpAlways) { + reader.readChunk() + } + processTable(rawTable, readDataSchema, clippedParquetSchema, dateRebaseMode, + timestampRebaseMode, isSchemaCaseSensitive, useFieldId, splits, metrics) + } + + override def close(): Unit = { + (Seq(reader) ++ buffers ++ Seq(dvInfo.serializedBitmap)).safeClose() + } + } +} diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuMultiThreadIcebergParquetReader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuMultiThreadIcebergParquetReader.scala index ee54ff480df..7fdb729eacf 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuMultiThreadIcebergParquetReader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuMultiThreadIcebergParquetReader.scala @@ -19,14 +19,21 @@ package com.nvidia.spark.rapids.iceberg.parquet import java.util.{Map => JMap} import java.util.concurrent.{ConcurrentHashMap, ConcurrentMap} -import com.nvidia.spark.rapids.Arm.withResource -import com.nvidia.spark.rapids.HostMemoryBuffersWithMetaDataBase +import com.nvidia.spark.rapids._ +import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource} +import com.nvidia.spark.rapids.RapidsPluginImplicits._ import com.nvidia.spark.rapids.fileio.iceberg.IcebergFileIO +import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector import com.nvidia.spark.rapids.iceberg.data.GpuDeleteFilter -import com.nvidia.spark.rapids.parquet.{CpuCompressionConfig, HostMemoryBuffersWithMetaData, MultiFileCloudParquetPartitionReader} +import com.nvidia.spark.rapids.parquet.{CpuCompressionConfig, HostMemoryBuffersWithMetaData, + HostMemoryEmptyMetaData, MultiFileCloudParquetPartitionReader, ParquetDataBlock} +import org.apache.parquet.hadoop.metadata.BlockMetaData +import org.apache.parquet.schema.MessageType +import org.apache.spark.TaskContext +import org.apache.spark.sql.catalyst.InternalRow import org.apache.spark.sql.execution.datasources.PartitionedFile -import org.apache.spark.sql.types.StructType +import org.apache.spark.sql.types.{LongType, StructType} import org.apache.spark.sql.vectorized.ColumnarBatch class GpuMultiThreadIcebergParquetReader( @@ -34,6 +41,7 @@ class GpuMultiThreadIcebergParquetReader( val files: Seq[IcebergPartitionedFile], val constantsProvider: IcebergPartitionedFile => JMap[Integer, _], val deleteFilterProvider: IcebergPartitionedFile => Option[GpuDeleteFilter], + val deletionVectorProvider: IcebergPartitionedFile => Option[IcebergDeletionVector], override val conf: GpuIcebergParquetReaderConf) extends GpuIcebergParquetReader { private val pathToFile = files.groupBy(_.urlEncodedPath).mapValues(_.toSeq) private val postProcessors: ConcurrentMap[IcebergPartitionedFile, GpuParquetReaderPostProcessor] @@ -64,6 +72,7 @@ class GpuMultiThreadIcebergParquetReader( private def createParquetReader() = { val sparkPartitionedFiles = files.map(_.sparkPartitionedFile).toArray val multiThreadConf = conf.threadConf.asInstanceOf[MultiThread] + val readerConf = conf inited = true new MultiFileCloudParquetPartitionReader( @@ -105,6 +114,66 @@ class GpuMultiThreadIcebergParquetReader( !curProcessor.compatibleForCombining(nextProcessor) } + override protected def readBufferToBatches( + buffer: HostMemoryBuffersWithMetaData): Iterator[ColumnarBatch] = { + val icebergFile = findIcebergFile(buffer.partitionedFile) + deletionVectorProvider(icebergFile).map { deletionVector => + val hmbAndInfo = buffer.memBuffersAndSizes.head + val hostBuffers = hmbAndInfo.hmbs + val blocks = hmbAndInfo.blockMeta.map(_.asInstanceOf[ParquetDataBlock].dataBlock) + val parseOptions = closeOnExcept(hostBuffers) { _ => + getParquetOptions(buffer.readSchema, buffer.clippedSchema, useFieldId = false) + } + val columnTypes = LongType +: buffer.readSchema.fields.map(_.dataType) + + withResource(hostBuffers) { _ => + RmmRapidsRetryIterator.withRetryNoSplit[Iterator[ColumnarBatch]] { + val hostBufs = hostBuffers.safeMap(_.getDataHostBuffer()) + GpuSemaphore.acquireIfNecessary(TaskContext.get()) + val producer = GpuIcebergDeletionVector.makeProducer( + readerConf.useChunkedReader, readerConf.maxChunkedReaderMemoryUsageSizeBytes, + readerConf.conf, readerConf.targetBatchSizeBytes, parseOptions, hostBufs, + readerConf.metrics, buffer.dateRebaseMode, buffer.timestampRebaseMode, + readerConf.caseSensitive, + useFieldId = false, buffer.readSchema, buffer.clippedSchema, + Array(buffer.partitionedFile), readerConf.parquetDebugDumpPrefix, + readerConf.parquetDebugDumpAlways, deletionVector, blocks) + CachedGpuBatchIterator(producer, columnTypes) + } + } + }.getOrElse(super.readBufferToBatches(buffer)) + } + + override protected def newHMEmptyMetadataForChunks( + partitionedFile: PartitionedFile, + bufferSize: Long, + bytesRead: Long, + dateRebaseMode: DateTimeRebaseMode, + timestampRebaseMode: DateTimeRebaseMode, + hasInt96Timestamps: Boolean, + clippedSchema: MessageType, + readSchema: StructType, + numRows: Long, + blocks: collection.Seq[BlockMetaData]): HostMemoryEmptyMetaData = { + IcebergHostMemoryEmptyMetaData(partitionedFile, bufferSize, bytesRead, + dateRebaseMode, timestampRebaseMode, hasInt96Timestamps, + clippedSchema, readSchema, numRows, blocks) + } + + override protected def computeNumRowsAlive( + totalNumRows: Long, + metadata: HostMemoryBuffersWithMetaDataBase): Int = { + val icebergFile = findIcebergFile(metadata.partitionedFile) + deletionVectorProvider(icebergFile).map { deletionVector => + val blocks = metadata match { + case empty: IcebergHostMemoryEmptyMetaData => empty.blocks + case _ => metadata.memBuffersAndSizes.flatMap(_.blockMeta) + .map(_.asInstanceOf[ParquetDataBlock].dataBlock).toSeq + } + GpuIcebergDeletionVector.computeNumRowsAlive(totalNumRows, blocks, deletionVector) + }.getOrElse(Math.toIntExact(totalNumRows)) + } + override def readBatches( fileBufsAndMeta: HostMemoryBuffersWithMetaDataBase): Iterator[ColumnarBatch] = { val icebergFile = findIcebergFile(fileBufsAndMeta.partitionedFile) @@ -127,21 +196,37 @@ class GpuMultiThreadIcebergParquetReader( private def filterBlock(f: PartitionedFile) = { val icebergFile = findIcebergFile(f) val deleteFilter = deleteFilterProvider(icebergFile) + val deletionVector = deletionVectorProvider(icebergFile) val requiredSchema = deleteFilter.map(_.requiredSchema).getOrElse(conf.expectedSchema) val (filteredParquet, shadedFileReadSchema) = - super.filterParquetBlocks(icebergFile, requiredSchema) + super.filterParquetBlocks(icebergFile, requiredSchema, deletionVector.isDefined) val postProcessor = new GpuParquetReaderPostProcessor( filteredParquet, constantsProvider(icebergFile), requiredSchema, shadedFileReadSchema, - conf.metrics) + conf.metrics, + hasNativeRowIndex = deletionVector.isDefined) val old = postProcessors.put(icebergFile, postProcessor) require(old == null, "Iceberg parquet partition file post processor already exists!") filteredParquet } -} \ No newline at end of file +} + +private case class IcebergHostMemoryEmptyMetaData( + override val partitionedFile: PartitionedFile, + bufferSize: Long, + override val bytesRead: Long, + dateRebaseMode: DateTimeRebaseMode, + timestampRebaseMode: DateTimeRebaseMode, + hasInt96Timestamps: Boolean, + clippedSchema: MessageType, + readSchema: StructType, + numRows: Long, + blocks: collection.Seq[BlockMetaData], + override val allPartValues: Option[Array[(Long, InternalRow)]] = None) + extends HostMemoryEmptyMetaData diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala index aeb5089e585..344f2bf88d2 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala @@ -162,6 +162,12 @@ private[iceberg] case object FetchFilePath extends ColumnAction { /** Fetch ROW_POSITION metadata column. */ private[iceberg] case object FetchRowPosition extends ColumnAction { override def execute(ctx: ColumnActionContext): CudfColumnVector = { + if (ctx.processor.hasNativeRowIndex) { + val rowIndex = ctx.requireColumn("FetchRowPosition") + rowIndex.incRefCount() + return rowIndex + } + val numRows = ctx.numRows val rowPoses = new Array[Long](numRows) val processor = ctx.processor @@ -629,7 +635,8 @@ class GpuParquetReaderPostProcessor( private[iceberg] val idToConstant: JMap[Integer, _], private[iceberg] val expectedSchema: Schema, shadedFileReadSchema: ShadedMessageType, - metrics: Map[String, com.nvidia.spark.rapids.GpuMetric] + metrics: Map[String, com.nvidia.spark.rapids.GpuMetric], + private[iceberg] val hasNativeRowIndex: Boolean = false ) { private val icebergBuildActionTimeMetricName = "icebergBuildActionTime" private val icebergPostProcessTimeMetricName = "icebergPostProcessTime" @@ -666,7 +673,8 @@ class GpuParquetReaderPostProcessor( // Map field ID to that batch position. private lazy val fieldIdToBatchIndex: Map[Int, Int] = { (0 until fileReadSchema.getFieldCount).flatMap { i => - Option(fileReadSchema.getType(i).getId).map(id => id.intValue() -> i) + val batchIndex = if (hasNativeRowIndex) i + 1 else i + Option(fileReadSchema.getType(i).getId).map(id => id.intValue() -> batchIndex) }.toMap } @@ -685,7 +693,7 @@ class GpuParquetReaderPostProcessor( private lazy val expectedSparkTypes = expectedFields.map(f => SparkSchemaUtil.convert(f.`type`())) // Check if we can pass through the entire batch without any processing. - private lazy val canPassThroughBatch: Boolean = rootAction == PassThrough + private lazy val canPassThroughBatch: Boolean = rootAction == PassThrough && !hasNativeRowIndex // Only constants that synthesize projected fields need to participate in combining checks. // If a projected field is still read from the parquet file, differing constant-map values for @@ -777,6 +785,8 @@ class GpuParquetReaderPostProcessor( // PassThrough is handled by canPassThroughBatch early return) val fieldActions = rootAction match { case ProcessStruct(actions, _) => actions + case PassThrough if hasNativeRowIndex => + Seq.fill(expectedFields.size)(PassThrough) case _ => throw new IllegalStateException( s"Root action must be ProcessStruct, but got: ${rootAction.getClass.getSimpleName}") } @@ -786,7 +796,12 @@ class GpuParquetReaderPostProcessor( // batch column (or None for generated fields) and assemble the output batch ourselves. val columns: Seq[ColumnVector] = fieldActions.zip(fields).zipWithIndex.safeMap { case ((action, field), idx) => - val batchIdx = fieldIdToBatchIndex.get(field.fieldId()) + val batchIdx = if (hasNativeRowIndex && + field.fieldId() == MetadataColumns.ROW_POSITION.fieldId()) { + Some(0) + } else { + fieldIdToBatchIndex.get(field.fieldId()) + } val col = batchIdx.map(i => batch.column(i).asInstanceOf[GpuColumnVector].getBase) val ctx = new ColumnActionContext(this, col, currentNumRows) val result = action.execute(ctx) diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala index 31ecd023cc9..b6832a5b755 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala @@ -20,14 +20,20 @@ import java.util.{Map => JMap} import scala.annotation.tailrec -import com.nvidia.spark.rapids.{DateTimeRebaseCorrected, PartitionReaderWithBytesRead} +import ai.rapids.cudf.ParquetOptions +import com.nvidia.spark.rapids.{CachedGpuBatchIterator, DateTimeRebaseCorrected, EmptyTableReader, + GpuSemaphore, PartitionReaderWithBytesRead, RmmRapidsRetryIterator, SpillableHostBuffer} import com.nvidia.spark.rapids.Arm.withResource import com.nvidia.spark.rapids.fileio.iceberg.IcebergFileIO +import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector import com.nvidia.spark.rapids.iceberg.data.GpuDeleteFilter import com.nvidia.spark.rapids.parquet.{CpuCompressionConfig, ParquetPartitionReader} import org.apache.hadoop.fs.Path +import org.apache.parquet.hadoop.metadata.BlockMetaData +import org.apache.spark.TaskContext import org.apache.spark.sql.rapids.InputFileUtils +import org.apache.spark.sql.types.{DataType, LongType} import org.apache.spark.sql.vectorized.ColumnarBatch class GpuSingleThreadIcebergParquetReader( @@ -35,6 +41,7 @@ class GpuSingleThreadIcebergParquetReader( val files: Seq[IcebergPartitionedFile], val constantsProvider: IcebergPartitionedFile => JMap[Integer, _], val gpuDeleteProvider: IcebergPartitionedFile => Option[GpuDeleteFilter], + val deletionVectorProvider: IcebergPartitionedFile => Option[IcebergDeletionVector], override val conf: GpuIcebergParquetReaderConf) extends GpuIcebergParquetReader { private val taskIterator = files.iterator @@ -65,7 +72,7 @@ class GpuSingleThreadIcebergParquetReader( gpuDeleteFilter = gpuDeleteProvider(file) parquetIterator = new SingleFileReader(rapidsFileIO, file, constantsProvider(file), - gpuDeleteFilter, conf) + gpuDeleteFilter, deletionVectorProvider(file), conf) dataIterator = gpuDeleteFilter .map(_.filterAndDelete(parquetIterator)) .getOrElse(parquetIterator) @@ -102,6 +109,7 @@ private class SingleFileReader( val file: IcebergPartitionedFile, val idToConstant: JMap[Integer, _], val deleteFilter: Option[GpuDeleteFilter], + val deletionVector: Option[IcebergDeletionVector], override val conf: GpuIcebergParquetReaderConf) extends GpuIcebergParquetReader { @@ -122,8 +130,10 @@ private class SingleFileReader( private def open() = { val requiredSchema = deleteFilter.map(_.requiredSchema).getOrElse(conf.expectedSchema) + val readerConf = conf - val (filteredParquet, shadedFileReadSchema) = super.filterParquetBlocks(file, requiredSchema) + val (filteredParquet, shadedFileReadSchema) = + super.filterParquetBlocks(file, requiredSchema, deletionVector.isDefined) val parquetPartReader = new ParquetPartitionReader( rapidsFileIO, @@ -146,14 +156,49 @@ private class SingleFileReader( DateTimeRebaseCorrected, // dateRebaseMode DateTimeRebaseCorrected, // timestampRebaseMode true, // hasInt96Timestamps - false) // useFieldId + false) { // useFieldId + override protected def readBuffer( + parquetOpts: ParquetOptions, + colTypes: Array[DataType], + chunkedBlocks: Seq[BlockMetaData], + dataBuffer: SpillableHostBuffer): Iterator[ColumnarBatch] = { + deletionVector.map { dv => + if (dataBuffer.length == 0) { + dataBuffer.close() + CachedGpuBatchIterator(EmptyTableReader, LongType +: colTypes) + } else { + RmmRapidsRetryIterator.withRetryNoSplit(dataBuffer) { _ => + val hostBuffer = dataBuffer.getDataHostBuffer() + GpuSemaphore.acquireIfNecessary(TaskContext.get()) + val producer = GpuIcebergDeletionVector.makeProducer( + readerConf.useChunkedReader, readerConf.maxChunkedReaderMemoryUsageSizeBytes, + readerConf.conf, readerConf.targetBatchSizeBytes, parquetOpts, Array(hostBuffer), + readerConf.metrics, DateTimeRebaseCorrected, DateTimeRebaseCorrected, + readerConf.caseSensitive, + useFieldId = false, filteredParquet.readSchema, filteredParquet.schema, + Array(file.sparkPartitionedFile), readerConf.parquetDebugDumpPrefix, + readerConf.parquetDebugDumpAlways, dv, chunkedBlocks) + CachedGpuBatchIterator(producer, LongType +: colTypes) + } + } + }.getOrElse(super.readBuffer(parquetOpts, colTypes, chunkedBlocks, dataBuffer)) + } + + override protected def computeNumRowsAlive( + totalNumRows: Long, + chunkedBlocks: Seq[BlockMetaData]): Int = { + deletionVector.map(GpuIcebergDeletionVector.computeNumRowsAlive( + totalNumRows, chunkedBlocks, _)).getOrElse(Math.toIntExact(totalNumRows)) + } + } val parquetReader = new PartitionReaderWithBytesRead(parquetPartReader) val postProcessor = new GpuParquetReaderPostProcessor(filteredParquet, idToConstant, requiredSchema, shadedFileReadSchema, - conf.metrics) + conf.metrics, + hasNativeRowIndex = deletionVector.isDefined) inited = true (parquetReader, postProcessor) diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala index 71a7f403142..fb70519928f 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala @@ -28,6 +28,7 @@ import com.nvidia.spark.rapids.fileio.iceberg.IcebergInputFile import com.nvidia.spark.rapids.iceberg.parquet.converter.FromIcebergShaded._ import com.nvidia.spark.rapids.parquet.{GpuParquetUtils, ParquetFileInfoWithBlockMeta} import com.nvidia.spark.rapids.shims.PartitionedFileUtilsShim +import com.nvidia.spark.rapids.shims.parquet.GpuParquetUtilsShims import org.apache.hadoop.conf.Configuration import org.apache.hadoop.fs.Path import org.apache.iceberg.{MetadataColumns, Schema} @@ -202,14 +203,25 @@ trait GpuIcebergParquetReader extends Iterator[ColumnarBatch] with AutoCloseable } def filterParquetBlocks(file: IcebergPartitionedFile, - requiredSchema: Schema): (ParquetFileInfoWithBlockMeta, ShadedMessageType) = { + requiredSchema: Schema, + hasDeletionVector: Boolean = false): (ParquetFileInfoWithBlockMeta, ShadedMessageType) = { withResource(file.newReader(conf.metrics)) { reader => val fileSchema = reader.getFileMetaData.getSchema - val (typeWithIds, fileReadSchema) = projectSchema(fileSchema, requiredSchema) - val filteredBlocks = filterRowGroups(reader, requiredSchema, typeWithIds, file.filter) val needsRowPosition = requiredSchema.findField(MetadataColumns.ROW_POSITION.fieldId()) != null - val blockFirstRowIndices: Seq[Long] = if (needsRowPosition) { + val initialProjection = projectSchema(fileSchema, requiredSchema) + val (typeWithIds, fileReadSchema) = + if (hasDeletionVector && needsRowPosition && initialProjection._2.getFieldCount == 0 && + fileSchema.getFieldCount > 0) { + val firstFileField = ParquetSchemaUtil.convert(fileSchema).columns().get(0) + val projectionFields = requiredSchema.columns().asScala + .filterNot(_.fieldId() == firstFileField.fieldId()) :+ firstFileField + projectSchema(fileSchema, new Schema(projectionFields.asJava)) + } else { + initialProjection + } + val filteredBlocks = filterRowGroups(reader, requiredSchema, typeWithIds, file.filter) + val blockFirstRowIndices: Seq[Long] = if (needsRowPosition || hasDeletionVector) { // _pos is file-global. When file.split is set the reader is opened with // ParquetReadOptions.withRange, which makes the footer expose only the row groups // that intersect the range, so the ranged reader's own footer is not usable for @@ -253,6 +265,9 @@ trait GpuIcebergParquetReader extends Iterator[ColumnarBatch] with AutoCloseable } } val blocks = clipBlocksToSchema(fileReadSchema, filteredBlocks.map(_._1)) + blocks.zip(blockFirstRowIndices).foreach { case (block, firstRowIndex) => + GpuParquetUtilsShims.setRowIndexOffset(block, firstRowIndex) + } val sqlConf = SQLConf.get val partReaderSparkSchema = new ParquetToSparkSchemaConverter( @@ -304,4 +319,4 @@ object GpuIcebergParquetReader { } optionsBuilder.build } -} \ No newline at end of file +} diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala index e239efdfa93..763ffa425b6 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala @@ -23,7 +23,8 @@ import com.nvidia.spark.rapids.MapUtil.toMapStrict import com.nvidia.spark.rapids.fileio.iceberg.IcebergFileIO import com.nvidia.spark.rapids.iceberg.ShimUtils import com.nvidia.spark.rapids.iceberg.ShimUtils.locationOf -import com.nvidia.spark.rapids.iceberg.data.GpuDeleteFilter +import com.nvidia.spark.rapids.iceberg.data.{DefaultDeleteLoader, GpuDeleteFileInfo, + GpuDeleteFilter} import com.nvidia.spark.rapids.iceberg.parquet._ import org.apache.iceberg._ import org.apache.iceberg.encryption.EncryptedFiles @@ -45,13 +46,26 @@ class GpuIcebergPartitionReader(private val task: GpuSparkInputPartition, private lazy val rapidsFileIO = new IcebergFileIO(fileIO) private lazy val conf = newConf() private lazy val (inputFiles, tasks) = collectFiles() + private lazy val deleteInfoMap: Map[IcebergPartitionedFile, GpuDeleteFileInfo] = + tasks.map { case (file, scanTask) => + file -> GpuDeleteFileInfo(scanTask.file(), scanTask.deletes().asScala.toSeq) + } + private lazy val deletionVectorMap = { + val loader = new DefaultDeleteLoader(rapidsFileIO, inputFiles, conf) + deleteInfoMap.map { case (file, deleteInfo) => + val deletionVector = deleteInfo.deletionVector.map { delete => + loader.loadDeletionVector(delete, ShimUtils.locationOf(tasks(file).file())) + } + file -> deletionVector + } + } private lazy val gpuDeleteFiterMap: Map[IcebergPartitionedFile, Option[GpuDeleteFilter]] = tasks.map { - case (file, task) => - val filter = if (task.deletes().asScala.nonEmpty) { + case (file, _) => + val postReadDeletes = deleteInfoMap(file).postReadDeletes + val filter = if (postReadDeletes.nonEmpty) { Some(new GpuDeleteFilter(rapidsFileIO, table.schema(), - inputFiles, conf, task.deletes().asScala.toSeq, - dataFile = Some(task.file()))) + inputFiles, conf, postReadDeletes)) } else { None } @@ -86,10 +100,10 @@ class GpuIcebergPartitionReader(private val task: GpuSparkInputPartition, threadConf match { case SingleFile => new GpuSingleThreadIcebergParquetReader(rapidsFileIO, files, constantsMap, - gpuDeleteFiterMap, conf) + gpuDeleteFiterMap, deletionVectorMap, conf) case _: MultiThread => new GpuMultiThreadIcebergParquetReader(rapidsFileIO, files, constantsMap, - gpuDeleteFiterMap, conf) + gpuDeleteFiterMap, deletionVectorMap, conf) case _: MultiFile => new GpuCoalescingIcebergParquetReader(rapidsFileIO, files, constantsMap, conf) } diff --git a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java index dba8e33f8bf..f0e21cbd4a7 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java @@ -19,6 +19,7 @@ import com.nvidia.spark.rapids.GpuMetric; import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.fileio.iceberg.IcebergInputFile; +import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import org.apache.hadoop.fs.Path; import org.apache.iceberg.*; @@ -38,6 +39,7 @@ import org.apache.spark.sql.connector.read.Scan; import java.io.IOException; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -75,14 +77,14 @@ public Long contentSizeInBytes(DeleteFile deleteFile) { } @Override - public long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + public IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, InputFile inputFile) throws IOException { Long offset = deleteFile.contentOffset(); Long size = deleteFile.contentSizeInBytes(); if (offset == null || offset < 0) { throw new IllegalArgumentException("Invalid deletion vector offset: " + offset); } - if (size == null || size < 0 || size > Integer.MAX_VALUE) { + if (size == null || size < 20 || size > Integer.MAX_VALUE) { throw new IllegalArgumentException("Invalid deletion vector size: " + size); } @@ -94,19 +96,36 @@ public long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) PositionDeleteIndex index = PositionDeleteIndex.deserialize(bytes, deleteFile); long cardinality = index.cardinality(); - if (cardinality > Integer.MAX_VALUE) { - throw new IllegalArgumentException( - "Cannot materialize deletion vector with more than 2^31-1 positions: " - + cardinality); - } - long[] positions = new long[(int) cardinality]; - int[] next = new int[] {0}; - index.forEach(position -> positions[next[0]++] = position); - if (next[0] != positions.length) { - throw new IllegalStateException( - "Deletion vector cardinality changed while materializing positions"); - } - return positions; + byte[] serializedBitmap = Arrays.copyOfRange(bytes, 8, bytes.length - 4); + return new IcebergDeletionVector() { + @Override + public byte[] serializedBitmap() { + return serializedBitmap; + } + + @Override + public long cardinality() { + return cardinality; + } + + @Override + public long countDeletedRows(long[] rowGroupOffsets, int[] rowGroupNumRows) { + if (rowGroupOffsets.length != rowGroupNumRows.length) { + throw new IllegalArgumentException("Mismatched row-group metadata lengths"); + } + long[] count = new long[] {0L}; + index.forEach(position -> { + for (int i = 0; i < rowGroupOffsets.length; i++) { + long start = rowGroupOffsets[i]; + if (position >= start && position - start < rowGroupNumRows[i]) { + count[0] += 1L; + break; + } + } + }); + return count[0]; + } + }; } @Override diff --git a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java index bda1417021e..a53941b25b0 100644 --- a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java @@ -19,6 +19,7 @@ import com.nvidia.spark.rapids.GpuMetric; import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.fileio.iceberg.IcebergInputFile; +import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import org.apache.hadoop.fs.Path; import org.apache.iceberg.*; @@ -38,6 +39,7 @@ import org.apache.spark.sql.connector.read.Scan; import java.io.IOException; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -75,14 +77,14 @@ public Long contentSizeInBytes(DeleteFile deleteFile) { } @Override - public long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + public IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, InputFile inputFile) throws IOException { Long offset = deleteFile.contentOffset(); Long size = deleteFile.contentSizeInBytes(); if (offset == null || offset < 0) { throw new IllegalArgumentException("Invalid deletion vector offset: " + offset); } - if (size == null || size < 0 || size > Integer.MAX_VALUE) { + if (size == null || size < 20 || size > Integer.MAX_VALUE) { throw new IllegalArgumentException("Invalid deletion vector size: " + size); } @@ -94,19 +96,36 @@ public long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) PositionDeleteIndex index = PositionDeleteIndex.deserialize(bytes, deleteFile); long cardinality = index.cardinality(); - if (cardinality > Integer.MAX_VALUE) { - throw new IllegalArgumentException( - "Cannot materialize deletion vector with more than 2^31-1 positions: " - + cardinality); - } - long[] positions = new long[(int) cardinality]; - int[] next = new int[] {0}; - index.forEach(position -> positions[next[0]++] = position); - if (next[0] != positions.length) { - throw new IllegalStateException( - "Deletion vector cardinality changed while materializing positions"); - } - return positions; + byte[] serializedBitmap = Arrays.copyOfRange(bytes, 8, bytes.length - 4); + return new IcebergDeletionVector() { + @Override + public byte[] serializedBitmap() { + return serializedBitmap; + } + + @Override + public long cardinality() { + return cardinality; + } + + @Override + public long countDeletedRows(long[] rowGroupOffsets, int[] rowGroupNumRows) { + if (rowGroupOffsets.length != rowGroupNumRows.length) { + throw new IllegalArgumentException("Mismatched row-group metadata lengths"); + } + long[] count = new long[] {0L}; + index.forEach(position -> { + for (int i = 0; i < rowGroupOffsets.length; i++) { + long start = rowGroupOffsets[i]; + if (position >= start && position - start < rowGroupNumRows[i]) { + count[0] += 1L; + break; + } + } + }); + return count[0]; + } + }; } @Override diff --git a/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java b/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java index ec0890df4b2..78d3f4ec711 100644 --- a/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java @@ -17,6 +17,7 @@ package com.nvidia.spark.rapids.iceberg.iceberg16x; import com.nvidia.spark.rapids.RapidsConf; +import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import org.apache.iceberg.*; import org.apache.iceberg.io.FileIO; @@ -82,7 +83,7 @@ public Long contentSizeInBytes(DeleteFile deleteFile) { } @Override - public long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + public IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, InputFile inputFile) throws IOException { throw new UnsupportedOperationException( "Iceberg 1.6 does not support Puffin deletion vectors"); diff --git a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java index c476c521db3..45360ef2b69 100644 --- a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java @@ -17,6 +17,7 @@ package com.nvidia.spark.rapids.iceberg.iceberg19x; import com.nvidia.spark.rapids.RapidsConf; +import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import org.apache.iceberg.*; import org.apache.iceberg.deletes.PositionDeleteIndex; @@ -33,6 +34,7 @@ import org.apache.spark.sql.connector.read.Scan; import java.io.IOException; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -70,14 +72,14 @@ public Long contentSizeInBytes(DeleteFile deleteFile) { } @Override - public long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + public IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, InputFile inputFile) throws IOException { Long offset = deleteFile.contentOffset(); Long size = deleteFile.contentSizeInBytes(); if (offset == null || offset < 0) { throw new IllegalArgumentException("Invalid deletion vector offset: " + offset); } - if (size == null || size < 0 || size > Integer.MAX_VALUE) { + if (size == null || size < 20 || size > Integer.MAX_VALUE) { throw new IllegalArgumentException("Invalid deletion vector size: " + size); } @@ -89,19 +91,36 @@ public long[] readDeletionVector(DeleteFile deleteFile, InputFile inputFile) PositionDeleteIndex index = PositionDeleteIndex.deserialize(bytes, deleteFile); long cardinality = index.cardinality(); - if (cardinality > Integer.MAX_VALUE) { - throw new IllegalArgumentException( - "Cannot materialize deletion vector with more than 2^31-1 positions: " - + cardinality); - } - long[] positions = new long[(int) cardinality]; - int[] next = new int[] {0}; - index.forEach(position -> positions[next[0]++] = position); - if (next[0] != positions.length) { - throw new IllegalStateException( - "Deletion vector cardinality changed while materializing positions"); - } - return positions; + byte[] serializedBitmap = Arrays.copyOfRange(bytes, 8, bytes.length - 4); + return new IcebergDeletionVector() { + @Override + public byte[] serializedBitmap() { + return serializedBitmap; + } + + @Override + public long cardinality() { + return cardinality; + } + + @Override + public long countDeletedRows(long[] rowGroupOffsets, int[] rowGroupNumRows) { + if (rowGroupOffsets.length != rowGroupNumRows.length) { + throw new IllegalArgumentException("Mismatched row-group metadata lengths"); + } + long[] count = new long[] {0L}; + index.forEach(position -> { + for (int i = 0; i < rowGroupOffsets.length; i++) { + long start = rowGroupOffsets[i]; + if (position >= start && position - start < rowGroupNumRows[i]) { + count[0] += 1L; + break; + } + } + }); + return count[0]; + } + }; } @Override diff --git a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala index cce18ff981a..37655ba3f88 100644 --- a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala +++ b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala @@ -1005,6 +1005,52 @@ class GpuPostProcessorSuite extends AnyFunSuite with BeforeAndAfterAll { } } + test("native deletion-vector row index supplies _pos and shifts physical columns") { + import ai.rapids.cudf.{ColumnVector => CudfColumnVector} + import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource} + import com.nvidia.spark.rapids.GpuColumnVector + import org.apache.spark.sql.types.LongType + import org.apache.spark.sql.vectorized.ColumnarBatch + + val dataFieldId = 1 + val rowPositionFieldId = MetadataColumns.ROW_POSITION.fieldId() + val dataField = ShadedTypes + .primitive(ShadedPrimitiveTypeName.INT64, ShadedRepetition.OPTIONAL) + .id(dataFieldId) + .named("data") + val parquetSchema = new ShadedMessageType("test", Seq[ShadedType](dataField).asJava) + val expectedSchema = new Schema( + Types.NestedField.optional(dataFieldId, "data", Types.LongType.get()), + Types.NestedField.optional(rowPositionFieldId, "_pos", Types.LongType.get())) + val (parquetInfo, shadedSchema) = createParquetInfo(parquetSchema, rowCount = 6) + val processor = new GpuParquetReaderPostProcessor( + parquetInfo, + new JHashMap[Integer, Any](), + expectedSchema, + shadedSchema, + Map.empty, + hasNativeRowIndex = true) + + val rowPositions = closeOnExcept(CudfColumnVector.fromLongs(0L, 2L, 5L)) { column => + GpuColumnVector.from(column, LongType) + } + val data = closeOnExcept(CudfColumnVector.fromLongs(10L, 12L, 15L)) { column => + GpuColumnVector.from(column, LongType) + } + val inputBatch = new ColumnarBatch(Array(rowPositions, data), 3) + + withResource(processor.process(inputBatch)) { outputBatch => + assert(outputBatch.numRows() == 3) + assert(outputBatch.numCols() == 2) + withResource(outputBatch.column(0).asInstanceOf[GpuColumnVector].copyToHost()) { host => + assert((0 until 3).map(i => host.getBase.getLong(i)) == Seq(10L, 12L, 15L)) + } + withResource(outputBatch.column(1).asInstanceOf[GpuColumnVector].copyToHost()) { host => + assert((0 until 3).map(i => host.getBase.getLong(i)) == Seq(0L, 2L, 5L)) + } + } + } + test("Constant struct with required children does not throw") { val structFieldId = 1 val fieldAId = 2 diff --git a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilterSuite.scala b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilterSuite.scala index cfb229bfaec..30afe10cd78 100644 --- a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilterSuite.scala +++ b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilterSuite.scala @@ -42,7 +42,8 @@ import com.nvidia.spark.rapids.iceberg.data.TestGpuDeleteLoader._ import com.nvidia.spark.rapids.iceberg.parquet.{GpuIcebergParquetReaderConf, SingleFile} import com.nvidia.spark.rapids.spill.SpillFramework import org.apache.hadoop.conf.Configuration -import org.apache.iceberg.{DeleteFile, FileContent, FileFormat, FileMetadata, MetadataColumns, PartitionSpec, Schema} +import org.apache.iceberg.{DeleteFile, FileContent, FileFormat, FileMetadata, MetadataColumns, + PartitionSpec, Schema} import org.apache.iceberg.MetadataColumns.isMetadataColumn import org.apache.iceberg.common.DynMethods import org.apache.iceberg.hadoop.HadoopFileIO @@ -186,146 +187,6 @@ class GpuDeleteFilterSuite extends AnyFunSuite with BeforeAndAfterAll { } } - test("Filter with Puffin deletion vector") { - if (!supportsDeletionVectors) { - cancel("Iceberg runtime does not expose Puffin deletion vectors") - } - - val dataFilePath = PooledTableGen.PooledFilePaths.head - val deletedPositions = Seq(1L, 3L, 5L) - val deleteFile = deletionVectorFile(dataFilePath, deletedPositions.size) - val tableGen = new PooledTableGen(tableGenSchema(TABLE_SCHEMA, Seq(deleteFile))) - val deleteLoader = new TestGpuDeleteLoader(tableGen, - rows = 57, - deletionVectors = Some(Map(dataFilePath -> deletedPositions))) - val deleteFilter = gpuDeleteFilterOf(TABLE_SCHEMA, Seq(deleteFile), Some(deleteLoader)) - - assert(fieldIndex(deleteFilter.requiredSchema, - MetadataColumns.ROW_POSITION.fieldId()) >= 0) - assert(!deleteFilter.requiredSchema.columns().asScala - .exists(_.fieldId() == MetadataColumns.FILE_PATH.fieldId())) - - val positionColumnIndex = fieldIndex(deleteFilter.requiredSchema, - MetadataColumns.ROW_POSITION.fieldId()) - val isDeletedColumnIndex = deleteFilter.requiredSchema.columns().size() - val input = Iterator(tableGen.toColumnarBatch(NUM_ROWS)) - - deleteFilter.filter(input).foreach { resultBatch => - withResource(resultBatch) { _ => - val bases = GpuColumnVector.extractBases(resultBatch) - withResource(bases.safeMap(_.copyToHost())) { hostColumns => - for (row <- 0 until resultBatch.numRows()) { - val position = hostColumns(positionColumnIndex).getLong(row) - assert(hostColumns(isDeletedColumnIndex).getBoolean(row) == - deletedPositions.contains(position)) - } - } - } - } - } - - test("Deletion vector supersedes legacy position deletes") { - if (!supportsDeletionVectors) { - cancel("Iceberg runtime does not expose Puffin deletion vectors") - } - - val dataFilePath = PooledTableGen.PooledFilePaths.head - val deletionVector = deletionVectorFile(dataFilePath, cardinality = 1) - val legacyPositionDelete = posDeleteFile() - val deleteFiles = Seq(legacyPositionDelete, deletionVector) - val tableGen = new PooledTableGen(tableGenSchema(TABLE_SCHEMA, deleteFiles)) - val deleteLoader = new TestGpuDeleteLoader(tableGen, - rows = 57, - posDeletes = Map(dataFilePath -> Seq(2L)), - deletionVectors = Some(Map(dataFilePath -> Seq(1L)))) - val deleteFilter = gpuDeleteFilterOf(TABLE_SCHEMA, deleteFiles, Some(deleteLoader)) - - val positionColumnIndex = fieldIndex(deleteFilter.requiredSchema, - MetadataColumns.ROW_POSITION.fieldId()) - val isDeletedColumnIndex = deleteFilter.requiredSchema.columns().size() - val input = Iterator(tableGen.toColumnarBatch(4)) - - deleteFilter.filter(input).foreach { resultBatch => - withResource(resultBatch) { _ => - val bases = GpuColumnVector.extractBases(resultBatch) - withResource(bases.safeMap(_.copyToHost())) { hostColumns => - for (row <- 0 until resultBatch.numRows()) { - val position = hostColumns(positionColumnIndex).getLong(row) - assert(hostColumns(isDeletedColumnIndex).getBoolean(row) == (position == 1L)) - } - } - } - } - } - - test("Empty deletion vector") { - if (!supportsDeletionVectors) { - cancel("Iceberg runtime does not expose Puffin deletion vectors") - } - - val dataFilePath = PooledTableGen.PooledFilePaths.head - val deletionVector = deletionVectorFile(dataFilePath, cardinality = 0) - val tableGen = new PooledTableGen(tableGenSchema(TABLE_SCHEMA, Seq(deletionVector))) - val deleteLoader = new TestGpuDeleteLoader(tableGen, - rows = 57, - deletionVectors = Some(Map(dataFilePath -> Seq.empty))) - val deleteFilter = gpuDeleteFilterOf(TABLE_SCHEMA, Seq(deletionVector), Some(deleteLoader)) - val isDeletedColumnIndex = deleteFilter.requiredSchema.columns().size() - - deleteFilter.filter(Iterator(tableGen.toColumnarBatch(4))).foreach { resultBatch => - withResource(resultBatch) { _ => - val bases = GpuColumnVector.extractBases(resultBatch) - withResource(bases.safeMap(_.copyToHost())) { hostColumns => - for (row <- 0 until resultBatch.numRows()) { - assert(!hostColumns(isDeletedColumnIndex).getBoolean(row)) - } - } - } - } - } - - test("Deletion vector composes with equality deletes and IS_DELETED") { - if (!supportsDeletionVectors) { - cancel("Iceberg runtime does not expose Puffin deletion vectors") - } - - val dataFilePath = PooledTableGen.PooledFilePaths.head - val deletedPositions = Seq(1L, 3L, 5L) - val equalityFieldIds = Seq(1, 3) - val equalityDelete = eqDeleteFile(equalityFieldIds) - val deletionVector = deletionVectorFile(dataFilePath, deletedPositions.size) - val deleteFiles = Seq(equalityDelete, deletionVector) - val tableSchema = tableSchemaWithIsDeletedColumn() - val tableGen = new PooledTableGen(tableGenSchema(tableSchema, deleteFiles)) - val deleteLoader = new TestGpuDeleteLoader(tableGen, - rows = 57, - deletionVectors = Some(Map(dataFilePath -> deletedPositions))) - val deleteFilter = gpuDeleteFilterOf(tableSchema, deleteFiles, Some(deleteLoader)) - val equalityColumnIndices = equalityFieldIds.map(fieldIndex(deleteFilter.requiredSchema, _)) - val equalityDeleteValues = deleteLoader.loadEqDeletes(equalityFieldIds) - val positionColumnIndex = fieldIndex(deleteFilter.requiredSchema, - MetadataColumns.ROW_POSITION.fieldId()) - val isDeletedColumnIndex = fieldIndex(deleteFilter.requiredSchema, - MetadataColumns.IS_DELETED.fieldId()) - - deleteFilter.filter(Iterator(tableGen.toColumnarBatch(NUM_ROWS))).foreach { resultBatch => - withResource(resultBatch) { _ => - val bases = GpuColumnVector.extractBases(resultBatch) - withResource(bases.safeMap(_.copyToHost())) { hostColumns => - for (row <- 0 until resultBatch.numRows()) { - val equalityValues = equalityColumnIndices.map { index => - valueOf(hostColumns(index), Integer.valueOf(row)) - } - val position = hostColumns(positionColumnIndex).getLong(row) - val expectedDeleted = deletedPositions.contains(position) || - equalityDeleteValues.exists(_.sameElements(equalityValues)) - assert(hostColumns(isDeletedColumnIndex).getBoolean(row) == expectedDeleted) - } - } - } - } - } - test("Filter with eq deletes and position deletes") { val eqFieldIdSets = Seq(Seq(1, 3), Seq(2, 6)) val f = fixture(eqFieldIdSets.map(eqDeleteFile) :+ posDeleteFile()) @@ -475,26 +336,9 @@ class GpuDeleteFilterSuite extends AnyFunSuite with BeforeAndAfterAll { class TestGpuDeleteLoader(private val tableGen: PooledTableGen, private val rows: Int, - private val posDeletes: Map[String, Seq[Long]] = Map.empty, - private val deletionVectors: Option[Map[String, Seq[Long]]] = None + private val posDeletes: Map[String, Seq[Long]] = Map.empty ) extends GpuDeleteLoader { - override def loadDeletionVector(delete: DeleteFile, - dataFilePath: String): LazySpillableColumnarBatch = { - val rowPositions = deletionVectors.getOrElse(posDeletes).getOrElse(dataFilePath, Seq.empty) - .map(java.lang.Long.valueOf) - val hostVectors = Seq(HostColumnVector.fromBoxedLongs(rowPositions: _*)) - - withResource(hostVectors) { _ => - val columns = hostVectors.safeMap(_.copyToDevice()) - .safeMap(cv => GpuColumnVector.from(cv, LongType)) - .toArray[ColumnVector] - withResource(new ColumnarBatch(columns, rowPositions.length)) { batch => - LazySpillableColumnarBatch(batch, "DV deletes build") - } - } - } - override def loadDeletes(deletes: Seq[DeleteFile], schema: Schema, sparkTypes: Array[DataType]): LazySpillableColumnarBatch = { @@ -615,28 +459,6 @@ private object TestGpuDeleteLoader { .build() } - def supportsDeletionVectors: Boolean = { - FileFormat.values().exists(_.name() == "PUFFIN") - } - - def deletionVectorFile(referencedDataFile: String, cardinality: Int): DeleteFile = { - val builder = FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) - .ofPositionDeletes() - .withPath("/tmp/delete-vectors.puffin") - .withFormat(FileFormat.valueOf("PUFFIN")) - .withRecordCount(cardinality) - .withFileSizeInBytes(1024) - - val builderClass = builder.getClass - builderClass.getMethod("withReferencedDataFile", classOf[CharSequence]) - .invoke(builder, referencedDataFile) - builderClass.getMethod("withContentOffset", java.lang.Long.TYPE) - .invoke(builder, Long.box(64L)) - builderClass.getMethod("withContentSizeInBytes", java.lang.Long.TYPE) - .invoke(builder, Long.box(128L)) - builder.build() - } - def gpuDeleteFilterOf( tableSchema: Schema, deleteFiles: Seq[DeleteFile], diff --git a/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala b/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala index 8b50cec52d7..a1964a7e193 100644 --- a/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala +++ b/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala @@ -56,7 +56,11 @@ class DeletionVectorReaderSuite extends AnyFunSuite { fileSize = fileBytes.length) val inputFile = HadoopInputFile.fromPath(new HadoopPath(path.toUri), new Configuration()) - assert(ShimUtils.readDeletionVector(deleteFile, inputFile).toSeq == expectedPositions) + val deletionVector = ShimUtils.readDeletionVector(deleteFile, inputFile) + assert(deletionVector.serializedBitmap().sameElements(portableBitmap(targetBlob))) + assert(deletionVector.cardinality() == expectedPositions.size) + assert(deletionVector.countDeletedRows( + Array(0L, 1L << 40), Array(18, 4)) == expectedPositions.size) } finally { Files.deleteIfExists(path) } @@ -76,7 +80,11 @@ class DeletionVectorReaderSuite extends AnyFunSuite { fileSize = blob.length) val inputFile = HadoopInputFile.fromPath(new HadoopPath(path.toUri), new Configuration()) - assert(ShimUtils.readDeletionVector(deleteFile, inputFile).isEmpty) + val deletionVector = ShimUtils.readDeletionVector(deleteFile, inputFile) + assert(deletionVector.serializedBitmap().sameElements(portableBitmap(blob))) + assert(deletionVector.serializedBitmap().length == 8) + assert(deletionVector.cardinality() == 0) + assert(deletionVector.countDeletedRows(Array(0L), Array(100)) == 0) } finally { Files.deleteIfExists(path) } @@ -95,6 +103,10 @@ class DeletionVectorReaderSuite extends AnyFunSuite { bytes } + private def portableBitmap(serializedIndex: Array[Byte]): Array[Byte] = { + serializedIndex.slice(8, serializedIndex.length - 4) + } + private def deletionVectorFile( location: String, referencedDataFile: String, From 36db87fe760a280a78fef28dde7836909476ed0a Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Mon, 17 Aug 2026 19:10:28 +0800 Subject: [PATCH 03/18] Address Iceberg deletion vector review feedback Signed-off-by: Ray Liu --- .../rapids/iceberg/IcebergDeletionVector.java | 47 ++++-- .../rapids/iceberg/data/GpuDeleteFilter.scala | 38 ++++- .../iceberg/data/GpuDeletionVector.scala | 86 ---------- .../parquet/GpuIcebergDeletionVector.scala | 149 ++++++++---------- .../GpuMultiThreadIcebergParquetReader.scala | 122 ++++++-------- .../GpuSingleThreadIcebergParquetReader.scala | 48 +++--- .../spark/rapids/iceberg/parquet/reader.scala | 2 +- .../source/GpuIcebergPartitionReader.scala | 22 ++- .../iceberg/iceberg110x/ShimUtilsImpl.java | 33 +--- .../iceberg/iceberg111x/ShimUtilsImpl.java | 33 +--- .../iceberg/iceberg19x/ShimUtilsImpl.java | 33 +--- .../iceberg/iceberg_merge_on_read_test.py | 75 ++++----- .../iceberg/data/GpuDeleteFilterSuite.scala | 3 +- .../iceberg/DeletionVectorReaderSuite.scala | 29 ++-- 14 files changed, 271 insertions(+), 449 deletions(-) delete mode 100644 iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeletionVector.scala diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java index 29f2b39dc89..dd9382cf7d9 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java @@ -16,20 +16,49 @@ package com.nvidia.spark.rapids.iceberg; +import ai.rapids.cudf.HostMemoryBuffer; + /** * A validated Iceberg deletion vector kept in its compressed Roaring-bitmap representation. * - *

The serialized bytes use the portable 64-bit Roaring format expected by cuDF. Range - * counting is provided by the version-specific Iceberg implementation so the common module does - * not depend on deletion-index APIs that are absent from Iceberg 1.6. + *

The serialized bytes use the portable 64-bit Roaring format expected by cuDF. This object + * owns its host buffer and must be closed after all borrowed references have been released. */ -public interface IcebergDeletionVector { - /** Returns the portable serialized 64-bit Roaring bitmap expected by cuDF. */ - byte[] serializedBitmap(); +public final class IcebergDeletionVector implements AutoCloseable { + private final HostMemoryBuffer serializedBitmap; + private final long cardinality; + + public IcebergDeletionVector( + byte[] serializedIndex, + int bitmapOffset, + int bitmapLength, + long cardinality) { + HostMemoryBuffer bitmap = HostMemoryBuffer.allocate(bitmapLength); + try { + bitmap.setBytes(0, serializedIndex, bitmapOffset, bitmapLength); + } catch (RuntimeException | Error e) { + bitmap.close(); + throw e; + } + this.serializedBitmap = bitmap; + this.cardinality = cardinality; + } + + /** + * Returns a new reference to the portable serialized 64-bit Roaring bitmap expected by cuDF. + */ + public HostMemoryBuffer serializedBitmap() { + serializedBitmap.incRefCount(); + return serializedBitmap; + } /** Returns the number of positions in the deletion vector. */ - long cardinality(); + public long cardinality() { + return cardinality; + } - /** Returns the number of deleted positions contained in the supplied file-row ranges. */ - long countDeletedRows(long[] rowGroupOffsets, int[] rowGroupNumRows); + @Override + public void close() { + serializedBitmap.close(); + } } diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala index f7dcbd43619..bc1b1909718 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala @@ -39,6 +39,33 @@ import org.apache.spark.sql.rapids.execution.HashedExistenceJoinIterator import org.apache.spark.sql.types.{BooleanType, DataType} import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector} +object GpuDeleteFilter { + case class DeleteFileInfo( + deletionVector: Option[DeleteFile], + postReadDeletes: Seq[DeleteFile]) + + /** Gives a deletion vector precedence over legacy position-delete files. */ + def splitDeleteFiles(deletes: Seq[DeleteFile]): DeleteFileInfo = { + val equalityDeletes = new ArrayBuffer[DeleteFile] + val positionDeletes = new ArrayBuffer[DeleteFile] + var deletionVector: Option[DeleteFile] = None + + deletes.foreach { delete => + delete.content() match { + case FileContent.EQUALITY_DELETES => equalityDeletes += delete + case FileContent.POSITION_DELETES if ShimUtils.isDeletionVector(delete) => + deletionVector = Some(delete) + case FileContent.POSITION_DELETES => positionDeletes += delete + case content => + throw new UnsupportedOperationException(s"Unsupported delete content: $content") + } + } + + val effectivePositionDeletes = if (deletionVector.isDefined) Seq.empty else positionDeletes + DeleteFileInfo(deletionVector, equalityDeletes.toSeq ++ effectivePositionDeletes) + } +} + class GpuDeleteFilter( private val rapidsFileIO: IcebergFileIO, private val tableSchema: Schema, @@ -57,11 +84,7 @@ class GpuDeleteFilter( .foreach(d => { throw new UnsupportedOperationException(s"Unsupported delete content: ${d.content()}") }) - val (equalityDeletes, positionDeletes) = - deletes.partition(_.content() == FileContent.EQUALITY_DELETES) - require(!positionDeletes.exists(ShimUtils.isDeletionVector), - "Deletion vectors must be applied by the native Parquet reader") - (equalityDeletes, positionDeletes) + deletes.partition(_.content() == FileContent.EQUALITY_DELETES) } /** @@ -71,8 +94,8 @@ class GpuDeleteFilter( * 1. Add all the fields in the [[GpuIcebergParquetReaderConf.expectedSchema]]. * 2. Add all missing fields which are required by the equality delete files, but not in the * [[GpuIcebergParquetReaderConf.expectedSchema]], if any. - * 3. Add [[MetadataColumns.ROW_POSITION]] and [[MetadataColumns.FILE_PATH]] for legacy - * position-delete files, if not already projected. + * 3. Add [[MetadataColumns.ROW_POSITION]] and [[MetadataColumns.FILE_PATH]] if there are + * position delete files, and they are not in the schema. */ lazy val requiredSchema: Schema = computeRequiredSchema() @@ -344,6 +367,7 @@ object GpuDeleteFilter2 { MetadataColumns.DELETE_FILE_PATH, MetadataColumns.DELETE_FILE_POS) + private[iceberg] def mergeColumn( batch: ColumnarBatch, srcColIdx: Int, destColIdx: Int) (mergeOp: (GpuColumnVector, GpuColumnVector) => GpuColumnVector): ColumnarBatch = { diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeletionVector.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeletionVector.scala deleted file mode 100644 index a3aa4efb298..00000000000 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeletionVector.scala +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (c) 2026, NVIDIA CORPORATION. - * - * Licensed 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 com.nvidia.spark.rapids.iceberg.data - -import java.util.Objects - -import com.nvidia.spark.rapids.iceberg.ShimUtils -import org.apache.iceberg.{DataFile, DeleteFile, FileContent, StructLike} - -/** Delete files split by the phase that applies them. */ -case class GpuDeleteFileInfo( - deletionVector: Option[DeleteFile], - postReadDeletes: Seq[DeleteFile]) - -object GpuDeleteFileInfo { - /** - * Validates task delete files and gives a Puffin deletion vector precedence over legacy - * position-delete files. Equality deletes remain post-read filters. - */ - def apply(dataFile: DataFile, deletes: Seq[DeleteFile]): GpuDeleteFileInfo = { - deletes.find(d => d.content() != FileContent.EQUALITY_DELETES && - d.content() != FileContent.POSITION_DELETES).foreach { delete => - throw new UnsupportedOperationException(s"Unsupported delete content: ${delete.content()}") - } - - val (equalityDeletes, positionDeletes) = - deletes.partition(_.content() == FileContent.EQUALITY_DELETES) - val (deletionVectors, legacyPositionDeletes) = - positionDeletes.partition(ShimUtils.isDeletionVector) - require(deletionVectors.size <= 1, - s"Expected at most one deletion vector per data file, found ${deletionVectors.size}") - - deletionVectors.headOption.foreach(validateScope(dataFile, _)) - val effectivePositionDeletes = - if (deletionVectors.nonEmpty) Seq.empty else legacyPositionDeletes - new GpuDeleteFileInfo(deletionVectors.headOption, - equalityDeletes ++ effectivePositionDeletes) - } - - private def validateScope(dataFile: DataFile, deletionVector: DeleteFile): Unit = { - val dataFilePath = ShimUtils.locationOf(dataFile) - val referencedDataFile = ShimUtils.referencedDataFile(deletionVector) - require(referencedDataFile != null, - s"Deletion vector ${ShimUtils.locationOf(deletionVector)} has no referenced data file") - require(dataFilePath == referencedDataFile, - s"Deletion vector ${ShimUtils.locationOf(deletionVector)} references " + - s"$referencedDataFile, not $dataFilePath") - - val deleteSequenceNumber = deletionVector.dataSequenceNumber() - val dataSequenceNumber = dataFile.dataSequenceNumber() - require(deleteSequenceNumber != null && dataSequenceNumber != null && - deleteSequenceNumber >= dataSequenceNumber, - s"Deletion vector sequence number $deleteSequenceNumber must be greater than or equal " + - s"to data file sequence number $dataSequenceNumber") - require(deletionVector.specId() == dataFile.specId(), - s"Deletion vector spec ${deletionVector.specId()} does not match data file spec " + - s"${dataFile.specId()}") - require(samePartition(deletionVector.partition(), dataFile.partition()), - s"Deletion vector partition ${deletionVector.partition()} does not match data file " + - s"partition ${dataFile.partition()}") - } - - private def samePartition(left: StructLike, right: StructLike): Boolean = { - if (left == null || right == null) { - return left == right - } - - left.size() == right.size() && (0 until left.size()).forall { index => - Objects.equals(left.get(index, classOf[Object]), right.get(index, classOf[Object])) - } - } -} diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuIcebergDeletionVector.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuIcebergDeletionVector.scala index 40a95e718c4..8976eb57239 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuIcebergDeletionVector.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuIcebergDeletionVector.scala @@ -44,18 +44,6 @@ object GpuIcebergDeletionVector extends Logging { (offsets.toArray, rowCounts.map(_.toInt).toArray) } - def computeNumRowsAlive( - totalNumRows: Long, - blocks: collection.Seq[BlockMetaData], - deletionVector: IcebergDeletionVector): Int = { - val (offsets, rowCounts) = rowGroupMetadata(blocks) - val deletedRows = deletionVector.countDeletedRows(offsets, rowCounts) - require(deletedRows <= totalNumRows, - s"Deletion vector cardinality in selected row groups ($deletedRows) exceeds " + - s"selected row count ($totalNumRows)") - Math.toIntExact(totalNumRows - deletedRows) - } - /** * Creates a cuDF table producer. The returned tables contain a leading INT64 file-row-index * column followed by the evolved Parquet columns. @@ -98,11 +86,26 @@ object GpuIcebergDeletionVector extends Logging { } else { withResource(buffers) { _ => withResource(dvInfo.serializedBitmap) { _ => - val rawTable = decodeWithErrorContext(conf, buffers, metrics, splits, - debugDumpPrefix, debugDumpAlways) { - RmmRapidsRetryIterator.withRetryNoSplit[Table] { - DeletionVector.readParquet(opts, buffers, Array(dvInfo)) + val rawTable = try { + NvtxIdWithMetrics(NvtxRegistry.PARQUET_DECODE, metrics(GPU_DECODE_TIME)) { + metrics.getOrElse(ICEBERG_DV_FILTER_TIME, NoopMetric).ns { + RmmRapidsRetryIterator.withRetryNoSplit[Table] { + DeletionVector.readParquet(opts, buffers, Array(dvInfo)) + } + } } + } catch { + case e: Exception => + val dumpMessage = debugDumpPrefix.map { prefix => + if (!debugDumpAlways) { + val path = DumpUtils.dumpBuffer(conf, buffers, prefix, ".parquet") + s", data dumped to $path" + } else { + "" + } + }.getOrElse("") + throw new IOException(s"Error when processing ${splits.mkString("; ")}" + + s"$dumpMessage", e) } new SingleGpuDataProducer(processTable(rawTable, readDataSchema, clippedParquetSchema, dateRebaseMode, timestampRebaseMode, isSchemaCaseSensitive, useFieldId, splits, @@ -115,47 +118,14 @@ object GpuIcebergDeletionVector extends Logging { private def makeInfo( deletionVector: IcebergDeletionVector, blocks: collection.Seq[BlockMetaData]): DeletionVector.DeletionVectorInfo = { - val bytes = deletionVector.serializedBitmap() - val bitmap = closeOnExcept(HostMemoryBuffer.allocate(bytes.length)) { buffer => - buffer.setBytes(0, bytes, 0, bytes.length) - buffer - } + val bitmap = deletionVector.serializedBitmap() closeOnExcept(bitmap) { _ => val (offsets, rowCounts) = rowGroupMetadata(blocks) new DeletionVector.DeletionVectorInfo(bitmap, false, offsets, rowCounts) } } - private def decodeWithMetrics[T](metrics: Map[String, GpuMetric])(decode: => T): T = { - NvtxIdWithMetrics(NvtxRegistry.PARQUET_DECODE, metrics(GPU_DECODE_TIME)) { - metrics.getOrElse(ICEBERG_DV_FILTER_TIME, NoopMetric).ns(decode) - } - } - - private def decodeWithErrorContext[T]( - conf: Configuration, - buffers: Array[HostMemoryBuffer], - metrics: Map[String, GpuMetric], - splits: Array[PartitionedFile], - debugDumpPrefix: Option[String], - debugDumpAlways: Boolean)(decode: => T): T = { - try { - decodeWithMetrics(metrics)(decode) - } catch { - case e: Exception => - val dumpMessage = debugDumpPrefix.map { prefix => - if (!debugDumpAlways) { - val path = DumpUtils.dumpBuffer(conf, buffers, prefix, ".parquet") - s", data dumped to $path" - } else { - "" - } - }.getOrElse("") - throw new IOException(s"Error when processing ${splits.mkString("; ")}$dumpMessage", e) - } - } - - private def processTable( + private[parquet] def processTable( rawTable: Table, readDataSchema: StructType, clippedParquetSchema: MessageType, @@ -192,39 +162,58 @@ object GpuIcebergDeletionVector extends Logging { } } - private class ChunkedDeletionVectorProducer( - maxChunkedReaderMemoryUsageSizeBytes: Long, - conf: Configuration, - chunkSizeByteLimit: Long, - opts: ParquetOptions, - buffers: Array[HostMemoryBuffer], - metrics: Map[String, GpuMetric], - dateRebaseMode: DateTimeRebaseMode, - timestampRebaseMode: DateTimeRebaseMode, - isSchemaCaseSensitive: Boolean, - useFieldId: Boolean, - readDataSchema: StructType, - clippedParquetSchema: MessageType, - splits: Array[PartitionedFile], - debugDumpPrefix: Option[String], - debugDumpAlways: Boolean, - dvInfo: DeletionVector.DeletionVectorInfo) extends GpuDataProducer[Table] { - private val reader = DeletionVector.newParquetChunkedReader( - chunkSizeByteLimit, maxChunkedReaderMemoryUsageSizeBytes, opts, buffers, Array(dvInfo)) +} - override def hasNext: Boolean = reader.hasNext +private class ChunkedDeletionVectorProducer( + maxChunkedReaderMemoryUsageSizeBytes: Long, + conf: Configuration, + chunkSizeByteLimit: Long, + opts: ParquetOptions, + buffers: Array[HostMemoryBuffer], + metrics: Map[String, GpuMetric], + dateRebaseMode: DateTimeRebaseMode, + timestampRebaseMode: DateTimeRebaseMode, + isSchemaCaseSensitive: Boolean, + useFieldId: Boolean, + readDataSchema: StructType, + clippedParquetSchema: MessageType, + splits: Array[PartitionedFile], + debugDumpPrefix: Option[String], + debugDumpAlways: Boolean, + dvInfo: DeletionVector.DeletionVectorInfo) extends GpuDataProducer[Table] { + private val reader = DeletionVector.newParquetChunkedReader( + chunkSizeByteLimit, maxChunkedReaderMemoryUsageSizeBytes, opts, buffers, Array(dvInfo)) - override def next: Table = { - val rawTable = decodeWithErrorContext(conf, buffers, metrics, splits, - debugDumpPrefix, debugDumpAlways) { - reader.readChunk() - } - processTable(rawTable, readDataSchema, clippedParquetSchema, dateRebaseMode, - timestampRebaseMode, isSchemaCaseSensitive, useFieldId, splits, metrics) + override def hasNext: Boolean = reader.hasNext + + override def next: Table = { + val rawTable = decodeWithErrorContext { + reader.readChunk() } + GpuIcebergDeletionVector.processTable(rawTable, readDataSchema, clippedParquetSchema, + dateRebaseMode, timestampRebaseMode, isSchemaCaseSensitive, useFieldId, splits, metrics) + } - override def close(): Unit = { - (Seq(reader) ++ buffers ++ Seq(dvInfo.serializedBitmap)).safeClose() + private def decodeWithErrorContext[T](decode: => T): T = { + try { + NvtxIdWithMetrics(NvtxRegistry.PARQUET_DECODE, metrics(GPU_DECODE_TIME)) { + metrics.getOrElse(ICEBERG_DV_FILTER_TIME, NoopMetric).ns(decode) + } + } catch { + case e: Exception => + val dumpMessage = debugDumpPrefix.map { prefix => + if (!debugDumpAlways) { + val path = DumpUtils.dumpBuffer(conf, buffers, prefix, ".parquet") + s", data dumped to $path" + } else { + "" + } + }.getOrElse("") + throw new IOException(s"Error when processing ${splits.mkString("; ")}$dumpMessage", e) } } + + override def close(): Unit = { + (Seq(reader) ++ buffers ++ Seq(dvInfo.serializedBitmap)).safeClose() + } } diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuMultiThreadIcebergParquetReader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuMultiThreadIcebergParquetReader.scala index 7fdb729eacf..ba9e2bd5961 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuMultiThreadIcebergParquetReader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuMultiThreadIcebergParquetReader.scala @@ -19,6 +19,8 @@ package com.nvidia.spark.rapids.iceberg.parquet import java.util.{Map => JMap} import java.util.concurrent.{ConcurrentHashMap, ConcurrentMap} +import scala.collection.JavaConverters._ + import com.nvidia.spark.rapids._ import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource} import com.nvidia.spark.rapids.RapidsPluginImplicits._ @@ -26,12 +28,9 @@ import com.nvidia.spark.rapids.fileio.iceberg.IcebergFileIO import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector import com.nvidia.spark.rapids.iceberg.data.GpuDeleteFilter import com.nvidia.spark.rapids.parquet.{CpuCompressionConfig, HostMemoryBuffersWithMetaData, - HostMemoryEmptyMetaData, MultiFileCloudParquetPartitionReader, ParquetDataBlock} -import org.apache.parquet.hadoop.metadata.BlockMetaData -import org.apache.parquet.schema.MessageType + MultiFileCloudParquetPartitionReader, ParquetDataBlock} import org.apache.spark.TaskContext -import org.apache.spark.sql.catalyst.InternalRow import org.apache.spark.sql.execution.datasources.PartitionedFile import org.apache.spark.sql.types.{LongType, StructType} import org.apache.spark.sql.vectorized.ColumnarBatch @@ -46,14 +45,20 @@ class GpuMultiThreadIcebergParquetReader( private val pathToFile = files.groupBy(_.urlEncodedPath).mapValues(_.toSeq) private val postProcessors: ConcurrentMap[IcebergPartitionedFile, GpuParquetReaderPostProcessor] = new ConcurrentHashMap[IcebergPartitionedFile, GpuParquetReaderPostProcessor](files.size) + private val deletionVectors: ConcurrentMap[IcebergPartitionedFile, IcebergDeletionVector] = + new ConcurrentHashMap[IcebergPartitionedFile, IcebergDeletionVector](files.size) private var inited = false private lazy val reader = createParquetReader() override def close(): Unit = { - if (inited) { - withResource(reader) { _ => } + try { + if (inited) { + withResource(reader) { _ => } + } + } finally { + deletionVectors.values().asScala.toSeq.safeClose() } } @@ -72,7 +77,6 @@ class GpuMultiThreadIcebergParquetReader( private def createParquetReader() = { val sparkPartitionedFiles = files.map(_.sparkPartitionedFile).toArray val multiThreadConf = conf.threadConf.asInstanceOf[MultiThread] - val readerConf = conf inited = true new MultiFileCloudParquetPartitionReader( @@ -117,7 +121,7 @@ class GpuMultiThreadIcebergParquetReader( override protected def readBufferToBatches( buffer: HostMemoryBuffersWithMetaData): Iterator[ColumnarBatch] = { val icebergFile = findIcebergFile(buffer.partitionedFile) - deletionVectorProvider(icebergFile).map { deletionVector => + Option(deletionVectors.get(icebergFile)).map { deletionVector => val hmbAndInfo = buffer.memBuffersAndSizes.head val hostBuffers = hmbAndInfo.hmbs val blocks = hmbAndInfo.blockMeta.map(_.asInstanceOf[ParquetDataBlock].dataBlock) @@ -131,49 +135,24 @@ class GpuMultiThreadIcebergParquetReader( val hostBufs = hostBuffers.safeMap(_.getDataHostBuffer()) GpuSemaphore.acquireIfNecessary(TaskContext.get()) val producer = GpuIcebergDeletionVector.makeProducer( - readerConf.useChunkedReader, readerConf.maxChunkedReaderMemoryUsageSizeBytes, - readerConf.conf, readerConf.targetBatchSizeBytes, parseOptions, hostBufs, - readerConf.metrics, buffer.dateRebaseMode, buffer.timestampRebaseMode, - readerConf.caseSensitive, + GpuMultiThreadIcebergParquetReader.this.conf.useChunkedReader, + GpuMultiThreadIcebergParquetReader.this.conf.maxChunkedReaderMemoryUsageSizeBytes, + GpuMultiThreadIcebergParquetReader.this.conf.conf, + GpuMultiThreadIcebergParquetReader.this.conf.targetBatchSizeBytes, + parseOptions, hostBufs, GpuMultiThreadIcebergParquetReader.this.conf.metrics, + buffer.dateRebaseMode, buffer.timestampRebaseMode, + GpuMultiThreadIcebergParquetReader.this.conf.caseSensitive, useFieldId = false, buffer.readSchema, buffer.clippedSchema, - Array(buffer.partitionedFile), readerConf.parquetDebugDumpPrefix, - readerConf.parquetDebugDumpAlways, deletionVector, blocks) + Array(buffer.partitionedFile), + GpuMultiThreadIcebergParquetReader.this.conf.parquetDebugDumpPrefix, + GpuMultiThreadIcebergParquetReader.this.conf.parquetDebugDumpAlways, + deletionVector, blocks) CachedGpuBatchIterator(producer, columnTypes) } } }.getOrElse(super.readBufferToBatches(buffer)) } - override protected def newHMEmptyMetadataForChunks( - partitionedFile: PartitionedFile, - bufferSize: Long, - bytesRead: Long, - dateRebaseMode: DateTimeRebaseMode, - timestampRebaseMode: DateTimeRebaseMode, - hasInt96Timestamps: Boolean, - clippedSchema: MessageType, - readSchema: StructType, - numRows: Long, - blocks: collection.Seq[BlockMetaData]): HostMemoryEmptyMetaData = { - IcebergHostMemoryEmptyMetaData(partitionedFile, bufferSize, bytesRead, - dateRebaseMode, timestampRebaseMode, hasInt96Timestamps, - clippedSchema, readSchema, numRows, blocks) - } - - override protected def computeNumRowsAlive( - totalNumRows: Long, - metadata: HostMemoryBuffersWithMetaDataBase): Int = { - val icebergFile = findIcebergFile(metadata.partitionedFile) - deletionVectorProvider(icebergFile).map { deletionVector => - val blocks = metadata match { - case empty: IcebergHostMemoryEmptyMetaData => empty.blocks - case _ => metadata.memBuffersAndSizes.flatMap(_.blockMeta) - .map(_.asInstanceOf[ParquetDataBlock].dataBlock).toSeq - } - GpuIcebergDeletionVector.computeNumRowsAlive(totalNumRows, blocks, deletionVector) - }.getOrElse(Math.toIntExact(totalNumRows)) - } - override def readBatches( fileBufsAndMeta: HostMemoryBuffersWithMetaDataBase): Iterator[ColumnarBatch] = { val icebergFile = findIcebergFile(fileBufsAndMeta.partitionedFile) @@ -197,36 +176,29 @@ class GpuMultiThreadIcebergParquetReader( val icebergFile = findIcebergFile(f) val deleteFilter = deleteFilterProvider(icebergFile) val deletionVector = deletionVectorProvider(icebergFile) - - val requiredSchema = deleteFilter.map(_.requiredSchema).getOrElse(conf.expectedSchema) - - val (filteredParquet, shadedFileReadSchema) = - super.filterParquetBlocks(icebergFile, requiredSchema, deletionVector.isDefined) - - val postProcessor = new GpuParquetReaderPostProcessor( - filteredParquet, - constantsProvider(icebergFile), - requiredSchema, - shadedFileReadSchema, - conf.metrics, - hasNativeRowIndex = deletionVector.isDefined) - - val old = postProcessors.put(icebergFile, postProcessor) - require(old == null, "Iceberg parquet partition file post processor already exists!") - filteredParquet + closeOnExcept(deletionVector) { _ => + val requiredSchema = deleteFilter.map(_.requiredSchema).getOrElse(conf.expectedSchema) + + val (filteredParquet, shadedFileReadSchema) = + super.filterParquetBlocks(icebergFile, requiredSchema, deletionVector.isDefined) + + val postProcessor = new GpuParquetReaderPostProcessor( + filteredParquet, + constantsProvider(icebergFile), + requiredSchema, + shadedFileReadSchema, + conf.metrics, + hasNativeRowIndex = deletionVector.isDefined) + + val oldProcessor = postProcessors.put(icebergFile, postProcessor) + require(oldProcessor == null, + "Iceberg parquet partition file post processor already exists!") + deletionVector.foreach { dv => + val oldVector = deletionVectors.put(icebergFile, dv) + require(oldVector == null, + "Iceberg parquet partition file deletion vector already exists!") + } + filteredParquet + } } } - -private case class IcebergHostMemoryEmptyMetaData( - override val partitionedFile: PartitionedFile, - bufferSize: Long, - override val bytesRead: Long, - dateRebaseMode: DateTimeRebaseMode, - timestampRebaseMode: DateTimeRebaseMode, - hasInt96Timestamps: Boolean, - clippedSchema: MessageType, - readSchema: StructType, - numRows: Long, - blocks: collection.Seq[BlockMetaData], - override val allPartValues: Option[Array[(Long, InternalRow)]] = None) - extends HostMemoryEmptyMetaData diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala index b6832a5b755..4f01349f001 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala @@ -21,8 +21,8 @@ import java.util.{Map => JMap} import scala.annotation.tailrec import ai.rapids.cudf.ParquetOptions -import com.nvidia.spark.rapids.{CachedGpuBatchIterator, DateTimeRebaseCorrected, EmptyTableReader, - GpuSemaphore, PartitionReaderWithBytesRead, RmmRapidsRetryIterator, SpillableHostBuffer} +import com.nvidia.spark.rapids.{CachedGpuBatchIterator, DateTimeRebaseCorrected, GpuSemaphore, + PartitionReaderWithBytesRead, RmmRapidsRetryIterator, SpillableHostBuffer} import com.nvidia.spark.rapids.Arm.withResource import com.nvidia.spark.rapids.fileio.iceberg.IcebergFileIO import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector @@ -117,8 +117,12 @@ private class SingleFileReader( private lazy val (reader, postProcessor) = open() override def close(): Unit = { - if (inited) { - withResource(reader) { _ => } + try { + if (inited) { + withResource(reader) { _ => } + } + } finally { + withResource(deletionVector) { _ => } } } @@ -163,33 +167,21 @@ private class SingleFileReader( chunkedBlocks: Seq[BlockMetaData], dataBuffer: SpillableHostBuffer): Iterator[ColumnarBatch] = { deletionVector.map { dv => - if (dataBuffer.length == 0) { - dataBuffer.close() - CachedGpuBatchIterator(EmptyTableReader, LongType +: colTypes) - } else { - RmmRapidsRetryIterator.withRetryNoSplit(dataBuffer) { _ => - val hostBuffer = dataBuffer.getDataHostBuffer() - GpuSemaphore.acquireIfNecessary(TaskContext.get()) - val producer = GpuIcebergDeletionVector.makeProducer( - readerConf.useChunkedReader, readerConf.maxChunkedReaderMemoryUsageSizeBytes, - readerConf.conf, readerConf.targetBatchSizeBytes, parquetOpts, Array(hostBuffer), - readerConf.metrics, DateTimeRebaseCorrected, DateTimeRebaseCorrected, - readerConf.caseSensitive, - useFieldId = false, filteredParquet.readSchema, filteredParquet.schema, - Array(file.sparkPartitionedFile), readerConf.parquetDebugDumpPrefix, - readerConf.parquetDebugDumpAlways, dv, chunkedBlocks) - CachedGpuBatchIterator(producer, LongType +: colTypes) - } + RmmRapidsRetryIterator.withRetryNoSplit(dataBuffer) { _ => + val hostBuffer = dataBuffer.getDataHostBuffer() + GpuSemaphore.acquireIfNecessary(TaskContext.get()) + val producer = GpuIcebergDeletionVector.makeProducer( + readerConf.useChunkedReader, readerConf.maxChunkedReaderMemoryUsageSizeBytes, + readerConf.conf, readerConf.targetBatchSizeBytes, parquetOpts, Array(hostBuffer), + readerConf.metrics, DateTimeRebaseCorrected, DateTimeRebaseCorrected, + readerConf.caseSensitive, + useFieldId = false, filteredParquet.readSchema, filteredParquet.schema, + Array(file.sparkPartitionedFile), readerConf.parquetDebugDumpPrefix, + readerConf.parquetDebugDumpAlways, dv, chunkedBlocks) + CachedGpuBatchIterator(producer, LongType +: colTypes) } }.getOrElse(super.readBuffer(parquetOpts, colTypes, chunkedBlocks, dataBuffer)) } - - override protected def computeNumRowsAlive( - totalNumRows: Long, - chunkedBlocks: Seq[BlockMetaData]): Int = { - deletionVector.map(GpuIcebergDeletionVector.computeNumRowsAlive( - totalNumRows, chunkedBlocks, _)).getOrElse(Math.toIntExact(totalNumRows)) - } } val parquetReader = new PartitionReaderWithBytesRead(parquetPartReader) diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala index fb70519928f..1d1acd92b30 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala @@ -211,7 +211,7 @@ trait GpuIcebergParquetReader extends Iterator[ColumnarBatch] with AutoCloseable requiredSchema.findField(MetadataColumns.ROW_POSITION.fieldId()) != null val initialProjection = projectSchema(fileSchema, requiredSchema) val (typeWithIds, fileReadSchema) = - if (hasDeletionVector && needsRowPosition && initialProjection._2.getFieldCount == 0 && + if (hasDeletionVector && initialProjection._2.getFieldCount == 0 && fileSchema.getFieldCount > 0) { val firstFileField = ParquetSchemaUtil.convert(fileSchema).columns().get(0) val projectionFields = requiredSchema.columns().asScala diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala index 763ffa425b6..304763d8d22 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala @@ -23,8 +23,7 @@ import com.nvidia.spark.rapids.MapUtil.toMapStrict import com.nvidia.spark.rapids.fileio.iceberg.IcebergFileIO import com.nvidia.spark.rapids.iceberg.ShimUtils import com.nvidia.spark.rapids.iceberg.ShimUtils.locationOf -import com.nvidia.spark.rapids.iceberg.data.{DefaultDeleteLoader, GpuDeleteFileInfo, - GpuDeleteFilter} +import com.nvidia.spark.rapids.iceberg.data.{DefaultDeleteLoader, GpuDeleteFilter} import com.nvidia.spark.rapids.iceberg.parquet._ import org.apache.iceberg._ import org.apache.iceberg.encryption.EncryptedFiles @@ -46,17 +45,14 @@ class GpuIcebergPartitionReader(private val task: GpuSparkInputPartition, private lazy val rapidsFileIO = new IcebergFileIO(fileIO) private lazy val conf = newConf() private lazy val (inputFiles, tasks) = collectFiles() - private lazy val deleteInfoMap: Map[IcebergPartitionedFile, GpuDeleteFileInfo] = + private lazy val deleteInfoMap = tasks.map { case (file, scanTask) => - file -> GpuDeleteFileInfo(scanTask.file(), scanTask.deletes().asScala.toSeq) + file -> GpuDeleteFilter.splitDeleteFiles(scanTask.deletes().asScala.toSeq) } - private lazy val deletionVectorMap = { - val loader = new DefaultDeleteLoader(rapidsFileIO, inputFiles, conf) - deleteInfoMap.map { case (file, deleteInfo) => - val deletionVector = deleteInfo.deletionVector.map { delete => - loader.loadDeletionVector(delete, ShimUtils.locationOf(tasks(file).file())) - } - file -> deletionVector + private lazy val deleteLoader = new DefaultDeleteLoader(rapidsFileIO, inputFiles, conf) + private def deletionVectorProvider(file: IcebergPartitionedFile) = { + deleteInfoMap(file).deletionVector.map { delete => + deleteLoader.loadDeletionVector(delete, ShimUtils.locationOf(tasks(file).file())) } } private lazy val gpuDeleteFiterMap: Map[IcebergPartitionedFile, Option[GpuDeleteFilter]] = @@ -100,10 +96,10 @@ class GpuIcebergPartitionReader(private val task: GpuSparkInputPartition, threadConf match { case SingleFile => new GpuSingleThreadIcebergParquetReader(rapidsFileIO, files, constantsMap, - gpuDeleteFiterMap, deletionVectorMap, conf) + gpuDeleteFiterMap, deletionVectorProvider, conf) case _: MultiThread => new GpuMultiThreadIcebergParquetReader(rapidsFileIO, files, constantsMap, - gpuDeleteFiterMap, deletionVectorMap, conf) + gpuDeleteFiterMap, deletionVectorProvider, conf) case _: MultiFile => new GpuCoalescingIcebergParquetReader(rapidsFileIO, files, constantsMap, conf) } diff --git a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java index f0e21cbd4a7..b063fac695d 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java @@ -39,7 +39,6 @@ import org.apache.spark.sql.connector.read.Scan; import java.io.IOException; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -95,37 +94,7 @@ public IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, InputFile } PositionDeleteIndex index = PositionDeleteIndex.deserialize(bytes, deleteFile); - long cardinality = index.cardinality(); - byte[] serializedBitmap = Arrays.copyOfRange(bytes, 8, bytes.length - 4); - return new IcebergDeletionVector() { - @Override - public byte[] serializedBitmap() { - return serializedBitmap; - } - - @Override - public long cardinality() { - return cardinality; - } - - @Override - public long countDeletedRows(long[] rowGroupOffsets, int[] rowGroupNumRows) { - if (rowGroupOffsets.length != rowGroupNumRows.length) { - throw new IllegalArgumentException("Mismatched row-group metadata lengths"); - } - long[] count = new long[] {0L}; - index.forEach(position -> { - for (int i = 0; i < rowGroupOffsets.length; i++) { - long start = rowGroupOffsets[i]; - if (position >= start && position - start < rowGroupNumRows[i]) { - count[0] += 1L; - break; - } - } - }); - return count[0]; - } - }; + return new IcebergDeletionVector(bytes, 8, bytes.length - 12, index.cardinality()); } @Override diff --git a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java index a53941b25b0..f8a7ab507fd 100644 --- a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java @@ -39,7 +39,6 @@ import org.apache.spark.sql.connector.read.Scan; import java.io.IOException; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -95,37 +94,7 @@ public IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, InputFile } PositionDeleteIndex index = PositionDeleteIndex.deserialize(bytes, deleteFile); - long cardinality = index.cardinality(); - byte[] serializedBitmap = Arrays.copyOfRange(bytes, 8, bytes.length - 4); - return new IcebergDeletionVector() { - @Override - public byte[] serializedBitmap() { - return serializedBitmap; - } - - @Override - public long cardinality() { - return cardinality; - } - - @Override - public long countDeletedRows(long[] rowGroupOffsets, int[] rowGroupNumRows) { - if (rowGroupOffsets.length != rowGroupNumRows.length) { - throw new IllegalArgumentException("Mismatched row-group metadata lengths"); - } - long[] count = new long[] {0L}; - index.forEach(position -> { - for (int i = 0; i < rowGroupOffsets.length; i++) { - long start = rowGroupOffsets[i]; - if (position >= start && position - start < rowGroupNumRows[i]) { - count[0] += 1L; - break; - } - } - }); - return count[0]; - } - }; + return new IcebergDeletionVector(bytes, 8, bytes.length - 12, index.cardinality()); } @Override diff --git a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java index 45360ef2b69..8a8f5c1f35b 100644 --- a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java @@ -34,7 +34,6 @@ import org.apache.spark.sql.connector.read.Scan; import java.io.IOException; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -90,37 +89,7 @@ public IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, InputFile } PositionDeleteIndex index = PositionDeleteIndex.deserialize(bytes, deleteFile); - long cardinality = index.cardinality(); - byte[] serializedBitmap = Arrays.copyOfRange(bytes, 8, bytes.length - 4); - return new IcebergDeletionVector() { - @Override - public byte[] serializedBitmap() { - return serializedBitmap; - } - - @Override - public long cardinality() { - return cardinality; - } - - @Override - public long countDeletedRows(long[] rowGroupOffsets, int[] rowGroupNumRows) { - if (rowGroupOffsets.length != rowGroupNumRows.length) { - throw new IllegalArgumentException("Mismatched row-group metadata lengths"); - } - long[] count = new long[] {0L}; - index.forEach(position -> { - for (int i = 0; i < rowGroupOffsets.length; i++) { - long start = rowGroupOffsets[i]; - if (position >= start && position - start < rowGroupNumRows[i]) { - count[0] += 1L; - break; - } - } - }); - return count[0]; - } - }; + return new IcebergDeletionVector(bytes, 8, bytes.length - 12, index.cardinality()); } @Override diff --git a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py index 660ff88f06c..77f84b2d6b4 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py @@ -99,43 +99,6 @@ def test_iceberg_v2_position_delete(spark_tmp_table_factory, reader_type): conf={'spark.rapids.sql.format.parquet.reader.type': reader_type}) -@iceberg -@ignore_order(local=True) -@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) -@pytest.mark.parametrize('reader_type', rapids_reader_types) -@validate_execs_in_gpu_plan('GpuBatchScanExec') -def test_iceberg_v3_deletion_vector(spark_tmp_table_factory, reader_type): - table_name = get_full_table_name(spark_tmp_table_factory) - create_iceberg_table( - table_name, - table_prop={ - 'format-version': '3', - 'write.delete.mode': 'merge-on-read', - }, - df_gen=lambda spark: spark.range(256).selectExpr( - 'id', 'CAST(id % 11 AS INT) AS value')) - - def setup_deletion_vector(spark): - spark.range(256).selectExpr( - 'id', 'CAST(id % 11 AS INT) AS value').writeTo(table_name).append() - spark.sql(f'DELETE FROM {table_name} WHERE id % 5 = 0') - spark.sql(f'REFRESH TABLE {table_name}') - delete_formats = { - row.file_format for row in - spark.sql(f'SELECT file_format FROM {table_name}.delete_files').collect() - } - assert 'PUFFIN' in delete_formats, \ - f'Expected a Puffin deletion vector, found delete formats {delete_formats}' - - with_cpu_session(setup_deletion_vector) - - assert_gpu_and_cpu_are_equal_collect( - lambda spark: spark.sql(f'SELECT id, value FROM {table_name}'), - conf={ - 'spark.rapids.sql.format.iceberg.v3.enabled': 'true', - 'spark.rapids.sql.format.parquet.reader.type': reader_type, - }) - @iceberg @ignore_order(local=True) @pytest.mark.parametrize('reader_type', rapids_reader_types) @@ -162,12 +125,14 @@ def test_iceberg_v2_position_delete_with_url_encoded_path(spark_tmp_table_factor @ignore_order(local=True) @pytest.mark.parametrize('reader_type', rapids_reader_types) @pytest.mark.skipif(is_iceberg_remote_catalog(), reason = "S3tables catalog is managed") +@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) @pytest.mark.xfail(reason = "https://github.com/NVIDIA/spark-rapids/issues/12885") # When using this datagen, local run is 784 rows @pytest.mark.datagen_overrides(seed=1749483297, permanent=True, reason="Debug https://github.com/NVIDIA/spark-rapids/issues/12885") -def test_iceberg_v2_mixed_deletes(spark_tmp_table_factory, spark_tmp_path, reader_type, - register_iceberg_add_eq_deletes_udf): +@validate_execs_in_gpu_plan('GpuBatchScanExec') +def test_iceberg_mixed_deletes(spark_tmp_table_factory, spark_tmp_path, reader_type, + register_iceberg_add_eq_deletes_udf): # We use a fixed seed here to ensure that data deletion vector has been generated table_name = setup_base_iceberg_table(spark_tmp_table_factory) # Position deletes @@ -193,18 +158,44 @@ def test_iceberg_v2_mixed_deletes(spark_tmp_table_factory, spark_tmp_path, reade spark_tmp_path), "No equation deletes generated") + # Upgrade only after creating v2 position deletes, then create a v3 deletion vector. This + # leaves equality deletes, legacy position deletes, and deletion vectors in the same table. + def add_deletion_vector(spark): + spark.sql( + f"ALTER TABLE {table_name} SET TBLPROPERTIES ('format-version' = '3')") + spark.sql(f"DELETE FROM {table_name} where _c1 >= 0 and _c2 % 7 = 0") + spark.sql(f"REFRESH TABLE {table_name}") + delete_files = { + (row.content, row.file_format) for row in + spark.sql( + f"SELECT content, file_format FROM {table_name}.delete_files").collect() + } + expected_delete_files = { + (1, 'PARQUET'), # Legacy position delete + (2, 'PARQUET'), # Equality delete + (1, 'PUFFIN'), # Deletion vector + } + assert expected_delete_files.issubset(delete_files), \ + f"Expected mixed delete files {expected_delete_files}, found {delete_files}" + + with_cpu_session(add_deletion_vector) + + read_conf = { + 'spark.rapids.sql.format.iceberg.v3.enabled': 'true', + 'spark.rapids.sql.format.parquet.reader.type': reader_type, + } # Trigger a count operation to verify that it works gpu_count = with_gpu_session(lambda spark: spark.table(table_name).count(), - conf={'spark.rapids.sql.format.parquet.reader.type': reader_type}) + conf=read_conf) cpu_count = with_cpu_session(lambda spark: spark.table(table_name).count(), - conf={'spark.rapids.sql.format.parquet.reader.type': reader_type}) + conf=read_conf) assert gpu_count == cpu_count, f"Result count diverges, cpu: {cpu_count}, gpu: {gpu_count}" logging.info(f"Count is {cpu_count}") assert_gpu_and_cpu_are_equal_collect( lambda spark: spark.table(table_name), - conf={'spark.rapids.sql.format.parquet.reader.type': reader_type}) + conf=read_conf) def _normalize_position_delete_df(df): diff --git a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilterSuite.scala b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilterSuite.scala index 30afe10cd78..71eccc21711 100644 --- a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilterSuite.scala +++ b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilterSuite.scala @@ -42,8 +42,7 @@ import com.nvidia.spark.rapids.iceberg.data.TestGpuDeleteLoader._ import com.nvidia.spark.rapids.iceberg.parquet.{GpuIcebergParquetReaderConf, SingleFile} import com.nvidia.spark.rapids.spill.SpillFramework import org.apache.hadoop.conf.Configuration -import org.apache.iceberg.{DeleteFile, FileContent, FileFormat, FileMetadata, MetadataColumns, - PartitionSpec, Schema} +import org.apache.iceberg.{DeleteFile, FileContent, FileFormat, FileMetadata, MetadataColumns, PartitionSpec, Schema} import org.apache.iceberg.MetadataColumns.isMetadataColumn import org.apache.iceberg.common.DynMethods import org.apache.iceberg.hadoop.HadoopFileIO diff --git a/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala b/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala index a1964a7e193..f44e70eaff7 100644 --- a/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala +++ b/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala @@ -29,6 +29,7 @@ import java.nio.file.Files import scala.collection.JavaConverters._ +import com.nvidia.spark.rapids.Arm.withResource import org.apache.hadoop.conf.Configuration import org.apache.hadoop.fs.{Path => HadoopPath} import org.apache.iceberg.{DeleteFile, FileFormat, FileMetadata, PartitionSpec} @@ -56,11 +57,10 @@ class DeletionVectorReaderSuite extends AnyFunSuite { fileSize = fileBytes.length) val inputFile = HadoopInputFile.fromPath(new HadoopPath(path.toUri), new Configuration()) - val deletionVector = ShimUtils.readDeletionVector(deleteFile, inputFile) - assert(deletionVector.serializedBitmap().sameElements(portableBitmap(targetBlob))) - assert(deletionVector.cardinality() == expectedPositions.size) - assert(deletionVector.countDeletedRows( - Array(0L, 1L << 40), Array(18, 4)) == expectedPositions.size) + withResource(ShimUtils.readDeletionVector(deleteFile, inputFile)) { deletionVector => + assert(serializedBitmap(deletionVector).sameElements(portableBitmap(targetBlob))) + assert(deletionVector.cardinality() == expectedPositions.size) + } } finally { Files.deleteIfExists(path) } @@ -80,11 +80,12 @@ class DeletionVectorReaderSuite extends AnyFunSuite { fileSize = blob.length) val inputFile = HadoopInputFile.fromPath(new HadoopPath(path.toUri), new Configuration()) - val deletionVector = ShimUtils.readDeletionVector(deleteFile, inputFile) - assert(deletionVector.serializedBitmap().sameElements(portableBitmap(blob))) - assert(deletionVector.serializedBitmap().length == 8) - assert(deletionVector.cardinality() == 0) - assert(deletionVector.countDeletedRows(Array(0L), Array(100)) == 0) + withResource(ShimUtils.readDeletionVector(deleteFile, inputFile)) { deletionVector => + val bitmap = serializedBitmap(deletionVector) + assert(bitmap.sameElements(portableBitmap(blob))) + assert(bitmap.length == 8) + assert(deletionVector.cardinality() == 0) + } } finally { Files.deleteIfExists(path) } @@ -107,6 +108,14 @@ class DeletionVectorReaderSuite extends AnyFunSuite { serializedIndex.slice(8, serializedIndex.length - 4) } + private def serializedBitmap(deletionVector: IcebergDeletionVector): Array[Byte] = { + withResource(deletionVector.serializedBitmap()) { bitmap => + val bytes = new Array[Byte](bitmap.getLength.toInt) + bitmap.getBytes(bytes, 0, 0, bytes.length) + bytes + } + } + private def deletionVectorFile( location: String, referencedDataFile: String, From aaa1c8d23a34ef74f425971929e62b94dfc81a2f Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Tue, 18 Aug 2026 16:38:18 +0800 Subject: [PATCH 04/18] Refine Iceberg deletion vector integration Signed-off-by: Ray Liu --- .../rapids/iceberg/IcebergDeletionVector.java | 13 +- .../iceberg/IcebergDeletionVectorReader.java | 51 +++++++ .../rapids/iceberg/IcebergShimUtils.java | 13 +- .../spark/rapids/iceberg/ShimUtils.java | 16 +- .../rapids/iceberg/data/GpuDeleteFilter.scala | 22 ++- .../rapids/iceberg/data/GpuDeleteLoader.scala | 21 +-- .../parquet/GpuIcebergDeletionVector.scala | 1 + .../GpuParquetReaderPostProcessor.scala | 58 ++++---- .../spark/rapids/iceberg/parquet/reader.scala | 3 + .../source/GpuIcebergPartitionReader.scala | 7 +- .../iceberg/iceberg110x/ShimUtilsImpl.java | 42 +----- .../iceberg/iceberg111x/ShimUtilsImpl.java | 42 +----- .../iceberg/iceberg16x/ShimUtilsImpl.java | 20 +-- .../iceberg/iceberg19x/ShimUtilsImpl.java | 42 +----- .../iceberg/iceberg_merge_on_read_test.py | 52 ++++++- .../iceberg/GpuPostProcessorSuite.scala | 44 ++++++ .../iceberg/DeletionVectorReaderSuite.scala | 137 ------------------ 17 files changed, 236 insertions(+), 348 deletions(-) create mode 100644 iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVectorReader.java delete mode 100644 tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java index dd9382cf7d9..25edf9836ce 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java @@ -26,6 +26,7 @@ */ public final class IcebergDeletionVector implements AutoCloseable { private final HostMemoryBuffer serializedBitmap; + private final long serializedSizeInBytes; private final long cardinality; public IcebergDeletionVector( @@ -41,17 +42,25 @@ public IcebergDeletionVector( throw e; } this.serializedBitmap = bitmap; + this.serializedSizeInBytes = serializedIndex.length; this.cardinality = cardinality; } /** - * Returns a new reference to the portable serialized 64-bit Roaring bitmap expected by cuDF. + * Returns the portable serialized 64-bit Roaring bitmap expected by cuDF. + * + *

The returned buffer is owned by this object. Callers that retain it must increment its + * reference count. */ public HostMemoryBuffer serializedBitmap() { - serializedBitmap.incRefCount(); return serializedBitmap; } + /** Returns the full serialized deletion-vector size, including its header and checksum. */ + public long serializedSizeInBytes() { + return serializedSizeInBytes; + } + /** Returns the number of positions in the deletion vector. */ public long cardinality() { return cardinality; diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVectorReader.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVectorReader.java new file mode 100644 index 00000000000..89a8795fe4b --- /dev/null +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVectorReader.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * Licensed 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 com.nvidia.spark.rapids.iceberg; + +import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; +import com.nvidia.spark.rapids.jni.fileio.SeekableInputStream; +import org.apache.iceberg.io.IOUtil; + +import java.io.IOException; +import java.util.function.ToLongFunction; + +/** Shared deletion-vector byte-range reader for Iceberg versions that support v3. */ +public final class IcebergDeletionVectorReader { + private IcebergDeletionVectorReader() {} + + public static IcebergDeletionVector read( + RapidsInputFile inputFile, + Long offset, + Long size, + ToLongFunction cardinality) throws IOException { + if (offset == null || offset < 0) { + throw new IllegalArgumentException("Invalid deletion vector offset: " + offset); + } + if (size == null || size < 20 || size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Invalid deletion vector size: " + size); + } + + byte[] bytes = new byte[size.intValue()]; + try (SeekableInputStream stream = inputFile.open()) { + stream.seek(offset); + IOUtil.readFully(stream, bytes, 0, bytes.length); + } + + return new IcebergDeletionVector( + bytes, 8, bytes.length - 12, cardinality.applyAsLong(bytes)); + } +} diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java index d6172e32ddb..873ecb6ce38 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java @@ -20,6 +20,7 @@ import com.nvidia.spark.rapids.NoopMetric$; import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.fileio.iceberg.IcebergInputFile; +import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.hadoop.fs.Path; import org.apache.iceberg.ContentFile; import org.apache.iceberg.DeleteFile; @@ -27,7 +28,6 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.Table; import org.apache.iceberg.io.FileIO; -import org.apache.iceberg.io.InputFile; import org.apache.iceberg.parquet.GpuParquetIO; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; @@ -61,15 +61,6 @@ public interface IcebergShimUtils { /** Returns whether a positional delete is an Iceberg Puffin deletion vector. */ boolean isDeletionVector(DeleteFile deleteFile); - /** Returns the data-file location referenced by a deletion vector. */ - String referencedDataFile(DeleteFile deleteFile); - - /** Returns the byte offset of the deletion-vector blob. */ - Long contentOffset(DeleteFile deleteFile); - - /** Returns the byte length of the deletion-vector blob. */ - Long contentSizeInBytes(DeleteFile deleteFile); - /** * Reads exactly the recorded deletion-vector byte range and returns its compressed bitmap. * @@ -77,7 +68,7 @@ public interface IcebergShimUtils { * deletion-vector manifest fields, and its {@code PositionDeleteIndex} lacks the decode and * iteration APIs available in later releases. */ - IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, RapidsInputFile inputFile) throws IOException; /** diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java index 649d846a229..c24043a49fd 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java @@ -20,6 +20,7 @@ import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.ShimLoader; import com.nvidia.spark.rapids.fileio.iceberg.IcebergInputFile; +import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.hadoop.fs.Path; import org.apache.iceberg.ContentFile; @@ -28,7 +29,6 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.Table; import org.apache.iceberg.io.FileIO; -import org.apache.iceberg.io.InputFile; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.source.GpuSparkScan; @@ -68,20 +68,8 @@ public static boolean isDeletionVector(DeleteFile deleteFile) { return IMPL.isDeletionVector(deleteFile); } - public static String referencedDataFile(DeleteFile deleteFile) { - return IMPL.referencedDataFile(deleteFile); - } - - public static Long contentOffset(DeleteFile deleteFile) { - return IMPL.contentOffset(deleteFile); - } - - public static Long contentSizeInBytes(DeleteFile deleteFile) { - return IMPL.contentSizeInBytes(deleteFile); - } - public static IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, - InputFile inputFile) + RapidsInputFile inputFile) throws IOException { return IMPL.readDeletionVector(deleteFile, inputFile); } diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala index bc1b1909718..47c810a4b2e 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteFilter.scala @@ -39,13 +39,21 @@ import org.apache.spark.sql.rapids.execution.HashedExistenceJoinIterator import org.apache.spark.sql.types.{BooleanType, DataType} import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector} -object GpuDeleteFilter { - case class DeleteFileInfo( - deletionVector: Option[DeleteFile], - postReadDeletes: Seq[DeleteFile]) +/** Delete files split by the phase that applies them. */ +case class GpuDeleteFileInfo( + deletionVector: Option[DeleteFile], + postReadDeletes: Seq[DeleteFile]) - /** Gives a deletion vector precedence over legacy position-delete files. */ - def splitDeleteFiles(deletes: Seq[DeleteFile]): DeleteFileInfo = { +object GpuDeleteFileInfo { + /** + * Iceberg scan planning produces one of two valid combinations for a data file: + * - no deletion vector, with optional position and equality deletes; or + * - one deletion vector, no position deletes, and optional equality deletes. + * + * The precedence below also avoids applying legacy position deletes twice if a task ever + * contains both representations. + */ + def apply(deletes: Seq[DeleteFile]): GpuDeleteFileInfo = { val equalityDeletes = new ArrayBuffer[DeleteFile] val positionDeletes = new ArrayBuffer[DeleteFile] var deletionVector: Option[DeleteFile] = None @@ -62,7 +70,7 @@ object GpuDeleteFilter { } val effectivePositionDeletes = if (deletionVector.isDefined) Seq.empty else positionDeletes - DeleteFileInfo(deletionVector, equalityDeletes.toSeq ++ effectivePositionDeletes) + new GpuDeleteFileInfo(deletionVector, equalityDeletes.toSeq ++ effectivePositionDeletes) } } diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteLoader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteLoader.scala index ee9718b640d..7ab7c101a0e 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteLoader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/data/GpuDeleteLoader.scala @@ -45,33 +45,20 @@ class DefaultDeleteLoader( private val inputFiles: Map[String, IcebergInputFile], private val parquetConf: GpuIcebergParquetReaderConf) extends GpuDeleteLoader { - def loadDeletionVector(delete: DeleteFile, - dataFilePath: String): IcebergDeletionVector = { + def loadDeletionVector(delete: DeleteFile): IcebergDeletionVector = { require(ShimUtils.isDeletionVector(delete), s"Expected a Puffin deletion vector, found ${delete.format()}") - val referencedDataFile = ShimUtils.referencedDataFile(delete) - require(referencedDataFile != null, - s"Deletion vector ${locationOf(delete)} has no referenced data file") - require(dataFilePath == referencedDataFile, - s"Deletion vector ${locationOf(delete)} references $referencedDataFile, not $dataFilePath") - - val contentOffset = ShimUtils.contentOffset(delete) - val contentSize = ShimUtils.contentSizeInBytes(delete) - require(contentOffset != null && contentOffset >= 0, - s"Deletion vector ${locationOf(delete)} has invalid offset $contentOffset") - require(contentSize != null && contentSize >= 0 && contentSize <= Int.MaxValue, - s"Deletion vector ${locationOf(delete)} has invalid size $contentSize") - val inputFile = inputFiles.getOrElse(locationOf(delete), throw new IllegalArgumentException( s"No decrypted input file was provided for deletion vector ${locationOf(delete)}")) val decodeTime = parquetConf.metrics.getOrElse(ICEBERG_DV_DECODE_TIME, NoopMetric) val deletionVector = decodeTime.ns { - ShimUtils.readDeletionVector(delete, inputFile.getDelegate) + ShimUtils.readDeletionVector(delete, inputFile) } - parquetConf.metrics.getOrElse(ICEBERG_DV_BYTES, NoopMetric) += contentSize.longValue() + parquetConf.metrics.getOrElse(ICEBERG_DV_BYTES, NoopMetric) += + deletionVector.serializedSizeInBytes() parquetConf.metrics.getOrElse(ICEBERG_DV_POSITIONS, NoopMetric) += deletionVector.cardinality() deletionVector diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuIcebergDeletionVector.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuIcebergDeletionVector.scala index 8976eb57239..277fa96ad32 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuIcebergDeletionVector.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuIcebergDeletionVector.scala @@ -119,6 +119,7 @@ object GpuIcebergDeletionVector extends Logging { deletionVector: IcebergDeletionVector, blocks: collection.Seq[BlockMetaData]): DeletionVector.DeletionVectorInfo = { val bitmap = deletionVector.serializedBitmap() + bitmap.incRefCount() closeOnExcept(bitmap) { _ => val (offsets, rowCounts) = rowGroupMetadata(blocks) new DeletionVector.DeletionVectorInfo(bitmap, false, offsets, rowCounts) diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala index 344f2bf88d2..da239ba5f60 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala @@ -667,33 +667,43 @@ class GpuParquetReaderPostProcessor( // Convert shaded parquet schema to Iceberg schema for comparison private lazy val fileIcebergSchema: Schema = ParquetSchemaUtil.convert(shadedFileReadSchema) - // Build field ID to batch index mapping using the UNSHADED schema from parquetInfo. - // The parquet reader returns top-level columns in the physical file-read order captured by - // parquetInfo.schema, which can differ from the requested Iceberg schema order. - // Map field ID to that batch position. - private lazy val fieldIdToBatchIndex: Map[Int, Int] = { - (0 until fileReadSchema.getFieldCount).flatMap { i => - val batchIndex = if (hasNativeRowIndex) i + 1 else i - Option(fileReadSchema.getType(i).getId).map(id => id.intValue() -> batchIndex) - }.toMap - } - // Pre-compute action tree by visiting expected schema with file schema as partner private lazy val rootAction: ColumnAction = buildActionTimeMetric.ns { val visitor = new ActionBuildingVisitor(idToConstant) val accessors = new FileSchemaAccessors() - SchemaWithPartnerVisitor.visit( + val action = SchemaWithPartnerVisitor.visit( expectedSchema.asStruct(), fileIcebergSchema.asStruct(), visitor, accessors) + + if (hasNativeRowIndex) { + action match { + case PassThrough => + // The native row index is input[0]. Select only the requested file columns, which + // makes dropping an unrequested row index part of the normal root action tree. + ProcessStruct( + Seq.fill(expectedFields.size)(PassThrough), + expectedFields.indices.map(index => Some(index + 1))) + case ProcessStruct(actions, inputIndices) => + val shiftedInputIndices = expectedFields.zip(inputIndices).map { + case (field, _) if field.fieldId() == MetadataColumns.ROW_POSITION.fieldId() => + Some(0) + case (_, inputIndex) => inputIndex.map(_ + 1) + }.toSeq + ProcessStruct(actions, shiftedInputIndices) + case other => other + } + } else { + action + } } private lazy val expectedFields = expectedSchema.asStruct().fields().asScala private lazy val expectedSparkTypes = expectedFields.map(f => SparkSchemaUtil.convert(f.`type`())) // Check if we can pass through the entire batch without any processing. - private lazy val canPassThroughBatch: Boolean = rootAction == PassThrough && !hasNativeRowIndex + private lazy val canPassThroughBatch: Boolean = rootAction == PassThrough // Only constants that synthesize projected fields need to participate in combining checks. // If a projected field is still read from the parquet file, differing constant-map values for @@ -779,14 +789,10 @@ class GpuParquetReaderPostProcessor( withResource(scb.getColumnarBatch()) { batch => currentNumRows = batch.numRows() - val fields = expectedFields - // Execute actions on batch (rootAction must be ProcessStruct here since // PassThrough is handled by canPassThroughBatch early return) - val fieldActions = rootAction match { - case ProcessStruct(actions, _) => actions - case PassThrough if hasNativeRowIndex => - Seq.fill(expectedFields.size)(PassThrough) + val (fieldActions, inputIndices) = rootAction match { + case ProcessStruct(actions, indices) => (actions, indices) case _ => throw new IllegalStateException( s"Root action must be ProcessStruct, but got: ${rootAction.getClass.getSimpleName}") } @@ -794,15 +800,11 @@ class GpuParquetReaderPostProcessor( // Root-level columns are not wrapped in a single struct column, so we cannot execute // ProcessStruct directly here. Instead we run each field action against the matching // batch column (or None for generated fields) and assemble the output batch ourselves. - val columns: Seq[ColumnVector] = fieldActions.zip(fields).zipWithIndex.safeMap { - case ((action, field), idx) => - val batchIdx = if (hasNativeRowIndex && - field.fieldId() == MetadataColumns.ROW_POSITION.fieldId()) { - Some(0) - } else { - fieldIdToBatchIndex.get(field.fieldId()) - } - val col = batchIdx.map(i => batch.column(i).asInstanceOf[GpuColumnVector].getBase) + val columns: Seq[ColumnVector] = + fieldActions.zip(inputIndices).zipWithIndex.safeMap { + case ((action, inputIndex), idx) => + val col = inputIndex.map(i => + batch.column(i).asInstanceOf[GpuColumnVector].getBase) val ctx = new ColumnActionContext(this, col, currentNumRows) val result = action.execute(ctx) closeOnExcept(result) { _ => diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala index 1d1acd92b30..07c0bdc622e 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala @@ -211,6 +211,9 @@ trait GpuIcebergParquetReader extends Iterator[ColumnarBatch] with AutoCloseable requiredSchema.findField(MetadataColumns.ROW_POSITION.fieldId()) != null val initialProjection = projectSchema(fileSchema, requiredSchema) val (typeWithIds, fileReadSchema) = + // cuDF's deletion-vector Parquet path needs at least one physical data column. Force one + // into metadata-only and count projections so native row-index filtering can determine + // the surviving row count; the post-processor drops it from the requested output. if (hasDeletionVector && initialProjection._2.getFieldCount == 0 && fileSchema.getFieldCount > 0) { val firstFileField = ParquetSchemaUtil.convert(fileSchema).columns().get(0) diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala index 304763d8d22..1ebd71ea6ee 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala @@ -23,7 +23,8 @@ import com.nvidia.spark.rapids.MapUtil.toMapStrict import com.nvidia.spark.rapids.fileio.iceberg.IcebergFileIO import com.nvidia.spark.rapids.iceberg.ShimUtils import com.nvidia.spark.rapids.iceberg.ShimUtils.locationOf -import com.nvidia.spark.rapids.iceberg.data.{DefaultDeleteLoader, GpuDeleteFilter} +import com.nvidia.spark.rapids.iceberg.data.{DefaultDeleteLoader, GpuDeleteFileInfo, + GpuDeleteFilter} import com.nvidia.spark.rapids.iceberg.parquet._ import org.apache.iceberg._ import org.apache.iceberg.encryption.EncryptedFiles @@ -47,12 +48,12 @@ class GpuIcebergPartitionReader(private val task: GpuSparkInputPartition, private lazy val (inputFiles, tasks) = collectFiles() private lazy val deleteInfoMap = tasks.map { case (file, scanTask) => - file -> GpuDeleteFilter.splitDeleteFiles(scanTask.deletes().asScala.toSeq) + file -> GpuDeleteFileInfo(scanTask.deletes().asScala.toSeq) } private lazy val deleteLoader = new DefaultDeleteLoader(rapidsFileIO, inputFiles, conf) private def deletionVectorProvider(file: IcebergPartitionedFile) = { deleteInfoMap(file).deletionVector.map { delete => - deleteLoader.loadDeletionVector(delete, ShimUtils.locationOf(tasks(file).file())) + deleteLoader.loadDeletionVector(delete) } } private lazy val gpuDeleteFiterMap: Map[IcebergPartitionedFile, Option[GpuDeleteFilter]] = diff --git a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java index b063fac695d..fb86e9ee5c8 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java @@ -20,13 +20,13 @@ import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.fileio.iceberg.IcebergInputFile; import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; +import com.nvidia.spark.rapids.iceberg.IcebergDeletionVectorReader; import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; +import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.hadoop.fs.Path; import org.apache.iceberg.*; import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.io.FileIO; -import org.apache.iceberg.io.IOUtil; -import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; @@ -61,40 +61,12 @@ public boolean isDeletionVector(DeleteFile deleteFile) { } @Override - public String referencedDataFile(DeleteFile deleteFile) { - return deleteFile.referencedDataFile(); - } - - @Override - public Long contentOffset(DeleteFile deleteFile) { - return deleteFile.contentOffset(); - } - - @Override - public Long contentSizeInBytes(DeleteFile deleteFile) { - return deleteFile.contentSizeInBytes(); - } - - @Override - public IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + public IcebergDeletionVector readDeletionVector( + DeleteFile deleteFile, RapidsInputFile inputFile) throws IOException { - Long offset = deleteFile.contentOffset(); - Long size = deleteFile.contentSizeInBytes(); - if (offset == null || offset < 0) { - throw new IllegalArgumentException("Invalid deletion vector offset: " + offset); - } - if (size == null || size < 20 || size > Integer.MAX_VALUE) { - throw new IllegalArgumentException("Invalid deletion vector size: " + size); - } - - byte[] bytes = new byte[size.intValue()]; - try (org.apache.iceberg.io.SeekableInputStream stream = inputFile.newStream()) { - stream.seek(offset); - IOUtil.readFully(stream, bytes, 0, bytes.length); - } - - PositionDeleteIndex index = PositionDeleteIndex.deserialize(bytes, deleteFile); - return new IcebergDeletionVector(bytes, 8, bytes.length - 12, index.cardinality()); + return IcebergDeletionVectorReader.read( + inputFile, deleteFile.contentOffset(), deleteFile.contentSizeInBytes(), + bytes -> PositionDeleteIndex.deserialize(bytes, deleteFile).cardinality()); } @Override diff --git a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java index f8a7ab507fd..21ed91a41d2 100644 --- a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java @@ -20,13 +20,13 @@ import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.fileio.iceberg.IcebergInputFile; import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; +import com.nvidia.spark.rapids.iceberg.IcebergDeletionVectorReader; import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; +import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.hadoop.fs.Path; import org.apache.iceberg.*; import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.io.FileIO; -import org.apache.iceberg.io.IOUtil; -import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; @@ -61,40 +61,12 @@ public boolean isDeletionVector(DeleteFile deleteFile) { } @Override - public String referencedDataFile(DeleteFile deleteFile) { - return deleteFile.referencedDataFile(); - } - - @Override - public Long contentOffset(DeleteFile deleteFile) { - return deleteFile.contentOffset(); - } - - @Override - public Long contentSizeInBytes(DeleteFile deleteFile) { - return deleteFile.contentSizeInBytes(); - } - - @Override - public IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + public IcebergDeletionVector readDeletionVector( + DeleteFile deleteFile, RapidsInputFile inputFile) throws IOException { - Long offset = deleteFile.contentOffset(); - Long size = deleteFile.contentSizeInBytes(); - if (offset == null || offset < 0) { - throw new IllegalArgumentException("Invalid deletion vector offset: " + offset); - } - if (size == null || size < 20 || size > Integer.MAX_VALUE) { - throw new IllegalArgumentException("Invalid deletion vector size: " + size); - } - - byte[] bytes = new byte[size.intValue()]; - try (org.apache.iceberg.io.SeekableInputStream stream = inputFile.newStream()) { - stream.seek(offset); - IOUtil.readFully(stream, bytes, 0, bytes.length); - } - - PositionDeleteIndex index = PositionDeleteIndex.deserialize(bytes, deleteFile); - return new IcebergDeletionVector(bytes, 8, bytes.length - 12, index.cardinality()); + return IcebergDeletionVectorReader.read( + inputFile, deleteFile.contentOffset(), deleteFile.contentSizeInBytes(), + bytes -> PositionDeleteIndex.deserialize(bytes, deleteFile).cardinality()); } @Override diff --git a/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java b/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java index 78d3f4ec711..b777558ca61 100644 --- a/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java @@ -19,9 +19,9 @@ import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; +import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.iceberg.*; import org.apache.iceberg.io.FileIO; -import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.spark.source.GpuBaseReader; import org.apache.iceberg.spark.source.GpuSparkCopyOnWriteV1Scan; @@ -68,22 +68,8 @@ public boolean isDeletionVector(DeleteFile deleteFile) { } @Override - public String referencedDataFile(DeleteFile deleteFile) { - return null; - } - - @Override - public Long contentOffset(DeleteFile deleteFile) { - return null; - } - - @Override - public Long contentSizeInBytes(DeleteFile deleteFile) { - return null; - } - - @Override - public IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + public IcebergDeletionVector readDeletionVector( + DeleteFile deleteFile, RapidsInputFile inputFile) throws IOException { throw new UnsupportedOperationException( "Iceberg 1.6 does not support Puffin deletion vectors"); diff --git a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java index 8a8f5c1f35b..52f9b401806 100644 --- a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java @@ -18,12 +18,12 @@ import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; +import com.nvidia.spark.rapids.iceberg.IcebergDeletionVectorReader; import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; +import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.iceberg.*; import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.io.FileIO; -import org.apache.iceberg.io.IOUtil; -import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; import org.apache.iceberg.spark.SparkUtil; @@ -56,40 +56,12 @@ public boolean isDeletionVector(DeleteFile deleteFile) { } @Override - public String referencedDataFile(DeleteFile deleteFile) { - return deleteFile.referencedDataFile(); - } - - @Override - public Long contentOffset(DeleteFile deleteFile) { - return deleteFile.contentOffset(); - } - - @Override - public Long contentSizeInBytes(DeleteFile deleteFile) { - return deleteFile.contentSizeInBytes(); - } - - @Override - public IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, InputFile inputFile) + public IcebergDeletionVector readDeletionVector( + DeleteFile deleteFile, RapidsInputFile inputFile) throws IOException { - Long offset = deleteFile.contentOffset(); - Long size = deleteFile.contentSizeInBytes(); - if (offset == null || offset < 0) { - throw new IllegalArgumentException("Invalid deletion vector offset: " + offset); - } - if (size == null || size < 20 || size > Integer.MAX_VALUE) { - throw new IllegalArgumentException("Invalid deletion vector size: " + size); - } - - byte[] bytes = new byte[size.intValue()]; - try (org.apache.iceberg.io.SeekableInputStream stream = inputFile.newStream()) { - stream.seek(offset); - IOUtil.readFully(stream, bytes, 0, bytes.length); - } - - PositionDeleteIndex index = PositionDeleteIndex.deserialize(bytes, deleteFile); - return new IcebergDeletionVector(bytes, 8, bytes.length - 12, index.cardinality()); + return IcebergDeletionVectorReader.read( + inputFile, deleteFile.contentOffset(), deleteFile.contentSizeInBytes(), + bytes -> PositionDeleteIndex.deserialize(bytes, deleteFile).cardinality()); } @Override diff --git a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py index 77f84b2d6b4..4e7c4391ea4 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py @@ -125,14 +125,12 @@ def test_iceberg_v2_position_delete_with_url_encoded_path(spark_tmp_table_factor @ignore_order(local=True) @pytest.mark.parametrize('reader_type', rapids_reader_types) @pytest.mark.skipif(is_iceberg_remote_catalog(), reason = "S3tables catalog is managed") -@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) @pytest.mark.xfail(reason = "https://github.com/NVIDIA/spark-rapids/issues/12885") # When using this datagen, local run is 784 rows @pytest.mark.datagen_overrides(seed=1749483297, permanent=True, reason="Debug https://github.com/NVIDIA/spark-rapids/issues/12885") -@validate_execs_in_gpu_plan('GpuBatchScanExec') -def test_iceberg_mixed_deletes(spark_tmp_table_factory, spark_tmp_path, reader_type, - register_iceberg_add_eq_deletes_udf): +def test_iceberg_v2_mixed_deletes(spark_tmp_table_factory, spark_tmp_path, reader_type, + register_iceberg_add_eq_deletes_udf): # We use a fixed seed here to ensure that data deletion vector has been generated table_name = setup_base_iceberg_table(spark_tmp_table_factory) # Position deletes @@ -158,8 +156,49 @@ def test_iceberg_mixed_deletes(spark_tmp_table_factory, spark_tmp_path, reader_t spark_tmp_path), "No equation deletes generated") - # Upgrade only after creating v2 position deletes, then create a v3 deletion vector. This - # leaves equality deletes, legacy position deletes, and deletion vectors in the same table. + # Trigger a count operation to verify that it works + gpu_count = with_gpu_session(lambda spark: spark.table(table_name).count(), + conf={'spark.rapids.sql.format.parquet.reader.type': reader_type}) + cpu_count = with_cpu_session(lambda spark: spark.table(table_name).count(), + conf={'spark.rapids.sql.format.parquet.reader.type': reader_type}) + assert gpu_count == cpu_count, f"Result count diverges, cpu: {cpu_count}, gpu: {gpu_count}" + logging.info(f"Count is {cpu_count}") + + assert_gpu_and_cpu_are_equal_collect( + lambda spark: spark.table(table_name), + conf={'spark.rapids.sql.format.parquet.reader.type': reader_type}) + + +@iceberg +@ignore_order(local=True) +@pytest.mark.parametrize('reader_type', rapids_reader_types) +@pytest.mark.skipif(is_iceberg_remote_catalog(), reason = "S3tables catalog is managed") +@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) +@pytest.mark.xfail(reason = "https://github.com/NVIDIA/spark-rapids/issues/12885") +# When using this datagen, local run is 784 rows +@pytest.mark.datagen_overrides(seed=1749483297, permanent=True, + reason="Debug https://github.com/NVIDIA/spark-rapids/issues/12885") +@validate_execs_in_gpu_plan('GpuBatchScanExec') +def test_iceberg_v3_mixed_deletes(spark_tmp_table_factory, spark_tmp_path, reader_type, + register_iceberg_add_eq_deletes_udf): + table_name = setup_base_iceberg_table(spark_tmp_table_factory) + _change_table(table_name, + lambda spark: spark.sql(f"DELETE FROM {table_name} where _c1 < 0"), + "No position deletes generated") + _change_table(table_name, + lambda spark: _add_eq_deletes(spark, ["_c0"], 170, table_name, spark_tmp_path), + "No equation deletes generated") + _change_table(table_name, + lambda spark: _add_eq_deletes(spark, ["_c2", "_c3", "_c6"], 140, table_name, + spark_tmp_path), + "No equation deletes generated") + _change_table(table_name, + lambda spark: _add_eq_deletes(spark, ["_c1", "_c2"], 110, table_name, + spark_tmp_path), + "No equation deletes generated") + + # Upgrade after creating v2 position deletes, then create a v3 deletion vector. This leaves + # equality deletes, legacy position deletes, and deletion vectors in the same table. def add_deletion_vector(spark): spark.sql( f"ALTER TABLE {table_name} SET TBLPROPERTIES ('format-version' = '3')") @@ -185,7 +224,6 @@ def add_deletion_vector(spark): 'spark.rapids.sql.format.parquet.reader.type': reader_type, } - # Trigger a count operation to verify that it works gpu_count = with_gpu_session(lambda spark: spark.table(table_name).count(), conf=read_conf) cpu_count = with_cpu_session(lambda spark: spark.table(table_name).count(), diff --git a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala index 37655ba3f88..c0e6148d5ed 100644 --- a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala +++ b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala @@ -1051,6 +1051,50 @@ class GpuPostProcessorSuite extends AnyFunSuite with BeforeAndAfterAll { } } + test("native deletion-vector row index is dropped through the root action tree") { + import ai.rapids.cudf.{ColumnVector => CudfColumnVector} + import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource} + import com.nvidia.spark.rapids.GpuColumnVector + import org.apache.spark.sql.types.LongType + import org.apache.spark.sql.vectorized.ColumnarBatch + + val dataFieldId = 1 + val dataField = ShadedTypes + .primitive(ShadedPrimitiveTypeName.INT64, ShadedRepetition.OPTIONAL) + .id(dataFieldId) + .named("data") + val parquetSchema = new ShadedMessageType("test", Seq[ShadedType](dataField).asJava) + val expectedSchema = new Schema( + Types.NestedField.optional(dataFieldId, "data", Types.LongType.get())) + val (parquetInfo, shadedSchema) = createParquetInfo(parquetSchema, rowCount = 6) + val processor = new GpuParquetReaderPostProcessor( + parquetInfo, + new JHashMap[Integer, Any](), + expectedSchema, + shadedSchema, + Map.empty, + hasNativeRowIndex = true) + + assert(processor.displayActionPlan() == + "ProcessStruct\n data (input[1]):\n PassThrough") + + val rowPositions = closeOnExcept(CudfColumnVector.fromLongs(0L, 2L, 5L)) { column => + GpuColumnVector.from(column, LongType) + } + val data = closeOnExcept(CudfColumnVector.fromLongs(10L, 12L, 15L)) { column => + GpuColumnVector.from(column, LongType) + } + val inputBatch = new ColumnarBatch(Array(rowPositions, data), 3) + + withResource(processor.process(inputBatch)) { outputBatch => + assert(outputBatch.numRows() == 3) + assert(outputBatch.numCols() == 1) + withResource(outputBatch.column(0).asInstanceOf[GpuColumnVector].copyToHost()) { host => + assert((0 until 3).map(i => host.getBase.getLong(i)) == Seq(10L, 12L, 15L)) + } + } + } + test("Constant struct with required children does not throw") { val structFieldId = 1 val fieldAId = 2 diff --git a/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala b/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala deleted file mode 100644 index f44e70eaff7..00000000000 --- a/tests/src/test/spark354/scala/com/nvidia/spark/rapids/iceberg/DeletionVectorReaderSuite.scala +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright (c) 2026, NVIDIA CORPORATION. - * - * Licensed 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. - */ - -/*** spark-rapids-shim-json-lines -{"spark": "354"} -{"spark": "355"} -{"spark": "356"} -{"spark": "357"} -{"spark": "358"} -{"spark": "359"} -spark-rapids-shim-json-lines ***/ -package com.nvidia.spark.rapids.iceberg - -import java.nio.ByteBuffer -import java.nio.file.Files - -import scala.collection.JavaConverters._ - -import com.nvidia.spark.rapids.Arm.withResource -import org.apache.hadoop.conf.Configuration -import org.apache.hadoop.fs.{Path => HadoopPath} -import org.apache.iceberg.{DeleteFile, FileFormat, FileMetadata, PartitionSpec} -import org.apache.iceberg.deletes.Deletes -import org.apache.iceberg.hadoop.HadoopInputFile -import org.apache.iceberg.io.CloseableIterable -import org.scalatest.funsuite.AnyFunSuite - -class DeletionVectorReaderSuite extends AnyFunSuite { - test("read only the deletion vector manifest byte range") { - val expectedPositions = Seq(0L, 17L, (1L << 40) + 3L) - val prefixBlob = serializePositions(Seq(4L, 8L)) - val targetBlob = serializePositions(expectedPositions) - val suffixBlob = serializePositions(Seq(99L)) - val fileBytes = prefixBlob ++ targetBlob ++ suffixBlob - val path = Files.createTempFile("iceberg-dv-range", ".puffin") - - try { - Files.write(path, fileBytes) - val deleteFile = deletionVectorFile(path.toUri.toString, - referencedDataFile = "/tmp/data.parquet", - offset = prefixBlob.length, - size = targetBlob.length, - cardinality = expectedPositions.size, - fileSize = fileBytes.length) - val inputFile = HadoopInputFile.fromPath(new HadoopPath(path.toUri), new Configuration()) - - withResource(ShimUtils.readDeletionVector(deleteFile, inputFile)) { deletionVector => - assert(serializedBitmap(deletionVector).sameElements(portableBitmap(targetBlob))) - assert(deletionVector.cardinality() == expectedPositions.size) - } - } finally { - Files.deleteIfExists(path) - } - } - - test("read an empty deletion vector") { - val blob = serializePositions(Seq.empty) - val path = Files.createTempFile("iceberg-empty-dv", ".puffin") - - try { - Files.write(path, blob) - val deleteFile = deletionVectorFile(path.toUri.toString, - referencedDataFile = "/tmp/data.parquet", - offset = 0, - size = blob.length, - cardinality = 0, - fileSize = blob.length) - val inputFile = HadoopInputFile.fromPath(new HadoopPath(path.toUri), new Configuration()) - - withResource(ShimUtils.readDeletionVector(deleteFile, inputFile)) { deletionVector => - val bitmap = serializedBitmap(deletionVector) - assert(bitmap.sameElements(portableBitmap(blob))) - assert(bitmap.length == 8) - assert(deletionVector.cardinality() == 0) - } - } finally { - Files.deleteIfExists(path) - } - } - - private def serializePositions(positions: Seq[Long]): Array[Byte] = { - val boxedPositions = positions.map(Long.box).asJava - val index = Deletes.toPositionIndex(CloseableIterable.withNoopClose(boxedPositions)) - copyBytes(index.serialize()) - } - - private def copyBytes(buffer: ByteBuffer): Array[Byte] = { - val copy = buffer.duplicate() - val bytes = new Array[Byte](copy.remaining()) - copy.get(bytes) - bytes - } - - private def portableBitmap(serializedIndex: Array[Byte]): Array[Byte] = { - serializedIndex.slice(8, serializedIndex.length - 4) - } - - private def serializedBitmap(deletionVector: IcebergDeletionVector): Array[Byte] = { - withResource(deletionVector.serializedBitmap()) { bitmap => - val bytes = new Array[Byte](bitmap.getLength.toInt) - bitmap.getBytes(bytes, 0, 0, bytes.length) - bytes - } - } - - private def deletionVectorFile( - location: String, - referencedDataFile: String, - offset: Long, - size: Long, - cardinality: Long, - fileSize: Long): DeleteFile = { - FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) - .ofPositionDeletes() - .withPath(location) - .withFormat(FileFormat.PUFFIN) - .withReferencedDataFile(referencedDataFile) - .withContentOffset(offset) - .withContentSizeInBytes(size) - .withRecordCount(cardinality) - .withFileSizeInBytes(fileSize) - .build() - } -} From 08d297c003a1224759c6d40a78e16831758cc407 Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Tue, 18 Aug 2026 16:40:13 +0800 Subject: [PATCH 05/18] Update Iceberg reader copyright year Signed-off-by: Ray Liu --- .../iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala index 4f01349f001..3fa7e0c6117 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, NVIDIA CORPORATION. + * Copyright (c) 2025-2026, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From 5640f1ff46696b5f6d564540e99ae658eaf52dce Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Wed, 19 Aug 2026 11:38:33 +0800 Subject: [PATCH 06/18] Address Iceberg deletion vector review feedback Signed-off-by: Ray Liu --- .../rapids/iceberg/IcebergDeletionVector.java | 29 ++++++++++ .../iceberg/IcebergDeletionVectorReader.java | 51 ----------------- .../GpuParquetReaderPostProcessor.scala | 56 +++++++++---------- .../iceberg/iceberg110x/ShimUtilsImpl.java | 3 +- .../iceberg/iceberg111x/ShimUtilsImpl.java | 3 +- .../iceberg/iceberg19x/ShimUtilsImpl.java | 3 +- 6 files changed, 57 insertions(+), 88 deletions(-) delete mode 100644 iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVectorReader.java diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java index 25edf9836ce..7dbe1fdd3eb 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java @@ -17,6 +17,12 @@ package com.nvidia.spark.rapids.iceberg; import ai.rapids.cudf.HostMemoryBuffer; +import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; +import com.nvidia.spark.rapids.jni.fileio.SeekableInputStream; +import org.apache.iceberg.io.IOUtil; + +import java.io.IOException; +import java.util.function.ToLongFunction; /** * A validated Iceberg deletion vector kept in its compressed Roaring-bitmap representation. @@ -46,6 +52,29 @@ public IcebergDeletionVector( this.cardinality = cardinality; } + /** Reads and validates an Iceberg deletion-vector byte range. */ + public static IcebergDeletionVector read( + RapidsInputFile inputFile, + Long offset, + Long size, + ToLongFunction cardinality) throws IOException { + if (offset == null || offset < 0) { + throw new IllegalArgumentException("Invalid deletion vector offset: " + offset); + } + if (size == null || size < 20 || size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Invalid deletion vector size: " + size); + } + + byte[] bytes = new byte[size.intValue()]; + try (SeekableInputStream stream = inputFile.open()) { + stream.seek(offset); + IOUtil.readFully(stream, bytes, 0, bytes.length); + } + + return new IcebergDeletionVector( + bytes, 8, bytes.length - 12, cardinality.applyAsLong(bytes)); + } + /** * Returns the portable serialized 64-bit Roaring bitmap expected by cuDF. * diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVectorReader.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVectorReader.java deleted file mode 100644 index 89a8795fe4b..00000000000 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVectorReader.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2026, NVIDIA CORPORATION. - * - * Licensed 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 com.nvidia.spark.rapids.iceberg; - -import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; -import com.nvidia.spark.rapids.jni.fileio.SeekableInputStream; -import org.apache.iceberg.io.IOUtil; - -import java.io.IOException; -import java.util.function.ToLongFunction; - -/** Shared deletion-vector byte-range reader for Iceberg versions that support v3. */ -public final class IcebergDeletionVectorReader { - private IcebergDeletionVectorReader() {} - - public static IcebergDeletionVector read( - RapidsInputFile inputFile, - Long offset, - Long size, - ToLongFunction cardinality) throws IOException { - if (offset == null || offset < 0) { - throw new IllegalArgumentException("Invalid deletion vector offset: " + offset); - } - if (size == null || size < 20 || size > Integer.MAX_VALUE) { - throw new IllegalArgumentException("Invalid deletion vector size: " + size); - } - - byte[] bytes = new byte[size.intValue()]; - try (SeekableInputStream stream = inputFile.open()) { - stream.seek(offset); - IOUtil.readFully(stream, bytes, 0, bytes.length); - } - - return new IcebergDeletionVector( - bytes, 8, bytes.length - 12, cardinality.applyAsLong(bytes)); - } -} diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala index da239ba5f60..796ec83a26d 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala @@ -36,7 +36,11 @@ import com.nvidia.spark.rapids.parquet.ParquetFileInfoWithBlockMeta import org.apache.iceberg.{MetadataColumns, Schema} import org.apache.iceberg.parquet.ParquetSchemaUtil import org.apache.iceberg.schema.SchemaWithPartnerVisitor -import org.apache.iceberg.shaded.org.apache.parquet.schema.{MessageType => ShadedMessageType} +import org.apache.iceberg.shaded.org.apache.parquet.schema.{ + MessageType => ShadedMessageType, Types => ShadedTypes} +import org.apache.iceberg.shaded.org.apache.parquet.schema.PrimitiveType.{ + PrimitiveTypeName => ShadedPrimitiveTypeName} +import org.apache.iceberg.shaded.org.apache.parquet.schema.Type.{Repetition => ShadedRepetition} import org.apache.iceberg.spark.SparkSchemaUtil import org.apache.iceberg.types.{Type, Types} @@ -162,12 +166,6 @@ private[iceberg] case object FetchFilePath extends ColumnAction { /** Fetch ROW_POSITION metadata column. */ private[iceberg] case object FetchRowPosition extends ColumnAction { override def execute(ctx: ColumnActionContext): CudfColumnVector = { - if (ctx.processor.hasNativeRowIndex) { - val rowIndex = ctx.requireColumn("FetchRowPosition") - rowIndex.incRefCount() - return rowIndex - } - val numRows = ctx.numRows val rowPoses = new Array[Long](numRows) val processor = ctx.processor @@ -629,6 +627,8 @@ private class FileSchemaAccessors * @param idToConstant Constant fields. * @param expectedSchema Iceberg schema required by reader. * @param shadedFileReadSchema Shaded parquet file read schema (to avoid conversion overhead). + * @param hasNativeRowIndex True when the cuDF deletion-vector reader prepends its file-global + * row-index column to every output batch. */ class GpuParquetReaderPostProcessor( private[iceberg] val parquetInfo: ParquetFileInfoWithBlockMeta, @@ -636,7 +636,7 @@ class GpuParquetReaderPostProcessor( private[iceberg] val expectedSchema: Schema, shadedFileReadSchema: ShadedMessageType, metrics: Map[String, com.nvidia.spark.rapids.GpuMetric], - private[iceberg] val hasNativeRowIndex: Boolean = false + hasNativeRowIndex: Boolean = false ) { private val icebergBuildActionTimeMetricName = "icebergBuildActionTime" private val icebergPostProcessTimeMetricName = "icebergPostProcessTime" @@ -664,39 +664,33 @@ class GpuParquetReaderPostProcessor( // Top-level batch row count for actions that generate a column without an input column. private[iceberg] var currentNumRows = 0 + // The cuDF deletion-vector reader prepends a file-global row index to its output. Add that + // column to the read schema so the standard field-ID-based action builder can select `_pos` + // when requested and drop it otherwise. + private lazy val actionFileReadSchema = if (hasNativeRowIndex) { + val rowPosition = ShadedTypes + .primitive(ShadedPrimitiveTypeName.INT64, ShadedRepetition.REQUIRED) + .id(MetadataColumns.ROW_POSITION.fieldId()) + .named(MetadataColumns.ROW_POSITION.name()) + new ShadedMessageType( + shadedFileReadSchema.getName, + (rowPosition +: shadedFileReadSchema.getFields.asScala).asJava) + } else { + shadedFileReadSchema + } + // Convert shaded parquet schema to Iceberg schema for comparison - private lazy val fileIcebergSchema: Schema = ParquetSchemaUtil.convert(shadedFileReadSchema) + private lazy val fileIcebergSchema: Schema = ParquetSchemaUtil.convert(actionFileReadSchema) // Pre-compute action tree by visiting expected schema with file schema as partner private lazy val rootAction: ColumnAction = buildActionTimeMetric.ns { val visitor = new ActionBuildingVisitor(idToConstant) val accessors = new FileSchemaAccessors() - val action = SchemaWithPartnerVisitor.visit( + SchemaWithPartnerVisitor.visit( expectedSchema.asStruct(), fileIcebergSchema.asStruct(), visitor, accessors) - - if (hasNativeRowIndex) { - action match { - case PassThrough => - // The native row index is input[0]. Select only the requested file columns, which - // makes dropping an unrequested row index part of the normal root action tree. - ProcessStruct( - Seq.fill(expectedFields.size)(PassThrough), - expectedFields.indices.map(index => Some(index + 1))) - case ProcessStruct(actions, inputIndices) => - val shiftedInputIndices = expectedFields.zip(inputIndices).map { - case (field, _) if field.fieldId() == MetadataColumns.ROW_POSITION.fieldId() => - Some(0) - case (_, inputIndex) => inputIndex.map(_ + 1) - }.toSeq - ProcessStruct(actions, shiftedInputIndices) - case other => other - } - } else { - action - } } private lazy val expectedFields = expectedSchema.asStruct().fields().asScala diff --git a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java index fb86e9ee5c8..aece8245341 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java @@ -20,7 +20,6 @@ import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.fileio.iceberg.IcebergInputFile; import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; -import com.nvidia.spark.rapids.iceberg.IcebergDeletionVectorReader; import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.hadoop.fs.Path; @@ -64,7 +63,7 @@ public boolean isDeletionVector(DeleteFile deleteFile) { public IcebergDeletionVector readDeletionVector( DeleteFile deleteFile, RapidsInputFile inputFile) throws IOException { - return IcebergDeletionVectorReader.read( + return IcebergDeletionVector.read( inputFile, deleteFile.contentOffset(), deleteFile.contentSizeInBytes(), bytes -> PositionDeleteIndex.deserialize(bytes, deleteFile).cardinality()); } diff --git a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java index 21ed91a41d2..d8321236b30 100644 --- a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java @@ -20,7 +20,6 @@ import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.fileio.iceberg.IcebergInputFile; import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; -import com.nvidia.spark.rapids.iceberg.IcebergDeletionVectorReader; import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.hadoop.fs.Path; @@ -64,7 +63,7 @@ public boolean isDeletionVector(DeleteFile deleteFile) { public IcebergDeletionVector readDeletionVector( DeleteFile deleteFile, RapidsInputFile inputFile) throws IOException { - return IcebergDeletionVectorReader.read( + return IcebergDeletionVector.read( inputFile, deleteFile.contentOffset(), deleteFile.contentSizeInBytes(), bytes -> PositionDeleteIndex.deserialize(bytes, deleteFile).cardinality()); } diff --git a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java index 52f9b401806..0728828be4c 100644 --- a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java @@ -18,7 +18,6 @@ import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; -import com.nvidia.spark.rapids.iceberg.IcebergDeletionVectorReader; import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.iceberg.*; @@ -59,7 +58,7 @@ public boolean isDeletionVector(DeleteFile deleteFile) { public IcebergDeletionVector readDeletionVector( DeleteFile deleteFile, RapidsInputFile inputFile) throws IOException { - return IcebergDeletionVectorReader.read( + return IcebergDeletionVector.read( inputFile, deleteFile.contentOffset(), deleteFile.contentSizeInBytes(), bytes -> PositionDeleteIndex.deserialize(bytes, deleteFile).cardinality()); } From 85b21db013475a5ad0eddba9c59da3258182ee8a Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Wed, 19 Aug 2026 15:25:44 +0800 Subject: [PATCH 07/18] Address latest Iceberg review feedback Signed-off-by: Ray Liu --- .../rapids/iceberg/IcebergDeletionVector.java | 9 +++--- .../GpuMultiThreadIcebergParquetReader.scala | 3 +- .../GpuParquetReaderPostProcessor.scala | 28 ++--------------- .../GpuSingleThreadIcebergParquetReader.scala | 3 +- .../spark/rapids/iceberg/parquet/reader.scala | 30 +++++++++++++++++-- .../iceberg/iceberg110x/ShimUtilsImpl.java | 3 +- .../iceberg/iceberg111x/ShimUtilsImpl.java | 3 +- .../iceberg/iceberg19x/ShimUtilsImpl.java | 3 +- .../iceberg/GpuPostProcessorSuite.scala | 10 +++---- 9 files changed, 43 insertions(+), 49 deletions(-) diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java index 7dbe1fdd3eb..9c82bc61fe0 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergDeletionVector.java @@ -22,10 +22,9 @@ import org.apache.iceberg.io.IOUtil; import java.io.IOException; -import java.util.function.ToLongFunction; /** - * A validated Iceberg deletion vector kept in its compressed Roaring-bitmap representation. + * An Iceberg deletion vector kept in its compressed Roaring-bitmap representation. * *

The serialized bytes use the portable 64-bit Roaring format expected by cuDF. This object * owns its host buffer and must be closed after all borrowed references have been released. @@ -52,12 +51,12 @@ public IcebergDeletionVector( this.cardinality = cardinality; } - /** Reads and validates an Iceberg deletion-vector byte range. */ + /** Reads an Iceberg deletion-vector byte range. */ public static IcebergDeletionVector read( RapidsInputFile inputFile, Long offset, Long size, - ToLongFunction cardinality) throws IOException { + long cardinality) throws IOException { if (offset == null || offset < 0) { throw new IllegalArgumentException("Invalid deletion vector offset: " + offset); } @@ -72,7 +71,7 @@ public static IcebergDeletionVector read( } return new IcebergDeletionVector( - bytes, 8, bytes.length - 12, cardinality.applyAsLong(bytes)); + bytes, 8, bytes.length - 12, cardinality); } /** diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuMultiThreadIcebergParquetReader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuMultiThreadIcebergParquetReader.scala index ba9e2bd5961..d5a32923a3d 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuMultiThreadIcebergParquetReader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuMultiThreadIcebergParquetReader.scala @@ -187,8 +187,7 @@ class GpuMultiThreadIcebergParquetReader( constantsProvider(icebergFile), requiredSchema, shadedFileReadSchema, - conf.metrics, - hasNativeRowIndex = deletionVector.isDefined) + conf.metrics) val oldProcessor = postProcessors.put(icebergFile, postProcessor) require(oldProcessor == null, diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala index 796ec83a26d..1b0069bb3eb 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuParquetReaderPostProcessor.scala @@ -36,11 +36,7 @@ import com.nvidia.spark.rapids.parquet.ParquetFileInfoWithBlockMeta import org.apache.iceberg.{MetadataColumns, Schema} import org.apache.iceberg.parquet.ParquetSchemaUtil import org.apache.iceberg.schema.SchemaWithPartnerVisitor -import org.apache.iceberg.shaded.org.apache.parquet.schema.{ - MessageType => ShadedMessageType, Types => ShadedTypes} -import org.apache.iceberg.shaded.org.apache.parquet.schema.PrimitiveType.{ - PrimitiveTypeName => ShadedPrimitiveTypeName} -import org.apache.iceberg.shaded.org.apache.parquet.schema.Type.{Repetition => ShadedRepetition} +import org.apache.iceberg.shaded.org.apache.parquet.schema.{MessageType => ShadedMessageType} import org.apache.iceberg.spark.SparkSchemaUtil import org.apache.iceberg.types.{Type, Types} @@ -627,16 +623,13 @@ private class FileSchemaAccessors * @param idToConstant Constant fields. * @param expectedSchema Iceberg schema required by reader. * @param shadedFileReadSchema Shaded parquet file read schema (to avoid conversion overhead). - * @param hasNativeRowIndex True when the cuDF deletion-vector reader prepends its file-global - * row-index column to every output batch. */ class GpuParquetReaderPostProcessor( private[iceberg] val parquetInfo: ParquetFileInfoWithBlockMeta, private[iceberg] val idToConstant: JMap[Integer, _], private[iceberg] val expectedSchema: Schema, shadedFileReadSchema: ShadedMessageType, - metrics: Map[String, com.nvidia.spark.rapids.GpuMetric], - hasNativeRowIndex: Boolean = false + metrics: Map[String, com.nvidia.spark.rapids.GpuMetric] ) { private val icebergBuildActionTimeMetricName = "icebergBuildActionTime" private val icebergPostProcessTimeMetricName = "icebergPostProcessTime" @@ -664,23 +657,8 @@ class GpuParquetReaderPostProcessor( // Top-level batch row count for actions that generate a column without an input column. private[iceberg] var currentNumRows = 0 - // The cuDF deletion-vector reader prepends a file-global row index to its output. Add that - // column to the read schema so the standard field-ID-based action builder can select `_pos` - // when requested and drop it otherwise. - private lazy val actionFileReadSchema = if (hasNativeRowIndex) { - val rowPosition = ShadedTypes - .primitive(ShadedPrimitiveTypeName.INT64, ShadedRepetition.REQUIRED) - .id(MetadataColumns.ROW_POSITION.fieldId()) - .named(MetadataColumns.ROW_POSITION.name()) - new ShadedMessageType( - shadedFileReadSchema.getName, - (rowPosition +: shadedFileReadSchema.getFields.asScala).asJava) - } else { - shadedFileReadSchema - } - // Convert shaded parquet schema to Iceberg schema for comparison - private lazy val fileIcebergSchema: Schema = ParquetSchemaUtil.convert(actionFileReadSchema) + private lazy val fileIcebergSchema: Schema = ParquetSchemaUtil.convert(shadedFileReadSchema) // Pre-compute action tree by visiting expected schema with file schema as partner private lazy val rootAction: ColumnAction = buildActionTimeMetric.ns { diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala index 3fa7e0c6117..dd5bae3df2a 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/GpuSingleThreadIcebergParquetReader.scala @@ -189,8 +189,7 @@ private class SingleFileReader( idToConstant, requiredSchema, shadedFileReadSchema, - conf.metrics, - hasNativeRowIndex = deletionVector.isDefined) + conf.metrics) inited = true (parquetReader, postProcessor) diff --git a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala index 07c0bdc622e..99263ac11a2 100644 --- a/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala +++ b/iceberg/common/src/main/scala/com/nvidia/spark/rapids/iceberg/parquet/reader.scala @@ -40,7 +40,11 @@ import org.apache.iceberg.parquet._ import org.apache.iceberg.shaded.org.apache.parquet.{HadoopReadOptions, ParquetReadOptions} import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader import org.apache.iceberg.shaded.org.apache.parquet.hadoop.metadata.{BlockMetaData => ShadedBlockMetaData} -import org.apache.iceberg.shaded.org.apache.parquet.schema.{MessageType => ShadedMessageType} +import org.apache.iceberg.shaded.org.apache.parquet.schema.{ + MessageType => ShadedMessageType, Types => ShadedTypes} +import org.apache.iceberg.shaded.org.apache.parquet.schema.PrimitiveType.{ + PrimitiveTypeName => ShadedPrimitiveTypeName} +import org.apache.iceberg.shaded.org.apache.parquet.schema.Type.{Repetition => ShadedRepetition} import org.apache.parquet.hadoop.metadata.BlockMetaData import org.apache.spark.internal.Logging @@ -291,8 +295,13 @@ trait GpuIcebergParquetReader extends Iterator[ColumnarBatch] with AutoCloseable hasInt96Timestamps = true, blockFirstRowIndices, ) - - (parquetFileInfo, fileReadSchema) + + val postProcessorReadSchema = if (hasDeletionVector) { + GpuIcebergParquetReader.withNativeRowIndex(fileReadSchema) + } else { + fileReadSchema + } + (parquetFileInfo, postProcessorReadSchema) } } } @@ -303,6 +312,21 @@ object GpuIcebergParquetReader { "parquet.private.read.filter.predicate", "parquet.read.support.class") + /** + * Adds the leading file-global row index emitted by the cuDF deletion-vector reader to the + * schema consumed by the Iceberg post-processor. + */ + private[iceberg] def withNativeRowIndex( + fileReadSchema: ShadedMessageType): ShadedMessageType = { + val rowPosition = ShadedTypes + .primitive(ShadedPrimitiveTypeName.INT64, ShadedRepetition.REQUIRED) + .id(MetadataColumns.ROW_POSITION.fieldId()) + .named(MetadataColumns.ROW_POSITION.name()) + new ShadedMessageType( + fileReadSchema.getName, + (rowPosition +: fileReadSchema.getFields.asScala).asJava) + } + def buildReaderOptions(file: InputFile, split: Option[(Long, Long)]) : ParquetReadOptions = { var optionsBuilder: ParquetReadOptions.Builder = null diff --git a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java index aece8245341..765d6e941fc 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java @@ -24,7 +24,6 @@ import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.hadoop.fs.Path; import org.apache.iceberg.*; -import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; @@ -65,7 +64,7 @@ public IcebergDeletionVector readDeletionVector( throws IOException { return IcebergDeletionVector.read( inputFile, deleteFile.contentOffset(), deleteFile.contentSizeInBytes(), - bytes -> PositionDeleteIndex.deserialize(bytes, deleteFile).cardinality()); + deleteFile.recordCount()); } @Override diff --git a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java index d8321236b30..05ded489fc4 100644 --- a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java @@ -24,7 +24,6 @@ import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.hadoop.fs.Path; import org.apache.iceberg.*; -import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; @@ -65,7 +64,7 @@ public IcebergDeletionVector readDeletionVector( throws IOException { return IcebergDeletionVector.read( inputFile, deleteFile.contentOffset(), deleteFile.contentSizeInBytes(), - bytes -> PositionDeleteIndex.deserialize(bytes, deleteFile).cardinality()); + deleteFile.recordCount()); } @Override diff --git a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java index 0728828be4c..3caf77f57a3 100644 --- a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java @@ -21,7 +21,6 @@ import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.iceberg.*; -import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; @@ -60,7 +59,7 @@ public IcebergDeletionVector readDeletionVector( throws IOException { return IcebergDeletionVector.read( inputFile, deleteFile.contentOffset(), deleteFile.contentSizeInBytes(), - bytes -> PositionDeleteIndex.deserialize(bytes, deleteFile).cardinality()); + deleteFile.recordCount()); } @Override diff --git a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala index c0e6148d5ed..c58227245b5 100644 --- a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala +++ b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala @@ -1027,9 +1027,8 @@ class GpuPostProcessorSuite extends AnyFunSuite with BeforeAndAfterAll { parquetInfo, new JHashMap[Integer, Any](), expectedSchema, - shadedSchema, - Map.empty, - hasNativeRowIndex = true) + GpuIcebergParquetReader.withNativeRowIndex(shadedSchema), + Map.empty) val rowPositions = closeOnExcept(CudfColumnVector.fromLongs(0L, 2L, 5L)) { column => GpuColumnVector.from(column, LongType) @@ -1071,9 +1070,8 @@ class GpuPostProcessorSuite extends AnyFunSuite with BeforeAndAfterAll { parquetInfo, new JHashMap[Integer, Any](), expectedSchema, - shadedSchema, - Map.empty, - hasNativeRowIndex = true) + GpuIcebergParquetReader.withNativeRowIndex(shadedSchema), + Map.empty) assert(processor.displayActionPlan() == "ProcessStruct\n data (input[1]):\n PassThrough") From 840bd99ed7179ca8cbd9d2552aac431a191d841d Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Thu, 20 Aug 2026 11:44:15 +0800 Subject: [PATCH 08/18] Add standalone Iceberg v3 deletion vector coverage Signed-off-by: Ray Liu --- .../iceberg/iceberg_merge_on_read_test.py | 37 +++++++++++++++++++ .../iceberg/GpuPostProcessorSuite.scala | 2 + 2 files changed, 39 insertions(+) diff --git a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py index 4e7c4391ea4..edc23210ee8 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py @@ -169,6 +169,43 @@ def test_iceberg_v2_mixed_deletes(spark_tmp_table_factory, spark_tmp_path, reade conf={'spark.rapids.sql.format.parquet.reader.type': reader_type}) +@iceberg +@ignore_order(local=True) +@pytest.mark.parametrize('reader_type', rapids_reader_types) +@pytest.mark.skipif(is_iceberg_remote_catalog(), reason = "S3tables catalog is managed") +@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) +@validate_execs_in_gpu_plan('GpuBatchScanExec') +def test_iceberg_v3_deletion_vector(spark_tmp_table_factory, reader_type): + table_name = setup_base_iceberg_table(spark_tmp_table_factory) + + def add_deletion_vector(spark): + spark.sql( + f"ALTER TABLE {table_name} SET TBLPROPERTIES ('format-version' = '3')") + spark.sql(f"DELETE FROM {table_name} where _c1 < 0") + spark.sql(f"REFRESH TABLE {table_name}") + delete_files = { + (row.content, row.file_format) for row in + spark.sql( + f"SELECT content, file_format FROM {table_name}.delete_files").collect() + } + expected_delete_files = {(1, 'PUFFIN')} + assert delete_files == expected_delete_files, \ + f"Expected only deletion vectors {expected_delete_files}, found {delete_files}" + + with_cpu_session(add_deletion_vector) + + read_conf = { + 'spark.rapids.sql.format.iceberg.v3.enabled': 'true', + 'spark.rapids.sql.format.parquet.reader.type': reader_type, + } + + assert_gpu_and_cpu_are_equal_collect( + lambda spark: spark.table(table_name), + conf=read_conf, + # Reset the GPU plan-validation config before fixture teardown. + is_cpu_first=False) + + @iceberg @ignore_order(local=True) @pytest.mark.parametrize('reader_type', rapids_reader_types) diff --git a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala index c58227245b5..365947f40d6 100644 --- a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala +++ b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala @@ -1030,6 +1030,8 @@ class GpuPostProcessorSuite extends AnyFunSuite with BeforeAndAfterAll { GpuIcebergParquetReader.withNativeRowIndex(shadedSchema), Map.empty) + assert(!processor.displayActionPlan().contains("FetchRowPosition")) + val rowPositions = closeOnExcept(CudfColumnVector.fromLongs(0L, 2L, 5L)) { column => GpuColumnVector.from(column, LongType) } From 33177e73c71ef892db9dab544e95fc9a0ba6cf50 Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Thu, 20 Aug 2026 13:38:10 +0800 Subject: [PATCH 09/18] Address latest Iceberg review feedback Signed-off-by: Ray Liu --- .../src/main/python/iceberg/__init__.py | 6 ++---- .../iceberg/iceberg_merge_on_read_test.py | 14 +++++++++----- .../iceberg/GpuPostProcessorSuite.scala | 19 +++++-------------- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/integration_tests/src/main/python/iceberg/__init__.py b/integration_tests/src/main/python/iceberg/__init__.py index 82343bb46db..fb59f243cf6 100644 --- a/integration_tests/src/main/python/iceberg/__init__.py +++ b/integration_tests/src/main/python/iceberg/__init__.py @@ -237,10 +237,8 @@ def setup_base_iceberg_table(spark_tmp_table_factory, table_name = get_full_table_name(spark_tmp_table_factory) tmp_view_name = spark_tmp_table_factory.get() - if table_prop is None: - table_prop = {'format-version':'2', 'write.delete.mode': 'merge-on-read'} - else: - table_prop = {**table_prop, 'format-version': '2', 'write.delete.mode': 'merge-on-read'} + default_table_prop = {'format-version': '2', 'write.delete.mode': 'merge-on-read'} + table_prop = {**default_table_prop, **(table_prop or {})} table_prop = _build_tblprops(table_prop) table_prop_sql = ", ".join([f"'{k}' = '{v}'" for k, v in table_prop.items()]) diff --git a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py index edc23210ee8..16ad42d9290 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py @@ -172,15 +172,14 @@ def test_iceberg_v2_mixed_deletes(spark_tmp_table_factory, spark_tmp_path, reade @iceberg @ignore_order(local=True) @pytest.mark.parametrize('reader_type', rapids_reader_types) -@pytest.mark.skipif(is_iceberg_remote_catalog(), reason = "S3tables catalog is managed") @pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) @validate_execs_in_gpu_plan('GpuBatchScanExec') def test_iceberg_v3_deletion_vector(spark_tmp_table_factory, reader_type): - table_name = setup_base_iceberg_table(spark_tmp_table_factory) + table_name = setup_base_iceberg_table( + spark_tmp_table_factory, + table_prop={'format-version': '3'}) def add_deletion_vector(spark): - spark.sql( - f"ALTER TABLE {table_name} SET TBLPROPERTIES ('format-version' = '3')") spark.sql(f"DELETE FROM {table_name} where _c1 < 0") spark.sql(f"REFRESH TABLE {table_name}") delete_files = { @@ -218,7 +217,12 @@ def add_deletion_vector(spark): @validate_execs_in_gpu_plan('GpuBatchScanExec') def test_iceberg_v3_mixed_deletes(spark_tmp_table_factory, spark_tmp_path, reader_type, register_iceberg_add_eq_deletes_udf): - table_name = setup_base_iceberg_table(spark_tmp_table_factory) + table_name = setup_base_iceberg_table( + spark_tmp_table_factory, + table_prop={ + 'format-version': '2', + 'write.delete.mode': 'merge-on-read', + }) _change_table(table_name, lambda spark: spark.sql(f"DELETE FROM {table_name} where _c1 < 0"), "No position deletes generated") diff --git a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala index 365947f40d6..ab5a7390c13 100644 --- a/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala +++ b/tests/src/test/spark350/scala/com/nvidia/spark/rapids/iceberg/GpuPostProcessorSuite.scala @@ -32,7 +32,9 @@ import java.util.{HashMap => JHashMap} import scala.collection.JavaConverters._ -import com.nvidia.spark.rapids.RapidsConf +import ai.rapids.cudf.{ColumnVector => CudfColumnVector} +import com.nvidia.spark.rapids.{GpuColumnVector, RapidsConf} +import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource} import com.nvidia.spark.rapids.iceberg.parquet._ import com.nvidia.spark.rapids.iceberg.parquet.converter.FromIcebergShaded.unshade import com.nvidia.spark.rapids.parquet.ParquetFileInfoWithBlockMeta @@ -55,7 +57,8 @@ import org.scalatest.funsuite.AnyFunSuite import org.apache.spark.SparkConf import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.types.StructType +import org.apache.spark.sql.types.{LongType, StructType} +import org.apache.spark.sql.vectorized.ColumnarBatch /** * Unit tests for GpuParquetReaderPostProcessor to verify that the correct @@ -1006,12 +1009,6 @@ class GpuPostProcessorSuite extends AnyFunSuite with BeforeAndAfterAll { } test("native deletion-vector row index supplies _pos and shifts physical columns") { - import ai.rapids.cudf.{ColumnVector => CudfColumnVector} - import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource} - import com.nvidia.spark.rapids.GpuColumnVector - import org.apache.spark.sql.types.LongType - import org.apache.spark.sql.vectorized.ColumnarBatch - val dataFieldId = 1 val rowPositionFieldId = MetadataColumns.ROW_POSITION.fieldId() val dataField = ShadedTypes @@ -1053,12 +1050,6 @@ class GpuPostProcessorSuite extends AnyFunSuite with BeforeAndAfterAll { } test("native deletion-vector row index is dropped through the root action tree") { - import ai.rapids.cudf.{ColumnVector => CudfColumnVector} - import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource} - import com.nvidia.spark.rapids.GpuColumnVector - import org.apache.spark.sql.types.LongType - import org.apache.spark.sql.vectorized.ColumnarBatch - val dataFieldId = 1 val dataField = ShadedTypes .primitive(ShadedPrimitiveTypeName.INT64, ShadedRepetition.OPTIONAL) From 5de16a700bc003fa546e807998994a6f8778df09 Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Thu, 20 Aug 2026 16:24:17 +0800 Subject: [PATCH 10/18] Add Iceberg v3 deletion vector writes Signed-off-by: Ray Liu --- .../rapids/iceberg/IcebergShimUtils.java | 44 +++++++++ .../spark/rapids/iceberg/ShimUtils.java | 28 ++++++ .../spark/source/GpuSparkWriteAccess.java | 34 +++++++ .../source/GpuSparkFileWriterFactory.scala | 9 +- .../source/GpuSparkPositionDeltaWrite.scala | 99 ++++++++++++++----- .../iceberg/spark/source/GpuSparkWrite.scala | 10 +- .../iceberg/iceberg110x/ShimUtilsImpl.java | 55 +++++++++++ .../iceberg/iceberg111x/ShimUtilsImpl.java | 55 +++++++++++ .../iceberg/iceberg19x/ShimUtilsImpl.java | 51 +++++++++- .../iceberg/iceberg_merge_on_read_test.py | 94 +++++++++++++++++- 10 files changed, 449 insertions(+), 30 deletions(-) diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java index 873ecb6ce38..be3f660a151 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java @@ -24,15 +24,23 @@ import org.apache.hadoop.fs.Path; import org.apache.iceberg.ContentFile; import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.Schema; import org.apache.iceberg.Table; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.io.DataWriteResult; +import org.apache.iceberg.io.DeleteWriteResult; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.io.PartitioningWriter; +import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.parquet.GpuParquetIO; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.catalyst.InternalRow; import scala.Option; import java.io.IOException; @@ -61,6 +69,42 @@ public interface IcebergShimUtils { /** Returns whether a positional delete is an Iceberg Puffin deletion vector. */ boolean isDeletionVector(DeleteFile deleteFile); + /** Returns whether a resolved delete-file format writes Puffin deletion vectors. */ + default boolean isDeletionVectorFormat(FileFormat fileFormat) { + return false; + } + + /** + * Creates Iceberg's version-specific deletion-vector writer. + * + *

The rewritable-deletes value is intentionally opaque because Iceberg 1.6 does not + * contain {@code DeleteFileSet}. Iceberg 1.9+ implementations cast it to the version-local + * map type and use it to merge applicable existing deletes. + */ + default PartitioningWriter, DeleteWriteResult> + newDeletionVectorWriter( + Table table, OutputFileFactory fileFactory, Object rewritableDeletes) { + throw new UnsupportedOperationException( + "This Iceberg version does not support Puffin deletion vectors"); + } + + /** Combines data and delete results, including rewritten deletes when supported. */ + default WriteResult positionDeltaWriteResult( + DataWriteResult dataResult, DeleteWriteResult deleteResult) { + return WriteResult.builder() + .addDataFiles(dataResult.dataFiles()) + .addDeleteFiles(deleteResult.deleteFiles()) + .addReferencedDataFiles(deleteResult.referencedDataFiles()) + .build(); + } + + /** Populates a reusable position-delete record across Iceberg API versions. */ + @SuppressWarnings("deprecation") + default void setPositionDelete( + PositionDelete delete, CharSequence path, long position) { + delete.set(path, position, null); + } + /** * Reads exactly the recorded deletion-vector byte range and returns its compressed bitmap. * diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java index c24043a49fd..af4b8e491bf 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java @@ -25,14 +25,22 @@ import org.apache.hadoop.fs.Path; import org.apache.iceberg.ContentFile; import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.Schema; import org.apache.iceberg.Table; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.io.DataWriteResult; +import org.apache.iceberg.io.DeleteWriteResult; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.io.PartitioningWriter; +import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.catalyst.InternalRow; import java.io.IOException; import java.util.Map; @@ -68,6 +76,26 @@ public static boolean isDeletionVector(DeleteFile deleteFile) { return IMPL.isDeletionVector(deleteFile); } + public static boolean isDeletionVectorFormat(FileFormat fileFormat) { + return IMPL.isDeletionVectorFormat(fileFormat); + } + + public static PartitioningWriter, DeleteWriteResult> + newDeletionVectorWriter( + Table table, OutputFileFactory fileFactory, Object rewritableDeletes) { + return IMPL.newDeletionVectorWriter(table, fileFactory, rewritableDeletes); + } + + public static WriteResult positionDeltaWriteResult( + DataWriteResult dataResult, DeleteWriteResult deleteResult) { + return IMPL.positionDeltaWriteResult(dataResult, deleteResult); + } + + public static void setPositionDelete( + PositionDelete delete, CharSequence path, long position) { + IMPL.setPositionDelete(delete, path, position); + } + public static IcebergDeletionVector readDeletionVector(DeleteFile deleteFile, RapidsInputFile inputFile) throws IOException { diff --git a/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java b/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java index 91ed87280da..c2591075411 100644 --- a/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java +++ b/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java @@ -16,7 +16,9 @@ package org.apache.iceberg.spark.source; +import java.io.Serializable; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.Map; import org.apache.iceberg.DataFile; @@ -115,6 +117,25 @@ public static Object context(DeltaWrite write) { return readField(positionDeltaWrite(write), "context", Object.class); } + /** Returns delete files that Iceberg requires a position-delta write to replace. */ + public static Serializable rewritableDeletes(DeltaWrite write, boolean useDVs) { + Object scan = readField(positionDeltaWrite(write), "scan", Object.class); + if (scan == null) { + return null; + } + + try { + Method method = findMethod(scan.getClass(), "rewritableDeletes", Boolean.TYPE); + method.setAccessible(true); + Map rewritableDeletes = (Map) method.invoke(scan, useDVs); + return rewritableDeletes == null || rewritableDeletes.isEmpty() + ? null : (Serializable) rewritableDeletes; + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Unable to discover rewritable deletes from " + scan.getClass().getName(), e); + } + } + public static Schema contextDataSchema(Object context) { return readField(context, "dataSchema", Schema.class); } @@ -208,4 +229,17 @@ private static Field findField(Class targetClass, String fieldName) { throw new IllegalStateException("No field " + fieldName + " in " + targetClass.getName()); } + private static Method findMethod( + Class targetClass, String methodName, Class... parameterTypes) { + Class current = targetClass; + while (current != null) { + try { + return current.getDeclaredMethod(methodName, parameterTypes); + } catch (NoSuchMethodException e) { + current = current.getSuperclass(); + } + } + throw new IllegalStateException("No method " + methodName + " in " + targetClass.getName()); + } + } diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkFileWriterFactory.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkFileWriterFactory.scala index 1a1422f00eb..301d14007b9 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkFileWriterFactory.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkFileWriterFactory.scala @@ -18,6 +18,7 @@ package org.apache.iceberg.spark.source import com.nvidia.spark.rapids.{ColumnarOutputWriterFactory, GpuParquetWriter, SpillableColumnarBatch} import com.nvidia.spark.rapids.fileio.iceberg.IcebergFileIO +import com.nvidia.spark.rapids.iceberg.ShimUtils import com.nvidia.spark.rapids.iceberg.parquet.GpuIcebergParquetAppender import org.apache.hadoop.conf.Configuration import org.apache.hadoop.mapreduce.TaskAttemptContext @@ -45,8 +46,10 @@ class GpuSparkFileWriterFactory(val table: Table, ) extends FileWriterFactory[SpillableColumnarBatch] { require(dataFileFormat == FileFormat.PARQUET, s"GpuSparkFileWriterFactory only supports PARQUET file format, but got $dataFileFormat") - require(deleteFileFormat == FileFormat.PARQUET, - s"GpuSparkFileWriterFactory only supports PARQUET file format, but got $deleteFileFormat") + require(deleteFileFormat == FileFormat.PARQUET || + ShimUtils.isDeletionVectorFormat(deleteFileFormat), + s"GpuSparkFileWriterFactory only supports PARQUET or Puffin deletion vectors, " + + s"but got $deleteFileFormat") private def newTaskAttemptContext(sparkType: StructType): TaskAttemptContext = { val conf = new Configuration(hadoopConf.value) @@ -111,4 +114,4 @@ class GpuSparkFileWriterFactory(val table: Table, fileIO = new IcebergFileIO(table.io()) ) } -} \ No newline at end of file +} diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala index 32cb672250f..c90a79a1e4e 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala @@ -28,11 +28,11 @@ import com.nvidia.spark.rapids.RmmRapidsRetryIterator.withRetryNoSplit import com.nvidia.spark.rapids.SpillPriorities.ACTIVE_ON_DECK_PRIORITY import com.nvidia.spark.rapids.fileio.iceberg.IcebergFileIO import com.nvidia.spark.rapids.iceberg.{ColumnarBatchWithPartition, GpuIcebergPartitioner, - GpuIcebergSpecPartitioner, IcebergFormatVersionSupport} + GpuIcebergSpecPartitioner, IcebergFormatVersionSupport, ShimUtils} import com.nvidia.spark.rapids.iceberg.utils.GpuStructProjection import org.apache.hadoop.mapreduce.Job import org.apache.iceberg._ -import org.apache.iceberg.deletes.DeleteGranularity +import org.apache.iceberg.deletes.{DeleteGranularity, PositionDelete} import org.apache.iceberg.io._ import org.apache.iceberg.io.DeleteSchemaUtil import org.apache.iceberg.spark.GpuTypeToSparkType @@ -91,6 +91,13 @@ class GpuSparkPositionDeltaWrite(cpu: DeltaWrite) val tableBroadcast = sparkContext.broadcast(SerializableTable.copyOf(table)) val command = GpuSparkWriteAccess.command(cpu) val context = GpuWriteContext(GpuSparkWriteAccess.context(cpu)) + val rewritableDeletes = if (context.useDVs) { + Option(GpuSparkWriteAccess.rewritableDeletes(cpu, true)) + .map(deletes => sparkContext.broadcast(deletes)) + .orNull + } else { + null + } val writeProps = GpuSparkWriteAccess.writeProperties(cpu) .asScala .toMap @@ -100,9 +107,10 @@ class GpuSparkPositionDeltaWrite(cpu: DeltaWrite) s"GpuSparkWrite only supports Parquet, but data format got: ${context.dataFileFormat}") } - if (!context.deleteFileFormat.equals(FileFormat.PARQUET)) { + if (!context.deleteFileFormat.equals(FileFormat.PARQUET) && !context.useDVs) { throw new UnsupportedOperationException( - s"GpuSparkWrite only supports Parquet, but delete format got: ${context.deleteFileFormat}") + s"GpuSparkWrite only supports Parquet or Puffin deletion vectors, " + + s"but delete format got: ${context.deleteFileFormat}") } val hadoopConf = sparkContext.hadoopConfiguration @@ -130,6 +138,7 @@ class GpuSparkPositionDeltaWrite(cpu: DeltaWrite) new GpuPositionDeltaWriterFactory( tableBroadcast, + rewritableDeletes, command, context, writeProps, @@ -189,7 +198,7 @@ object GpuSparkPositionDeltaWrite { // Merge-on-read writes both data files and position-delete files, so the resolved // delete codec matters too. GpuSparkWrite.tagParquetCompressionForGpu(GpuSparkWriteAccess.writeProperties(deltaWrite), - hasDeleteFiles = true, meta) + hasDeleteFiles = !context.useDVs, meta) } def convert(deltaWrite: DeltaWrite): GpuSparkPositionDeltaWrite = { @@ -199,6 +208,7 @@ object GpuSparkPositionDeltaWrite { class GpuPositionDeltaWriterFactory( val tableSer: Broadcast[Table], + val rewritableDeletesSer: Broadcast[java.io.Serializable], val command: Command, val context: GpuWriteContext, val writeProps: Map[String, String], @@ -208,6 +218,9 @@ class GpuPositionDeltaWriterFactory( override def createWriter(partitionId: Int, taskId: Long): DeltaWriter[InternalRow] = { val table = tableSer.value + val rewritableDeletes = Option(rewritableDeletesSer) + .map(_.value.asInstanceOf[AnyRef]) + .orNull val deleteFileFactory = OutputFileFactory.builderFor(table, partitionId, taskId) .format(context.deleteFileFormat) @@ -233,16 +246,17 @@ class GpuPositionDeltaWriterFactory( new IcebergFileIO(table.io())) if (command == Command.DELETE) { - new GpuDeleteOnlyDeltaWriter(table, writerFactory, deleteFileFactory, context) + new GpuDeleteOnlyDeltaWriter( + table, rewritableDeletes, writerFactory, deleteFileFactory, context) .asInstanceOf[DeltaWriter[InternalRow]] } else { if (table.spec().isUnpartitioned) { - new GpuUnpartitionedDeltaWriter(table, writerFactory, dataFileFactory, - deleteFileFactory, context) + new GpuUnpartitionedDeltaWriter(table, rewritableDeletes, writerFactory, + dataFileFactory, deleteFileFactory, context) .asInstanceOf[DeltaWriter[InternalRow]] } else { - new GpuPartitionedDeltaWriter(table, writerFactory, dataFileFactory, - deleteFileFactory, context) + new GpuPartitionedDeltaWriter(table, rewritableDeletes, writerFactory, + dataFileFactory, deleteFileFactory, context) .asInstanceOf[DeltaWriter[InternalRow]] } } @@ -262,6 +276,7 @@ trait GpuDeltaWriter extends DeltaWriter[ColumnarBatch] { } protected def newDeleteWriter(table: Table, + rewritableDeletes: AnyRef, writerFactory: GpuSparkFileWriterFactory, outputFileFactory: OutputFileFactory, context: GpuWriteContext): PartitioningWriter[SpillableColumnarBatch, DeleteWriteResult] = { @@ -270,7 +285,10 @@ trait GpuDeltaWriter extends DeltaWriter[ColumnarBatch] { val inputOrdered = context.inputOrdered val targetFileSize = context.targetDeleteFileSize - if (inputOrdered) { + if (context.useDVs) { + new GpuBatchPositionDeleteWriter( + ShimUtils.newDeletionVectorWriter(table, outputFileFactory, rewritableDeletes)) + } else if (inputOrdered) { new GpuClusteredPositionDeleteWriter(writerFactory, outputFileFactory, io, targetFileSize) } else { new GpuFanoutPositionDeleteWriter(writerFactory, outputFileFactory, io, targetFileSize) @@ -294,13 +312,15 @@ trait GpuDeltaWriter extends DeltaWriter[ColumnarBatch] { } protected def newPositionDeltaWriter(table: Table, + rewritableDeletes: AnyRef, writerFactory: GpuSparkFileWriterFactory, dataFileFactory: OutputFileFactory, deleteFileFactory: OutputFileFactory, context: GpuWriteContext): GpuBasePositionDeltaWriter = { val dataWriter = newDataWriter(table, writerFactory, dataFileFactory, context) - val deleteWriter = newDeleteWriter(table, writerFactory, deleteFileFactory, context) + val deleteWriter = newDeleteWriter( + table, rewritableDeletes, writerFactory, deleteFileFactory, context) new GpuBasePositionDeltaWriter(dataWriter, deleteWriter) } @@ -413,12 +433,39 @@ class GpuBasePositionDeltaWriter( def result(): WriteResult = { val dataResult = dataWriter.result() val deleteResult = deleteWriter.result() - WriteResult.builder() - .addDataFiles(dataResult.dataFiles()) - .addDeleteFiles(deleteResult.deleteFiles()) - .addReferencedDataFiles(deleteResult.referencedDataFiles()) - .build() + ShimUtils.positionDeltaWriteResult(dataResult, deleteResult) + } +} + +/** Converts GPU-produced batches of file paths and positions for Iceberg's DV encoder. */ +class GpuBatchPositionDeleteWriter( + private val delegate: PartitioningWriter[PositionDelete[InternalRow], DeleteWriteResult]) + extends PartitioningWriter[SpillableColumnarBatch, DeleteWriteResult] { + + private val positionDelete = PositionDelete.create[InternalRow]() + + override def write( + spillableBatch: SpillableColumnarBatch, + spec: PartitionSpec, + partition: StructLike): Unit = { + withResource(spillableBatch) { spillable => + withResource(spillable.getColumnarBatch()) { batch => + withResource(batch.column(0).asInstanceOf[GpuColumnVector].copyToHost()) { paths => + withResource(batch.column(1).asInstanceOf[GpuColumnVector].copyToHost()) { positions => + for (row <- 0 until batch.numRows()) { + ShimUtils.setPositionDelete( + positionDelete, paths.getUTF8String(row).toString, positions.getLong(row)) + delegate.write(positionDelete, spec, partition) + } + } + } + } + } } + + override def result(): DeleteWriteResult = delegate.result() + + override def close(): Unit = delegate.close() } /** @@ -539,6 +586,7 @@ trait GpuDeleteAndDataDeltaWriter extends GpuDeltaWriter { */ class GpuDeleteOnlyDeltaWriter( table: Table, + rewritableDeletes: AnyRef, writerFactory: GpuSparkFileWriterFactory, deleteFileFactory: OutputFileFactory, override val context: GpuWriteContext) extends GpuDeltaWriter { @@ -558,7 +606,7 @@ class GpuDeleteOnlyDeltaWriter( // Delegate writer based on whether the table uses fanout or clustered writing // GPU writers work with SpillableColumnarBatch instead of PositionDelete objects private val delegate: PartitioningWriter[SpillableColumnarBatch, DeleteWriteResult] = - newDeleteWriter(table, writerFactory, deleteFileFactory, context) + newDeleteWriter(table, rewritableDeletes, writerFactory, deleteFileFactory, context) // Partition projections for each spec @@ -658,6 +706,7 @@ class GpuDeleteOnlyDeltaWriter( */ class GpuUnpartitionedDeltaWriter( protected val table: Table, + rewritableDeletes: AnyRef, writerFactory: GpuSparkFileWriterFactory, dataFileFactory: OutputFileFactory, deleteFileFactory: OutputFileFactory, @@ -687,7 +736,8 @@ class GpuUnpartitionedDeltaWriter( // Create the combined position delta writer protected val delegate: GpuBasePositionDeltaWriter = - newPositionDeltaWriter(table, writerFactory, dataFileFactory, deleteFileFactory, context) + newPositionDeltaWriter( + table, rewritableDeletes, writerFactory, dataFileFactory, deleteFileFactory, context) protected def writeDelete(batch: SpillableColumnarBatch, spec: PartitionSpec, partition: StructLike): Unit = { @@ -708,6 +758,7 @@ class GpuUnpartitionedDeltaWriter( */ class GpuPartitionedDeltaWriter( protected val table: Table, + rewritableDeletes: AnyRef, writerFactory: GpuSparkFileWriterFactory, dataFileFactory: OutputFileFactory, deleteFileFactory: OutputFileFactory, @@ -739,7 +790,8 @@ class GpuPartitionedDeltaWriter( // Create the combined position delta writer protected val delegate: GpuBasePositionDeltaWriter = - newPositionDeltaWriter(table, writerFactory, dataFileFactory, deleteFileFactory, context) + newPositionDeltaWriter( + table, rewritableDeletes, writerFactory, dataFileFactory, deleteFileFactory, context) protected def writeDelete(batch: SpillableColumnarBatch, spec: PartitionSpec, partition: StructLike): Unit = { @@ -768,7 +820,8 @@ case class GpuWriteContext( deleteGranularity: DeleteGranularity, queryId: String, useFanoutWriter: Boolean, - inputOrdered: Boolean) { + inputOrdered: Boolean, + useDVs: Boolean) { /** * Returns the ordinal of the spec ID column in the delete schema. @@ -830,6 +883,7 @@ object GpuWriteContext { val queryId = GpuSparkWriteAccess.contextQueryId(cpu) val useFanoutWriter = GpuSparkWriteAccess.contextUseFanoutWriter(cpu) val inputOrdered = GpuSparkWriteAccess.contextInputOrdered(cpu) + val useDVs = ShimUtils.isDeletionVectorFormat(deleteFileFormat) GpuWriteContext( dataSchema, @@ -843,6 +897,7 @@ object GpuWriteContext { deleteGranularity, queryId, useFanoutWriter, - inputOrdered) + inputOrdered, + useDVs) } } diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkWrite.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkWrite.scala index f2659d5ddea..5a8d61864fe 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkWrite.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkWrite.scala @@ -26,7 +26,8 @@ import com.nvidia.spark.rapids.Arm.closeOnExcept import com.nvidia.spark.rapids.RapidsPluginImplicits.AutoCloseableSeq import com.nvidia.spark.rapids.SpillPriorities.ACTIVE_ON_DECK_PRIORITY import com.nvidia.spark.rapids.fileio.iceberg.IcebergFileIO -import com.nvidia.spark.rapids.iceberg.{GpuIcebergSpecPartitioner, IcebergFormatVersionSupport} +import com.nvidia.spark.rapids.iceberg.{GpuIcebergSpecPartitioner, IcebergFormatVersionSupport, + ShimUtils} import com.nvidia.spark.rapids.shims.parquet.ParquetFieldIdShims import org.apache.hadoop.mapreduce.Job import org.apache.iceberg._ @@ -240,8 +241,11 @@ object GpuSparkWrite { meta.willNotWorkOnGpu(s"GpuSparkWrite only supports Parquet, but got: ${dataFormat.get}") } - if (deleteFormat.exists(!_.equals(FileFormat.PARQUET))) { - meta.willNotWorkOnGpu(s"GpuSparkWrite only supports Parquet, but got: ${deleteFormat.get}") + if (deleteFormat.exists(format => + !format.equals(FileFormat.PARQUET) && !ShimUtils.isDeletionVectorFormat(format))) { + meta.willNotWorkOnGpu( + s"GpuSparkWrite only supports Parquet or Puffin deletion vectors, " + + s"but got: ${deleteFormat.get}") } // Check partition transform support diff --git a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java index 765d6e941fc..d6b4ae7cbbd 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java @@ -24,9 +24,19 @@ import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.hadoop.fs.Path; import org.apache.iceberg.*; +import org.apache.iceberg.data.BaseDeleteLoader; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.deletes.PositionDeleteIndex; +import org.apache.iceberg.encryption.EncryptingFileIO; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.DataWriteResult; +import org.apache.iceberg.io.DeleteWriteResult; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.io.PartitioningDVWriter; +import org.apache.iceberg.io.PartitioningWriter; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; +import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.SparkUtil; @@ -34,12 +44,15 @@ import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PartitionUtil; +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.function.Function; /** Iceberg 1.10.x shim: uses {@code SparkUtil::internalToSpark} and a cache-aware footer path. */ public class ShimUtilsImpl implements IcebergShimUtils { @@ -58,6 +71,48 @@ public boolean isDeletionVector(DeleteFile deleteFile) { return deleteFile.format() == FileFormat.PUFFIN; } + @Override + public boolean isDeletionVectorFormat(FileFormat fileFormat) { + return fileFormat == FileFormat.PUFFIN; + } + + @Override + public PartitioningWriter, DeleteWriteResult> + newDeletionVectorWriter( + Table table, OutputFileFactory fileFactory, Object rewritableDeletes) { + return new PartitioningDVWriter<>( + fileFactory, previousDeleteLoader(table, rewritableDeletes)); + } + + @Override + public WriteResult positionDeltaWriteResult( + DataWriteResult dataResult, DeleteWriteResult deleteResult) { + return WriteResult.builder() + .addDataFiles(dataResult.dataFiles()) + .addDeleteFiles(deleteResult.deleteFiles()) + .addReferencedDataFiles(deleteResult.referencedDataFiles()) + .addRewrittenDeleteFiles(deleteResult.rewrittenDeleteFiles()) + .build(); + } + + @SuppressWarnings("unchecked") + private Function previousDeleteLoader( + Table table, Object rewritableDeletes) { + if (rewritableDeletes == null) { + return path -> null; + } + + Map deleteFiles = + (Map) rewritableDeletes; + BaseDeleteLoader deleteLoader = new BaseDeleteLoader( + deleteFile -> EncryptingFileIO.combine(table.io(), table.encryption()) + .newInputFile(deleteFile)); + return path -> { + DeleteFileSet files = deleteFiles.get(path.toString()); + return files != null ? deleteLoader.loadPositionDeletes(files, path) : null; + }; + } + @Override public IcebergDeletionVector readDeletionVector( DeleteFile deleteFile, RapidsInputFile inputFile) diff --git a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java index 05ded489fc4..014629dc096 100644 --- a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java @@ -24,9 +24,19 @@ import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.hadoop.fs.Path; import org.apache.iceberg.*; +import org.apache.iceberg.data.BaseDeleteLoader; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.deletes.PositionDeleteIndex; +import org.apache.iceberg.encryption.EncryptingFileIO; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.DataWriteResult; +import org.apache.iceberg.io.DeleteWriteResult; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.io.PartitioningDVWriter; +import org.apache.iceberg.io.PartitioningWriter; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; +import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.SparkUtil; @@ -34,12 +44,15 @@ import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PartitionUtil; +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.function.Function; /** Iceberg 1.11.x shim: uses {@code SparkUtil::internalToSpark} and a cache-aware footer path. */ public class ShimUtilsImpl implements IcebergShimUtils { @@ -58,6 +71,48 @@ public boolean isDeletionVector(DeleteFile deleteFile) { return deleteFile.format() == FileFormat.PUFFIN; } + @Override + public boolean isDeletionVectorFormat(FileFormat fileFormat) { + return fileFormat == FileFormat.PUFFIN; + } + + @Override + public PartitioningWriter, DeleteWriteResult> + newDeletionVectorWriter( + Table table, OutputFileFactory fileFactory, Object rewritableDeletes) { + return new PartitioningDVWriter<>( + fileFactory, previousDeleteLoader(table, rewritableDeletes)); + } + + @Override + public WriteResult positionDeltaWriteResult( + DataWriteResult dataResult, DeleteWriteResult deleteResult) { + return WriteResult.builder() + .addDataFiles(dataResult.dataFiles()) + .addDeleteFiles(deleteResult.deleteFiles()) + .addReferencedDataFiles(deleteResult.referencedDataFiles()) + .addRewrittenDeleteFiles(deleteResult.rewrittenDeleteFiles()) + .build(); + } + + @SuppressWarnings("unchecked") + private Function previousDeleteLoader( + Table table, Object rewritableDeletes) { + if (rewritableDeletes == null) { + return path -> null; + } + + Map deleteFiles = + (Map) rewritableDeletes; + BaseDeleteLoader deleteLoader = new BaseDeleteLoader( + deleteFile -> EncryptingFileIO.combine(table.io(), table.encryption()) + .newInputFile(deleteFile)); + return path -> { + DeleteFileSet files = deleteFiles.get(path.toString()); + return files != null ? deleteLoader.loadPositionDeletes(files, path) : null; + }; + } + @Override public IcebergDeletionVector readDeletionVector( DeleteFile deleteFile, RapidsInputFile inputFile) diff --git a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java index 3caf77f57a3..bfd5bf66eaa 100644 --- a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java @@ -21,7 +21,11 @@ import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.iceberg.*; -import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.data.BaseDeleteLoader; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.deletes.PositionDeleteIndex; +import org.apache.iceberg.encryption.EncryptingFileIO; +import org.apache.iceberg.io.*; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; import org.apache.iceberg.spark.SparkUtil; @@ -29,12 +33,15 @@ import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PartitionUtil; +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.function.Function; /** Iceberg 1.9.x shim: uses {@code SparkUtil::internalToSpark}. */ public class ShimUtilsImpl implements IcebergShimUtils { @@ -53,6 +60,48 @@ public boolean isDeletionVector(DeleteFile deleteFile) { return deleteFile.format() == FileFormat.PUFFIN; } + @Override + public boolean isDeletionVectorFormat(FileFormat fileFormat) { + return fileFormat == FileFormat.PUFFIN; + } + + @Override + public PartitioningWriter, DeleteWriteResult> + newDeletionVectorWriter( + Table table, OutputFileFactory fileFactory, Object rewritableDeletes) { + return new PartitioningDVWriter<>( + fileFactory, previousDeleteLoader(table, rewritableDeletes)); + } + + @Override + public WriteResult positionDeltaWriteResult( + DataWriteResult dataResult, DeleteWriteResult deleteResult) { + return WriteResult.builder() + .addDataFiles(dataResult.dataFiles()) + .addDeleteFiles(deleteResult.deleteFiles()) + .addReferencedDataFiles(deleteResult.referencedDataFiles()) + .addRewrittenDeleteFiles(deleteResult.rewrittenDeleteFiles()) + .build(); + } + + @SuppressWarnings("unchecked") + private Function previousDeleteLoader( + Table table, Object rewritableDeletes) { + if (rewritableDeletes == null) { + return path -> null; + } + + Map deleteFiles = + (Map) rewritableDeletes; + BaseDeleteLoader deleteLoader = new BaseDeleteLoader( + deleteFile -> EncryptingFileIO.combine(table.io(), table.encryption()) + .newInputFile(deleteFile)); + return path -> { + DeleteFileSet files = deleteFiles.get(path.toString()); + return files != null ? deleteLoader.loadPositionDeletes(files, path) : null; + }; + } + @Override public IcebergDeletionVector readDeletionVector( DeleteFile deleteFile, RapidsInputFile inputFile) diff --git a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py index 16ad42d9290..3296b938d46 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py @@ -24,7 +24,7 @@ representative_eq_column_combinations, eq_reader_canary_pairs, \ iceberg_unsupported_mark, create_iceberg_table, \ iceberg_base_table_cols, iceberg_gens_list, get_full_table_name, \ - supports_iceberg_v3, ICEBERG_V3_UNSUPPORTED_REASON + iceberg_write_enabled_conf, supports_iceberg_v3, ICEBERG_V3_UNSUPPORTED_REASON from data_gen import gen_df, get_datagen_seed, int_gen, long_gen, string_gen from marks import iceberg, ignore_order, validate_execs_in_gpu_plan from spark_session import with_gpu_session, with_cpu_session @@ -205,6 +205,98 @@ def add_deletion_vector(spark): is_cpu_first=False) +def _assert_v3_deletion_vectors(spark, table_name, expected_positions): + delete_files = spark.sql(f""" + SELECT content, file_format, record_count, referenced_data_file, + content_offset, content_size_in_bytes + FROM {table_name}.delete_files + WHERE content = 1 + """).collect() + assert delete_files, "Expected at least one positional delete file" + assert all(row.file_format == 'PUFFIN' for row in delete_files), \ + f"Expected only Puffin deletion vectors, found {delete_files}" + referenced_files = [row.referenced_data_file for row in delete_files] + assert all(path is not None for path in referenced_files), \ + f"Expected every deletion vector to reference a data file, found {delete_files}" + assert len(referenced_files) == len(set(referenced_files)), \ + f"Expected at most one deletion vector per data file, found {delete_files}" + assert all(row.content_offset is not None and row.content_offset >= 0 and + row.content_size_in_bytes is not None and row.content_size_in_bytes > 0 + for row in delete_files), \ + f"Expected valid Puffin ranges, found {delete_files}" + actual_positions = sum(row.record_count for row in delete_files) + assert actual_positions == expected_positions, \ + f"Expected {expected_positions} deleted positions, found {actual_positions}" + + +@iceberg +@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) +@pytest.mark.parametrize('fanout_enabled', [False, True], ids=['clustered', 'fanout']) +def test_iceberg_v3_gpu_write_and_merge_deletion_vectors( + spark_tmp_table_factory, fanout_enabled): + table_name = get_full_table_name(spark_tmp_table_factory) + + def setup_table(spark): + spark.sql(f""" + CREATE TABLE {table_name} (id BIGINT) USING ICEBERG + PARTITIONED BY (bucket(2, id)) + TBLPROPERTIES ( + 'format-version' = '3', + 'write.delete.mode' = 'merge-on-read', + 'write.spark.fanout.enabled' = '{str(fanout_enabled).lower()}') + """) + spark.sql(f"INSERT INTO {table_name} SELECT id FROM range(128)") + + with_cpu_session(setup_table) + write_conf = dict(iceberg_write_enabled_conf) + write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' + + # The second delete must merge with the first DV rather than add another DV for a data file. + with_gpu_session( + lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 3 = 0").collect(), + conf=write_conf) + with_gpu_session( + lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0").collect(), + conf=write_conf) + + with_cpu_session(lambda spark: _assert_v3_deletion_vectors(spark, table_name, 60)) + actual = with_cpu_session( + lambda spark: [row.id for row in spark.table(table_name).collect()]) + expected = [value for value in range(128) if value % 3 != 0 and value % 5 != 0] + assert sorted(actual) == expected + + +@iceberg +@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) +def test_iceberg_v3_gpu_write_upgrades_position_deletes(spark_tmp_table_factory): + table_name = get_full_table_name(spark_tmp_table_factory) + + def setup_table(spark): + spark.sql(f""" + CREATE TABLE {table_name} (id BIGINT) USING ICEBERG + TBLPROPERTIES ( + 'format-version' = '2', + 'write.delete.mode' = 'merge-on-read') + """) + spark.sql(f"INSERT INTO {table_name} SELECT id FROM range(64)") + spark.sql(f"DELETE FROM {table_name} WHERE id % 4 = 0") + spark.sql( + f"ALTER TABLE {table_name} SET TBLPROPERTIES ('format-version' = '3')") + + with_cpu_session(setup_table) + write_conf = dict(iceberg_write_enabled_conf) + write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' + with_gpu_session( + lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0").collect(), + conf=write_conf) + + with_cpu_session(lambda spark: _assert_v3_deletion_vectors(spark, table_name, 25)) + actual = with_cpu_session( + lambda spark: [row.id for row in spark.table(table_name).collect()]) + expected = [value for value in range(64) if value % 4 != 0 and value % 5 != 0] + assert sorted(actual) == expected + + @iceberg @ignore_order(local=True) @pytest.mark.parametrize('reader_type', rapids_reader_types) From 6223f687e97c227a799046ac878a9db34d2a637d Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Wed, 26 Aug 2026 17:04:37 +0800 Subject: [PATCH 11/18] Address Iceberg deletion vector review feedback Signed-off-by: Ray Liu --- .../rapids/iceberg/IcebergShimUtils.java | 11 +++--- .../spark/rapids/iceberg/ShimUtils.java | 7 ++-- .../spark/source/GpuSparkWriteAccess.java | 33 +++++++++------- .../source/GpuSparkFileWriterFactory.scala | 2 +- .../source/GpuSparkPositionDeltaWrite.scala | 39 ++++++++----------- .../iceberg/spark/source/GpuSparkWrite.scala | 2 +- .../apache/iceberg/spark/source/write.scala | 4 +- .../iceberg/iceberg110x/ShimUtilsImpl.java | 12 +++--- .../iceberg/iceberg111x/ShimUtilsImpl.java | 12 +++--- .../iceberg/iceberg19x/ShimUtilsImpl.java | 12 +++--- 10 files changed, 65 insertions(+), 69 deletions(-) diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java index 51adaa436cc..5ef8994c4cf 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java @@ -70,20 +70,21 @@ public interface IcebergShimUtils { boolean isDeletionVector(DeleteFile deleteFile); /** Returns whether a resolved delete-file format writes Puffin deletion vectors. */ - default boolean isDeletionVectorFormat(FileFormat fileFormat) { + default boolean isPuffinFormat(FileFormat fileFormat) { return false; } /** * Creates Iceberg's version-specific deletion-vector writer. * - *

The rewritable-deletes value is intentionally opaque because Iceberg 1.6 does not - * contain {@code DeleteFileSet}. Iceberg 1.9+ implementations cast it to the version-local - * map type and use it to merge applicable existing deletes. + *

The map value type is intentionally unspecified because Iceberg 1.6 does not contain + * {@code DeleteFileSet}. Iceberg 1.9+ implementations cast each value to the version-local + * type and use it to merge applicable existing deletes. */ default PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( - Table table, OutputFileFactory fileFactory, Object rewritableDeletes) { + Table table, OutputFileFactory fileFactory, + Map rewritableDeletes) { throw new UnsupportedOperationException( "This Iceberg version does not support Puffin deletion vectors"); } diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java index 372b10be532..c8be8806543 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java @@ -76,13 +76,14 @@ public static boolean isDeletionVector(DeleteFile deleteFile) { return IMPL.isDeletionVector(deleteFile); } - public static boolean isDeletionVectorFormat(FileFormat fileFormat) { - return IMPL.isDeletionVectorFormat(fileFormat); + public static boolean isPuffinFormat(FileFormat fileFormat) { + return IMPL.isPuffinFormat(fileFormat); } public static PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( - Table table, OutputFileFactory fileFactory, Object rewritableDeletes) { + Table table, OutputFileFactory fileFactory, + Map rewritableDeletes) { return IMPL.newDeletionVectorWriter(table, fileFactory, rewritableDeletes); } diff --git a/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java b/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java index c2591075411..4e0b4153ec1 100644 --- a/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java +++ b/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java @@ -16,7 +16,6 @@ package org.apache.iceberg.spark.source; -import java.io.Serializable; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Map; @@ -29,6 +28,8 @@ import org.apache.iceberg.io.DeleteWriteResult; import org.apache.iceberg.io.WriteResult; import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.connector.write.DeltaBatchWrite; import org.apache.spark.sql.connector.write.DeltaWrite; import org.apache.spark.sql.connector.write.RowLevelOperation.Command; import org.apache.spark.sql.connector.write.Write; @@ -117,22 +118,28 @@ public static Object context(DeltaWrite write) { return readField(positionDeltaWrite(write), "context", Object.class); } - /** Returns delete files that Iceberg requires a position-delta write to replace. */ - public static Serializable rewritableDeletes(DeltaWrite write, boolean useDVs) { - Object scan = readField(positionDeltaWrite(write), "scan", Object.class); - if (scan == null) { - return null; - } - + /** + * Calls Iceberg's private {@code broadcastRewritableDeletes} method. + * + *

In pseudo-code, Iceberg does the following: + *

+   * if (scan exists and deletes should be rewritten) {
+   *   deletes = scan.rewritableDeletes(context.useDVs)
+   *   return deletes.nonEmpty ? broadcast(deletes) : null
+   * }
+   * return null
+   * 
+ * This bridge reuses that logic because both the batch-write class and method are private. + */ + @SuppressWarnings("unchecked") + public static Broadcast> broadcastRewritableDeletes(DeltaBatchWrite write) { try { - Method method = findMethod(scan.getClass(), "rewritableDeletes", Boolean.TYPE); + Method method = findMethod(write.getClass(), "broadcastRewritableDeletes"); method.setAccessible(true); - Map rewritableDeletes = (Map) method.invoke(scan, useDVs); - return rewritableDeletes == null || rewritableDeletes.isEmpty() - ? null : (Serializable) rewritableDeletes; + return (Broadcast>) method.invoke(write); } catch (ReflectiveOperationException e) { throw new IllegalStateException( - "Unable to discover rewritable deletes from " + scan.getClass().getName(), e); + "Unable to broadcast rewritable deletes from " + write.getClass().getName(), e); } } diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkFileWriterFactory.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkFileWriterFactory.scala index 301d14007b9..091a5f7045f 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkFileWriterFactory.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkFileWriterFactory.scala @@ -47,7 +47,7 @@ class GpuSparkFileWriterFactory(val table: Table, require(dataFileFormat == FileFormat.PARQUET, s"GpuSparkFileWriterFactory only supports PARQUET file format, but got $dataFileFormat") require(deleteFileFormat == FileFormat.PARQUET || - ShimUtils.isDeletionVectorFormat(deleteFileFormat), + ShimUtils.isPuffinFormat(deleteFileFormat), s"GpuSparkFileWriterFactory only supports PARQUET or Puffin deletion vectors, " + s"but got $deleteFileFormat") diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala index c90a79a1e4e..b410b8fe12e 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala @@ -86,18 +86,14 @@ class GpuSparkPositionDeltaWrite(cpu: DeltaWrite) override def advisoryPartitionSizeInBytes(): Long = writeRequirements.advisoryPartitionSizeInBytes() - private[source] def createDeltaWriterFactory: DeltaWriterFactory = { + private[source] def createDeltaWriterFactory( + cpuBatchWrite: DeltaBatchWrite): DeltaWriterFactory = { val sparkContext: JavaSparkContext = GpuSparkWriteAccess.sparkContext(cpu) val tableBroadcast = sparkContext.broadcast(SerializableTable.copyOf(table)) val command = GpuSparkWriteAccess.command(cpu) val context = GpuWriteContext(GpuSparkWriteAccess.context(cpu)) - val rewritableDeletes = if (context.useDVs) { - Option(GpuSparkWriteAccess.rewritableDeletes(cpu, true)) - .map(deletes => sparkContext.broadcast(deletes)) - .orNull - } else { - null - } + val rewritableDeletes = Option( + GpuSparkWriteAccess.broadcastRewritableDeletes(cpuBatchWrite)) val writeProps = GpuSparkWriteAccess.writeProperties(cpu) .asScala .toMap @@ -208,7 +204,7 @@ object GpuSparkPositionDeltaWrite { class GpuPositionDeltaWriterFactory( val tableSer: Broadcast[Table], - val rewritableDeletesSer: Broadcast[java.io.Serializable], + val rewritableDeletesSer: Option[Broadcast[java.util.Map[String, _]]], val command: Command, val context: GpuWriteContext, val writeProps: Map[String, String], @@ -218,10 +214,6 @@ class GpuPositionDeltaWriterFactory( override def createWriter(partitionId: Int, taskId: Long): DeltaWriter[InternalRow] = { val table = tableSer.value - val rewritableDeletes = Option(rewritableDeletesSer) - .map(_.value.asInstanceOf[AnyRef]) - .orNull - val deleteFileFactory = OutputFileFactory.builderFor(table, partitionId, taskId) .format(context.deleteFileFormat) .operationId(context.queryId) @@ -247,15 +239,15 @@ class GpuPositionDeltaWriterFactory( if (command == Command.DELETE) { new GpuDeleteOnlyDeltaWriter( - table, rewritableDeletes, writerFactory, deleteFileFactory, context) + table, rewritableDeletesSer, writerFactory, deleteFileFactory, context) .asInstanceOf[DeltaWriter[InternalRow]] } else { if (table.spec().isUnpartitioned) { - new GpuUnpartitionedDeltaWriter(table, rewritableDeletes, writerFactory, + new GpuUnpartitionedDeltaWriter(table, rewritableDeletesSer, writerFactory, dataFileFactory, deleteFileFactory, context) .asInstanceOf[DeltaWriter[InternalRow]] } else { - new GpuPartitionedDeltaWriter(table, rewritableDeletes, writerFactory, + new GpuPartitionedDeltaWriter(table, rewritableDeletesSer, writerFactory, dataFileFactory, deleteFileFactory, context) .asInstanceOf[DeltaWriter[InternalRow]] } @@ -276,7 +268,7 @@ trait GpuDeltaWriter extends DeltaWriter[ColumnarBatch] { } protected def newDeleteWriter(table: Table, - rewritableDeletes: AnyRef, + rewritableDeletes: Option[Broadcast[java.util.Map[String, _]]], writerFactory: GpuSparkFileWriterFactory, outputFileFactory: OutputFileFactory, context: GpuWriteContext): PartitioningWriter[SpillableColumnarBatch, DeleteWriteResult] = { @@ -287,7 +279,8 @@ trait GpuDeltaWriter extends DeltaWriter[ColumnarBatch] { if (context.useDVs) { new GpuBatchPositionDeleteWriter( - ShimUtils.newDeletionVectorWriter(table, outputFileFactory, rewritableDeletes)) + ShimUtils.newDeletionVectorWriter( + table, outputFileFactory, rewritableDeletes.map(_.value).orNull)) } else if (inputOrdered) { new GpuClusteredPositionDeleteWriter(writerFactory, outputFileFactory, io, targetFileSize) } else { @@ -312,7 +305,7 @@ trait GpuDeltaWriter extends DeltaWriter[ColumnarBatch] { } protected def newPositionDeltaWriter(table: Table, - rewritableDeletes: AnyRef, + rewritableDeletes: Option[Broadcast[java.util.Map[String, _]]], writerFactory: GpuSparkFileWriterFactory, dataFileFactory: OutputFileFactory, deleteFileFactory: OutputFileFactory, @@ -586,7 +579,7 @@ trait GpuDeleteAndDataDeltaWriter extends GpuDeltaWriter { */ class GpuDeleteOnlyDeltaWriter( table: Table, - rewritableDeletes: AnyRef, + rewritableDeletes: Option[Broadcast[java.util.Map[String, _]]], writerFactory: GpuSparkFileWriterFactory, deleteFileFactory: OutputFileFactory, override val context: GpuWriteContext) extends GpuDeltaWriter { @@ -706,7 +699,7 @@ class GpuDeleteOnlyDeltaWriter( */ class GpuUnpartitionedDeltaWriter( protected val table: Table, - rewritableDeletes: AnyRef, + rewritableDeletes: Option[Broadcast[java.util.Map[String, _]]], writerFactory: GpuSparkFileWriterFactory, dataFileFactory: OutputFileFactory, deleteFileFactory: OutputFileFactory, @@ -758,7 +751,7 @@ class GpuUnpartitionedDeltaWriter( */ class GpuPartitionedDeltaWriter( protected val table: Table, - rewritableDeletes: AnyRef, + rewritableDeletes: Option[Broadcast[java.util.Map[String, _]]], writerFactory: GpuSparkFileWriterFactory, dataFileFactory: OutputFileFactory, deleteFileFactory: OutputFileFactory, @@ -883,7 +876,7 @@ object GpuWriteContext { val queryId = GpuSparkWriteAccess.contextQueryId(cpu) val useFanoutWriter = GpuSparkWriteAccess.contextUseFanoutWriter(cpu) val inputOrdered = GpuSparkWriteAccess.contextInputOrdered(cpu) - val useDVs = ShimUtils.isDeletionVectorFormat(deleteFileFormat) + val useDVs = ShimUtils.isPuffinFormat(deleteFileFormat) GpuWriteContext( dataSchema, diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkWrite.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkWrite.scala index 5a8d61864fe..59942bf06a4 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkWrite.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkWrite.scala @@ -242,7 +242,7 @@ object GpuSparkWrite { } if (deleteFormat.exists(format => - !format.equals(FileFormat.PARQUET) && !ShimUtils.isDeletionVectorFormat(format))) { + !format.equals(FileFormat.PARQUET) && !ShimUtils.isPuffinFormat(format))) { meta.willNotWorkOnGpu( s"GpuSparkWrite only supports Parquet or Puffin deletion vectors, " + s"but got: ${deleteFormat.get}") diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/write.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/write.scala index 4861d846e73..dce9bcb805f 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/write.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/write.scala @@ -98,6 +98,6 @@ class GpuPositionDeltaBatchWrite(write: GpuSparkPositionDeltaWrite, override def useCommitCoordinator(): Boolean = cpuBatchWrite.useCommitCoordinator() override def createBatchWriterFactory(info: PhysicalWriteInfo): DeltaWriterFactory = { - write.createDeltaWriterFactory + write.createDeltaWriterFactory(cpuBatchWrite) } -} \ No newline at end of file +} diff --git a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java index 29771f8e044..9995246e0c0 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java @@ -72,14 +72,15 @@ public boolean isDeletionVector(DeleteFile deleteFile) { } @Override - public boolean isDeletionVectorFormat(FileFormat fileFormat) { + public boolean isPuffinFormat(FileFormat fileFormat) { return fileFormat == FileFormat.PUFFIN; } @Override public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( - Table table, OutputFileFactory fileFactory, Object rewritableDeletes) { + Table table, OutputFileFactory fileFactory, + Map rewritableDeletes) { return new PartitioningDVWriter<>( fileFactory, previousDeleteLoader(table, rewritableDeletes)); } @@ -95,20 +96,17 @@ public WriteResult positionDeltaWriteResult( .build(); } - @SuppressWarnings("unchecked") private Function previousDeleteLoader( - Table table, Object rewritableDeletes) { + Table table, Map rewritableDeletes) { if (rewritableDeletes == null) { return path -> null; } - Map deleteFiles = - (Map) rewritableDeletes; BaseDeleteLoader deleteLoader = new BaseDeleteLoader( deleteFile -> EncryptingFileIO.combine(table.io(), table.encryption()) .newInputFile(deleteFile)); return path -> { - DeleteFileSet files = deleteFiles.get(path.toString()); + DeleteFileSet files = (DeleteFileSet) rewritableDeletes.get(path.toString()); return files != null ? deleteLoader.loadPositionDeletes(files, path) : null; }; } diff --git a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java index 4175b20b0e8..86254612d85 100644 --- a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java @@ -72,14 +72,15 @@ public boolean isDeletionVector(DeleteFile deleteFile) { } @Override - public boolean isDeletionVectorFormat(FileFormat fileFormat) { + public boolean isPuffinFormat(FileFormat fileFormat) { return fileFormat == FileFormat.PUFFIN; } @Override public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( - Table table, OutputFileFactory fileFactory, Object rewritableDeletes) { + Table table, OutputFileFactory fileFactory, + Map rewritableDeletes) { return new PartitioningDVWriter<>( fileFactory, previousDeleteLoader(table, rewritableDeletes)); } @@ -95,20 +96,17 @@ public WriteResult positionDeltaWriteResult( .build(); } - @SuppressWarnings("unchecked") private Function previousDeleteLoader( - Table table, Object rewritableDeletes) { + Table table, Map rewritableDeletes) { if (rewritableDeletes == null) { return path -> null; } - Map deleteFiles = - (Map) rewritableDeletes; BaseDeleteLoader deleteLoader = new BaseDeleteLoader( deleteFile -> EncryptingFileIO.combine(table.io(), table.encryption()) .newInputFile(deleteFile)); return path -> { - DeleteFileSet files = deleteFiles.get(path.toString()); + DeleteFileSet files = (DeleteFileSet) rewritableDeletes.get(path.toString()); return files != null ? deleteLoader.loadPositionDeletes(files, path) : null; }; } diff --git a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java index 69b47f64361..0ecf728935d 100644 --- a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java @@ -61,14 +61,15 @@ public boolean isDeletionVector(DeleteFile deleteFile) { } @Override - public boolean isDeletionVectorFormat(FileFormat fileFormat) { + public boolean isPuffinFormat(FileFormat fileFormat) { return fileFormat == FileFormat.PUFFIN; } @Override public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( - Table table, OutputFileFactory fileFactory, Object rewritableDeletes) { + Table table, OutputFileFactory fileFactory, + Map rewritableDeletes) { return new PartitioningDVWriter<>( fileFactory, previousDeleteLoader(table, rewritableDeletes)); } @@ -84,20 +85,17 @@ public WriteResult positionDeltaWriteResult( .build(); } - @SuppressWarnings("unchecked") private Function previousDeleteLoader( - Table table, Object rewritableDeletes) { + Table table, Map rewritableDeletes) { if (rewritableDeletes == null) { return path -> null; } - Map deleteFiles = - (Map) rewritableDeletes; BaseDeleteLoader deleteLoader = new BaseDeleteLoader( deleteFile -> EncryptingFileIO.combine(table.io(), table.encryption()) .newInputFile(deleteFile)); return path -> { - DeleteFileSet files = deleteFiles.get(path.toString()); + DeleteFileSet files = (DeleteFileSet) rewritableDeletes.get(path.toString()); return files != null ? deleteLoader.loadPositionDeletes(files, path) : null; }; } From 1133ee1c2b205f4b29a6bf65ffd20279261eef6b Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Thu, 27 Aug 2026 12:15:31 +0800 Subject: [PATCH 12/18] Address additional deletion vector review feedback Signed-off-by: Ray Liu --- .../rapids/iceberg/IcebergShimUtils.java | 34 ++----- .../spark/rapids/iceberg/ShimUtils.java | 3 +- .../spark/source/GpuSparkWriteAccess.java | 33 ++++--- .../source/GpuSparkPositionDeltaWrite.scala | 41 ++++---- .../iceberg/iceberg110x/ShimUtilsImpl.java | 14 ++- .../iceberg/iceberg111x/ShimUtilsImpl.java | 14 ++- .../iceberg/iceberg16x/ShimUtilsImpl.java | 39 ++++++++ .../iceberg/iceberg19x/ShimUtilsImpl.java | 14 ++- .../python/iceberg/iceberg_delete_test.py | 92 ++++++++++++++++++ .../iceberg/iceberg_merge_on_read_test.py | 94 +------------------ 10 files changed, 217 insertions(+), 161 deletions(-) diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java index 5ef8994c4cf..68adbeb20af 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java @@ -45,6 +45,7 @@ import java.io.IOException; import java.util.Map; +import java.util.Set; /** * Version-specific Iceberg API adapter. @@ -70,41 +71,26 @@ public interface IcebergShimUtils { boolean isDeletionVector(DeleteFile deleteFile); /** Returns whether a resolved delete-file format writes Puffin deletion vectors. */ - default boolean isPuffinFormat(FileFormat fileFormat) { - return false; - } + boolean isPuffinFormat(FileFormat fileFormat); /** * Creates Iceberg's version-specific deletion-vector writer. * - *

The map value type is intentionally unspecified because Iceberg 1.6 does not contain - * {@code DeleteFileSet}. Iceberg 1.9+ implementations cast each value to the version-local - * type and use it to merge applicable existing deletes. + *

Iceberg 1.9+ supplies {@code DeleteFileSet} values, exposed through the stable + * {@link Set} API because Iceberg 1.6 does not contain {@code DeleteFileSet}. */ - default PartitioningWriter, DeleteWriteResult> + PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( Table table, OutputFileFactory fileFactory, - Map rewritableDeletes) { - throw new UnsupportedOperationException( - "This Iceberg version does not support Puffin deletion vectors"); - } + Map> rewritableDeletes); /** Combines data and delete results, including rewritten deletes when supported. */ - default WriteResult positionDeltaWriteResult( - DataWriteResult dataResult, DeleteWriteResult deleteResult) { - return WriteResult.builder() - .addDataFiles(dataResult.dataFiles()) - .addDeleteFiles(deleteResult.deleteFiles()) - .addReferencedDataFiles(deleteResult.referencedDataFiles()) - .build(); - } + WriteResult positionDeltaWriteResult( + DataWriteResult dataResult, DeleteWriteResult deleteResult); /** Populates a reusable position-delete record across Iceberg API versions. */ - @SuppressWarnings("deprecation") - default void setPositionDelete( - PositionDelete delete, CharSequence path, long position) { - delete.set(path, position, null); - } + void setPositionDelete( + PositionDelete delete, CharSequence path, long position); /** * Reads exactly the recorded deletion-vector byte range and returns its compressed bitmap. diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java index c8be8806543..7dcd64feca7 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java @@ -44,6 +44,7 @@ import java.io.IOException; import java.util.Map; +import java.util.Set; /** * Dispatches to the correct version-specific Iceberg shim utilities based on the @@ -83,7 +84,7 @@ public static boolean isPuffinFormat(FileFormat fileFormat) { public static PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( Table table, OutputFileFactory fileFactory, - Map rewritableDeletes) { + Map> rewritableDeletes) { return IMPL.newDeletionVectorWriter(table, fileFactory, rewritableDeletes); } diff --git a/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java b/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java index 4e0b4153ec1..8a3918e1a49 100644 --- a/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java +++ b/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java @@ -19,8 +19,10 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Map; +import java.util.Set; import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.Schema; import org.apache.iceberg.Table; @@ -44,6 +46,16 @@ * class loader as Iceberg itself. */ public final class GpuSparkWriteAccess { + private static final ClassValue BROADCAST_REWRITABLE_DELETES_METHOD = + new ClassValue() { + @Override + protected Method computeValue(Class type) { + Method method = findMethod(type, "broadcastRewritableDeletes"); + method.setAccessible(true); + return method; + } + }; + private GpuSparkWriteAccess() { } @@ -119,24 +131,15 @@ public static Object context(DeltaWrite write) { } /** - * Calls Iceberg's private {@code broadcastRewritableDeletes} method. - * - *

In pseudo-code, Iceberg does the following: - *

-   * if (scan exists and deletes should be rewritten) {
-   *   deletes = scan.rewritableDeletes(context.useDVs)
-   *   return deletes.nonEmpty ? broadcast(deletes) : null
-   * }
-   * return null
-   * 
- * This bridge reuses that logic because both the batch-write class and method are private. + * Calls Iceberg's private + * {@code SparkPositionDeltaWrite.PositionDeltaBatchWrite.broadcastRewritableDeletes()} method. */ @SuppressWarnings("unchecked") - public static Broadcast> broadcastRewritableDeletes(DeltaBatchWrite write) { + public static Broadcast>> broadcastRewritableDeletes( + DeltaBatchWrite write) { try { - Method method = findMethod(write.getClass(), "broadcastRewritableDeletes"); - method.setAccessible(true); - return (Broadcast>) method.invoke(write); + Method method = BROADCAST_REWRITABLE_DELETES_METHOD.get(write.getClass()); + return (Broadcast>>) method.invoke(write); } catch (ReflectiveOperationException e) { throw new IllegalStateException( "Unable to broadcast rewritable deletes from " + write.getClass().getName(), e); diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala index b410b8fe12e..e9753539f7f 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala @@ -92,8 +92,11 @@ class GpuSparkPositionDeltaWrite(cpu: DeltaWrite) val tableBroadcast = sparkContext.broadcast(SerializableTable.copyOf(table)) val command = GpuSparkWriteAccess.command(cpu) val context = GpuWriteContext(GpuSparkWriteAccess.context(cpu)) - val rewritableDeletes = Option( - GpuSparkWriteAccess.broadcastRewritableDeletes(cpuBatchWrite)) + val rewritableDeletes = if (context.useDVs) { + Option(GpuSparkWriteAccess.broadcastRewritableDeletes(cpuBatchWrite)) + } else { + None + } val writeProps = GpuSparkWriteAccess.writeProperties(cpu) .asScala .toMap @@ -204,7 +207,8 @@ object GpuSparkPositionDeltaWrite { class GpuPositionDeltaWriterFactory( val tableSer: Broadcast[Table], - val rewritableDeletesSer: Option[Broadcast[java.util.Map[String, _]]], + val rewritableDeletesSer: + Option[Broadcast[java.util.Map[String, java.util.Set[DeleteFile]]]], val command: Command, val context: GpuWriteContext, val writeProps: Map[String, String], @@ -260,6 +264,13 @@ trait GpuDeltaWriter extends DeltaWriter[ColumnarBatch] { def context: GpuWriteContext + protected def rewritableDeletesSer: + Option[Broadcast[java.util.Map[String, java.util.Set[DeleteFile]]]] + + protected def rewritableDeletes: java.util.Map[String, java.util.Set[DeleteFile]] = { + rewritableDeletesSer.map(_.value).orNull + } + protected def buildPartitionProjections( partitionType: IcebergTypes.StructType, specs: collection.Map[Integer, PartitionSpec]): Map[Int, GpuStructProjection] = { @@ -268,7 +279,6 @@ trait GpuDeltaWriter extends DeltaWriter[ColumnarBatch] { } protected def newDeleteWriter(table: Table, - rewritableDeletes: Option[Broadcast[java.util.Map[String, _]]], writerFactory: GpuSparkFileWriterFactory, outputFileFactory: OutputFileFactory, context: GpuWriteContext): PartitioningWriter[SpillableColumnarBatch, DeleteWriteResult] = { @@ -280,7 +290,7 @@ trait GpuDeltaWriter extends DeltaWriter[ColumnarBatch] { if (context.useDVs) { new GpuBatchPositionDeleteWriter( ShimUtils.newDeletionVectorWriter( - table, outputFileFactory, rewritableDeletes.map(_.value).orNull)) + table, outputFileFactory, rewritableDeletes)) } else if (inputOrdered) { new GpuClusteredPositionDeleteWriter(writerFactory, outputFileFactory, io, targetFileSize) } else { @@ -305,15 +315,13 @@ trait GpuDeltaWriter extends DeltaWriter[ColumnarBatch] { } protected def newPositionDeltaWriter(table: Table, - rewritableDeletes: Option[Broadcast[java.util.Map[String, _]]], writerFactory: GpuSparkFileWriterFactory, dataFileFactory: OutputFileFactory, deleteFileFactory: OutputFileFactory, context: GpuWriteContext): GpuBasePositionDeltaWriter = { val dataWriter = newDataWriter(table, writerFactory, dataFileFactory, context) - val deleteWriter = newDeleteWriter( - table, rewritableDeletes, writerFactory, deleteFileFactory, context) + val deleteWriter = newDeleteWriter(table, writerFactory, deleteFileFactory, context) new GpuBasePositionDeltaWriter(dataWriter, deleteWriter) } @@ -579,7 +587,8 @@ trait GpuDeleteAndDataDeltaWriter extends GpuDeltaWriter { */ class GpuDeleteOnlyDeltaWriter( table: Table, - rewritableDeletes: Option[Broadcast[java.util.Map[String, _]]], + override protected val rewritableDeletesSer: + Option[Broadcast[java.util.Map[String, java.util.Set[DeleteFile]]]], writerFactory: GpuSparkFileWriterFactory, deleteFileFactory: OutputFileFactory, override val context: GpuWriteContext) extends GpuDeltaWriter { @@ -599,7 +608,7 @@ class GpuDeleteOnlyDeltaWriter( // Delegate writer based on whether the table uses fanout or clustered writing // GPU writers work with SpillableColumnarBatch instead of PositionDelete objects private val delegate: PartitioningWriter[SpillableColumnarBatch, DeleteWriteResult] = - newDeleteWriter(table, rewritableDeletes, writerFactory, deleteFileFactory, context) + newDeleteWriter(table, writerFactory, deleteFileFactory, context) // Partition projections for each spec @@ -699,7 +708,8 @@ class GpuDeleteOnlyDeltaWriter( */ class GpuUnpartitionedDeltaWriter( protected val table: Table, - rewritableDeletes: Option[Broadcast[java.util.Map[String, _]]], + override protected val rewritableDeletesSer: + Option[Broadcast[java.util.Map[String, java.util.Set[DeleteFile]]]], writerFactory: GpuSparkFileWriterFactory, dataFileFactory: OutputFileFactory, deleteFileFactory: OutputFileFactory, @@ -729,8 +739,7 @@ class GpuUnpartitionedDeltaWriter( // Create the combined position delta writer protected val delegate: GpuBasePositionDeltaWriter = - newPositionDeltaWriter( - table, rewritableDeletes, writerFactory, dataFileFactory, deleteFileFactory, context) + newPositionDeltaWriter(table, writerFactory, dataFileFactory, deleteFileFactory, context) protected def writeDelete(batch: SpillableColumnarBatch, spec: PartitionSpec, partition: StructLike): Unit = { @@ -751,7 +760,8 @@ class GpuUnpartitionedDeltaWriter( */ class GpuPartitionedDeltaWriter( protected val table: Table, - rewritableDeletes: Option[Broadcast[java.util.Map[String, _]]], + override protected val rewritableDeletesSer: + Option[Broadcast[java.util.Map[String, java.util.Set[DeleteFile]]]], writerFactory: GpuSparkFileWriterFactory, dataFileFactory: OutputFileFactory, deleteFileFactory: OutputFileFactory, @@ -783,8 +793,7 @@ class GpuPartitionedDeltaWriter( // Create the combined position delta writer protected val delegate: GpuBasePositionDeltaWriter = - newPositionDeltaWriter( - table, rewritableDeletes, writerFactory, dataFileFactory, deleteFileFactory, context) + newPositionDeltaWriter(table, writerFactory, dataFileFactory, deleteFileFactory, context) protected def writeDelete(batch: SpillableColumnarBatch, spec: PartitionSpec, partition: StructLike): Unit = { diff --git a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java index 9995246e0c0..5faac4505ce 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java @@ -44,7 +44,6 @@ import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PartitionUtil; -import org.apache.iceberg.util.DeleteFileSet; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; @@ -52,6 +51,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.function.Function; /** Iceberg 1.10.x shim: uses {@code SparkUtil::internalToSpark} and a cache-aware footer path. */ @@ -80,7 +80,7 @@ public boolean isPuffinFormat(FileFormat fileFormat) { public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( Table table, OutputFileFactory fileFactory, - Map rewritableDeletes) { + Map> rewritableDeletes) { return new PartitioningDVWriter<>( fileFactory, previousDeleteLoader(table, rewritableDeletes)); } @@ -97,7 +97,7 @@ public WriteResult positionDeltaWriteResult( } private Function previousDeleteLoader( - Table table, Map rewritableDeletes) { + Table table, Map> rewritableDeletes) { if (rewritableDeletes == null) { return path -> null; } @@ -106,11 +106,17 @@ private Function previousDeleteLoader( deleteFile -> EncryptingFileIO.combine(table.io(), table.encryption()) .newInputFile(deleteFile)); return path -> { - DeleteFileSet files = (DeleteFileSet) rewritableDeletes.get(path.toString()); + Set files = rewritableDeletes.get(path.toString()); return files != null ? deleteLoader.loadPositionDeletes(files, path) : null; }; } + @Override + public void setPositionDelete( + PositionDelete delete, CharSequence path, long position) { + delete.set(path, position); + } + @Override public IcebergDeletionVector readDeletionVector( DeleteFile deleteFile, RapidsInputFile inputFile, boolean validateCrc) diff --git a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java index 86254612d85..fa78748c867 100644 --- a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java @@ -44,7 +44,6 @@ import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PartitionUtil; -import org.apache.iceberg.util.DeleteFileSet; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; @@ -52,6 +51,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.function.Function; /** Iceberg 1.11.x shim: uses {@code SparkUtil::internalToSpark} and a cache-aware footer path. */ @@ -80,7 +80,7 @@ public boolean isPuffinFormat(FileFormat fileFormat) { public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( Table table, OutputFileFactory fileFactory, - Map rewritableDeletes) { + Map> rewritableDeletes) { return new PartitioningDVWriter<>( fileFactory, previousDeleteLoader(table, rewritableDeletes)); } @@ -97,7 +97,7 @@ public WriteResult positionDeltaWriteResult( } private Function previousDeleteLoader( - Table table, Map rewritableDeletes) { + Table table, Map> rewritableDeletes) { if (rewritableDeletes == null) { return path -> null; } @@ -106,11 +106,17 @@ private Function previousDeleteLoader( deleteFile -> EncryptingFileIO.combine(table.io(), table.encryption()) .newInputFile(deleteFile)); return path -> { - DeleteFileSet files = (DeleteFileSet) rewritableDeletes.get(path.toString()); + Set files = rewritableDeletes.get(path.toString()); return files != null ? deleteLoader.loadPositionDeletes(files, path) : null; }; } + @Override + public void setPositionDelete( + PositionDelete delete, CharSequence path, long position) { + delete.set(path, position); + } + @Override public IcebergDeletionVector readDeletionVector( DeleteFile deleteFile, RapidsInputFile inputFile, boolean validateCrc) diff --git a/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java b/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java index 1312b3c8ff9..b40e19e0e2c 100644 --- a/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java @@ -21,18 +21,26 @@ import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.iceberg.*; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.io.DataWriteResult; +import org.apache.iceberg.io.DeleteWriteResult; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.io.PartitioningWriter; +import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.spark.source.GpuBaseReader; import org.apache.iceberg.spark.source.GpuSparkCopyOnWriteV1Scan; import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PartitionUtil; +import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; import java.io.IOException; import java.util.Collections; import java.util.Map; +import java.util.Set; /** Iceberg 1.6.x shim: uses {@code ContentFile.path()} and {@code GpuBaseReader::convertConstant}. */ public class ShimUtilsImpl implements IcebergShimUtils { @@ -67,6 +75,37 @@ public boolean isDeletionVector(DeleteFile deleteFile) { return false; } + @Override + public boolean isPuffinFormat(FileFormat fileFormat) { + return false; + } + + @Override + public PartitioningWriter, DeleteWriteResult> + newDeletionVectorWriter( + Table table, OutputFileFactory fileFactory, + Map> rewritableDeletes) { + throw new UnsupportedOperationException( + "Iceberg 1.6 does not support Puffin deletion vectors"); + } + + @Override + public WriteResult positionDeltaWriteResult( + DataWriteResult dataResult, DeleteWriteResult deleteResult) { + return WriteResult.builder() + .addDataFiles(dataResult.dataFiles()) + .addDeleteFiles(deleteResult.deleteFiles()) + .addReferencedDataFiles(deleteResult.referencedDataFiles()) + .build(); + } + + @Override + @SuppressWarnings("deprecation") + public void setPositionDelete( + PositionDelete delete, CharSequence path, long position) { + delete.set(path, position, null); + } + @Override public IcebergDeletionVector readDeletionVector( DeleteFile deleteFile, RapidsInputFile inputFile, boolean validateCrc) diff --git a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java index 0ecf728935d..b5d87d17369 100644 --- a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java @@ -33,7 +33,6 @@ import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PartitionUtil; -import org.apache.iceberg.util.DeleteFileSet; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; @@ -41,6 +40,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.function.Function; /** Iceberg 1.9.x shim: uses {@code SparkUtil::internalToSpark}. */ @@ -69,7 +69,7 @@ public boolean isPuffinFormat(FileFormat fileFormat) { public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( Table table, OutputFileFactory fileFactory, - Map rewritableDeletes) { + Map> rewritableDeletes) { return new PartitioningDVWriter<>( fileFactory, previousDeleteLoader(table, rewritableDeletes)); } @@ -86,7 +86,7 @@ public WriteResult positionDeltaWriteResult( } private Function previousDeleteLoader( - Table table, Map rewritableDeletes) { + Table table, Map> rewritableDeletes) { if (rewritableDeletes == null) { return path -> null; } @@ -95,11 +95,17 @@ private Function previousDeleteLoader( deleteFile -> EncryptingFileIO.combine(table.io(), table.encryption()) .newInputFile(deleteFile)); return path -> { - DeleteFileSet files = (DeleteFileSet) rewritableDeletes.get(path.toString()); + Set files = rewritableDeletes.get(path.toString()); return files != null ? deleteLoader.loadPositionDeletes(files, path) : null; }; } + @Override + public void setPositionDelete( + PositionDelete delete, CharSequence path, long position) { + delete.set(path, position); + } + @Override public IcebergDeletionVector readDeletionVector( DeleteFile deleteFile, RapidsInputFile inputFile, boolean validateCrc) diff --git a/integration_tests/src/main/python/iceberg/iceberg_delete_test.py b/integration_tests/src/main/python/iceberg/iceberg_delete_test.py index 8df3add9b56..cd4737b16a1 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_delete_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_delete_test.py @@ -166,6 +166,98 @@ def delete_data(spark, table_name): conf=iceberg_delete_cow_enabled_conf) +def _assert_v3_deletion_vectors(spark, table_name, expected_positions): + delete_files = spark.sql(f""" + SELECT content, file_format, record_count, referenced_data_file, + content_offset, content_size_in_bytes + FROM {table_name}.delete_files + WHERE content = 1 + """).collect() + assert delete_files, "Expected at least one positional delete file" + assert all(row.file_format == 'PUFFIN' for row in delete_files), \ + f"Expected only Puffin deletion vectors, found {delete_files}" + referenced_files = [row.referenced_data_file for row in delete_files] + assert all(path is not None for path in referenced_files), \ + f"Expected every deletion vector to reference a data file, found {delete_files}" + assert len(referenced_files) == len(set(referenced_files)), \ + f"Expected at most one deletion vector per data file, found {delete_files}" + assert all(row.content_offset is not None and row.content_offset >= 0 and + row.content_size_in_bytes is not None and row.content_size_in_bytes > 0 + for row in delete_files), \ + f"Expected valid Puffin ranges, found {delete_files}" + actual_positions = sum(row.record_count for row in delete_files) + assert actual_positions == expected_positions, \ + f"Expected {expected_positions} deleted positions, found {actual_positions}" + + +@iceberg +@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) +@pytest.mark.parametrize('fanout_enabled', [False, True], ids=['clustered', 'fanout']) +def test_iceberg_delete_v3_gpu_writes_and_merges_deletion_vectors( + spark_tmp_table_factory, fanout_enabled): + table_name = get_full_table_name(spark_tmp_table_factory) + + def setup_table(spark): + spark.sql(f""" + CREATE TABLE {table_name} (id BIGINT) USING ICEBERG + PARTITIONED BY (bucket(2, id)) + TBLPROPERTIES ( + 'format-version' = '3', + 'write.delete.mode' = 'merge-on-read', + 'write.spark.fanout.enabled' = '{str(fanout_enabled).lower()}') + """) + spark.sql(f"INSERT INTO {table_name} SELECT id FROM range(128)") + + with_cpu_session(setup_table) + write_conf = dict(iceberg_write_enabled_conf) + write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' + + # The second delete must merge with the first DV rather than add another DV for a data file. + with_gpu_session( + lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 3 = 0").collect(), + conf=write_conf) + with_gpu_session( + lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0").collect(), + conf=write_conf) + + with_cpu_session(lambda spark: _assert_v3_deletion_vectors(spark, table_name, 60)) + actual = with_cpu_session( + lambda spark: [row.id for row in spark.table(table_name).collect()]) + expected = [value for value in range(128) if value % 3 != 0 and value % 5 != 0] + assert sorted(actual) == expected + + +@iceberg +@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) +def test_iceberg_delete_v3_gpu_upgrades_position_deletes(spark_tmp_table_factory): + table_name = get_full_table_name(spark_tmp_table_factory) + + def setup_table(spark): + spark.sql(f""" + CREATE TABLE {table_name} (id BIGINT) USING ICEBERG + TBLPROPERTIES ( + 'format-version' = '2', + 'write.delete.mode' = 'merge-on-read') + """) + spark.sql(f"INSERT INTO {table_name} SELECT id FROM range(64)") + spark.sql(f"DELETE FROM {table_name} WHERE id % 4 = 0") + spark.sql( + f"ALTER TABLE {table_name} SET TBLPROPERTIES ('format-version' = '3')") + + with_cpu_session(setup_table) + write_conf = dict(iceberg_write_enabled_conf) + write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' + with_gpu_session( + lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0").collect(), + conf=write_conf) + + with_cpu_session(lambda spark: _assert_v3_deletion_vectors(spark, table_name, 25)) + actual = with_cpu_session( + lambda spark: [row.id for row in spark.table(table_name).collect()]) + expected = [value for value in range(64) if value % 4 != 0 and value % 5 != 0] + assert sorted(actual) == expected + + def _do_test_iceberg_delete_partitioned_table(spark_tmp_table_factory, partition_col_sql, delete_mode, table_properties=None): """Helper function for partitioned table DELETE tests.""" do_delete_test( diff --git a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py index 9483c87d93b..0412fc76a9c 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_merge_on_read_test.py @@ -25,7 +25,7 @@ representative_eq_column_combinations, eq_reader_canary_pairs, \ iceberg_unsupported_mark, create_iceberg_table, \ iceberg_base_table_cols, iceberg_gens_list, get_full_table_name, \ - iceberg_write_enabled_conf, supports_iceberg_v3, ICEBERG_V3_UNSUPPORTED_REASON + supports_iceberg_v3, ICEBERG_V3_UNSUPPORTED_REASON from data_gen import disable_parquet_field_id_write, gen_df, get_datagen_seed, int_gen, \ long_gen, string_gen from marks import iceberg, ignore_order, validate_execs_in_gpu_plan @@ -212,98 +212,6 @@ def add_deletion_vector(spark): is_cpu_first=False) -def _assert_v3_deletion_vectors(spark, table_name, expected_positions): - delete_files = spark.sql(f""" - SELECT content, file_format, record_count, referenced_data_file, - content_offset, content_size_in_bytes - FROM {table_name}.delete_files - WHERE content = 1 - """).collect() - assert delete_files, "Expected at least one positional delete file" - assert all(row.file_format == 'PUFFIN' for row in delete_files), \ - f"Expected only Puffin deletion vectors, found {delete_files}" - referenced_files = [row.referenced_data_file for row in delete_files] - assert all(path is not None for path in referenced_files), \ - f"Expected every deletion vector to reference a data file, found {delete_files}" - assert len(referenced_files) == len(set(referenced_files)), \ - f"Expected at most one deletion vector per data file, found {delete_files}" - assert all(row.content_offset is not None and row.content_offset >= 0 and - row.content_size_in_bytes is not None and row.content_size_in_bytes > 0 - for row in delete_files), \ - f"Expected valid Puffin ranges, found {delete_files}" - actual_positions = sum(row.record_count for row in delete_files) - assert actual_positions == expected_positions, \ - f"Expected {expected_positions} deleted positions, found {actual_positions}" - - -@iceberg -@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) -@pytest.mark.parametrize('fanout_enabled', [False, True], ids=['clustered', 'fanout']) -def test_iceberg_v3_gpu_write_and_merge_deletion_vectors( - spark_tmp_table_factory, fanout_enabled): - table_name = get_full_table_name(spark_tmp_table_factory) - - def setup_table(spark): - spark.sql(f""" - CREATE TABLE {table_name} (id BIGINT) USING ICEBERG - PARTITIONED BY (bucket(2, id)) - TBLPROPERTIES ( - 'format-version' = '3', - 'write.delete.mode' = 'merge-on-read', - 'write.spark.fanout.enabled' = '{str(fanout_enabled).lower()}') - """) - spark.sql(f"INSERT INTO {table_name} SELECT id FROM range(128)") - - with_cpu_session(setup_table) - write_conf = dict(iceberg_write_enabled_conf) - write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' - - # The second delete must merge with the first DV rather than add another DV for a data file. - with_gpu_session( - lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 3 = 0").collect(), - conf=write_conf) - with_gpu_session( - lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0").collect(), - conf=write_conf) - - with_cpu_session(lambda spark: _assert_v3_deletion_vectors(spark, table_name, 60)) - actual = with_cpu_session( - lambda spark: [row.id for row in spark.table(table_name).collect()]) - expected = [value for value in range(128) if value % 3 != 0 and value % 5 != 0] - assert sorted(actual) == expected - - -@iceberg -@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) -def test_iceberg_v3_gpu_write_upgrades_position_deletes(spark_tmp_table_factory): - table_name = get_full_table_name(spark_tmp_table_factory) - - def setup_table(spark): - spark.sql(f""" - CREATE TABLE {table_name} (id BIGINT) USING ICEBERG - TBLPROPERTIES ( - 'format-version' = '2', - 'write.delete.mode' = 'merge-on-read') - """) - spark.sql(f"INSERT INTO {table_name} SELECT id FROM range(64)") - spark.sql(f"DELETE FROM {table_name} WHERE id % 4 = 0") - spark.sql( - f"ALTER TABLE {table_name} SET TBLPROPERTIES ('format-version' = '3')") - - with_cpu_session(setup_table) - write_conf = dict(iceberg_write_enabled_conf) - write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' - with_gpu_session( - lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0").collect(), - conf=write_conf) - - with_cpu_session(lambda spark: _assert_v3_deletion_vectors(spark, table_name, 25)) - actual = with_cpu_session( - lambda spark: [row.id for row in spark.table(table_name).collect()]) - expected = [value for value in range(64) if value % 4 != 0 and value % 5 != 0] - assert sorted(actual) == expected - - @iceberg @pytest.mark.parametrize('reader_type', ['PERFILE', 'MULTITHREADED']) @pytest.mark.skipif(is_iceberg_remote_catalog(), reason="add_files requires a local catalog") From 35c576c12a7d9d5bd72e6ea1788dc65ff1561691 Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Thu, 27 Aug 2026 16:29:06 +0800 Subject: [PATCH 13/18] Address Iceberg deletion vector review feedback Signed-off-by: Ray Liu --- .../rapids/iceberg/IcebergShimUtils.java | 8 ++- .../spark/rapids/iceberg/ShimUtils.java | 9 ++- .../spark/source/GpuSparkWriteAccess.java | 44 ------------ .../source/GpuSparkPositionDeltaWrite.scala | 2 +- .../iceberg/iceberg110x/ShimUtilsImpl.java | 10 +++ .../GpuSparkPositionDeltaWriteAccess.java | 68 +++++++++++++++++++ .../iceberg/iceberg111x/ShimUtilsImpl.java | 10 +++ .../GpuSparkPositionDeltaWriteAccess.java | 68 +++++++++++++++++++ .../iceberg/iceberg16x/ShimUtilsImpl.java | 9 +++ .../iceberg/iceberg19x/ShimUtilsImpl.java | 10 +++ .../GpuSparkPositionDeltaWriteAccess.java | 68 +++++++++++++++++++ .../src/main/python/iceberg/__init__.py | 25 +++++++ .../python/iceberg/iceberg_delete_test.py | 33 ++------- .../main/python/iceberg/iceberg_merge_test.py | 47 ++++++++++++- .../python/iceberg/iceberg_update_test.py | 34 +++++++++- 15 files changed, 369 insertions(+), 76 deletions(-) create mode 100644 iceberg/iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java create mode 100644 iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java create mode 100644 iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java index 68adbeb20af..a675320a20e 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java @@ -39,8 +39,10 @@ import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.source.GpuSparkScan; -import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.write.DeltaBatchWrite; import scala.Option; import java.io.IOException; @@ -73,6 +75,10 @@ public interface IcebergShimUtils { /** Returns whether a resolved delete-file format writes Puffin deletion vectors. */ boolean isPuffinFormat(FileFormat fileFormat); + /** Returns delete files that Iceberg requires a deletion-vector write to replace. */ + Broadcast>> broadcastRewritableDeletes( + DeltaBatchWrite write); + /** * Creates Iceberg's version-specific deletion-vector writer. * diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java index 7dcd64feca7..c6e0520d98a 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java @@ -39,8 +39,10 @@ import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.source.GpuSparkScan; -import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.write.DeltaBatchWrite; import java.io.IOException; import java.util.Map; @@ -81,6 +83,11 @@ public static boolean isPuffinFormat(FileFormat fileFormat) { return IMPL.isPuffinFormat(fileFormat); } + public static Broadcast>> broadcastRewritableDeletes( + DeltaBatchWrite write) { + return IMPL.broadcastRewritableDeletes(write); + } + public static PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( Table table, OutputFileFactory fileFactory, diff --git a/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java b/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java index 8a3918e1a49..91ed87280da 100644 --- a/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java +++ b/iceberg/common/src/main/java/org/apache/iceberg/spark/source/GpuSparkWriteAccess.java @@ -17,12 +17,9 @@ package org.apache.iceberg.spark.source; import java.lang.reflect.Field; -import java.lang.reflect.Method; import java.util.Map; -import java.util.Set; import org.apache.iceberg.DataFile; -import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.Schema; import org.apache.iceberg.Table; @@ -30,8 +27,6 @@ import org.apache.iceberg.io.DeleteWriteResult; import org.apache.iceberg.io.WriteResult; import org.apache.spark.api.java.JavaSparkContext; -import org.apache.spark.broadcast.Broadcast; -import org.apache.spark.sql.connector.write.DeltaBatchWrite; import org.apache.spark.sql.connector.write.DeltaWrite; import org.apache.spark.sql.connector.write.RowLevelOperation.Command; import org.apache.spark.sql.connector.write.Write; @@ -46,16 +41,6 @@ * class loader as Iceberg itself. */ public final class GpuSparkWriteAccess { - private static final ClassValue BROADCAST_REWRITABLE_DELETES_METHOD = - new ClassValue() { - @Override - protected Method computeValue(Class type) { - Method method = findMethod(type, "broadcastRewritableDeletes"); - method.setAccessible(true); - return method; - } - }; - private GpuSparkWriteAccess() { } @@ -130,22 +115,6 @@ public static Object context(DeltaWrite write) { return readField(positionDeltaWrite(write), "context", Object.class); } - /** - * Calls Iceberg's private - * {@code SparkPositionDeltaWrite.PositionDeltaBatchWrite.broadcastRewritableDeletes()} method. - */ - @SuppressWarnings("unchecked") - public static Broadcast>> broadcastRewritableDeletes( - DeltaBatchWrite write) { - try { - Method method = BROADCAST_REWRITABLE_DELETES_METHOD.get(write.getClass()); - return (Broadcast>>) method.invoke(write); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException( - "Unable to broadcast rewritable deletes from " + write.getClass().getName(), e); - } - } - public static Schema contextDataSchema(Object context) { return readField(context, "dataSchema", Schema.class); } @@ -239,17 +208,4 @@ private static Field findField(Class targetClass, String fieldName) { throw new IllegalStateException("No field " + fieldName + " in " + targetClass.getName()); } - private static Method findMethod( - Class targetClass, String methodName, Class... parameterTypes) { - Class current = targetClass; - while (current != null) { - try { - return current.getDeclaredMethod(methodName, parameterTypes); - } catch (NoSuchMethodException e) { - current = current.getSuperclass(); - } - } - throw new IllegalStateException("No method " + methodName + " in " + targetClass.getName()); - } - } diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala index e9753539f7f..e6aff355616 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala @@ -93,7 +93,7 @@ class GpuSparkPositionDeltaWrite(cpu: DeltaWrite) val command = GpuSparkWriteAccess.command(cpu) val context = GpuWriteContext(GpuSparkWriteAccess.context(cpu)) val rewritableDeletes = if (context.useDVs) { - Option(GpuSparkWriteAccess.broadcastRewritableDeletes(cpuBatchWrite)) + Option(ShimUtils.broadcastRewritableDeletes(cpuBatchWrite)) } else { None } diff --git a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java index 5faac4505ce..15cd849c1cd 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java @@ -41,11 +41,14 @@ import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.SparkUtil; import org.apache.iceberg.spark.source.GpuSparkCopyOnWriteV1Scan; +import org.apache.iceberg.spark.source.GpuSparkPositionDeltaWriteAccess; import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PartitionUtil; +import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.write.DeltaBatchWrite; import java.io.IOException; import java.util.Collections; @@ -76,6 +79,13 @@ public boolean isPuffinFormat(FileFormat fileFormat) { return fileFormat == FileFormat.PUFFIN; } + @Override + @SuppressWarnings({"rawtypes", "unchecked"}) + public Broadcast>> broadcastRewritableDeletes( + DeltaBatchWrite write) { + return (Broadcast) GpuSparkPositionDeltaWriteAccess.broadcastRewritableDeletes(write); + } + @Override public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( diff --git a/iceberg/iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java b/iceberg/iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java new file mode 100644 index 00000000000..6cc7d5f037f --- /dev/null +++ b/iceberg/iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * Licensed 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.iceberg.spark.source; + +import java.lang.reflect.Method; +import java.util.Map; + +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.connector.write.DeltaBatchWrite; + +/** Iceberg-version-specific access to position-delta batch-write internals. */ +public final class GpuSparkPositionDeltaWriteAccess { + private static final ClassValue BROADCAST_REWRITABLE_DELETES_METHOD = + new ClassValue() { + @Override + protected Method computeValue(Class type) { + Method method = findMethod(type, "broadcastRewritableDeletes"); + method.setAccessible(true); + return method; + } + }; + + private GpuSparkPositionDeltaWriteAccess() { + } + + /** + * Calls + * {@code SparkPositionDeltaWrite.PositionDeltaBatchWrite.broadcastRewritableDeletes()}. + */ + @SuppressWarnings("unchecked") + public static Broadcast> broadcastRewritableDeletes( + DeltaBatchWrite write) { + try { + Method method = BROADCAST_REWRITABLE_DELETES_METHOD.get(write.getClass()); + return (Broadcast>) method.invoke(write); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Unable to broadcast rewritable deletes from " + write.getClass().getName(), e); + } + } + + private static Method findMethod(Class targetClass, String methodName) { + Class current = targetClass; + while (current != null) { + try { + return current.getDeclaredMethod(methodName); + } catch (NoSuchMethodException e) { + current = current.getSuperclass(); + } + } + throw new IllegalStateException("No method " + methodName + " in " + targetClass.getName()); + } +} diff --git a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java index fa78748c867..60ddad247ae 100644 --- a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java @@ -41,11 +41,14 @@ import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.SparkUtil; import org.apache.iceberg.spark.source.GpuSparkCopyOnWriteScan; +import org.apache.iceberg.spark.source.GpuSparkPositionDeltaWriteAccess; import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PartitionUtil; +import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.write.DeltaBatchWrite; import java.io.IOException; import java.util.Collections; @@ -76,6 +79,13 @@ public boolean isPuffinFormat(FileFormat fileFormat) { return fileFormat == FileFormat.PUFFIN; } + @Override + @SuppressWarnings({"rawtypes", "unchecked"}) + public Broadcast>> broadcastRewritableDeletes( + DeltaBatchWrite write) { + return (Broadcast) GpuSparkPositionDeltaWriteAccess.broadcastRewritableDeletes(write); + } + @Override public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( diff --git a/iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java b/iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java new file mode 100644 index 00000000000..6cc7d5f037f --- /dev/null +++ b/iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * Licensed 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.iceberg.spark.source; + +import java.lang.reflect.Method; +import java.util.Map; + +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.connector.write.DeltaBatchWrite; + +/** Iceberg-version-specific access to position-delta batch-write internals. */ +public final class GpuSparkPositionDeltaWriteAccess { + private static final ClassValue BROADCAST_REWRITABLE_DELETES_METHOD = + new ClassValue() { + @Override + protected Method computeValue(Class type) { + Method method = findMethod(type, "broadcastRewritableDeletes"); + method.setAccessible(true); + return method; + } + }; + + private GpuSparkPositionDeltaWriteAccess() { + } + + /** + * Calls + * {@code SparkPositionDeltaWrite.PositionDeltaBatchWrite.broadcastRewritableDeletes()}. + */ + @SuppressWarnings("unchecked") + public static Broadcast> broadcastRewritableDeletes( + DeltaBatchWrite write) { + try { + Method method = BROADCAST_REWRITABLE_DELETES_METHOD.get(write.getClass()); + return (Broadcast>) method.invoke(write); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Unable to broadcast rewritable deletes from " + write.getClass().getName(), e); + } + } + + private static Method findMethod(Class targetClass, String methodName) { + Class current = targetClass; + while (current != null) { + try { + return current.getDeclaredMethod(methodName); + } catch (NoSuchMethodException e) { + current = current.getSuperclass(); + } + } + throw new IllegalStateException("No method " + methodName + " in " + targetClass.getName()); + } +} diff --git a/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java b/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java index b40e19e0e2c..a5d2e3714ab 100644 --- a/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java @@ -34,8 +34,10 @@ import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PartitionUtil; +import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.write.DeltaBatchWrite; import java.io.IOException; import java.util.Collections; @@ -80,6 +82,13 @@ public boolean isPuffinFormat(FileFormat fileFormat) { return false; } + @Override + public Broadcast>> broadcastRewritableDeletes( + DeltaBatchWrite write) { + throw new UnsupportedOperationException( + "Iceberg 1.6 does not support Puffin deletion vectors"); + } + @Override public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( diff --git a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java index b5d87d17369..0fbc55aab41 100644 --- a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java @@ -30,11 +30,14 @@ import org.apache.iceberg.io.SupportsStorageCredentials; import org.apache.iceberg.spark.SparkUtil; import org.apache.iceberg.spark.source.GpuSparkCopyOnWriteV1Scan; +import org.apache.iceberg.spark.source.GpuSparkPositionDeltaWriteAccess; import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PartitionUtil; +import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.write.DeltaBatchWrite; import java.io.IOException; import java.util.Collections; @@ -65,6 +68,13 @@ public boolean isPuffinFormat(FileFormat fileFormat) { return fileFormat == FileFormat.PUFFIN; } + @Override + @SuppressWarnings({"rawtypes", "unchecked"}) + public Broadcast>> broadcastRewritableDeletes( + DeltaBatchWrite write) { + return (Broadcast) GpuSparkPositionDeltaWriteAccess.broadcastRewritableDeletes(write); + } + @Override public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( diff --git a/iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java b/iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java new file mode 100644 index 00000000000..6cc7d5f037f --- /dev/null +++ b/iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * Licensed 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.iceberg.spark.source; + +import java.lang.reflect.Method; +import java.util.Map; + +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.connector.write.DeltaBatchWrite; + +/** Iceberg-version-specific access to position-delta batch-write internals. */ +public final class GpuSparkPositionDeltaWriteAccess { + private static final ClassValue BROADCAST_REWRITABLE_DELETES_METHOD = + new ClassValue() { + @Override + protected Method computeValue(Class type) { + Method method = findMethod(type, "broadcastRewritableDeletes"); + method.setAccessible(true); + return method; + } + }; + + private GpuSparkPositionDeltaWriteAccess() { + } + + /** + * Calls + * {@code SparkPositionDeltaWrite.PositionDeltaBatchWrite.broadcastRewritableDeletes()}. + */ + @SuppressWarnings("unchecked") + public static Broadcast> broadcastRewritableDeletes( + DeltaBatchWrite write) { + try { + Method method = BROADCAST_REWRITABLE_DELETES_METHOD.get(write.getClass()); + return (Broadcast>) method.invoke(write); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Unable to broadcast rewritable deletes from " + write.getClass().getName(), e); + } + } + + private static Method findMethod(Class targetClass, String methodName) { + Class current = targetClass; + while (current != null) { + try { + return current.getDeclaredMethod(methodName); + } catch (NoSuchMethodException e) { + current = current.getSuperclass(); + } + } + throw new IllegalStateException("No method " + methodName + " in " + targetClass.getName()); + } +} diff --git a/integration_tests/src/main/python/iceberg/__init__.py b/integration_tests/src/main/python/iceberg/__init__.py index 38891805531..b3fa2bdc191 100644 --- a/integration_tests/src/main/python/iceberg/__init__.py +++ b/integration_tests/src/main/python/iceberg/__init__.py @@ -40,6 +40,31 @@ ICEBERG_V3_UNSUPPORTED_REASON = ( "Iceberg v3 requires Iceberg 1.9.0 or later and a catalog backend with v3 support") + +def assert_iceberg_v3_deletion_vectors(spark, table_name, expected_positions): + delete_files = spark.sql(f""" + SELECT content, file_format, record_count, referenced_data_file, + content_offset, content_size_in_bytes + FROM {table_name}.delete_files + WHERE content = 1 + """).collect() + assert delete_files, "Expected at least one positional delete file" + assert all(row.file_format == 'PUFFIN' for row in delete_files), \ + f"Expected only Puffin deletion vectors, found {delete_files}" + referenced_files = [row.referenced_data_file for row in delete_files] + assert all(path is not None for path in referenced_files), \ + f"Expected every deletion vector to reference a data file, found {delete_files}" + assert len(referenced_files) == len(set(referenced_files)), \ + f"Expected at most one deletion vector per data file, found {delete_files}" + assert all(row.content_offset is not None and row.content_offset >= 0 and + row.content_size_in_bytes is not None and row.content_size_in_bytes > 0 + for row in delete_files), \ + f"Expected valid Puffin ranges, found {delete_files}" + actual_positions = sum(row.record_count for row in delete_files) + assert actual_positions == expected_positions, \ + f"Expected {expected_positions} deleted positions, found {actual_positions}" + + # iceberg supported types iceberg_table_gen = MappingProxyType({ '_c0': byte_gen, '_c1': short_gen, '_c2': int_gen, diff --git a/integration_tests/src/main/python/iceberg/iceberg_delete_test.py b/integration_tests/src/main/python/iceberg/iceberg_delete_test.py index cd4737b16a1..4609de5c253 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_delete_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_delete_test.py @@ -21,7 +21,8 @@ iceberg_base_table_cols, iceberg_gens_list, iceberg_nested_write_gens_list, iceberg_unsupported_mark, delete_partition_transforms_distributed, _build_tblprops, assert_iceberg_files_use_codec, - supports_iceberg_v3, ICEBERG_V3_UNSUPPORTED_REASON) + assert_iceberg_v3_deletion_vectors, supports_iceberg_v3, + ICEBERG_V3_UNSUPPORTED_REASON) from marks import allow_non_gpu, allow_non_gpu_conditional, iceberg, ignore_order, datagen_overrides from spark_session import is_spark_35x, is_spark_400_or_later, with_cpu_session, with_gpu_session @@ -166,30 +167,6 @@ def delete_data(spark, table_name): conf=iceberg_delete_cow_enabled_conf) -def _assert_v3_deletion_vectors(spark, table_name, expected_positions): - delete_files = spark.sql(f""" - SELECT content, file_format, record_count, referenced_data_file, - content_offset, content_size_in_bytes - FROM {table_name}.delete_files - WHERE content = 1 - """).collect() - assert delete_files, "Expected at least one positional delete file" - assert all(row.file_format == 'PUFFIN' for row in delete_files), \ - f"Expected only Puffin deletion vectors, found {delete_files}" - referenced_files = [row.referenced_data_file for row in delete_files] - assert all(path is not None for path in referenced_files), \ - f"Expected every deletion vector to reference a data file, found {delete_files}" - assert len(referenced_files) == len(set(referenced_files)), \ - f"Expected at most one deletion vector per data file, found {delete_files}" - assert all(row.content_offset is not None and row.content_offset >= 0 and - row.content_size_in_bytes is not None and row.content_size_in_bytes > 0 - for row in delete_files), \ - f"Expected valid Puffin ranges, found {delete_files}" - actual_positions = sum(row.record_count for row in delete_files) - assert actual_positions == expected_positions, \ - f"Expected {expected_positions} deleted positions, found {actual_positions}" - - @iceberg @pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) @pytest.mark.parametrize('fanout_enabled', [False, True], ids=['clustered', 'fanout']) @@ -220,7 +197,8 @@ def setup_table(spark): lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0").collect(), conf=write_conf) - with_cpu_session(lambda spark: _assert_v3_deletion_vectors(spark, table_name, 60)) + with_cpu_session( + lambda spark: assert_iceberg_v3_deletion_vectors(spark, table_name, 60)) actual = with_cpu_session( lambda spark: [row.id for row in spark.table(table_name).collect()]) expected = [value for value in range(128) if value % 3 != 0 and value % 5 != 0] @@ -251,7 +229,8 @@ def setup_table(spark): lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0").collect(), conf=write_conf) - with_cpu_session(lambda spark: _assert_v3_deletion_vectors(spark, table_name, 25)) + with_cpu_session( + lambda spark: assert_iceberg_v3_deletion_vectors(spark, table_name, 25)) actual = with_cpu_session( lambda spark: [row.id for row in spark.table(table_name).collect()]) expected = [value for value in range(64) if value % 4 != 0 and value % 5 != 0] diff --git a/integration_tests/src/main/python/iceberg/iceberg_merge_test.py b/integration_tests/src/main/python/iceberg/iceberg_merge_test.py index b8e7932b11b..f69abcc0279 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_merge_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_merge_test.py @@ -20,7 +20,8 @@ from iceberg import (create_iceberg_table, get_full_table_name, iceberg_write_enabled_conf, iceberg_base_table_cols, iceberg_gens_list, iceberg_nested_write_gens_list, iceberg_unsupported_mark, merge_partition_transforms_distributed, - supports_iceberg_v3, ICEBERG_V3_UNSUPPORTED_REASON) + assert_iceberg_v3_deletion_vectors, supports_iceberg_v3, + ICEBERG_V3_UNSUPPORTED_REASON) from marks import allow_non_gpu, allow_non_gpu_conditional, iceberg, ignore_order, datagen_overrides from spark_session import is_spark_400_or_later, with_gpu_session, with_cpu_session @@ -223,6 +224,50 @@ def merge_data(spark, target_table): conf=iceberg_merge_enabled_conf) +@iceberg +@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) +def test_iceberg_merge_v3_gpu_writes_deletion_vectors(spark_tmp_table_factory): + table_name = get_full_table_name(spark_tmp_table_factory) + source_table = f"{table_name}_source" + + def setup_tables(spark): + spark.sql(f""" + CREATE TABLE {table_name} (id BIGINT, value BIGINT) USING ICEBERG + TBLPROPERTIES ( + 'format-version' = '3', + 'write.merge.mode' = 'merge-on-read') + """) + spark.sql(f"INSERT INTO {table_name} SELECT id, id FROM range(64)") + spark.sql(f""" + CREATE TABLE {source_table} (id BIGINT, value BIGINT) USING ICEBERG + TBLPROPERTIES ('format-version' = '2') + """) + spark.sql(f""" + INSERT INTO {source_table} + SELECT id, id + 1000 FROM range(64) WHERE id % 4 = 0 + """) + + with_cpu_session(setup_tables) + write_conf = dict(iceberg_write_enabled_conf) + write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' + with_gpu_session( + lambda spark: spark.sql(f""" + MERGE INTO {table_name} t + USING {source_table} s + ON t.id = s.id + WHEN MATCHED THEN UPDATE SET value = s.value + """).collect(), + conf=write_conf) + + with_cpu_session( + lambda spark: assert_iceberg_v3_deletion_vectors(spark, table_name, 16)) + actual = with_cpu_session( + lambda spark: [(row.id, row.value) for row in spark.table(table_name).collect()]) + expected = [(value, value + 1000 if value % 4 == 0 else value) + for value in range(64)] + assert sorted(actual) == expected + + @allow_non_gpu("MergeRows$Keep", "MergeRows$Discard", "MergeRows$Split") @iceberg @datagen_overrides(seed=0, reason='https://github.com/NVIDIA/spark-rapids-jni/issues/4016') diff --git a/integration_tests/src/main/python/iceberg/iceberg_update_test.py b/integration_tests/src/main/python/iceberg/iceberg_update_test.py index 9175426bce7..3d7b8a0281b 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_update_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_update_test.py @@ -20,7 +20,8 @@ from iceberg import (create_iceberg_table, get_full_table_name, iceberg_write_enabled_conf, iceberg_base_table_cols, iceberg_gens_list, iceberg_nested_write_gens_list, iceberg_unsupported_mark, update_partition_transforms_distributed, - supports_iceberg_v3, ICEBERG_V3_UNSUPPORTED_REASON) + assert_iceberg_v3_deletion_vectors, supports_iceberg_v3, + ICEBERG_V3_UNSUPPORTED_REASON) from marks import allow_non_gpu, allow_non_gpu_conditional, disable_ansi_mode, iceberg, ignore_order, datagen_overrides from spark_session import is_spark_400_or_later, with_cpu_session, with_gpu_session @@ -158,6 +159,37 @@ def update_data(spark, table_name): conf=iceberg_update_cow_enabled_conf) +@iceberg +@pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) +def test_iceberg_update_v3_gpu_writes_deletion_vectors(spark_tmp_table_factory): + table_name = get_full_table_name(spark_tmp_table_factory) + + def setup_table(spark): + spark.sql(f""" + CREATE TABLE {table_name} (id BIGINT, value BIGINT) USING ICEBERG + TBLPROPERTIES ( + 'format-version' = '3', + 'write.update.mode' = 'merge-on-read') + """) + spark.sql(f"INSERT INTO {table_name} SELECT id, id FROM range(64)") + + with_cpu_session(setup_table) + write_conf = dict(iceberg_write_enabled_conf) + write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' + with_gpu_session( + lambda spark: spark.sql( + f"UPDATE {table_name} SET value = value + 1000 WHERE id % 4 = 0").collect(), + conf=write_conf) + + with_cpu_session( + lambda spark: assert_iceberg_v3_deletion_vectors(spark, table_name, 16)) + actual = with_cpu_session( + lambda spark: [(row.id, row.value) for row in spark.table(table_name).collect()]) + expected = [(value, value + 1000 if value % 4 == 0 else value) + for value in range(64)] + assert sorted(actual) == expected + + @iceberg @ignore_order(local=True) @pytest.mark.datagen_overrides(seed=UPDATE_TEST_SEED, reason=UPDATE_TEST_SEED_OVERRIDE_REASON) From 714fd4a909716f60bb24c323a74e6f6c9591aeaa Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Thu, 27 Aug 2026 17:48:41 +0800 Subject: [PATCH 14/18] Address deletion vector shim review feedback Signed-off-by: Ray Liu --- .../rapids/iceberg/IcebergShimUtils.java | 19 +++++++----- .../spark/rapids/iceberg/ShimUtils.java | 6 ++-- .../source/GpuSparkPositionDeltaWrite.scala | 22 +++++-------- .../iceberg/iceberg110x/ShimUtilsImpl.java | 31 ++++++++++++++----- .../GpuSparkPositionDeltaWriteAccess.java | 4 +-- .../iceberg/iceberg111x/ShimUtilsImpl.java | 31 ++++++++++++++----- .../GpuSparkPositionDeltaWriteAccess.java | 4 +-- .../iceberg/iceberg16x/ShimUtilsImpl.java | 6 ++-- .../iceberg/iceberg19x/ShimUtilsImpl.java | 31 ++++++++++++++----- .../GpuSparkPositionDeltaWriteAccess.java | 4 +-- 10 files changed, 101 insertions(+), 57 deletions(-) rename iceberg/{iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source => iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/iceberg110x}/GpuSparkPositionDeltaWriteAccess.java (94%) rename iceberg/{iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source => iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/iceberg111x}/GpuSparkPositionDeltaWriteAccess.java (94%) rename iceberg/{iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source => iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/iceberg19x}/GpuSparkPositionDeltaWriteAccess.java (94%) diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java index a675320a20e..26c67578179 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java @@ -39,15 +39,14 @@ import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.source.GpuSparkScan; -import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; import org.apache.spark.sql.connector.write.DeltaBatchWrite; import scala.Option; import java.io.IOException; +import java.io.Serializable; import java.util.Map; -import java.util.Set; /** * Version-specific Iceberg API adapter. @@ -76,19 +75,25 @@ public interface IcebergShimUtils { boolean isPuffinFormat(FileFormat fileFormat); /** Returns delete files that Iceberg requires a deletion-vector write to replace. */ - Broadcast>> broadcastRewritableDeletes( + RewritableDeletes broadcastRewritableDeletes( DeltaBatchWrite write); /** - * Creates Iceberg's version-specific deletion-vector writer. + * Opaque, serializable handle for version-specific rewritable-delete state. * - *

Iceberg 1.9+ supplies {@code DeleteFileSet} values, exposed through the stable - * {@link Set} API because Iceberg 1.6 does not contain {@code DeleteFileSet}. + *

The underlying broadcast value uses Iceberg's {@code DeleteFileSet}, which is absent + * from Iceberg 1.6. Each supported shim supplies a concrete implementation so the common + * module does not expose an API that is unavailable in older Iceberg versions. + */ + interface RewritableDeletes extends Serializable {} + + /** + * Creates Iceberg's version-specific deletion-vector writer. */ PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( Table table, OutputFileFactory fileFactory, - Map> rewritableDeletes); + RewritableDeletes rewritableDeletes); /** Combines data and delete results, including rewritten deletes when supported. */ WriteResult positionDeltaWriteResult( diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java index c6e0520d98a..3a7268c245c 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/ShimUtils.java @@ -39,14 +39,12 @@ import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.source.GpuSparkScan; -import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; import org.apache.spark.sql.connector.write.DeltaBatchWrite; import java.io.IOException; import java.util.Map; -import java.util.Set; /** * Dispatches to the correct version-specific Iceberg shim utilities based on the @@ -83,7 +81,7 @@ public static boolean isPuffinFormat(FileFormat fileFormat) { return IMPL.isPuffinFormat(fileFormat); } - public static Broadcast>> broadcastRewritableDeletes( + public static IcebergShimUtils.RewritableDeletes broadcastRewritableDeletes( DeltaBatchWrite write) { return IMPL.broadcastRewritableDeletes(write); } @@ -91,7 +89,7 @@ public static Broadcast>> broadcastRewritableDeletes public static PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( Table table, OutputFileFactory fileFactory, - Map> rewritableDeletes) { + IcebergShimUtils.RewritableDeletes rewritableDeletes) { return IMPL.newDeletionVectorWriter(table, fileFactory, rewritableDeletes); } diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala index e6aff355616..6b2522587a5 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala @@ -28,7 +28,7 @@ import com.nvidia.spark.rapids.RmmRapidsRetryIterator.withRetryNoSplit import com.nvidia.spark.rapids.SpillPriorities.ACTIVE_ON_DECK_PRIORITY import com.nvidia.spark.rapids.fileio.iceberg.IcebergFileIO import com.nvidia.spark.rapids.iceberg.{ColumnarBatchWithPartition, GpuIcebergPartitioner, - GpuIcebergSpecPartitioner, IcebergFormatVersionSupport, ShimUtils} + GpuIcebergSpecPartitioner, IcebergFormatVersionSupport, IcebergShimUtils, ShimUtils} import com.nvidia.spark.rapids.iceberg.utils.GpuStructProjection import org.apache.hadoop.mapreduce.Job import org.apache.iceberg._ @@ -207,8 +207,7 @@ object GpuSparkPositionDeltaWrite { class GpuPositionDeltaWriterFactory( val tableSer: Broadcast[Table], - val rewritableDeletesSer: - Option[Broadcast[java.util.Map[String, java.util.Set[DeleteFile]]]], + val rewritableDeletesSer: Option[IcebergShimUtils.RewritableDeletes], val command: Command, val context: GpuWriteContext, val writeProps: Map[String, String], @@ -265,11 +264,7 @@ trait GpuDeltaWriter extends DeltaWriter[ColumnarBatch] { def context: GpuWriteContext protected def rewritableDeletesSer: - Option[Broadcast[java.util.Map[String, java.util.Set[DeleteFile]]]] - - protected def rewritableDeletes: java.util.Map[String, java.util.Set[DeleteFile]] = { - rewritableDeletesSer.map(_.value).orNull - } + Option[IcebergShimUtils.RewritableDeletes] protected def buildPartitionProjections( partitionType: IcebergTypes.StructType, @@ -290,7 +285,7 @@ trait GpuDeltaWriter extends DeltaWriter[ColumnarBatch] { if (context.useDVs) { new GpuBatchPositionDeleteWriter( ShimUtils.newDeletionVectorWriter( - table, outputFileFactory, rewritableDeletes)) + table, outputFileFactory, rewritableDeletesSer.orNull)) } else if (inputOrdered) { new GpuClusteredPositionDeleteWriter(writerFactory, outputFileFactory, io, targetFileSize) } else { @@ -587,8 +582,7 @@ trait GpuDeleteAndDataDeltaWriter extends GpuDeltaWriter { */ class GpuDeleteOnlyDeltaWriter( table: Table, - override protected val rewritableDeletesSer: - Option[Broadcast[java.util.Map[String, java.util.Set[DeleteFile]]]], + override protected val rewritableDeletesSer: Option[IcebergShimUtils.RewritableDeletes], writerFactory: GpuSparkFileWriterFactory, deleteFileFactory: OutputFileFactory, override val context: GpuWriteContext) extends GpuDeltaWriter { @@ -708,8 +702,7 @@ class GpuDeleteOnlyDeltaWriter( */ class GpuUnpartitionedDeltaWriter( protected val table: Table, - override protected val rewritableDeletesSer: - Option[Broadcast[java.util.Map[String, java.util.Set[DeleteFile]]]], + override protected val rewritableDeletesSer: Option[IcebergShimUtils.RewritableDeletes], writerFactory: GpuSparkFileWriterFactory, dataFileFactory: OutputFileFactory, deleteFileFactory: OutputFileFactory, @@ -760,8 +753,7 @@ class GpuUnpartitionedDeltaWriter( */ class GpuPartitionedDeltaWriter( protected val table: Table, - override protected val rewritableDeletesSer: - Option[Broadcast[java.util.Map[String, java.util.Set[DeleteFile]]]], + override protected val rewritableDeletesSer: Option[IcebergShimUtils.RewritableDeletes], writerFactory: GpuSparkFileWriterFactory, dataFileFactory: OutputFileFactory, deleteFileFactory: OutputFileFactory, diff --git a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java index 15cd849c1cd..f00ef4b15ce 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java @@ -41,9 +41,10 @@ import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.SparkUtil; import org.apache.iceberg.spark.source.GpuSparkCopyOnWriteV1Scan; -import org.apache.iceberg.spark.source.GpuSparkPositionDeltaWriteAccess; +import org.apache.iceberg.spark.source.iceberg110x.GpuSparkPositionDeltaWriteAccess; import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.DeleteFileSet; import org.apache.iceberg.util.PartitionUtil; import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; @@ -80,19 +81,23 @@ public boolean isPuffinFormat(FileFormat fileFormat) { } @Override - @SuppressWarnings({"rawtypes", "unchecked"}) - public Broadcast>> broadcastRewritableDeletes( + public RewritableDeletes broadcastRewritableDeletes( DeltaBatchWrite write) { - return (Broadcast) GpuSparkPositionDeltaWriteAccess.broadcastRewritableDeletes(write); + Broadcast> rewritableDeletes = + GpuSparkPositionDeltaWriteAccess.broadcastRewritableDeletes(write); + return rewritableDeletes != null ? new RewritableDeletesImpl(rewritableDeletes) : null; } @Override public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( Table table, OutputFileFactory fileFactory, - Map> rewritableDeletes) { + RewritableDeletes rewritableDeletes) { + Map deleteFiles = rewritableDeletes == null + ? null + : ((RewritableDeletesImpl) rewritableDeletes).value(); return new PartitioningDVWriter<>( - fileFactory, previousDeleteLoader(table, rewritableDeletes)); + fileFactory, previousDeleteLoader(table, deleteFiles)); } @Override @@ -107,7 +112,7 @@ public WriteResult positionDeltaWriteResult( } private Function previousDeleteLoader( - Table table, Map> rewritableDeletes) { + Table table, Map rewritableDeletes) { if (rewritableDeletes == null) { return path -> null; } @@ -121,6 +126,18 @@ private Function previousDeleteLoader( }; } + private static final class RewritableDeletesImpl implements RewritableDeletes { + private final Broadcast> delegate; + + private RewritableDeletesImpl(Broadcast> delegate) { + this.delegate = delegate; + } + + private Map value() { + return delegate.value(); + } + } + @Override public void setPositionDelete( PositionDelete delete, CharSequence path, long position) { diff --git a/iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java b/iceberg/iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/iceberg110x/GpuSparkPositionDeltaWriteAccess.java similarity index 94% rename from iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java rename to iceberg/iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/iceberg110x/GpuSparkPositionDeltaWriteAccess.java index 6cc7d5f037f..6df80bf7b95 100644 --- a/iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java +++ b/iceberg/iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/iceberg110x/GpuSparkPositionDeltaWriteAccess.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.apache.iceberg.spark.source; +package org.apache.iceberg.spark.source.iceberg110x; import java.lang.reflect.Method; import java.util.Map; @@ -23,7 +23,7 @@ import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.connector.write.DeltaBatchWrite; -/** Iceberg-version-specific access to position-delta batch-write internals. */ +/** Iceberg 1.10.x-specific access to position-delta batch-write internals. */ public final class GpuSparkPositionDeltaWriteAccess { private static final ClassValue BROADCAST_REWRITABLE_DELETES_METHOD = new ClassValue() { diff --git a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java index 60ddad247ae..edc1e752fe8 100644 --- a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java @@ -41,9 +41,10 @@ import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.SparkUtil; import org.apache.iceberg.spark.source.GpuSparkCopyOnWriteScan; -import org.apache.iceberg.spark.source.GpuSparkPositionDeltaWriteAccess; +import org.apache.iceberg.spark.source.iceberg111x.GpuSparkPositionDeltaWriteAccess; import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.DeleteFileSet; import org.apache.iceberg.util.PartitionUtil; import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; @@ -80,19 +81,23 @@ public boolean isPuffinFormat(FileFormat fileFormat) { } @Override - @SuppressWarnings({"rawtypes", "unchecked"}) - public Broadcast>> broadcastRewritableDeletes( + public RewritableDeletes broadcastRewritableDeletes( DeltaBatchWrite write) { - return (Broadcast) GpuSparkPositionDeltaWriteAccess.broadcastRewritableDeletes(write); + Broadcast> rewritableDeletes = + GpuSparkPositionDeltaWriteAccess.broadcastRewritableDeletes(write); + return rewritableDeletes != null ? new RewritableDeletesImpl(rewritableDeletes) : null; } @Override public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( Table table, OutputFileFactory fileFactory, - Map> rewritableDeletes) { + RewritableDeletes rewritableDeletes) { + Map deleteFiles = rewritableDeletes == null + ? null + : ((RewritableDeletesImpl) rewritableDeletes).value(); return new PartitioningDVWriter<>( - fileFactory, previousDeleteLoader(table, rewritableDeletes)); + fileFactory, previousDeleteLoader(table, deleteFiles)); } @Override @@ -107,7 +112,7 @@ public WriteResult positionDeltaWriteResult( } private Function previousDeleteLoader( - Table table, Map> rewritableDeletes) { + Table table, Map rewritableDeletes) { if (rewritableDeletes == null) { return path -> null; } @@ -121,6 +126,18 @@ private Function previousDeleteLoader( }; } + private static final class RewritableDeletesImpl implements RewritableDeletes { + private final Broadcast> delegate; + + private RewritableDeletesImpl(Broadcast> delegate) { + this.delegate = delegate; + } + + private Map value() { + return delegate.value(); + } + } + @Override public void setPositionDelete( PositionDelete delete, CharSequence path, long position) { diff --git a/iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java b/iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/iceberg111x/GpuSparkPositionDeltaWriteAccess.java similarity index 94% rename from iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java rename to iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/iceberg111x/GpuSparkPositionDeltaWriteAccess.java index 6cc7d5f037f..11d6b1f5c02 100644 --- a/iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java +++ b/iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/iceberg111x/GpuSparkPositionDeltaWriteAccess.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.apache.iceberg.spark.source; +package org.apache.iceberg.spark.source.iceberg111x; import java.lang.reflect.Method; import java.util.Map; @@ -23,7 +23,7 @@ import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.connector.write.DeltaBatchWrite; -/** Iceberg-version-specific access to position-delta batch-write internals. */ +/** Iceberg 1.11.x-specific access to position-delta batch-write internals. */ public final class GpuSparkPositionDeltaWriteAccess { private static final ClassValue BROADCAST_REWRITABLE_DELETES_METHOD = new ClassValue() { diff --git a/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java b/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java index a5d2e3714ab..69333d74322 100644 --- a/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-6-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg16x/ShimUtilsImpl.java @@ -34,7 +34,6 @@ import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PartitionUtil; -import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; import org.apache.spark.sql.connector.write.DeltaBatchWrite; @@ -42,7 +41,6 @@ import java.io.IOException; import java.util.Collections; import java.util.Map; -import java.util.Set; /** Iceberg 1.6.x shim: uses {@code ContentFile.path()} and {@code GpuBaseReader::convertConstant}. */ public class ShimUtilsImpl implements IcebergShimUtils { @@ -83,7 +81,7 @@ public boolean isPuffinFormat(FileFormat fileFormat) { } @Override - public Broadcast>> broadcastRewritableDeletes( + public RewritableDeletes broadcastRewritableDeletes( DeltaBatchWrite write) { throw new UnsupportedOperationException( "Iceberg 1.6 does not support Puffin deletion vectors"); @@ -93,7 +91,7 @@ public Broadcast>> broadcastRewritableDeletes( public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( Table table, OutputFileFactory fileFactory, - Map> rewritableDeletes) { + RewritableDeletes rewritableDeletes) { throw new UnsupportedOperationException( "Iceberg 1.6 does not support Puffin deletion vectors"); } diff --git a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java index 0fbc55aab41..eec234ebeb4 100644 --- a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java @@ -30,9 +30,10 @@ import org.apache.iceberg.io.SupportsStorageCredentials; import org.apache.iceberg.spark.SparkUtil; import org.apache.iceberg.spark.source.GpuSparkCopyOnWriteV1Scan; -import org.apache.iceberg.spark.source.GpuSparkPositionDeltaWriteAccess; +import org.apache.iceberg.spark.source.iceberg19x.GpuSparkPositionDeltaWriteAccess; import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.DeleteFileSet; import org.apache.iceberg.util.PartitionUtil; import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; @@ -69,19 +70,23 @@ public boolean isPuffinFormat(FileFormat fileFormat) { } @Override - @SuppressWarnings({"rawtypes", "unchecked"}) - public Broadcast>> broadcastRewritableDeletes( + public RewritableDeletes broadcastRewritableDeletes( DeltaBatchWrite write) { - return (Broadcast) GpuSparkPositionDeltaWriteAccess.broadcastRewritableDeletes(write); + Broadcast> rewritableDeletes = + GpuSparkPositionDeltaWriteAccess.broadcastRewritableDeletes(write); + return rewritableDeletes != null ? new RewritableDeletesImpl(rewritableDeletes) : null; } @Override public PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( Table table, OutputFileFactory fileFactory, - Map> rewritableDeletes) { + RewritableDeletes rewritableDeletes) { + Map deleteFiles = rewritableDeletes == null + ? null + : ((RewritableDeletesImpl) rewritableDeletes).value(); return new PartitioningDVWriter<>( - fileFactory, previousDeleteLoader(table, rewritableDeletes)); + fileFactory, previousDeleteLoader(table, deleteFiles)); } @Override @@ -96,7 +101,7 @@ public WriteResult positionDeltaWriteResult( } private Function previousDeleteLoader( - Table table, Map> rewritableDeletes) { + Table table, Map rewritableDeletes) { if (rewritableDeletes == null) { return path -> null; } @@ -110,6 +115,18 @@ private Function previousDeleteLoader( }; } + private static final class RewritableDeletesImpl implements RewritableDeletes { + private final Broadcast> delegate; + + private RewritableDeletesImpl(Broadcast> delegate) { + this.delegate = delegate; + } + + private Map value() { + return delegate.value(); + } + } + @Override public void setPositionDelete( PositionDelete delete, CharSequence path, long position) { diff --git a/iceberg/iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java b/iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/iceberg19x/GpuSparkPositionDeltaWriteAccess.java similarity index 94% rename from iceberg/iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java rename to iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/iceberg19x/GpuSparkPositionDeltaWriteAccess.java index 6cc7d5f037f..1f28ea2330f 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java +++ b/iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/iceberg19x/GpuSparkPositionDeltaWriteAccess.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.apache.iceberg.spark.source; +package org.apache.iceberg.spark.source.iceberg19x; import java.lang.reflect.Method; import java.util.Map; @@ -23,7 +23,7 @@ import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.connector.write.DeltaBatchWrite; -/** Iceberg-version-specific access to position-delta batch-write internals. */ +/** Iceberg 1.9.x-specific access to position-delta batch-write internals. */ public final class GpuSparkPositionDeltaWriteAccess { private static final ClassValue BROADCAST_REWRITABLE_DELETES_METHOD = new ClassValue() { From f6c9a3a48ed444b5f7637a221e20e6ba8d161a49 Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Fri, 28 Aug 2026 10:38:27 +0800 Subject: [PATCH 15/18] Refine Iceberg deletion vector tests Signed-off-by: Ray Liu --- .../src/main/python/iceberg/__init__.py | 24 ----- .../python/iceberg/iceberg_delete_test.py | 96 ++++++++++--------- .../main/python/iceberg/iceberg_merge_test.py | 74 +++++++------- .../python/iceberg/iceberg_update_test.py | 51 +++++----- 4 files changed, 114 insertions(+), 131 deletions(-) diff --git a/integration_tests/src/main/python/iceberg/__init__.py b/integration_tests/src/main/python/iceberg/__init__.py index b3fa2bdc191..ff5922133b0 100644 --- a/integration_tests/src/main/python/iceberg/__init__.py +++ b/integration_tests/src/main/python/iceberg/__init__.py @@ -41,30 +41,6 @@ "Iceberg v3 requires Iceberg 1.9.0 or later and a catalog backend with v3 support") -def assert_iceberg_v3_deletion_vectors(spark, table_name, expected_positions): - delete_files = spark.sql(f""" - SELECT content, file_format, record_count, referenced_data_file, - content_offset, content_size_in_bytes - FROM {table_name}.delete_files - WHERE content = 1 - """).collect() - assert delete_files, "Expected at least one positional delete file" - assert all(row.file_format == 'PUFFIN' for row in delete_files), \ - f"Expected only Puffin deletion vectors, found {delete_files}" - referenced_files = [row.referenced_data_file for row in delete_files] - assert all(path is not None for path in referenced_files), \ - f"Expected every deletion vector to reference a data file, found {delete_files}" - assert len(referenced_files) == len(set(referenced_files)), \ - f"Expected at most one deletion vector per data file, found {delete_files}" - assert all(row.content_offset is not None and row.content_offset >= 0 and - row.content_size_in_bytes is not None and row.content_size_in_bytes > 0 - for row in delete_files), \ - f"Expected valid Puffin ranges, found {delete_files}" - actual_positions = sum(row.record_count for row in delete_files) - assert actual_positions == expected_positions, \ - f"Expected {expected_positions} deleted positions, found {actual_positions}" - - # iceberg supported types iceberg_table_gen = MappingProxyType({ '_c0': byte_gen, '_c1': short_gen, '_c2': int_gen, diff --git a/integration_tests/src/main/python/iceberg/iceberg_delete_test.py b/integration_tests/src/main/python/iceberg/iceberg_delete_test.py index 4609de5c253..9c3ed64183e 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_delete_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_delete_test.py @@ -21,8 +21,7 @@ iceberg_base_table_cols, iceberg_gens_list, iceberg_nested_write_gens_list, iceberg_unsupported_mark, delete_partition_transforms_distributed, _build_tblprops, assert_iceberg_files_use_codec, - assert_iceberg_v3_deletion_vectors, supports_iceberg_v3, - ICEBERG_V3_UNSUPPORTED_REASON) + supports_iceberg_v3, ICEBERG_V3_UNSUPPORTED_REASON) from marks import allow_non_gpu, allow_non_gpu_conditional, iceberg, ignore_order, datagen_overrides from spark_session import is_spark_35x, is_spark_400_or_later, with_cpu_session, with_gpu_session @@ -172,69 +171,74 @@ def delete_data(spark, table_name): @pytest.mark.parametrize('fanout_enabled', [False, True], ids=['clustered', 'fanout']) def test_iceberg_delete_v3_gpu_writes_and_merges_deletion_vectors( spark_tmp_table_factory, fanout_enabled): - table_name = get_full_table_name(spark_tmp_table_factory) + base_table_name = get_full_table_name(spark_tmp_table_factory) + cpu_table_name = f"{base_table_name}_cpu" + gpu_table_name = f"{base_table_name}_gpu" + data_gen_func = lambda spark: gen_df( + spark, [('id', LongGen(nullable=False, min_val=0, max_val=127))], + length=128, seed=0) + table_properties = { + 'format-version': '3', + 'write.spark.fanout.enabled': fanout_enabled + } + create_iceberg_table_with_data( + cpu_table_name, "bucket(2, id)", data_gen_func, table_properties, + delete_mode='merge-on-read') + create_iceberg_table_with_data( + gpu_table_name, "bucket(2, id)", data_gen_func, table_properties, + delete_mode='merge-on-read') - def setup_table(spark): - spark.sql(f""" - CREATE TABLE {table_name} (id BIGINT) USING ICEBERG - PARTITIONED BY (bucket(2, id)) - TBLPROPERTIES ( - 'format-version' = '3', - 'write.delete.mode' = 'merge-on-read', - 'write.spark.fanout.enabled' = '{str(fanout_enabled).lower()}') - """) - spark.sql(f"INSERT INTO {table_name} SELECT id FROM range(128)") - - with_cpu_session(setup_table) + def delete_data(spark, table_name): + spark.sql(f"DELETE FROM {table_name} WHERE id % 3 = 0") + spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0") + + with_cpu_session(lambda spark: delete_data(spark, cpu_table_name)) write_conf = dict(iceberg_write_enabled_conf) write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' + with_gpu_session(lambda spark: delete_data(spark, gpu_table_name), conf=write_conf) - # The second delete must merge with the first DV rather than add another DV for a data file. - with_gpu_session( - lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 3 = 0").collect(), - conf=write_conf) - with_gpu_session( - lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0").collect(), - conf=write_conf) - - with_cpu_session( - lambda spark: assert_iceberg_v3_deletion_vectors(spark, table_name, 60)) - actual = with_cpu_session( - lambda spark: [row.id for row in spark.table(table_name).collect()]) - expected = [value for value in range(128) if value % 3 != 0 and value % 5 != 0] - assert sorted(actual) == expected + cpu_data = with_cpu_session(lambda spark: spark.table(cpu_table_name).collect()) + gpu_data = with_cpu_session(lambda spark: spark.table(gpu_table_name).collect()) + assert_equal_with_local_sort(cpu_data, gpu_data) @iceberg @pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) def test_iceberg_delete_v3_gpu_upgrades_position_deletes(spark_tmp_table_factory): - table_name = get_full_table_name(spark_tmp_table_factory) + base_table_name = get_full_table_name(spark_tmp_table_factory) + cpu_table_name = f"{base_table_name}_cpu" + gpu_table_name = f"{base_table_name}_gpu" + data_gen_func = lambda spark: gen_df( + spark, [('id', LongGen(nullable=False, min_val=0, max_val=63))], + length=64, seed=0) + table_properties = {'format-version': '2'} + create_iceberg_table_with_data( + cpu_table_name, data_gen_func=data_gen_func, table_properties=table_properties, + delete_mode='merge-on-read') + create_iceberg_table_with_data( + gpu_table_name, data_gen_func=data_gen_func, table_properties=table_properties, + delete_mode='merge-on-read') - def setup_table(spark): - spark.sql(f""" - CREATE TABLE {table_name} (id BIGINT) USING ICEBERG - TBLPROPERTIES ( - 'format-version' = '2', - 'write.delete.mode' = 'merge-on-read') - """) - spark.sql(f"INSERT INTO {table_name} SELECT id FROM range(64)") + def create_position_deletes(spark, table_name): spark.sql(f"DELETE FROM {table_name} WHERE id % 4 = 0") spark.sql( f"ALTER TABLE {table_name} SET TBLPROPERTIES ('format-version' = '3')") - with_cpu_session(setup_table) + with_cpu_session(lambda spark: create_position_deletes(spark, cpu_table_name)) + with_cpu_session(lambda spark: create_position_deletes(spark, gpu_table_name)) write_conf = dict(iceberg_write_enabled_conf) write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' + with_cpu_session( + lambda spark: spark.sql( + f"DELETE FROM {cpu_table_name} WHERE id % 5 = 0").collect()) with_gpu_session( - lambda spark: spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0").collect(), + lambda spark: spark.sql( + f"DELETE FROM {gpu_table_name} WHERE id % 5 = 0").collect(), conf=write_conf) - with_cpu_session( - lambda spark: assert_iceberg_v3_deletion_vectors(spark, table_name, 25)) - actual = with_cpu_session( - lambda spark: [row.id for row in spark.table(table_name).collect()]) - expected = [value for value in range(64) if value % 4 != 0 and value % 5 != 0] - assert sorted(actual) == expected + cpu_data = with_cpu_session(lambda spark: spark.table(cpu_table_name).collect()) + gpu_data = with_cpu_session(lambda spark: spark.table(gpu_table_name).collect()) + assert_equal_with_local_sort(cpu_data, gpu_data) def _do_test_iceberg_delete_partitioned_table(spark_tmp_table_factory, partition_col_sql, delete_mode, table_properties=None): diff --git a/integration_tests/src/main/python/iceberg/iceberg_merge_test.py b/integration_tests/src/main/python/iceberg/iceberg_merge_test.py index f69abcc0279..47297405ba7 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_merge_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_merge_test.py @@ -20,8 +20,7 @@ from iceberg import (create_iceberg_table, get_full_table_name, iceberg_write_enabled_conf, iceberg_base_table_cols, iceberg_gens_list, iceberg_nested_write_gens_list, iceberg_unsupported_mark, merge_partition_transforms_distributed, - assert_iceberg_v3_deletion_vectors, supports_iceberg_v3, - ICEBERG_V3_UNSUPPORTED_REASON) + supports_iceberg_v3, ICEBERG_V3_UNSUPPORTED_REASON) from marks import allow_non_gpu, allow_non_gpu_conditional, iceberg, ignore_order, datagen_overrides from spark_session import is_spark_400_or_later, with_gpu_session, with_cpu_session @@ -227,45 +226,50 @@ def merge_data(spark, target_table): @iceberg @pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) def test_iceberg_merge_v3_gpu_writes_deletion_vectors(spark_tmp_table_factory): - table_name = get_full_table_name(spark_tmp_table_factory) - source_table = f"{table_name}_source" + base_table_name = get_full_table_name(spark_tmp_table_factory) + cpu_table_name = f"{base_table_name}_cpu" + gpu_table_name = f"{base_table_name}_gpu" + source_table = f"{base_table_name}_source" + target_data_gen = lambda spark: gen_df(spark, [ + ('id', LongGen(nullable=False, min_val=0, max_val=63)), + ('value', LongGen(nullable=False, min_val=-1000, max_val=1000)) + ], length=64, seed=0) + source_data_gen = lambda spark: gen_df(spark, [ + ('id', UniqueLongGen()), + ('value', LongGen(nullable=False, min_val=1001, max_val=2000)) + ], length=64, seed=1) + table_properties = { + 'format-version': '3', + 'write.merge.mode': 'merge-on-read' + } + create_iceberg_table(cpu_table_name, table_prop=table_properties, df_gen=target_data_gen) + create_iceberg_table(gpu_table_name, table_prop=table_properties, df_gen=target_data_gen) + create_iceberg_table(source_table, table_prop={'format-version': '2'}, + df_gen=source_data_gen) - def setup_tables(spark): - spark.sql(f""" - CREATE TABLE {table_name} (id BIGINT, value BIGINT) USING ICEBERG - TBLPROPERTIES ( - 'format-version' = '3', - 'write.merge.mode' = 'merge-on-read') - """) - spark.sql(f"INSERT INTO {table_name} SELECT id, id FROM range(64)") - spark.sql(f""" - CREATE TABLE {source_table} (id BIGINT, value BIGINT) USING ICEBERG - TBLPROPERTIES ('format-version' = '2') - """) - spark.sql(f""" - INSERT INTO {source_table} - SELECT id, id + 1000 FROM range(64) WHERE id % 4 = 0 - """) + def insert_data(spark, table_name, data_gen_func): + data_gen_func(spark).writeTo(table_name).append() - with_cpu_session(setup_tables) - write_conf = dict(iceberg_write_enabled_conf) - write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' - with_gpu_session( - lambda spark: spark.sql(f""" + with_cpu_session(lambda spark: insert_data(spark, cpu_table_name, target_data_gen)) + with_cpu_session(lambda spark: insert_data(spark, gpu_table_name, target_data_gen)) + with_cpu_session(lambda spark: insert_data(spark, source_table, source_data_gen)) + + def merge_data(spark, table_name): + spark.sql(f""" MERGE INTO {table_name} t USING {source_table} s ON t.id = s.id WHEN MATCHED THEN UPDATE SET value = s.value - """).collect(), - conf=write_conf) - - with_cpu_session( - lambda spark: assert_iceberg_v3_deletion_vectors(spark, table_name, 16)) - actual = with_cpu_session( - lambda spark: [(row.id, row.value) for row in spark.table(table_name).collect()]) - expected = [(value, value + 1000 if value % 4 == 0 else value) - for value in range(64)] - assert sorted(actual) == expected + """) + + with_cpu_session(lambda spark: merge_data(spark, cpu_table_name)) + write_conf = dict(iceberg_write_enabled_conf) + write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' + with_gpu_session(lambda spark: merge_data(spark, gpu_table_name), conf=write_conf) + + cpu_data = with_cpu_session(lambda spark: spark.table(cpu_table_name).collect()) + gpu_data = with_cpu_session(lambda spark: spark.table(gpu_table_name).collect()) + assert_equal_with_local_sort(cpu_data, gpu_data) @allow_non_gpu("MergeRows$Keep", "MergeRows$Discard", "MergeRows$Split") diff --git a/integration_tests/src/main/python/iceberg/iceberg_update_test.py b/integration_tests/src/main/python/iceberg/iceberg_update_test.py index 3d7b8a0281b..65f052cc0e8 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_update_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_update_test.py @@ -20,8 +20,7 @@ from iceberg import (create_iceberg_table, get_full_table_name, iceberg_write_enabled_conf, iceberg_base_table_cols, iceberg_gens_list, iceberg_nested_write_gens_list, iceberg_unsupported_mark, update_partition_transforms_distributed, - assert_iceberg_v3_deletion_vectors, supports_iceberg_v3, - ICEBERG_V3_UNSUPPORTED_REASON) + supports_iceberg_v3, ICEBERG_V3_UNSUPPORTED_REASON) from marks import allow_non_gpu, allow_non_gpu_conditional, disable_ansi_mode, iceberg, ignore_order, datagen_overrides from spark_session import is_spark_400_or_later, with_cpu_session, with_gpu_session @@ -162,32 +161,32 @@ def update_data(spark, table_name): @iceberg @pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) def test_iceberg_update_v3_gpu_writes_deletion_vectors(spark_tmp_table_factory): - table_name = get_full_table_name(spark_tmp_table_factory) - - def setup_table(spark): - spark.sql(f""" - CREATE TABLE {table_name} (id BIGINT, value BIGINT) USING ICEBERG - TBLPROPERTIES ( - 'format-version' = '3', - 'write.update.mode' = 'merge-on-read') - """) - spark.sql(f"INSERT INTO {table_name} SELECT id, id FROM range(64)") - - with_cpu_session(setup_table) + base_table_name = get_full_table_name(spark_tmp_table_factory) + cpu_table_name = f"{base_table_name}_cpu" + gpu_table_name = f"{base_table_name}_gpu" + data_gen_func = lambda spark: gen_df(spark, [ + ('id', LongGen(nullable=False, min_val=0, max_val=63)), + ('value', LongGen(nullable=False, min_val=-1000, max_val=1000)) + ], length=64, seed=0) + table_properties = {'format-version': '3'} + create_iceberg_table_with_data( + cpu_table_name, data_gen_func=data_gen_func, table_properties=table_properties, + update_mode='merge-on-read') + create_iceberg_table_with_data( + gpu_table_name, data_gen_func=data_gen_func, table_properties=table_properties, + update_mode='merge-on-read') + + def update_data(spark, table_name): + spark.sql(f"UPDATE {table_name} SET value = value + 1000 WHERE id % 4 = 0") + + with_cpu_session(lambda spark: update_data(spark, cpu_table_name)) write_conf = dict(iceberg_write_enabled_conf) write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' - with_gpu_session( - lambda spark: spark.sql( - f"UPDATE {table_name} SET value = value + 1000 WHERE id % 4 = 0").collect(), - conf=write_conf) - - with_cpu_session( - lambda spark: assert_iceberg_v3_deletion_vectors(spark, table_name, 16)) - actual = with_cpu_session( - lambda spark: [(row.id, row.value) for row in spark.table(table_name).collect()]) - expected = [(value, value + 1000 if value % 4 == 0 else value) - for value in range(64)] - assert sorted(actual) == expected + with_gpu_session(lambda spark: update_data(spark, gpu_table_name), conf=write_conf) + + cpu_data = with_cpu_session(lambda spark: spark.table(cpu_table_name).collect()) + gpu_data = with_cpu_session(lambda spark: spark.table(gpu_table_name).collect()) + assert_equal_with_local_sort(cpu_data, gpu_data) @iceberg From 586d2d5e764f761fe4f3c62a26133927614ef857 Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Mon, 31 Aug 2026 10:32:01 +0800 Subject: [PATCH 16/18] Refine Iceberg deletion vector test data Signed-off-by: Ray Liu --- .../src/main/python/iceberg/__init__.py | 1 - .../python/iceberg/iceberg_delete_test.py | 24 ++++++++++++------- .../main/python/iceberg/iceberg_merge_test.py | 17 ++++++++----- .../python/iceberg/iceberg_update_test.py | 11 +++++---- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/integration_tests/src/main/python/iceberg/__init__.py b/integration_tests/src/main/python/iceberg/__init__.py index ff5922133b0..38891805531 100644 --- a/integration_tests/src/main/python/iceberg/__init__.py +++ b/integration_tests/src/main/python/iceberg/__init__.py @@ -40,7 +40,6 @@ ICEBERG_V3_UNSUPPORTED_REASON = ( "Iceberg v3 requires Iceberg 1.9.0 or later and a catalog backend with v3 support") - # iceberg supported types iceberg_table_gen = MappingProxyType({ '_c0': byte_gen, '_c1': short_gen, '_c2': int_gen, diff --git a/integration_tests/src/main/python/iceberg/iceberg_delete_test.py b/integration_tests/src/main/python/iceberg/iceberg_delete_test.py index 9c3ed64183e..d14d631b803 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_delete_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_delete_test.py @@ -175,8 +175,11 @@ def test_iceberg_delete_v3_gpu_writes_and_merges_deletion_vectors( cpu_table_name = f"{base_table_name}_cpu" gpu_table_name = f"{base_table_name}_gpu" data_gen_func = lambda spark: gen_df( - spark, [('id', LongGen(nullable=False, min_val=0, max_val=127))], - length=128, seed=0) + spark, [ + ('id', LongGen(nullable=False, min_val=0, max_val=127)), + ('value', LongGen()), + ('data', StringGen()) + ], seed=0) table_properties = { 'format-version': '3', 'write.spark.fanout.enabled': fanout_enabled @@ -193,8 +196,9 @@ def delete_data(spark, table_name): spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0") with_cpu_session(lambda spark: delete_data(spark, cpu_table_name)) - write_conf = dict(iceberg_write_enabled_conf) - write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' + write_conf = copy_and_update(iceberg_write_enabled_conf, { + 'spark.rapids.sql.format.iceberg.v3.enabled': 'true' + }) with_gpu_session(lambda spark: delete_data(spark, gpu_table_name), conf=write_conf) cpu_data = with_cpu_session(lambda spark: spark.table(cpu_table_name).collect()) @@ -209,8 +213,11 @@ def test_iceberg_delete_v3_gpu_upgrades_position_deletes(spark_tmp_table_factory cpu_table_name = f"{base_table_name}_cpu" gpu_table_name = f"{base_table_name}_gpu" data_gen_func = lambda spark: gen_df( - spark, [('id', LongGen(nullable=False, min_val=0, max_val=63))], - length=64, seed=0) + spark, [ + ('id', LongGen(nullable=False, min_val=0, max_val=63)), + ('value', LongGen()), + ('data', StringGen()) + ], seed=0) table_properties = {'format-version': '2'} create_iceberg_table_with_data( cpu_table_name, data_gen_func=data_gen_func, table_properties=table_properties, @@ -226,8 +233,9 @@ def create_position_deletes(spark, table_name): with_cpu_session(lambda spark: create_position_deletes(spark, cpu_table_name)) with_cpu_session(lambda spark: create_position_deletes(spark, gpu_table_name)) - write_conf = dict(iceberg_write_enabled_conf) - write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' + write_conf = copy_and_update(iceberg_write_enabled_conf, { + 'spark.rapids.sql.format.iceberg.v3.enabled': 'true' + }) with_cpu_session( lambda spark: spark.sql( f"DELETE FROM {cpu_table_name} WHERE id % 5 = 0").collect()) diff --git a/integration_tests/src/main/python/iceberg/iceberg_merge_test.py b/integration_tests/src/main/python/iceberg/iceberg_merge_test.py index 47297405ba7..2b4fb95b24f 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_merge_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_merge_test.py @@ -232,12 +232,16 @@ def test_iceberg_merge_v3_gpu_writes_deletion_vectors(spark_tmp_table_factory): source_table = f"{base_table_name}_source" target_data_gen = lambda spark: gen_df(spark, [ ('id', LongGen(nullable=False, min_val=0, max_val=63)), - ('value', LongGen(nullable=False, min_val=-1000, max_val=1000)) - ], length=64, seed=0) + ('value', LongGen(nullable=False, min_val=-1000, max_val=1000)), + ('data', StringGen()), + ('flag', BooleanGen()) + ], seed=0) source_data_gen = lambda spark: gen_df(spark, [ ('id', UniqueLongGen()), - ('value', LongGen(nullable=False, min_val=1001, max_val=2000)) - ], length=64, seed=1) + ('value', LongGen(nullable=False, min_val=1001, max_val=2000)), + ('data', StringGen()), + ('flag', BooleanGen()) + ], seed=1) table_properties = { 'format-version': '3', 'write.merge.mode': 'merge-on-read' @@ -263,8 +267,9 @@ def merge_data(spark, table_name): """) with_cpu_session(lambda spark: merge_data(spark, cpu_table_name)) - write_conf = dict(iceberg_write_enabled_conf) - write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' + write_conf = copy_and_update(iceberg_write_enabled_conf, { + 'spark.rapids.sql.format.iceberg.v3.enabled': 'true' + }) with_gpu_session(lambda spark: merge_data(spark, gpu_table_name), conf=write_conf) cpu_data = with_cpu_session(lambda spark: spark.table(cpu_table_name).collect()) diff --git a/integration_tests/src/main/python/iceberg/iceberg_update_test.py b/integration_tests/src/main/python/iceberg/iceberg_update_test.py index 65f052cc0e8..15cf3a86197 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_update_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_update_test.py @@ -166,8 +166,10 @@ def test_iceberg_update_v3_gpu_writes_deletion_vectors(spark_tmp_table_factory): gpu_table_name = f"{base_table_name}_gpu" data_gen_func = lambda spark: gen_df(spark, [ ('id', LongGen(nullable=False, min_val=0, max_val=63)), - ('value', LongGen(nullable=False, min_val=-1000, max_val=1000)) - ], length=64, seed=0) + ('value', LongGen(nullable=False, min_val=-1000, max_val=1000)), + ('data', StringGen()), + ('flag', BooleanGen()) + ], seed=0) table_properties = {'format-version': '3'} create_iceberg_table_with_data( cpu_table_name, data_gen_func=data_gen_func, table_properties=table_properties, @@ -180,8 +182,9 @@ def update_data(spark, table_name): spark.sql(f"UPDATE {table_name} SET value = value + 1000 WHERE id % 4 = 0") with_cpu_session(lambda spark: update_data(spark, cpu_table_name)) - write_conf = dict(iceberg_write_enabled_conf) - write_conf['spark.rapids.sql.format.iceberg.v3.enabled'] = 'true' + write_conf = copy_and_update(iceberg_write_enabled_conf, { + 'spark.rapids.sql.format.iceberg.v3.enabled': 'true' + }) with_gpu_session(lambda spark: update_data(spark, gpu_table_name), conf=write_conf) cpu_data = with_cpu_session(lambda spark: spark.table(cpu_table_name).collect()) From 66d6b944f02cf60de787c9694074804c2ef7ee9a Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Mon, 31 Aug 2026 11:08:46 +0800 Subject: [PATCH 17/18] Use standard assertions for Iceberg v3 DML tests Signed-off-by: Ray Liu --- .../python/iceberg/iceberg_delete_test.py | 34 +++++++++---------- .../main/python/iceberg/iceberg_merge_test.py | 16 ++++----- .../python/iceberg/iceberg_update_test.py | 16 ++++----- 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/integration_tests/src/main/python/iceberg/iceberg_delete_test.py b/integration_tests/src/main/python/iceberg/iceberg_delete_test.py index d14d631b803..3ca19ab8c76 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_delete_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_delete_test.py @@ -14,7 +14,8 @@ import pytest -from asserts import assert_equal_with_local_sort, assert_gpu_fallback_write_sql +from asserts import (assert_equal_with_local_sort, assert_gpu_and_cpu_are_equal_collect, + assert_gpu_fallback_write_sql) from conftest import is_iceberg_remote_catalog from data_gen import * from iceberg import (create_iceberg_table, get_full_table_name, iceberg_write_enabled_conf, @@ -167,6 +168,7 @@ def delete_data(spark, table_name): @iceberg +@ignore_order(local=True) @pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) @pytest.mark.parametrize('fanout_enabled', [False, True], ids=['clustered', 'fanout']) def test_iceberg_delete_v3_gpu_writes_and_merges_deletion_vectors( @@ -191,22 +193,21 @@ def test_iceberg_delete_v3_gpu_writes_and_merges_deletion_vectors( gpu_table_name, "bucket(2, id)", data_gen_func, table_properties, delete_mode='merge-on-read') - def delete_data(spark, table_name): + def delete_data(spark): + is_gpu = spark.conf.get('spark.rapids.sql.enabled') == 'true' + table_name = gpu_table_name if is_gpu else cpu_table_name spark.sql(f"DELETE FROM {table_name} WHERE id % 3 = 0") spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0") + return spark.table(table_name) - with_cpu_session(lambda spark: delete_data(spark, cpu_table_name)) write_conf = copy_and_update(iceberg_write_enabled_conf, { 'spark.rapids.sql.format.iceberg.v3.enabled': 'true' }) - with_gpu_session(lambda spark: delete_data(spark, gpu_table_name), conf=write_conf) - - cpu_data = with_cpu_session(lambda spark: spark.table(cpu_table_name).collect()) - gpu_data = with_cpu_session(lambda spark: spark.table(gpu_table_name).collect()) - assert_equal_with_local_sort(cpu_data, gpu_data) + assert_gpu_and_cpu_are_equal_collect(delete_data, conf=write_conf) @iceberg +@ignore_order(local=True) @pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) def test_iceberg_delete_v3_gpu_upgrades_position_deletes(spark_tmp_table_factory): base_table_name = get_full_table_name(spark_tmp_table_factory) @@ -236,17 +237,14 @@ def create_position_deletes(spark, table_name): write_conf = copy_and_update(iceberg_write_enabled_conf, { 'spark.rapids.sql.format.iceberg.v3.enabled': 'true' }) - with_cpu_session( - lambda spark: spark.sql( - f"DELETE FROM {cpu_table_name} WHERE id % 5 = 0").collect()) - with_gpu_session( - lambda spark: spark.sql( - f"DELETE FROM {gpu_table_name} WHERE id % 5 = 0").collect(), - conf=write_conf) - cpu_data = with_cpu_session(lambda spark: spark.table(cpu_table_name).collect()) - gpu_data = with_cpu_session(lambda spark: spark.table(gpu_table_name).collect()) - assert_equal_with_local_sort(cpu_data, gpu_data) + def delete_data(spark): + is_gpu = spark.conf.get('spark.rapids.sql.enabled') == 'true' + table_name = gpu_table_name if is_gpu else cpu_table_name + spark.sql(f"DELETE FROM {table_name} WHERE id % 5 = 0") + return spark.table(table_name) + + assert_gpu_and_cpu_are_equal_collect(delete_data, conf=write_conf) def _do_test_iceberg_delete_partitioned_table(spark_tmp_table_factory, partition_col_sql, delete_mode, table_properties=None): diff --git a/integration_tests/src/main/python/iceberg/iceberg_merge_test.py b/integration_tests/src/main/python/iceberg/iceberg_merge_test.py index 2b4fb95b24f..aa793bf2b0a 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_merge_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_merge_test.py @@ -14,7 +14,8 @@ import pytest -from asserts import assert_equal_with_local_sort, assert_gpu_fallback_write_sql +from asserts import (assert_equal_with_local_sort, assert_gpu_and_cpu_are_equal_collect, + assert_gpu_fallback_write_sql) from conftest import is_iceberg_remote_catalog from data_gen import * from iceberg import (create_iceberg_table, get_full_table_name, iceberg_write_enabled_conf, @@ -224,6 +225,7 @@ def merge_data(spark, target_table): @iceberg +@ignore_order(local=True) @pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) def test_iceberg_merge_v3_gpu_writes_deletion_vectors(spark_tmp_table_factory): base_table_name = get_full_table_name(spark_tmp_table_factory) @@ -258,23 +260,21 @@ def insert_data(spark, table_name, data_gen_func): with_cpu_session(lambda spark: insert_data(spark, gpu_table_name, target_data_gen)) with_cpu_session(lambda spark: insert_data(spark, source_table, source_data_gen)) - def merge_data(spark, table_name): + def merge_data(spark): + is_gpu = spark.conf.get('spark.rapids.sql.enabled') == 'true' + table_name = gpu_table_name if is_gpu else cpu_table_name spark.sql(f""" MERGE INTO {table_name} t USING {source_table} s ON t.id = s.id WHEN MATCHED THEN UPDATE SET value = s.value """) + return spark.table(table_name) - with_cpu_session(lambda spark: merge_data(spark, cpu_table_name)) write_conf = copy_and_update(iceberg_write_enabled_conf, { 'spark.rapids.sql.format.iceberg.v3.enabled': 'true' }) - with_gpu_session(lambda spark: merge_data(spark, gpu_table_name), conf=write_conf) - - cpu_data = with_cpu_session(lambda spark: spark.table(cpu_table_name).collect()) - gpu_data = with_cpu_session(lambda spark: spark.table(gpu_table_name).collect()) - assert_equal_with_local_sort(cpu_data, gpu_data) + assert_gpu_and_cpu_are_equal_collect(merge_data, conf=write_conf) @allow_non_gpu("MergeRows$Keep", "MergeRows$Discard", "MergeRows$Split") diff --git a/integration_tests/src/main/python/iceberg/iceberg_update_test.py b/integration_tests/src/main/python/iceberg/iceberg_update_test.py index 15cf3a86197..3f845c2ea56 100644 --- a/integration_tests/src/main/python/iceberg/iceberg_update_test.py +++ b/integration_tests/src/main/python/iceberg/iceberg_update_test.py @@ -14,7 +14,8 @@ import pytest -from asserts import assert_equal_with_local_sort, assert_gpu_fallback_write_sql +from asserts import (assert_equal_with_local_sort, assert_gpu_and_cpu_are_equal_collect, + assert_gpu_fallback_write_sql) from conftest import is_iceberg_remote_catalog from data_gen import * from iceberg import (create_iceberg_table, get_full_table_name, iceberg_write_enabled_conf, @@ -159,6 +160,7 @@ def update_data(spark, table_name): @iceberg +@ignore_order(local=True) @pytest.mark.skipif(not supports_iceberg_v3, reason=ICEBERG_V3_UNSUPPORTED_REASON) def test_iceberg_update_v3_gpu_writes_deletion_vectors(spark_tmp_table_factory): base_table_name = get_full_table_name(spark_tmp_table_factory) @@ -178,18 +180,16 @@ def test_iceberg_update_v3_gpu_writes_deletion_vectors(spark_tmp_table_factory): gpu_table_name, data_gen_func=data_gen_func, table_properties=table_properties, update_mode='merge-on-read') - def update_data(spark, table_name): + def update_data(spark): + is_gpu = spark.conf.get('spark.rapids.sql.enabled') == 'true' + table_name = gpu_table_name if is_gpu else cpu_table_name spark.sql(f"UPDATE {table_name} SET value = value + 1000 WHERE id % 4 = 0") + return spark.table(table_name) - with_cpu_session(lambda spark: update_data(spark, cpu_table_name)) write_conf = copy_and_update(iceberg_write_enabled_conf, { 'spark.rapids.sql.format.iceberg.v3.enabled': 'true' }) - with_gpu_session(lambda spark: update_data(spark, gpu_table_name), conf=write_conf) - - cpu_data = with_cpu_session(lambda spark: spark.table(cpu_table_name).collect()) - gpu_data = with_cpu_session(lambda spark: spark.table(gpu_table_name).collect()) - assert_equal_with_local_sort(cpu_data, gpu_data) + assert_gpu_and_cpu_are_equal_collect(update_data, conf=write_conf) @iceberg From 2d8bd6d8450fd74bfc931b4c122e199ed0bef9f8 Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Wed, 2 Sep 2026 11:28:57 +0800 Subject: [PATCH 18/18] Refactor Iceberg 1.9+ deletion vector shims Signed-off-by: Ray Liu --- .../rapids/iceberg/IcebergShimUtils.java | 13 +- .../source/GpuSparkPositionDeltaWrite.scala | 25 ++-- iceberg/iceberg-1-10-x/pom.xml | 1 + .../iceberg/iceberg110x/ShimUtilsImpl.java | 95 +------------- iceberg/iceberg-1-11-x/pom.xml | 1 + .../iceberg/iceberg111x/ShimUtilsImpl.java | 95 +------------- .../GpuSparkPositionDeltaWriteAccess.java | 68 ---------- iceberg/iceberg-1-9-x/pom.xml | 1 + .../iceberg/iceberg19x/ShimUtilsImpl.java | 89 +------------- .../GpuSparkPositionDeltaWriteAccess.java | 68 ---------- .../iceberg/Iceberg19PlusShimUtils.java | 116 ++++++++++++++++++ .../GpuSparkPositionDeltaWriteAccess.java | 15 ++- scala2.13/iceberg/iceberg-1-10-x/pom.xml | 1 + scala2.13/iceberg/iceberg-1-11-x/pom.xml | 1 + scala2.13/iceberg/iceberg-1-9-x/pom.xml | 1 + 15 files changed, 166 insertions(+), 424 deletions(-) delete mode 100644 iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/iceberg111x/GpuSparkPositionDeltaWriteAccess.java delete mode 100644 iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/iceberg19x/GpuSparkPositionDeltaWriteAccess.java create mode 100644 iceberg/iceberg-19plus-common/src/main/java/com/nvidia/spark/rapids/iceberg/Iceberg19PlusShimUtils.java rename iceberg/{iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/iceberg110x => iceberg-19plus-common/src/main/java/org/apache/iceberg/spark/source}/GpuSparkPositionDeltaWriteAccess.java (74%) diff --git a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java index 26c67578179..4ce46b6bf60 100644 --- a/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java +++ b/iceberg/common/src/main/java/com/nvidia/spark/rapids/iceberg/IcebergShimUtils.java @@ -74,7 +74,11 @@ public interface IcebergShimUtils { /** Returns whether a resolved delete-file format writes Puffin deletion vectors. */ boolean isPuffinFormat(FileFormat fileFormat); - /** Returns delete files that Iceberg requires a deletion-vector write to replace. */ + /** + * Returns delete files that Iceberg requires a deletion-vector write to replace. + * + * @return an opaque handle, or {@code null} when there are no existing deletes to rewrite + */ RewritableDeletes broadcastRewritableDeletes( DeltaBatchWrite write); @@ -82,13 +86,16 @@ RewritableDeletes broadcastRewritableDeletes( * Opaque, serializable handle for version-specific rewritable-delete state. * *

The underlying broadcast value uses Iceberg's {@code DeleteFileSet}, which is absent - * from Iceberg 1.6. Each supported shim supplies a concrete implementation so the common - * module does not expose an API that is unavailable in older Iceberg versions. + * from Iceberg 1.6. Iceberg 1.9 and later share an implementation outside the common module + * so this interface does not expose an API that is unavailable in older Iceberg versions. */ interface RewritableDeletes extends Serializable {} /** * Creates Iceberg's version-specific deletion-vector writer. + * + * @param rewritableDeletes existing deletes to merge and replace, or {@code null} when the + * data file has no existing deletes */ PartitioningWriter, DeleteWriteResult> newDeletionVectorWriter( diff --git a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala index a861648e41a..ba5d8ea8319 100644 --- a/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala +++ b/iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWrite.scala @@ -483,6 +483,8 @@ class GpuBatchPositionDeleteWriter( private val delegate: PartitioningWriter[PositionDelete[InternalRow], DeleteWriteResult]) extends PartitioningWriter[SpillableColumnarBatch, DeleteWriteResult] { + require(delegate != null, "delegate must not be null") + private val positionDelete = PositionDelete.create[InternalRow]() override def write( @@ -490,14 +492,21 @@ class GpuBatchPositionDeleteWriter( spec: PartitionSpec, partition: StructLike): Unit = { withResource(spillableBatch) { spillable => - withResource(spillable.getColumnarBatch()) { batch => - withResource(batch.column(0).asInstanceOf[GpuColumnVector].copyToHost()) { paths => - withResource(batch.column(1).asInstanceOf[GpuColumnVector].copyToHost()) { positions => - for (row <- 0 until batch.numRows()) { - ShimUtils.setPositionDelete( - positionDelete, paths.getUTF8String(row).toString, positions.getLong(row)) - delegate.write(positionDelete, spec, partition) - } + val (paths, positions, numRows) = withResource(spillable.getColumnarBatch()) { batch => + closeOnExcept(batch.column(0).asInstanceOf[GpuColumnVector].copyToHost()) { paths => + closeOnExcept(batch.column(1).asInstanceOf[GpuColumnVector].copyToHost()) { positions => + (paths, positions, batch.numRows()) + } + } + } + withResource(paths) { pathColumn => + withResource(positions) { positionColumn => + for (row <- 0 until numRows) { + ShimUtils.setPositionDelete( + positionDelete, + pathColumn.getUTF8String(row).toString, + positionColumn.getLong(row)) + delegate.write(positionDelete, spec, partition) } } } diff --git a/iceberg/iceberg-1-10-x/pom.xml b/iceberg/iceberg-1-10-x/pom.xml index 0b3b2926c33..32ed835abf2 100644 --- a/iceberg/iceberg-1-10-x/pom.xml +++ b/iceberg/iceberg-1-10-x/pom.xml @@ -84,6 +84,7 @@ ${spark.rapids.source.basedir}/iceberg/common/src/main/java ${spark.rapids.source.basedir}/iceberg/common/src/main/scala + ${spark.rapids.source.basedir}/iceberg/iceberg-19plus-common/src/main/java diff --git a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java index f00ef4b15ce..6ca849f9487 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-10-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg110x/ShimUtilsImpl.java @@ -20,46 +20,29 @@ import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.fileio.iceberg.IcebergInputFile; import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; -import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; +import com.nvidia.spark.rapids.iceberg.Iceberg19PlusShimUtils; import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.hadoop.fs.Path; import org.apache.iceberg.*; -import org.apache.iceberg.data.BaseDeleteLoader; -import org.apache.iceberg.deletes.PositionDelete; -import org.apache.iceberg.deletes.PositionDeleteIndex; -import org.apache.iceberg.encryption.EncryptingFileIO; import org.apache.iceberg.io.FileIO; -import org.apache.iceberg.io.DataWriteResult; -import org.apache.iceberg.io.DeleteWriteResult; -import org.apache.iceberg.io.OutputFileFactory; -import org.apache.iceberg.io.PartitioningDVWriter; -import org.apache.iceberg.io.PartitioningWriter; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; -import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.SparkUtil; import org.apache.iceberg.spark.source.GpuSparkCopyOnWriteV1Scan; -import org.apache.iceberg.spark.source.iceberg110x.GpuSparkPositionDeltaWriteAccess; import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; -import org.apache.iceberg.util.DeleteFileSet; import org.apache.iceberg.util.PartitionUtil; -import org.apache.spark.broadcast.Broadcast; -import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; -import org.apache.spark.sql.connector.write.DeltaBatchWrite; import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.Map; -import java.util.Set; -import java.util.function.Function; /** Iceberg 1.10.x shim: uses {@code SparkUtil::internalToSpark} and a cache-aware footer path. */ -public class ShimUtilsImpl implements IcebergShimUtils { +public class ShimUtilsImpl extends Iceberg19PlusShimUtils { @Override public int formatVersion(Table table) { return TableUtil.formatVersion(table); @@ -70,80 +53,6 @@ public String locationOf(ContentFile f) { return f.location(); } - @Override - public boolean isDeletionVector(DeleteFile deleteFile) { - return deleteFile.format() == FileFormat.PUFFIN; - } - - @Override - public boolean isPuffinFormat(FileFormat fileFormat) { - return fileFormat == FileFormat.PUFFIN; - } - - @Override - public RewritableDeletes broadcastRewritableDeletes( - DeltaBatchWrite write) { - Broadcast> rewritableDeletes = - GpuSparkPositionDeltaWriteAccess.broadcastRewritableDeletes(write); - return rewritableDeletes != null ? new RewritableDeletesImpl(rewritableDeletes) : null; - } - - @Override - public PartitioningWriter, DeleteWriteResult> - newDeletionVectorWriter( - Table table, OutputFileFactory fileFactory, - RewritableDeletes rewritableDeletes) { - Map deleteFiles = rewritableDeletes == null - ? null - : ((RewritableDeletesImpl) rewritableDeletes).value(); - return new PartitioningDVWriter<>( - fileFactory, previousDeleteLoader(table, deleteFiles)); - } - - @Override - public WriteResult positionDeltaWriteResult( - DataWriteResult dataResult, DeleteWriteResult deleteResult) { - return WriteResult.builder() - .addDataFiles(dataResult.dataFiles()) - .addDeleteFiles(deleteResult.deleteFiles()) - .addReferencedDataFiles(deleteResult.referencedDataFiles()) - .addRewrittenDeleteFiles(deleteResult.rewrittenDeleteFiles()) - .build(); - } - - private Function previousDeleteLoader( - Table table, Map rewritableDeletes) { - if (rewritableDeletes == null) { - return path -> null; - } - - BaseDeleteLoader deleteLoader = new BaseDeleteLoader( - deleteFile -> EncryptingFileIO.combine(table.io(), table.encryption()) - .newInputFile(deleteFile)); - return path -> { - Set files = rewritableDeletes.get(path.toString()); - return files != null ? deleteLoader.loadPositionDeletes(files, path) : null; - }; - } - - private static final class RewritableDeletesImpl implements RewritableDeletes { - private final Broadcast> delegate; - - private RewritableDeletesImpl(Broadcast> delegate) { - this.delegate = delegate; - } - - private Map value() { - return delegate.value(); - } - } - - @Override - public void setPositionDelete( - PositionDelete delete, CharSequence path, long position) { - delete.set(path, position); - } - @Override public IcebergDeletionVector readDeletionVector( DeleteFile deleteFile, RapidsInputFile inputFile, boolean validateCrc) diff --git a/iceberg/iceberg-1-11-x/pom.xml b/iceberg/iceberg-1-11-x/pom.xml index e7e3c290f0c..012af9cc3b8 100644 --- a/iceberg/iceberg-1-11-x/pom.xml +++ b/iceberg/iceberg-1-11-x/pom.xml @@ -84,6 +84,7 @@ ${spark.rapids.source.basedir}/iceberg/common/src/main/java ${spark.rapids.source.basedir}/iceberg/common/src/main/scala + ${spark.rapids.source.basedir}/iceberg/iceberg-19plus-common/src/main/java diff --git a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java index edc1e752fe8..a5c50f70160 100644 --- a/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-11-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg111x/ShimUtilsImpl.java @@ -20,46 +20,29 @@ import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.fileio.iceberg.IcebergInputFile; import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; -import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; +import com.nvidia.spark.rapids.iceberg.Iceberg19PlusShimUtils; import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.hadoop.fs.Path; import org.apache.iceberg.*; -import org.apache.iceberg.data.BaseDeleteLoader; -import org.apache.iceberg.deletes.PositionDelete; -import org.apache.iceberg.deletes.PositionDeleteIndex; -import org.apache.iceberg.encryption.EncryptingFileIO; import org.apache.iceberg.io.FileIO; -import org.apache.iceberg.io.DataWriteResult; -import org.apache.iceberg.io.DeleteWriteResult; -import org.apache.iceberg.io.OutputFileFactory; -import org.apache.iceberg.io.PartitioningDVWriter; -import org.apache.iceberg.io.PartitioningWriter; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; -import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.shaded.org.apache.parquet.ParquetReadOptions; import org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader; import org.apache.iceberg.spark.SparkUtil; import org.apache.iceberg.spark.source.GpuSparkCopyOnWriteScan; -import org.apache.iceberg.spark.source.iceberg111x.GpuSparkPositionDeltaWriteAccess; import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; -import org.apache.iceberg.util.DeleteFileSet; import org.apache.iceberg.util.PartitionUtil; -import org.apache.spark.broadcast.Broadcast; -import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; -import org.apache.spark.sql.connector.write.DeltaBatchWrite; import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.Map; -import java.util.Set; -import java.util.function.Function; /** Iceberg 1.11.x shim: uses {@code SparkUtil::internalToSpark} and a cache-aware footer path. */ -public class ShimUtilsImpl implements IcebergShimUtils { +public class ShimUtilsImpl extends Iceberg19PlusShimUtils { @Override public int formatVersion(Table table) { return TableUtil.formatVersion(table); @@ -70,80 +53,6 @@ public String locationOf(ContentFile f) { return f.location(); } - @Override - public boolean isDeletionVector(DeleteFile deleteFile) { - return deleteFile.format() == FileFormat.PUFFIN; - } - - @Override - public boolean isPuffinFormat(FileFormat fileFormat) { - return fileFormat == FileFormat.PUFFIN; - } - - @Override - public RewritableDeletes broadcastRewritableDeletes( - DeltaBatchWrite write) { - Broadcast> rewritableDeletes = - GpuSparkPositionDeltaWriteAccess.broadcastRewritableDeletes(write); - return rewritableDeletes != null ? new RewritableDeletesImpl(rewritableDeletes) : null; - } - - @Override - public PartitioningWriter, DeleteWriteResult> - newDeletionVectorWriter( - Table table, OutputFileFactory fileFactory, - RewritableDeletes rewritableDeletes) { - Map deleteFiles = rewritableDeletes == null - ? null - : ((RewritableDeletesImpl) rewritableDeletes).value(); - return new PartitioningDVWriter<>( - fileFactory, previousDeleteLoader(table, deleteFiles)); - } - - @Override - public WriteResult positionDeltaWriteResult( - DataWriteResult dataResult, DeleteWriteResult deleteResult) { - return WriteResult.builder() - .addDataFiles(dataResult.dataFiles()) - .addDeleteFiles(deleteResult.deleteFiles()) - .addReferencedDataFiles(deleteResult.referencedDataFiles()) - .addRewrittenDeleteFiles(deleteResult.rewrittenDeleteFiles()) - .build(); - } - - private Function previousDeleteLoader( - Table table, Map rewritableDeletes) { - if (rewritableDeletes == null) { - return path -> null; - } - - BaseDeleteLoader deleteLoader = new BaseDeleteLoader( - deleteFile -> EncryptingFileIO.combine(table.io(), table.encryption()) - .newInputFile(deleteFile)); - return path -> { - Set files = rewritableDeletes.get(path.toString()); - return files != null ? deleteLoader.loadPositionDeletes(files, path) : null; - }; - } - - private static final class RewritableDeletesImpl implements RewritableDeletes { - private final Broadcast> delegate; - - private RewritableDeletesImpl(Broadcast> delegate) { - this.delegate = delegate; - } - - private Map value() { - return delegate.value(); - } - } - - @Override - public void setPositionDelete( - PositionDelete delete, CharSequence path, long position) { - delete.set(path, position); - } - @Override public IcebergDeletionVector readDeletionVector( DeleteFile deleteFile, RapidsInputFile inputFile, boolean validateCrc) diff --git a/iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/iceberg111x/GpuSparkPositionDeltaWriteAccess.java b/iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/iceberg111x/GpuSparkPositionDeltaWriteAccess.java deleted file mode 100644 index 11d6b1f5c02..00000000000 --- a/iceberg/iceberg-1-11-x/src/main/java/org/apache/iceberg/spark/source/iceberg111x/GpuSparkPositionDeltaWriteAccess.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2026, NVIDIA CORPORATION. - * - * Licensed 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.iceberg.spark.source.iceberg111x; - -import java.lang.reflect.Method; -import java.util.Map; - -import org.apache.iceberg.util.DeleteFileSet; -import org.apache.spark.broadcast.Broadcast; -import org.apache.spark.sql.connector.write.DeltaBatchWrite; - -/** Iceberg 1.11.x-specific access to position-delta batch-write internals. */ -public final class GpuSparkPositionDeltaWriteAccess { - private static final ClassValue BROADCAST_REWRITABLE_DELETES_METHOD = - new ClassValue() { - @Override - protected Method computeValue(Class type) { - Method method = findMethod(type, "broadcastRewritableDeletes"); - method.setAccessible(true); - return method; - } - }; - - private GpuSparkPositionDeltaWriteAccess() { - } - - /** - * Calls - * {@code SparkPositionDeltaWrite.PositionDeltaBatchWrite.broadcastRewritableDeletes()}. - */ - @SuppressWarnings("unchecked") - public static Broadcast> broadcastRewritableDeletes( - DeltaBatchWrite write) { - try { - Method method = BROADCAST_REWRITABLE_DELETES_METHOD.get(write.getClass()); - return (Broadcast>) method.invoke(write); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException( - "Unable to broadcast rewritable deletes from " + write.getClass().getName(), e); - } - } - - private static Method findMethod(Class targetClass, String methodName) { - Class current = targetClass; - while (current != null) { - try { - return current.getDeclaredMethod(methodName); - } catch (NoSuchMethodException e) { - current = current.getSuperclass(); - } - } - throw new IllegalStateException("No method " + methodName + " in " + targetClass.getName()); - } -} diff --git a/iceberg/iceberg-1-9-x/pom.xml b/iceberg/iceberg-1-9-x/pom.xml index 1064213fcc0..6361f5d72b4 100644 --- a/iceberg/iceberg-1-9-x/pom.xml +++ b/iceberg/iceberg-1-9-x/pom.xml @@ -74,6 +74,7 @@ ${spark.rapids.source.basedir}/iceberg/common/src/main/java ${spark.rapids.source.basedir}/iceberg/common/src/main/scala + ${spark.rapids.source.basedir}/iceberg/iceberg-19plus-common/src/main/java diff --git a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java index eec234ebeb4..ae58ba9f99a 100644 --- a/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java +++ b/iceberg/iceberg-1-9-x/src/main/java/com/nvidia/spark/rapids/iceberg/iceberg19x/ShimUtilsImpl.java @@ -18,37 +18,26 @@ import com.nvidia.spark.rapids.RapidsConf; import com.nvidia.spark.rapids.iceberg.IcebergDeletionVector; -import com.nvidia.spark.rapids.iceberg.IcebergShimUtils; +import com.nvidia.spark.rapids.iceberg.Iceberg19PlusShimUtils; import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile; import org.apache.iceberg.*; -import org.apache.iceberg.data.BaseDeleteLoader; -import org.apache.iceberg.deletes.PositionDelete; -import org.apache.iceberg.deletes.PositionDeleteIndex; -import org.apache.iceberg.encryption.EncryptingFileIO; import org.apache.iceberg.io.*; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsStorageCredentials; import org.apache.iceberg.spark.SparkUtil; import org.apache.iceberg.spark.source.GpuSparkCopyOnWriteV1Scan; -import org.apache.iceberg.spark.source.iceberg19x.GpuSparkPositionDeltaWriteAccess; import org.apache.iceberg.spark.source.GpuSparkScan; import org.apache.iceberg.types.Types; -import org.apache.iceberg.util.DeleteFileSet; import org.apache.iceberg.util.PartitionUtil; -import org.apache.spark.broadcast.Broadcast; -import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.read.Scan; -import org.apache.spark.sql.connector.write.DeltaBatchWrite; import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.Map; -import java.util.Set; -import java.util.function.Function; /** Iceberg 1.9.x shim: uses {@code SparkUtil::internalToSpark}. */ -public class ShimUtilsImpl implements IcebergShimUtils { +public class ShimUtilsImpl extends Iceberg19PlusShimUtils { @Override public int formatVersion(Table table) { return TableUtil.formatVersion(table); @@ -59,80 +48,6 @@ public String locationOf(ContentFile f) { return f.location(); } - @Override - public boolean isDeletionVector(DeleteFile deleteFile) { - return deleteFile.format() == FileFormat.PUFFIN; - } - - @Override - public boolean isPuffinFormat(FileFormat fileFormat) { - return fileFormat == FileFormat.PUFFIN; - } - - @Override - public RewritableDeletes broadcastRewritableDeletes( - DeltaBatchWrite write) { - Broadcast> rewritableDeletes = - GpuSparkPositionDeltaWriteAccess.broadcastRewritableDeletes(write); - return rewritableDeletes != null ? new RewritableDeletesImpl(rewritableDeletes) : null; - } - - @Override - public PartitioningWriter, DeleteWriteResult> - newDeletionVectorWriter( - Table table, OutputFileFactory fileFactory, - RewritableDeletes rewritableDeletes) { - Map deleteFiles = rewritableDeletes == null - ? null - : ((RewritableDeletesImpl) rewritableDeletes).value(); - return new PartitioningDVWriter<>( - fileFactory, previousDeleteLoader(table, deleteFiles)); - } - - @Override - public WriteResult positionDeltaWriteResult( - DataWriteResult dataResult, DeleteWriteResult deleteResult) { - return WriteResult.builder() - .addDataFiles(dataResult.dataFiles()) - .addDeleteFiles(deleteResult.deleteFiles()) - .addReferencedDataFiles(deleteResult.referencedDataFiles()) - .addRewrittenDeleteFiles(deleteResult.rewrittenDeleteFiles()) - .build(); - } - - private Function previousDeleteLoader( - Table table, Map rewritableDeletes) { - if (rewritableDeletes == null) { - return path -> null; - } - - BaseDeleteLoader deleteLoader = new BaseDeleteLoader( - deleteFile -> EncryptingFileIO.combine(table.io(), table.encryption()) - .newInputFile(deleteFile)); - return path -> { - Set files = rewritableDeletes.get(path.toString()); - return files != null ? deleteLoader.loadPositionDeletes(files, path) : null; - }; - } - - private static final class RewritableDeletesImpl implements RewritableDeletes { - private final Broadcast> delegate; - - private RewritableDeletesImpl(Broadcast> delegate) { - this.delegate = delegate; - } - - private Map value() { - return delegate.value(); - } - } - - @Override - public void setPositionDelete( - PositionDelete delete, CharSequence path, long position) { - delete.set(path, position); - } - @Override public IcebergDeletionVector readDeletionVector( DeleteFile deleteFile, RapidsInputFile inputFile, boolean validateCrc) diff --git a/iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/iceberg19x/GpuSparkPositionDeltaWriteAccess.java b/iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/iceberg19x/GpuSparkPositionDeltaWriteAccess.java deleted file mode 100644 index 1f28ea2330f..00000000000 --- a/iceberg/iceberg-1-9-x/src/main/java/org/apache/iceberg/spark/source/iceberg19x/GpuSparkPositionDeltaWriteAccess.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2026, NVIDIA CORPORATION. - * - * Licensed 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.iceberg.spark.source.iceberg19x; - -import java.lang.reflect.Method; -import java.util.Map; - -import org.apache.iceberg.util.DeleteFileSet; -import org.apache.spark.broadcast.Broadcast; -import org.apache.spark.sql.connector.write.DeltaBatchWrite; - -/** Iceberg 1.9.x-specific access to position-delta batch-write internals. */ -public final class GpuSparkPositionDeltaWriteAccess { - private static final ClassValue BROADCAST_REWRITABLE_DELETES_METHOD = - new ClassValue() { - @Override - protected Method computeValue(Class type) { - Method method = findMethod(type, "broadcastRewritableDeletes"); - method.setAccessible(true); - return method; - } - }; - - private GpuSparkPositionDeltaWriteAccess() { - } - - /** - * Calls - * {@code SparkPositionDeltaWrite.PositionDeltaBatchWrite.broadcastRewritableDeletes()}. - */ - @SuppressWarnings("unchecked") - public static Broadcast> broadcastRewritableDeletes( - DeltaBatchWrite write) { - try { - Method method = BROADCAST_REWRITABLE_DELETES_METHOD.get(write.getClass()); - return (Broadcast>) method.invoke(write); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException( - "Unable to broadcast rewritable deletes from " + write.getClass().getName(), e); - } - } - - private static Method findMethod(Class targetClass, String methodName) { - Class current = targetClass; - while (current != null) { - try { - return current.getDeclaredMethod(methodName); - } catch (NoSuchMethodException e) { - current = current.getSuperclass(); - } - } - throw new IllegalStateException("No method " + methodName + " in " + targetClass.getName()); - } -} diff --git a/iceberg/iceberg-19plus-common/src/main/java/com/nvidia/spark/rapids/iceberg/Iceberg19PlusShimUtils.java b/iceberg/iceberg-19plus-common/src/main/java/com/nvidia/spark/rapids/iceberg/Iceberg19PlusShimUtils.java new file mode 100644 index 00000000000..6dd4b6fc939 --- /dev/null +++ b/iceberg/iceberg-19plus-common/src/main/java/com/nvidia/spark/rapids/iceberg/Iceberg19PlusShimUtils.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * Licensed 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 com.nvidia.spark.rapids.iceberg; + +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.BaseDeleteLoader; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.deletes.PositionDeleteIndex; +import org.apache.iceberg.encryption.EncryptingFileIO; +import org.apache.iceberg.io.DataWriteResult; +import org.apache.iceberg.io.DeleteWriteResult; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.io.PartitioningDVWriter; +import org.apache.iceberg.io.PartitioningWriter; +import org.apache.iceberg.io.WriteResult; +import org.apache.iceberg.spark.source.GpuSparkPositionDeltaWriteAccess; +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.write.DeltaBatchWrite; + +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +/** Shared deletion-vector shim implementation for Iceberg 1.9 and later. */ +public abstract class Iceberg19PlusShimUtils implements IcebergShimUtils { + @Override + public boolean isDeletionVector(DeleteFile deleteFile) { + return deleteFile.format() == FileFormat.PUFFIN; + } + + @Override + public boolean isPuffinFormat(FileFormat fileFormat) { + return fileFormat == FileFormat.PUFFIN; + } + + @Override + public RewritableDeletes broadcastRewritableDeletes(DeltaBatchWrite write) { + Broadcast> rewritableDeletes = + GpuSparkPositionDeltaWriteAccess.broadcastRewritableDeletes(write); + return rewritableDeletes != null ? new RewritableDeletesImpl(rewritableDeletes) : null; + } + + @Override + public PartitioningWriter, DeleteWriteResult> + newDeletionVectorWriter( + Table table, OutputFileFactory fileFactory, + RewritableDeletes rewritableDeletes) { + Map deleteFiles = rewritableDeletes == null + ? null + : ((RewritableDeletesImpl) rewritableDeletes).value(); + return new PartitioningDVWriter<>( + fileFactory, previousDeleteLoader(table, deleteFiles)); + } + + @Override + public WriteResult positionDeltaWriteResult( + DataWriteResult dataResult, DeleteWriteResult deleteResult) { + return WriteResult.builder() + .addDataFiles(dataResult.dataFiles()) + .addDeleteFiles(deleteResult.deleteFiles()) + .addReferencedDataFiles(deleteResult.referencedDataFiles()) + .addRewrittenDeleteFiles(deleteResult.rewrittenDeleteFiles()) + .build(); + } + + @Override + public void setPositionDelete( + PositionDelete delete, CharSequence path, long position) { + delete.set(path, position); + } + + private static Function previousDeleteLoader( + Table table, Map rewritableDeletes) { + if (rewritableDeletes == null) { + return path -> null; + } + + BaseDeleteLoader deleteLoader = new BaseDeleteLoader( + deleteFile -> EncryptingFileIO.combine(table.io(), table.encryption()) + .newInputFile(deleteFile)); + return path -> { + Set files = rewritableDeletes.get(path.toString()); + return files != null ? deleteLoader.loadPositionDeletes(files, path) : null; + }; + } + + private static final class RewritableDeletesImpl implements RewritableDeletes { + private final Broadcast> delegate; + + private RewritableDeletesImpl(Broadcast> delegate) { + this.delegate = delegate; + } + + private Map value() { + return delegate.value(); + } + } +} diff --git a/iceberg/iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/iceberg110x/GpuSparkPositionDeltaWriteAccess.java b/iceberg/iceberg-19plus-common/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java similarity index 74% rename from iceberg/iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/iceberg110x/GpuSparkPositionDeltaWriteAccess.java rename to iceberg/iceberg-19plus-common/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java index 6df80bf7b95..3e77027119c 100644 --- a/iceberg/iceberg-1-10-x/src/main/java/org/apache/iceberg/spark/source/iceberg110x/GpuSparkPositionDeltaWriteAccess.java +++ b/iceberg/iceberg-19plus-common/src/main/java/org/apache/iceberg/spark/source/GpuSparkPositionDeltaWriteAccess.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.apache.iceberg.spark.source.iceberg110x; +package org.apache.iceberg.spark.source; import java.lang.reflect.Method; import java.util.Map; @@ -23,7 +23,7 @@ import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.connector.write.DeltaBatchWrite; -/** Iceberg 1.10.x-specific access to position-delta batch-write internals. */ +/** Access to position-delta batch-write internals shared by Iceberg 1.9 and later. */ public final class GpuSparkPositionDeltaWriteAccess { private static final ClassValue BROADCAST_REWRITABLE_DELETES_METHOD = new ClassValue() { @@ -39,8 +39,15 @@ private GpuSparkPositionDeltaWriteAccess() { } /** - * Calls - * {@code SparkPositionDeltaWrite.PositionDeltaBatchWrite.broadcastRewritableDeletes()}. + * Returns the delete files that Iceberg's CPU batch write selected for replacement. + * + *

Iceberg keeps {@code broadcastRewritableDeletes()} private on its position-delta batch + * writer, but the GPU deletion-vector writer must use the same selection when merging an + * existing deletion vector. Package placement cannot access a private member, so this helper + * uses reflection and caches the resolved method per runtime class and class loader. + * + * @return the broadcast delete-file map, or {@code null} when there are no existing deletes + * to rewrite, such as the first deletion-vector write for a data file */ @SuppressWarnings("unchecked") public static Broadcast> broadcastRewritableDeletes( diff --git a/scala2.13/iceberg/iceberg-1-10-x/pom.xml b/scala2.13/iceberg/iceberg-1-10-x/pom.xml index 5ef88591020..42a2e6cd6a3 100644 --- a/scala2.13/iceberg/iceberg-1-10-x/pom.xml +++ b/scala2.13/iceberg/iceberg-1-10-x/pom.xml @@ -84,6 +84,7 @@ ${spark.rapids.source.basedir}/iceberg/common/src/main/java ${spark.rapids.source.basedir}/iceberg/common/src/main/scala + ${spark.rapids.source.basedir}/iceberg/iceberg-19plus-common/src/main/java diff --git a/scala2.13/iceberg/iceberg-1-11-x/pom.xml b/scala2.13/iceberg/iceberg-1-11-x/pom.xml index 73a3ff63484..c17c4a510c5 100644 --- a/scala2.13/iceberg/iceberg-1-11-x/pom.xml +++ b/scala2.13/iceberg/iceberg-1-11-x/pom.xml @@ -84,6 +84,7 @@ ${spark.rapids.source.basedir}/iceberg/common/src/main/java ${spark.rapids.source.basedir}/iceberg/common/src/main/scala + ${spark.rapids.source.basedir}/iceberg/iceberg-19plus-common/src/main/java diff --git a/scala2.13/iceberg/iceberg-1-9-x/pom.xml b/scala2.13/iceberg/iceberg-1-9-x/pom.xml index 989a536f6d5..ba311489ca7 100644 --- a/scala2.13/iceberg/iceberg-1-9-x/pom.xml +++ b/scala2.13/iceberg/iceberg-1-9-x/pom.xml @@ -74,6 +74,7 @@ ${spark.rapids.source.basedir}/iceberg/common/src/main/java ${spark.rapids.source.basedir}/iceberg/common/src/main/scala + ${spark.rapids.source.basedir}/iceberg/iceberg-19plus-common/src/main/java