Skip to content

Commit 2acd1ec

Browse files
Add withRetry to GpuBatchedBoundedWindowIterator (#14139)
Contributes to #13672 ### Description This PR: Add retry support to GpuBatchedBoundedWindowIterator to handle OOM: - Protect the following 3 operations with OOM retry support. - Window computation (by `computeWindowWithRetry`) - Input batch concatenation with the cache (by `getNextInputBatchWithRetry`) - Batch trim (by `trimWithRetry`) - Add unit tests for retry and split-and-retry OOM scenarios NDS numbers (:Seconds) with 10k data size shows no perf regressions. 'rapids-4-spark_2.12-26.02.0-20260102.073925-32-cuda12.jar' was used for nightly runs, not sure why it is a little slower. Will try to run this more. |ID|with PR| Nitghtly| |--|--|--| |1| 1302 | 1369| |2| 1315 | 1389| |avg| 1308.5|1379| ### Checklists - [x] This PR has added documentation for new or modified features or behaviors. - [x] This PR has added new tests or modified existing tests to cover new code paths. (Please explain in the PR description how the new code paths are tested, such as names of the new/existing tests that cover them.) - [x] Performance testing has been performed and its results are added in the PR description. Or, an issue has been filed with a link in the PR description. --------- Signed-off-by: Firestarman <firestarmanllc@gmail.com> Co-authored-by: Firestarman <firestarmanllc@gmail.com>
1 parent 101af1f commit 2acd1ec

2 files changed

Lines changed: 248 additions & 112 deletions

File tree

sql-plugin/src/main/scala/com/nvidia/spark/rapids/window/GpuBatchedBoundedWindowExec.scala

Lines changed: 103 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2023-2025, NVIDIA CORPORATION.
2+
* Copyright (c) 2023-2026, NVIDIA CORPORATION.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,9 +16,11 @@
1616

1717
package com.nvidia.spark.rapids.window
1818

19-
import ai.rapids.cudf.{ColumnVector => CudfColumnVector, Table => CudfTable}
19+
import ai.rapids.cudf.Table
2020
import com.nvidia.spark.rapids._
21-
import com.nvidia.spark.rapids.Arm.withResource
21+
import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource}
22+
import com.nvidia.spark.rapids.RapidsPluginImplicits._
23+
import com.nvidia.spark.rapids.RmmRapidsRetryIterator.withRetryNoSplit
2224
import com.nvidia.spark.rapids.ScalableTaskCompletion.onTaskCompletion
2325

2426
import org.apache.spark.TaskContext
@@ -46,7 +48,8 @@ class GpuBatchedBoundedWindowIterator(
4648

4749
override def hasNext: Boolean = numUnprocessedInCache > 0 || input.hasNext
4850

49-
var cached: Option[Array[CudfColumnVector]] = None // For processing with the next batch.
51+
// For processing with the next batch.
52+
private var cached: Option[SpillableColumnarBatch] = None
5053

5154
private var numUnprocessedInCache: Int = 0 // numRows at the bottom not processed completely.
5255
private var numPrecedingRowsAdded: Int = 0 // numRows at the top, added for preceding context.
@@ -59,151 +62,140 @@ class GpuBatchedBoundedWindowIterator(
5962
}
6063

6164
// Caches input column schema on first read.
62-
var inputTypes: Option[Array[DataType]] = None
65+
private var inputTypes: Option[Array[DataType]] = None
6366

64-
// Clears cached column vectors, after consumption.
65-
private def clearCached(): Unit = {
66-
cached.foreach(_.foreach(_.close))
67+
// Clears the cache after consumption.
68+
private[rapids] def clearCached(): Unit = {
69+
cached.foreach(_.close())
6770
cached = None
6871
}
6972

70-
private def getNextInputBatch: SpillableColumnarBatch = {
71-
// Sets column batch types using the types cached from the
72-
// first input column read.
73-
def optionallySetInputTypes(inputCB: ColumnarBatch): Unit = {
74-
if (inputTypes.isEmpty) {
75-
inputTypes = Some(GpuColumnVector.extractTypes(inputCB))
76-
}
77-
}
78-
79-
// Reads fresh batch from iterator, initializes input data-types if necessary.
80-
def getFreshInputBatch: ColumnarBatch = {
81-
val fresh_batch = input.next()
82-
optionallySetInputTypes(fresh_batch)
83-
fresh_batch
84-
}
85-
86-
def concatenateColumns(cached: Array[CudfColumnVector],
87-
freshBatchTable: CudfTable)
88-
: Array[CudfColumnVector] = {
89-
90-
if (cached.length != freshBatchTable.getNumberOfColumns) {
91-
throw new IllegalArgumentException("Expected the same number of columns " +
92-
"in input batch and cached batch.")
93-
}
94-
cached.zipWithIndex.map { case (cachedCol, idx) =>
95-
CudfColumnVector.concatenate(cachedCol, freshBatchTable.getColumn(idx))
96-
}
97-
}
73+
protected final def hasCache: Boolean = cached.isDefined // for unit test
9874

75+
protected def getNextInputBatchWithRetry: SpillableColumnarBatch = {
9976
// Either cached has unprocessed rows, or input.hasNext().
10077
if (input.hasNext) {
101-
if (cached.isDefined) {
78+
val freshSCB = closeOnExcept(input.next()) { freshCB =>
79+
if (inputTypes.isEmpty) {
80+
// initializes input data-types if necessary.
81+
inputTypes = Some(GpuColumnVector.extractTypes(freshCB))
82+
}
83+
SpillableColumnarBatch(freshCB, SpillPriorities.ACTIVE_BATCHING_PRIORITY)
84+
}
85+
if (hasCache) {
10286
// Cached input AND new input rows exist. Return concat-ed rows.
103-
withResource(getFreshInputBatch) { freshBatchCB =>
104-
withResource(GpuColumnVector.from(freshBatchCB)) { freshBatchTable =>
105-
withResource(concatenateColumns(cached.get, freshBatchTable)) { concat =>
106-
clearCached()
107-
SpillableColumnarBatch(convertToBatch(inputTypes.get, concat),
108-
SpillPriorities.ACTIVE_BATCHING_PRIORITY)
109-
}
87+
// The two input batches will be closed by `withRetryNoSplit`.
88+
val concatedTbl = withRetryNoSplit(Seq(cached.get, freshSCB)) { toConcat =>
89+
val cbs = toConcat.safeMap(_.getColumnarBatch())
90+
val tbls = withResource(cbs)(_ => cbs.safeMap(GpuColumnVector.from))
91+
withResource(tbls)(_ => Table.concatenate(tbls: _*))
92+
}
93+
withResource(concatedTbl) { _ =>
94+
cached = None
95+
closeOnExcept(GpuColumnVector.from(concatedTbl, inputTypes.get)) { concatedCB =>
96+
SpillableColumnarBatch(concatedCB, SpillPriorities.ACTIVE_BATCHING_PRIORITY)
11097
}
11198
}
112-
} else {
113-
// No cached input available. Return fresh input rows, only.
114-
SpillableColumnarBatch(getFreshInputBatch,
115-
SpillPriorities.ACTIVE_BATCHING_PRIORITY)
99+
} else { // no cache, return the input batch directly
100+
freshSCB
116101
}
117-
}
118-
else {
119-
// No fresh input available. Return cached input.
120-
val cachedCB = convertToBatch(inputTypes.get, cached.get)
121-
clearCached()
122-
SpillableColumnarBatch(cachedCB,
123-
SpillPriorities.ACTIVE_BATCHING_PRIORITY)
102+
} else { // No fresh input available. Return cached input.
103+
val cachedSCB = cached.get
104+
cached = None
105+
cachedSCB
124106
}
125107
}
126108

127109
/**
128110
* Helper to trim specified number of rows off the top and bottom,
129111
* of all specified columns.
130112
*/
131-
private def trim(columns: Array[CudfColumnVector],
132-
offTheTop: Int,
133-
offTheBottom: Int): Array[CudfColumnVector] = {
113+
protected def trimWithRetry(scb: SpillableColumnarBatch, offTheTop: Int,
114+
offTheBottom: Int): ColumnarBatch = {
115+
val batchRowsNum = scb.numRows()
116+
if ((offTheTop + offTheBottom) > batchRowsNum) {
117+
throw new IllegalArgumentException(s"Cannot trim batch of size ${batchRowsNum} by " +
118+
s"$offTheTop rows at the top, and $offTheBottom rows at the bottom.")
119+
}
134120

135-
def checkValidSizes(col: CudfColumnVector): Unit =
136-
if ((offTheTop + offTheBottom) > col.getRowCount) {
137-
throw new IllegalArgumentException(s"Cannot trim column of size ${col.getRowCount} by " +
138-
s"$offTheTop rows at the top, and $offTheBottom rows at the bottom.")
121+
withRetryNoSplit[ColumnarBatch] {
122+
withResource(scb.getColumnarBatch()) { cb =>
123+
val types = GpuColumnVector.extractTypes(cb)
124+
val baseCols = GpuColumnVector.extractBases(cb)
125+
withResource(baseCols.safeMap(_.subVector(offTheTop, batchRowsNum - offTheBottom))) {
126+
convertToBatch(types, _)
127+
}
139128
}
129+
}
130+
}
140131

141-
columns.map{ col =>
142-
checkValidSizes(col)
143-
col.subVector(offTheTop, col.getRowCount.toInt - offTheBottom)
132+
/**
133+
* Compute the window aggregations on the input batch with retry support.
134+
* It does not close the input batch.
135+
*/
136+
protected def computeWindowWithRetry(
137+
scb: SpillableColumnarBatch): SpillableColumnarBatch = {
138+
val outputCB = withRetryNoSplit[ColumnarBatch] {
139+
val outputCols = withResource(scb.getColumnarBatch())(computeBasicWindow)
140+
withResource(outputCols)(convertToBatch(outputTypes, _))
144141
}
142+
SpillableColumnarBatch(outputCB, SpillPriorities.ACTIVE_BATCHING_PRIORITY)
145143
}
146144

147-
private def resetInputCache(newCache: Option[Array[CudfColumnVector]],
145+
private def resetInputCache(newCache: Option[ColumnarBatch],
148146
newPrecedingAdded: Int): Unit= {
149-
cached.foreach(_.foreach(_.close))
150-
cached = newCache
147+
clearCached()
148+
cached = newCache.map(
149+
SpillableColumnarBatch(_, SpillPriorities.ACTIVE_BATCHING_PRIORITY)
150+
)
151151
numPrecedingRowsAdded = newPrecedingAdded
152152
}
153153

154154
override def next(): ColumnarBatch = {
155155
var outputBatch: ColumnarBatch = null
156-
while (outputBatch == null && hasNext) {
157-
withResource(getNextInputBatch) { inputCbSpillable =>
158-
withResource(inputCbSpillable.getColumnarBatch()) { inputCB =>
159-
160-
val inputRowCount = inputCB.numRows()
161-
val noMoreInput = !input.hasNext
162-
numUnprocessedInCache = if (noMoreInput) {
163-
// If there are no more input rows expected,
164-
// this is the last output batch.
165-
// Consider all rows in the batch as processed.
156+
while (outputBatch == null && hasNext) {
157+
withResource(getNextInputBatchWithRetry) { inputSCB =>
158+
val inputRowCount = inputSCB.numRows()
159+
val noMoreInput = !input.hasNext
160+
numUnprocessedInCache = if (noMoreInput) {
161+
// If there are no more input rows expected,
162+
// this is the last output batch.
163+
// Consider all rows in the batch as processed.
164+
0
165+
} else {
166+
// More input rows expected. The last `maxFollowing` rows can't be finalized.
167+
// Cannot exceed `inputRowCount`.
168+
if (maxFollowing < 0) { // E.g. LAG(3) => [ preceding=-3, following=-3 ]
169+
// -ve following => No need to wait for more following rows.
170+
// All "following" context is already available in the current batch.
166171
0
167172
} else {
168-
// More input rows expected. The last `maxFollowing` rows can't be finalized.
169-
// Cannot exceed `inputRowCount`.
170-
if (maxFollowing < 0) { // E.g. LAG(3) => [ preceding=-3, following=-3 ]
171-
// -ve following => No need to wait for more following rows.
172-
// All "following" context is already available in the current batch.
173-
0
174-
} else {
175-
maxFollowing min inputRowCount
176-
}
173+
maxFollowing min inputRowCount
177174
}
175+
}
178176

179-
if (numPrecedingRowsAdded + numUnprocessedInCache >= inputRowCount) {
180-
// No point calling windowing kernel: the results will simply be ignored.
181-
logWarning("Not enough rows! Cannot output a batch.")
182-
} else {
183-
NvtxIdWithMetrics(NvtxRegistry.WINDOW_EXEC, opTime) {
184-
withResource(computeBasicWindow(inputCB)) { outputCols =>
185-
outputBatch = withResource(
186-
trim(outputCols,
187-
numPrecedingRowsAdded, numUnprocessedInCache)) { trimmed =>
188-
convertToBatch(outputTypes, trimmed)
189-
}
190-
}
177+
if (numPrecedingRowsAdded + numUnprocessedInCache >= inputRowCount) {
178+
// No point calling windowing kernel: the results will simply be ignored.
179+
logWarning("Not enough rows! Cannot output a batch.")
180+
} else {
181+
NvtxIdWithMetrics(NvtxRegistry.WINDOW_EXEC, opTime) {
182+
outputBatch = withResource(computeWindowWithRetry(inputSCB)) {
183+
trimWithRetry(_, numPrecedingRowsAdded, numUnprocessedInCache)
191184
}
192185
}
186+
}
193187

194-
// Compute new cache using current input.
195-
numPrecedingRowsAdded = if (minPreceding > 0) { // E.g. LEAD(3) => [prec=3, foll=3]
196-
// preceding > 0 => No "preceding" rows need be carried forward.
197-
// Only the rows that need to be recomputed.
198-
0
199-
} else {
200-
Math.abs(minPreceding) min (inputRowCount - numUnprocessedInCache)
201-
}
202-
val inputCols = Range(0, inputCB.numCols()).map {
203-
inputCB.column(_).asInstanceOf[GpuColumnVector].getBase
204-
}.toArray
188+
// Compute new cache using current input.
189+
numPrecedingRowsAdded = if (minPreceding > 0) { // E.g. LEAD(3) => [prec=3, foll=3]
190+
// preceding > 0 => No "preceding" rows need be carried forward.
191+
// Only the rows that need to be recomputed.
192+
0
193+
} else {
194+
Math.abs(minPreceding) min (inputRowCount - numUnprocessedInCache)
195+
}
205196

206-
val newCached = trim(inputCols,
197+
if (!noMoreInput) { // cache is needed only when have more input data
198+
val newCached = trimWithRetry(inputSCB,
207199
inputRowCount - (numPrecedingRowsAdded + numUnprocessedInCache),
208200
0)
209201
resetInputCache(Some(newCached), numPrecedingRowsAdded)

0 commit comments

Comments
 (0)