Skip to content

Commit c69e922

Browse files
Enable optimized S3 tail reads for Iceberg Parquet footers (#15384)
### Description Fixes #15363 Parquet footer reads through Iceberg currently open and seek an input stream instead of using `RapidsInputFile.readTail`. This bypasses the optimized Iceberg S3 suffix-range path and adds an avoidable length/seek round trip. This change: - Reads the Parquet trailer with `RapidsInputFile.readTail` and the footer body with `readVectored`. - Makes `IcebergS3InputFile` an `IcebergInputFile` subclass backed by the original `org.apache.iceberg.io.InputFile`, overriding only the S3-specific vectored and tail reads. - Routes decrypted Iceberg input files through `IcebergFileIO`, preserving Iceberg decryption while enabling optimized S3 reads. - Emits a debug message after a suffix-range tail read completes. ### Testing - Spark 4.0.2 compilation passed for the Iceberg 1.10 and 1.11 modules: `mvn -Dbuildver=402 -DskipTests -pl iceberg/iceberg-1-10-x,iceberg/iceberg-1-11-x -am compile` - Existing Parquet and Iceberg scan coverage includes `ParquetScanSuite`, `ParquetFormatScanSuite`, and `integration_tests/src/main/python/iceberg/iceberg_test.py`. - An EMR GPU table scan completed successfully, and executor logs confirmed that Iceberg `readTail` used an S3 suffix-range GET. ### Checklists Documentation - [ ] Updated for new or modified user-facing features or behaviors - [x] No user-facing change Testing - [ ] Added or modified tests to cover new code paths - [x] Covered by existing tests - [ ] Not required Performance - [ ] Tests ran and results are added in the PR description - [ ] Issue filed with a link in the PR description - [x] Not required --------- Signed-off-by: Ray Liu <liurenjie2008@gmail.com>
1 parent 02754a5 commit c69e922

5 files changed

Lines changed: 29 additions & 76 deletions

File tree

iceberg/common/src/main/java/com/nvidia/spark/rapids/fileio/iceberg/IcebergFileIO.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,17 @@ public IcebergFileIO(FileIO delegate) {
4949

5050

5151
@Override
52-
public RapidsInputFile newInputFile(String path) throws IOException {
53-
InputFile inputFile = delegate.newInputFile(path);
52+
public IcebergInputFile newInputFile(String path) throws IOException {
53+
return newInputFile(delegate.newInputFile(path));
54+
}
55+
56+
/**
57+
* Wraps an existing Iceberg input file, preserving any decryption performed by Iceberg.
58+
*
59+
* @param inputFile the Iceberg input file to wrap
60+
*/
61+
public IcebergInputFile newInputFile(InputFile inputFile) {
62+
Objects.requireNonNull(inputFile, "inputFile can't be null");
5463
return IcebergS3InputFile.maybeCreate(inputFile, delegate);
5564
}
5665

iceberg/common/src/main/java/org/apache/iceberg/aws/s3/IcebergS3InputFile.java

Lines changed: 11 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
import com.nvidia.spark.rapids.fileio.iceberg.IcebergInputFile;
2424
import com.nvidia.spark.rapids.iceberg.ShimUtils;
2525
import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile;
26-
import com.nvidia.spark.rapids.jni.fileio.SeekableInputStream;
2726
import org.apache.iceberg.io.FileIO;
2827
import org.apache.iceberg.io.InputFile;
2928
import org.apache.spark.TaskContext;
@@ -33,33 +32,31 @@
3332

3433
import java.io.IOException;
3534
import java.util.List;
36-
import java.util.OptionalLong;
3735

3836
/**
3937
* S3-backed {@link RapidsInputFile} that delegates byte-range reads to
4038
* {@link IcebergS3RangeCopier}. The supplied {@link FileIO} is only used for
4139
* its property map and any per-prefix storage-credential overlays.
4240
*/
43-
public final class IcebergS3InputFile implements RapidsInputFile {
41+
public final class IcebergS3InputFile extends IcebergInputFile {
4442
private static final Logger LOG = LoggerFactory.getLogger(IcebergS3InputFile.class);
4543

46-
private final IcebergInputFile delegate;
4744
private final String s3Bucket;
4845
private final String s3Key;
4946
private final IcebergS3Client icebergS3Client;
5047

5148
private IcebergS3InputFile(
52-
IcebergInputFile delegate,
49+
InputFile delegate,
5350
String s3Bucket,
5451
String s3Key,
5552
IcebergS3Client icebergS3Client) {
56-
this.delegate = delegate;
53+
super(delegate);
5754
this.s3Bucket = s3Bucket;
5855
this.s3Key = s3Key;
5956
this.icebergS3Client = icebergS3Client;
6057
}
6158

62-
public static RapidsInputFile maybeCreate(InputFile inputFile, FileIO fileIO) {
59+
public static IcebergInputFile maybeCreate(InputFile inputFile, FileIO fileIO) {
6360
// When the gating conf is off (or the file is not an S3 file), return the
6461
// default IcebergInputFile so the standard Iceberg SeekableInputStream path is used.
6562
IcebergInputFile delegate = new IcebergInputFile(inputFile);
@@ -86,36 +83,7 @@ public static RapidsInputFile maybeCreate(InputFile inputFile, FileIO fileIO) {
8683
return delegate;
8784
}
8885
LOG.debug("IcebergS3RangeCopier path active for {}", inputFile.location());
89-
return new IcebergS3InputFile(delegate, s3Bucket, s3Key, icebergS3Client);
90-
}
91-
92-
@Override
93-
public String path() {
94-
return delegate.path();
95-
}
96-
97-
@Override
98-
public long getLength() throws IOException {
99-
return delegate.getLength();
100-
}
101-
102-
@Override
103-
public OptionalLong getLastModificationTime() throws IOException {
104-
return delegate.getLastModificationTime();
105-
}
106-
107-
@Override
108-
public SeekableInputStream open() throws IOException {
109-
return delegate.open();
110-
}
111-
112-
/**
113-
* Returns the underlying Iceberg {@link InputFile}, matching
114-
* {@link IcebergInputFile#getDelegate()} for use by iceberg-internal
115-
* code paths that need direct access to the iceberg API.
116-
*/
117-
public InputFile getDelegate() {
118-
return delegate.getDelegate();
86+
return new IcebergS3InputFile(inputFile, s3Bucket, s3Key, icebergS3Client);
11987
}
12088

12189
@Override
@@ -140,5 +108,11 @@ public void readTail(long length, HostMemoryBuffer output) throws IOException {
140108
}
141109
IcebergS3RangeCopier.copyTailToHMB(
142110
icebergS3Client, output, s3Bucket, s3Key, length, /*dstOffset*/ 0L);
111+
LOG.debug(
112+
"PerfIO S3 Iceberg readTail suffix-range GET completed: uri=s3://{}/{}, "
113+
+ "range=bytes=-{}",
114+
s3Bucket,
115+
s3Key,
116+
length);
143117
}
144118
}

iceberg/common/src/main/scala/org/apache/iceberg/spark/source/GpuIcebergPartitionReader.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import scala.collection.JavaConverters._
2020

2121
import com.nvidia.spark.rapids.GpuMetric
2222
import com.nvidia.spark.rapids.MapUtil.toMapStrict
23-
import com.nvidia.spark.rapids.fileio.iceberg.{IcebergFileIO, IcebergInputFile}
23+
import com.nvidia.spark.rapids.fileio.iceberg.IcebergFileIO
2424
import com.nvidia.spark.rapids.iceberg.ShimUtils
2525
import com.nvidia.spark.rapids.iceberg.ShimUtils.locationOf
2626
import com.nvidia.spark.rapids.iceberg.data.GpuDeleteFilter
@@ -110,7 +110,7 @@ class GpuIcebergPartitionReader(private val task: GpuSparkInputPartition,
110110
val inputFiles = table.encryption()
111111
.decrypt(encryptedFiles.asJava)
112112
.asScala
113-
.map(f => f.location() -> new IcebergInputFile(f))
113+
.map(f => f.location() -> rapidsFileIO.newInputFile(f))
114114
.toMap
115115

116116
val taskMap = toMapStrict(tasks.map(t => {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,7 @@ protected case class GpuParquetFileFilterHandler(
570570
if (fileIO.isInstanceOf[HadoopFileIO]) {
571571
// We should remove this after https://github.com/NVIDIA/spark-rapids/issues/13306 is
572572
// implemented.
573-
val result = PerfIO.readParquetFooterBuffer(filePath, conf, verifyParquetMagic)
573+
val result = PerfIO.readParquetFooterBuffer(filePath, conf, verifyParquetMagic _)
574574
val scheme = filePath.toUri.getScheme
575575
if (scheme != null && scheme.startsWith("s3")) {
576576
GpuTaskMetrics.get.recordPerfioS3BackendOnce()

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

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,11 @@ import java.nio.charset.StandardCharsets
2020
import java.util.Arrays
2121

2222
import ai.rapids.cudf.HostMemoryBuffer
23-
import com.nvidia.spark.rapids.{GpuMetric, NoopMetric, NvtxRegistry, RapidsConf}
24-
import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource}
23+
import com.nvidia.spark.rapids.{GpuMetric, NoopMetric, NvtxRegistry, PerfIO, RapidsConf}
24+
import com.nvidia.spark.rapids.Arm.closeOnExcept
2525
import com.nvidia.spark.rapids.filecache.FileCache
2626
import com.nvidia.spark.rapids.jni.fileio.RapidsInputFile
2727
import org.apache.hadoop.fs.Path
28-
import org.apache.hadoop.io.IOUtils
29-
import org.apache.parquet.bytes.BytesUtils.readIntLittleEndian
3028
import org.apache.parquet.hadoop.ParquetFileWriter.MAGIC
3129

3230
/**
@@ -80,36 +78,8 @@ object ParquetFooterUtils {
8078
def readFooterBufferFromInputFile(
8179
inputFile: RapidsInputFile,
8280
filePath: Path): HostMemoryBuffer = {
83-
val fileLen = inputFile.getLength
84-
if (fileLen < MAGIC.length + FooterLengthSize + MAGIC.length) {
85-
throw new RuntimeException(s"$filePath is not a Parquet file (too small length: $fileLen )")
86-
}
87-
val footerLengthIndex = fileLen - FooterLengthSize - MAGIC.length
88-
withResource(inputFile.open()) { inputStream =>
89-
NvtxRegistry.PARQUET_READ_FOOTER_BYTES {
90-
inputStream.seek(footerLengthIndex)
91-
val footerLength = readIntLittleEndian(inputStream)
92-
val magic = new Array[Byte](MAGIC.length)
93-
IOUtils.readFully(inputStream, magic, 0, magic.length)
94-
verifyParquetMagic(filePath, magic)
95-
val fIdx = footerIndex(filePath, fileLen, footerLength, FooterLengthSize)
96-
val tailBytes = Math.toIntExact(fileLen - fIdx)
97-
closeOnExcept(HostMemoryBuffer.allocate(tailBytes + MAGIC.length, false)) { outBuffer =>
98-
outBuffer.setBytes(0, MAGIC, 0, MAGIC.length)
99-
inputStream.seek(fIdx)
100-
val tmp = new Array[Byte](4096)
101-
var written = MAGIC.length.toLong
102-
var bytesLeft = tailBytes
103-
while (bytesLeft > 0) {
104-
val toRead = math.min(bytesLeft, tmp.length)
105-
IOUtils.readFully(inputStream, tmp, 0, toRead)
106-
outBuffer.setBytes(written, tmp, 0, toRead)
107-
written += toRead
108-
bytesLeft -= toRead
109-
}
110-
outBuffer
111-
}
112-
}
81+
NvtxRegistry.PARQUET_READ_FOOTER_BYTES {
82+
PerfIO.readParquetFooterBuffer(inputFile, filePath, verifyParquetMagic)
11383
}
11484
}
11585

0 commit comments

Comments
 (0)