|
| 1 | +/* |
| 2 | + * Copyright (c) 2026, NVIDIA CORPORATION. |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + * you may not use this file except in compliance with the License. |
| 6 | + * You may obtain a copy of the License at |
| 7 | + * |
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | + * See the License for the specific language governing permissions and |
| 14 | + * limitations under the License. |
| 15 | + */ |
| 16 | + |
| 17 | +package com.databricks.sql.transaction.tahoe.rapids |
| 18 | + |
| 19 | +import com.databricks.sql.io.skipping.liquid.ClusteredTableUtils |
| 20 | +import com.databricks.sql.transaction.tahoe.DeltaIdentityColumnStatsTracker |
| 21 | +import com.databricks.sql.transaction.tahoe.commands.WriteIntoDeltaCommand |
| 22 | +import com.databricks.sql.transaction.tahoe.stats.{DeltaJobStatisticsTracker, DeltaStatistics, |
| 23 | + StatisticsOnLoadJobTracker} |
| 24 | +import com.nvidia.spark.rapids.{DataFromReplacementRule, DataWritingCommandMeta, |
| 25 | + GpuDataWritingCommand, GpuMetric, GpuParquetFileFormat, NoopMetric, RapidsConf, RapidsMeta} |
| 26 | +import com.nvidia.spark.rapids.delta.{GpuDeltaJobStatisticsTracker, GpuStatisticsCollection, |
| 27 | + RapidsDeltaUtils} |
| 28 | + |
| 29 | +import org.apache.spark.SparkContext |
| 30 | +import org.apache.spark.sql.SparkSession |
| 31 | +import org.apache.spark.sql.catalyst.InternalRow |
| 32 | +import org.apache.spark.sql.catalyst.expressions.{CreateNamedStruct, RuntimeReplaceable} |
| 33 | +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan |
| 34 | +import org.apache.spark.sql.execution.SparkPlan |
| 35 | +import org.apache.spark.sql.execution.datasources.{BasicWriteJobStatsTracker, |
| 36 | + WriteJobStatsTracker} |
| 37 | +import org.apache.spark.sql.rapids.{BasicColumnarWriteJobStatsTracker, |
| 38 | + ColumnarWriteJobStatsTracker} |
| 39 | +import org.apache.spark.sql.rapids.shims.TrampolineConnectShims.{ |
| 40 | + SparkSession => ClassicSparkSession} |
| 41 | +import org.apache.spark.sql.types.{StructField, StructType} |
| 42 | +import org.apache.spark.sql.vectorized.ColumnarBatch |
| 43 | +import org.apache.spark.util.SerializableConfiguration |
| 44 | + |
| 45 | +/** Limits the generic DBR V1 write rule to the native liquid OPTIMIZE call stack. */ |
| 46 | +object GpuLiquidOptimizeWriteContext { |
| 47 | + private val activeKey = "spark.rapids.sql.delta.liquidOptimizeWrite.active" |
| 48 | + |
| 49 | + def isActive: Boolean = SparkContext.getActive |
| 50 | + .exists(_.getLocalProperty(activeKey) == "true") |
| 51 | + |
| 52 | + def withOptimize[T](spark: SparkSession)(body: => T): T = { |
| 53 | + // DBR's SparkThreadLocalCapturingHelper captures Spark local properties when each native |
| 54 | + // OPTIMIZE batch is submitted, installs them in its shared worker pool, and restores the |
| 55 | + // worker's prior properties in finally. |
| 56 | + val sparkContext = spark.sparkContext |
| 57 | + val previous = sparkContext.getLocalProperty(activeKey) |
| 58 | + sparkContext.setLocalProperty(activeKey, "true") |
| 59 | + try { |
| 60 | + body |
| 61 | + } finally { |
| 62 | + sparkContext.setLocalProperty(activeKey, previous) |
| 63 | + } |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +/** Metadata for the DBR write command used by the native liquid OPTIMIZE framework. */ |
| 68 | +class GpuLiquidOptimizeWriteIntoDeltaCommandMeta( |
| 69 | + cmd: WriteIntoDeltaCommand, |
| 70 | + conf: RapidsConf, |
| 71 | + parent: Option[RapidsMeta[_, _, _]], |
| 72 | + rule: DataFromReplacementRule) |
| 73 | + extends DataWritingCommandMeta[WriteIntoDeltaCommand](cmd, conf, parent, rule) { |
| 74 | + |
| 75 | + override protected def tagSelfForGpuInternal(): Unit = { |
| 76 | + if (!GpuLiquidOptimizeWriteContext.isActive || |
| 77 | + !ClusteredTableUtils.isSupported(cmd.protocol)) { |
| 78 | + willNotWorkOnGpu( |
| 79 | + "DBR WriteIntoDeltaCommand GPU support is limited to native liquid OPTIMIZE") |
| 80 | + } |
| 81 | + if (!conf.isDeltaWriteEnabled) { |
| 82 | + willNotWorkOnGpu("Delta Lake output acceleration has been disabled. To enable set " + |
| 83 | + s"${RapidsConf.ENABLE_DELTA_WRITE} to true") |
| 84 | + } |
| 85 | + RapidsDeltaUtils.tagForDeltaWrite( |
| 86 | + this, cmd.query.schema, Some(cmd.deltaLog), cmd.options, SparkSession.active) |
| 87 | + cmd.statsTrackers.foreach { |
| 88 | + case _: DeltaIdentityColumnStatsTracker => |
| 89 | + willNotWorkOnGpu("DBR identity-column write statistics are not supported by the " + |
| 90 | + "GPU WriteIntoDeltaCommand") |
| 91 | + case _: StatisticsOnLoadJobTracker => |
| 92 | + willNotWorkOnGpu("DBR statistics-on-load are not supported by the GPU " + |
| 93 | + "WriteIntoDeltaCommand") |
| 94 | + case _: BasicWriteJobStatsTracker => |
| 95 | + case delta: DeltaJobStatisticsTracker => |
| 96 | + GpuLiquidOptimizeWriteIntoDeltaCommand.extractStatsCollectionSchema(delta) match { |
| 97 | + case Left(reason) => willNotWorkOnGpu(reason) |
| 98 | + case Right(_) => |
| 99 | + } |
| 100 | + case tracker => |
| 101 | + willNotWorkOnGpu(s"DBR write statistics tracker ${tracker.getClass.getName} is not " + |
| 102 | + "supported by the GPU WriteIntoDeltaCommand") |
| 103 | + } |
| 104 | + } |
| 105 | + |
| 106 | + override def convertToGpu(): GpuDataWritingCommand = GpuLiquidOptimizeWriteIntoDeltaCommand( |
| 107 | + cmd, |
| 108 | + conf.stableSort, |
| 109 | + conf.concurrentWriterPartitionFlushSize, |
| 110 | + conf.outputDebugDumpPrefix) |
| 111 | +} |
| 112 | + |
| 113 | +object GpuLiquidOptimizeWriteIntoDeltaCommand { |
| 114 | + def extractStatsCollectionSchema( |
| 115 | + tracker: DeltaJobStatisticsTracker): Either[String, StructType] = { |
| 116 | + val dataSchema = StructType(tracker.dataCols.map { attr => |
| 117 | + StructField(attr.name, attr.dataType, attr.nullable, attr.metadata) |
| 118 | + }) |
| 119 | + val nullCountSchema = tracker.statsColExpr.collect { |
| 120 | + case struct: CreateNamedStruct => struct.dataType |
| 121 | + }.collectFirst { |
| 122 | + case schema: StructType if schema.fieldNames.contains(DeltaStatistics.NULL_COUNT) => |
| 123 | + schema(DeltaStatistics.NULL_COUNT).dataType match { |
| 124 | + case nullCountSchema: StructType => Right(nullCountSchema) |
| 125 | + case other => Left(s"DBR Delta statistics ${DeltaStatistics.NULL_COUNT} field has " + |
| 126 | + s"unsupported type $other") |
| 127 | + } |
| 128 | + }.getOrElse(Left( |
| 129 | + s"DBR Delta statistics expression has no ${DeltaStatistics.NULL_COUNT} struct")) |
| 130 | + nullCountSchema.flatMap(projectStatsCollectionSchema(dataSchema, _)) |
| 131 | + } |
| 132 | + |
| 133 | + private def projectStatsCollectionSchema( |
| 134 | + dataSchema: StructType, |
| 135 | + statsShape: StructType, |
| 136 | + parentPath: Seq[String] = Nil): Either[String, StructType] = { |
| 137 | + statsShape.fields.foldLeft[Either[String, Seq[StructField]]](Right(Seq.empty)) { |
| 138 | + case (result, statsField) => |
| 139 | + result.flatMap { projectedFields => |
| 140 | + val fieldPath = parentPath :+ statsField.name |
| 141 | + dataSchema.fields.find(_.name == statsField.name) match { |
| 142 | + case None => |
| 143 | + Left(s"DBR Delta statistics field ${fieldPath.mkString(".")} is not present " + |
| 144 | + "in the write data schema") |
| 145 | + case Some(dataField) => |
| 146 | + (dataField.dataType, statsField.dataType) match { |
| 147 | + case (dataStruct: StructType, statsStruct: StructType) => |
| 148 | + projectStatsCollectionSchema(dataStruct, statsStruct, fieldPath) |
| 149 | + .map(projected => projectedFields :+ dataField.copy(dataType = projected)) |
| 150 | + case (_: StructType, _) | (_, _: StructType) => |
| 151 | + Left(s"DBR Delta statistics field ${fieldPath.mkString(".")} has a " + |
| 152 | + "different nested structure than the write data schema") |
| 153 | + case _ => |
| 154 | + // nullCount contains Long leaves. Keep only its shape and ordering while using |
| 155 | + // the original data types required for min/max collection. |
| 156 | + Right(projectedFields :+ dataField) |
| 157 | + } |
| 158 | + } |
| 159 | + } |
| 160 | + }.map(StructType(_)) |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +/** |
| 165 | + * GPU file-writing equivalent of DBR's [[WriteIntoDeltaCommand]]. |
| 166 | + * |
| 167 | + * The command deliberately reuses the native output specification and commit protocol. The |
| 168 | + * enclosing DBR transaction therefore remains responsible for AddFile creation, liquid domain |
| 169 | + * metadata, and the final commit; only FileFormatWriter's row writer is replaced here. |
| 170 | + */ |
| 171 | +case class GpuLiquidOptimizeWriteIntoDeltaCommand( |
| 172 | + cpuCmd: WriteIntoDeltaCommand, |
| 173 | + useStableSort: Boolean, |
| 174 | + concurrentWriterPartitionFlushSize: Long, |
| 175 | + baseDebugOutputPath: Option[String]) |
| 176 | + extends GpuDataWritingCommand { |
| 177 | + |
| 178 | + override def query: LogicalPlan = cpuCmd.query |
| 179 | + |
| 180 | + override def outputColumnNames: Seq[String] = cpuCmd.outputColumnNames |
| 181 | + |
| 182 | + override def requireSingleBatch: Boolean = false |
| 183 | + |
| 184 | + override def runColumnar( |
| 185 | + sparkSession: ClassicSparkSession, |
| 186 | + child: SparkPlan): Seq[ColumnarBatch] = { |
| 187 | + val outputColumns = cpuCmd.outputSpec.outputColumns |
| 188 | + val partitionColumns = cpuCmd.partitionColExprIds.map { exprId => |
| 189 | + outputColumns.find(_.exprId == exprId).get |
| 190 | + } |
| 191 | + val convertedTrackers = cpuCmd.statsTrackers.map(convertTracker) |
| 192 | + |
| 193 | + GpuDeltaFileFormatWriter.write( |
| 194 | + sparkSession = sparkSession, |
| 195 | + plan = child, |
| 196 | + fileFormat = new GpuParquetFileFormat, |
| 197 | + committer = cpuCmd.committer, |
| 198 | + outputSpec = cpuCmd.outputSpec, |
| 199 | + hadoopConf = cpuCmd.hadoopConf, |
| 200 | + partitionColumns = partitionColumns, |
| 201 | + bucketSpec = cpuCmd.bucketSpec, |
| 202 | + statsTrackers = convertedTrackers.map(_.gpu) :+ |
| 203 | + gpuWriteJobStatsTracker(cpuCmd.hadoopConf), |
| 204 | + options = cpuCmd.options, |
| 205 | + useStableSort = useStableSort, |
| 206 | + concurrentWriterPartitionFlushSize = concurrentWriterPartitionFlushSize, |
| 207 | + baseDebugOutputPath = baseDebugOutputPath) |
| 208 | + convertedTrackers.foreach(_.copyResultToCpu()) |
| 209 | + Seq.empty |
| 210 | + } |
| 211 | + |
| 212 | + private case class ConvertedTracker( |
| 213 | + gpu: ColumnarWriteJobStatsTracker, |
| 214 | + copyResultToCpu: () => Unit) |
| 215 | + |
| 216 | + private def convertTracker(tracker: WriteJobStatsTracker): ConvertedTracker = tracker match { |
| 217 | + case identity: DeltaIdentityColumnStatsTracker => |
| 218 | + throw new IllegalStateException( |
| 219 | + s"Unsupported identity-column statistics tracker ${identity.getClass.getName}") |
| 220 | + case statsOnLoad: StatisticsOnLoadJobTracker => |
| 221 | + throw new IllegalStateException( |
| 222 | + s"Unsupported statistics-on-load tracker ${statsOnLoad.getClass.getName}") |
| 223 | + case basic: BasicWriteJobStatsTracker => |
| 224 | + val gpu = new BasicColumnarWriteJobStatsTracker( |
| 225 | + new SerializableConfiguration(cpuCmd.hadoopConf), |
| 226 | + GpuMetric.wrap(basic.driverSideMetrics), |
| 227 | + NoopMetric) |
| 228 | + ConvertedTracker(gpu, () => ()) |
| 229 | + case delta: DeltaJobStatisticsTracker => |
| 230 | + val dataSchema = StructType(delta.dataCols.map { attr => |
| 231 | + StructField(attr.name, attr.dataType, attr.nullable, attr.metadata) |
| 232 | + }) |
| 233 | + val statsSchema = GpuLiquidOptimizeWriteIntoDeltaCommand.extractStatsCollectionSchema(delta) |
| 234 | + .fold(reason => throw new IllegalStateException(reason), identity) |
| 235 | + val explodedDataSchema = GpuStatisticsCollection.explode(dataSchema).toMap |
| 236 | + val statsColExpr = delta.statsColExpr.transform { |
| 237 | + case runtime: RuntimeReplaceable => runtime.replacement |
| 238 | + } |
| 239 | + val batchStatsToRow = (batch: ColumnarBatch, row: InternalRow) => { |
| 240 | + GpuStatisticsCollection.batchStatsToRow( |
| 241 | + statsSchema, explodedDataSchema, batch, row) |
| 242 | + } |
| 243 | + val gpu = new GpuDeltaJobStatisticsTracker( |
| 244 | + delta.dataCols, statsColExpr, batchStatsToRow) |
| 245 | + ConvertedTracker(gpu, () => delta.recordedStats = gpu.recordedStats) |
| 246 | + case other => |
| 247 | + throw new IllegalStateException(s"Unsupported write statistics tracker " + |
| 248 | + other.getClass.getName) |
| 249 | + } |
| 250 | +} |
0 commit comments