Spark 4.1: Add MergingSortedRowDataReader for k-way merge of sorted files - #14948
Spark 4.1: Add MergingSortedRowDataReader for k-way merge of sorted files#14948anuragmantri wants to merge 18 commits into
Conversation
cc08ff2 to
b4fde94
Compare
|
Moved the changes to Spark 4.1 since it is now the latest version. Marked this PR as WIP as there is a prerequisite PR #14683 that is also in review. |
|
My concern from Spark PoV is that unnecessary partition grouping can cause performance degradations. SPARK-55092 is a ticket about the problem and apache/spark#53859 / apache/spark#54330 PRs try to fix the problem. If this PR disables bin packing then the above PRs won't be able to fix the issue.
So I would suggest keeping bin packing and reporting sort order for those packed partitions (i.e. the partitions might not be unique by key, but they are locally sorted), and when partition grouping is actually needed then Spark should merge the sorted partitions with the same key using k-way merge. |
|
As we discussed offline, a long term (after apache/spark#54330) solution could be to improve the new |
|
This pull request has been marked as stale due to 30 days of inactivity. It will be closed in 1 week if no further activity occurs. If you think that’s incorrect or this pull request requires a review, please simply write any comment. If closed, you can revive the PR at any time and @mention a reviewer or discuss it on the dev@iceberg.apache.org list. Thank you for your contributions. |
|
This PR is not stale. We are waiting waiting for the pre-requisite PR to be merged. I will update this PR after that one is merged. |
b4fde94 to
dbf3ea9
Compare
|
I rebased the PR after #15150. This is ready for review now. @RussellSpitzer @aokolnychyi @szehon-ho - Could you take a look please? |
Thanks @peter-toth , this makes sense. I think this PR is still needed and would still be valuable for tables with decently sized partitions. Since it's gated by a flag, I think it's safe to implement. |
Absolutely. |
dbf3ea9 to
a6f0205
Compare
|
Excited for this! Out of curiosity would this fix/help rewrite_data_files of sorted files/partitions? |
a6f0205 to
51b4150
Compare
@Hugo-WB - This is only a read side optimization for partitioned tables using the SparkScan. Rewrite data files uses a different staged file scan which bypasses this for more control of the shuffling and sorting. I do have another change that uses the same K-way merge logic to do compactions. I will submit a PR for that soon. |
|
amazing! Tysm. Looking forward to these changes! we are compacting a lot of sorted files and keen to eliminate shuffles where possible. |
| continue; | ||
| } | ||
|
|
||
| FileScanTask fileTask = (FileScanTask) task; |
There was a problem hiding this comment.
fileTask can be replaced with pattern variable
| } | ||
|
|
||
| /** Returns whether sort ordering was reported for this batch's scan. */ | ||
| private boolean isOrderingEnabled() { |
There was a problem hiding this comment.
why do we need this private method?
There was a problem hiding this comment.
Inlined it with orderingEnabled boolean.
| * enabled at the table level (validated by {@link #isOrderingEnabled()}, multiple files in the | ||
| * group, and all tasks being {@link FileScanTask}s. | ||
| */ | ||
| private boolean shouldUseMergingSortedReader(ScanTaskGroup<?> taskGroup) { |
There was a problem hiding this comment.
We can cache the result instead of caching this every time.
There was a problem hiding this comment.
You are right. We are anyway disabling bin packing so a task group is 1:1 with an input partition. I refactored this check for all readers anyGroupNeedsMergingReader().
|
|
||
| MergingSortedRowDataReader( | ||
| Table table, | ||
| org.apache.iceberg.io.FileIO io, |
There was a problem hiding this comment.
nit: can we import FileIO? I don't see a existing class conflict
| } | ||
| } | ||
| Preconditions.checkState( | ||
| found, "Projection field id=%s not found in merge read schema — this is a bug", fieldId); |
There was a problem hiding this comment.
can probably drop — this is a bug, I don't think we usually include such in precondition check?
| boolean caseSensitive, | ||
| boolean cacheDeleteFilesOnExecutors) { | ||
| SortOrder sortOrder = table.sortOrder(); | ||
|
|
There was a problem hiding this comment.
nit, remove the extra newline
| * A {@link PartitionReader} that reads multiple sorted files and merges them into a single sorted | ||
| * stream using a k-way heap merge ({@link SortedMerge}). | ||
| * | ||
| * <p>This reader is used when {@code preserve-data-ordering} is enabled and the task group contains |
There was a problem hiding this comment.
I think this refer to 2nd part of the PR in https://github.com/apache/iceberg/pull/16750/changes? Probably can follow up with the actual link to the table property. WDYT?
There was a problem hiding this comment.
Yes, removed the reference.
| List<Types.NestedField> mergeColumns = mergeSchema.columns(); | ||
| List<Object> positions = Lists.newArrayListWithCapacity(projection.columns().size()); | ||
|
|
||
| for (int i = 0; i < projection.columns().size(); i++) { | ||
| int fieldId = projection.columns().get(i).fieldId(); | ||
| boolean found = false; | ||
| for (int j = 0; j < mergeColumns.size(); j++) { | ||
| if (mergeColumns.get(j).fieldId() == fieldId) { | ||
| positions.add(j); | ||
| found = true; | ||
| break; | ||
| } | ||
| } | ||
| Preconditions.checkState( | ||
| found, "Projection field id=%s not found in merge read schema — this is a bug", fieldId); | ||
| } |
There was a problem hiding this comment.
instead of manually find projection with mergedSchema with O(n·m) nested scan, I think we can probably leverage existing utility?
List<Object> positions = Lists.newArrayListWithCapacity(projection.columns().size());
for (Types.NestedField column : projection.columns()) {
Accessor<StructLike> accessor = mergeSchema.accessorForField(column.fieldId());
Preconditions.checkState(
accessor != null,
"Projection field id=%s not found in merge read schema",
column.fieldId());
positions.add(Accessors.toPosition(accessor));
}There was a problem hiding this comment.
Good idea. Done.
| @Override | ||
| public void close() { | ||
| // No-op. The RowDataReaders are owned by the enclosing CloseableGroup | ||
| // (resources) and closed exactly once from close(). SortedMerge cannot be the | ||
| // sole owner because it filters out empty iterators (for example a file whose | ||
| // rows are all removed by deletes), so those readers would never be closed | ||
| // through the merge. Closing here as well would double-close the readers that | ||
| // SortedMerge does drain. | ||
| } |
There was a problem hiding this comment.
do we still need this override?
There was a problem hiding this comment.
Just kept it for the comment. I don't have a preference though.
| * <p>Sort key columns absent from the requested projection are temporarily added to the read schema | ||
| * so that {@link SortOrderComparators} can access them during the merge. The extra columns are | ||
| * stripped from each row before it is returned to Spark. | ||
| */ |
There was a problem hiding this comment.
I feel this is implementation details, do we really need those?
There was a problem hiding this comment.
I clean it up a bit.
| * directions, and null ordering. The two {@link InternalRowWrapper} instances are allocated once | ||
| * and reused — {@code wrap()} just updates an internal reference. |
There was a problem hiding this comment.
I think last sentence is also a bit explaining the details, maybe just mention reuse of InternalRowWrapper for the purpose of comparator.
There was a problem hiding this comment.
I changed to have less details.
7110906 to
cb987a4
Compare
anuragmantri
left a comment
There was a problem hiding this comment.
Thanks @dramaticlly. I addressed your comments and made some improvements in the latest revision.
| @Override | ||
| public void close() { | ||
| // No-op. The RowDataReaders are owned by the enclosing CloseableGroup | ||
| // (resources) and closed exactly once from close(). SortedMerge cannot be the | ||
| // sole owner because it filters out empty iterators (for example a file whose | ||
| // rows are all removed by deletes), so those readers would never be closed | ||
| // through the merge. Closing here as well would double-close the readers that | ||
| // SortedMerge does drain. | ||
| } |
There was a problem hiding this comment.
Just kept it for the comment. I don't have a preference though.
| * directions, and null ordering. The two {@link InternalRowWrapper} instances are allocated once | ||
| * and reused — {@code wrap()} just updates an internal reference. |
There was a problem hiding this comment.
I changed to have less details.
| * <p>Sort key columns absent from the requested projection are temporarily added to the read schema | ||
| * so that {@link SortOrderComparators} can access them during the merge. The extra columns are | ||
| * stripped from each row before it is returned to Spark. | ||
| */ |
There was a problem hiding this comment.
I clean it up a bit.
| List<Types.NestedField> mergeColumns = mergeSchema.columns(); | ||
| List<Object> positions = Lists.newArrayListWithCapacity(projection.columns().size()); | ||
|
|
||
| for (int i = 0; i < projection.columns().size(); i++) { | ||
| int fieldId = projection.columns().get(i).fieldId(); | ||
| boolean found = false; | ||
| for (int j = 0; j < mergeColumns.size(); j++) { | ||
| if (mergeColumns.get(j).fieldId() == fieldId) { | ||
| positions.add(j); | ||
| found = true; | ||
| break; | ||
| } | ||
| } | ||
| Preconditions.checkState( | ||
| found, "Projection field id=%s not found in merge read schema — this is a bug", fieldId); | ||
| } |
There was a problem hiding this comment.
Good idea. Done.
| * A {@link PartitionReader} that reads multiple sorted files and merges them into a single sorted | ||
| * stream using a k-way heap merge ({@link SortedMerge}). | ||
| * | ||
| * <p>This reader is used when {@code preserve-data-ordering} is enabled and the task group contains |
There was a problem hiding this comment.
Yes, removed the reference.
| boolean caseSensitive, | ||
| boolean cacheDeleteFilesOnExecutors) { | ||
| SortOrder sortOrder = table.sortOrder(); | ||
|
|
| } | ||
| } | ||
| Preconditions.checkState( | ||
| found, "Projection field id=%s not found in merge read schema — this is a bug", fieldId); |
|
|
||
| MergingSortedRowDataReader( | ||
| Table table, | ||
| org.apache.iceberg.io.FileIO io, |
| fieldId, | ||
| table.name()); | ||
| Preconditions.checkArgument( | ||
| TypeUtil.ancestorFields(tableSchema, fieldId).isEmpty(), |
There was a problem hiding this comment.
ancestorFields is a full schema walk per sort field, I think if we can just check with tableSchema.asStruct().field(fieldId) != null is enough for verifying top level (non-nested) field here. As it depends on the sortKey
- if this is already projected we can read fine even nested.
- else I think we need fails as we don't use join with nested sortKey
| if (projection.columns().size() == mergeSchema.columns().size()) { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
looks like column count check is a proxy to evaluate whether the projection is the same
| if (projection.columns().size() == mergeSchema.columns().size()) { | |
| return null; | |
| } | |
| if (projection.sameSchema(mergeSchema)) { | |
| return null; | |
| } |
There was a problem hiding this comment.
Yes, the same. Done.
| StructType sparkSchema = SparkSchemaUtil.convert(mergeReadSchema); | ||
| Comparator<StructLike> keyComparator = | ||
| SortOrderComparators.forSchema(mergeReadSchema, sortOrder); |
There was a problem hiding this comment.
From Claude, it seems for UUIDType we might have different ordering, the merge comparator use iceberg ordering
while files written by spark might be sorted by spark's ordering.See example repro in https://gist.github.com/dramaticlly/c57b7382f4e57cc9bf90f4b9e8366e9a
There was a problem hiding this comment.
Found a related issue in #14216, I think at minimal we shall exclude the sortKey with UUID type.
There was a problem hiding this comment.
Good catch, I looked at this more. In the merging reader, we are comparing the output of the transforms. So only impacted case seems to be identity(UUID). I added a pre-condition to ensure the result of the transform cannot be UUID. But the actual check should be upstream when creating the MergingSortedRowReader. I will add that check in the wiring PR #16750
| Schema mergeReadSchema = mergeReadSchema(projection, sortOrder, table); | ||
| this.projectingRow = buildProjectingRow(projection, mergeReadSchema); | ||
|
|
||
| this.resources = new CloseableGroup(); |
There was a problem hiding this comment.
do we want to enable this.closeableGroup.setSuppressCloseFailure(true)?
| } | ||
|
|
||
| @Test | ||
| void mergeWithFileFullyRemovedByDeletes() throws IOException { |
There was a problem hiding this comment.
can we add a 3 files here so that file1 is fully removed and file2 and file3 still enter the merge code path after apply the row to be positional deleted?
…entions for SupportsReportOrdering
…g ordering decision to SparkRowReaderFactory
Revert integration code (SortOrderAnalyzer, SparkPartitioningAwareScan, SparkBatch wiring, config flags) to a follow-up PR. Keep only the k-way merge reader and its direct unit tests.
Fix NPE on files with a null sort order ID, avoid re-setting InputFileBlockHolder on every row, use TypeUtil.ancestorFields to distinguish nested sort keys from missing ones, and resolve projection positions via Accessors. Add tests for the preconditions, sort order evolution, and a file fully removed by deletes.
cb987a4 to
d21c514
Compare
|
This is ready for another round of review. @szehon-ho @huaxingao @RussellSpitzer - It will be good to include this in the |
|
@manuzhang - Just FYI for your Spark 4.2 work. This PR is relevant for Spark 4.2 as well. However the plumbing PR #16750 will be slightly less restrictive as we can keep the bin-packing on Iceberg and spark can do another k-way merge. |
|
|
||
| @Test | ||
| void mergeDescendingOrder() throws IOException { | ||
| catalog.dropTable(TableIdentifier.of("default", "test_merging_reader")); |
There was a problem hiding this comment.
do we need this? I thought we already dropped in afterEach ? same for the rest of 5 repetition below
| catalog.dropTable(TableIdentifier.of("default", "test_merging_reader")); | ||
|
|
||
| Schema nestedSchema = | ||
| new Schema( | ||
| required(1, "id", Types.IntegerType.get()), | ||
| required( | ||
| 2, "location", Types.StructType.of(required(3, "city", Types.StringType.get())))); | ||
|
|
||
| table = catalog.createTable(TableIdentifier.of("default", "test_merging_reader"), nestedSchema); |
There was a problem hiding this comment.
nit: maybe update schema is easier than drop and recreate?
| hasNext(); | ||
| } | ||
| advanced = false; | ||
| return new TaggedRow(reader.get().copy(), block); |
There was a problem hiding this comment.
Actually I think there might be more problem as this is not a deep copy for Parquet collections of complex elements. We might need toUnsafe.apply(reader.get()).copy() via UnsafeProjection over the merge schema.
I think primitive type is generally ok, but for array or map of struct it might lead to row corruption. Might worth a try for this UT https://gist.github.com/dramaticlly/f91eb9d4186ae4b3101834bd4c5d20dd
This is first PR to report ordering to Spark.
This PR adds
MergingSortedRowDataReader, aPartitionReaderthat merges rows from multiple sorted data files into a single sorted stream using a k-way heap merge (SortedMerge).This reader is not wired up yet. A follow-up PR will integrate it with the
SupportsReportOrderingDSv2 API to enable Spark's sort elimination optimization.How it works:
RowDataReaderSortOrderComparators.forSchema()withInternalRowWrapperhandles all transform types (identity, bucket, truncate), ASC/DESC directions, and null orderingProjectingInternalRowbefore returning rows to SparkConstraints:
AI Usage: I used Claude Opus 4.6 for code generation and writing tests. I manually reviewed the generated code.