Skip to content

perf: dedupe Iceberg residuals and delete files in native scan serde#4982

Open
mbutrovich wants to merge 13 commits into
apache:mainfrom
mbutrovich:iceberg_delete_file_dedup
Open

perf: dedupe Iceberg residuals and delete files in native scan serde#4982
mbutrovich wants to merge 13 commits into
apache:mainfrom
mbutrovich:iceberg_delete_file_dedup

Conversation

@mbutrovich

@mbutrovich mbutrovich commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #4944.

Rationale for this change

CometIcebergNativeScanExec broadcasts an Iceberg scan's shared data as a single IcebergScanCommon protobuf. On a large merge-on-read plan this crashed the driver:

java.lang.NegativeArraySizeException: -954238687
	at org.apache.comet.shaded.protobuf.AbstractMessageLite.toByteArray(AbstractMessageLite.java:46)
	at org.apache.comet.serde.operator.CometIcebergNativeScan$.serializePartitions(CometIcebergNativeScan.scala:1025)

toByteArray allocates new byte[getSerializedSize()], and getSerializedSize() returns int, so a message over 2 GiB wraps to a negative length. Per-pool instrumentation on the reporter's plan showed the dominant contributor was the residual_pool at ~3.43 GB (13,695 residuals, one per task, zero deduplication), not the delete files. Two serde issues let pools grow with the number of tasks; this PR fixes both so the common message scales with the number of distinct values rather than references.

Residual pool (the confirmed cause)

Residuals were serialized through the general Spark expression serde (exprToProto), which stamps every node with a fresh expr_id (from a global counter) and a query_context (the full SQL text) for native ANSI error reporting. Neither is used on the residual path: a residual only drives row-group pruning, and the post-scan CometFilter enforces correctness. The per-node expr_id made every task's residual serialize to distinct bytes, so structurally identical residuals never deduplicated and the pool grew with the task count.

Delete files

Each DeleteFileList held full copies of every IcebergDeleteFile in a task's delete set. Under Iceberg's default partition delete granularity one delete file applies to many data files, and staggered sequence numbers give each task a different subset of the same files, so a shared delete file was re-serialized once per distinct set. Iceberg's own DeleteFileIndex stores each delete file once and references it many times; the serde did not preserve that sharing.

What changes are included in this PR?

Replace the residual's Spark-expression round-trip with a purpose-built Iceberg-native serde, and intern delete files by index. The Iceberg path no longer depends on the Spark expression format at all.

  • proto (operator.proto): add IcebergLiteral (a typed primitive-value oneof, shared by partition values and predicate literals) and IcebergPredicate (mirroring iceberg-rust's Predicate and PredicateOperator). residual_pool becomes repeated IcebergPredicate; PartitionValue unifies onto IcebergLiteral. Add delete_file_pool and make DeleteFileList hold uint32 indices into it.
  • Scala serde (CometIcebergNativeScan.scala): serialize residuals directly to IcebergPredicate (no expr_id/query_context); a shared icebergLiteralToProto builds both partition values and predicate literals; delete files are interned into the flat pool by path.
  • Rust decode (planner.rs): iceberg_predicate_to_predicate maps the proto onto iceberg-rust Reference builders (with rewrite_not() before bind, since the scan evaluators require negation-normal form); delete files resolve from the flat pool by index.

Two rules govern what a residual pushes. A residual is only a pruning hint, so anything unrepresentable is safely omitted and left to CometFilter:

  • FIXED_LEN_BYTE_ARRAY columns are never pushed. iceberg-rust's page-index evaluator rejects that physical column-index type (page_index_evaluator.rs), so any predicate on a decimal, uuid, or fixed column fails the scan, including the IS NOT NULL that Iceberg adds for every filtered column. These columns are identified by their Iceberg type from the table schema, since Spark surfaces uuid as StringType and fixed as BinaryType and cannot distinguish them from ordinary string/binary columns.
  • Residuals are pushed whole or not at all. Dropping a conjunct is safe in positive position but not under a NOT: De Morgan makes NOT(A AND B) equal to NOT A OR NOT B, where dropping B strengthens the predicate and would wrongly prune. Rather than track polarity across arbitrary nesting, any unconvertible leaf elides the entire residual.

Other intended changes: float, date, and timestamp[tz] literals now push with the correct Datum type (previously widened or dropped), so pruning is more precise. NOT_IN is still not pushed (inherently unprunable from column stats). Correctness is unchanged in all cases; the general Spark expression serde used by every other operator is untouched. Unrecognized ops or literal types degrade to no-pushdown, so the format stays correct across Iceberg library versions and new primitive types (geometry, variant, nanosecond timestamps) are additive.

serializePartitions also logs per-pool byte sizes before toByteArray, so a future overflow names its dominant pool directly.

How are these changes tested?

  • The existing CometIcebergNativeSuite (residual-filter, transform/decimal partition, positional/equality/MOR delete, and NOT/comparison filter tests) passes unchanged, confirming the refactor is behavior-preserving on results.
  • New predicate-fuzz test in CometFuzzIcebergSuite: per primitive column it samples real values and builds predicates through the typed Column API (comparisons, !=, explicit NOT, IN/NOT IN, IS [NOT] NULL, AND/OR) against Spark, exercising every operator and type through the new serde.
  • Filter tests over decimal, uuid, and fixed columns confirm the scan stays native and returns correct results when the page-index gate drops the predicate.
  • NOT over a partially supported AND does not drop rows confirms a residual mixing a supported and an unsupported leaf is elided rather than partially pushed.
  • Residual-dedup and delete-file-dedup regressions: a shared non-partition filter collapses to one pooled residual across many task references, and each physical delete file is serialized exactly once (raw-byte check, with a guard that files are actually shared).

@mbutrovich

Copy link
Copy Markdown
Contributor Author

@sandugood are you able to test this PR branch to see if it resolves the issue for you? Thanks for reporting! I also added some INFO level instrumentation that might help us track it down if it's still failing.

@mbutrovich mbutrovich added this to the 1.0.0 milestone Jul 20, 2026
@mbutrovich mbutrovich self-assigned this Jul 20, 2026
@sandugood

Copy link
Copy Markdown
Contributor

Testing it right away

@mbutrovich

Copy link
Copy Markdown
Contributor Author

Testing it right away

Thank you so much! The new logging I added looks like this:

26/07/20 14:07:52.317 shuffle-exchange-6 INFO CometIcebergNativeScan: IcebergScan: 25 tasks, 9 pools (89.6% avg dedup)
26/07/20 14:07:52.318 shuffle-exchange-6 INFO CometIcebergNativeScan:   Common pools (unique/bytes): schema=1/149, partition_type=0/0, partition_spec=1/25, name_mapping=0/0, project_field_ids=1/4, partition_data=0/0, delete_file=5/1004, delete_files_set=5/25, residual=0/0; total=1207 bytes
26/07/20 14:07:52.318 shuffle-exchange-6 INFO CometIcebergNativeScan:   Per-partition messages: 23, total=4842 bytes, max=388 bytes

@sandugood

Copy link
Copy Markdown
Contributor

The error still persists. Here are the logs:

26/07/20 21:54:08 INFO CometIcebergNativeScan:   Common pools (unique/bytes): schema=1/516, partition_type=0/0, partition_spec=1/25, name_mapping=0/0, project_field_ids=1/4, partition_data=0/0, delete_file=0/0, delete_files_set=0/0, residual=1/108; total=653 bytes
26/07/20 21:54:08 INFO CometIcebergNativeScan:   Per-partition messages: 1, total=167 bytes, max=167 bytes
26/07/20 21:54:23 INFO CometIcebergNativeScan: IcebergScan: 1 tasks, 9 pools (0.0% avg dedup)
26/07/20 21:54:23 INFO CometIcebergNativeScan:   Common pools (unique/bytes): schema=1/488, partition_type=0/0, partition_spec=1/25, name_mapping=0/0, project_field_ids=1/5, partition_data=0/0, delete_file=0/0, delete_files_set=0/0, residual=1/84; total=602 bytes
26/07/20 21:54:23 INFO CometIcebergNativeScan:   Per-partition messages: 1, total=175 bytes, max=175 bytes
26/07/20 21:58:51 INFO CometIcebergNativeScan: IcebergScan: 13695 tasks, 9 pools (81.6% avg dedup)
26/07/20 21:58:51 INFO CometIcebergNativeScan:   Common pools (unique/bytes): schema=1/1868, partition_type=1/97, partition_spec=1/106, name_mapping=0/0, project_field_ids=1/9, partition_data=1395/12555, delete_file=0/0, delete_files_set=0/0, residual=13695/3432661835; total=3432676470 bytes
26/07/20 21:58:51 INFO CometIcebergNativeScan:   Per-partition messages: 10352, total=3002592 bytes, max=1742 bytes
        at org.apache.comet.serde.operator.CometIcebergNativeScan$.serializePartitions(CometIcebergNativeScan.scala:1094)

So at one point the number of tasks just explodes and exceeds the 2GB threshold.

Here is the error stacktrace:

Py4JJavaError: An error occurred while calling o12676.createOrReplace.
: java.lang.NegativeArraySizeException: -862232487
	at org.apache.comet.shaded.protobuf.AbstractMessageLite.toByteArray(AbstractMessageLite.java:46)
	at org.apache.comet.serde.operator.CometIcebergNativeScan$.serializePartitions(CometIcebergNativeScan.scala:1094)
	at org.apache.spark.sql.comet.CometIcebergNativeScanExec.serializedPartitionData$lzycompute(CometIcebergNativeScanExec.scala:111)
	at org.apache.spark.sql.comet.CometIcebergNativeScanExec.org$apache$spark$sql$comet$CometIcebergNativeScanExec$$serializedPartitionData(CometIcebergNativeScanExec.scala:86)

@mbutrovich

mbutrovich commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @sandugood, that log is exactly what we needed. The instrumentation pins it, and it is not the delete files:

IcebergScan: 13695 tasks, 9 pools (81.6% avg dedup)
  Common pools (unique/bytes): schema=1/1868, ..., partition_data=1395/12555,
    delete_file=0/0, delete_files_set=0/0, residual=13695/3432661835; total=3432676470 bytes

The residual pool is 3.43 GB: 13,695 residuals, one per task, none deduplicated, averaging ~250 KB each. delete_file=0/0, so the delete-file change in this PR is orthogonal to your failure (it is still a valid fix for merge-on-read tables, just not the cause here). This is the same overflow, a different pool.

Two things stand out. A single residual is normally a small reduced predicate, so ~250 KB each means the scan's filter is very large, and 13,695 distinct residuals means each task carries its own copy. The residual is only used for row-group pruning on the native side; the post-scan filter still enforces correctness, so we can safely bound or skip it.

To make sure the fix targets the real shape: what does the pushed-down filter on the source scan look like? The residual comes from Iceberg's per-file ResidualEvaluator against the scan's static predicate, so ~250 KB per residual points to a large static filter, for example a WHERE col IN (...) with many literals, or a large or complex predicate on a non-partition column. Is there something like that on the table being read?

@sandugood

Copy link
Copy Markdown
Contributor

Thanks @sandugood, that log is exactly what we needed. The instrumentation pins it, and it is not the delete files:

IcebergScan: 13695 tasks, 9 pools (81.6% avg dedup)
  Common pools (unique/bytes): schema=1/1868, ..., partition_data=1395/12555,
    delete_file=0/0, delete_files_set=0/0, residual=13695/3432661835; total=3432676470 bytes

The residual pool is 3.43 GB: 13,695 residuals, one per task, none deduplicated, averaging ~250 KB each. delete_file=0/0, so the delete-file change in this PR is orthogonal to your failure (it is still a valid fix for merge-on-read tables, just not the cause here). This is the same overflow, a different pool.

Two things stand out. A single residual is normally a small reduced predicate, so ~250 KB each means the scan's filter is very large, and 13,695 distinct residuals means each task carries its own copy. The residual is only used for row-group pruning on the native side; the post-scan filter still enforces correctness, so we can safely bound or skip it.

To make sure the fix targets the real shape: what does the pushed-down filter on the source scan look like? The residual comes from Iceberg's per-file ResidualEvaluator against the scan's static predicate, so ~250 KB per residual points to a large static filter, for example a WHERE col IN (...) with many literals, or a large or complex predicate on a non-partition column. Is there something like that on the table being read?

You've nailed down exactly the case - it is a large static filter: WHERE col_name in ({strs})

@mbutrovich

Copy link
Copy Markdown
Contributor Author

You've nailed down exactly the case - it is a large static filter: WHERE col_name in ({strs})

I've looked at this a bit and a few problems with Iceberg serde pop out at me for residuals: 1) dedupe doesn't really work because they all get unique expr_id 2) we're serializing stuff (query_context) that we never use.

I can try to come up with a cleaner way to do this. I'm afraid it ended up with an inefficient implementation when trying to POC the Iceberg scan. But we wanted correctness first, and then once it was correct we could optimize. This is the optimize step :)

mbutrovich and others added 3 commits July 20, 2026 18:36
- proto: IcebergLiteral/IcebergDecimal/IcebergPredicate + IcebergPredicateOperator (mirrors iceberg-rust); PartitionValue unified onto IcebergLiteral;
residual_pool → repeated IcebergPredicate.
- Rust: iceberg_predicate_to_predicate + iceberg_literal_to_datum, partition_value_to_literal rewrite, rewrite_not() before bind, old Spark-expr conversion cluster
deleted.
- Scala: icebergExprToProto + proto builders + predicateLiteralToProto, partitionValueToProto/icebergLiteralToProto, residual site + pool type, old conversion
cluster deleted, imports cleaned (ByteString/Base64 imported).

Tests:
- New predicate fuzz test in CometFuzzIcebergSuite (sample-from-data + Column API over primitive columns: comparisons, !=, explicit NOT, IN/NOT IN, IS [NOT] NULL,
AND/OR) — the coverage that would have caught the NOT bug.
- Residual dedup test tightened: asserts one pool entry shared by many task references (no longer vacuously passable), logInfo removed.
- Delete-file dedup test: logInfo removed.
@mbutrovich mbutrovich changed the title perf: dedupe Iceberg delete files by index in native scan serde perf: dedupe Iceberg residuals and delete files in native scan serde Jul 20, 2026
}
}

test("Iceberg temporal types written as INT96") {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Iceberg's writer doesn't write INT96. The only time they can end up in a Parquet data file is migration, which "migration - INT96 timestamp" covers in CometIcebergNativeSuite. These ParquetOutputTimestampType tests in CometFuzzIcebergSuite are a waste.

assert(testedBinary)
}

test("regexp_replace") {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decode and regexp_replace are exercise elsewhere and this is is redundant.

@mbutrovich

Copy link
Copy Markdown
Contributor Author

@sandugood once CI goes green, do you mind trying this PR again? I refactored the serde code and added some new tests. Local testing looked good, but I want to make sure that CI goes green before you try it on your table. Thank you in advance!

@sandugood

Copy link
Copy Markdown
Contributor

@sandugood once CI goes green, do you mind trying this PR again? I refactored the serde code and added some new tests. Local testing looked good, but I want to make sure that CI goes green before you try it on your table. Thank you in advance!

Sure

@sandugood

Copy link
Copy Markdown
Contributor

Yeah, I think there is still some refactor needed to push the CI to green
Quickly tried to build and test the current branch and got an error:

Py4JJavaError: An error occurred while calling o293.collectToPython.
: org.apache.spark.SparkException: Job aborted due to stage failure: Task 9 in stage 0.0 failed 4 times, most recent failure: Lost task 9.3 in stage 0.0 (TID 19) (10.232.15.197 executor 2): org.apache.comet.CometNativeException: Fail to deserialize to native operator with reason: failed to decode Protobuf message: BoundReference.datatype: Expr.expr_struct: MathExpr.right: Expr.expr_struct: MathExpr.left: Expr.expr_struct: MathExpr.right: Expr.expr_struct: MathExpr.left: Expr.expr_struct: MathExpr.left: Expr.expr_struct: IcebergScanCommon.residual_pool: IcebergScan.common: Operator.op_struct: Operator.children: Operator.children: invalid wire type: Varint (expected LengthDelimited).

@mbutrovich

Copy link
Copy Markdown
Contributor Author

Yeah, I think there is still some refactor needed to push the CI to green Quickly tried to build and test the current branch and got an error:

Py4JJavaError: An error occurred while calling o293.collectToPython.
: org.apache.spark.SparkException: Job aborted due to stage failure: Task 9 in stage 0.0 failed 4 times, most recent failure: Lost task 9.3 in stage 0.0 (TID 19) (10.232.15.197 executor 2): org.apache.comet.CometNativeException: Fail to deserialize to native operator with reason: failed to decode Protobuf message: BoundReference.datatype: Expr.expr_struct: MathExpr.right: Expr.expr_struct: MathExpr.left: Expr.expr_struct: MathExpr.right: Expr.expr_struct: MathExpr.left: Expr.expr_struct: MathExpr.left: Expr.expr_struct: IcebergScanCommon.residual_pool: IcebergScan.common: Operator.op_struct: Operator.children: Operator.children: invalid wire type: Varint (expected LengthDelimited).

Thanks for trying again. I think I cleaned up the failures. Could you try a clean build? That failure looks like proto wasn't rebuilt. Thanks again for all of your help so far @sandugood!

@sandugood

Copy link
Copy Markdown
Contributor

Yeah, the proto seemed to be the issue
Tested it again against the full pipeline - no errors caught. Thanks @mbutrovich

@mbutrovich

Copy link
Copy Markdown
Contributor Author

Yeah, the proto seemed to be the issue Tested it again against the full pipeline - no errors caught. Thanks @mbutrovich

Wonderful!

@mbutrovich
mbutrovich requested a review from andygrove July 21, 2026 18:28

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review assisted by an LLM (Claude); a human reviewed the findings before posting.

Nice fix, and the instrumentation-driven diagnosis via @sandugood's logs is a great artifact of the PR. All CI green and reporter confirms the workload unblocks. A few small things I noticed while reading through.

Stale documentation in planner.rs

The block comment at native/core/src/execution/planner.rs:4094-4106 still describes the old conversion path:

// Predicates are converted through Spark expressions rather than directly from
// Iceberg Java to Iceberg Rust. This leverages Comet's existing expression
// serialization infrastructure, which handles hundreds of expression types.
//
// Conversion path:
//   Iceberg Expression (Java) -> Spark Catalyst Expression -> Protobuf -> Iceberg Predicate (Rust)

That path is exactly what this PR removes. Also, line 4108 has a leftover one-line docstring /// Converts a protobuf Spark expression to an Iceberg predicate for row-group filtering. above the new (correct) multiline docstring on iceberg_predicate_to_predicate.

Nested-column gap in pageIndexUnsupportedColumns

IcebergReflection.pageIndexUnsupportedColumns iterates schema.columns(), which returns top-level fields only. If Iceberg ever emits a residual on a nested field (name arrives as a dotted path like struct.decimal_field), the gate would miss it and let a FIXED_LEN_BYTE_ARRAY predicate through. The docstring already flags this as top-level. Do we know whether Iceberg's ResidualEvaluator can produce nested references from a Spark push-down? If so it might be worth a follow-up test.

Missed pushdown for ByteType/ShortType

predicateLiteralToProto in CometIcebergNativeScan.scala handles Boolean/Integer/Long/Float/Double/Date/String/Timestamp/TimestampNTZ but not ByteType or ShortType. The old extract_literal_as_datum widened ByteVal/ShortVal to Datum::int. On Iceberg 1.6.0+ (which supports byte/short natively, and the fuzz base only excludes them for older versions), filters on a byte or short column now silently skip pushdown. Correctness is fine, but it's a small regression on that surface. Two extra cases widening to int_val would restore parity.

Similarly, BinaryType falls through to None. iceberg-rust supports BYTE_ARRAY in the page index (only FIXED_LEN_BYTE_ARRAY is the problem this PR is guarding), so binary predicates could in principle push. Deliberate exclusion or worth revisiting?

Fuzz test observation, not a defect

filter pushdown - fuzz predicates over primitive columns asserts results match Spark and that the scan stays native. Both hold when predicateLiteralToProto returns None — the scan just skips pushdown and CometFilter picks it up. So the byte/short/binary case above wouldn't fail this test. Worth knowing when relying on it as regression coverage.

Test coverage strengths worth calling out

The dedup regressions are well-designed. delete file pool does not duplicate shared delete files counts raw-byte occurrences (structure-agnostic) and guards totalReferences > distinctPaths.size against a vacuous pass. residual pool dedups a shared non-partition filter to one entry similarly guards tasksWithResidual > 1. The three FIXED_LEN_BYTE_ARRAY tests all set write.parquet.row-group-size-bytes = 100 to force the page index into existence, which is what actually exercises the failure surface. NOT over a partially supported AND does not drop rows is exactly the right anchor for the whole-or-nothing rule.

Nice bonus: float literals no longer widen to double (Datum::double(*v as f64)Datum::float(*v)) and date/timestamp literals push with the correct type. Those were real latent issues on the old path.

Nothing here is a block. Mostly the stale planner.rs comment and the byte/short/binary pushdown gaps are worth a look.

@mbutrovich

Copy link
Copy Markdown
Contributor Author

Thanks for the careful read, @andygrove. All four addressed in the latest commit.

Stale planner.rs comment. Fixed. Removed the section banner describing the old Spark-Catalyst round-trip and the leftover one-line docstring, so only the accurate multiline docstring remains.

Byte/Short pushdown. Restored. predicateLiteralToProto now widens ByteType/ShortType to int_val, matching the old Datum::int path and how iceberg-rust stores these (it has no narrower integer type, so byte/short map to Iceberg int).

Nested-column gate. I verified the concern does not reach the gate. Spark does emit nested references as dotted paths, but Comet reads the residual's column name (CometIcebergNativeScan.scala, icebergExprToProto) and looks it up in attributeMap, which is keyed by top-level output attribute name. A dotted struct.field never matches, so a nested residual is dropped there, one step before pageIndexUnsupportedColumns is consulted. So the top-level-only gate is sound. I added a comment documenting that invariant, and the new fuzz test below locks it in.

Binary. This one turned out to be the opposite of a missed optimization. iceberg-rust's page-index evaluator accepts BYTE_ARRAY but decodes the column-index min/max as UTF-8 (String::from_utf8(..).unwrap() in page_index_evaluator.rs), and that decode runs in calc_row_selection before the predicate closure, so even a bare IS NOT NULL triggers it. Binary was not in the gate, so a filtered binary column already pushed its implicit IS NOT NULL and panicked the native scan on non-UTF-8 bounds. I confirmed it with a failing test (a 0xFF-prefixed binary column, WHERE b IS NOT NULL), then added binary to pageIndexUnsupportedColumns, which turns it green. So rather than adding binary literal pushdown, the fix gates binary alongside the FIXED_LEN_BYTE_ARRAY types.

Fuzz coverage. Good catch that the existing test cannot see pushdown. I added filter pushdown - residual pool reflects whether the column type is pushable, which runs an equality per column and asserts the serialized residual pool is non-empty exactly when the type is pushable and empty for a gated type. The fuzz table is unpartitioned, so the residual is the full filter, which makes that a real signal. It anchors both the byte/short fix and the binary gate.

@mbutrovich
mbutrovich requested a review from andygrove July 21, 2026 23:33
# Conflicts:
#	native/core/src/execution/planner.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Comet throws java.lang.NegativeArraySizeException

3 participants