Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b3698f5
Support NOT MATCHED BY SOURCE in the GPU MERGE command on Databricks …
jtwynne Sep 3, 2026
6c0354c
Fix batch leak in the GPU merge processor when a clause projection th…
jtwynne Sep 3, 2026
e0df412
Merge branch 'main' into db173-merge-not-matched-by-source
jtwynne Sep 3, 2026
620ba54
Compensate numTargetRowsMatchedDeleted for duplicate source matches […
jtwynne Sep 3, 2026
fbb6b2a
Merge branch 'main' into db173-merge-not-matched-by-source
jtwynne Sep 3, 2026
d39b613
Merge branch 'main' into db173-merge-not-matched-by-source
jtwynne Sep 3, 2026
329f704
Merge branch 'main' into db173-merge-not-matched-by-source
jtwynne Sep 3, 2026
7d95ec4
Merge branch 'main' into db173-merge-not-matched-by-source
jtwynne Sep 4, 2026
cfa6d77
Merge branch 'main' into db173-merge-not-matched-by-source
jtwynne Sep 4, 2026
eb8f690
Apply WHEN MATCHED conditions to multiple-match detection on Databric…
jtwynne Sep 4, 2026
ded963e
Treat a NULL clause condition as false in the GPU merge processor [da…
jtwynne Sep 4, 2026
84c88fc
Merge branch 'main' into db173-merge-not-matched-by-source
jtwynne Sep 4, 2026
108cf7c
Generate the helper column names for duplicate-match de-duplication […
jtwynne Sep 6, 2026
e23e07e
Merge branch 'db173-merge-not-matched-by-source' of https://github.co…
jtwynne Sep 6, 2026
bbfabf8
Attach every merge helper column under a generated name [databricks]
jtwynne Sep 6, 2026
2a1669b
Preserve row tracking through the Databricks 17.3 GPU merge [databricks]
jtwynne Sep 6, 2026
6be0e90
Merge branch 'main' into db173-merge-not-matched-by-source
jtwynne Sep 6, 2026
3ebe117
Merge branch 'main' into db173-merge-not-matched-by-source
jtwynne Sep 7, 2026
661ef6b
Merge branch 'main' into db173-merge-not-matched-by-source
jtwynne Sep 7, 2026
cf98af6
Merge branch 'main' into db173-merge-not-matched-by-source
jtwynne Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023-2025, NVIDIA CORPORATION.
* Copyright (c) 2023-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.
Expand All @@ -18,7 +18,7 @@ package com.nvidia.spark.rapids.delta

import scala.collection.mutable.ArrayBuffer

import ai.rapids.cudf.{NvtxColor, Table}
import ai.rapids.cudf.{ColumnVector, NvtxColor, Scalar, Table}
import com.nvidia.spark.rapids._
import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource}
import com.nvidia.spark.rapids.AssertUtils.assertInTests
Expand Down Expand Up @@ -49,7 +49,8 @@ object RapidsProcessDeltaMergeJoinStrategy extends SparkStrategy {
notMatchedBySourceConditions = p.notMatchedBySourceConditions,
notMatchedBySourceOutputs = p.notMatchedBySourceOutputs,
noopCopyOutput = p.noopCopyOutput,
deleteRowOutput = p.deleteRowOutput))
deleteRowOutput = p.deleteRowOutput,
rowDroppedColumnIndex = p.rowDroppedColumnIndex))
case _ => Nil
}
}
Expand All @@ -66,7 +67,10 @@ case class RapidsProcessDeltaMergeJoin(
notMatchedBySourceConditions: Seq[Expression],
notMatchedBySourceOutputs: Seq[Seq[Seq[Expression]]],
noopCopyOutput: Seq[Expression],
deleteRowOutput: Seq[Expression]) extends UnaryNode {
deleteRowOutput: Seq[Expression],
// Position of the row-dropped control column in every projected output row. When None, the
// column is located by its name in `output`, falling back to the position after `output`.
rowDroppedColumnIndex: Option[Int] = None) extends UnaryNode {

@transient
override lazy val references: AttributeSet = inputSet
Expand All @@ -88,7 +92,8 @@ case class RapidsProcessDeltaMergeJoinExec(
notMatchedConditions: Seq[Expression],
notMatchedOutputs: Seq[Seq[Seq[Expression]]],
noopCopyOutput: Seq[Expression],
deleteRowOutput: Seq[Expression]) extends UnaryExecNode {
deleteRowOutput: Seq[Expression],
rowDroppedColumnIndex: Option[Int] = None) extends UnaryExecNode {

override protected def doExecute(): RDD[InternalRow] = {
throw new IllegalStateException("Should have been replaced by a GpuRapidsProcessMergeJoinExec")
Expand Down Expand Up @@ -122,7 +127,8 @@ class RapidsProcessDeltaMergeJoinMeta(
notMatchedBySourceConditions = p.notMatchedBySourceConditions.map(convertExprToGpu),
notMatchedBySourceOutputs = p.notMatchedBySourceOutputs.map(_.map(_.map(convertExprToGpu))),
noopCopyOutput = p.noopCopyOutput.map(convertExprToGpu),
deleteRowOutput = p.deleteRowOutput.map(convertExprToGpu))
deleteRowOutput = p.deleteRowOutput.map(convertExprToGpu),
rowDroppedColumnIndex = p.rowDroppedColumnIndex)
}

private def convertExprToGpu(e: Expression): Expression = {
Expand All @@ -149,14 +155,11 @@ case class GpuRapidsProcessDeltaMergeJoinExec(
notMatchedBySourceConditions: Seq[Expression],
notMatchedBySourceOutputs: Seq[Seq[Seq[Expression]]],
noopCopyOutput: Seq[Expression],
deleteRowOutput: Seq[Expression]) extends UnaryExecNode with GpuExec {
deleteRowOutput: Seq[Expression],
rowDroppedColumnIndex: Option[Int] = None) extends UnaryExecNode with GpuExec {
require(matchedConditions.length == matchedOutputs.length)
require(notMatchedConditions.length == notMatchedOutputs.length)

// TODO add support for notMatchedBy*
// see https://github.com/NVIDIA/spark-rapids/issues/8415
require(notMatchedBySourceConditions.isEmpty)
require(notMatchedBySourceOutputs.isEmpty)
require(notMatchedBySourceConditions.length == notMatchedBySourceOutputs.length)

private lazy val inputTypes: Array[DataType] = GpuColumnVector.extractTypes(child.schema)
private lazy val outputExprs: Seq[GpuBoundReference] = output.zipWithIndex.map {
Expand All @@ -169,6 +172,10 @@ case class GpuRapidsProcessDeltaMergeJoinExec(
private lazy val boundMatchedOutputs = matchedOutputs.map(_.map(_.map(bindForGpu)))
private lazy val boundNotMatchedConditions = notMatchedConditions.map(bindForGpu)
private lazy val boundNotMatchedOutputs = notMatchedOutputs.map(_.map(_.map(bindForGpu)))
private lazy val boundNotMatchedBySourceConditions =
notMatchedBySourceConditions.map(bindForGpu)
private lazy val boundNotMatchedBySourceOutputs =
notMatchedBySourceOutputs.map(_.map(_.map(bindForGpu)))
private lazy val boundNoopCopyOutput = noopCopyOutput.map(bindForGpu)
private lazy val boundDeleteRowOutput = deleteRowOutput.map(bindForGpu)

Expand All @@ -195,8 +202,11 @@ case class GpuRapidsProcessDeltaMergeJoinExec(
val localMatchedOutputs = boundMatchedOutputs
val localNotMatchedConditions = boundNotMatchedConditions
val localNotMatchedOutputs = boundNotMatchedOutputs
val localNotMatchedBySourceConditions = boundNotMatchedBySourceConditions
val localNotMatchedBySourceOutputs = boundNotMatchedBySourceOutputs
val localNoopCopyOutput = boundNoopCopyOutput
val localDeleteRowOutput = boundDeleteRowOutput
val localRowDroppedColumnIndex = rowDroppedColumnIndex
child.executeColumnar().mapPartitions { iter =>
new GpuRapidsProcessDeltaMergeJoinIterator(
iter = iter,
Expand All @@ -209,9 +219,12 @@ case class GpuRapidsProcessDeltaMergeJoinExec(
matchedOutputs = localMatchedOutputs,
notMatchedConditions = localNotMatchedConditions,
notMatchedOutputs = localNotMatchedOutputs,
notMatchedBySourceConditions = localNotMatchedBySourceConditions,
notMatchedBySourceOutputs = localNotMatchedBySourceOutputs,
noopCopyOutput = localNoopCopyOutput,
deleteRowOutput = localDeleteRowOutput,
allMetrics)
metrics = allMetrics,
rowDroppedColumnIndex = localRowDroppedColumnIndex)
}
}

Expand All @@ -231,9 +244,12 @@ class GpuRapidsProcessDeltaMergeJoinIterator(
matchedOutputs: Seq[Seq[Seq[GpuExpression]]],
notMatchedConditions: Seq[GpuExpression],
notMatchedOutputs: Seq[Seq[Seq[GpuExpression]]],
notMatchedBySourceConditions: Seq[GpuExpression],
notMatchedBySourceOutputs: Seq[Seq[Seq[GpuExpression]]],
noopCopyOutput: Seq[GpuExpression],
deleteRowOutput: Seq[GpuExpression],
metrics: Map[String, GpuMetric])
metrics: Map[String, GpuMetric],
rowDroppedColumnIndex: Option[Int] = None)
extends Iterator[ColumnarBatch] with AutoCloseable {

private[this] val intermediateTypes: Array[DataType] = noopCopyOutput.map(_.dataType).toArray
Expand Down Expand Up @@ -286,10 +302,13 @@ class GpuRapidsProcessDeltaMergeJoinIterator(
private def processSingleBatch(input: ColumnarBatch): ColumnarBatch = {
val (targetNoMatchBatch, targetMatchBatch) =
splitBatchAndClose(input, inputTypes, targetRowHasNoMatch)
val noopCopyBatch = closeOnExcept(targetMatchBatch) { _ =>
GpuProjectExec.projectAndClose(targetNoMatchBatch, noopCopyOutput, NoopMetric)
// Target rows without a source match are handled by the NOT MATCHED BY SOURCE clauses.
// A target row that satisfies none of the clause conditions is copied unchanged.
val targetNotMatchedBatches = closeOnExcept(targetMatchBatch) { _ =>
processProjectionSeries(targetNoMatchBatch,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processProjectionSeries partitions rows using predicate and predicate.not(). If a nullable NOT MATCHED BY SOURCE condition evaluates to NULL, both cuDF filter masks exclude the row because NOT NULL remains NULL. The row consequently reaches neither the clause output nor noopCopyOutput, silently removing an unmatched target row. Could we coalesce each condition to false before splitting and add a nullable-condition test? SQL MERGE should preserve the target row when no clause condition evaluates to true.

@jtwynne jtwynne Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha! And it looks like it isn't specific to the new clause. Every condition goes through the same split, so a WHEN MATCHED AND s.flag with a NULL flag was deleting the target row on the GPU where the CPU copies it. Fixed in ded963e, one level below these lines in splitBatchAndClose (the one place every clause type passes through) rather than just on the not-matched-by-source conditions: NULLs in the evaluated condition get replaced with false before the two filters (masks without NULLs are used as-is), so the row falls through to the next clause or the default the same way the CPU row processor does.

Tests: test_delta_merge_nullable_matched_conditions (all Delta versions; matched update and delete clauses plus an insert clause with NULL conditions, expected rows spelled out) and test_delta_merge_nullable_not_matched_by_source_condition (OSS 4.1 and 17.3+; adds a NOT MATCHED BY SOURCE condition that's NULL for target rows with a NULL column, GPU processor asserted in the plan). Without the fix the first one loses the four matched rows with a NULL flag and the second also loses the two target-only rows with a NULL column.

notMatchedBySourceConditions, notMatchedBySourceOutputs, noopCopyOutput)
}
val bigTable = withResource(noopCopyBatch) { _ =>
val bigTable = withResource(targetNotMatchedBatches) { _ =>
val (sourceNoMatchBatch, sourceMatchBatch) =
splitBatchAndClose(targetMatchBatch, inputTypes, sourceRowHasNoMatch)
val sourceNotMatchedBatches = closeOnExcept(sourceMatchBatch) { _ =>
Expand All @@ -300,9 +319,15 @@ class GpuRapidsProcessDeltaMergeJoinIterator(
val sourceMatchedBatches = processProjectionSeries(sourceMatchBatch,
matchedConditions, matchedOutputs, noopCopyOutput)
withResource(sourceMatchedBatches) { _ =>
val allBatches = (noopCopyBatch +: sourceNotMatchedBatches) ++ sourceMatchedBatches
// annoyingly Table.concatenate does not gracefully handle the degenerate case
if (allBatches.size == 1) {
val allBatches = targetNotMatchedBatches ++ sourceNotMatchedBatches ++
sourceMatchedBatches
// annoyingly Table.concatenate does not gracefully handle the degenerate cases
if (allBatches.isEmpty) {
// every projection series skips empty inputs, so an empty input batch ends up here
withResource(GpuColumnVector.emptyBatchFromTypes(intermediateTypes)) { emptyBatch =>
GpuColumnVector.from(emptyBatch)
}
} else if (allBatches.size == 1) {
GpuColumnVector.from(allBatches.head)
} else {
withResource(allBatches.safeMap(GpuColumnVector.from)) { allTables =>
Expand All @@ -313,11 +338,13 @@ class GpuRapidsProcessDeltaMergeJoinIterator(
}
}
val shouldNotDeleteBatch = withResource(bigTable) { _ =>
// If ROW_DROPPED_COL is not in output schema
// then CDC must be disabled and it's the column after our output cols
val shouldDeleteColumnIndex =
// The command that built the plan knows where the control column sits. Without that, if
// ROW_DROPPED_COL is not in the output schema then CDC must be disabled and it's the column
// after our output cols.
val shouldDeleteColumnIndex = rowDroppedColumnIndex.getOrElse {
output.zipWithIndex.find(_._1.name == GpuDeltaMergeConstants.ROW_DROPPED_COL).map(_._2)
.getOrElse(output.size)
}
val shouldDeleteColumn = bigTable.getColumn(shouldDeleteColumnIndex)
withResource(shouldDeleteColumn.not()) { notDeleteColumn =>
withResource(bigTable.filter(notDeleteColumn)) { notDeleteTable =>
Expand All @@ -336,11 +363,13 @@ class GpuRapidsProcessDeltaMergeJoinIterator(
closeOnExcept(new ArrayBuffer[ColumnarBatch]) { results =>
var leftOverBatch = input
conditions.zip(outputs).foreach { case (condition, output) =>
closeOnExcept(leftOverBatch) { _ =>
if (leftOverBatch.numRows() > 0) {
val (matchBatch, notMatchBatch) =
splitBatchAndClose(leftOverBatch, inputTypes, condition)
leftOverBatch = notMatchBatch
if (leftOverBatch.numRows() > 0) {
// splitBatchAndClose closes the batch it is given, so only the not-matched remainder
// is still open if a projection below throws
val (matchBatch, notMatchBatch) =
splitBatchAndClose(leftOverBatch, inputTypes, condition)
leftOverBatch = notMatchBatch
closeOnExcept(notMatchBatch) { _ =>
withResource(matchBatch) { _ =>
output.foreach { exprs =>
results.append(GpuProjectExec.project(matchBatch, exprs))
Expand All @@ -364,15 +393,20 @@ class GpuRapidsProcessDeltaMergeJoinIterator(
predicate: Expression): (ColumnarBatch, ColumnarBatch) = {
withResource(input) { _ =>
withResource(GpuColumnVector.from(input)) { inTable =>
val predCol = predicate.columnarEval(input)
// A clause condition that evaluates to NULL is false in SQL: the row moves on to the
// next clause or to the default output. cuDF's filter drops rows whose mask is NULL, and
// NOT NULL is NULL, so without this both halves would lose the row.
val predCol = withResource(predicate.columnarEval(input)) { evaluated =>
nullsAsFalse(evaluated.getBase)
}
val matchedBatch = closeOnExcept(predCol) { _ =>
withResource(inTable.filter(predCol.getBase)) { matchedTable =>
withResource(inTable.filter(predCol)) { matchedTable =>
GpuColumnVector.from(matchedTable, inputTypes)
}
}
closeOnExcept(matchedBatch) { _ =>
val notPredCol = withResource(predCol) { _ =>
predCol.getBase.not()
predCol.not()
}
val notMatchedBatch = withResource(notPredCol) { _ =>
withResource(inTable.filter(notPredCol)) { notMatchedTable =>
Expand All @@ -384,4 +418,14 @@ class GpuRapidsProcessDeltaMergeJoinIterator(
}
}
}

private def nullsAsFalse(mask: ColumnVector): ColumnVector = {
if (mask.hasNulls) {
withResource(Scalar.fromBool(false)) { falseScalar =>
mask.replaceNulls(falseScalar)
}
} else {
mask.incRefCount()
}
}
}
Loading
Loading