peter-toth commented on code in PR #58895:
URL: https://github.com/apache/spark/pull/58895#discussion_r4084280609
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/VectorizedParquetRecordReader.java:
##########
@@ -421,11 +552,313 @@ public boolean nextBatch() throws IOException {
return true;
}
+ /**
+ * Splicing emit path. Closes the previous emit's dequeued key vectors
(releasing survivor memory
+ * incrementally), advances to the next row group if needed via {@link
#checkEndOfRowGroup()},
+ * dequeues one survivor key vector per key column, drives non-key column
readers for {@code num}
+ * rows, and assembles a fresh {@link ColumnarBatch} interleaving key
(dequeued) and non-key
+ * (persistent value-vector) slots in the original projection order.
+ * The per-emit batch is a transient view over vectors owned elsewhere; see
{@link #close()} and
+ * {@link #closeSplicingState()}.
+ */
+ private boolean nextBatchSplicing() throws IOException {
+ if (pendingCloseKeyVectors != null) {
+ for (WritableColumnVector v : pendingCloseKeyVectors) {
+ if (v != null) v.close();
+ }
+ pendingCloseKeyVectors = null;
+ }
+ for (int i = 0; i < columnVectors.length; i++) {
+ if (isKeyTopLevel[i]) continue;
+ columnVectors[i].reset();
+ }
+ // Match the eager path and zero the outgoing batch before the terminal
checks below. Without
+ // this, a terminal call leaves `columnarBatch` pointing at the previous
emit whose key slots
+ // were just closed above -- with off-heap vectors those buffers are
already freed, so a
+ // consumer that read the batch after nextBatch() returned false would see
freed memory.
+ if (columnarBatch != null) columnarBatch.setNumRows(0);
+ if (hitEndOfData) return false;
+ if (rowsReturned >= totalRowCount) return false;
+ checkEndOfRowGroup();
+ if (hitEndOfData) return false;
+
+ int num = (int) Math.min(capacity, totalCountLoadedSoFar - rowsReturned);
+
+ WritableColumnVector[] dequeued = new
WritableColumnVector[keyVectorQueues.length];
+ for (int i = 0; i < keyVectorQueues.length; i++) {
+ dequeued[i] = keyVectorQueues[i].removeFirst();
Review Comment:
Fixed, and the shape changed rather than the assignment moving: a survivor
vector now stays owned by its queue while the batch is built on it, and the
next emit removes and closes it. So there is no window where a vector is out of
the queues and not yet reachable from a field, and `pendingCloseKeyVectors` is
gone.
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/VectorizedParquetRecordReader.java:
##########
@@ -492,6 +929,450 @@ private void checkEndOfRowGroup() throws IOException {
totalCountLoadedSoFar += pages.getRowCount();
}
+ /**
+ * Loads the next row group using the three-phase late-materialization
pattern, all driven by the
+ * single {@link #lateMatReader} with its requested schema mutated per phase:
+ * - Phase 0 (full schema): compute {@code pushedFilterRanges} from the
pushed data filter via
+ * column index (metadata-only) using {@link
ParquetFileReader#getRowRanges}.
+ * - Phase 1 (key-only schema): read key-column pages restricted to {@code
pushedFilterRanges},
+ * evaluate the storage filter per row, build {@code finalRanges}.
+ * - Phase 2 (non-key schema): read non-key columns restricted to {@code
finalRanges}.
+ * Skipped entirely when {@link #nonKeyRequestedSchema} is null
(all-keys projection).
+ *
+ * Row groups for which {@code finalRanges} is empty are skipped entirely
(no phase-2 IO).
+ * Sets {@link #hitEndOfData} when all row groups have been processed.
+ */
+ private void loadNextRowGroupWithLateMaterialization() throws IOException {
+ while (nextBlockIndex < totalBlockCount) {
+ int blockIdx = nextBlockIndex++;
+ long blockRowCount =
lateMatReader.getRowGroups().get(blockIdx).getRowCount();
+ if (blockRowCount == 0) {
+ // parquet-mr never writes these, but RowRanges.createSingle(0) would
build Range(0, -1) and
+ // trip parquet's own `from <= to` assertion. The plain read path
skips them too.
+ continue;
+ }
+
+ // Phase 0: rows allowed by the pushed data filter (column-index
granularity). Restore the
+ // full requestedSchema first: phases 1 and 2 below narrow the reader's
schema, and
+ // ParquetFileReader.getRowRanges computes ranges against whatever
schema is set (it passes
+ // the reader's current `paths` to ColumnIndexFilter). This is defensive
-- with column-index
+ // filtering on, getFilteredRecordCount() in initialize() has already
memoized every block's
+ // ranges under the full schema, and with it off we do not call
getRowRanges at all.
+ lateMatReader.setRequestedSchema(requestedSchema);
+ // ParquetFileReader.getRowRanges only checks whether a filter is
pushed, NOT
+ // options.useColumnIndexFilter(), so calling it unconditionally would
keep applying
+ // column-index filtering after a user turned it off. That conf is the
documented escape hatch
+ // for files whose column index is wrong, and trusting a wrong column
index here would drop
+ // rows for good: finalRanges is a subset of pushedFilterRanges, and the
post-scan Filter no
+ // longer holds this predicate.
+ RowRanges pushedFilterRanges = useColumnIndexFilter
+ ? lateMatReader.getRowRanges(blockIdx)
+ : RowRanges.createSingle(blockRowCount);
+ if (pushedFilterRanges.rowCount() == 0) {
+ // Pushed data filter rejects this block entirely via column index.
Not a storage-filter
+ // skip, so we don't increment storage-filter metrics.
+ continue;
+ }
+
+ // Both byte metrics are always wired in production (FileSourceScanLike
creates all five
+ // whenever storageFilters is non-empty), so this only skips the work on
the test-only path
+ // that drives the reader directly. compressedBytesForRowRanges never
does IO of its own, so
+ // there is nothing here to avoid on the production path.
+ StorageFilterMetrics m = storageFilter.metrics();
+ SQLMetric bytesAvoidedRg = m.bytesAvoidedByRowGroup();
+ SQLMetric bytesAvoidedPf = m.bytesAvoidedByPageFiltering();
+ boolean needBytes = bytesAvoidedRg != null || bytesAvoidedPf != null;
+ long baselineRows = pushedFilterRanges.rowCount();
+ long baselineBytes = needBytes
+ ? compressedBytesForRowRanges(lateMatReader, blockIdx,
requestedSchema,
+ pushedFilterRanges)
+ : 0L;
+
+ // Phase 1: switch to key-only schema, read key columns under
pushedFilterRanges, evaluate the
+ // storage filter per row.
+ lateMatReader.setRequestedSchema(keyOnlyRequestedSchema);
+ long phase1Bytes = needBytes
+ ? compressedBytesForRowRanges(lateMatReader, blockIdx,
keyOnlyRequestedSchema,
+ pushedFilterRanges)
+ : 0L;
+ PageReadStore keyPages = lateMatReader.readFilteredRowGroup(blockIdx,
pushedFilterRanges);
+ if (keyPages == null) {
+ // Unreachable: readFilteredRowGroup returns null only for an empty
block, and we already
+ // know pushedFilterRanges selects at least one row. Skipping the
block here would drop its
+ // surviving rows from the output, so assert rather than `continue`.
+ throw new IllegalStateException(
+ "No key pages for row group " + blockIdx + " despite "
+ + pushedFilterRanges.rowCount() + " rows selected by the
pushed filter");
+ }
+ RowRanges finalRanges = evaluateStorageFilter(keyPages,
pushedFilterRanges);
+
+ if (finalRanges.rowCount() == 0) {
+ // Every surviving row was rejected by the storage filter; skip block
entirely. We still
+ // paid phase-1 to read the key column, so the bytes avoided vs a
no-storage-filter read
+ // are baseline - phase1 (the non-key bytes the no-filter path would
have read).
+ SQLMetric rgSkipped = m.rowGroupsSkipped();
+ if (rgSkipped != null) rgSkipped.add(1L);
+ SQLMetric rowsExcludedRg = m.rowsExcludedByRowGroup();
+ if (rowsExcludedRg != null) rowsExcludedRg.add(baselineRows);
+ if (bytesAvoidedRg != null) bytesAvoidedRg.add(baselineBytes -
phase1Bytes);
+ continue;
+ }
+
+ // Phase 2: switch to non-key schema, read non-key columns under
finalRanges. Skipped entirely
+ // when the projection is all keys (nonKeyRequestedSchema is null); emit
reconstructs each
+ // batch from the key queues alone.
+ long keptRows;
+ long phase2Bytes;
+ PageReadStore dataPages = null;
+ if (nonKeyRequestedSchema == null) {
+ keptRows = finalRanges.rowCount();
+ phase2Bytes = 0L;
+ } else {
+ lateMatReader.setRequestedSchema(nonKeyRequestedSchema);
+ // requireOffsetIndexesForPhase2() already established that every
projected column of every
+ // row group has an offset index, so this page-filtering read cannot
fail for want of one.
+ dataPages = lateMatReader.readFilteredRowGroup(blockIdx, finalRanges);
+ if (dataPages == null) {
+ // Unreachable: readFilteredRowGroup returns null only for an empty
block or empty ranges,
+ // both excluded above. Match phase 1 and fail with a message rather
than an NPE.
+ throw new IllegalStateException(
+ "No data pages for row group " + blockIdx + " despite " +
finalRanges.rowCount()
+ + " surviving rows");
+ }
+ keptRows = dataPages.getRowCount();
+ phase2Bytes = bytesAvoidedPf != null
+ ? compressedBytesForRowRanges(lateMatReader, blockIdx,
nonKeyRequestedSchema,
+ finalRanges)
+ : 0L;
+ }
+ long filteredRows = baselineRows - keptRows;
+ SQLMetric rowsExcludedWithinRg = m.rowsExcludedWithinRowGroup();
+ if (rowsExcludedWithinRg != null && filteredRows > 0)
rowsExcludedWithinRg.add(filteredRows);
+ if (bytesAvoidedPf != null) {
+ bytesAvoidedPf.add(baselineBytes - phase1Bytes - phase2Bytes);
+ }
+
+ if (dataPages != null) {
+ if (rowIndexGenerator != null) {
+ rowIndexGenerator.initFromPageReadStore(dataPages);
+ }
+ for (int i = 0; i < columnVectors.length; i++) {
+ if (isKeyTopLevel[i]) {
+ // Key columns are sourced from the queues during emit; skip
phase-2 reader init.
+ continue;
+ }
+ initColumnReader(dataPages, columnVectors[i]);
+ }
+ }
+ totalCountLoadedSoFar += keptRows;
+ return;
+ }
+ hitEndOfData = true;
+ }
+
+ /**
+ * Compressed bytes the reader transfers for the leaf columns of {@code
schema} when it reads
+ * exactly {@code rowRanges} of the given block. Page headers and the
dictionary page are
+ * included, because both are read whenever any page of a chunk is read.
+ *
+ * <p>Two sources, chosen so this never causes IO of its own:
+ * <ul>
+ * <li>{@code rowRanges} covers the whole block: the answer is the sum of
the chunks'
+ * {@code getTotalSize()}, which is already in the footer. This is the
case that matters --
+ * whenever nothing else has built the block's {@link
ColumnIndexStore}, {@code rowRanges}
+ * is necessarily the whole block, because a narrower range can only
come from column-index
+ * filtering, which builds the store as a side effect.
+ * <li>{@code rowRanges} is a strict subset: walk the offset index, as
parquet's own read path
+ * does, and add the dictionary page the way {@code
calculateOffsetRanges} does. The store
+ * is guaranteed to exist here, so the walk is pure metadata
arithmetic.
+ * </ul>
+ *
+ * <p>Columns absent from this physical file (schema evolution) contribute
nothing, which is
+ * correct: the reader transfers nothing for them. Every caller for a given
block walks the same
+ * metadata, so a skipped column drops out of the baseline and the per-phase
totals alike.
+ */
+ private static long compressedBytesForRowRanges(
+ ParquetFileReader reader,
+ int blockIndex,
+ MessageType schema,
+ RowRanges rowRanges) {
+ if (schema == null || rowRanges.rowCount() == 0 ||
schema.getColumns().isEmpty()) {
+ return 0L;
+ }
+ BlockMetaData block = reader.getRowGroups().get(blockIndex);
+ long blockRowCount = block.getRowCount();
+ Map<ColumnPath, ColumnChunkMetaData> chunks = new HashMap<>();
+ for (ColumnChunkMetaData chunk : block.getColumns()) {
+ chunks.put(chunk.getPath(), chunk);
+ }
+ boolean wholeBlock = rowRanges.rowCount() == blockRowCount;
+ ColumnIndexStore ciStore = wholeBlock ? null :
reader.getColumnIndexStore(blockIndex);
+ long total = 0L;
+ for (ColumnDescriptor column : schema.getColumns()) {
+ ColumnPath path = ColumnPath.get(column.getPath());
+ ColumnChunkMetaData chunk = chunks.get(path);
+ if (chunk == null) {
+ // Column is in the (clipped) requested schema but not in this file.
+ continue;
+ }
+ if (wholeBlock) {
+ total += chunk.getTotalSize();
+ continue;
+ }
+ OffsetIndex offsetIndex;
+ try {
+ offsetIndex = ciStore.getOffsetIndex(path);
+ } catch (MissingOffsetIndexException e) {
+ continue;
+ }
+ if (offsetIndex == null) {
+ continue;
+ }
+ // The dictionary page is read whenever any data page of the chunk is,
so count it here the
+ // same way parquet's ColumnIndexFilterUtils.calculateOffsetRanges does.
+ total += dictionaryPageSize(chunk);
+ int pageCount = offsetIndex.getPageCount();
+ for (int i = 0; i < pageCount; i++) {
+ long from = offsetIndex.getFirstRowIndex(i);
+ long to = offsetIndex.getLastRowIndex(i, blockRowCount);
+ if (rowRanges.isOverlapping(from, to)) {
+ total += offsetIndex.getCompressedPageSize(i);
+ }
+ }
+ }
+ return total;
+ }
+
+ /**
+ * Compressed size of a chunk's dictionary page, or 0 if it has none.
+ * {@link ColumnChunkMetaData#getStartingPos()} already resolves to the
dictionary page offset
+ * when there is a valid one, so the gap up to the first data page is
exactly the dictionary page.
+ */
+ private static long dictionaryPageSize(ColumnChunkMetaData chunk) {
+ long startingPos = chunk.getStartingPos();
+ long firstDataPageOffset = chunk.getFirstDataPageOffset();
+ return startingPos < firstDataPageOffset ? firstDataPageOffset -
startingPos : 0L;
+ }
+
+ /**
+ * Reads all rows of the given key-only {@link PageReadStore} (which
contains only rows in
+ * {@code pushedFilterRanges}) in capacity-sized chunks, evaluates the
storage filter on each row,
+ * builds a {@link RowRanges} of surviving rows in original block-row
coordinates, and appends
+ * survivor key values into the per-key-column accumulators ({@link
#currentKeyAccumulators}).
+ * When an accumulator hits {@link #capacity}, it's pushed into {@link
#keyVectorQueues} and a
+ * fresh one is allocated. After all rows have been examined, any partial
trailing accumulator is
+ * pushed too.
+ *
+ * <p>The result is a subset of {@code pushedFilterRanges}: rows not in
{@code
+ * pushedFilterRanges} were never read and are implicitly excluded.
+ */
+ private RowRanges evaluateStorageFilter(
+ PageReadStore keyPages,
+ RowRanges pushedFilterRanges) throws IOException {
+ ensureKeyScratchAllocated();
+ VectorizedColumnReader[] readers = new
VectorizedColumnReader[keyDescriptors.length];
+ for (int i = 0; i < readers.length; i++) {
+ readers[i] = new VectorizedColumnReader(
+ keyDescriptors[i], keyRequired[i], keyPages, convertTz,
datetimeRebaseMode,
+ datetimeRebaseTz, int96RebaseMode, int96RebaseTz, writerVersion);
+ }
+ ensureCurrentKeyAccumulatorsAllocated();
+
+ long keyRowsTotal = pushedFilterRanges.rowCount();
+ PrimitiveIterator.OfLong rowIndexIter = pushedFilterRanges.iterator();
+ RowRanges.Builder finalRangesBuilder = RowRanges.builder();
+ long remaining = keyRowsTotal;
+ while (remaining > 0) {
+ int num = (int) Math.min((long) capacity, remaining);
+ for (int i = 0; i < keyScratchVectors.length; i++) {
+ keyScratchVectors[i].reset();
+ readers[i].readBatch(num, keyScratchVectors[i], null, null);
+ }
+ keyScratchBatch.setNumRows(num);
+ for (int r = 0; r < num; r++) {
+ long blockRow = rowIndexIter.nextLong();
+ if (storageFilter.test(keyScratchBatch.getRow(r))) {
+ finalRangesBuilder.addSelectedRow(blockRow);
+ appendSurvivorRowToAccumulators(r);
+ }
+ }
+ remaining -= num;
+ }
+
+ finalizePartialAccumulators();
+
+ return finalRangesBuilder.build();
+ }
+
+ private void ensureKeyScratchAllocated() {
Review Comment:
Fixed: the array is assigned before the loop and `close()` frees the scratch
vectors by walking it element-wise, so `keyScratchBatch` is no longer what
reaches them.
The guard stays, and it cannot be reached in the state you describe: an
allocation failure here propagates out of the reader, the task fails and
`close()` runs, so nothing calls this again on a half-filled array.
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/VectorizedParquetRecordReader.java:
##########
@@ -421,11 +552,313 @@ public boolean nextBatch() throws IOException {
return true;
}
+ /**
+ * Splicing emit path. Closes the previous emit's dequeued key vectors
(releasing survivor memory
+ * incrementally), advances to the next row group if needed via {@link
#checkEndOfRowGroup()},
+ * dequeues one survivor key vector per key column, drives non-key column
readers for {@code num}
+ * rows, and assembles a fresh {@link ColumnarBatch} interleaving key
(dequeued) and non-key
+ * (persistent value-vector) slots in the original projection order.
+ * The per-emit batch is a transient view over vectors owned elsewhere; see
{@link #close()} and
+ * {@link #closeSplicingState()}.
+ */
+ private boolean nextBatchSplicing() throws IOException {
+ if (pendingCloseKeyVectors != null) {
+ for (WritableColumnVector v : pendingCloseKeyVectors) {
+ if (v != null) v.close();
+ }
+ pendingCloseKeyVectors = null;
+ }
+ for (int i = 0; i < columnVectors.length; i++) {
+ if (isKeyTopLevel[i]) continue;
+ columnVectors[i].reset();
+ }
+ // Match the eager path and zero the outgoing batch before the terminal
checks below. Without
+ // this, a terminal call leaves `columnarBatch` pointing at the previous
emit whose key slots
+ // were just closed above -- with off-heap vectors those buffers are
already freed, so a
+ // consumer that read the batch after nextBatch() returned false would see
freed memory.
+ if (columnarBatch != null) columnarBatch.setNumRows(0);
+ if (hitEndOfData) return false;
+ if (rowsReturned >= totalRowCount) return false;
+ checkEndOfRowGroup();
+ if (hitEndOfData) return false;
+
+ int num = (int) Math.min(capacity, totalCountLoadedSoFar - rowsReturned);
+
+ WritableColumnVector[] dequeued = new
WritableColumnVector[keyVectorQueues.length];
+ for (int i = 0; i < keyVectorQueues.length; i++) {
+ dequeued[i] = keyVectorQueues[i].removeFirst();
+ }
+
+ for (int i = 0; i < columnVectors.length; i++) {
+ if (isKeyTopLevel[i]) continue;
+ ParquetColumnVector cv = columnVectors[i];
+ for (ParquetColumnVector leafCv : cv.getLeaves()) {
+ VectorizedColumnReader columnReader = leafCv.getColumnReader();
+ if (columnReader != null) {
+ columnReader.readBatch(num, leafCv.getValueVector(),
+ leafCv.getRepetitionLevelVector(),
leafCv.getDefinitionLevelVector());
+ }
+ }
+ cv.assemble();
+ }
+ if (rowIndexGenerator != null) {
+ // Row-index column is identified by name
(ROW_INDEX_TEMPORARY_COLUMN_NAME), which is a
+ // synthetic metadata column never referenced by a storage filter, so
its slot is a non-key
+ // slot with a persistent ParquetColumnVector.
+ rowIndexGenerator.populateRowIndex(columnVectors, num);
+ }
+
+ ColumnVector[] cols = new ColumnVector[persistentBatchColumns.length];
+ // This walks batch slots in ascending order while `keyIdx` walks the
survivor queues in
+ // key-row-position order, so it pairs the k-th smallest key slot with
key-row position k.
+ // That is only the identity because `ParquetStorageFilter.create` sorts
`keyColumnIndices`
+ // ascending -- see the comment there. `isKeyTopLevel` marks which slots
are keys but not their
+ // position in that list, so this loop cannot reconstruct the pairing on
its own: if the list
+ // ever stops being sorted, key columns silently swap places in the output
batch.
+ int keyIdx = 0;
+ for (int i = 0; i < persistentBatchColumns.length; i++) {
+ if (i < isKeyTopLevel.length && isKeyTopLevel[i]) {
+ cols[i] = dequeued[keyIdx++];
+ } else {
+ cols[i] = persistentBatchColumns[i];
+ }
+ }
+ columnarBatch = new ColumnarBatch(cols);
Review Comment:
Took the second option, since documenting an exception to a public method's
contract is the worse half of the trade. There is one `ColumnarBatch` for the
whole read again, and the emit path rewrites its key slots in place --
`ColumnarBatch` holds the column array by reference, its staging row included,
so writing a slot publishes it. Two allocations per batch go away with it, and
the hoist-once pattern works on this path.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileFormat.scala:
##########
@@ -165,6 +166,47 @@ trait FileFormat {
}
}
+ /**
+ * Like [[buildReaderWithPartitionValues]] but additionally accepts a
sequence of storage filters:
+ * Catalyst expressions that the storage layer may evaluate to drive
value-column IO pruning based
+ * on key-column evaluation (e.g., late materialization with a runtime bloom
filter).
+ *
+ * The default implementation delegates to
[[buildReaderWithPartitionValues]] and accepts no
+ * storage filter at all. File formats that support storage-filter pushdown
(e.g., Parquet) should
+ * override this method.
+ *
+ * A non-empty `storageFilters` here is a planner bug, so the default body
rejects it rather than
+ * dropping it. The planner removes an extracted conjunct from the post-scan
`Filter`, so a reader
+ * that ignores it returns rows the filter rejects. Every other layer of the
feature fails loudly
+ * for the same reason. Unreachable today, because
`FileSourceStrategy.extractStorageFilters` only
+ * extracts for `ParquetFileFormat` itself, but that keeps the invariant one
method away from the
+ * code that relies on it.
+ *
+ * Scalar subqueries inside `storageFilters` are expected to have been
materialized before this
+ * method is called, so that the returned reader can be safely serialized to
executors.
+ *
+ * `storageFilterMetrics` is an optional map of SQL metrics the reader can
update during execution
+ * (e.g. number of row groups skipped). The scan is expected to expose these
metrics via its
+ * `metrics` field so they show up in the SQL UI.
+ */
+ def buildReaderWithStorageFilters(
+ sparkSession: SparkSession,
+ dataSchema: StructType,
+ partitionSchema: StructType,
+ requiredSchema: StructType,
+ filters: Seq[Filter],
+ storageFilters: Seq[Expression],
+ options: Map[String, String],
+ hadoopConf: Configuration,
+ storageFilterMetrics: Map[String, SQLMetric] = Map.empty
+ ): PartitionedFile => Iterator[InternalRow] = {
+ require(storageFilters.isEmpty,
+ s"${getClass.getSimpleName} does not support storage-filter pushdown,
but was given " +
+ storageFilters.mkString("[", ", ", "]"))
+ buildReaderWithPartitionValues(
Review Comment:
Closed by routing both public builders through a private
`buildParquetReader`, and the scaladoc on `buildReaderWithStorageFilters` now
says an override must do the same, `super` included, since that call is virtual
too.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]