Skip to content

Commit f1566c2

Browse files
authored
[auto-merge] release/26.08 to main [skip ci] [bot] (NVIDIA#15395)
auto-merge triggered by github actions on `release/26.08` to create a PR keeping `main` up-to-date. If this PR is unable to be merged due to conflicts, it will remain open until manually fix.
2 parents 334e5b1 + 41129e5 commit f1566c2

23 files changed

Lines changed: 1705 additions & 226 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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

Comments
 (0)