1616
1717package com .nvidia .spark .rapids
1818
19- import com .nvidia .spark .Retryable
2019import com .nvidia .spark .rapids .Arm .withResource
2120import 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 }
2322import com .nvidia .spark .rapids .shims .{CudfUnsafeRow , GpuTypeShims , ShimUnaryExecNode }
2423
2524import 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-
774756object GeneratedInternalRowToCudfRowIterator extends Logging {
775757 def apply (input : Iterator [InternalRow ],
776758 schema : Array [Attribute ],
0 commit comments