Repository navigation
fix: ship Iceberg scan pool entries per partition so task memory does not grow with the table - #7491
parthchandra wants to merge 8 commits into
Conversation
… not grow with the table Native Iceberg scans put the whole scan's delete files, delete-file paths and sets, partition values and residuals in the common block. Every task deserialized it from the task binary, and the injector copied it into each partition's native plan, so tasks on large merge-on-read tables ran out of heap. Each partition now carries only the pool entries its tasks use, in IcebergScan.own_pools (a new IcebergFilePools message), and the common block keeps only the small pools and settings. The driver builds each partition's message lazily, while Spark serializes its task (PartitionPlanData / PlanDataSlice), so it never holds every partition's message at once. The executor merges the partition's pools into the common block before planning, and native rejects a plan that still carries own_pools. A coalesce without a shuffle repeats shared delete files once per input partition; this is documented and pinned by a test.
…ck, and hold driver pools as bytes Entries that every non-empty partition uses, such as an unpartitioned equality delete, now ship once in the common block instead of in every task. Every task needs them anyway, so executors pay nothing extra, and tasks no longer grow past spark.rpc.message.maxSize when many shared delete files exist. Partition messages index the common entries first, then their own. After planning, the driver keeps the full pools as the serialized bytes plus an offset per entry, and parses only the entries a task uses when that task is serialized. The parsed message took about 3.9 times its serialized size; bytes plus the index take about 1.1 times. Adds coalesce queries to the merge-on-read SQL test.
…se in the common block An equality delete applies only to older data files, so newer appends, or a bucket with no deletes, leave some partitions without it. Requiring every partition to use an entry sent such deletes back into every task. Entries used by at least half of the non-empty partitions now ship once in the common block. Every entry there is needed by at least half of the tasks, so the common block is at most twice what an average partition's tasks need.
|
Thanks @parthchandra, I'll take a look in the morning! |
mbutrovich
left a comment
There was a problem hiding this comment.
Thanks @parthchandra for tracking this down, and for the numbers in #7490. Sending each task only the pool entries its own files use looks like the right fix to me, and I'd like to get there by building on the interning that #4982 and #5853 added.
| private[apache] trait PartitionPlanData { | ||
| def length: Int | ||
|
|
||
| /** Partition i's bytes. Must be safe to call from any thread, and more than once. */ | ||
| def apply(i: Int): Array[Byte] | ||
|
|
||
| /** | ||
| * Whether `other` holds the same data, decided without building any partition. The default only | ||
| * knows identity. | ||
| */ | ||
| def sameAs(other: PartitionPlanData): Boolean = this eq other | ||
|
|
||
| /** Every partition's bytes. For tests and diagnostics only: it builds them all. */ | ||
| def toArray: Array[Array[Byte]] = Array.tabulate(length)(apply) | ||
| } | ||
|
|
||
| private[apache] object PartitionPlanData { | ||
|
|
||
| private final class Built(val bytes: Array[Array[Byte]]) extends PartitionPlanData { | ||
| override def length: Int = bytes.length | ||
| override def apply(i: Int): Array[Byte] = bytes(i) | ||
| override def sameAs(other: PartitionPlanData): Boolean = other match { | ||
| case that: Built => | ||
| that.bytes.length == bytes.length && | ||
| bytes.indices.forall(i => java.util.Arrays.equals(bytes(i), that.bytes(i))) | ||
| case _ => false | ||
| } | ||
| } | ||
|
|
||
| /** Data already built for every partition. */ | ||
| def apply(bytes: Array[Array[Byte]]): PartitionPlanData = new Built(bytes) | ||
|
|
||
| /** Whether two scans carry the same data. */ | ||
| def same(a: PartitionPlanData, b: PartitionPlanData): Boolean = (a eq b) || a.sameAs(b) | ||
| } | ||
|
|
||
| /** | ||
| * The planning data one task needs, keyed by scan. On the driver it holds only a reference to | ||
| * every scan's data and builds this partition's bytes while the task is serialized. On the | ||
| * executor it holds the bytes it received. | ||
| */ | ||
| private[comet] final class PlanDataSlice( | ||
| @transient private val source: Map[String, PartitionPlanData], | ||
| index: Int) | ||
| extends Serializable { | ||
|
|
||
| @transient private var received: Map[String, Array[Byte]] = _ | ||
|
|
||
| def get: Map[String, Array[Byte]] = | ||
| if (received != null) received else source.map { case (key, data) => key -> data(index) } | ||
|
|
||
| private def writeObject(out: ObjectOutputStream): Unit = { | ||
| out.defaultWriteObject() | ||
| out.writeObject(get) | ||
| } | ||
|
|
||
| private def readObject(in: ObjectInputStream): Unit = { | ||
| in.defaultReadObject() | ||
| received = in.readObject().asInstanceOf[Map[String, Array[Byte]]] | ||
| } | ||
| } |
There was a problem hiding this comment.
Should the lazy driver-side build go in its own PR? #7490 reports executor OOM. PartitionPlanData, PlanDataSlice, SerializedPools, and IcebergPartitionPlanData address driver memory, and they change CometExecRDD, CometNativeShuffleInputRDD, NativeExecContext, CometNativeScanExec, and the Delta contrib scan. They also move each partition's build inside Spark's scheduler lock, as the description notes.
On the table from #7490 (partition delete granularity, grouped read), as I read it each delete file is used by one partition. So eager per-partition messages from the previous comment would total about what the common block holds on main today. The cost of eager messages is driver duplication of entries many partitions share, such as a global equality delete. For context, the driver already holds every FileScanTask and DeleteFile for the life of the plan, because Iceberg's SparkPartitioningAwareScan caches its task groups (line 69, filled at lines 204-213). How were the 1.1x and 3.9x heap figures in the description measured? Driver heap numbers for eager messages on a shared-delete layout would tell us whether the lazy build is needed, and a separate issue and PR could carry them.
There was a problem hiding this comment.
Agreed, it's out of this PR and I'll open a separate issue with driver numbers. The 3.9× and 1.1× figures came from a standalone JVM program. It built an IcebergScanCommon with 200k and 1M synthetic position-delete entries (path, delete file, set, partition value), serialized it, and parsed it back. It then measured the used-heap difference with a serial GC, after several System.gc() calls, keeping the bytes alive. The 1.1× figure is the serialized bytes plus a per-entry offset index. These are microbenchmark numbers, not driver numbers from a real job, so the follow-up will measure eager messages on a shared-delete layout.
… serde pass Review feedback: build each partition's pools in the pass that already interns them, and ship them in the partition's existing IcebergScan.common, so the injector merges the two with mergeFrom. This drops the global-then-split step (IcebergScanPools, Layout, SerializedPools), the IcebergFilePools message and own_pools field, and the half-of-partitions threshold: the common block now holds none of the per-file pools. The lazy driver-side build (PartitionPlanData, PlanDataSlice) moves out of this PR, so CometExecRDD, CometNativeShuffleInputRDD, NativeExecContext, CometNativeScanExec and the Delta contrib scan are unchanged from main.
parthchandra
left a comment
There was a problem hiding this comment.
Thanks Matt. Reworked the PR to follow your approach,
Thanks @parthchandra! I will take another pass soon! |
comphead
left a comment
There was a problem hiding this comment.
Thanks @parthchandra, the write-up of what ships where made this easy to review, and the new revision is much smaller.
One cross-PR note that has no diff anchor here. #7443, #7156, #7233 and #6847 add CometIcebergNativeSuite tests that parse IcebergScanCommon.parseFrom(scan.commonData) and read its residual pool, for example assert(common.getResidualPoolCount == (if (pushed) 1 else 0)) in #7156 and #7233. All four merge with this branch without a textual conflict, but with this change the common block holds no residuals, so I expect those assertions to need the per-partition data, whichever PR lands second. I have not run them.
| that its own files need, the way Spark's Iceberg reader does. A coalesce without a shuffle | ||
| (`coalesce(n)`, or a write that coalesces its input) combines several input partitions into one | ||
| task, and that task then carries a copy of a shared delete file for every input partition that | ||
| uses it. Scans where many partitions share delete files, such as tables with unpartitioned | ||
| equality deletes, can make coalesced tasks large. Compacting delete files, or repartitioning | ||
| instead of coalescing, avoids this. |
There was a problem hiding this comment.
Thanks for documenting this limit. As far as I can tell Spark's reader avoids it. A coalesced task writes all of its input partitions into one ObjectOutputStream (CoalescedRDDPartition.writeObject writes parents through defaultWriteObject), and Iceberg's DeleteFileIndex.forDataFile hands out references to the same DeleteFile objects, so a shared delete file is written once per task and later occurrences are back-references. The latest release ships it once too, in the common block. With the threshold gone, this now also applies to unpartitioned equality deletes, which every partition uses, so a coalesced task over such a table carries one copy per input partition where the release carries one.
Could each partition hold its entries as references to one shared object per entry, rather than as copied bytes, so that Java serialization shares them within a task the same way?
There was a problem hiding this comment.
Agreed, this was a regression against the release for coalesced reads. Each delete file is now serialized once per scan, and every partition that uses it holds the same array and path string (IcebergPartitionPlanData). Java serialization writes it once per task stream, so a coalesced task carries one copy, and the driver keeps one copy too. The executor sets file_path_idx when it builds the partition's message, because the path pool order differs per partition. PlanDataInjectorSuite has a serialization test for the coalesced case, and the doc paragraph is updated.
…k carries each once Review feedback: a coalesce without a shuffle writes several input partitions into one task, and each partition held its own copy of a shared delete file, so the task carried one copy per input partition where the latest release carried one. Per-partition plan data is now a PartitionPlanData object instead of bytes. An Iceberg partition holds its own message plus references to delete files serialized once for the whole scan, so Java serialization writes a shared delete file once per task stream and the driver keeps one copy. The executor sets each delete file's path index when it builds the partition's message, since the path pool order differs per partition. Native Parquet and contrib scans wrap their bytes, so the CometScanWithPlanData trait is unchanged. Move the Iceberg pool test helpers into IcebergScanPlanData, with distinctResiduals for tests that read the residual pool.
parthchandra
left a comment
There was a problem hiding this comment.
Thanks for flagging this. The helpers are now in org.apache.comet.IcebergScanPlanData, with a distinctResiduals(scan) helper, so whichever PR lands second can change getResidualPoolCount == n to distinctResiduals(scan).size == n.
| that its own files need, the way Spark's Iceberg reader does. A coalesce without a shuffle | ||
| (`coalesce(n)`, or a write that coalesces its input) combines several input partitions into one | ||
| task, and that task then carries a copy of a shared delete file for every input partition that | ||
| uses it. Scans where many partitions share delete files, such as tables with unpartitioned | ||
| equality deletes, can make coalesced tasks large. Compacting delete files, or repartitioning | ||
| instead of coalescing, avoids this. |
There was a problem hiding this comment.
Agreed, this was a regression against the release for coalesced reads. Each delete file is now serialized once per scan, and every partition that uses it holds the same array and path string (IcebergPartitionPlanData). Java serialization writes it once per task stream, so a coalesced task carries one copy, and the driver keeps one copy too. The executor sets file_path_idx when it builds the partition's message, because the path pool order differs per partition. PlanDataInjectorSuite has a serialization test for the coalesced case, and the doc paragraph is updated.
Which issue does this PR close?
Closes #7490.
Rationale for this change
Each native Iceberg scan task deserialized and serialized the whole scan's delete-file metadata, even when its own files needed a tiny fraction of it. The scan's pools of delete files, delete-file paths, delete-file sets, partition values and residuals lived in the common block. Every task got the common block in the task binary, and IcebergPlanDataInjector.inject copied it into each partition's native plan. On large merge-on-read tables, executor heap use grew with the size of the table, and tasks ran out of memory where Spark does not.
What changes are included in this PR?
Per-partition pools
Executor merge
Logging
Behavior compared with the latest release
Known limits (documented in the Iceberg user guide)
How are these changes tested?
Unit tests
Integration tests, compared with Spark