Skip to content

Commit d7fd093

Browse files
thirtisevenclaude
andauthored
Use per-batch retry block in R2C with lightweight OOM recovery (#14428)
Fixes #14368 ### Description PR #13842 wrapped each row in `RowToColumnarIterator.buildBatch` with `withRetryNoSplit` + `withRestoreOnRetry`, which introduced significant per-row overhead (~19x slower) from JNI calls, synchronized blocks, and object allocations on every row — even in the common no-OOM path. This PR replaces the per-row retry framework usage with a single per-batch retry block and a lightweight catch-based OOM recovery: **Design: per-batch retry block + inline OOM handling** The conversion loop runs inside `RmmRapidsRetryIterator.withRetryBlock`, which enters the RMM retry block once per batch (not per row). A per-row `captureState()` / `restoreState()` on the column builders enables rollback on OOM — these are lightweight offset snapshots with negligible overhead. On OOM, a single catch arm handles all cases: | Scenario | Action | |----------|--------| | RetryOOM/SplitAndRetryOOM with rows already converted (non-RequireSingleBatch) | Emit partial batch, save failed row as `pendingRow` for next batch | | RetryOOM with no rows yet (or RequireSingleBatch) | `blockUntilMemoryFreed()` — wait for spill, then while-loop retries naturally | | SplitAndRetryOOM with no rows yet | Propagate — can't split a single row | This is an **optimistic strategy**: pay minimal overhead (one `captureState` per row) in the common case, and only block/wait when absolutely necessary. **New retry framework utilities** Two general-purpose methods are added to `RmmRapidsRetryIterator` for incremental operations where partial progress is valuable (doesn't fit the standard atomic-retry model of `withRetryNoSplit`): - `withRetryBlock[T](fn: => T): T` — manages the retry block lifecycle without automatic retry - `blockUntilMemoryFreed()` — follows the standard protocol (exit retry block → `blockThreadUntilReady` → re-enter) for use within `withRetryBlock` **Other changes** - `ENABLE_R2C_RETRY` config default flipped from `false` to `true` — retry is now on by default with negligible overhead. The config is retained as an internal kill-switch to disable retry if needed. - Removed `RetryableRowConverter` class — no longer needed without per-row `withRetryNoSplit` **Performance** [Benchmark script](#14368 (comment)) | Configuration | Median | vs no-retry | |---------------|--------|-------------| | No retry | 829 ms | baseline | | Per-row retry (old) | 15,526 ms | **~19x slower** | | Per-batch retry (this PR) | 876 ms | **~5% slower** | ### 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. - `test simple GPU/CPU OOM retry` — OOM during conversion with RequireSingleBatch - `test CPU OOM retry preserves all rows for non-RequireSingleBatch` — emit-early path - `test first-row CPU OOM with TargetSize/RequireSingleBatch falls back to retry` — blockUntilMemoryFreed path - `test CPU SplitAndRetryOOM emit-early for non-RequireSingleBatch` — SplitAndRetryOOM emit-early path - `test simple OOM split and retry` — SplitAndRetryOOM propagation - [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: Haoyang Li <haoyangl@nvidia.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5f7aa22 commit d7fd093

4 files changed

Lines changed: 208 additions & 172 deletions

File tree

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

Lines changed: 110 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,9 @@
1616

1717
package com.nvidia.spark.rapids
1818

19-
import com.nvidia.spark.Retryable
2019
import com.nvidia.spark.rapids.Arm.withResource
2120
import com.nvidia.spark.rapids.GpuColumnVector.GpuColumnarBatchBuilder
22-
import com.nvidia.spark.rapids.RmmRapidsRetryIterator.{withRestoreOnRetry, withRetryNoSplit}
21+
import com.nvidia.spark.rapids.jni.{CpuRetryOOM, CpuSplitAndRetryOOM, GpuRetryOOM, GpuSplitAndRetryOOM}
2322
import com.nvidia.spark.rapids.shims.{CudfUnsafeRow, GpuTypeShims, ShimUnaryExecNode}
2423

2524
import org.apache.spark.TaskContext
@@ -578,7 +577,7 @@ class RowToColumnarIterator(
578577
localGoal: CoalesceSizeGoal,
579578
batchSizeBytes: Long,
580579
converters: GpuRowToColumnConverter,
581-
enableRetry: Boolean = false,
580+
enableRetry: Boolean = true,
582581
numInputRows: GpuMetric = NoopMetric,
583582
numOutputRows: GpuMetric = NoopMetric,
584583
numOutputBatches: GpuMetric = NoopMetric,
@@ -592,11 +591,18 @@ class RowToColumnarIterator(
592591
private var totalOutputRows: Long = 0
593592
private lazy val rowCopyProjection: UnsafeProjection = UnsafeProjection.create(localSchema)
594593

594+
// Carries a failed row across batch boundaries when OOM interrupted conversion.
595+
private var pendingRow: InternalRow = _
596+
595597
override def hasNext: Boolean = {
596-
val start = System.nanoTime()
597-
val result = rowIter.hasNext
598-
streamTime += System.nanoTime() - start
599-
result
598+
if (pendingRow != null) {
599+
true
600+
} else {
601+
val start = System.nanoTime()
602+
val result = rowIter.hasNext
603+
streamTime += System.nanoTime() - start
604+
result
605+
}
600606
}
601607

602608
override def next(): ColumnarBatch = {
@@ -606,56 +612,110 @@ class RowToColumnarIterator(
606612
buildBatch()
607613
}
608614

609-
private def buildBatch(): ColumnarBatch = {
610-
NvtxRegistry.ROW_TO_COLUMNAR {
611-
val streamStart = System.nanoTime()
612-
// estimate the size of the first batch based on the schema
613-
if (targetRows == 0) {
614-
if (localSchema.fields.isEmpty) {
615-
// if there are no columns then we just default to a small number
616-
// of rows for the first batch
617-
targetRows = 1024
618-
initialRows = targetRows
619-
} else {
620-
val sampleRows = GpuBatchUtils.VALIDITY_BUFFER_BOUNDARY_ROWS
621-
val sampleBytes = GpuBatchUtils.estimateGpuMemory(localSchema, sampleRows)
622-
targetRows = GpuBatchUtils.estimateRowCount(targetSizeBytes, sampleBytes, sampleRows)
623-
initialRows = GpuBatchUtils.estimateRowCount(batchSizeBytes, sampleBytes, sampleRows)
624-
}
625-
}
615+
private def copyRow(row: InternalRow): InternalRow = row match {
616+
case unsafe: UnsafeRow => unsafe.copy()
617+
case other => rowCopyProjection.apply(other).copy()
618+
}
626619

627-
withResource(new GpuColumnarBatchBuilder(localSchema, initialRows)) { builders =>
628-
var rowCount = 0
629-
// Double because validity can be < 1 byte, and this is just an estimate anyways
630-
var byteCount: Double = 0
631-
632-
if (enableRetry) {
633-
val converter = new RetryableRowConverter(builders, rowCopyProjection)
634-
// read at least one row
635-
while (rowIter.hasNext &&
636-
(rowCount == 0 || rowCount < targetRows && byteCount < targetSizeBytes)) {
637-
converter.attempt(rowIter.next())
638-
val bytesWritten = withRetryNoSplit {
639-
withRestoreOnRetry(converter) {
640-
converters.convert(converter.currentRow, builders)
641-
}
642-
}
643-
byteCount += bytesWritten
644-
rowCount += 1
645-
}
646-
} else {
647-
// Disabling R2C retry only removes the per-row retry wrapper around convert().
648-
// The final builders.tryBuild(rowCount) call below still uses withRetryNoSplit.
649-
while (rowIter.hasNext &&
650-
(rowCount == 0 || rowCount < targetRows && byteCount < targetSizeBytes)) {
651-
val row = rowIter.next()
620+
/** Consume the pending row if available, otherwise advance the iterator. */
621+
private def nextRow(): InternalRow = {
622+
if (pendingRow != null) {
623+
val r = pendingRow
624+
pendingRow = null
625+
r
626+
} else {
627+
rowIter.next()
628+
}
629+
}
630+
631+
/**
632+
* Core conversion loop. When retry is enabled, runs inside a single RMM retry block
633+
* so host allocations throw retryable OOMs instead of fatal OutOfMemoryError.
634+
*
635+
* OOM strategy (retry enabled):
636+
* - Has rows to emit (and not RequireSingleBatch) → emit partial batch, save failed row
637+
* - No rows yet + RetryOOM → block until memory freed, then while loop retries
638+
* - No rows yet + SplitAndRetryOOM → propagate (can't split a single row)
639+
*/
640+
private def convertRows(builders: GpuColumnarBatchBuilder): (Int, Double) = {
641+
var rowCount = 0
642+
var byteCount: Double = 0
643+
644+
if (enableRetry) {
645+
var batchDone = false
646+
RmmRapidsRetryIterator.withRetryBlock {
647+
while (!batchDone && hasNext &&
648+
(rowCount == 0 || rowCount < targetRows && byteCount < targetSizeBytes)) {
649+
val snapshots = builders.captureState()
650+
var row: InternalRow = null
651+
try {
652+
row = nextRow()
652653
byteCount += converters.convert(row, builders)
653654
rowCount += 1
655+
} catch {
656+
case oom @ (_: CpuRetryOOM | _: CpuSplitAndRetryOOM |
657+
_: GpuRetryOOM | _: GpuSplitAndRetryOOM) =>
658+
builders.restoreState(snapshots)
659+
if (rowCount > 0 && !localGoal.isInstanceOf[RequireSingleBatchLike]) {
660+
// Emit partial batch. This also handles SplitAndRetryOOM: emitting
661+
// a smaller batch IS the right response to memory pressure, and
662+
// tryBuild() has its own withRetryNoSplit to handle GPU OOM.
663+
if (row != null) {
664+
pendingRow = copyRow(row)
665+
}
666+
batchDone = true
667+
} else {
668+
// No rows to emit — must retry or fail.
669+
oom match {
670+
case _: CpuSplitAndRetryOOM | _: GpuSplitAndRetryOOM => throw oom
671+
case _ =>
672+
if (row != null) {
673+
pendingRow = copyRow(row)
674+
}
675+
RmmRapidsRetryIterator.blockUntilMemoryFreed()
676+
}
677+
}
654678
}
655679
}
680+
}
681+
} else {
682+
while (hasNext && (rowCount == 0 || rowCount < targetRows && byteCount < targetSizeBytes)) {
683+
val row = nextRow()
684+
byteCount += converters.convert(row, builders)
685+
rowCount += 1
686+
}
687+
}
688+
(rowCount, byteCount)
689+
}
690+
691+
private def estimateInitialTargetRows(): Unit = {
692+
// estimate the size of the first batch based on the schema
693+
if (targetRows == 0) {
694+
if (localSchema.fields.isEmpty) {
695+
// if there are no columns then we just default to a small number
696+
// of rows for the first batch
697+
targetRows = 1024
698+
initialRows = targetRows
699+
} else {
700+
val sampleRows = GpuBatchUtils.VALIDITY_BUFFER_BOUNDARY_ROWS
701+
val sampleBytes = GpuBatchUtils.estimateGpuMemory(localSchema, sampleRows)
702+
targetRows = GpuBatchUtils.estimateRowCount(targetSizeBytes, sampleBytes, sampleRows)
703+
initialRows = GpuBatchUtils.estimateRowCount(batchSizeBytes, sampleBytes, sampleRows)
704+
}
705+
}
706+
}
707+
708+
private def buildBatch(): ColumnarBatch = {
709+
NvtxRegistry.ROW_TO_COLUMNAR {
710+
val streamStart = System.nanoTime()
711+
estimateInitialTargetRows()
712+
713+
withResource(new GpuColumnarBatchBuilder(localSchema, initialRows)) { builders =>
714+
val (rowCount, _) = convertRows(builders)
656715

657716
// enforce RequireSingleBatch limit
658-
if (rowIter.hasNext && localGoal.isInstanceOf[RequireSingleBatchLike]) {
717+
if ((pendingRow != null || rowIter.hasNext) &&
718+
localGoal.isInstanceOf[RequireSingleBatchLike]) {
659719
throw new IllegalStateException("A single batch is required for this operation." +
660720
" Please try increasing your partition count.")
661721
}
@@ -693,84 +753,6 @@ class RowToColumnarIterator(
693753

694754
}
695755

696-
/**
697-
* A retryable row converter that integrates with the retry framework via `withRestoreOnRetry`.
698-
*
699-
* The design defers expensive operations to when OOM actually occurs:
700-
* - Builder state is captured lazily on first `currentRow` access (before convert runs)
701-
* - Row copying is deferred to `restore()` - only happens if OOM occurs
702-
* - `checkpoint()` is a no-op since we capture state lazily
703-
*
704-
* This class is designed to be reused across multiple rows to minimize object allocation
705-
* overhead. Call `attempt()` with a new row before each conversion attempt.
706-
*
707-
* This minimizes overhead in the common case (no OOM) while still enabling proper
708-
* rollback when OOM does occur.
709-
*/
710-
private class RetryableRowConverter(
711-
builders: GpuColumnarBatchBuilder,
712-
projection: UnsafeProjection)
713-
extends Retryable {
714-
715-
// The current row being converted - set via attempt()
716-
private var initialRow: InternalRow = _
717-
718-
// Builder state - captured once on first call to ensureSnapshotCaptured()
719-
private var builderSnapshots: Array[RapidsHostColumnBuilder.BuilderSnapshot] = _
720-
private var snapshotCaptured: Boolean = false
721-
722-
// Row snapshot - only created when restore() is called (i.e., OOM happened)
723-
private var rowSnapshot: Option[UnsafeRow] = None
724-
725-
/**
726-
* Attempt the conversion for a new row. This must be called before each row conversion.
727-
*/
728-
def attempt(row: InternalRow): Unit = {
729-
initialRow = row
730-
snapshotCaptured = false
731-
rowSnapshot = None
732-
}
733-
734-
/**
735-
* Ensures builder state is captured exactly once, before conversion starts.
736-
* This must be called before convert() to enable rollback on OOM.
737-
*/
738-
private def ensureSnapshotCaptured(): Unit = {
739-
if (!snapshotCaptured) {
740-
builderSnapshots = builders.captureState()
741-
snapshotCaptured = true
742-
}
743-
}
744-
745-
/**
746-
* The row to convert. Captures builder state on first access.
747-
* After restore(), returns the copied row for retry.
748-
*/
749-
def currentRow: InternalRow = {
750-
ensureSnapshotCaptured()
751-
rowSnapshot.getOrElse(initialRow)
752-
}
753-
754-
override def checkpoint(): Unit = ()
755-
756-
/**
757-
* Called by withRestoreOnRetry when an OOM occurs.
758-
* Copies the row (for retry) and rolls back the builders to pre-conversion state.
759-
*/
760-
override def restore(): Unit = {
761-
// Snapshot the row for the retry attempt (only on first restore)
762-
if (rowSnapshot.isEmpty) {
763-
rowSnapshot = Some(initialRow match {
764-
case unsafe: UnsafeRow => unsafe.copy()
765-
case other => projection.apply(other).copy()
766-
})
767-
}
768-
// Roll back builders to state before this row's conversion started
769-
builders.restoreState(builderSnapshots)
770-
}
771-
772-
}
773-
774756
object GeneratedInternalRowToCudfRowIterator extends Logging {
775757
def apply(input: Iterator[InternalRow],
776758
schema: Array[Attribute],

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -439,13 +439,12 @@ object RapidsConf extends Logging {
439439
.createWithDefault(2)
440440

441441
val ENABLE_R2C_RETRY = conf("spark.rapids.sql.rowToColumnar.retry.enabled")
442-
.doc("When true, the row-to-columnar conversion wraps each row's conversion with " +
443-
"retry logic so that host OOM during conversion can be recovered. This adds a small " +
444-
"per-row overhead. When false (default), the retry is disabled to avoid that overhead, " +
445-
"at the risk of failing the task on host OOM during R2C conversion.")
442+
.doc("When true (default), the row-to-columnar conversion uses a per-batch retry block " +
443+
"so that host OOM during conversion can be recovered with negligible overhead. " +
444+
"Set to false to disable retry and let host OOM fail the task immediately.")
446445
.internal()
447446
.booleanConf
448-
.createWithDefault(false)
447+
.createWithDefault(true)
449448

450449
val GPU_COREDUMP_DIR = conf("spark.rapids.gpu.coreDump.dir")
451450
.doc("The URI to a directory where a GPU core dump will be created if the GPU encounters " +

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,44 @@ object RmmRapidsRetryIterator extends Logging {
188188
new RmmRapidsRetryAutoCloseableIterator(attemptIter))
189189
}
190190

191+
/**
192+
* Execute `fn` inside a retry block where host/GPU allocations throw retryable
193+
* OOM exceptions instead of fatal errors. Unlike `withRetryNoSplit`, this does
194+
* NOT automatically retry — the caller manages its own retry logic within `fn`.
195+
*
196+
* Use this for incremental operations (e.g. row-by-row accumulation) where
197+
* partial progress is valuable and the standard atomic-retry model doesn't fit.
198+
*
199+
* @param fn the work to perform inside the retry block
200+
* @tparam T result type
201+
* @return the result of `fn`
202+
*/
203+
def withRetryBlock[T](fn: => T): T = {
204+
RmmSpark.currentThreadStartRetryBlock()
205+
try {
206+
fn
207+
} finally {
208+
RmmSpark.currentThreadEndRetryBlock()
209+
}
210+
}
211+
212+
/**
213+
* Block the current thread until memory is freed, following the standard
214+
* protocol of exiting and re-entering the retry block around the blocking call.
215+
*
216+
* Must be called from within an active retry block (i.e. inside `withRetryBlock`).
217+
* After this call returns, the retry block is re-entered and the caller can retry
218+
* the failed operation.
219+
*/
220+
def blockUntilMemoryFreed(): Unit = {
221+
RmmSpark.currentThreadEndRetryBlock()
222+
try {
223+
RmmSpark.blockThreadUntilReady()
224+
} finally {
225+
RmmSpark.currentThreadStartRetryBlock()
226+
}
227+
}
228+
191229
/**
192230
* Returns a tuple of (shouldRetry, shouldSplit, isFromGpuOom) depending the exception
193231
* passed

0 commit comments

Comments
 (0)