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
mbutrovich
left a comment
There was a problem hiding this comment.
First pass, looks to be in pretty good shape.
| } | ||
| } | ||
|
|
||
| test("partitioned table with several files per partition") { |
There was a problem hiding this comment.
Every test in this suite inserts 2 to 4 files per partition. Given the memory concern in iceberg_scan.rs is specifically about a partition with a large number of files (a live Parquet reader plus a buffered batch per file, all opened at once), would it be worth adding one test that inserts something like 50 to 100 single-row files into one partition? It would give the merge path a correctness check under the actual shape of the workload this feature targets, not just its happy-path shape, and would be a natural place to assert on memory pool usage or peak concurrent readers once #5343 lands.
| /// Concurrency note: in the ordered path each partition reads exactly one task, so | ||
| /// `data_file_concurrency_limit` no longer bounds cross-file concurrency; instead the wrapping | ||
| /// SortPreservingMergeExec drives one reader per file to merge them. That fan-out (files per | ||
| /// Spark partition) is intrinsic to a k-way merge of per-file sorted streams -- the files must |
There was a problem hiding this comment.
Following up on the fan-out discussion: I don't think this needs a wholly new algorithm, but it does need more than IcebergScanExec alone, since the operator that decides when to poll each partition is SortPreservingMergeExec, not this one. That operator has no notion of a bound and primes every input stream up front because polling is the only signal it has for "what's in this stream." Making IcebergScanExec::execute lazy internally doesn't avoid that, since SortPreservingMergeExec still calls poll_next on every partition almost immediately to seed its loser tree.
What would actually bound this is a custom merge operator that takes each file's min/max bound on the sort key alongside its FileScanTask, keeps a small active set merged the way SortPreservingMergeExec does today, and holds a priority queue of unopened files ordered by min bound, only calling execute/poll_next on the next pending file once its min bound could produce the next output value. That bounds concurrently-open readers by the overlap width of the file ranges at a given point in the merge rather than by total file count, which is the number that actually blows up on a sorted table with a lot of small commits.
The blocker today is data, not algorithm: FileScanTask in iceberg-rust doesn't carry column-level bounds. They exist upstream on the manifest-entry DataFile (lower_bounds/upper_bounds, already used for predicate pushdown), just not threaded down into the task struct the native scan gets. So this needs plumbing in iceberg-rust or an extra field fetched at scan-planning time on the JVM side, either way before a bound-driven admission scheme can work.
Given the risk, could we land @andygrove's simpler mitigation in this PR, a config for the max files to merge per partition that falls back to the unordered read above it, and keep the bound-driven design above as the concrete plan for #5343? Sort-merge ships on by default, and the workload it targets (a sorted table with accumulated small commits) is exactly where a partition ends up with hundreds of files, so I'd rather the default be safe now and the deeper optimization follow once the stats plumbing exists.
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).