feat: Sort-aware Iceberg reads in Comet via a per-partition streaming merge - #5331
feat: Sort-aware Iceberg reads in Comet via a per-partition streaming merge#5331parthchandra wants to merge 3 commits into
Conversation
|
This PR has some followups -
|
|
@anuragmantri, @peter-toth , you might be interested in looking at this. k-way merge to maintain the ordered property of sorted Iceberg tables. Feedback, especially about test coverage, would be highly appreciated. |
312356d to
e30a33f
Compare
|
Added more followup issues in parent issue - #5323 |
anuragmantri
left a comment
There was a problem hiding this comment.
Thanks for pinging here @parthchandra. It is great to see there is interest in this optimization in the comet project.
I reviewed the general implementation and the test coverage in this PR. I don't have any major comments, most are clarifications and minor suggestions. That said, I'm new to Comet codebase and would like someone more familiar to also take a look.
| private def isReportable(order: SortOrder, output: Seq[Attribute]): Boolean = | ||
| isIdentityProjected(order, output) && exprToProto(order, output).isDefined | ||
|
|
There was a problem hiding this comment.
As identified in the Iceberg PR and the design doc #5323, UUID orders differently in Iceberg than in Spark's comparator, and the identity case for UUID needs to follow Iceberg's byte ordering specifically. This gate doesn't check the sort column's type at all. I believe we could rely on upstream apache/iceberg#16750 to not report ordering on UUID. Is my understanding correct?
There was a problem hiding this comment.
I was planning to address this in a follow up. But now I've added a type check rather than relying only on upstream. IcebergReflection.orderingUnsafeColumns returns UUID column names, and reportableOrdering now refuses any sort key in that set
| * We read scanExec.ordering (the raw reported order), not scanExec.outputOrdering. Spark blanks | ||
| * outputOrdering when a partition holds more than one file -- the case this merge handles. |
There was a problem hiding this comment.
I believe using scanExec.ordering is to bypass the check in Spark 3.4 and 3.5 which was fixed in Spark 4.2 by SPARK-55715 and this is needed to support all these Spark versions. @peter-toth, would you please also take a look at this to see if this is safe?
There was a problem hiding this comment.
Seems good to me, scanExec.ordering is the order reported by the source and that's what we need to keep with k-way merge if possible, and return as scanExec.outputOrdering so as Spark can use it to elimiate sorts.
There was a problem hiding this comment.
So as we discussed offline CometIcebergNativeScanExec.scala.outputOrdering and this whole logic might not be needed if BatchScanExec -> CometIcebergNativeScanExec translation happend after EnsureRequirements runs (i.e. we eliminated the SortExecs based on BatchScanExec.outputOrdering).
And same for CometIcebergNativeScanExec.outputPartitioning...
There was a problem hiding this comment.
@anuragmantri Correct. DataSourceV2ScanExecBase.outputOrdering blanks the ordering when a partition has more than one file on Spark 3.4–4.1 (relaxed in 4.2 by SPARK-55715). We need to keep this as long as we support versions before 4.2.
There was a problem hiding this comment.
So as we discussed offline
CometIcebergNativeScanExec.scala.outputOrderingand this whole logic might not be needed ifBatchScanExec->CometIcebergNativeScanExectranslation happend afterEnsureRequirementsruns (i.e. we eliminated theSortExecs based onBatchScanExec.outputOrdering).And same for
CometIcebergNativeScanExec.outputPartitioning...
@peter-toth I dug in deeper and you are correct. EnsureRequirements does run before Comet converts the scan (confirmed in AdaptiveSparkPlanExec.queryStagePreparationRules and the non-AQE order), so it eliminates the shuffle on the BatchScanExec. An experiment confirmed it: with partitioning reporting off, the SMJ still has no Exchange. So I removed the outputPartitioning override and the reportPartitioning flag entirely.
outputOrdering is still needed because pre 4.2 Spark still has the check that blanks out BatchScanExec.outputOrdering for multi-file partitions. So we need to include the check to eliminate sort.
There was a problem hiding this comment.
Claude flagged this:
BaselineMetrics::new(metrics, 0) hardcodes partition 0, but this operator is now multi-partition in the ordered path. Should this thread through the actual partition index from execute()?
There was a problem hiding this comment.
Good catch Claude! Updated this.
| * cached per catalog name, so a shared name would bind every test to the first warehouse), and | ||
| * its tables are dropped in a `finally` so a failing test cannot leak a table into a later one. | ||
| */ | ||
| class CometIcebergSortMergeReadSuite |
There was a problem hiding this comment.
I reviewed the tests, very nice coverage already. I would also a test that deletes some rows from one file in a multi-file sorted partition, to cover the merge alongside MOR deletes.
There was a problem hiding this comment.
added new test for this case
|
One thing I'd like to dig into more before this lands: the reader fan-out in the ordered path.
The comment in The tables in the tests are fine, but the workload this targets is a sorted table, and a sorted table that has accumulated a lot of small commits is exactly where you get hundreds of files in one task. Since the merge reserves against Comet's memory pool, hitting the limit there is a query failure rather than a slowdown, which is a worse failure mode than the extra sort we're trying to avoid. Could we add a config for the maximum files per partition we're willing to merge, and fall back to the unordered read above it? That keeps the default safe and lets people opt into deeper merges once we have a better sense of the memory profile. |
|
Following up on test coverage, because I want to make sure I'm reading this right. As far as I can tell If that's right, the effect in CI is bigger than the canceled plan assertions. The k-way merge itself never runs — every There's a related wrinkle even on a reporting build: a global Am I understanding the situation correctly, or is there something in the CI setup I'm missing that does exercise the merge? |
|
One more, on the gate in val reportable = reportableOrdering(scanExec.ordering, output)
if (reportable.nonEmpty) {
val protoOrders = reportable.map(exprToProto(_, output))
if (protoOrders.forall(_.isDefined)) {
commonBuilder.addAllTableSortOrders(protoOrders.map(_.get).asJava)
}
}The comment above it argues the That's not purely hypothetical, because the gate gets evaluated twice against two different points in time.
|
Fixed. This is evaluated once now.
Fixed. This throws if a reported order can't be serialized, instead of the old silent |
You're reading this right. I cannot think of a way to mock Iceberg without the actual implementation (which is still in review). The tests cover the fallback, and are somewhat forward looking. In a way this feature is itself forward looking - once this feature is released in Iceberg and Spark starts to eliminate sorts based on what iceberg-java reports, Comet will produce absolutely garbage results unless we have this implementation in place. |
I was planning to address this properly in a followup (#5343). But I can implement that in this PR if you think I should address it right away. |
f35bb6a to
a4a5b91
Compare
Which issue does this PR close?
Closes ##5337
Rationale for this change
Currently when Comet reads a sorted Iceberg table, the scan throws away the ordering to achieve parallelism. As a result Spark can't tell the data is already sorted, and it re-sorts on every read — in joins, aggregates, windows, and order-by queries — even though the work was already done at write time.
This PR modifies the native scan to preserve and report that ordering, so Spark can drop the redundant sorts. It builds on Iceberg's own
SupportsReportOrdering(apache/iceberg#14948): when Iceberg reports a sortorder, merge the sorted files per partition, and tell Spark the result is sorted.
The scan also reports Iceberg's key-group partitioning. (For Spark to eliminate shuffle in SMJ, the scan must also report how the data is grouped by the join key (storage-partitioned join)).
What changes are included in this PR?
For every Spark partition, the scan now reads each sorted file as its own stream and k-way-merges them into one sorted stream using DataFusion's
SortPreservingMergeExec.Summary of changes -
table_sort_ordersfield onIcebergScanCommoncarries the reported sort order to the native side.actually in the projection. Anything else (transforms, a sort key that isn't selected) falls back to today's unordered read and reports nothing, so it's always correct.
IcebergScanExecbecomes multi-partition when an ordering is present (one sorted stream per file), and the planner wraps it inSortPreservingMergeExec. No changes to iceberg-rust — we justcall its existing reader once per file instead of once for the whole batch.
The PR also adds two config flags for the Iceberg scan:
spark.comet.scan.icebergNative.sortMerge.enabled(default on) — report the sort order and do the per-partition merge. Only does anything when Iceberg'sspark.sql.iceberg.planning.preserve-data-orderingison (off by default).
spark.comet.scan.icebergNative.reportPartitioning.enabled(default off) — report key-grouped partitioning for storage-partitioned joins. Off by default while we build out coverage for the adaptive-executionpartition-pushdown path.
Note: A global
ORDER BYstill keeps its final sort — a per-partition merge isn't a cluster-wide order — so that case is unchanged.How are these changes tested?
iceberg_scan.rs: multi-partition with a reported ordering, single-partition without one.CometIcebergSortMergeReadSuiteover real Iceberg tables (local Hadoop catalog, sort order set via the Iceberg Java API, one file per insert).