From 373ae5f1601d5bd9d6845d5e8e710ff8cfe927c9 Mon Sep 17 00:00:00 2001 From: Rahul Prabhu Date: Tue, 1 Sep 2026 13:07:03 -0700 Subject: [PATCH 1/5] Init Commit Signed-off-by: Rahul Prabhu --- .../delta/common/DeleteCommandMetaBase.scala | 11 +- .../GpuDeltaParquetFileFormatBase.scala | 36 +++- .../GpuDeltaParquetFileFormatBase2.scala | 62 ++++-- .../common/MergeIntoCommandMetaBase.scala | 12 +- .../delta/common/RapidsDeletionVectors.scala | 17 ++ .../delta/common/UpdateCommandMetaBase.scala | 12 +- .../sql/delta/rapids/DeltaCommandShims.scala | 10 + .../delta/rapids/GpuDeleteCommandBase.scala | 46 ++++- .../GpuDeletionVectorBitmapGenerator.scala | 177 ++++++++++++++++++ .../delta/rapids/GpuUpdateCommandBase.scala | 81 +++++--- .../DMLWithDeletionVectorsHelperShims.scala | 71 +++++++ .../delta/rapids/GpuMergeIntoCommand.scala | 150 +++++++++++---- .../rapids/ClassicSparkCommandShims.scala | 8 + .../delta/delta33x/Delta33xProvider.scala | 8 +- .../delta33x/Delta33xCommandShims.scala | 9 + .../GpuCheckOverflowInTableWrite.scala | 68 +++++++ .../rapids/delta33x/GpuMergeIntoCommand.scala | 145 ++++++++++---- .../delta/delta40x/Delta40xProvider.scala | 2 +- .../DMLWithDeletionVectorsHelperShims.scala | 77 ++++++++ .../delta/delta41x/Delta41xProvider.scala | 2 +- .../DMLWithDeletionVectorsHelperShims.scala | 84 +++++++++ .../src/main/python/delta_lake_delete_test.py | 48 ++--- .../main/python/delta_lake_merge_common.py | 5 +- .../src/main/python/delta_lake_merge_test.py | 30 +-- .../src/main/python/delta_lake_update_test.py | 85 +++++++-- .../src/main/python/delta_lake_utils.py | 62 +++++- .../spark/rapids/parquet/GpuParquetScan.scala | 8 +- 27 files changed, 1077 insertions(+), 249 deletions(-) create mode 100644 delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala create mode 100644 delta-lake/common/src/main/delta-33x/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala create mode 100644 delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuCheckOverflowInTableWrite.scala create mode 100644 delta-lake/delta-40x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala create mode 100644 delta-lake/delta-41x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/DeleteCommandMetaBase.scala b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/DeleteCommandMetaBase.scala index bf5a86eaae0..13fa48c12bc 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/DeleteCommandMetaBase.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/DeleteCommandMetaBase.scala @@ -20,8 +20,7 @@ import com.nvidia.spark.rapids.{DataFromReplacementRule, RapidsConf, RapidsMeta, import com.nvidia.spark.rapids.delta.RapidsDeltaUtils import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.delta.commands.{DeleteCommand, DeletionVectorUtils} -import org.apache.spark.sql.delta.sources.DeltaSQLConf +import org.apache.spark.sql.delta.commands.DeleteCommand abstract class DeleteCommandMetaBase( deleteCmd: DeleteCommand, @@ -35,14 +34,6 @@ abstract class DeleteCommandMetaBase( willNotWorkOnGpu("Delta Lake output acceleration has been disabled. To enable set " + s"${RapidsConf.ENABLE_DELTA_WRITE} to true") } - val dvFeatureEnabled = DeletionVectorUtils.deletionVectorsWritable( - deleteCmd.deltaLog.unsafeVolatileSnapshot) - if (dvFeatureEnabled && deleteCmd.conf.getConf( - DeltaSQLConf.DELETE_USE_PERSISTENT_DELETION_VECTORS)) { - // https://github.com/NVIDIA/spark-rapids/issues/8554 - willNotWorkOnGpu("Deletion vectors are not supported on GPU") - } - RapidsDeltaUtils.tagForDeltaWrite(this, deleteCmd.target.schema, Some(deleteCmd.deltaLog), Map.empty, SparkSession.active) } diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/GpuDeltaParquetFileFormatBase.scala b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/GpuDeltaParquetFileFormatBase.scala index 853e8824e8a..6a49f43c1d3 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/GpuDeltaParquetFileFormatBase.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/GpuDeltaParquetFileFormatBase.scala @@ -88,8 +88,14 @@ class GpuDeltaParquetFileFormatBase( * key to remove from the metadata, which does not exist in earlier versions. */ override def prepareSchema(inputSchema: StructType): StructType = { - val schema = DeltaColumnMapping.createPhysicalSchema( - inputSchema, referenceSchema, columnMappingMode) + val internalColumnNames = Set(IS_ROW_DELETED_COLUMN_NAME, ROW_INDEX_COLUMN_NAME) + val dataSchema = StructType(inputSchema.fields.filterNot( + field => internalColumnNames.contains(field.name))) + val physicalDataFields = DeltaColumnMapping.createPhysicalSchema( + dataSchema, referenceSchema, columnMappingMode).fields.iterator + val schema = StructType(inputSchema.fields.map { field => + if (internalColumnNames.contains(field.name)) field else physicalDataFields.next() + }) if (columnMappingMode == NameMapping) { SchemaMergingUtils.transformColumns(schema) { (_, field, _) => field.copy(metadata = new MetadataBuilder() @@ -189,7 +195,6 @@ class GpuDeltaParquetFileFormatBase( // We don't have any additional columns to generate, just return the original reader as is. if (isRowDeletedColumn.isEmpty && rowIndexColumn.isEmpty) return dataReader - if (isRowDeletedColumn.isEmpty) return dataReader require(useMetadataRowIndex || !optimizationsEnabled, "Cannot generate row index related metadata with file splitting or predicate pushdown") @@ -239,7 +244,11 @@ class GpuDeltaParquetFileFormatBase( // When it is true, combining small files is disabled. Since we don't currently support // combining small files with deletion vectors, we need to disable it when deletion vectors // exist (which is when tablePath is defined). - queryUsesInputFile = hasTablePath || fileScan.queryUsesInputFile) + // Explicit row indices must restart at zero for each input file. Treat these scans as + // input-file-sensitive so the multi-threaded reader does not combine files into a partition. + queryUsesInputFile = hasTablePath || + fileScan.requiredSchema.fieldNames.contains(ROW_INDEX_COLUMN_NAME) || + fileScan.queryUsesInputFile) } } @@ -315,8 +324,12 @@ class DeltaMultiFileParquetPartitionReader( override def get(): ColumnarBatch = { val batch = reader.get() - if (isRowDeletedColumnOpt.isEmpty) { + if (isRowDeletedColumnOpt.isEmpty && rowIndexColumnOpt.isEmpty) { return batch + } else if (file == null && isRowDeletedColumnOpt.isEmpty && files.length == 1) { + file = files.head + rowIndex = 0 + rowIndexFilterOpt = None } else if (file == null || !compareFile(file)) { file = filesMap(InputFileUtils.getCurInputFilePath()) rowIndex = 0 @@ -450,6 +463,10 @@ object RapidsDeletionVectorUtils { batch: ColumnarBatch, indexVectorTuples: (Int, org.apache.spark.sql.vectorized.ColumnVector) *): ColumnarBatch = { val vectors = ArrayBuffer[org.apache.spark.sql.vectorized.ColumnVector]() + val appendedVectors = indexVectorTuples.filter(_._1 >= batch.numCols()).sortBy(_._1) + require(appendedVectors.zipWithIndex.forall { case ((index, _), offset) => + index == batch.numCols() + offset + }, "Generated metadata columns must be contiguous after the physical batch columns") for (i <- 0 until batch.numCols()) { var replaced: Boolean = false for (indexVectorTuple <- indexVectorTuples) { @@ -464,6 +481,7 @@ object RapidsDeletionVectorUtils { vectors += batch.column(i) } } + appendedVectors.foreach { case (_, vector) => vectors += vector } new ColumnarBatch(vectors.toArray, batch.numRows()) } @@ -536,9 +554,11 @@ object RapidsDeletionVectorUtils { indexVectorTuples += (rowIndexCol.index -> rowIndexGpuCol.incRefCount()) } startTime = System.nanoTime() - val isRowDeletedVector = rowIndexFilterOpt.get.materializeIntoVector(rowIndexGpuCol) - metrics("isRowDeletedColumnGenTime") += System.nanoTime() - startTime - indexVectorTuples += (isRowDeletedColumnOpt.get.index -> isRowDeletedVector) + isRowDeletedColumnOpt.foreach { isRowDeletedColumn => + val isRowDeletedVector = rowIndexFilterOpt.get.materializeIntoVector(rowIndexGpuCol) + metrics("isRowDeletedColumnGenTime") += System.nanoTime() - startTime + indexVectorTuples += (isRowDeletedColumn.index -> isRowDeletedVector) + } replaceVectors(batch, indexVectorTuples.toSeq: _*) } catch { case e: Throwable => diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/GpuDeltaParquetFileFormatBase2.scala b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/GpuDeltaParquetFileFormatBase2.scala index 8381b49fba2..009949924ed 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/GpuDeltaParquetFileFormatBase2.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/GpuDeltaParquetFileFormatBase2.scala @@ -1300,10 +1300,24 @@ case class DeltaParquetTableReader( override protected lazy val resources: Seq[AutoCloseable] = Seq(reader) ++ buffers ++ dvInfos.map(_.serializedBitmap) + private val rowIndexColumn = readDataSchema.fieldNames.indexOf(ROW_INDEX_COLUMN_NAME) + override protected def postProcessChunk(chunk: Table): Table = { - // The cuDF reader prepends an extra index column in the output table. - // We need to drop it before returning as we don't use it. - RapidsDeletionVectors.dropFirstColumn(chunk) + // Keep the prepended cuDF physical index through schema evolution when Delta requests it. + if (rowIndexColumn >= 0) chunk else RapidsDeletionVectors.dropFirstColumn(chunk) + } + + override protected def evolveSchemaAndClose(table: Table): Table = { + if (rowIndexColumn < 0) { + super.evolveSchemaAndClose(table) + } else { + withResource(table.getColumn(0).castTo(DType.INT64)) { physicalRowIndex => + val dataTable = RapidsDeletionVectors.dropFirstColumn(table) + val evolvedTable = super.evolveSchemaAndClose(dataTable) + RapidsDeletionVectors.replaceColumnAndClose( + evolvedTable, rowIndexColumn, physicalRowIndex) + } + } } } @@ -1365,24 +1379,34 @@ object MakeParquetTableWithDVProducer extends Logging { } } } - // The cuDF reader prepends an extra index column in the output table. - // We need to drop it before returning as we don't use it. - val tableWithoutIndex = RapidsDeletionVectors.dropFirstColumn(table) - closeOnExcept(tableWithoutIndex) { _ => - GpuParquetScan.throwIfRebaseNeededInExceptionMode(tableWithoutIndex, dateRebaseMode, - timestampRebaseMode) - if (readDataSchema.length < tableWithoutIndex.getNumberOfColumns) { - throw new QueryExecutionException(s"Expected ${readDataSchema.length} columns " + - s"but read ${tableWithoutIndex.getNumberOfColumns} from ${splits.mkString("; ")}") + // Preserve cuDF physical row indexes only for Delta internal row-index scans. + val rowIndexColumn = readDataSchema.fieldNames.indexOf(ROW_INDEX_COLUMN_NAME) + val physicalRowIndex = if (rowIndexColumn >= 0) { + Some(table.getColumn(0).castTo(DType.INT64)) + } else { + None + } + withResource(physicalRowIndex) { _ => + val tableWithoutIndex = RapidsDeletionVectors.dropFirstColumn(table) + closeOnExcept(tableWithoutIndex) { _ => + GpuParquetScan.throwIfRebaseNeededInExceptionMode(tableWithoutIndex, dateRebaseMode, + timestampRebaseMode) + if (readDataSchema.length < tableWithoutIndex.getNumberOfColumns) { + throw new QueryExecutionException(s"Expected ${readDataSchema.length} columns " + + s"but read ${tableWithoutIndex.getNumberOfColumns} from ${splits.mkString("; ")}") + } } + metrics(NUM_OUTPUT_BATCHES) += 1 + val evolvedSchemaTable = ParquetSchemaUtils.evolveSchemaIfNeededAndClose(tableWithoutIndex, + clippedParquetSchema, readDataSchema, isSchemaCaseSensitive, useFieldId) + val tableWithRowIndex = physicalRowIndex.map { index => + RapidsDeletionVectors.replaceColumnAndClose(evolvedSchemaTable, rowIndexColumn, index) + }.getOrElse(evolvedSchemaTable) + val outputTable = GpuParquetScan.rebaseDateTime(tableWithRowIndex, dateRebaseMode, + timestampRebaseMode) + GpuMetric.recordOutputBatchBytes(outputTable, metrics.get(GPU_OUTPUT_BATCH_BYTES)) + new SingleGpuDataProducer(outputTable) } - metrics(NUM_OUTPUT_BATCHES) += 1 - val evolvedSchemaTable = ParquetSchemaUtils.evolveSchemaIfNeededAndClose(tableWithoutIndex, - clippedParquetSchema, readDataSchema, isSchemaCaseSensitive, useFieldId) - val outputTable = GpuParquetScan.rebaseDateTime(evolvedSchemaTable, dateRebaseMode, - timestampRebaseMode) - GpuMetric.recordOutputBatchBytes(outputTable, metrics.get(GPU_OUTPUT_BATCH_BYTES)) - new SingleGpuDataProducer(outputTable) } } } diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/MergeIntoCommandMetaBase.scala b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/MergeIntoCommandMetaBase.scala index e4ca99d6c75..ff8c5371f0a 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/MergeIntoCommandMetaBase.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/MergeIntoCommandMetaBase.scala @@ -21,8 +21,7 @@ import com.nvidia.spark.rapids.delta.RapidsDeltaUtils import org.apache.spark.internal.Logging import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.delta.commands.{DeletionVectorUtils, MergeIntoCommand} -import org.apache.spark.sql.delta.sources.DeltaSQLConf +import org.apache.spark.sql.delta.commands.MergeIntoCommand abstract class MergeIntoCommandMetaBase( mergeCmd: MergeIntoCommand, @@ -43,15 +42,6 @@ abstract class MergeIntoCommandMetaBase( willNotWorkOnGpu("notMatchedBySourceClauses not supported on GPU") } val deltaLog = mergeCmd.targetFileIndex.deltaLog - val dvFeatureEnabled = - DeletionVectorUtils.deletionVectorsWritable(deltaLog.unsafeVolatileSnapshot) - - if (dvFeatureEnabled && mergeCmd.conf.getConf( - DeltaSQLConf.MERGE_USE_PERSISTENT_DELETION_VECTORS)) { - // https://github.com/NVIDIA/spark-rapids/issues/8654 - willNotWorkOnGpu("Deletion vectors are not supported on GPU") - } - val targetSchema = mergeCmd.migratedSchema.getOrElse(mergeCmd.target.schema) RapidsDeltaUtils.tagForDeltaWrite(this, targetSchema, Some(deltaLog), Map.empty, SparkSession.active) diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/RapidsDeletionVectors.scala b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/RapidsDeletionVectors.scala index 0056b59939a..f05c239f102 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/RapidsDeletionVectors.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/RapidsDeletionVectors.scala @@ -237,6 +237,23 @@ object RapidsDeletionVectors extends Logging { } } + /** + * Replaces one column in a table and consumes the input table. The returned table owns + * references to the replacement and all unchanged columns. + */ + def replaceColumnAndClose( + table: Table, + outputColumn: Int, + replacement: ColumnVector): Table = { + require(outputColumn >= 0 && outputColumn < table.getNumberOfColumns, + "Invalid replacement column position") + withResource(table) { input => + val outputColumns = (0 until input.getNumberOfColumns).map(input.getColumn).toArray + outputColumns(outputColumn) = replacement + new Table(outputColumns: _*) + } + } + def isIfNotContainedRowIndexFilter(filterTypeOpt: Option[RowIndexFilterType]): Boolean = { filterTypeOpt.contains(RowIndexFilterType.IF_NOT_CONTAINED) } diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/UpdateCommandMetaBase.scala b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/UpdateCommandMetaBase.scala index 25b882de3ab..d1cb58b5eab 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/UpdateCommandMetaBase.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/UpdateCommandMetaBase.scala @@ -19,8 +19,7 @@ package com.nvidia.spark.rapids.delta.common import com.nvidia.spark.rapids.{DataFromReplacementRule, RapidsConf, RapidsMeta, RunnableCommandMeta} import com.nvidia.spark.rapids.delta.RapidsDeltaUtils -import org.apache.spark.sql.delta.commands.{DeletionVectorUtils, UpdateCommand} -import org.apache.spark.sql.delta.sources.DeltaSQLConf +import org.apache.spark.sql.delta.commands.UpdateCommand abstract class UpdateCommandMetaBase( updateCmd: UpdateCommand, @@ -35,15 +34,6 @@ abstract class UpdateCommandMetaBase( s"${RapidsConf.ENABLE_DELTA_WRITE} to true") } - val dvFeatureEnabled = DeletionVectorUtils.deletionVectorsWritable( - updateCmd.tahoeFileIndex.deltaLog.unsafeVolatileSnapshot) - - if (dvFeatureEnabled && updateCmd.conf.getConf( - DeltaSQLConf.DELETE_USE_PERSISTENT_DELETION_VECTORS)) { - // https://github.com/NVIDIA/spark-rapids/issues/8554 - willNotWorkOnGpu("Deletion vectors are not supported on GPU") - } - RapidsDeltaUtils.tagForDeltaWrite(this, updateCmd.target.schema, Some(updateCmd.tahoeFileIndex.deltaLog), Map.empty, updateCmd.tahoeFileIndex.spark) diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/DeltaCommandShims.scala b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/DeltaCommandShims.scala index 9066a76a10d..9fa685d4454 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/DeltaCommandShims.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/DeltaCommandShims.scala @@ -19,6 +19,8 @@ package org.apache.spark.sql.delta.rapids import org.apache.spark.sql.{Column, DataFrame, SparkSession} import org.apache.spark.sql.catalyst.expressions.Expression import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan +import org.apache.spark.sql.delta.actions.FileAction +import org.apache.spark.sql.delta.commands.TouchedFileWithDV /** * Trait to abstract version-specific Spark API differences between Delta 3.3.x and Spark 4.x @@ -82,6 +84,14 @@ trait DeltaCommandShims { */ def exprToColumn(expr: Expression): Column + /** + * Apply version-specific Delta statistics handling to new deletion-vector actions. + */ + def processUnmodifiedData( + spark: OperationSparkSession, + touchedFiles: Seq[TouchedFileWithDV], + txn: GpuOptimisticTransactionBase): (Seq[FileAction], Map[String, Long]) + /** * Recache by plan with the correct SparkSession type. */ diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeleteCommandBase.scala b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeleteCommandBase.scala index 308c53ccf4b..03aac90d776 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeleteCommandBase.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeleteCommandBase.scala @@ -31,6 +31,7 @@ import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference, import org.apache.spark.sql.catalyst.plans.QueryPlan import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan import org.apache.spark.sql.delta.{DeltaConfigs, DeltaLog, DeltaOperations, DeltaTableUtils, DeltaUDF, NumRecordsStats, OptimisticTransaction, RowTracking} +import org.apache.spark.sql.delta.DeltaParquetFileFormat.ROW_INDEX_COLUMN_NAME import org.apache.spark.sql.delta.actions.{Action, AddCDCFile, FileAction} import org.apache.spark.sql.delta.commands.{DeleteCommandMetrics, DeleteMetric, DeletionVectorUtils} import org.apache.spark.sql.delta.commands.DeleteCommand.{rewritingFilesMsg, FINDING_TOUCHED_FILES_MSG} @@ -38,7 +39,7 @@ import org.apache.spark.sql.delta.commands.MergeIntoCommandBase.totalBytesAndDis import org.apache.spark.sql.delta.files.TahoeBatchFileIndex import org.apache.spark.sql.delta.sources.DeltaSQLConf import org.apache.spark.sql.execution.command.LeafRunnableCommand -import org.apache.spark.sql.functions.input_file_name +import org.apache.spark.sql.functions.{col, input_file_name} import org.apache.spark.sql.types.LongType /** @@ -131,10 +132,9 @@ abstract class GpuDeleteCommandBase( var numPartitionsAddedTo: Option[Long] = None var numDeletedRows: Option[Long] = None var numCopiedRows: Option[Long] = None - // Deletion vectors are not supported yet. - val numDeletionVectorsAdded: Long = 0 - val numDeletionVectorsRemoved: Long = 0 - val numDeletionVectorsUpdated: Long = 0 + var numDeletionVectorsAdded: Long = 0 + var numDeletionVectorsRemoved: Long = 0 + var numDeletionVectorsUpdated: Long = 0 val startTime = System.nanoTime() val numFilesTotal = txn.snapshot.numOfFiles @@ -146,6 +146,7 @@ abstract class GpuDeleteCommandBase( val allFiles = txn.filterFiles(Nil, keepNumRecords = reportRowLevelMetrics) numRemovedFiles = allFiles.size + numDeletionVectorsRemoved = allFiles.count(_.deletionVector != null) scanTimeMs = (System.nanoTime() - startTime) / 1000 / 1000 val (numBytes, numPartitions) = totalBytesAndDistinctPartitionValues(allFiles) numBytesRemoved = numBytes @@ -182,6 +183,7 @@ abstract class GpuDeleteCommandBase( numRemovedFiles = candidateFiles.size numBytesRemoved = candidateFiles.map(_.size).sum numFilesAfterSkipping = candidateFiles.size + numDeletionVectorsRemoved = candidateFiles.count(_.deletionVector != null) val (numCandidateBytes, numCandidatePartitions) = totalBytesAndDistinctPartitionValues(candidateFiles) numBytesAfterSkipping = numCandidateBytes @@ -219,11 +221,34 @@ abstract class GpuDeleteCommandBase( sparkSession, "delete", candidateFiles, deltaLog, deltaLog.dataPath, txn.snapshot) if (shouldWriteDVs) { - // this should be unreachable because we fall back to CPU - // if deletion vectors are enabled. The tracking issue for adding deletion vector - // support is https://github.com/NVIDIA/spark-rapids/issues/8554 - throw new IllegalStateException("Deletion vectors are not supported on GPU") - + val targetDf = DMLWithDeletionVectorsHelperShims + .createTargetDfForGpuScanningForMatches(sparkSession, target, fileIndex) + val touchedFiles = GpuDeletionVectorBitmapGenerator.findTouchedFiles( + sparkSession, + txn, + tableHasDVs = candidateFiles.exists(_.deletionVector != null), + rowsArePartitionedByFile = true, + targetDf, + candidateFiles, + exprToColumn(cond), + input_file_name(), + col(ROW_INDEX_COLUMN_NAME), + nameToAddFileMap) + + if (touchedFiles.nonEmpty) { + val (actions, metricMap) = processUnmodifiedData( + sparkSession, + touchedFiles, + txn) + metrics("numDeletedRows").set(metricMap("numModifiedRows")) + numDeletionVectorsAdded = metricMap("numDeletionVectorsAdded") + numDeletionVectorsRemoved = metricMap("numDeletionVectorsRemoved") + numDeletionVectorsUpdated = metricMap("numDeletionVectorsUpdated") + numRemovedFiles = metricMap("numRemovedFiles") + actions + } else { + Nil + } } else { // Keep everything from the resolved target except a new TahoeFileIndex // that only involves the affected files instead of all files. @@ -291,6 +316,7 @@ abstract class GpuDeleteCommandBase( numDeletedRows = Some(metrics("numDeletedRows").value) numCopiedRows = Some(metrics("numTouchedRows").value - metrics("numDeletedRows").value) + numDeletionVectorsRemoved = removedFiles.count(_.deletionVector != null) val operationTimestamp = System.currentTimeMillis() removeFilesFromPaths(deltaLog, nameToAddFileMap, filesToRewrite, diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala new file mode 100644 index 00000000000..4bb70fdde88 --- /dev/null +++ b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala @@ -0,0 +1,177 @@ +/* + * 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.spark.sql.delta.rapids + +import scala.collection.mutable + +import com.nvidia.spark.rapids.{GpuBringBackToHost, GpuColumnarToRowExec, RapidsHostColumnVector} + +import org.apache.spark.paths.SparkPath +import org.apache.spark.sql.{Column, DataFrame, Encoder, Encoders, SparkSession} +import org.apache.spark.sql.delta.OptimisticTransaction +import org.apache.spark.sql.delta.actions.AddFile +import org.apache.spark.sql.delta.commands.{DeletionVectorData, DeletionVectorWriter, DMLWithDeletionVectorsHelper, TouchedFileWithDV} +import org.apache.spark.sql.delta.deletionvectors.{RoaringBitmapArray, RoaringBitmapArrayFormat} +import org.apache.spark.sql.delta.util.{Utils => DeltaUtils} +import org.apache.spark.sql.delta.util.DeltaFileOperations.absolutePath +import org.apache.spark.sql.functions.{broadcast, col, collect_list} + +private[rapids] object GpuDeletionVectorBitmapGenerator { + private val FileNameColumn = "filePath" + private val FileIdColumn = "fileId" + private val FileNameKeyColumn = "fileNameKey" + private val RowIndexColumn = "rowIndexCol" + private val RowIndexListColumn = "rowIndexList" + + case class FileDictionaryRow(fileNameKey: String, fileId: Long) + + private object FileDictionaryRow { + implicit val encoder: Encoder[FileDictionaryRow] = Encoders.product[FileDictionaryRow] + } + + case class GroupedRowIndexes( + fileId: Long, + rowIndexList: Seq[Long]) + + private object GroupedRowIndexes { + implicit val encoder: Encoder[GroupedRowIndexes] = Encoders.product[GroupedRowIndexes] + } + + def findTouchedFiles( + spark: SparkSession, + txn: OptimisticTransaction, + tableHasDVs: Boolean, + rowsArePartitionedByFile: Boolean, + targetDf: DataFrame, + candidateFiles: Seq[AddFile], + condition: Column, + fileNameColumn: Column, + rowIndexColumn: Column, + nameToAddFileMap: Map[String, AddFile]): Seq[TouchedFileWithDV] = { + val matchedRows = targetDf + .withColumn(FileNameColumn, fileNameColumn) + .filter(condition) + .withColumn(RowIndexColumn, rowIndexColumn) + + val basePath = txn.deltaLog.dataPath.toString + val candidateFilePaths = candidateFiles.map { addFile => + SparkPath.fromPath(absolutePath(basePath, addFile.path)).urlEncoded + } + require(candidateFilePaths.distinct.size == candidateFilePaths.size, + "Cannot safely match duplicate deletion-vector candidate paths") + val fileDictionaryRows = candidateFilePaths.zipWithIndex.map { case (filePath, fileId) => + FileDictionaryRow(filePath, fileId.toLong) + } + val fileInfoById = candidateFiles.zipWithIndex.map { case (addFile, fileId) => + val canonicalPath = SparkPath.fromPath(absolutePath(basePath, addFile.path)).urlEncoded + val serializedDv = if (tableHasDVs) { + Option(addFile.deletionVector).map(_.serializeToBase64()) + } else { + None + } + (fileId.toLong, (canonicalPath, serializedDv)) + }.toMap + val fileInfoBroadcast = spark.sparkContext.broadcast(fileInfoById) + + import FileDictionaryRow.encoder + val fileDictionaryDf = broadcast(spark.createDataset(fileDictionaryRows)) + val joinExpr = fileDictionaryDf(FileNameKeyColumn) === matchedRows(FileNameColumn) + val matchedRowsWithIndexes = matchedRows + .join(fileDictionaryDf, joinExpr, "inner") + .filter(col(RowIndexColumn).isNotNull) + .select(fileDictionaryDf(FileIdColumn), matchedRows(RowIndexColumn)) + + val prefixLength = DeltaUtils.getRandomPrefixLength(txn.metadata) + val storeDvs = DeletionVectorWriter.createMapperToStoreDeletionVectors( + spark, + txn.deltaLog.newDeltaHadoopConf(), + txn.deltaLog.dataPath, + prefixLength) + + val storedResults = try { + if (rowsArePartitionedByFile) { + // Preserve the GPU scan/filter/project and cross the CPU boundary in columnar batches. + // Direct DML scans are unsplit, so all matches for a file stay in one Spark partition. + // The write stub gives AQE the parent context it needs to plan columnar output instead of + // placing a row-producing AdaptiveSparkPlanExec at the root of this manually-run plan. + val gpuRead = DMLWithDeletionVectorsHelperShims.withGpuExecutionContext( + spark, matchedRowsWithIndexes) + val columnarPlan = gpuRead.queryExecution.executedPlan match { + case transition: GpuColumnarToRowExec => transition.child + case plan if plan.supportsColumnar => plan + case plan => throw new IllegalStateException( + "GPU deletion-vector match plan is not columnar: " + plan) + } + GpuBringBackToHost(columnarPlan).executeColumnar().mapPartitions { batches => + val bitmaps = mutable.LinkedHashMap.empty[Long, RoaringBitmapArray] + val fileInfo = fileInfoBroadcast.value + // GpuBringBackToHost returns an auto-closing iterator; it owns each host batch. + batches.foreach { hostBatch => + val fileIds = hostBatch.column(0).asInstanceOf[RapidsHostColumnVector] + val rowIndexes = hostBatch.column(1).asInstanceOf[RapidsHostColumnVector] + var row = 0 + while (row < hostBatch.numRows()) { + val fileId = fileIds.getLong(row) + bitmaps.getOrElseUpdate(fileId, new RoaringBitmapArray()) + .add(rowIndexes.getLong(row)) + row += 1 + } + } + val deletionVectorData = bitmaps.iterator.map { case (fileId, bitmap) => + val (filePath, deletionVectorId) = fileInfo(fileId) + bitmap.runOptimize() + DeletionVectorData( + filePath, + deletionVectorId, + bitmap.serializeAsByteArray(RoaringBitmapArrayFormat.Portable), + bitmap.cardinality) + } + storeDvs(deletionVectorData) + }.collect().toSeq + } else { + import GroupedRowIndexes.encoder + val groupedRows = matchedRowsWithIndexes + .groupBy(col(FileIdColumn)) + .agg(collect_list(col(RowIndexColumn)).as(RowIndexListColumn)) + .as[GroupedRowIndexes] + + groupedRows.mapPartitions { rows => + val fileInfo = fileInfoBroadcast.value + val deletionVectorData = rows.map { row => + val (filePath, deletionVectorId) = fileInfo(row.fileId) + val bitmap = new RoaringBitmapArray() + row.rowIndexList.foreach(bitmap.add) + bitmap.runOptimize() + DeletionVectorData( + filePath, + deletionVectorId, + bitmap.serializeAsByteArray(RoaringBitmapArrayFormat.Portable), + bitmap.cardinality) + } + storeDvs(deletionVectorData) + }.collect().toSeq + } + } finally { + fileInfoBroadcast.destroy() + } + + DMLWithDeletionVectorsHelper.findFilesWithMatchingRows( + txn, + nameToAddFileMap, + storedResults) + } +} diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuUpdateCommandBase.scala b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuUpdateCommandBase.scala index 7aa56cfcc35..da65bb81bcf 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuUpdateCommandBase.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuUpdateCommandBase.scala @@ -33,6 +33,7 @@ import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference, import org.apache.spark.sql.catalyst.plans.QueryPlan import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan import org.apache.spark.sql.delta.{DeltaLog, DeltaOperations, DeltaTableUtils, DeltaUDF, NumRecordsStats, RowTracking} +import org.apache.spark.sql.delta.DeltaParquetFileFormat.ROW_INDEX_COLUMN_NAME import org.apache.spark.sql.delta.actions.{AddCDCFile, AddFile, FileAction} import org.apache.spark.sql.delta.commands.{DeletionVectorUtils, TouchedFileWithDV, UpdateCommand, UpdateMetric} import org.apache.spark.sql.delta.files.{TahoeBatchFileIndex, TahoeFileIndex} @@ -40,7 +41,7 @@ import org.apache.spark.sql.delta.sources.DeltaSQLConf import org.apache.spark.sql.execution.command.LeafRunnableCommand import org.apache.spark.sql.execution.metric.SQLMetric import org.apache.spark.sql.execution.metric.SQLMetrics.{createMetric, createTimingMetric} -import org.apache.spark.sql.functions.input_file_name +import org.apache.spark.sql.functions.{col, input_file_name} import org.apache.spark.sql.types.LongType /** @@ -120,10 +121,9 @@ abstract class GpuUpdateCommandBase( var changeFileBytes: Long = 0 var scanTimeMs: Long = 0 var rewriteTimeMs: Long = 0 - // Deletion vector not supported yet - val numDeletionVectorsAdded: Long = 0 - val numDeletionVectorsRemoved: Long = 0 - val numDeletionVectorsUpdated: Long = 0 + var numDeletionVectorsAdded: Long = 0 + var numDeletionVectorsRemoved: Long = 0 + var numDeletionVectorsUpdated: Long = 0 val startTime = System.nanoTime() val numFilesTotal = txn.snapshot.numOfFiles @@ -135,7 +135,9 @@ abstract class GpuUpdateCommandBase( // Should we write the DVs to represent updated rows? val shouldWriteDeletionVectors = shouldWritePersistentDeletionVectors(sparkSession, txn) - val candidateFiles = txn.filterFiles(metadataPredicates ++ dataPredicates) + val candidateFiles = txn.filterFiles( + metadataPredicates ++ dataPredicates, + keepNumRecords = shouldWriteDeletionVectors) val nameToAddFile = generateCandidateFileMap(deltaLog.dataPath, candidateFiles) scanTimeMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime) @@ -155,10 +157,19 @@ abstract class GpuUpdateCommandBase( sparkSession, "update", candidateFiles, deltaLog, tahoeFileIndex.path, txn.snapshot) val touchedFilesWithDV = if (shouldWriteDeletionVectors) { - // this should be unreachable because we fall back to CPU - // if deletion vectors are enabled. The tracking issue for adding deletion vector - // support is https://github.com/NVIDIA/spark-rapids/issues/8554 - throw new IllegalStateException("Deletion vectors are not supported on GPU") + val targetDf = DMLWithDeletionVectorsHelperShims + .createTargetDfForGpuScanningForMatches(sparkSession, target, fileIndex) + GpuDeletionVectorBitmapGenerator.findTouchedFiles( + sparkSession, + txn, + tableHasDVs = candidateFiles.exists(_.deletionVector != null), + rowsArePartitionedByFile = true, + targetDf, + candidateFiles, + exprToColumn(updateCondition), + input_file_name(), + col(ROW_INDEX_COLUMN_NAME), + nameToAddFile) } else { // Case 3.2: Find all the affected files using the non-DV path // Keep everything from the resolved target except a new TahoeFileIndex @@ -191,17 +202,8 @@ abstract class GpuUpdateCommandBase( } val totalActions = { - // When DV is on, we first mask removed rows with DVs and generate (remove, add) pairs. - val actionsForExistingFiles = if (shouldWriteDeletionVectors) { - // this should be unreachable because we fall back to CPU - // if deletion vectors are enabled. The tracking issue for adding deletion vector - // support is https://github.com/NVIDIA/spark-rapids/issues/8554 - throw new IllegalStateException("Deletion vectors are not supported on GPU") - } else { - // Without DV we'll leave the job to `rewriteFiles`. - Nil - } - + // When DV is on, write the updated rows first so the full-width scan can populate the + // RAPIDS file cache before the later narrow row-index scan that builds deletion vectors. // When DV is on, we write out updated rows only. The return value will be only `add` actions. // When DV is off, we write out updated rows plus unmodified rows from the same file, then // return `add` and `remove` actions. @@ -224,6 +226,29 @@ abstract class GpuUpdateCommandBase( } rewriteTimeMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - rewriteStartNs) + val actionsForExistingFiles = if (shouldWriteDeletionVectors) { + if (dataPredicates.isEmpty) { + val operationTimestamp = System.currentTimeMillis() + filesToRewrite.map(_.fileLogEntry.removeWithTimestamp(operationTimestamp)) + } else { + val filesToRewriteWithDV = filesToRewrite.filter(_.newDeletionVector != null) + val (dvActions, metricMap) = processUnmodifiedData( + sparkSession, + filesToRewriteWithDV, + txn) + metrics("numUpdatedRows").set(metricMap("numModifiedRows")) + metrics("numTouchedRows").set(metricMap("numModifiedRows")) + numDeletionVectorsAdded = metricMap("numDeletionVectorsAdded") + numDeletionVectorsRemoved = metricMap("numDeletionVectorsRemoved") + numDeletionVectorsUpdated = metricMap("numDeletionVectorsUpdated") + numTouchedFiles = metricMap("numRemovedFiles") + dvActions + } + } else { + // Without DV we leave the job to rewriteFiles. + Nil + } + numTouchedFiles = filesToRewrite.length val (addActions, removeActions) = actionsForNewFiles.partition(_.isInstanceOf[AddFile]) numRewrittenFiles = addActions.size @@ -316,11 +341,6 @@ abstract class GpuUpdateCommandBase( generateRemoveFileActions: Boolean, copyUnmodifiedRows: Boolean): Seq[FileAction] = { - val touchedRowCount = metrics("numTouchedRows") - val touchedRowUdf = DeltaUDF.boolean { - new GpuDeltaMetricUpdateUDF(touchedRowCount) - }.asNondeterministic() - // Containing the map from the relative file path to AddFile val baseRelation = buildBaseRelation( spark, txn, "update", rootPath, inputLeafFiles.map(_.path), nameToAddFileMap) @@ -333,13 +353,16 @@ abstract class GpuUpdateCommandBase( val targetDfWithEvaluatedCondition = { val evalDf = targetDf.withColumn(UpdateCommand.CONDITION_COLUMN_NAME, exprToColumn(condition)) - val copyAndUpdateRowsDf = if (copyUnmodifiedRows) { - evalDf + if (copyUnmodifiedRows) { + val touchedRowCount = metrics("numTouchedRows") + val touchedRowUdf = DeltaUDF.boolean { + new GpuDeltaMetricUpdateUDF(touchedRowCount) + }.asNondeterministic() + evalDf.filter(touchedRowUdf()) } else { import org.apache.spark.sql.functions.col evalDf.filter(col(UpdateCommand.CONDITION_COLUMN_NAME)) } - copyAndUpdateRowsDf.filter(touchedRowUdf()) } diff --git a/delta-lake/common/src/main/delta-33x/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala b/delta-lake/common/src/main/delta-33x/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala new file mode 100644 index 00000000000..7c265823440 --- /dev/null +++ b/delta-lake/common/src/main/delta-33x/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * This file was derived from DMLWithDeletionVectorsHelper.scala + * in the Delta Lake project at https://github.com/delta-io/delta. + * + * Copyright (2021) The Delta Lake Project Authors. + * + * 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.spark.sql.delta.rapids + +import com.nvidia.spark.rapids.delta.RapidsDeltaWrite + +import org.apache.spark.sql.{DataFrame, Dataset, SparkSession} +import org.apache.spark.sql.catalyst.expressions.AttributeReference +import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, Project} +import org.apache.spark.sql.delta.{DeltaParquetFileFormat, OptimisticTransaction} +import org.apache.spark.sql.delta.DeltaParquetFileFormat.{ROW_INDEX_COLUMN_NAME, + ROW_INDEX_STRUCT_FIELD} +import org.apache.spark.sql.delta.actions.FileAction +import org.apache.spark.sql.delta.commands.{DMLWithDeletionVectorsHelper, TouchedFileWithDV} +import org.apache.spark.sql.delta.files.TahoeFileIndex +import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelationWithTable} +import org.apache.spark.sql.types.StructType + +object DMLWithDeletionVectorsHelperShims { + def withGpuExecutionContext(spark: SparkSession, df: DataFrame): DataFrame = { + Dataset.ofRows(spark, RapidsDeltaWrite(df.queryExecution.logical)) + } + + def createTargetDfForGpuScanningForMatches( + spark: SparkSession, + target: LogicalPlan, + fileIndex: TahoeFileIndex): DataFrame = { + val rowIndexCol = + AttributeReference(ROW_INDEX_COLUMN_NAME, ROW_INDEX_STRUCT_FIELD.dataType)() + val newTarget = target.transformUp { + case l @ LogicalRelationWithTable( + hfsr @ HadoopFsRelation(_, _, _, _, format: DeltaParquetFileFormat, _), _) => + val newDataSchema = StructType(hfsr.dataSchema).add(ROW_INDEX_STRUCT_FIELD) + val newFormat = format.copy(optimizationsEnabled = false) + val newBaseRelation = hfsr.copy( + location = fileIndex, + dataSchema = newDataSchema, + fileFormat = newFormat)(hfsr.sparkSession) + l.copy(relation = newBaseRelation, output = l.output :+ rowIndexCol) + case p @ Project(projectList, _) => + p.copy(projectList = projectList :+ rowIndexCol) + } + Dataset.ofRows(spark, newTarget) + } + + def processUnmodifiedData( + spark: SparkSession, + touchedFiles: Seq[TouchedFileWithDV], + txn: OptimisticTransaction): (Seq[FileAction], Map[String, Long]) = { + DMLWithDeletionVectorsHelper.processUnmodifiedData(spark, touchedFiles, txn.snapshot) + } +} diff --git a/delta-lake/common/src/main/delta-40x-41x/scala/org/apache/spark/sql/delta/rapids/GpuMergeIntoCommand.scala b/delta-lake/common/src/main/delta-40x-41x/scala/org/apache/spark/sql/delta/rapids/GpuMergeIntoCommand.scala index ecb1e82fc47..e5948437361 100644 --- a/delta-lake/common/src/main/delta-40x-41x/scala/org/apache/spark/sql/delta/rapids/GpuMergeIntoCommand.scala +++ b/delta-lake/common/src/main/delta-40x-41x/scala/org/apache/spark/sql/delta/rapids/GpuMergeIntoCommand.scala @@ -24,24 +24,26 @@ package org.apache.spark.sql.delta.rapids import java.util.concurrent.TimeUnit import scala.collection.JavaConverters._ - import com.nvidia.spark.rapids.RapidsConf import com.nvidia.spark.rapids.delta._ import org.apache.spark.SparkContext +import org.apache.spark.paths.SparkPath import org.apache.spark.sql.{Row, SparkSession => SqlSparkSession} import org.apache.spark.sql.catalyst.catalog.CatalogTable import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference, Expression, Literal, Or} import org.apache.spark.sql.catalyst.plans.logical._ import org.apache.spark.sql.classic.{SparkSession => ClassicSparkSession} import org.apache.spark.sql.delta._ +import org.apache.spark.sql.delta.DeltaParquetFileFormat.ROW_INDEX_COLUMN_NAME import org.apache.spark.sql.delta.actions.{AddFile, FileAction} import org.apache.spark.sql.delta.commands.MergeIntoCommandBase +import org.apache.spark.sql.delta.commands.MergeIntoCommandBase._ import org.apache.spark.sql.delta.commands.merge._ import org.apache.spark.sql.delta.files._ import org.apache.spark.sql.delta.rapids.{GpuDeltaLog, GpuOptimisticTransactionBase} import org.apache.spark.sql.delta.sources.DeltaSQLConf -import org.apache.spark.sql.delta.util.SetAccumulator +import org.apache.spark.sql.delta.util.DeltaFileOperations.absolutePath import org.apache.spark.sql.functions._ import org.apache.spark.sql.nvidia.DFUDFShims import org.apache.spark.sql.rapids.shims.TrampolineConnectShims @@ -103,6 +105,53 @@ case class GpuMergeIntoCommand( AttributeReference("num_deleted_rows", LongType)(), AttributeReference("num_inserted_rows", LongType)()) + override protected def writeDVs( + spark: SqlSparkSession, + deltaTxn: OptimisticTransaction, + filesToRewrite: Seq[AddFile]): Seq[FileAction] = recordMergeOperation( + extraOpType = "writeDeletionVectors", + status = "MERGE operation - Rewriting Deletion Vectors to " + filesToRewrite.size + + " files", + sqlMetricName = "rewriteTimeMs") { + val fileIndex = new TahoeBatchFileIndex( + spark, "merge", filesToRewrite, deltaTxn.deltaLog, + deltaTxn.deltaLog.dataPath, deltaTxn.snapshot) + val targetFileNameColumn = "__gpu_target_file_name" + val targetDf = DMLWithDeletionVectorsHelperShims + .createTargetDfForGpuScanningForMatches(spark, target, fileIndex) + .withColumn(targetFileNameColumn, input_file_name()) + val joinType = if (notMatchedBySourceClauses.isEmpty) "inner" else "rightOuter" + val joinedDf = getMergeSource.df + .withColumn(SOURCE_ROW_PRESENT_COL, lit(true)) + .join(targetDf, DFUDFShims.exprToColumn(condition), joinType) + val nameToAddFileMap = generateCandidateFileMap(targetDeltaLog.dataPath, filesToRewrite) + val touchedFilesWithDVs = GpuDeletionVectorBitmapGenerator.findTouchedFiles( + spark, + deltaTxn, + filesToRewrite.exists(_.deletionVector != null), + false, + joinedDf, + filesToRewrite, + DFUDFShims.exprToColumn(generateFilterForModifiedRows()), + col(targetFileNameColumn), + col(ROW_INDEX_COLUMN_NAME), + nameToAddFileMap) + val (dvActions, metricsMap) = DMLWithDeletionVectorsHelperShims.processUnmodifiedData( + spark.asInstanceOf[ClassicSparkSession], touchedFilesWithDVs, deltaTxn) + metrics("numTargetDeletionVectorsAdded") + .set(metricsMap.getOrElse("numDeletionVectorsAdded", 0L)) + metrics("numTargetDeletionVectorsRemoved") + .set(metricsMap.getOrElse("numDeletionVectorsRemoved", 0L)) + metrics("numTargetDeletionVectorsUpdated") + .set(metricsMap.getOrElse("numDeletionVectorsUpdated", 0L)) + metrics("numTargetFilesRemoved").set(metricsMap.getOrElse("numRemovedFiles", 0L)) + val fullyRemovedFiles = touchedFilesWithDVs.filter(_.isFullyReplaced()).map(_.fileLogEntry) + val (removedBytes, removedPartitions) = totalBytesAndDistinctPartitionValues(fullyRemovedFiles) + metrics("numTargetBytesRemoved").set(removedBytes) + metrics("numTargetPartitionsRemovedFrom").set(removedPartitions) + dvActions + } + @transient override protected lazy val sc: SparkContext = SparkContext.getOrCreate() // No override: base metrics are extended at commit time for 4.0-specific keys @@ -174,8 +223,20 @@ case class GpuMergeIntoCommand( val shouldWriteDeletionVectors = shouldWritePersistentDeletionVectors(spark, gpuDeltaTxn) if (shouldWriteDeletionVectors) { - // We should never come here because we should have tagged the Exec to fallback - throw new IllegalStateException("Deletion Vectors are not supported on the GPU") + val newWrittenFiles = withStatusCode("DELTA", "Writing modified data") { + writeAllChanges( + spark, + gpuDeltaTxn, + filesToRewrite, + deduplicateCDFDeletes, + writeUnmodifiedRows = false) + } + val dvActions = withStatusCode( + "DELTA", + "Writing Deletion Vectors for modified data") { + writeDVs(spark, gpuDeltaTxn, filesToRewrite) + } + newWrittenFiles ++ dvActions } else { val newWrittenFiles = withStatusCode("DELTA", "Writing modified data") { writeAllChanges( @@ -309,13 +370,8 @@ case class GpuMergeIntoCommand( val columnComparator = spark.sessionState.analyzer.resolver - // Accumulator to collect all the distinct touched files - val touchedFilesAccum = new SetAccumulator[String]() - import org.apache.spark.sql.delta.commands.MergeIntoCommandBase._ - spark.sparkContext.register(touchedFilesAccum, TOUCHED_FILES_ACCUM_NAME) - // Prune non-matching files if we don't need to collect them for NOT MATCHED BY SOURCE clauses. val dataSkippedFiles = if (notMatchedBySourceClauses.isEmpty) { @@ -374,38 +430,52 @@ case class GpuMergeIntoCommand( gpuDeltaTxn, dataSkippedFiles, columnsToDrop) - val targetDF = TrampolineConnectShims.createDataFrame( + val targetFileIdColumn = "__gpu_target_file_id" + val fileNameKeyColumn = "__gpu_file_name_key" + val candidateFilePaths = dataSkippedFiles.map { addFile => + SparkPath.fromPath(absolutePath(targetDeltaLog.dataPath.toString, addFile.path)).urlEncoded + } + require(candidateFilePaths.distinct.size == candidateFilePaths.size, + "Cannot safely match duplicate MERGE candidate paths") + val fileIdToAddFile = dataSkippedFiles.zipWithIndex.map { case (addFile, fileId) => + fileId.toLong -> addFile + }.toMap + val classicSpark = spark.asInstanceOf[ClassicSparkSession] + import classicSpark.implicits._ + val fileDictionaryDf = broadcast(candidateFilePaths.zipWithIndex.map { + case (filePath, fileId) => (filePath, fileId.toLong) + }.toDF(fileNameKeyColumn, targetFileIdColumn)) + val targetDFWithFileName = TrampolineConnectShims.createDataFrame( TrampolineConnectShims.getActiveSession, targetPlan) .withColumn(ROW_ID_COL, monotonically_increasing_id()) .withColumn(FILE_NAME_COL, input_file_name()) + val dictionaryJoinExpr = + fileDictionaryDf(fileNameKeyColumn) === targetDFWithFileName(FILE_NAME_COL) + val targetDF = targetDFWithFileName + .join(fileDictionaryDf, dictionaryJoinExpr, "inner") + .drop(FILE_NAME_COL, fileNameKeyColumn) val joinToFindTouchedFiles = sourceDF.join(targetDF, DFUDFShims.exprToColumn(condition), joinType) - // UDFs to records touched files names and add them to the accumulator - val recordTouchedFileName = - DeltaUDF.intFromStringBoolean( - new GpuDeltaRecordTouchedFilesStringBoolUDF(touchedFilesAccum)).asNondeterministic() - - // Process the matches from the inner join to record touched files and find multiple matches - val collectTouchedFiles = joinToFindTouchedFiles - .select(col(ROW_ID_COL), - recordTouchedFileName(col(FILE_NAME_COL), DFUDFShims.exprToColumn( - matchedPredicate)).as("one")) - - // Calculate frequency of matches per source row - val matchedRowCounts = collectTouchedFiles.groupBy(ROW_ID_COL).agg(sum("one").as("count")) - - // Get multiple matches and simultaneously collect (using touchedFilesAccum) the file names - val mmRow = matchedRowCounts - .filter(col("count") > lit(1)) - .select( - coalesce(count(lit(1)), lit(0)).as("cnt"), - coalesce(sum("count"), lit(0)).as("sum")) - .collect() - .head - val multipleMatchCount = mmRow.getLong(0) - val multipleMatchSum = mmRow.getLong(1) + // Keep touched-file discovery and duplicate detection in a single GPU aggregation. + // This avoids copying distinct compact file IDs to the host once per input batch from a UDF. + val matchedPredicateColumn = DFUDFShims.exprToColumn(matchedPredicate) + val collectTouchedFiles = joinToFindTouchedFiles.select( + col(ROW_ID_COL), + when(matchedPredicateColumn, col(targetFileIdColumn)).as(targetFileIdColumn), + when(matchedPredicateColumn, lit(1L)).otherwise(lit(0L)).as("one")) + + val matchedRowCounts = collectTouchedFiles.groupBy(ROW_ID_COL).agg( + sum("one").as("count"), + first(col(targetFileIdColumn), ignoreNulls = true).as(targetFileIdColumn)) + + val matchSummary = matchedRowCounts.agg( + coalesce(sum(when(col("count") > 1L, lit(1L)).otherwise(lit(0L))), lit(0L)), + coalesce(sum(when(col("count") > 1L, col("count")).otherwise(lit(0L))), lit(0L)), + collect_set(col(targetFileIdColumn))).head() + val multipleMatchCount = matchSummary.getLong(0) + val multipleMatchSum = matchSummary.getLong(1) val hasMultipleMatches = multipleMatchCount > 0 throwErrorOnMultipleMatches(hasMultipleMatches, spark) @@ -419,13 +489,11 @@ case class GpuMergeIntoCommand( multipleMatchDeleteOnlyOvercount = Some(duplicateCount) } - // Get the AddFiles using the touched file names. - val touchedFileNames = touchedFilesAccum.value.iterator().asScala.toSeq - logTrace(s"findTouchedFiles: matched files:\n\t${touchedFileNames.mkString("\n\t")}") - - val nameToAddFileMap = generateCandidateFileMap(targetDeltaLog.dataPath, dataSkippedFiles) - val touchedAddFiles = touchedFileNames.map( - getTouchedFile(targetDeltaLog.dataPath, _, nameToAddFileMap)) + // Convert the compact file IDs back to AddFiles only after GPU aggregation. + val touchedFileIds = matchSummary.getSeq[Long](2) + val touchedAddFiles = touchedFileIds.map(fileIdToAddFile) + logTrace("findTouchedFiles: matched files:\n\t" + + touchedAddFiles.map(_.path).mkString("\n\t")) // Do NOT re-count here if the metric has already been populated from prepareMergeSource. diff --git a/delta-lake/common/src/main/delta-40x-42x/scala/org/apache/spark/sql/delta/rapids/ClassicSparkCommandShims.scala b/delta-lake/common/src/main/delta-40x-42x/scala/org/apache/spark/sql/delta/rapids/ClassicSparkCommandShims.scala index 38c92ef4a16..64421326843 100644 --- a/delta-lake/common/src/main/delta-40x-42x/scala/org/apache/spark/sql/delta/rapids/ClassicSparkCommandShims.scala +++ b/delta-lake/common/src/main/delta-40x-42x/scala/org/apache/spark/sql/delta/rapids/ClassicSparkCommandShims.scala @@ -48,6 +48,14 @@ trait ClassicSessionDeltaCommandShims extends DeltaCommandShims { override def exprToColumn(expr: Expression): Column = DFUDFShims.exprToColumn(expr) + override def processUnmodifiedData( + spark: OperationSparkSession, + touchedFiles: Seq[org.apache.spark.sql.delta.commands.TouchedFileWithDV], + txn: GpuOptimisticTransactionBase) + : (Seq[org.apache.spark.sql.delta.actions.FileAction], Map[String, Long]) = { + DMLWithDeletionVectorsHelperShims.processUnmodifiedData(spark, touchedFiles, txn) + } + override def recacheByPlan(spark: ShimSparkSession, plan: LogicalPlan): Unit = { val classic = ClassicSparkSession.active classic.sharedState.cacheManager.recacheByPlan(classic, plan) diff --git a/delta-lake/delta-33x/src/main/scala/com/nvidia/spark/rapids/delta/delta33x/Delta33xProvider.scala b/delta-lake/delta-33x/src/main/scala/com/nvidia/spark/rapids/delta/delta33x/Delta33xProvider.scala index 550e6009cf6..cd9067cf7c8 100644 --- a/delta-lake/delta-33x/src/main/scala/com/nvidia/spark/rapids/delta/delta33x/Delta33xProvider.scala +++ b/delta-lake/delta-33x/src/main/scala/com/nvidia/spark/rapids/delta/delta33x/Delta33xProvider.scala @@ -21,6 +21,7 @@ import com.nvidia.spark.rapids.delta.common.{DeltaCDFRelationStrategy, DeltaProv DeltaReorgTableCommandMeta} import org.apache.spark.internal.Logging +import org.apache.spark.sql.catalyst.expressions.Expression import org.apache.spark.sql.connector.catalog.SupportsWrite import org.apache.spark.sql.delta.{DeltaDynamicPartitionOverwriteCommand, DeltaParquetFileFormat} import org.apache.spark.sql.delta.catalog.DeltaTableV2 @@ -70,6 +71,11 @@ object Delta33xProvider extends DeltaProviderBase with Logging { } } + override def getExprs: Map[Class[_ <: Expression], ExprRule[_ <: Expression]] = { + val rule = org.apache.spark.sql.delta.rapids.delta33x.GpuCheckOverflowInTableWrite.exprRule + super.getExprs + (rule.getClassFor.asSubclass(classOf[Expression]) -> rule) + } + override def getRunnableCommandRules: Map[Class[_ <: RunnableCommand], RunnableCommandRule[_ <: RunnableCommand]] = { Seq( @@ -94,7 +100,7 @@ object Delta33xProvider extends DeltaProviderBase with Logging { override protected def toGpuParquetFileFormat(conf: RapidsConf, fmt: DeltaParquetFileFormat) : FileFormat = { - if (isPushDVPredicateDownEnabled(conf)) { + if (isPushDVPredicateDownEnabled(conf) && fmt.optimizationsEnabled) { // Pushing down deletion vector predicates is currently only supported // when the metadata row index is enabled. GpuDelta33xParquetFileFormat2(fmt.protocol, fmt.metadata, fmt.nullableRowTrackingFields, diff --git a/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/Delta33xCommandShims.scala b/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/Delta33xCommandShims.scala index 6a5c403a11b..1849aac7339 100644 --- a/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/Delta33xCommandShims.scala +++ b/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/Delta33xCommandShims.scala @@ -41,6 +41,15 @@ trait Delta33xCommandShims extends DeltaCommandShims { override def exprToColumn(expr: Expression): Column = new Column(expr) + override def processUnmodifiedData( + spark: OperationSparkSession, + touchedFiles: Seq[org.apache.spark.sql.delta.commands.TouchedFileWithDV], + txn: org.apache.spark.sql.delta.rapids.GpuOptimisticTransactionBase) + : (Seq[org.apache.spark.sql.delta.actions.FileAction], Map[String, Long]) = { + org.apache.spark.sql.delta.rapids.DMLWithDeletionVectorsHelperShims + .processUnmodifiedData(spark, touchedFiles, txn) + } + override def recacheByPlan(spark: ShimSparkSession, plan: LogicalPlan): Unit = { spark.sharedState.cacheManager.recacheByPlan(spark, plan) } diff --git a/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuCheckOverflowInTableWrite.scala b/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuCheckOverflowInTableWrite.scala new file mode 100644 index 00000000000..17e301ee05c --- /dev/null +++ b/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuCheckOverflowInTableWrite.scala @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * This file was derived from CheckOverflowInTableWrite in the + * Delta Lake project at https://github.com/delta-io/delta. + * + * Copyright (2021) The Delta Lake Project Authors. + * + * 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.spark.sql.delta.rapids.delta33x + +import com.nvidia.spark.rapids._ +import com.nvidia.spark.rapids.shims.ShimUnaryExpression + +import org.apache.spark.sql.catalyst.expressions.Expression +import org.apache.spark.sql.delta.{CheckOverflowInTableWrite, DeltaErrors} +import org.apache.spark.sql.types.DataType +import org.apache.spark.sql.vectorized.ColumnarBatch + +/** GPU version of Delta's CheckOverflowInTableWrite expression. */ +case class GpuCheckOverflowInTableWrite(child: GpuCast, columnName: String) + extends ShimUnaryExpression with GpuExpression { + + override def dataType: DataType = child.dataType + + override def columnarEval(batch: ColumnarBatch): GpuColumnVector = { + try { + child.columnarEval(batch) + } catch { + case _: ArithmeticException => + throw DeltaErrors.castingCauseOverflowErrorInTableWrite( + child.child.dataType, + dataType, + columnName) + } + } + + override def sql: String = child.sql + + override def toString: String = child.toString +} + +object GpuCheckOverflowInTableWrite { + val exprRule: ExprRule[CheckOverflowInTableWrite] = + GpuOverrides.expr[CheckOverflowInTableWrite]( + "Casting a numeric value as another numeric type in a Delta table write", + ExprChecks.unaryProjectInputMatchesOutput(TypeSig.all, TypeSig.all), + (check, conf, parent, rule) => + new UnaryExprMeta[CheckOverflowInTableWrite](check, conf, parent, rule) { + override def convertToGpu(child: Expression): GpuExpression = child match { + case cast: GpuCast => GpuCheckOverflowInTableWrite(cast, check.columnName) + case _ => + throw new IllegalStateException("Expression child is not of type GpuCast") + } + }) +} diff --git a/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuMergeIntoCommand.scala b/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuMergeIntoCommand.scala index f7a7504df19..b293bdc7c94 100644 --- a/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuMergeIntoCommand.scala +++ b/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuMergeIntoCommand.scala @@ -23,24 +23,26 @@ package org.apache.spark.sql.delta.rapids.delta33x import java.util.concurrent.TimeUnit -import scala.collection.JavaConverters._ - import com.fasterxml.jackson.databind.annotation.JsonDeserialize import com.nvidia.spark.rapids.RapidsConf import com.nvidia.spark.rapids.delta._ +import org.apache.spark.paths.SparkPath import org.apache.spark.sql._ import org.apache.spark.sql.catalyst.catalog.CatalogTable import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference, Expression, Literal, Or} import org.apache.spark.sql.catalyst.plans.logical._ import org.apache.spark.sql.delta._ +import org.apache.spark.sql.delta.DeltaParquetFileFormat.ROW_INDEX_COLUMN_NAME import org.apache.spark.sql.delta.actions.{AddFile, FileAction} import org.apache.spark.sql.delta.commands.MergeIntoCommandBase +import org.apache.spark.sql.delta.commands.MergeIntoCommandBase._ import org.apache.spark.sql.delta.commands.merge._ import org.apache.spark.sql.delta.files._ -import org.apache.spark.sql.delta.rapids.{GpuDeltaLog, GpuOptimisticTransactionBase} +import org.apache.spark.sql.delta.rapids.{DMLWithDeletionVectorsHelperShims, + GpuDeletionVectorBitmapGenerator, GpuDeltaLog, GpuOptimisticTransactionBase} import org.apache.spark.sql.delta.sources.DeltaSQLConf -import org.apache.spark.sql.delta.util.SetAccumulator +import org.apache.spark.sql.delta.util.DeltaFileOperations.absolutePath import org.apache.spark.sql.execution.metric.SQLMetric import org.apache.spark.sql.functions._ import org.apache.spark.sql.types.{LongType, StructType} @@ -284,6 +286,53 @@ case class GpuMergeIntoCommand( AttributeReference("num_deleted_rows", LongType)(), AttributeReference("num_inserted_rows", LongType)()) + override protected def writeDVs( + spark: SparkSession, + deltaTxn: OptimisticTransaction, + filesToRewrite: Seq[AddFile]): Seq[FileAction] = recordMergeOperation( + extraOpType = "writeDeletionVectors", + status = "MERGE operation - Rewriting Deletion Vectors to " + filesToRewrite.size + + " files", + sqlMetricName = "rewriteTimeMs") { + val fileIndex = new TahoeBatchFileIndex( + spark, "merge", filesToRewrite, deltaTxn.deltaLog, + deltaTxn.deltaLog.dataPath, deltaTxn.snapshot) + val targetFileNameColumn = "__gpu_target_file_name" + val targetDf = DMLWithDeletionVectorsHelperShims + .createTargetDfForGpuScanningForMatches(spark, target, fileIndex) + .withColumn(targetFileNameColumn, input_file_name()) + val joinType = if (notMatchedBySourceClauses.isEmpty) "inner" else "rightOuter" + val joinedDf = getMergeSource.df + .withColumn(SOURCE_ROW_PRESENT_COL, lit(true)) + .join(targetDf, Column(condition), joinType) + val nameToAddFileMap = generateCandidateFileMap(targetDeltaLog.dataPath, filesToRewrite) + val touchedFilesWithDVs = GpuDeletionVectorBitmapGenerator.findTouchedFiles( + spark, + deltaTxn, + filesToRewrite.exists(_.deletionVector != null), + false, + joinedDf, + filesToRewrite, + Column(generateFilterForModifiedRows()), + col(targetFileNameColumn), + col(ROW_INDEX_COLUMN_NAME), + nameToAddFileMap) + val (dvActions, metricsMap) = DMLWithDeletionVectorsHelperShims.processUnmodifiedData( + spark, touchedFilesWithDVs, deltaTxn) + metrics("numTargetDeletionVectorsAdded") + .set(metricsMap.getOrElse("numDeletionVectorsAdded", 0L)) + metrics("numTargetDeletionVectorsRemoved") + .set(metricsMap.getOrElse("numDeletionVectorsRemoved", 0L)) + metrics("numTargetDeletionVectorsUpdated") + .set(metricsMap.getOrElse("numDeletionVectorsUpdated", 0L)) + metrics("numTargetFilesRemoved").set(metricsMap.getOrElse("numRemovedFiles", 0L)) + val fullyRemovedFiles = touchedFilesWithDVs.filter(_.isFullyReplaced()).map(_.fileLogEntry) + val (removedBytes, removedPartitions) = totalBytesAndDistinctPartitionValues(fullyRemovedFiles) + metrics("numTargetBytesRemoved").set(removedBytes) + metrics("numTargetPartitionsRemovedFrom").set(removedPartitions) + dvActions + } + protected def runMerge(spark: SparkSession): Seq[Row] = { recordDeltaOperation(targetDeltaLog, "delta.dml.merge") { val startTime = System.nanoTime() @@ -337,8 +386,20 @@ case class GpuMergeIntoCommand( val shouldWriteDeletionVectors = shouldWritePersistentDeletionVectors(spark, gpuDeltaTxn) if (shouldWriteDeletionVectors) { - // We should never come here because we should have tagged the Exec to fallback - throw new IllegalStateException("Deletion Vectors are not supported on the GPU") + val newWrittenFiles = withStatusCode("DELTA", "Writing modified data") { + writeAllChanges( + spark, + gpuDeltaTxn, + filesToRewrite, + deduplicateCDFDeletes, + writeUnmodifiedRows = false) + } + val dvActions = withStatusCode( + "DELTA", + "Writing Deletion Vectors for modified data") { + writeDVs(spark, gpuDeltaTxn, filesToRewrite) + } + newWrittenFiles ++ dvActions } else { val newWrittenFiles = withStatusCode("DELTA", "Writing modified data") { writeAllChanges( @@ -468,13 +529,8 @@ case class GpuMergeIntoCommand( val columnComparator = spark.sessionState.analyzer.resolver - // Accumulator to collect all the distinct touched files - val touchedFilesAccum = new SetAccumulator[String]() - import org.apache.spark.sql.delta.commands.MergeIntoCommandBase._ - spark.sparkContext.register(touchedFilesAccum, TOUCHED_FILES_ACCUM_NAME) - // Prune non-matching files if we don't need to collect them for NOT MATCHED BY SOURCE clauses. val dataSkippedFiles = if (notMatchedBySourceClauses.isEmpty) { @@ -525,34 +581,49 @@ case class GpuMergeIntoCommand( gpuDeltaTxn, dataSkippedFiles, columnsToDrop) - val targetDF = Dataset.ofRows(spark, targetPlan) + val targetFileIdColumn = "__gpu_target_file_id" + val fileNameKeyColumn = "__gpu_file_name_key" + val candidateFilePaths = dataSkippedFiles.map { addFile => + SparkPath.fromPath(absolutePath(targetDeltaLog.dataPath.toString, addFile.path)).urlEncoded + } + require(candidateFilePaths.distinct.size == candidateFilePaths.size, + "Cannot safely match duplicate MERGE candidate paths") + val fileIdToAddFile = dataSkippedFiles.zipWithIndex.map { case (addFile, fileId) => + fileId.toLong -> addFile + }.toMap + import spark.implicits._ + val fileDictionaryDf = broadcast(candidateFilePaths.zipWithIndex.map { + case (filePath, fileId) => (filePath, fileId.toLong) + }.toDF(fileNameKeyColumn, targetFileIdColumn)) + val targetDFWithFileName = Dataset.ofRows(spark, targetPlan) .withColumn(ROW_ID_COL, monotonically_increasing_id()) .withColumn(FILE_NAME_COL, input_file_name()) + val dictionaryJoinExpr = + fileDictionaryDf(fileNameKeyColumn) === targetDFWithFileName(FILE_NAME_COL) + val targetDF = targetDFWithFileName + .join(fileDictionaryDf, dictionaryJoinExpr, "inner") + .drop(FILE_NAME_COL, fileNameKeyColumn) val joinToFindTouchedFiles = sourceDF.join(targetDF, Column(condition), joinType) - // UDFs to records touched files names and add them to the accumulator - val recordTouchedFileName = - DeltaUDF.intFromStringBoolean( - new GpuDeltaRecordTouchedFilesStringBoolUDF(touchedFilesAccum)).asNondeterministic() + // Keep touched-file discovery and duplicate detection in a single GPU aggregation. + // This avoids copying distinct compact file IDs to the host once per input batch from a UDF. + val collectTouchedFiles = joinToFindTouchedFiles.select( + col(ROW_ID_COL), + when(Column(matchedPredicate), col(targetFileIdColumn)).as(targetFileIdColumn), + when(Column(matchedPredicate), lit(1L)).otherwise(lit(0L)).as("one")) - // Process the matches from the inner join to record touched files and find multiple matches - val collectTouchedFiles = joinToFindTouchedFiles - .select(col(ROW_ID_COL), - recordTouchedFileName(col(FILE_NAME_COL), Column(matchedPredicate)).as("one")) + val matchedRowCounts = collectTouchedFiles.groupBy(ROW_ID_COL).agg( + sum("one").as("count"), + first(col(targetFileIdColumn), ignoreNulls = true).as(targetFileIdColumn)) - // Calculate frequency of matches per source row - val matchedRowCounts = collectTouchedFiles.groupBy(ROW_ID_COL).agg(sum("one").as("count")) - - // Get multiple matches and simultaneously collect (using touchedFilesAccum) the file names - import org.apache.spark.sql.delta.implicits._ - val (multipleMatchCount, multipleMatchSum) = matchedRowCounts - .filter("count > 1") - .select(coalesce(count(Column("*")), lit(0)), coalesce(sum("count"), lit(0))) - .as[(Long, Long)] - .collect() - .head + val matchSummary = matchedRowCounts.agg( + coalesce(sum(when(col("count") > 1L, lit(1L)).otherwise(lit(0L))), lit(0L)), + coalesce(sum(when(col("count") > 1L, col("count")).otherwise(lit(0L))), lit(0L)), + collect_set(col(targetFileIdColumn))).head() + val multipleMatchCount = matchSummary.getLong(0) + val multipleMatchSum = matchSummary.getLong(1) val hasMultipleMatches = multipleMatchCount > 0 throwErrorOnMultipleMatches(hasMultipleMatches, spark) @@ -566,13 +637,11 @@ case class GpuMergeIntoCommand( multipleMatchDeleteOnlyOvercount = Some(duplicateCount) } - // Get the AddFiles using the touched file names. - val touchedFileNames = touchedFilesAccum.value.iterator().asScala.toSeq - logTrace(s"findTouchedFiles: matched files:\n\t${touchedFileNames.mkString("\n\t")}") - - val nameToAddFileMap = generateCandidateFileMap(targetDeltaLog.dataPath, dataSkippedFiles) - val touchedAddFiles = touchedFileNames.map( - getTouchedFile(targetDeltaLog.dataPath, _, nameToAddFileMap)) + // Convert the compact file IDs back to AddFiles only after GPU aggregation. + val touchedFileIds = matchSummary.getSeq[Long](2) + val touchedAddFiles = touchedFileIds.map(fileIdToAddFile) + logTrace("findTouchedFiles: matched files:\n\t" + + touchedAddFiles.map(_.path).mkString("\n\t")) if (metrics("numSourceRows").value == 0 && (dataSkippedFiles.isEmpty || dataSkippedFiles.forall(_.numLogicalRecords.getOrElse(0) == 0))) { diff --git a/delta-lake/delta-40x/src/main/scala/com/nvidia/spark/rapids/delta/delta40x/Delta40xProvider.scala b/delta-lake/delta-40x/src/main/scala/com/nvidia/spark/rapids/delta/delta40x/Delta40xProvider.scala index 1baa9b4d02f..fe07515d23d 100644 --- a/delta-lake/delta-40x/src/main/scala/com/nvidia/spark/rapids/delta/delta40x/Delta40xProvider.scala +++ b/delta-lake/delta-40x/src/main/scala/com/nvidia/spark/rapids/delta/delta40x/Delta40xProvider.scala @@ -98,7 +98,7 @@ object Delta40xProvider extends DeltaProviderBase with Logging { override protected def toGpuParquetFileFormat(conf: RapidsConf, fmt: DeltaParquetFileFormat) : FileFormat = { - if (isPushDVPredicateDownEnabled(conf)) { + if (isPushDVPredicateDownEnabled(conf) && fmt.optimizationsEnabled) { GpuDeltaParquetFileFormat2( protocol = fmt.protocol, metadata = fmt.metadata, diff --git a/delta-lake/delta-40x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala b/delta-lake/delta-40x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala new file mode 100644 index 00000000000..052873242a4 --- /dev/null +++ b/delta-lake/delta-40x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * This file was derived from DMLWithDeletionVectorsHelper.scala + * in the Delta Lake project at https://github.com/delta-io/delta. + * + * Copyright (2021) The Delta Lake Project Authors. + * + * 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.spark.sql.delta.rapids + +import com.nvidia.spark.rapids.delta.RapidsDeltaWrite + +import org.apache.spark.sql.{DataFrame, SparkSession => SqlSparkSession} +import org.apache.spark.sql.catalyst.expressions.AttributeReference +import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, Project} +import org.apache.spark.sql.classic.{Dataset, SparkSession} +import org.apache.spark.sql.delta.{DeltaParquetFileFormat, OptimisticTransaction} +import org.apache.spark.sql.delta.DeltaParquetFileFormat.{ROW_INDEX_COLUMN_NAME, + ROW_INDEX_STRUCT_FIELD} +import org.apache.spark.sql.delta.actions.FileAction +import org.apache.spark.sql.delta.commands.{DMLWithDeletionVectorsHelper, TouchedFileWithDV} +import org.apache.spark.sql.delta.files.TahoeFileIndex +import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelationWithTable} +import org.apache.spark.sql.functions.{input_file_name, struct} +import org.apache.spark.sql.types.StructType + +object DMLWithDeletionVectorsHelperShims { + def withGpuExecutionContext(spark: SqlSparkSession, df: DataFrame): DataFrame = { + val classicSpark = spark.asInstanceOf[SparkSession] + Dataset.ofRows(classicSpark, RapidsDeltaWrite(df.queryExecution.logical)) + } + + def createTargetDfForGpuScanningForMatches( + spark: SqlSparkSession, + target: LogicalPlan, + fileIndex: TahoeFileIndex): DataFrame = { + val classicSpark = spark.asInstanceOf[SparkSession] + val rowIndexCol = + AttributeReference(ROW_INDEX_COLUMN_NAME, ROW_INDEX_STRUCT_FIELD.dataType)() + + val newTarget = target.transformUp { + case l @ LogicalRelationWithTable( + hfsr @ HadoopFsRelation(_, _, _, _, format: DeltaParquetFileFormat, _), _) => + val newDataSchema = StructType(hfsr.dataSchema).add(ROW_INDEX_STRUCT_FIELD) + val newFormat = format.copy(optimizationsEnabled = false) + val newBaseRelation = hfsr.copy( + location = fileIndex, + dataSchema = newDataSchema, + fileFormat = newFormat)(hfsr.sparkSession) + l.copy(relation = newBaseRelation, output = l.output :+ rowIndexCol) + case p @ Project(projectList, _) => + p.copy(projectList = projectList :+ rowIndexCol) + } + Dataset.ofRows(classicSpark, newTarget) + .withColumn("_metadata", struct(input_file_name().as("file_path"))) + } + + def processUnmodifiedData( + spark: SparkSession, + touchedFiles: Seq[TouchedFileWithDV], + txn: OptimisticTransaction): (Seq[FileAction], Map[String, Long]) = { + DMLWithDeletionVectorsHelper.processUnmodifiedData(spark, touchedFiles, txn.snapshot) + } +} diff --git a/delta-lake/delta-41x/src/main/scala/com/nvidia/spark/rapids/delta/delta41x/Delta41xProvider.scala b/delta-lake/delta-41x/src/main/scala/com/nvidia/spark/rapids/delta/delta41x/Delta41xProvider.scala index bb669496082..cadbeba28ef 100644 --- a/delta-lake/delta-41x/src/main/scala/com/nvidia/spark/rapids/delta/delta41x/Delta41xProvider.scala +++ b/delta-lake/delta-41x/src/main/scala/com/nvidia/spark/rapids/delta/delta41x/Delta41xProvider.scala @@ -98,7 +98,7 @@ object Delta41xProvider extends DeltaProviderBase with Logging { override protected def toGpuParquetFileFormat(conf: RapidsConf, fmt: DeltaParquetFileFormat) : FileFormat = { - if (isPushDVPredicateDownEnabled(conf)) { + if (isPushDVPredicateDownEnabled(conf) && fmt.optimizationsEnabled) { GpuDeltaParquetFileFormat2( protocol = fmt.protocol, metadata = fmt.metadata, diff --git a/delta-lake/delta-41x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala b/delta-lake/delta-41x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala new file mode 100644 index 00000000000..7110f6c9c28 --- /dev/null +++ b/delta-lake/delta-41x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * This file was derived from DMLWithDeletionVectorsHelper.scala + * in the Delta Lake project at https://github.com/delta-io/delta. + * + * Copyright (2021) The Delta Lake Project Authors. + * + * 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.spark.sql.delta.rapids + +import com.nvidia.spark.rapids.delta.RapidsDeltaWrite + +import org.apache.spark.sql.{DataFrame, SparkSession => SqlSparkSession} +import org.apache.spark.sql.catalyst.expressions.AttributeReference +import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, Project} +import org.apache.spark.sql.classic.{Dataset, SparkSession} +import org.apache.spark.sql.delta.{DeltaParquetFileFormat, OptimisticTransaction} +import org.apache.spark.sql.delta.DeltaParquetFileFormat.{ROW_INDEX_COLUMN_NAME, + ROW_INDEX_STRUCT_FIELD} +import org.apache.spark.sql.delta.actions.FileAction +import org.apache.spark.sql.delta.commands.{DMLWithDeletionVectorsHelper, TouchedFileWithDV} +import org.apache.spark.sql.delta.files.TahoeFileIndex +import org.apache.spark.sql.delta.stats.StatsCollectionUtils +import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelationWithTable} +import org.apache.spark.sql.functions.{input_file_name, struct} +import org.apache.spark.sql.types.StructType + +object DMLWithDeletionVectorsHelperShims { + def withGpuExecutionContext(spark: SqlSparkSession, df: DataFrame): DataFrame = { + val classicSpark = spark.asInstanceOf[SparkSession] + Dataset.ofRows(classicSpark, RapidsDeltaWrite(df.queryExecution.logical)) + } + + def createTargetDfForGpuScanningForMatches( + spark: SqlSparkSession, + target: LogicalPlan, + fileIndex: TahoeFileIndex): DataFrame = { + val classicSpark = spark.asInstanceOf[SparkSession] + val rowIndexCol = + AttributeReference(ROW_INDEX_COLUMN_NAME, ROW_INDEX_STRUCT_FIELD.dataType)() + + val newTarget = target.transformUp { + case l @ LogicalRelationWithTable( + hfsr @ HadoopFsRelation(_, _, _, _, format: DeltaParquetFileFormat, _), _) => + val newDataSchema = StructType(hfsr.dataSchema).add(ROW_INDEX_STRUCT_FIELD) + val newFormat = format.copy(optimizationsEnabled = false) + val newBaseRelation = hfsr.copy( + location = fileIndex, + dataSchema = newDataSchema, + fileFormat = newFormat)(hfsr.sparkSession) + l.copy(relation = newBaseRelation, output = l.output :+ rowIndexCol) + case p @ Project(projectList, _) => + p.copy(projectList = projectList :+ rowIndexCol) + } + Dataset.ofRows(classicSpark, newTarget) + .withColumn("_metadata", struct(input_file_name().as("file_path"))) + } + + def processUnmodifiedData( + spark: SparkSession, + touchedFiles: Seq[TouchedFileWithDV], + txn: OptimisticTransaction): (Seq[FileAction], Map[String, Long]) = { + val stringPrefixLength = + StatsCollectionUtils.getDataSkippingStringPrefixLength(spark, txn.metadata) + DMLWithDeletionVectorsHelper.processUnmodifiedData( + spark, + touchedFiles, + txn.snapshot, + stringPrefixLength) + } +} diff --git a/integration_tests/src/main/python/delta_lake_delete_test.py b/integration_tests/src/main/python/delta_lake_delete_test.py index 94d96133a04..e3c0bc0b8d2 100644 --- a/integration_tests/src/main/python/delta_lake_delete_test.py +++ b/integration_tests/src/main/python/delta_lake_delete_test.py @@ -74,13 +74,15 @@ def checker(data_path, do_delete): if not skip_sql_result_check: # compare resulting dataframe from the delete operation (some older Spark versions return empty here) cpu_result = with_cpu_session(lambda spark: do_delete(spark, cpu_path).collect(), conf=conf) - if expect_write: - gpu_result = assert_rapids_delta_write(lambda spark: do_delete(spark, gpu_path).collect(), conf=conf) - elif assert_gpu_delete_command: + if expect_write and not enable_deletion_vectors: + gpu_result = assert_rapids_delta_write( + lambda spark: do_delete(spark, gpu_path).collect(), conf=conf) + elif assert_gpu_delete_command or enable_deletion_vectors: gpu_result = assert_rapids_gpu_delete_ran( lambda spark: do_delete(spark, gpu_path).collect(), conf=conf) else: - gpu_result = with_gpu_session(lambda spark: do_delete(spark, gpu_path).collect(), conf=conf) + gpu_result = with_gpu_session( + lambda spark: do_delete(spark, gpu_path).collect(), conf=conf) assert_equal(cpu_result, gpu_result) if expected_num_affected_rows is not None: assert gpu_result[0][0] == expected_num_affected_rows @@ -109,7 +111,7 @@ def checker(data_path, do_delete): @ignore_order @pytest.mark.parametrize("disable_conf", fallback_test_params, ids=idfn) @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") -@pytest.mark.parametrize("enable_deletion_vectors", deletion_vector_values, ids=idfn) +@pytest.mark.parametrize("enable_deletion_vectors", dml_deletion_vector_values, ids=idfn) def test_delta_delete_disabled_fallback(spark_tmp_path, disable_conf, enable_deletion_vectors): data_path = spark_tmp_path + "/DELTA_DATA" def setup_tables(spark): @@ -123,27 +125,25 @@ def write_func(spark, path): assert_gpu_fallback_write(write_func, read_delta_path, data_path, "ExecutedCommandExec", disable_conf) -@allow_non_gpu("ExecutedCommandExec", *delta_meta_allow) +@allow_non_gpu("ColumnarToRowExec", *delta_meta_allow) @delta_lake @ignore_order @pytest.mark.parametrize("use_cdf", [True, False], ids=idfn) @pytest.mark.skipif(not supports_delta_lake_deletion_vectors(), \ reason="Deletion vectors new in Delta Lake 2.4 / Apache Spark 3.4") -def test_delta_deletion_vector_fallback(spark_tmp_path, use_cdf): - data_path = spark_tmp_path + "/DELTA_DATA" - def setup_tables(spark): - setup_delta_dest_tables(spark, data_path, - dest_table_func=lambda spark: unary_op_df(spark, int_gen), - use_cdf=use_cdf, enable_deletion_vectors=True) - def write_func(spark, path): - delete_sql="DELETE FROM delta.`{}`".format(path) - spark.sql(delete_sql) - with_cpu_session(setup_tables) - disable_conf = copy_and_update(delta_delete_enabled_conf, +def test_delta_delete_with_deletion_vectors(spark_tmp_path, use_cdf): + conf = copy_and_update( + delta_delete_enabled_conf, {"spark.databricks.delta.delete.deletionVectors.persistent": "true"}) - - assert_gpu_fallback_write(write_func, read_delta_path, data_path, - "ExecutedCommandExec", disable_conf) + assert_delta_sql_delete_collect( + spark_tmp_path, + use_cdf=use_cdf, + dest_table_func=lambda spark: unary_op_df(spark, int_gen), + delete_sql="DELETE FROM delta.`{path}` WHERE a = 0", + enable_deletion_vectors=True, + conf=conf, + expect_write=False, + assert_gpu_delete_command=True) @allow_non_gpu("SortExec, ColumnarToRowExec", *delta_meta_allow) @delta_lake @@ -287,7 +287,7 @@ def read_parquet_sql(data_path): @ignore_order @pytest.mark.parametrize("use_cdf", [True, False], ids=idfn) @pytest.mark.parametrize("partition_columns", [None, ["a"]], ids=idfn) -@pytest.mark.parametrize("enable_deletion_vectors", deletion_vector_values, ids=idfn) +@pytest.mark.parametrize("enable_deletion_vectors", dml_deletion_vector_values, ids=idfn) @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") def test_delta_delete_entire_table(spark_tmp_path, use_cdf, partition_columns, enable_deletion_vectors): def generate_dest_data(spark): @@ -309,7 +309,7 @@ def generate_dest_data(spark): @ignore_order @pytest.mark.parametrize("use_cdf", [True, False], ids=idfn) @pytest.mark.parametrize("partition_columns", [["a"], ["a", "b"]], ids=idfn) -@pytest.mark.parametrize("enable_deletion_vectors", deletion_vector_values, ids=idfn) +@pytest.mark.parametrize("enable_deletion_vectors", dml_deletion_vector_values, ids=idfn) @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") def test_delta_delete_partitions(spark_tmp_path, use_cdf, partition_columns, enable_deletion_vectors): def generate_dest_data(spark): @@ -333,7 +333,7 @@ def generate_dest_data(spark): @pytest.mark.parametrize("partition_columns", [None, ["a"]], ids=idfn) @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") @datagen_overrides(seed=0, permanent=True, reason='https://github.com/NVIDIA/spark-rapids/issues/9884') -@pytest.mark.parametrize("enable_deletion_vectors", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vectors", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason="https://github.com/NVIDIA/spark-rapids/issues/12041"), ids=idfn) def test_delta_delete_rows(spark_tmp_path, use_cdf, partition_columns, enable_deletion_vectors): # Databricks changes the number of files being written, so we cannot compare logs unless there's only one slice @@ -442,7 +442,7 @@ def generate_dest_data(spark): @pytest.mark.parametrize("partition_columns", [None, ["a"]], ids=idfn) @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") @datagen_overrides(seed=0, permanent=True, reason='https://github.com/NVIDIA/spark-rapids/issues/9884') -@pytest.mark.parametrize("enable_deletion_vectors", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vectors", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason="https://github.com/NVIDIA/spark-rapids/issues/12041"), ids=idfn) def test_delta_delete_dataframe_api(spark_tmp_path, use_cdf, partition_columns, enable_deletion_vectors): from delta.tables import DeltaTable diff --git a/integration_tests/src/main/python/delta_lake_merge_common.py b/integration_tests/src/main/python/delta_lake_merge_common.py index 2fa2441f842..ecf4a9dfc43 100644 --- a/integration_tests/src/main/python/delta_lake_merge_common.py +++ b/integration_tests/src/main/python/delta_lake_merge_common.py @@ -59,9 +59,10 @@ def assert_collect(do_merge, data_path, conf, expect_write=True): gpu_path = data_path + "/GPU" cpu_result = with_cpu_session(lambda spark: do_merge(spark, cpu_path), conf=conf) if expect_write: - gpu_result = assert_rapids_delta_write(lambda spark: do_merge(spark, gpu_path), conf=conf) + gpu_result = assert_rapids_delta_write(lambda spark: do_merge(spark, gpu_path), conf=conf, expected_command="GpuMergeIntoCommand") else: - gpu_result = with_gpu_session(lambda spark: do_merge(spark, gpu_path), conf=conf) + gpu_result = assert_rapids_gpu_merge_ran( + lambda spark: do_merge(spark, gpu_path), conf=conf) assert_equal(cpu_result, gpu_result) # This method is used for making sure ExecutedCommand fallsback for Spark 3.5.3 diff --git a/integration_tests/src/main/python/delta_lake_merge_test.py b/integration_tests/src/main/python/delta_lake_merge_test.py index d2e35d2b3a0..b171a5c1de9 100644 --- a/integration_tests/src/main/python/delta_lake_merge_test.py +++ b/integration_tests/src/main/python/delta_lake_merge_test.py @@ -151,7 +151,7 @@ def do_merge(spark): @ignore_order @pytest.mark.parametrize("disable_conf", fallback_test_params, ids=idfn) @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") -@pytest.mark.parametrize("enable_deletion_vectors", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vectors", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_merge_disabled_fallback(spark_tmp_path, spark_tmp_table_factory, disable_conf, enable_deletion_vectors): def checker(data_path, do_merge): @@ -194,7 +194,7 @@ def checker(data_path, do_merge): reason="NOT MATCHED BY SOURCE is supported on the GPU with OSS Delta 4.1+") @pytest.mark.skipif(is_databricks173_or_later(), reason="NOT MATCHED BY SOURCE is supported on the GPU with Databricks 17.3+") -@pytest.mark.parametrize("enable_deletion_vectors", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vectors", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_merge_not_matched_by_source_fallback(spark_tmp_path, spark_tmp_table_factory, enable_deletion_vectors): def checker(data_path, do_merge): @@ -387,7 +387,7 @@ def source_df(spark): @pytest.mark.parametrize("disable_conf", [ "spark.rapids.sql.exec.RapidsProcessDeltaMergeJoinExec", "spark.rapids.sql.expression.Add"], ids=idfn) -@pytest.mark.parametrize("enable_deletion_vectors", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vectors", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_merge_partial_fallback_via_conf(spark_tmp_path, spark_tmp_table_factory, use_cdf, partition_columns, num_slices, disable_conf, enable_deletion_vectors): @@ -416,7 +416,7 @@ def test_delta_merge_partial_fallback_via_conf(spark_tmp_path, spark_tmp_table_f @pytest.mark.parametrize("use_cdf", [True, False], ids=idfn) @pytest.mark.parametrize("partition_columns", [None, ["a"], ["b"], ["a", "b"]], ids=idfn) @pytest.mark.parametrize("num_slices", num_slices_to_test, ids=idfn) -@pytest.mark.parametrize("enable_deletion_vector", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vector", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_merge_not_match_insert_only(spark_tmp_path, spark_tmp_table_factory, table_ranges, use_cdf, partition_columns, num_slices, enable_deletion_vector): @@ -478,14 +478,18 @@ def checker(data_path, do_merge): @pytest.mark.parametrize("use_cdf", [True, False], ids=idfn) @pytest.mark.parametrize("partition_columns", [None, ["a"], ["b"], ["a", "b"]], ids=idfn) @pytest.mark.parametrize("num_slices", num_slices_to_test, ids=idfn) -@pytest.mark.parametrize("enable_deletion_vector", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vector", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_merge_match_delete_only(spark_tmp_path, spark_tmp_table_factory, table_ranges_expect_write, use_cdf, partition_columns, num_slices, enable_deletion_vector): table_ranges, expect_write = table_ranges_expect_write - do_test_delta_merge_match_delete_only(spark_tmp_path, spark_tmp_table_factory, table_ranges, - use_cdf, enable_deletion_vector, partition_columns, num_slices, - num_slices == 1, delta_merge_enabled_conf, expect_write=expect_write) + # A DV-only matched delete preserves the original Parquet file. CDF still writes + # change-data files, so only CDF-off DV deletes are command-only operations. + expect_parquet_write = expect_write and (not enable_deletion_vector or use_cdf) + do_test_delta_merge_match_delete_only( + spark_tmp_path, spark_tmp_table_factory, table_ranges, + use_cdf, enable_deletion_vector, partition_columns, num_slices, + num_slices == 1, delta_merge_enabled_conf, expect_write=expect_parquet_write) @allow_non_gpu(*delta_meta_allow) @delta_lake @@ -493,7 +497,7 @@ def test_delta_merge_match_delete_only(spark_tmp_path, spark_tmp_table_factory, @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") @pytest.mark.parametrize("use_cdf", [True, False], ids=idfn) @pytest.mark.parametrize("num_slices", num_slices_to_test, ids=idfn) -@pytest.mark.parametrize("enable_deletion_vector", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vector", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_merge_standard_upsert(spark_tmp_path, spark_tmp_table_factory, use_cdf, num_slices, enable_deletion_vector): do_test_delta_merge_standard_upsert(spark_tmp_path, spark_tmp_table_factory, use_cdf, enable_deletion_vector, @@ -1506,7 +1510,7 @@ def tracked_rows(spark, path): " WHEN NOT MATCHED AND s.b > 'b' AND s.b < 'f' THEN INSERT *" \ " WHEN NOT MATCHED AND s.b > 'f' AND s.b < 'z' THEN INSERT (b) VALUES ('not here')" ], ids=idfn) @pytest.mark.parametrize("num_slices", num_slices_to_test, ids=idfn) -@pytest.mark.parametrize("enable_deletion_vector", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vector", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_merge_upsert_with_condition(spark_tmp_path, spark_tmp_table_factory, use_cdf, merge_sql, num_slices, enable_deletion_vector): @@ -1520,7 +1524,7 @@ def test_delta_merge_upsert_with_condition(spark_tmp_path, spark_tmp_table_facto @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") @pytest.mark.parametrize("use_cdf", [True, False], ids=idfn) @pytest.mark.parametrize("num_slices", num_slices_to_test, ids=idfn) -@pytest.mark.parametrize("enable_deletion_vector", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vector", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_merge_upsert_with_unmatchable_match_condition(spark_tmp_path, spark_tmp_table_factory, use_cdf, num_slices, enable_deletion_vector): @@ -1535,7 +1539,7 @@ def test_delta_merge_upsert_with_unmatchable_match_condition(spark_tmp_path, spa @ignore_order @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") @pytest.mark.parametrize("use_cdf", [True, False], ids=idfn) -@pytest.mark.parametrize("enable_deletion_vector", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vector", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_merge_update_with_aggregation(spark_tmp_path, spark_tmp_table_factory, use_cdf, enable_deletion_vector): do_test_delta_merge_update_with_aggregation(spark_tmp_path, spark_tmp_table_factory, use_cdf, enable_deletion_vector, @@ -1548,7 +1552,7 @@ def test_delta_merge_update_with_aggregation(spark_tmp_path, spark_tmp_table_fac @pytest.mark.xfail(not is_databricks_runtime() and is_before_spark_353(), reason="https://github.com/NVIDIA/spark-rapids/issues/7573") @pytest.mark.parametrize("use_cdf", [True, False], ids=idfn) @pytest.mark.parametrize("num_slices", num_slices_to_test, ids=idfn) -@pytest.mark.parametrize("enable_deletion_vector", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vector", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_merge_dataframe_api(spark_tmp_path, use_cdf, num_slices, enable_deletion_vector): from delta.tables import DeltaTable diff --git a/integration_tests/src/main/python/delta_lake_update_test.py b/integration_tests/src/main/python/delta_lake_update_test.py index c2704e93f3c..23313fcfd04 100644 --- a/integration_tests/src/main/python/delta_lake_update_test.py +++ b/integration_tests/src/main/python/delta_lake_update_test.py @@ -49,7 +49,7 @@ def checker(data_path, do_update): gpu_path = data_path + "/GPU" # compare resulting dataframe from the update operation (some older Spark versions return empty here) cpu_result = with_cpu_session(lambda spark: do_update(spark, cpu_path).collect(), conf=conf) - gpu_result = assert_rapids_delta_write(lambda spark: do_update(spark, gpu_path).collect(), conf=conf) + gpu_result = assert_rapids_delta_write(lambda spark: do_update(spark, gpu_path).collect(), conf=conf, expected_command="GpuUpdateCommand") assert_equal(cpu_result, gpu_result) # compare table data results, read both via CPU to make sure GPU write can be read by CPU cpu_result = with_cpu_session(lambda spark: read_data(spark, cpu_path).collect(), conf=conf) @@ -63,24 +63,71 @@ def checker(data_path, do_update): delta_sql_update_test(spark_tmp_path, use_cdf, dest_table_func, update_sql, checker, partition_columns, enable_deletion_vectors) -@allow_non_gpu('ColumnarToRowExec', delta_write_fallback_allow, *delta_meta_allow) +@allow_non_gpu('ColumnarToRowExec', *delta_meta_allow) @delta_lake @ignore_order @pytest.mark.skipif(not supports_delta_lake_deletion_vectors(), reason="Deletion vectors aren't supported") @pytest.mark.skipif((not is_databricks_runtime()) and is_before_spark_353(), reason="Update with deletion vector is only supported after delta.io 3.0.0") -def test_delta_update_fallback_with_deletion_vectors(spark_tmp_path): +def test_delta_update_with_deletion_vectors(spark_tmp_path): + conf = copy_and_update( + delta_update_enabled_conf, + {"spark.databricks.delta.update.deletionVectors.persistent": "true"}) + assert_delta_sql_update_collect( + spark_tmp_path, + use_cdf=False, + enable_deletion_vectors=True, + dest_table_func=lambda spark: unary_op_df(spark, int_gen), + update_sql="UPDATE delta.`{path}` SET a = 1 WHERE a = 0", + conf=conf) + +@allow_non_gpu("ExecutedCommandExec", *delta_meta_allow) +@delta_lake +@pytest.mark.skipif(not supports_delta_lake_deletion_vectors(), + reason="Deletion vectors are not supported") +@pytest.mark.skipif((not is_databricks_runtime()) and is_before_spark_353(), + reason="Update with deletion vector requires delta.io 3.0.0 or later") +def test_delta_update_twice_with_dv(spark_tmp_path): data_path = spark_tmp_path + "/DELTA_DATA" - def setup_tables(spark): - setup_delta_dest_tables(spark, data_path, - dest_table_func=lambda spark: unary_op_df(spark, int_gen), - use_cdf=False, enable_deletion_vectors=True) - def write_func(spark, path): - update_sql="UPDATE delta.`{}` SET a = 0".format(path) - spark.sql(update_sql) - with_cpu_session(setup_tables) - assert_gpu_fallback_write(write_func, read_delta_path, data_path, - "ExecutedCommandExec", delta_update_enabled_conf) + + def generate_dest_data(spark): + return spark.createDataFrame([(1, 10), (2, 20), (3, 30)], ["a", "b"]).repartition(1) + + conf = copy_and_update( + delta_update_enabled_conf, + {"spark.databricks.delta.update.deletionVectors.persistent": "true"}) + with_cpu_session(lambda spark: setup_delta_dest_tables( + spark, data_path, generate_dest_data, use_cdf=False, enable_deletion_vectors=True)) + cpu_path = data_path + "/CPU" + gpu_path = data_path + "/GPU" + + first_update_sql = "UPDATE delta.`{path}` SET b = 11 WHERE a = 1" + with_cpu_session( + lambda spark: spark.sql(first_update_sql.format(path=cpu_path)).collect(), conf=conf) + assert_rapids_delta_write( + lambda spark: spark.sql(first_update_sql.format(path=gpu_path)).collect(), + conf=conf, expected_command="GpuUpdateCommand") + + def assert_has_dv(spark, path): + dv_count = spark.read.json(path + "/_delta_log/*.json") \ + .where("add.deletionVector IS NOT NULL").count() + assert dv_count > 0, "Expected the first UPDATE to create a deletion vector" + + with_cpu_session(lambda spark: assert_has_dv(spark, cpu_path), conf=conf) + with_cpu_session(lambda spark: assert_has_dv(spark, gpu_path), conf=conf) + + second_update_sql = "UPDATE delta.`{path}` SET b = 21 WHERE a = 2" + with_cpu_session( + lambda spark: spark.sql(second_update_sql.format(path=cpu_path)).collect(), conf=conf) + assert_rapids_delta_write( + lambda spark: spark.sql(second_update_sql.format(path=gpu_path)).collect(), + conf=conf, expected_command="GpuUpdateCommand") + + cpu_result = with_cpu_session( + lambda spark: spark.read.format("delta").load(cpu_path).sort("a", "b").collect(), conf=conf) + gpu_result = with_cpu_session( + lambda spark: spark.read.format("delta").load(gpu_path).sort("a", "b").collect(), conf=conf) + assert_equal(cpu_result, gpu_result) fallback_test_params = [{"spark.rapids.sql.format.delta.write.enabled": "false"}, {"spark.rapids.sql.format.parquet.write.enabled": "false"}, @@ -95,7 +142,7 @@ def write_func(spark, path): @ignore_order @pytest.mark.parametrize("disable_conf", fallback_test_params, ids=idfn) @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") -@pytest.mark.parametrize("enable_deletion_vector", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vector", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_update_disabled_fallback(spark_tmp_path, disable_conf, enable_deletion_vector): data_path = spark_tmp_path + "/DELTA_DATA" @@ -116,7 +163,7 @@ def write_func(spark, path): @pytest.mark.parametrize("use_cdf", [True, False], ids=idfn) @pytest.mark.parametrize("partition_columns", [None, ["a"]], ids=idfn) @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") -@pytest.mark.parametrize("enable_deletion_vector", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vector", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_update_entire_table(spark_tmp_path, use_cdf, partition_columns, enable_deletion_vector): def generate_dest_data(spark): @@ -134,7 +181,7 @@ def generate_dest_data(spark): @pytest.mark.parametrize("use_cdf", [True, False], ids=idfn) @pytest.mark.parametrize("partition_columns", [["a"], ["a", "b"]], ids=idfn) @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") -@pytest.mark.parametrize("enable_deletion_vector", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vector", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_update_partitions(spark_tmp_path, use_cdf, partition_columns, enable_deletion_vector): def generate_dest_data(spark): @@ -153,7 +200,7 @@ def generate_dest_data(spark): @pytest.mark.parametrize("partition_columns", [None, ["a"]], ids=idfn) @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") @datagen_overrides(seed=0, permanent=True, reason='https://github.com/NVIDIA/spark-rapids/issues/9884') -@pytest.mark.parametrize("enable_deletion_vector", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vector", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_update_rows(spark_tmp_path, use_cdf, partition_columns, enable_deletion_vector): # Databricks changes the number of files being written, so we cannot compare logs unless there's only one slice @@ -172,7 +219,7 @@ def generate_dest_data(spark): @ignore_order @pytest.mark.parametrize("use_cdf", [True, False], ids=idfn) @pytest.mark.parametrize("partition_columns", [None, ["a"]], ids=idfn) -@pytest.mark.parametrize("enable_deletion_vectors", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vectors", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) @pytest.mark.skipif(not supports_delta_lake_deletion_vectors(), reason="Deletion vectors are new in Spark 3.4.0 / DBR 12.2") @datagen_overrides(seed=0, reason='https://github.com/NVIDIA/spark-rapids/issues/10025') @@ -205,7 +252,7 @@ def test_delta_update_preserves_row_tracking(spark_tmp_path): @pytest.mark.parametrize("partition_columns", [None, ["a"]], ids=idfn) @pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x") @datagen_overrides(seed=0, reason='https://github.com/NVIDIA/spark-rapids/issues/10025') -@pytest.mark.parametrize("enable_deletion_vector", deletion_vector_values_with_xfail_reasons( +@pytest.mark.parametrize("enable_deletion_vector", dml_deletion_vector_values_with_xfail_reasons( enabled_xfail_reason='https://github.com/NVIDIA/spark-rapids/issues/12042'), ids=idfn) def test_delta_update_dataframe_api(spark_tmp_path, use_cdf, partition_columns, enable_deletion_vector): from delta.tables import DeltaTable diff --git a/integration_tests/src/main/python/delta_lake_utils.py b/integration_tests/src/main/python/delta_lake_utils.py index efe159a7c7c..fe542800b6c 100644 --- a/integration_tests/src/main/python/delta_lake_utils.py +++ b/integration_tests/src/main/python/delta_lake_utils.py @@ -18,7 +18,7 @@ import re from spark_session import is_databricks122_or_later, supports_delta_lake_deletion_vectors, \ - is_databricks173_or_later, is_spark_local_mode, with_cpu_session, with_gpu_session + is_databricks173_or_later, is_spark_353_or_later, is_spark_local_mode, with_cpu_session, with_gpu_session from asserts import assert_equal from conftest import is_databricks_runtime, spark_jvm @@ -93,24 +93,46 @@ def is_oss_delta_lake_41_or_42(): # Parameterize Deletion Vectors only on runtimes that expose the feature in these tests. def deletion_vector_values_with_xfail_reasons(enabled_xfail_reason=None, disabled_xfail_reason=None): - # Always include the DV-disabled case. On supported Databricks runtimes it can be marked xfail - # when the caller needs to document a runtime-specific expectation. + # Preserve the existing Databricks-only parameterization for non-DML Delta suites. if is_databricks_runtime() and disabled_xfail_reason is not None: - enable_deletion_vector = [pytest.param(False, marks=pytest.mark.xfail(reason=disabled_xfail_reason))] + enable_deletion_vector = [ + pytest.param(False, marks=pytest.mark.xfail(reason=disabled_xfail_reason))] else: enable_deletion_vector = [False] - # Add the DV-enabled case on supported Databricks runtimes. This parameterizes the feature; - # it does not imply every runtime has GPU DV scan coverage. if is_databricks_runtime(): if enabled_xfail_reason is None: enable_deletion_vector.append(True) else: - enable_deletion_vector.append(pytest.param(True, marks=pytest.mark.xfail(reason=enabled_xfail_reason))) + enable_deletion_vector.append( + pytest.param(True, marks=pytest.mark.xfail(reason=enabled_xfail_reason))) return enable_deletion_vector + +def dml_deletion_vector_values_with_xfail_reasons( + enabled_xfail_reason=None, disabled_xfail_reason=None): + # DELETE, UPDATE, and MERGE support DVs on OSS Delta 3.3+. Keep Databricks xfails + # without suppressing OSS coverage. + if is_databricks_runtime() and disabled_xfail_reason is not None: + enable_deletion_vector = [ + pytest.param(False, marks=pytest.mark.xfail(reason=disabled_xfail_reason))] + else: + enable_deletion_vector = [False] + + if supports_delta_lake_deletion_vectors() and ( + is_databricks_runtime() or is_spark_353_or_later()): + if is_databricks_runtime() and enabled_xfail_reason is not None: + enable_deletion_vector.append( + pytest.param(True, marks=pytest.mark.xfail(reason=enabled_xfail_reason))) + else: + enable_deletion_vector.append(True) + + return enable_deletion_vector + + deletion_vector_values = deletion_vector_values_with_xfail_reasons() +dml_deletion_vector_values = dml_deletion_vector_values_with_xfail_reasons() delta_writes_enabled_conf = {"spark.rapids.sql.format.delta.write.enabled": "true"} @@ -229,6 +251,7 @@ def fixup_deletion_vector(c_val, g_val): elif key == "add": assert c_val.keys() == g_val.keys(), "Delta log {} 'add' keys mismatch:\nCPU: {}\nGPU: {}".format(filename, c_val, g_val) del_keys(("modificationTime", "size"), c_val, g_val) + fixup_deletion_vector(c_val, g_val) fixup_path(c_val) fixup_path(g_val) elif key == "cdc": @@ -410,7 +433,7 @@ def read_sorted_delta_path(spark, path): with_cpu_session(lambda spark: assert_gpu_and_cpu_latest_delta_log_equivalent(spark, data_path), conf=conf) -def assert_rapids_delta_write(do_test, conf): +def assert_rapids_delta_write(do_test, conf, expected_command=None): """ Validates that a Delta write operation executed on the GPU produces the expected execution plans. This function starts a plan capture mechanism using the Spark JVM's ExecutionPlanCaptureCallback, @@ -424,6 +447,8 @@ def assert_rapids_delta_write(do_test, conf): A function that performs the Delta write operation to be validated. conf : dict A dictionary of configuration options to be passed to the GPU session. + expected_command : str, optional + GPU DML command class that must also be present in a captured plan. Returns ------- @@ -434,7 +459,11 @@ def assert_rapids_delta_write(do_test, conf): jvm.org.apache.spark.sql.rapids.ExecutionPlanCaptureCallback.startCapture() try: result = with_gpu_session(do_test, conf=conf) - captured_plans = jvm.org.apache.spark.sql.rapids.ExecutionPlanCaptureCallback.getResultsWithTimeout(10000) + callback = jvm.org.apache.spark.sql.rapids.ExecutionPlanCaptureCallback + captured_plans = callback.getResultsWithTimeout(10000) + if expected_command is not None: + assert any(callback.contains(plan, expected_command) for plan in captured_plans), \ + f"{expected_command} is not found in any captured plan" # Some write functions are no-op. We may not capture any GPU plan. if len(captured_plans) > 0: for cls in delta_write: @@ -540,6 +569,21 @@ def assert_db173_gpu_data_writing_command( finally: callback.endCapture() +def assert_rapids_gpu_merge_ran(do_test, conf): + """Runs a Delta MERGE and asserts that the GPU command did not fall back.""" + jvm = spark_jvm() + callback = jvm.org.apache.spark.sql.rapids.ExecutionPlanCaptureCallback + callback.startCapture() + try: + result = with_gpu_session(do_test, conf=conf) + captured_plans = callback.getResultsWithTimeout(10000) + assert any(callback.contains(plan, "GpuMergeIntoCommand") for plan in captured_plans), \ + "GpuMergeIntoCommand not found in any captured plan; MERGE may have fallen back to CPU" + return result + finally: + callback.endCapture() + + def assert_rapids_gpu_delete_ran(do_test, conf): """ Runs a Delta DELETE on the GPU and asserts the GpuDeleteCommand actually executed diff --git a/sql-plugin/src/main/scala/com/nvidia/spark/rapids/parquet/GpuParquetScan.scala b/sql-plugin/src/main/scala/com/nvidia/spark/rapids/parquet/GpuParquetScan.scala index 2c080d49da3..6b9cc8d529d 100644 --- a/sql-plugin/src/main/scala/com/nvidia/spark/rapids/parquet/GpuParquetScan.scala +++ b/sql-plugin/src/main/scala/com/nvidia/spark/rapids/parquet/GpuParquetScan.scala @@ -3580,6 +3580,11 @@ abstract class AbstractParquetTableReader( protected def postProcessChunk(chunk: Table): Table + protected def evolveSchemaAndClose(table: Table): Table = { + ParquetSchemaUtils.evolveSchemaIfNeededAndClose( + table, clippedParquetSchema, readDataSchema, isSchemaCaseSensitive, useFieldId) + } + override def next: Table = { val table = NvtxIdWithMetrics(NvtxRegistry.PARQUET_DECODE, metrics(GPU_DECODE_TIME)) { try { @@ -3609,8 +3614,7 @@ abstract class AbstractParquetTableReader( } } metrics(NUM_OUTPUT_BATCHES) += 1 - val evolvedSchemaTable = ParquetSchemaUtils.evolveSchemaIfNeededAndClose(postProcessedTable, - clippedParquetSchema, readDataSchema, isSchemaCaseSensitive, useFieldId) + val evolvedSchemaTable = evolveSchemaAndClose(postProcessedTable) val outputTable = GpuParquetScan.rebaseDateTime(evolvedSchemaTable, dateRebaseMode, timestampRebaseMode) GpuMetric.recordOutputBatchBytes(outputTable, metrics.get(GPU_OUTPUT_BATCH_BYTES)) From 9872750d94d2ecb85623d950393f115cb6f428c6 Mon Sep 17 00:00:00 2001 From: Rahul Prabhu Date: Tue, 1 Sep 2026 14:18:12 -0700 Subject: [PATCH 2/5] Fix build errors --- .../spark/sql/delta/rapids/delta33x/Delta33xCommandShims.scala | 2 +- .../spark/sql/delta/rapids/delta33x/GpuMergeIntoCommand.scala | 2 +- integration_tests/src/main/python/delta_lake_merge_common.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/Delta33xCommandShims.scala b/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/Delta33xCommandShims.scala index 1849aac7339..4e016ab2c4d 100644 --- a/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/Delta33xCommandShims.scala +++ b/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/Delta33xCommandShims.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. diff --git a/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuMergeIntoCommand.scala b/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuMergeIntoCommand.scala index b293bdc7c94..b9771b6feb5 100644 --- a/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuMergeIntoCommand.scala +++ b/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuMergeIntoCommand.scala @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, NVIDIA CORPORATION. + * Copyright (c) 2025-2026, NVIDIA CORPORATION. * * This file was derived from MergeIntoCommand.scala * in the Delta Lake project at https://github.com/delta-io/delta. diff --git a/integration_tests/src/main/python/delta_lake_merge_common.py b/integration_tests/src/main/python/delta_lake_merge_common.py index ecf4a9dfc43..284c5eafe46 100644 --- a/integration_tests/src/main/python/delta_lake_merge_common.py +++ b/integration_tests/src/main/python/delta_lake_merge_common.py @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025, NVIDIA CORPORATION. +# Copyright (c) 2024-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 917339118a37dc685d92b274c8869b1ca1c194b7 Mon Sep 17 00:00:00 2001 From: Rahul Prabhu Date: Thu, 10 Sep 2026 09:49:31 -0700 Subject: [PATCH 3/5] Avoid materializing filenames per row --- .../delta/common/DeltaProviderBase.scala | 5 +- .../delta/rapids/GpuDeleteCommandBase.scala | 2 +- .../GpuDeletionVectorBitmapGenerator.scala | 36 +++----- .../delta/rapids/GpuUpdateCommandBase.scala | 2 +- .../delta/rapids/InputFileDictionaryId.scala | 91 +++++++++++++++++++ .../delta/rapids/GpuMergeIntoCommand.scala | 35 +++---- .../rapids/delta33x/GpuMergeIntoCommand.scala | 35 +++---- 7 files changed, 146 insertions(+), 60 deletions(-) create mode 100644 delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/InputFileDictionaryId.scala diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/DeltaProviderBase.scala b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/DeltaProviderBase.scala index 77a493ca2f1..649e226b935 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/DeltaProviderBase.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/com/nvidia/spark/rapids/delta/common/DeltaProviderBase.scala @@ -29,7 +29,7 @@ import org.apache.spark.sql.delta.{DeltaLog, DeltaParquetFileFormat} import org.apache.spark.sql.delta.DeltaParquetFileFormat.IS_ROW_DELETED_COLUMN_NAME import org.apache.spark.sql.delta.catalog.DeltaCatalog import org.apache.spark.sql.delta.metric.IncrementMetric -import org.apache.spark.sql.delta.rapids.DeltaRuntimeShim +import org.apache.spark.sql.delta.rapids.{DeltaRuntimeShim, InputFileDictionaryId} import org.apache.spark.sql.delta.sources.DeltaSQLConf import org.apache.spark.sql.execution._ import org.apache.spark.sql.execution.datasources.{FileFormat, HadoopFsRelation, SaveIntoDataSourceCommand} @@ -94,7 +94,8 @@ abstract class DeltaProviderBase extends DeltaIOProvider { "IncrementMetric", ExprChecks.unaryProject(TypeSig.all, TypeSig.all, TypeSig.all, TypeSig.all), (cpuInc, conf, p, r) => GpuIncrementMetricMeta(cpuInc, conf, p, r) - ) + ), + InputFileDictionaryId.exprRule ).map(r => (r.getClassFor.asSubclass(classOf[Expression]), r)).toMap override def tagSupportForGpuFileSourceScan(meta: SparkPlanMeta[FileSourceScanExec]): Unit = { diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeleteCommandBase.scala b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeleteCommandBase.scala index 03aac90d776..cf3638bf5e5 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeleteCommandBase.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeleteCommandBase.scala @@ -231,7 +231,7 @@ abstract class GpuDeleteCommandBase( targetDf, candidateFiles, exprToColumn(cond), - input_file_name(), + None, col(ROW_INDEX_COLUMN_NAME), nameToAddFileMap) diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala index 4bb70fdde88..3f7a63c56b3 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala @@ -28,21 +28,13 @@ import org.apache.spark.sql.delta.commands.{DeletionVectorData, DeletionVectorWr import org.apache.spark.sql.delta.deletionvectors.{RoaringBitmapArray, RoaringBitmapArrayFormat} import org.apache.spark.sql.delta.util.{Utils => DeltaUtils} import org.apache.spark.sql.delta.util.DeltaFileOperations.absolutePath -import org.apache.spark.sql.functions.{broadcast, col, collect_list} +import org.apache.spark.sql.functions.{col, collect_list} private[rapids] object GpuDeletionVectorBitmapGenerator { - private val FileNameColumn = "filePath" private val FileIdColumn = "fileId" - private val FileNameKeyColumn = "fileNameKey" private val RowIndexColumn = "rowIndexCol" private val RowIndexListColumn = "rowIndexList" - case class FileDictionaryRow(fileNameKey: String, fileId: Long) - - private object FileDictionaryRow { - implicit val encoder: Encoder[FileDictionaryRow] = Encoders.product[FileDictionaryRow] - } - case class GroupedRowIndexes( fileId: Long, rowIndexList: Seq[Long]) @@ -59,23 +51,18 @@ private[rapids] object GpuDeletionVectorBitmapGenerator { targetDf: DataFrame, candidateFiles: Seq[AddFile], condition: Column, - fileNameColumn: Column, + precomputedFileIdColumn: Option[Column], rowIndexColumn: Column, nameToAddFileMap: Map[String, AddFile]): Seq[TouchedFileWithDV] = { - val matchedRows = targetDf - .withColumn(FileNameColumn, fileNameColumn) - .filter(condition) - .withColumn(RowIndexColumn, rowIndexColumn) - val basePath = txn.deltaLog.dataPath.toString val candidateFilePaths = candidateFiles.map { addFile => SparkPath.fromPath(absolutePath(basePath, addFile.path)).urlEncoded } require(candidateFilePaths.distinct.size == candidateFilePaths.size, "Cannot safely match duplicate deletion-vector candidate paths") - val fileDictionaryRows = candidateFilePaths.zipWithIndex.map { case (filePath, fileId) => - FileDictionaryRow(filePath, fileId.toLong) - } + val fileIdByPath = candidateFilePaths.zipWithIndex.map { case (filePath, fileId) => + filePath -> fileId.toLong + }.toMap val fileInfoById = candidateFiles.zipWithIndex.map { case (addFile, fileId) => val canonicalPath = SparkPath.fromPath(absolutePath(basePath, addFile.path)).urlEncoded val serializedDv = if (tableHasDVs) { @@ -87,13 +74,14 @@ private[rapids] object GpuDeletionVectorBitmapGenerator { }.toMap val fileInfoBroadcast = spark.sparkContext.broadcast(fileInfoById) - import FileDictionaryRow.encoder - val fileDictionaryDf = broadcast(spark.createDataset(fileDictionaryRows)) - val joinExpr = fileDictionaryDf(FileNameKeyColumn) === matchedRows(FileNameColumn) - val matchedRowsWithIndexes = matchedRows - .join(fileDictionaryDf, joinExpr, "inner") + val rowsWithFileId = precomputedFileIdColumn.fold( + targetDf.withColumn(FileIdColumn, new Column(InputFileDictionaryId(fileIdByPath))))( + fileId => targetDf.withColumn(FileIdColumn, fileId)) + val matchedRowsWithIndexes = rowsWithFileId + .filter(condition) + .withColumn(RowIndexColumn, rowIndexColumn) .filter(col(RowIndexColumn).isNotNull) - .select(fileDictionaryDf(FileIdColumn), matchedRows(RowIndexColumn)) + .select(col(FileIdColumn), col(RowIndexColumn)) val prefixLength = DeltaUtils.getRandomPrefixLength(txn.metadata) val storeDvs = DeletionVectorWriter.createMapperToStoreDeletionVectors( diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuUpdateCommandBase.scala b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuUpdateCommandBase.scala index da65bb81bcf..8d2921cd878 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuUpdateCommandBase.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuUpdateCommandBase.scala @@ -167,7 +167,7 @@ abstract class GpuUpdateCommandBase( targetDf, candidateFiles, exprToColumn(updateCondition), - input_file_name(), + None, col(ROW_INDEX_COLUMN_NAME), nameToAddFile) } else { diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/InputFileDictionaryId.scala b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/InputFileDictionaryId.scala new file mode 100644 index 00000000000..2afc2d04905 --- /dev/null +++ b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/InputFileDictionaryId.scala @@ -0,0 +1,91 @@ +/* + * 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.spark.sql.delta.rapids + +import ai.rapids.cudf.{ColumnVector, Scalar} +import com.nvidia.spark.rapids.{Arm, ExprChecks, ExprRule, GpuColumnVector, GpuExpression, + GpuOverrides, GpuUnaryExpression, TypeSig} + +import org.apache.spark.rdd.InputFileBlockHolder +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.{Expression, LeafExpression, Nondeterministic} +import org.apache.spark.sql.catalyst.expressions.codegen.CodegenFallback +import org.apache.spark.sql.rapids.GpuInputFileName +import org.apache.spark.sql.types.{DataType, LongType} +import org.apache.spark.sql.vectorized.ColumnarBatch + +/** + * Resolves the current input file to a collision-free ID without expanding the file path into a + * string value for every row. This is internal to Delta DML plans. + */ +case class InputFileDictionaryId(fileIdByPath: Map[String, Long]) + extends LeafExpression with Nondeterministic with CodegenFallback { + override def nullable: Boolean = false + override def dataType: DataType = LongType + override def prettyName: String = "input_file_dictionary_id" + + override protected def initializeInternal(partitionIndex: Int): Unit = {} + + override protected def evalInternal(input: InternalRow): Any = currentFileId + + private def currentFileId: Long = { + val path = InputFileBlockHolder.getInputFilePath.toString + fileIdByPath.getOrElse(path, + throw new IllegalStateException(s"No dictionary ID for input file $path")) + } +} + +case class GpuInputFileDictionaryId(fileIdByPath: Map[String, Long], child: Expression) + extends GpuUnaryExpression { + override lazy val deterministic: Boolean = false + override def foldable: Boolean = false + override def nullable: Boolean = false + override def dataType: DataType = LongType + override def prettyName: String = "input_file_dictionary_id" + override def disableCoalesceUntilInput(): Boolean = true + + override protected def doColumnar(input: GpuColumnVector): ColumnVector = + throw new UnsupportedOperationException("GpuInputFileDictionaryId evaluates per batch") + + override def columnarEval(batch: ColumnarBatch): GpuColumnVector = { + // Some scan paths emit an empty batch after clearing the input-file holder. The ID is + // unobservable for that batch, so avoid looking it up while preserving strict validation for + // every batch containing rows. + val fileId = if (batch.numRows() == 0) { + 0L + } else { + val path = InputFileBlockHolder.getInputFilePath.toString + fileIdByPath.getOrElse(path, + throw new IllegalStateException(s"No dictionary ID for input file $path")) + } + Arm.withResource(Scalar.fromLong(fileId)) { scalar => + GpuColumnVector.from(ColumnVector.fromScalar(scalar, batch.numRows()), dataType) + } + } +} + +object InputFileDictionaryId { + val exprRule: ExprRule[InputFileDictionaryId] = + GpuOverrides.expr[InputFileDictionaryId]( + "Resolve a Delta input file to a collision-free dictionary ID", + ExprChecks.projectOnly(TypeSig.LONG, TypeSig.LONG), + (expr, conf, parent, rule) => new com.nvidia.spark.rapids.ExprMeta[InputFileDictionaryId]( + expr, conf, parent, rule) { + override def convertToGpuImpl(): GpuExpression = + GpuInputFileDictionaryId(expr.fileIdByPath, GpuInputFileName()) + }) +} diff --git a/delta-lake/common/src/main/delta-40x-41x/scala/org/apache/spark/sql/delta/rapids/GpuMergeIntoCommand.scala b/delta-lake/common/src/main/delta-40x-41x/scala/org/apache/spark/sql/delta/rapids/GpuMergeIntoCommand.scala index e5948437361..3882581d2b4 100644 --- a/delta-lake/common/src/main/delta-40x-41x/scala/org/apache/spark/sql/delta/rapids/GpuMergeIntoCommand.scala +++ b/delta-lake/common/src/main/delta-40x-41x/scala/org/apache/spark/sql/delta/rapids/GpuMergeIntoCommand.scala @@ -116,10 +116,20 @@ case class GpuMergeIntoCommand( val fileIndex = new TahoeBatchFileIndex( spark, "merge", filesToRewrite, deltaTxn.deltaLog, deltaTxn.deltaLog.dataPath, deltaTxn.snapshot) - val targetFileNameColumn = "__gpu_target_file_name" + val candidateFilePaths = filesToRewrite.map { addFile => + SparkPath.fromPath( + absolutePath(deltaTxn.deltaLog.dataPath.toString, addFile.path)).urlEncoded + } + require(candidateFilePaths.distinct.size == candidateFilePaths.size, + "Cannot safely match duplicate deletion-vector candidate paths") + val fileIdByPath = candidateFilePaths.zipWithIndex.map { case (filePath, fileId) => + filePath -> fileId.toLong + }.toMap + val targetFileIdColumn = "__gpu_target_file_id" val targetDf = DMLWithDeletionVectorsHelperShims .createTargetDfForGpuScanningForMatches(spark, target, fileIndex) - .withColumn(targetFileNameColumn, input_file_name()) + .withColumn(targetFileIdColumn, + DFUDFShims.exprToColumn(InputFileDictionaryId(fileIdByPath))) val joinType = if (notMatchedBySourceClauses.isEmpty) "inner" else "rightOuter" val joinedDf = getMergeSource.df .withColumn(SOURCE_ROW_PRESENT_COL, lit(true)) @@ -133,7 +143,7 @@ case class GpuMergeIntoCommand( joinedDf, filesToRewrite, DFUDFShims.exprToColumn(generateFilterForModifiedRows()), - col(targetFileNameColumn), + Some(col(targetFileIdColumn)), col(ROW_INDEX_COLUMN_NAME), nameToAddFileMap) val (dvActions, metricsMap) = DMLWithDeletionVectorsHelperShims.processUnmodifiedData( @@ -431,7 +441,6 @@ case class GpuMergeIntoCommand( dataSkippedFiles, columnsToDrop) val targetFileIdColumn = "__gpu_target_file_id" - val fileNameKeyColumn = "__gpu_file_name_key" val candidateFilePaths = dataSkippedFiles.map { addFile => SparkPath.fromPath(absolutePath(targetDeltaLog.dataPath.toString, addFile.path)).urlEncoded } @@ -440,20 +449,14 @@ case class GpuMergeIntoCommand( val fileIdToAddFile = dataSkippedFiles.zipWithIndex.map { case (addFile, fileId) => fileId.toLong -> addFile }.toMap - val classicSpark = spark.asInstanceOf[ClassicSparkSession] - import classicSpark.implicits._ - val fileDictionaryDf = broadcast(candidateFilePaths.zipWithIndex.map { - case (filePath, fileId) => (filePath, fileId.toLong) - }.toDF(fileNameKeyColumn, targetFileIdColumn)) - val targetDFWithFileName = TrampolineConnectShims.createDataFrame( + val fileIdByPath = candidateFilePaths.zipWithIndex.map { case (filePath, fileId) => + filePath -> fileId.toLong + }.toMap + val targetDF = TrampolineConnectShims.createDataFrame( TrampolineConnectShims.getActiveSession, targetPlan) .withColumn(ROW_ID_COL, monotonically_increasing_id()) - .withColumn(FILE_NAME_COL, input_file_name()) - val dictionaryJoinExpr = - fileDictionaryDf(fileNameKeyColumn) === targetDFWithFileName(FILE_NAME_COL) - val targetDF = targetDFWithFileName - .join(fileDictionaryDf, dictionaryJoinExpr, "inner") - .drop(FILE_NAME_COL, fileNameKeyColumn) + .withColumn(targetFileIdColumn, + DFUDFShims.exprToColumn(InputFileDictionaryId(fileIdByPath))) val joinToFindTouchedFiles = sourceDF.join(targetDF, DFUDFShims.exprToColumn(condition), joinType) diff --git a/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuMergeIntoCommand.scala b/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuMergeIntoCommand.scala index b9771b6feb5..2d2fa3e146b 100644 --- a/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuMergeIntoCommand.scala +++ b/delta-lake/delta-33x/src/main/scala/org/apache/spark/sql/delta/rapids/delta33x/GpuMergeIntoCommand.scala @@ -40,7 +40,8 @@ import org.apache.spark.sql.delta.commands.MergeIntoCommandBase._ import org.apache.spark.sql.delta.commands.merge._ import org.apache.spark.sql.delta.files._ import org.apache.spark.sql.delta.rapids.{DMLWithDeletionVectorsHelperShims, - GpuDeletionVectorBitmapGenerator, GpuDeltaLog, GpuOptimisticTransactionBase} + GpuDeletionVectorBitmapGenerator, GpuDeltaLog, GpuOptimisticTransactionBase, + InputFileDictionaryId} import org.apache.spark.sql.delta.sources.DeltaSQLConf import org.apache.spark.sql.delta.util.DeltaFileOperations.absolutePath import org.apache.spark.sql.execution.metric.SQLMetric @@ -297,10 +298,19 @@ case class GpuMergeIntoCommand( val fileIndex = new TahoeBatchFileIndex( spark, "merge", filesToRewrite, deltaTxn.deltaLog, deltaTxn.deltaLog.dataPath, deltaTxn.snapshot) - val targetFileNameColumn = "__gpu_target_file_name" + val candidateFilePaths = filesToRewrite.map { addFile => + SparkPath.fromPath( + absolutePath(deltaTxn.deltaLog.dataPath.toString, addFile.path)).urlEncoded + } + require(candidateFilePaths.distinct.size == candidateFilePaths.size, + "Cannot safely match duplicate deletion-vector candidate paths") + val fileIdByPath = candidateFilePaths.zipWithIndex.map { case (filePath, fileId) => + filePath -> fileId.toLong + }.toMap + val targetFileIdColumn = "__gpu_target_file_id" val targetDf = DMLWithDeletionVectorsHelperShims .createTargetDfForGpuScanningForMatches(spark, target, fileIndex) - .withColumn(targetFileNameColumn, input_file_name()) + .withColumn(targetFileIdColumn, Column(InputFileDictionaryId(fileIdByPath))) val joinType = if (notMatchedBySourceClauses.isEmpty) "inner" else "rightOuter" val joinedDf = getMergeSource.df .withColumn(SOURCE_ROW_PRESENT_COL, lit(true)) @@ -314,7 +324,7 @@ case class GpuMergeIntoCommand( joinedDf, filesToRewrite, Column(generateFilterForModifiedRows()), - col(targetFileNameColumn), + Some(col(targetFileIdColumn)), col(ROW_INDEX_COLUMN_NAME), nameToAddFileMap) val (dvActions, metricsMap) = DMLWithDeletionVectorsHelperShims.processUnmodifiedData( @@ -582,7 +592,6 @@ case class GpuMergeIntoCommand( dataSkippedFiles, columnsToDrop) val targetFileIdColumn = "__gpu_target_file_id" - val fileNameKeyColumn = "__gpu_file_name_key" val candidateFilePaths = dataSkippedFiles.map { addFile => SparkPath.fromPath(absolutePath(targetDeltaLog.dataPath.toString, addFile.path)).urlEncoded } @@ -591,18 +600,12 @@ case class GpuMergeIntoCommand( val fileIdToAddFile = dataSkippedFiles.zipWithIndex.map { case (addFile, fileId) => fileId.toLong -> addFile }.toMap - import spark.implicits._ - val fileDictionaryDf = broadcast(candidateFilePaths.zipWithIndex.map { - case (filePath, fileId) => (filePath, fileId.toLong) - }.toDF(fileNameKeyColumn, targetFileIdColumn)) - val targetDFWithFileName = Dataset.ofRows(spark, targetPlan) + val fileIdByPath = candidateFilePaths.zipWithIndex.map { case (filePath, fileId) => + filePath -> fileId.toLong + }.toMap + val targetDF = Dataset.ofRows(spark, targetPlan) .withColumn(ROW_ID_COL, monotonically_increasing_id()) - .withColumn(FILE_NAME_COL, input_file_name()) - val dictionaryJoinExpr = - fileDictionaryDf(fileNameKeyColumn) === targetDFWithFileName(FILE_NAME_COL) - val targetDF = targetDFWithFileName - .join(fileDictionaryDf, dictionaryJoinExpr, "inner") - .drop(FILE_NAME_COL, fileNameKeyColumn) + .withColumn(targetFileIdColumn, Column(InputFileDictionaryId(fileIdByPath))) val joinToFindTouchedFiles = sourceDF.join(targetDF, Column(condition), joinType) From 2084918477991001caa7e68ad82f4e175cf0162a Mon Sep 17 00:00:00 2001 From: Rahul Prabhu Date: Thu, 10 Sep 2026 11:29:08 -0700 Subject: [PATCH 4/5] Add Delta 4.2 DV DML compatibility Signed-off-by: Rahul Prabhu --- .../GpuDeletionVectorBitmapGenerator.scala | 3 +- .../delta/rapids/GpuMergeIntoCommand.scala | 1 - .../DMLWithDeletionVectorsHelperShims.scala | 33 +++- .../DMLWithDeletionVectorsHelperShims.scala | 38 ++++- .../DMLWithDeletionVectorsHelperShims.scala | 108 ++++++++++++ .../delta42x/GpuMergeIntoCommand42x.scala | 161 +++++++++++++----- 6 files changed, 290 insertions(+), 54 deletions(-) create mode 100644 delta-lake/delta-42x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala index 3f7a63c56b3..6899e48e9be 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala @@ -29,6 +29,7 @@ import org.apache.spark.sql.delta.deletionvectors.{RoaringBitmapArray, RoaringBi import org.apache.spark.sql.delta.util.{Utils => DeltaUtils} import org.apache.spark.sql.delta.util.DeltaFileOperations.absolutePath import org.apache.spark.sql.functions.{col, collect_list} +import org.apache.spark.sql.nvidia.DFUDFShims private[rapids] object GpuDeletionVectorBitmapGenerator { private val FileIdColumn = "fileId" @@ -75,7 +76,7 @@ private[rapids] object GpuDeletionVectorBitmapGenerator { val fileInfoBroadcast = spark.sparkContext.broadcast(fileInfoById) val rowsWithFileId = precomputedFileIdColumn.fold( - targetDf.withColumn(FileIdColumn, new Column(InputFileDictionaryId(fileIdByPath))))( + targetDf.withColumn(FileIdColumn, DFUDFShims.exprToColumn(InputFileDictionaryId(fileIdByPath))))( fileId => targetDf.withColumn(FileIdColumn, fileId)) val matchedRowsWithIndexes = rowsWithFileId .filter(condition) diff --git a/delta-lake/common/src/main/delta-40x-41x/scala/org/apache/spark/sql/delta/rapids/GpuMergeIntoCommand.scala b/delta-lake/common/src/main/delta-40x-41x/scala/org/apache/spark/sql/delta/rapids/GpuMergeIntoCommand.scala index 3882581d2b4..7be47112ab6 100644 --- a/delta-lake/common/src/main/delta-40x-41x/scala/org/apache/spark/sql/delta/rapids/GpuMergeIntoCommand.scala +++ b/delta-lake/common/src/main/delta-40x-41x/scala/org/apache/spark/sql/delta/rapids/GpuMergeIntoCommand.scala @@ -23,7 +23,6 @@ package org.apache.spark.sql.delta.rapids import java.util.concurrent.TimeUnit -import scala.collection.JavaConverters._ import com.nvidia.spark.rapids.RapidsConf import com.nvidia.spark.rapids.delta._ diff --git a/delta-lake/delta-40x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala b/delta-lake/delta-40x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala index 052873242a4..57443b304a0 100644 --- a/delta-lake/delta-40x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala +++ b/delta-lake/delta-40x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala @@ -21,6 +21,8 @@ package org.apache.spark.sql.delta.rapids +import java.lang.reflect.InvocationTargetException + import com.nvidia.spark.rapids.delta.RapidsDeltaWrite import org.apache.spark.sql.{DataFrame, SparkSession => SqlSparkSession} @@ -33,6 +35,7 @@ import org.apache.spark.sql.delta.DeltaParquetFileFormat.{ROW_INDEX_COLUMN_NAME, import org.apache.spark.sql.delta.actions.FileAction import org.apache.spark.sql.delta.commands.{DMLWithDeletionVectorsHelper, TouchedFileWithDV} import org.apache.spark.sql.delta.files.TahoeFileIndex +import org.apache.spark.sql.delta.stats.StatsCollectionUtils import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelationWithTable} import org.apache.spark.sql.functions.{input_file_name, struct} import org.apache.spark.sql.types.StructType @@ -68,10 +71,38 @@ object DMLWithDeletionVectorsHelperShims { .withColumn("_metadata", struct(input_file_name().as("file_path"))) } + private lazy val processUnmodifiedDataMethod = { + val methods = DMLWithDeletionVectorsHelper.getClass.getMethods + .filter(_.getName == "processUnmodifiedData") + methods.find(_.getParameterCount == 4) + .orElse(methods.find(_.getParameterCount == 3)) + .getOrElse(throw new IllegalStateException( + "Delta DMLWithDeletionVectorsHelper.processUnmodifiedData is unavailable")) + } + private lazy val getDataSkippingStringPrefixLengthMethod = + StatsCollectionUtils.getClass.getMethods + .find(_.getName == "getDataSkippingStringPrefixLength") + .getOrElse(throw new IllegalStateException( + "Delta StatsCollectionUtils.getDataSkippingStringPrefixLength is unavailable")) + + def processUnmodifiedData( spark: SparkSession, touchedFiles: Seq[TouchedFileWithDV], txn: OptimisticTransaction): (Seq[FileAction], Map[String, Long]) = { - DMLWithDeletionVectorsHelper.processUnmodifiedData(spark, touchedFiles, txn.snapshot) + val args: Array[AnyRef] = if (processUnmodifiedDataMethod.getParameterCount == 4) { + val stringPrefixLength = getDataSkippingStringPrefixLengthMethod + .invoke(StatsCollectionUtils, spark, txn.metadata).asInstanceOf[Int] + Array(spark, touchedFiles, txn.snapshot, Int.box(stringPrefixLength)) + } else { + Array(spark, touchedFiles, txn.snapshot) + } + try { + processUnmodifiedDataMethod + .invoke(DMLWithDeletionVectorsHelper, args: _*) + .asInstanceOf[(Seq[FileAction], Map[String, Long])] + } catch { + case e: InvocationTargetException => throw e.getCause + } } } diff --git a/delta-lake/delta-41x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala b/delta-lake/delta-41x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala index 7110f6c9c28..57443b304a0 100644 --- a/delta-lake/delta-41x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala +++ b/delta-lake/delta-41x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala @@ -21,6 +21,8 @@ package org.apache.spark.sql.delta.rapids +import java.lang.reflect.InvocationTargetException + import com.nvidia.spark.rapids.delta.RapidsDeltaWrite import org.apache.spark.sql.{DataFrame, SparkSession => SqlSparkSession} @@ -69,16 +71,38 @@ object DMLWithDeletionVectorsHelperShims { .withColumn("_metadata", struct(input_file_name().as("file_path"))) } + private lazy val processUnmodifiedDataMethod = { + val methods = DMLWithDeletionVectorsHelper.getClass.getMethods + .filter(_.getName == "processUnmodifiedData") + methods.find(_.getParameterCount == 4) + .orElse(methods.find(_.getParameterCount == 3)) + .getOrElse(throw new IllegalStateException( + "Delta DMLWithDeletionVectorsHelper.processUnmodifiedData is unavailable")) + } + private lazy val getDataSkippingStringPrefixLengthMethod = + StatsCollectionUtils.getClass.getMethods + .find(_.getName == "getDataSkippingStringPrefixLength") + .getOrElse(throw new IllegalStateException( + "Delta StatsCollectionUtils.getDataSkippingStringPrefixLength is unavailable")) + + def processUnmodifiedData( spark: SparkSession, touchedFiles: Seq[TouchedFileWithDV], txn: OptimisticTransaction): (Seq[FileAction], Map[String, Long]) = { - val stringPrefixLength = - StatsCollectionUtils.getDataSkippingStringPrefixLength(spark, txn.metadata) - DMLWithDeletionVectorsHelper.processUnmodifiedData( - spark, - touchedFiles, - txn.snapshot, - stringPrefixLength) + val args: Array[AnyRef] = if (processUnmodifiedDataMethod.getParameterCount == 4) { + val stringPrefixLength = getDataSkippingStringPrefixLengthMethod + .invoke(StatsCollectionUtils, spark, txn.metadata).asInstanceOf[Int] + Array(spark, touchedFiles, txn.snapshot, Int.box(stringPrefixLength)) + } else { + Array(spark, touchedFiles, txn.snapshot) + } + try { + processUnmodifiedDataMethod + .invoke(DMLWithDeletionVectorsHelper, args: _*) + .asInstanceOf[(Seq[FileAction], Map[String, Long])] + } catch { + case e: InvocationTargetException => throw e.getCause + } } } diff --git a/delta-lake/delta-42x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala b/delta-lake/delta-42x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala new file mode 100644 index 00000000000..57443b304a0 --- /dev/null +++ b/delta-lake/delta-42x/src/main/scala/org/apache/spark/sql/delta/rapids/DMLWithDeletionVectorsHelperShims.scala @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION. + * + * This file was derived from DMLWithDeletionVectorsHelper.scala + * in the Delta Lake project at https://github.com/delta-io/delta. + * + * Copyright (2021) The Delta Lake Project Authors. + * + * 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.spark.sql.delta.rapids + +import java.lang.reflect.InvocationTargetException + +import com.nvidia.spark.rapids.delta.RapidsDeltaWrite + +import org.apache.spark.sql.{DataFrame, SparkSession => SqlSparkSession} +import org.apache.spark.sql.catalyst.expressions.AttributeReference +import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, Project} +import org.apache.spark.sql.classic.{Dataset, SparkSession} +import org.apache.spark.sql.delta.{DeltaParquetFileFormat, OptimisticTransaction} +import org.apache.spark.sql.delta.DeltaParquetFileFormat.{ROW_INDEX_COLUMN_NAME, + ROW_INDEX_STRUCT_FIELD} +import org.apache.spark.sql.delta.actions.FileAction +import org.apache.spark.sql.delta.commands.{DMLWithDeletionVectorsHelper, TouchedFileWithDV} +import org.apache.spark.sql.delta.files.TahoeFileIndex +import org.apache.spark.sql.delta.stats.StatsCollectionUtils +import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelationWithTable} +import org.apache.spark.sql.functions.{input_file_name, struct} +import org.apache.spark.sql.types.StructType + +object DMLWithDeletionVectorsHelperShims { + def withGpuExecutionContext(spark: SqlSparkSession, df: DataFrame): DataFrame = { + val classicSpark = spark.asInstanceOf[SparkSession] + Dataset.ofRows(classicSpark, RapidsDeltaWrite(df.queryExecution.logical)) + } + + def createTargetDfForGpuScanningForMatches( + spark: SqlSparkSession, + target: LogicalPlan, + fileIndex: TahoeFileIndex): DataFrame = { + val classicSpark = spark.asInstanceOf[SparkSession] + val rowIndexCol = + AttributeReference(ROW_INDEX_COLUMN_NAME, ROW_INDEX_STRUCT_FIELD.dataType)() + + val newTarget = target.transformUp { + case l @ LogicalRelationWithTable( + hfsr @ HadoopFsRelation(_, _, _, _, format: DeltaParquetFileFormat, _), _) => + val newDataSchema = StructType(hfsr.dataSchema).add(ROW_INDEX_STRUCT_FIELD) + val newFormat = format.copy(optimizationsEnabled = false) + val newBaseRelation = hfsr.copy( + location = fileIndex, + dataSchema = newDataSchema, + fileFormat = newFormat)(hfsr.sparkSession) + l.copy(relation = newBaseRelation, output = l.output :+ rowIndexCol) + case p @ Project(projectList, _) => + p.copy(projectList = projectList :+ rowIndexCol) + } + Dataset.ofRows(classicSpark, newTarget) + .withColumn("_metadata", struct(input_file_name().as("file_path"))) + } + + private lazy val processUnmodifiedDataMethod = { + val methods = DMLWithDeletionVectorsHelper.getClass.getMethods + .filter(_.getName == "processUnmodifiedData") + methods.find(_.getParameterCount == 4) + .orElse(methods.find(_.getParameterCount == 3)) + .getOrElse(throw new IllegalStateException( + "Delta DMLWithDeletionVectorsHelper.processUnmodifiedData is unavailable")) + } + private lazy val getDataSkippingStringPrefixLengthMethod = + StatsCollectionUtils.getClass.getMethods + .find(_.getName == "getDataSkippingStringPrefixLength") + .getOrElse(throw new IllegalStateException( + "Delta StatsCollectionUtils.getDataSkippingStringPrefixLength is unavailable")) + + + def processUnmodifiedData( + spark: SparkSession, + touchedFiles: Seq[TouchedFileWithDV], + txn: OptimisticTransaction): (Seq[FileAction], Map[String, Long]) = { + val args: Array[AnyRef] = if (processUnmodifiedDataMethod.getParameterCount == 4) { + val stringPrefixLength = getDataSkippingStringPrefixLengthMethod + .invoke(StatsCollectionUtils, spark, txn.metadata).asInstanceOf[Int] + Array(spark, touchedFiles, txn.snapshot, Int.box(stringPrefixLength)) + } else { + Array(spark, touchedFiles, txn.snapshot) + } + try { + processUnmodifiedDataMethod + .invoke(DMLWithDeletionVectorsHelper, args: _*) + .asInstanceOf[(Seq[FileAction], Map[String, Long])] + } catch { + case e: InvocationTargetException => throw e.getCause + } + } +} diff --git a/delta-lake/delta-42x/src/main/scala/org/apache/spark/sql/delta/rapids/delta42x/GpuMergeIntoCommand42x.scala b/delta-lake/delta-42x/src/main/scala/org/apache/spark/sql/delta/rapids/delta42x/GpuMergeIntoCommand42x.scala index db05a5d8bce..26799f172f4 100644 --- a/delta-lake/delta-42x/src/main/scala/org/apache/spark/sql/delta/rapids/delta42x/GpuMergeIntoCommand42x.scala +++ b/delta-lake/delta-42x/src/main/scala/org/apache/spark/sql/delta/rapids/delta42x/GpuMergeIntoCommand42x.scala @@ -23,29 +23,33 @@ package org.apache.spark.sql.delta.rapids.delta42x import java.util.concurrent.TimeUnit -import scala.collection.JavaConverters._ - import com.nvidia.spark.rapids.RapidsConf import com.nvidia.spark.rapids.delta._ import org.apache.spark.SparkContext +import org.apache.spark.paths.SparkPath import org.apache.spark.sql.{Row, SparkSession => SqlSparkSession} import org.apache.spark.sql.catalyst.catalog.CatalogTable import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference, Expression, Literal, Or} import org.apache.spark.sql.catalyst.plans.logical._ import org.apache.spark.sql.classic.{SparkSession => ClassicSparkSession} import org.apache.spark.sql.delta._ +import org.apache.spark.sql.delta.DeltaParquetFileFormat.ROW_INDEX_COLUMN_NAME import org.apache.spark.sql.delta.actions.{AddFile, FileAction} import org.apache.spark.sql.delta.commands.MergeIntoCommandBase +import org.apache.spark.sql.delta.commands.MergeIntoCommandBase._ import org.apache.spark.sql.delta.commands.merge._ import org.apache.spark.sql.delta.files._ import org.apache.spark.sql.delta.rapids.{ + DMLWithDeletionVectorsHelperShims, + GpuDeletionVectorBitmapGenerator, GpuDeltaCommandLike, GpuDeltaLog, GpuMergeStats, - GpuOptimisticTransactionBase} + GpuOptimisticTransactionBase, + InputFileDictionaryId} import org.apache.spark.sql.delta.sources.DeltaSQLConf -import org.apache.spark.sql.delta.util.SetAccumulator +import org.apache.spark.sql.delta.util.DeltaFileOperations.absolutePath import org.apache.spark.sql.functions._ import org.apache.spark.sql.nvidia.DFUDFShims import org.apache.spark.sql.rapids.shims.TrampolineConnectShims @@ -107,6 +111,63 @@ case class GpuMergeIntoCommand42x( AttributeReference("num_deleted_rows", LongType)(), AttributeReference("num_inserted_rows", LongType)()) + override protected def writeDVs( + spark: SqlSparkSession, + deltaTxn: OptimisticTransaction, + filesToRewrite: Seq[AddFile]): Seq[FileAction] = recordMergeOperation( + extraOpType = "writeDeletionVectors", + status = "MERGE operation - Rewriting Deletion Vectors to " + filesToRewrite.size + + " files", + sqlMetricName = "rewriteTimeMs") { + val fileIndex = new TahoeBatchFileIndex( + spark, "merge", filesToRewrite, deltaTxn.deltaLog, + deltaTxn.deltaLog.dataPath, deltaTxn.snapshot) + val candidateFilePaths = filesToRewrite.map { addFile => + SparkPath.fromPath( + absolutePath(deltaTxn.deltaLog.dataPath.toString, addFile.path)).urlEncoded + } + require(candidateFilePaths.distinct.size == candidateFilePaths.size, + "Cannot safely match duplicate deletion-vector candidate paths") + val fileIdByPath = candidateFilePaths.zipWithIndex.map { case (filePath, fileId) => + filePath -> fileId.toLong + }.toMap + val targetFileIdColumn = "__gpu_target_file_id" + val targetDf = DMLWithDeletionVectorsHelperShims + .createTargetDfForGpuScanningForMatches(spark, target, fileIndex) + .withColumn(targetFileIdColumn, + DFUDFShims.exprToColumn(InputFileDictionaryId(fileIdByPath))) + val joinType = if (notMatchedBySourceClauses.isEmpty) "inner" else "rightOuter" + val joinedDf = getMergeSource.df + .withColumn(SOURCE_ROW_PRESENT_COL, lit(true)) + .join(targetDf, DFUDFShims.exprToColumn(condition), joinType) + val nameToAddFileMap = generateCandidateFileMap(targetDeltaLog.dataPath, filesToRewrite) + val touchedFilesWithDVs = GpuDeletionVectorBitmapGenerator.findTouchedFiles( + spark, + deltaTxn, + filesToRewrite.exists(_.deletionVector != null), + false, + joinedDf, + filesToRewrite, + DFUDFShims.exprToColumn(generateFilterForModifiedRows()), + Some(col(targetFileIdColumn)), + col(ROW_INDEX_COLUMN_NAME), + nameToAddFileMap) + val (dvActions, metricsMap) = DMLWithDeletionVectorsHelperShims.processUnmodifiedData( + spark.asInstanceOf[ClassicSparkSession], touchedFilesWithDVs, deltaTxn) + metrics("numTargetDeletionVectorsAdded") + .set(metricsMap.getOrElse("numDeletionVectorsAdded", 0L)) + metrics("numTargetDeletionVectorsRemoved") + .set(metricsMap.getOrElse("numDeletionVectorsRemoved", 0L)) + metrics("numTargetDeletionVectorsUpdated") + .set(metricsMap.getOrElse("numDeletionVectorsUpdated", 0L)) + metrics("numTargetFilesRemoved").set(metricsMap.getOrElse("numRemovedFiles", 0L)) + val fullyRemovedFiles = touchedFilesWithDVs.filter(_.isFullyReplaced()).map(_.fileLogEntry) + val (removedBytes, removedPartitions) = totalBytesAndDistinctPartitionValues(fullyRemovedFiles) + metrics("numTargetBytesRemoved").set(removedBytes) + metrics("numTargetPartitionsRemovedFrom").set(removedPartitions) + dvActions + } + @transient override protected lazy val sc: SparkContext = SparkContext.getOrCreate() // No override: base metrics are extended at commit time for 4.0-specific keys @@ -178,8 +239,20 @@ case class GpuMergeIntoCommand42x( val shouldWriteDeletionVectors = shouldWritePersistentDeletionVectors(spark, gpuDeltaTxn) if (shouldWriteDeletionVectors) { - // We should never come here because we should have tagged the Exec to fallback - throw new IllegalStateException("Deletion Vectors are not supported on the GPU") + val newWrittenFiles = withStatusCode("DELTA", "Writing modified data") { + writeAllChanges( + spark, + gpuDeltaTxn, + filesToRewrite, + deduplicateCDFDeletes, + writeUnmodifiedRows = false) + } + val dvActions = withStatusCode( + "DELTA", + "Writing Deletion Vectors for modified data") { + writeDVs(spark, gpuDeltaTxn, filesToRewrite) + } + newWrittenFiles ++ dvActions } else { val newWrittenFiles = withStatusCode("DELTA", "Writing modified data") { writeAllChanges( @@ -313,13 +386,8 @@ case class GpuMergeIntoCommand42x( val columnComparator = spark.sessionState.analyzer.resolver - // Accumulator to collect all the distinct touched files - val touchedFilesAccum = new SetAccumulator[String]() - import org.apache.spark.sql.delta.commands.MergeIntoCommandBase._ - spark.sparkContext.register(touchedFilesAccum, TOUCHED_FILES_ACCUM_NAME) - // Prune non-matching files if we don't need to collect them for NOT MATCHED BY SOURCE clauses. val dataSkippedFiles = if (notMatchedBySourceClauses.isEmpty) { @@ -378,38 +446,45 @@ case class GpuMergeIntoCommand42x( gpuDeltaTxn, dataSkippedFiles, columnsToDrop) - val targetDF = TrampolineConnectShims.createDataFrame( + val targetFileIdColumn = "__gpu_target_file_id" + val candidateFilePaths = dataSkippedFiles.map { addFile => + SparkPath.fromPath(absolutePath(targetDeltaLog.dataPath.toString, addFile.path)).urlEncoded + } + require(candidateFilePaths.distinct.size == candidateFilePaths.size, + "Cannot safely match duplicate MERGE candidate paths") + val fileIdToAddFile = dataSkippedFiles.zipWithIndex.map { case (addFile, fileId) => + fileId.toLong -> addFile + }.toMap + val fileIdByPath = candidateFilePaths.zipWithIndex.map { case (filePath, fileId) => + filePath -> fileId.toLong + }.toMap + val targetDF = TrampolineConnectShims.createDataFrame( TrampolineConnectShims.getActiveSession, targetPlan) .withColumn(ROW_ID_COL, monotonically_increasing_id()) - .withColumn(FILE_NAME_COL, input_file_name()) + .withColumn(targetFileIdColumn, + DFUDFShims.exprToColumn(InputFileDictionaryId(fileIdByPath))) val joinToFindTouchedFiles = sourceDF.join(targetDF, DFUDFShims.exprToColumn(condition), joinType) - // UDFs to records touched files names and add them to the accumulator - val recordTouchedFileName = - DeltaUDF.intFromStringBoolean( - new GpuDeltaRecordTouchedFilesStringBoolUDF(touchedFilesAccum)).asNondeterministic() - - // Process the matches from the inner join to record touched files and find multiple matches - val collectTouchedFiles = joinToFindTouchedFiles - .select(col(ROW_ID_COL), - recordTouchedFileName(col(FILE_NAME_COL), DFUDFShims.exprToColumn( - matchedPredicate)).as("one")) - - // Calculate frequency of matches per source row - val matchedRowCounts = collectTouchedFiles.groupBy(ROW_ID_COL).agg(sum("one").as("count")) - - // Get multiple matches and simultaneously collect (using touchedFilesAccum) the file names - val mmRow = matchedRowCounts - .filter(col("count") > lit(1)) - .select( - coalesce(count(lit(1)), lit(0)).as("cnt"), - coalesce(sum("count"), lit(0)).as("sum")) - .collect() - .head - val multipleMatchCount = mmRow.getLong(0) - val multipleMatchSum = mmRow.getLong(1) + // Keep touched-file discovery and duplicate detection in a single GPU aggregation. + // This avoids copying distinct compact file IDs to the host once per input batch from a UDF. + val matchedPredicateColumn = DFUDFShims.exprToColumn(matchedPredicate) + val collectTouchedFiles = joinToFindTouchedFiles.select( + col(ROW_ID_COL), + when(matchedPredicateColumn, col(targetFileIdColumn)).as(targetFileIdColumn), + when(matchedPredicateColumn, lit(1L)).otherwise(lit(0L)).as("one")) + + val matchedRowCounts = collectTouchedFiles.groupBy(ROW_ID_COL).agg( + sum("one").as("count"), + first(col(targetFileIdColumn), ignoreNulls = true).as(targetFileIdColumn)) + + val matchSummary = matchedRowCounts.agg( + coalesce(sum(when(col("count") > 1L, lit(1L)).otherwise(lit(0L))), lit(0L)), + coalesce(sum(when(col("count") > 1L, col("count")).otherwise(lit(0L))), lit(0L)), + collect_set(col(targetFileIdColumn))).head() + val multipleMatchCount = matchSummary.getLong(0) + val multipleMatchSum = matchSummary.getLong(1) val hasMultipleMatches = multipleMatchCount > 0 throwErrorOnMultipleMatches(hasMultipleMatches, spark) @@ -423,13 +498,11 @@ case class GpuMergeIntoCommand42x( multipleMatchDeleteOnlyOvercount = Some(duplicateCount) } - // Get the AddFiles using the touched file names. - val touchedFileNames = touchedFilesAccum.value.iterator().asScala.toSeq - logTrace(s"findTouchedFiles: matched files:\n\t${touchedFileNames.mkString("\n\t")}") - - val nameToAddFileMap = generateCandidateFileMap(targetDeltaLog.dataPath, dataSkippedFiles) - val touchedAddFiles = touchedFileNames.map( - getTouchedFile(targetDeltaLog.dataPath, _, nameToAddFileMap)) + // Convert the compact file IDs back to AddFiles only after GPU aggregation. + val touchedFileIds = matchSummary.getSeq[Long](2) + val touchedAddFiles = touchedFileIds.map(fileIdToAddFile) + logTrace("findTouchedFiles: matched files:\n\t" + + touchedAddFiles.map(_.path).mkString("\n\t")) // Do NOT re-count here if the metric has already been populated from prepareMergeSource. From 843da43d7cb01b471cc9df97b4ce19015bdac2fd Mon Sep 17 00:00:00 2001 From: Rahul Prabhu Date: Thu, 10 Sep 2026 13:29:54 -0700 Subject: [PATCH 5/5] Fix scala style --- .../sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala index 6899e48e9be..26961e7330e 100644 --- a/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala +++ b/delta-lake/common/src/main/delta-33x-42x/scala/org/apache/spark/sql/delta/rapids/GpuDeletionVectorBitmapGenerator.scala @@ -76,7 +76,9 @@ private[rapids] object GpuDeletionVectorBitmapGenerator { val fileInfoBroadcast = spark.sparkContext.broadcast(fileInfoById) val rowsWithFileId = precomputedFileIdColumn.fold( - targetDf.withColumn(FileIdColumn, DFUDFShims.exprToColumn(InputFileDictionaryId(fileIdByPath))))( + targetDf.withColumn( + FileIdColumn, + DFUDFShims.exprToColumn(InputFileDictionaryId(fileIdByPath))))( fileId => targetDf.withColumn(FileIdColumn, fileId)) val matchedRowsWithIndexes = rowsWithFileId .filter(condition)