Skip to content

Commit 41129e5

Browse files
authored
Add DBR 17.3 Delta CTAS/RTAS support and fix optimized writes [databricks] (NVIDIA#15320)
Fixes NVIDIA#15263 ### Description The initial Delta Lake write support for Databricks Runtime 17.3 kept atomic CTAS and RTAS operations on CPU. In addition, DBR 17.3 optimized writes could lose their target-partitioning marker during CPU-to-GPU shuffle conversion, preventing the optimized-write exchange from being reconstructed and resized correctly on GPU. This PR enables the supported DBR 17.3 Delta CTAS/RTAS paths on GPU and fixes the optimized-write shuffle conversion. In this PR: - Converts supported `AtomicCreateTableAsSelectExec` and `AtomicReplaceTableAsSelectExec` plans to the corresponding GPU atomic wrappers. - Retains DBR’s native catalog, staged-table, commit, abort, cache invalidation, and table-replacement semantics. - Keeps the query and Delta data-file write path GPU-eligible inside the atomic operation. - Preserves `DeltaOptimizedWritePartitioning` as the target partitioning while advertising the derived physical partitioning as the shuffle output. - Preserves optimized-write marker-local configuration overrides during CPU-to-GPU conversion. - Keeps the target and physical partitioning contracts consistent through tree copies, explicit partition-count changes, and AQE reconstruction. - Adds structural plan validation based on the `DELTA_OPTIMIZED_WRITE` shuffle origin, preventing unrelated GPU exchanges from satisfying the optimized-write plan check. - Adds focused conversion and AQE coverage for the DBR 17.3 optimized-write shuffle. ### Expected fallback behavior The implementation continues to fall back to CPU for table-creation features whose DBR metadata or catalog semantics have not been qualified for the GPU create path, including: - Row filters and column masks. - Liquid clustering and auto-TTL. - Catalog-owned tables and coordinated commits. - Explicit or session-default deletion-vector creation settings that require unsupported metadata handling. When Delta optimized writes are enabled with AQE, the `DELTA_OPTIMIZED_WRITE` exchange can run on GPU. With optimized writes enabled and AQE disabled, DBR uses its non-AQE `DeltaOptimizedWriterExec`; that path remains an expected CPU fallback. ### Performance The scaled workload used: - Databricks Runtime 17.3, Spark 4.0, and Scala 2.13. - Eight `g4dn.4xlarge` T4 workers and a matching driver. - A deterministic SF100-class Delta source with 452,433,852 rows across 12 source tables and 27.36 GB of compressed source data. - Fourteen representative model-build transformations producing 425,795,424 final rows. This is an SF100-class workload and is not an official TPC-DS SF100 benchmark. #### GPU Delta writer benefit Delta optimized writes were disabled in this comparison so that it isolates the Delta writer change. | Configuration | Delta writer | Fresh CTAS | Replacement RTAS | | --- | --- | ---: | ---: | | RAPIDS 26.06 release | CPU fallback | 138.819 s | 133.876 s | | 26.08 SNAPSHOT CPU-writer control | CPU | 128.665 s | 126.982 s | | 26.08 SNAPSHOT with this GPU write path | GPU | 92.874 s | 90.778 s | Compared with the same-26.08 CPU-writer control, GPU Delta writing was: - 1.385x faster for fresh table creation. - 1.399x faster for replacement. Compared with the RAPIDS 26.06 released behavior, it was: - 1.495x faster for fresh table creation, a 33.1% wall-time reduction. - 1.475x faster for replacement, a 32.2% wall-time reduction. Physical-plan validation confirmed that the 26.06 run accelerated the query operators but crossed through `GpuColumnarToRow` into CPU `WriteIntoDeltaCommand`/`WriteFiles`. The 26.08 GPU result used the GPU atomic wrapper and GPU Delta writer. #### Delta optimized-write tradeoff spark.databricks.delta.optimizeWrite.enabled=false/true using the build jar from this PR. | Delta optimized writes | Fresh median | Replacement median | Output files | | --- | ---: | ---: | ---: | | Off | 95.724 s | 95.318 s | 2,004 | | On, with AQE | 117.304 s | 116.041 s | 84 | Enabling optimized writes increased write time by 22.5% for fresh creation and 21.7% for replacement. In exchange, it reduced the output from 2,004 files to 84 files, a 23.86x reduction. This was not caused by CPU fallback: the optimize-on runs used GPU atomic wrappers, GPU Delta writing, and GPU `DELTA_OPTIMIZED_WRITE` exchanges. The additional time represents the actual repartitioning and file-sizing work required to produce the more compact layout. ### Checklists Documentation - [ ] Updated for new or modified user-facing features or behaviors - [x] No user-facing change Testing - [x] Added or modified tests to cover new code paths - [ ] Covered by existing tests (Please provide the names of the existing tests in the PR description.) - [ ] Not required Performance - [x] Tests ran and results are added in the PR description - [ ] Issue filed with a link in the PR description - [ ] Not required --------- Signed-off-by: Niranjan Artal <nartal@nvidia.com>
1 parent 09482e5 commit 41129e5

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)