Skip to content

feat: Support Iceberg system functions (bucket, truncate, years/months/days/hours) natively - #5638

Merged
andygrove merged 19 commits into
apache:mainfrom
andygrove:feat/iceberg-system-functions
Sep 4, 2026
Merged

feat: Support Iceberg system functions (bucket, truncate, years/months/days/hours) natively#5638
andygrove merged 19 commits into
apache:mainfrom
andygrove:feat/iceberg-system-functions

Conversation

@andygrove

@andygrove andygrove commented Sep 2, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #5635.

Rationale for this change

Iceberg exposes its partition transforms as SQL functions: bucket, truncate, years, months, days and hours. Spark binds each of them as a StaticInvoke on a per-type class under org.apache.iceberg.spark.functions. None of those classes were in CometStaticInvoke's allowlist, so any plan that mentioned one of these functions fell back to Spark.

The case that matters most is a write to a partitioned table. With the default write.distribution-mode=hash, Iceberg asks Spark for a shuffle and a local sort keyed on the partition transforms. Both stayed on the JVM, so the native writer from #5361 declined its input for not being a CometNativeExec. The 5.5x number reported in #5361 was only reachable after setting write.distribution-mode=none. The same functions also turn up in row-level filters (#5259) and in transformed sort orders (#5339).

What changes are included in this PR?

New native kernels in native/spark-expr/src/iceberg_funcs/, registered as six scalar UDFs.

They reproduce Iceberg's Java implementations rather than approximate them. There are three places where the obvious approach gives a different answer:

Dictionary-encoded input is transformed once per distinct value and expanded through the keys.

On the Scala side, serde/icebergFunctions.scala keys its handlers on the fully qualified class name, because Iceberg is not on Comet's compile classpath. CometStaticInvoke falls through to that map and delegates getSupportLevel and getUnsupportedReasons to it. A numBuckets or width argument that is not a positive integer literal reports Unsupported, so bucket(0, x) still raises Iceberg's own ArithmeticException. Fallback reasons now name the declaring class, since every Iceberg function is called invoke.

truncate on a decimal stays with Spark. Truncating a negative decimal grows its magnitude, so the result can need one more digit than the column's precision allows: truncate(10, v) on a decimal(18,4) value of -99999999999999.9999 is -100000000000000.0000, which has 19 digits. Iceberg's TruncateDecimal.invoke hands that oversized Decimal back unchanged, and Spark turns it into null only when the row is materialized. An Arrow Decimal128(p, s) array has no encoding for it, so a native kernel would have to null it during evaluation, and that changes what an enclosing predicate or hash sees. Decimal truncate therefore reports Unsupported. Every other truncate input type, and bucket on decimals, runs natively.

How are these changes tested?

CometIcebergSystemFunctionSuite runs every function over every supported type with Comet on and off, so the reference values are Iceberg's own JVM classes evaluated by Spark. The corpus is seeded random data with nulls plus per-type boundary values: numeric extremes, decimal(38,10) extremes, the epoch and the microsecond before it, and surrogate pairs. The temporal comparisons are repeated under three session timezones. The suite also covers filters, a native sort, a native hash shuffle, the decimal fallback, and an end-to-end INSERT with the default distribution mode. That last test asserts a native shuffle, a CometIcebergWriteExec, a row round trip, and Iceberg's own partition count matching what the JVM transforms compute; it partitions on the multi-byte string column, so it also exercises the partition-path escaping that #5651 picked up.

iceberg_rust_transform_parity in iceberg_write.rs pins each kernel against iceberg-rust's create_transform_function. This matters because a partitioned write uses both: the sort key comes from these kernels and the partition values come from iceberg-rust. The clustered writer requires the two to agree, and when they do not the task fails at runtime with The input is not sorted!. The parity test turns a future iceberg-rust bump that changes semantics into a build failure instead. The inputs it excludes are the real divergences, documented against apache/iceberg-rust#3141 and #3142.

The Rust unit tests pin Appendix B's hash vectors, the minimal encoding produced by BigInteger.toByteArray(), and Java's wrapping edge cases. Expected values at the domain extremes were produced by running Iceberg's DateTimeUtil on a JDK 17 JVM. A separate test checks the integer calendar split against chrono everywhere chrono can represent the date.

Benchmarks

There are two, and they measure different things.

native/spark-expr/benches/iceberg_transforms.rs is a criterion benchmark of the kernels on their own: every transform over one array per supported type, with and without nulls, plus the dictionary-encoded string shape a Parquet scan produces for a partition column. This is the measurement that isolates the transform. It is what caught the dictionary path being slower than the plain path (bucket 72.9µs to 3.0µs, truncate 140.9µs to 35.4µs over 8192 rows with 8 distinct strings).

CometIcebergSystemFunctionBenchmark is the query-level comparison. With Comet off the transform runs through Iceberg's own JVM class, because Spark binds it as a StaticInvoke and codegen calls it once per row. Turning Comet on also replaces the Parquet scan and the projection with native operators, so each ratio below is a scan-plus-projection result and is not a measurement of the transform on its own.

Every case runs over a column with no nulls and a column with one null in eight. Before a case is timed, both engines run it over the same corpus and the rows are compared one by one; all 40 cases matched. The benchmark also warns if the Comet plan is not fully native, or if the optimizer folded the expression away; neither warning fired.

Apple M3 Max, JDK 17, Spark 4.1, release native build, 1,048,576 rows, best of five, shown as no-null / with-null:

bucket(int)       1.2X / 1.4X    truncate(int)       1.1X / 1.4X    years(date)   2.0X / 2.2X
bucket(long)      1.2X / 1.4X    truncate(long)      1.2X / 1.4X    years(ts)     2.5X / 2.5X
bucket(dec)       3.6X / 3.2X    truncate(str_dict)  1.6X / 1.8X    months(date)  2.0X / 2.4X
bucket(str_dict)  1.6X / 1.7X    truncate(str)       1.2X / 1.3X    months(ts)    2.6X / 2.5X
bucket(str)       1.3X / 1.3X    truncate(bin)       1.4X / 1.4X    days(date)    1.1X / 1.4X
bucket(bin)       1.4X / 1.3X                                       days(ts)      2.9X / 2.8X
bucket(date)      1.2X / 1.5X                                       hours(ts)     2.9X / 2.8X
bucket(ts)        1.6X / 1.7X

The executed plans and the full results file from that run are attached in a comment below.


Scaffolded with the implement-comet-expression and wire-datafusion-function skills; the upstream check found no datafusion-spark implementation of these transforms. Written with LLM assistance (Claude Code).

Implement Iceberg's `bucket`, `truncate`, `years`, `months`, `days`, and
`hours` system functions as native scalar functions and route the
`StaticInvoke` calls Spark binds for them through `CometStaticInvoke`,
keyed on the Iceberg implementation class names.

With these native, the hash distribution and local sort that Iceberg
requests in front of a partitioned write stay in Comet, so the native
Iceberg writer no longer declines a partitioned table that uses the
default `write.distribution-mode`. Filters, projections, and sort keys
over hidden-partitioning expressions stay native as well.

The kernels match Iceberg's Java implementations exactly: the spec's
byte encodings hashed with standard 32-bit Murmur3, Java's wrapping
integer arithmetic for truncate, code-point counting for strings, and
UTC-only calendar math for the temporal transforms. A truncated decimal
that no longer fits its precision becomes null, as it does in Spark.

Also name the declaring class in the fallback reason for an unlisted
static invoke, since every Iceberg system function is called `invoke`.

Closes apache#5635
@andygrove

Copy link
Copy Markdown
Member Author

@jordepic could you help with reviews?

@andygrove
andygrove requested a review from comphead September 2, 2026 21:37
Native: hash tinyint/smallint directly instead of casting first, fold the
string/binary bucket arms into one generic helper, compute the minimal
two's complement length with leading_ones/leading_zeros, fuse the
years/months passes into a single kernel, reuse is_valid_decimal_precision
for the decimal overflow check, and share the string/binary truncation
helpers with a corrected note on which Arrow kernel overflows.

Serde: key CometStaticInvoke's single map by (functionName, class name)
so the Iceberg handlers join it instead of a second lookup, and use type
sets for the temporal predicates.

Tests: move capturePlans into CometIcebergTestBase, write the source
parquet once per suite, batch the per-type comparisons into one query
per column, and declare boundary values column-wise.
@jordepic

jordepic commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Yep! I'm gone the rest of today but it's on my list for tomorrow

let truncated: Decimal128Array =
array.as_primitive::<Decimal128Type>().unary_opt(|v| {
let truncated = truncate_i128(v, width as i128);
is_valid_decimal_precision(truncated, *precision).then_some(truncated)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this precision check is happening too early relative to Spark's semantics.

Iceberg's JVM TruncateDecimal.invoke returns Decimal.apply(truncatedValue) without coercing it back to the declared DecimalType. Spark's StaticInvoke therefore exposes a non-null Decimal to its parent expression; changePrecision(precision, scale) only happens later when an UnsafeRowWriter materializes the value.

Here unary_opt turns that intermediate into NULL during expression evaluation itself. That changes nested/predicate semantics. For example, with DECIMAL(18,4) value -99999999999999.9999, truncate(10, v) mathematically produces -100000000000000.0000 (19 digits). On the JVM the StaticInvoke result is still non-null, whereas this branch returns NULL, so WHERE truncate(10, v) IS NULL can silently select a row that Spark does not.

Could we either preserve the JVM intermediate semantics or fall back when this overflow case occurs?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One data point for scoping the fix: on the write path the null sort key is actually consistent with Spark. Spark's SortExec builds its key through an UnsafeRowWriter too, so the JVM sort sees null for the same rows. The divergence you describe is confined to filters and projections, where codegen evaluates invoke without materializing the row. It may be worth keeping the fix scoped to those paths so the sort key stays aligned with what Spark's own sort produces.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

LLM-assisted: this reply and the changes it describes were written with Claude Code.

You're right about the intermediate: TruncateDecimal.invoke returns Decimal.apply(...) with no changePrecision, so the StaticInvoke result is non-null and only UnsafeRowWriter nulls it.

I don't think the kernel can reproduce that, though. It has to return a Decimal128(precision, scale) array, and that array has no encoding for "exceeds the declared precision but isn't null yet" — whatever it holds is what both the plan output and any parent expression see. Emitting the oversized value would make the common case wrong: SELECT truncate(10, dec18) is null in Spark, and the corpus in the new suite already contains -99999999999999.9999, so checkSparkAnswerAndOperator fails on it. Nulling eagerly is also what Spark does for decimal overflow elsewhere, via CheckOverflow(..., nullOnOverflow = true); Iceberg's invoke simply isn't wrapped in one.

So it's a choice of which side to be right on, and I've taken the materialized side. As @jordepic notes, SortExec builds its key through an UnsafeRowWriter too, so the sort agrees, and so does the projection output. What differs is a truncated decimal feeding another expression directly: WHERE truncate(10, v) IS NULL, and the Murmur3Hash behind DISTRIBUTE BY (that one only moves rows between partitions, it doesn't change an answer). The Iceberg partition value isn't affected either way — the writer derives it from the untruncated column.

I've written the difference down rather than leaving it implicit: 3dc1a2d in the Iceberg user guide, plus the kernel comment. The alternative is to fall back for decimal truncate entirely, which costs native decimal partitioning for a case that needs a value within width - 1 ULP of the negative precision boundary. Would you rather have that?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I need to correct what I said above, and @sunchao is right to push on it. SortExec does not build its key through an UnsafeRowWriter. It creates the comparator with RowOrdering.create, which generates code that evaluates the sort expression on each input row and compares the raw result, and the sort prefix for a decimal(18,4) is toUnscaledLong of that same value. So Spark's sort sees the oversized decimal and Comet's sort sees null. The sentence about the sort key in the new iceberg.md paragraph and in the truncate.rs comment came from my mistake and should be dropped.

The practical effect on the write path is still nil, for a different reason: for a given precision and width exactly one truncated value can exceed the precision (the multiples of the width that land below the negative bound form a window narrower than the width), and it is the column minimum, so it sorts first under either key and the clustered writer sees a single run. The difference is confined to ordering position under a non-default null ordering, and to predicates and hashes as you described.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

LLM-assisted: this reply and the changes it describes were written with Claude Code.

Thanks for catching it. I'd taken the UnsafeRowWriter claim from your earlier note and propagated it into iceberg.md and the kernel comment without checking, so that one's on me too. Confirmed: SortExec orders the child's rows with an ordering from RowOrdering.create, which evaluates the sort expression per comparison, and the decimal(18,4) prefix is toUnscaledLong of that same value — no UnsafeRowWriter in the path. Both places are corrected in 0a57b6a, and it is moot anyway now that decimals fall back.

}

fn civil_date(days: i32) -> Result<chrono::NaiveDate> {
Date32Type::to_naive_date_opt(days).ok_or_else(|| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This introduces a narrower date domain than the Iceberg JVM implementation.

Date32Type::to_naive_date_opt ultimately uses chrono's date representation, whose range is only about 262k years in either direction. Spark DateType, however, carries an i32 epoch-day value, and Iceberg's DateTimeUtil.daysToYears / daysToMonths uses Java LocalDate; that supports the complete i32 epoch-day domain.

For example, epoch day 100000000 is valid for the JVM implementation but falls outside chrono's range, so native years / months will return an execution error where Spark/Iceberg succeeds. The timestamp versions have the same issue near the extremes of the i64 microsecond domain.

Could we compute the proleptic-Gregorian year/month directly from the epoch-day integer (or fall back outside chrono's range) and add a reference test with epoch days beyond chrono's limits?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

LLM-assisted: this reply and the changes it describes were written with Claude Code.

Good catch, fixed in 71e03be. years and months no longer touch chrono: civil_from_days does the proleptic Gregorian split in i64 integer arithmetic, exact over the whole i32 epoch-day domain — i32::MAX days is +5881580-07-11 and i32::MIN is -5877641-06-23, both well inside LocalDate. The kernels are infallible now as a result.

The expected values come from running Iceberg's own DateTimeUtil.convertDays / convertMicros on a JDK 17 JVM, and the tables pin epoch days ±1e8, ±1e9 and both i32 extremes, plus i64::MIN / i64::MAX micros. There is also a test checking the integer split against chrono for every day in a 400-year window around the epoch and on a stride over the rest, so it cannot drift in between.

On the timestamp side you're right that the same concern applies, though it lands differently per function. days was already safe: i64::MAX micros is 1.07e8 days, comfortably inside an i32. years and months went through chrono after that conversion and had exactly the problem you describe. hours is the one that genuinely overflows — i64::MAX micros is 2.56e9 hours — and Iceberg narrows it with a plain (int) cast, so as i32 wraps to the same value; that is now pinned rather than incidental.

@sunchao sunchao 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.

The native transform integration is useful, but the existing decimal-intermediate P2 and calendar-range P2 remain. I independently confirmed both with exact-version component probes and maintained Spark source tracing. There is an additional test-fixture issue inline.

Please also add a reusable expression microbenchmark and matched Comet-versus-Iceberg-JVM results for representative numeric/decimal, string/binary, and temporal inputs, including relevant dictionary/null cases. Include versions, settings, evaluated plans, and correctness checks. I found no benchmark of these new functions in the change or discussion. The 5.5x result from #5361 used write.distribution-mode=none and does not measure these transforms. Dictionary expansion and repeated scalar/array work are costs to measure, not demonstrated regressions.

The latest CI snapshot has 34 successful, 7 skipped, 26 running and 4 failed checks. The Spark 3.4/3.5 scan jobs abort during this suite's fixture setup, as detailed inline. The Spark 4.0 and Spark 4.1 jobs each fail the new partitioned-write end-to-end test because an expected Parquet file is missing. I inspected those logs but have not established the cause of that missing file, so that test is not yet passing end-to-end evidence.

My own validation was source tracing and focused Arrow/Chrono/Iceberg/Guava component probes. It did not execute a full Spark query, Comet suite, JNI/native writer or performance benchmark.

maybeNull(randomDecimal38()),
maybeNull(randomString()),
maybeNull(randomBinary()),
maybeNull(LocalDate.ofEpochDay(random.nextInt(40000) - 20000)),

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.

[P2] Match the fixture's date/time objects to Spark's encoder

These rows contain java.time.LocalDate and Instant, but the Spark 3.4/3.5 CI runs encode the date field as java.sql.Date. beforeAll writes this DataFrame before any tests run, so the entire new suite aborts with LocalDate cannot be cast to java.sql.Date in both the 3.4 job and 3.5 job. The suite's sparkConf does not select the Java 8 date/time API. Could you enable spark.sql.datetime.java8API.enabled before constructing the fixture, or emit java.sql.Date/Timestamp when that setting is false? Until this setup succeeds, those profiles do not execute the new transform comparisons.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

LLM-assisted: this reply and the changes it describes were written with Claude Code.

Fixed in 7de2708, and all six [scans] jobs are green on the current head, 3.4 and 3.5 included.

The write is now wrapped in withSQLConf with datetimeJava8ApiEnabled set, along with two other Spark 3.x defaults the corpus trips over: datetimeRebaseModeInWrite (the timestamps reach back to 1843 and 3.x refuses to write before 1900) and outputTimestampType (3.x defaults to INT96, which has its own ancient-timestamp check). The row encoder is built on the driver inside that block, so setting the conf around the write is enough, and none of it changes a comparison since every test reads the data back from Parquet.

On the 4.0/4.1 failures: those were fixed by f8db9b5, where the 4.0, 4.1 and 4.2 [scans] jobs all passed and the only two failures left were the 3.4/3.5 ones above. I should be straight about the cause, though. I could not retrieve those job logs, so I do not have a confirmed root cause either. That commit narrowed the test in two ways — it stopped using the multi-byte string column as a partition source and it excluded Long.MinValue — and I cannot reproduce a failure locally with either one restored (Spark 4.1, macOS, including with sun.jnu.encoding forced to US-ASCII), so the charset explanation the comment gave was a hypothesis I have not demonstrated. I have replaced it with what is actually known (f4e8e45). What I can say is that the test is green on 4.0/4.1/4.2, that the string transforms are still covered by the comparison, filter, sort and shuffle tests, and that truncate at Long.MinValue is pinned against the JVM in the Rust unit tests.

…d write test

The CI containers run the JVM with an ASCII platform charset, so raw
multi-byte partition directory names written by the pinned iceberg-rust
cannot be reopened by iceberg-java for the metrics rebuild; partition on
the long column instead. iceberg-rust's truncate transform also overflows
on Long.MinValue in debug builds, so that boundary row is not written.

@sunchao sunchao 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.

Rechecked f8db9b5f. No new findings beyond the existing [P2] threads for decimal intermediates, calendar range, and fixture encoding. All three remain unchanged.

This update narrows only the partitioned-write test. It does not establish the cause of the earlier missing-file failures. Current-head CI has 15 successful, 6 running and 7 skipped checks at the latest snapshot. This re-review checked the exact source increment and unchanged prior evidence. I have not rerun the Spark/native test.

@andygrove

Copy link
Copy Markdown
Member Author

cc @anuragmantri

@jordepic jordepic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for putting this together. The class-name keyed dispatch in CometStaticInvoke is a clean way to handle Iceberg not being on the compile classpath, and the fallback message naming the declaring class is a real usability improvement given that every Iceberg function is called invoke. I traced the kernels against DateTimeUtil, TruncateUtil, and BucketFunction.bind in iceberg-java and they match where I looked, including the floor semantics for negative dates and the int-then-narrow behavior for tinyint and smallint.

My main question is about the relationship with iceberg-rust. The native writer already computes partition values through iceberg::transform::create_transform_function inside PartitionValueCalculator, on every batch. After this change the sort key feeding the clustered writer comes from these new kernels and the partition key the writer groups by comes from iceberg-rust's kernels. The clustered writer requires the two to agree, and when they don't the task fails with The input is not sorted! Cannot write to partition that was previously closed, so any drift between the implementations surfaces as a runtime failure on the write path rather than at plan time.

Comparing against the pinned iceberg-rust rev, bucket, hours, and days are functionally identical there. iceberg-rust's bucket uses the murmur3 crate (x86 32-bit, seed 0, the same as Guava's murmur3_32_fixed) with the same minimal two's-complement decimal encoding, and that crate is already in native/Cargo.lock. The only gaps are Int8/Int16 inputs and dictionary unpacking, both of which this PR already handles in a thin wrapper. Could those three call iceberg-rust's transforms directly? That would remove most of bucket.rs including the hand-rolled murmur3_32. truncate is the one function where iceberg-rust genuinely diverges from Java (rem_euclid for i32, unchecked arithmetic for i64), so keeping a local kernel there makes sense.

If you would rather keep all the local kernels, it would be worth adding a Rust test that asserts each SparkIceberg* UDF matches create_transform_function over the same boundary inputs. Without that, a future iceberg-rust bump can silently desynchronize the sort key from the writer.

One small addition to @sunchao's benchmark request: the dictionary unpack via cast in apply_unary allocates a full copy of the values before hashing. String partition columns coming out of the Parquet scan are usually dictionary-encoded, so a benchmark of bucket and truncate over a dictionary string column would tell us whether hashing the dictionary values once and taking through the keys is worth doing now.

//! `hours` are plain floor division of the epoch value. `days` returns a date (Iceberg's
//! `DaysFunction.resultType()` is `DateType`), the other three return an int.
//!
//! The kernels work on the raw epoch values rather than going through Arrow's timezone-aware

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In Comet, TimestampType arrays are always tagged UTC (native/core/src/execution/serde.rs), and the writer casts every batch to iceberg-rust's Arrow schema, which tags Timestamptz as +00:00. So date_part in iceberg-rust's Year and Month transforms would also evaluate in UTC on both the sort path and the write path. Is there a plan shape where a non-UTC tag reaches these kernels? If not, it would be good for this doc to say the concern is defensive rather than observed, or these two could reuse iceberg-rust's kernels as well.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

LLM-assisted: this reply and the changes it describes were written with Claude Code.

Agreed on both counts, and the module docs now say so (71e03be): Comet tags TimestampType UTC and the writer casts to a schema that tags Timestamptz +00:00, so date_part would agree today; the epoch arithmetic is correct for any tag. I know of no plan shape where a different tag reaches these kernels.

I kept the local kernels rather than delegating, and added a test that makes the reason executable: iceberg_rust_years_follow_the_timezone_tag in iceberg_write.rs shows Transform::Year returning 0 for -1 micros tagged Asia/Kathmandu where Iceberg's Java DateTimeUtil and Comet both return -1. If that test ever fails, iceberg-rust has dropped the tag dependency and delegating becomes safe.

CREATE TABLE $table (i32 INT, i64 BIGINT, ts TIMESTAMP, dt DATE)
USING iceberg
PARTITIONED BY (bucket(4, i32), truncate(1000, i64), days(ts), months(dt))""")
// iceberg-rust's own truncate transform, which the writer uses for partition values, does

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This works around unchecked arithmetic in iceberg-rust's truncate_i64, which also affects the native writer's partition values in debug builds, independent of this PR. Could you file that upstream in iceberg-rust and reference the issue here instead of describing the workaround inline? Otherwise the filter reads as a test-data choice and the writer-side bug gets lost.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

LLM-assisted: this reply and the changes it describes were written with Claude Code.

Fair — the writer-side bug should not be buried in a test comment. I will file it against iceberg-rust separately and link it here rather than hold this PR on it. It is a bit wider than truncate_i64: truncate_i32 uses rem_euclid, which diverges from Java for widths above 2^30 as well as overflowing at Integer.MIN_VALUE, and the decimal kernel has the same unchecked subtraction. In the meantime the new iceberg_rust_transform_parity module in iceberg_write.rs records the excluded inputs in one place, with the reason, instead of only here.

…faults

CometIcebergSystemFunctionSuite aborted in beforeAll on Spark 3.4 and 3.5.
Three Spark 3.x defaults differ from Spark 4's and each blocks writing the
corpus:

- datetimeJava8ApiEnabled is off, so the row encoder rejects the java.time
  values sourceData supplies (ClassCastException: LocalDate -> java.sql.Date)
- datetimeRebaseModeInWrite is EXCEPTION, which rejects the deliberately
  pre-epoch timestamps (the corpus reaches back to 1843)
- outputTimestampType is INT96, which has its own ancient-timestamp check

Set all three to Spark 4's values around the write only. Every test reads the
data back from parquet, so no result comparison is affected.
@jordepic

jordepic commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

I was mainly looking at this one and cross-referencing the iceberg-rust source code and the iceberg source code, in the event that other code reviews hadn't. I think I mainly would love to avoid code duplication where possible!

I may also just ask that you rewrite the PR description by hand, since understanding claudisms is really challenging for me from an english perspective.

`years` and `months` split the calendar with `chrono::NaiveDate`, whose range stops
at about year 262143. A Spark `DateType` is an `i32` epoch day (up to year 5881580)
and Iceberg's `DateTimeUtil` goes through `LocalDate`, which covers all of it, so
`years(DATE)` on a far-future date raised an execution error where the JVM returns a
value.

Replace the `chrono` round trip with Howard Hinnant's `civil_from_days` in `i64`
arithmetic, which is exact over the whole `i32` epoch-day domain and drops the
fallible path from the kernels. The pinned expectations for the extremes come from
running `DateTimeUtil.convertDays` / `convertMicros` on a JVM, and a new test checks
the integer split against `chrono` everywhere `chrono` can represent the date.
`apply_unary` unpacked a dictionary with `cast` before handing it to the kernel, so
a dictionary-encoded string column -- what a Parquet scan hands back for a low
cardinality partition column -- paid for a full copy of the values buffer and then
hashed or truncated every row instead of every distinct value.

Run the kernel over the dictionary's values and expand the result through the keys
instead. Over 8192 rows with eight distinct strings (`cargo bench --bench
iceberg_transforms`):

  iceberg_bucket/string_dict     72.9 us -> 3.0 us
  iceberg_truncate/string_dict  140.9 us -> 35.4 us

The decimal comment in `truncate` also now spells out which paths agree with the
JVM result and which do not.
A partitioned write sorts on Comet's kernels and then groups by the partition values
iceberg-rust computes, and the clustered writer fails at runtime when the two
disagree. Assert they agree over the boundary inputs of every shared type, so an
iceberg-rust bump that changes a transform breaks here first.

The excluded cases are the interesting ones: iceberg-rust's `truncate` does not wrap
like Java's, and its `years` / `months` go through Arrow's `date_part`, which honours
the array's timezone tag. The last test pins that tag dependency, since it is the
reason `years` and `months` keep local kernels while `bucket`, `days`, and `hours`
could in principle be delegated.
Iceberg's `TruncateDecimal.invoke` returns a `Decimal` that can exceed the column's
precision and Spark nulls it only when the row is materialized, while Comet nulls it
in the kernel. Say so, and say which paths that leaves in agreement.
…erg's JVM

`native/spark-expr/benches/iceberg_transforms.rs` covers every transform over every
supported type, with and without nulls, plus the dictionary-encoded string shape a
Parquet scan produces.

`CometIcebergSystemFunctionBenchmark` runs the same queries with Comet on and off.
The Comet-off case is Iceberg's own JVM implementation, since Spark binds each
function as a `StaticInvoke` of the class under `org.apache.iceberg.spark.functions`.
The data stays in Parquet rather than an Iceberg table so both cases scan
identically and only the transform differs.
`HashPartitioning` hashes the `StaticInvoke` result directly rather than through an
`UnsafeRowWriter`, so the shuffle hash is on the differing side, not the agreeing
one. The Iceberg partition value does not come from this kernel at all.
…usion

The multi-byte string column is out of the write test's partition spec because the
test failed on the Linux CI runners with it in, not because the platform-charset
mechanism the comment claimed has been demonstrated -- it does not reproduce
locally, including with sun.jnu.encoding forced to US-ASCII.

@sunchao sunchao 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.

Re-reviewed f4e8e454 against 10537e14. The calendar-range P2 is fixed in source. The replacement arithmetic matched 317,133 sampled date/timestamp inputs against each of Iceberg Java 1.8.1, 1.10.0 and 1.11.0 in isolated scalar checks. The earlier explicit fixture settings also address the reported setup mismatch in source, though the Spark suite was not rerun.

The existing decimal-intermediate P2 remains. Documenting early nulling does not preserve results for enclosing predicates or hashes, and decimal inputs are still enabled as Compatible. Could unsafe decimal cases fall back until the intermediate semantics are preserved? Please also narrow the new sort claim: SortExec materializes a Long prefix, not necessarily the decimal expression result. An embedded transform in a sort expression differs from sorting an already-materialized decimal column.

The dictionary optimization, writer comparison tests, and two benchmark entry points address the earlier follow-ups. The author-reported native dictionary timings are useful. The existing benchmark request still needs matched Comet/Iceberg-JVM results with versions/settings, executed plans and fallback status, correctness checks, and comparable dictionary/null inputs. The writer comparisons explicitly omit known divergent boundaries, so they do not establish complete writer alignment.

No new P1/P2 or duplicate inline findings. Validation was source tracing, revalidation of retained evidence, and fresh scalar calendar checks. It did not execute the new Arrow/UDF tests, Spark queries, sort/writer paths, or benchmarks. At the final check, CI had 18 successful checks, six skipped and seven still running.

@andygrove

Copy link
Copy Markdown
Member Author

LLM-assisted: this reply and the changes it describes were written with Claude Code.

@jordepic thanks for tracing it against iceberg-java, and for the framing about the sort key and the partition values coming from two different implementations — that is the sharpest statement of the risk here.

I went with the test rather than the delegation, for two reasons. The wrapper would not get much thinner: the Int8/Int16 arm and the dictionary handling stay either way and days still needs its Date32 identity case, so what actually disappears is murmur3_32 and the two hash_* helpers — in exchange for coupling the semantics to a pinned git rev when Appendix B already fixes them. And years/months cannot be delegated at all, since date_part honours the timezone tag.

So there is now an iceberg_rust_transform_parity module in iceberg_write.rs (8d898c2) asserting each SparkIceberg* UDF matches create_transform_function over the boundary inputs of every type both sides accept: bucket over int/long/date/timestamp (tagged and untagged)/decimal/string/binary at four bucket counts, truncate at five widths, days/hours over the epoch boundaries, and years/months where iceberg-rust can represent the date. The exclusions are the divergences themselves and are documented there.

On the dictionary unpack — you were right that it was worth measuring, and worth doing. apply_unary now runs the kernel over the dictionary's values and expands through the keys (1622503). Over 8192 rows with eight distinct strings:

case before after
iceberg_bucket/string_dict 72.9 µs 3.0 µs
iceberg_truncate/string_dict 140.9 µs 35.4 µs

For reference the plain string column is 31.2 µs for bucket and 99.1 µs for truncate, so the dictionary shape went from 2.3x / 1.4x slower than plain to faster than it, which is what you would expect once the hash runs once per distinct value.

@andygrove

Copy link
Copy Markdown
Member Author

LLM-assisted: this reply and the changes it describes were written with Claude Code.

@sunchao benchmarks added, both kinds.

native/spark-expr/benches/iceberg_transforms.rs (fa0158d) is the reusable microbenchmark: every transform over every supported type, with and without nulls, plus the dictionary-encoded string shape. CometIcebergSystemFunctionBenchmark is the matched Comet-versus-Iceberg-JVM comparison — the Comet-off arm is Iceberg's own implementation, since Spark binds each function as a StaticInvoke of the class under org.apache.iceberg.spark.functions and codegen calls it per row. The data stays in Parquet rather than an Iceberg table so both arms scan identically and only the transform differs, and runExpressionBenchmark warns loudly if the Comet plan is not fully native or if the optimizer folded the expression away; neither warning fired for any case.

Apple M3 Max, JDK 17, Spark 4.1, release native build, 1,048,576 rows, best of five:

                     Spark (Iceberg JVM)   Comet   Relative
bucket(int)                        31 ms   22 ms      1.4X
bucket(long)                       28 ms   21 ms      1.3X
bucket(decimal(38,10))            110 ms   30 ms      3.7X
bucket(string, dictionary)         33 ms   19 ms      1.7X
bucket(string)                    101 ms   73 ms      1.4X
bucket(binary)                     71 ms   50 ms      1.4X
bucket(date)                       25 ms   21 ms      1.2X
bucket(timestamp)                  34 ms   22 ms      1.6X
truncate(int)                      23 ms   19 ms      1.2X
truncate(long)                     24 ms   19 ms      1.2X
truncate(decimal(38,10))          134 ms   72 ms      1.9X
truncate(string, dictionary)       42 ms   25 ms      1.7X
truncate(string)                   95 ms   74 ms      1.3X
truncate(binary)                   60 ms   47 ms      1.3X
years(date)                        38 ms   18 ms      2.0X
years(timestamp)                   53 ms   20 ms      2.7X
months(date)                       36 ms   18 ms      2.0X
months(timestamp)                  52 ms   20 ms      2.6X
days(date)                         21 ms   18 ms      1.2X
days(timestamp)                    59 ms   19 ms      3.0X
hours(timestamp)                   60 ms   19 ms      3.1X

No case regresses. Dictionary expansion was a regression before this round — @jordepic called it — and is fixed in 1622503; numbers on that thread. Correctness is unchanged: the same queries run under checkSparkAnswerAndOperator in the suite, so every one of these is compared against the Iceberg JVM result as well as timed against it.

On the two P2s: the calendar-range one is fixed in 71e03be, with the reference values taken from running Iceberg's DateTimeUtil on a JVM. The decimal-intermediate one I have answered on its thread — short version, a Decimal128(p, s) array has no encoding for the JVM intermediate, so the kernel has to pick a side; I picked the side Spark materializes and documented the difference in the user guide.

On CI: all six [scans] jobs pass on the current head, 3.4 and 3.5 included. I have replied on the fixture thread about the 4.0/4.1 missing file — I could not fetch those logs either, so I cannot give you a confirmed cause, only that the test has been green on 4.0/4.1/4.2 since f8db9b5.

@andygrove

Copy link
Copy Markdown
Member Author

LLM-assisted: this reply and the changes it describes were written with Claude Code.

@jordepic filed upstream as promised:

Two things came out of writing them up that are worth correcting here, since both change what I said earlier on those threads.

The truncate_i64 overflow you and I were both treating as the headline is debug-only. In a release build Rust wraps and matches Java bit for bit, and both Comet CI and make release build --release, so it never affected a real write — only a debug or test build of a downstream writer. The genuinely broken one is truncate_i32, which uses rem_euclid and so diverges from Java semantically in release builds too, for any width above 2^30 with a positive value. Verified against TruncateUtil on JDK 17:

width value Java iceberg-rust
2000000000 1500000000 -2000000000 0
2147483647 2147483646 -2147483647 0

Iceberg Java only validates width > 0, so such a spec is legal. That is a wrong partition value, not a panic.

The timezone one turned out to be more than the Comet-facing concern I described on the temporal.rs thread: iceberg-rust's Year/Month transform and transform_literal disagree with each other for a non-UTC-tagged array, because the literal path uses DateTime::from_timestamp_micros (UTC) while the array path uses date_part. So predicate projection and partition-value computation can diverge within iceberg-rust itself. Latent today since its own callers always pass +00:00-tagged arrays, but it does firm up the case for Comet keeping local kernels for those two.

I have updated the test comment in iceberg_write.rs and the one in CometIcebergSystemFunctionSuite to point at the two issues instead of describing the workaround inline, which was your original ask.

The truncate exclusions in the parity tests and the Long.MinValue filter in the
write test now point at apache/iceberg-rust#3141, and the timezone-tag tests at
apache/iceberg-rust#3142, instead of describing the workaround inline.

@sunchao sunchao 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.

Rechecked ada706be against 10537e14. This increment changes tracking comments only. Thanks for filing iceberg-rust #3141 and #3142. Those links address the upstream-tracking request.

On the remaining decimal P2: yes, I prefer fallback where the JVM intermediate semantics cannot be preserved. Returning null early can change which rows a predicate selects, even if the boundary is rare. If safe cases cannot be distinguished, falling back for decimal truncate is preferable to reporting the changed behavior as Compatible. This costs native evaluation, but avoids introducing context-dependent nulling rules. The earlier clarification about an embedded sort expression versus a materialized decimal column still applies.

The 21-case matched benchmark table addresses the missing-numbers part of the request. I am treating the reported 1.2x–3.7x gains as query-level, author-measured results. The runner changes Comet execution for the scan and projection, so common Parquet input alone does not isolate transform cost. Could you attach the executed plans, verify equal outputs on the timed corpus, and include comparable null cases? Related suite coverage is useful, but is not the same corpus and expression-parameter coverage as this benchmark.

No new P1/P2 findings. This pass revalidated source and retained evidence, without rerunning tests or benchmarks. Current-head CI has 16 successful checks, six skipped and six still running.

@jordepic jordepic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Went through the new commits. The civil_from_days arithmetic matches the JVM at both i32 extremes, the hours narrowing matches Java's (int) cast, the dictionary path is correct including null keys, and the parity module plus the two upstream issues are exactly what I was after. Three follow-ups inline, one of which is a correction of something I said earlier.

} else if (!valueTypeSupported(value.dataType)) {
Unsupported(Some(s"$nativeName does not support input type ${value.dataType}"))
} else {
Compatible()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

On the decimal truncate question @sunchao and you are going back and forth on: Comet already has the mechanism that gives both of you what you want. If getSupportLevel returns Incompatible(Some(...)) for a DecimalType value instead of Compatible(), the expression falls back by default and a user who accepts the documented difference can opt in with the per-expression allowIncompatible config. That is how every other expression with a known divergence from Spark is handled here, and it keeps the native path available for partitioned writes on tables where the operator has read the caveat. It would also need getIncompatibleReasons so the generated compatibility page carries the note, rather than only the Iceberg guide. That seems like a better answer to your "would you rather have that?" than a hard fallback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

LLM-assisted: this reply and the changes it describes were written with Claude Code.

Good call on reaching for the existing mechanism. I went one step past it and marked decimal truncate Unsupported rather than Incompatible, for two reasons.

The wiring @sunchao flagged is real: getExprConfigName resolves against the outer expression class, so the opt-in would have been spark.comet.expression.StaticInvoke.allowIncompatible, which also unlocks every future incompatible static invoke. Fixing that properly means changing what spark.comet.expression.StaticInvoke.enabled covers for readSidePadding, aesEncrypt, decode and base64, and I would rather not smuggle that into this PR. Unsupported needs no key, so the question disappears.

The docs half of your point I did take. CometStaticInvoke.getUnsupportedReasons() now aggregates the per-function handlers' notes, since GenerateDocs only asks the serde registered for the expression class — without it the note reached the Iceberg guide but not the generated compatibility page. There is a test asserting it is reachable that way so it cannot silently drop out.

If you would rather have the opt-in after all, Incompatible is a one-line change once the config-name question is settled on its own.

// No `write.distribution-mode`: Iceberg picks hash distribution for a partitioned table,
// which plans a shuffle and a local sort on the partition transforms. The string column is
// deliberately not a partition source: with it in the spec this test failed on the Linux
// CI runners with a missing data file, which does not reproduce locally, so the multi-byte

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the missing-file failure does have an explanation, and it is the partition-path caveat already documented in iceberg-writes.md. iceberg-java percent-encodes partition directory names and values through URLEncoder in PartitionSpec.partitionToPath, and iceberg-rust writes them raw. The Linux CI runners run the JVM under a POSIX locale, so sun.jnu.encoding is ASCII and opening a local path containing 日本 or 😀 fails, which reads back as a missing data file. On macOS that encoding is fixed to UTF-8 by the launcher and a -Dsun.jnu.encoding override is ignored, which would be why it did not reproduce locally even when forced. Running the original version of this test under LANG=C on Linux should confirm it. If it does, this is a native-writer divergence with an operational effect rather than a test artifact, and worth filing against iceberg-rust's partition_to_path the same way you filed the other two.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

LLM-assisted: this reply and the changes it describes were written with Claude Code.

Your mechanism is right, and it is already fixed upstream — which is the part I had wrong when I said I had no cause.

iceberg-java escapes both field name and value through URLEncoder.encode in PartitionSpec.partitionToPath; iceberg-rust at our pinned rev formats {name}={human_string} raw. apache/iceberg-rust#2875 changed that to form_urlencoded::Serializer::append_pair on both sides and merged 2026-07-30 — one day after the rev we pin, 3d84c81 (2026-07-29). So the caveat in iceberg-writes.md is accurate for our pin and stale against upstream main.

Three things support the locale half without my having read the logs. [scans] runs in the amd64/rust container, which is Debian with no LANG, so sun.jnu.encoding resolves to ASCII. The failing set was exactly {4.0, 4.1}, which is exactly the set of jobs that reached the test — 3.4 and 3.5 aborted in beforeAll on the LocalDate encoder, and 4.2 skips the suite because icebergAvailable is false for isSpark42Plus. And @sunchao reproduced the filesystem half on Linux/JDK 21. I would still call the historical failure inferred rather than confirmed, per his point.

#5651 bumps the pin, so I am not doing it here. Once it lands the string column can go back into the write test's partition spec, and that run is what would actually confirm this.

@sunchao sunchao 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.

I agree with the decimal-specific Incompatible(Some(reason)) approach in the new discussion. It uses the existing default-fallback/explicit-opt-in mechanism without disabling other truncate types or decimal bucket. The existing P2 remains until that behavior is implemented.

One wiring detail: with the current registration, the outer CometStaticInvoke supplies the configuration name, so the opt-in would be spark.comet.expression.StaticInvoke.allowIncompatible. The generated documentation also queries that registered wrapper. A getIncompatibleReasons override only on the inner truncate handler would not surface the note unless the wrapper exposes or aggregates it. Keep the existing invalid-parameter and unsupported-type checks.

On the Unicode-path hypothesis, I independently reproduced the proposed filesystem mechanism in a standalone Linux/OpenJDK 21 probe. With LANG=LC_ALL=C, an existing raw 日本-😀 filename failed through FileInputStream and NIO, while the percent-encoded ASCII control opened. Under C.UTF-8, both opened. Adding -Dsun.jnu.encoding=UTF-8 under the C locale did not change the result on this installation.

That is component evidence, not confirmation of the original JDK 17 CI/Hadoop/Comet failure. Please capture the failing JVM's effective locale/path encoding and exact path/stack, with a UTF-8 control, before treating the historical cause as established. No new P1/P2 findings or full Spark/native run in this pass. Current-head CI has 57 successful checks, six skipped and eight still running.

Iceberg's `TruncateDecimal.invoke` returns a `Decimal` that can exceed the column's
declared precision, and Spark turns it into null only when the row is materialized.
An Arrow `Decimal128(precision, scale)` array has no encoding for that intermediate,
so the kernel nulled it during evaluation, which changes what an enclosing predicate
or hash sees. Report decimal inputs as `Unsupported` so they stay with Spark.

`CometStaticInvoke.getUnsupportedReasons()` now aggregates the per-function handlers'
notes, since `GenerateDocs` only asks the serde registered for the expression class
and the note would otherwise reach the Iceberg guide but not the compatibility page.

Also drops the claim that Comet and Spark agree on the sort key. `SortExec` orders
rows with `RowOrdering.create`, which evaluates the sort expression per comparison
rather than through an `UnsafeRowWriter`, so Spark's sort sees the oversized decimal.
That was wrong in `iceberg.md` and in the kernel comment.
…benchmark

Every case now runs over a no-null column and a column with one null in eight, and
each case's output is compared row by row between the two engines over the same
corpus that is then timed, before it is timed. `truncate` on a decimal is dropped
since it no longer has a native path.

`excludedRulesWith` becomes `protected` so the verification can use the same
constant-folding exclusion the timed cases use.
@andygrove

Copy link
Copy Markdown
Member Author

LLM-assisted: this reply and the changes it describes were written with Claude Code.

@sunchao the decimal P2 is resolved rather than mitigated: decimal truncate now reports Unsupported (0a57b6a), so it falls back to Spark and there is no early nulling left to document. @jordepic's Incompatible route would also have worked; I chose Unsupported because the config-name problem you identified is real, and fixing it properly changes what spark.comet.expression.StaticInvoke.enabled covers for four unrelated static invokes. Unsupported needs no key. I did take your docs point — CometStaticInvoke.getUnsupportedReasons() now aggregates the per-function notes so the generated compatibility page carries it, with a test pinning that.

The sort claim is dropped rather than narrowed. @jordepic corrected himself and you were right to push: RowOrdering.create evaluates the sort expression per comparison, so there is no UnsafeRowWriter in the sort path at all.

Benchmark: every case now runs over a no-null column and a column with one null in eight, and each case's output is compared row by row between the two engines over the same corpus that is then timed, before it is timed. 40 cases, all equal. truncate on a decimal is gone from the set since it no longer has a native path.

Apple M3 Max, JDK 17, Spark 4.1, release native build, 1,048,576 rows, best of five, as no-null / with-null:

bucket(int)      1.3X / 1.4X    truncate(int)      1.1X / 1.3X    years(date)  1.9X / 2.3X
bucket(long)     1.2X / 1.4X    truncate(long)     1.2X / 1.4X    years(ts)    2.6X / 2.4X
bucket(dec)      3.6X / 3.1X    truncate(str_dict) 1.6X / 1.8X    months(date) 2.0X / 2.2X
bucket(str_dict) 1.6X / 1.7X    truncate(str)      1.2X / 1.3X    months(ts)   2.4X / 2.5X
bucket(str)      1.3X / 1.3X    truncate(bin)      1.6X / 1.3X    days(date)   1.1X / 1.4X
bucket(bin)      1.4X / 1.3X                                      days(ts)     2.9X / 2.9X
bucket(date)     1.2X / 1.4X                                      hours(ts)    3.1X / 3.0X
bucket(ts)       1.5X / 1.6X

On isolating transform cost from the scan: I have not done that, and you are right that a shared Parquet input does not achieve it. Both arms read the same files with the same partitioning, so the delta is scan-plus-projection, and these ratios are a floor on the expression-level gain rather than a measurement of it. The kernel-level criterion benchmark is the isolated measurement and the query-level one is the end-to-end number; I have described them that way rather than adding a third arm, which would need a change to the shared benchmark base.

@sunchao sunchao 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.

The hard decimal fallback addresses the existing P2 in the normal Spark system-function path, and the unsupported note now reaches the registered wrapper used by GenerateDocs. Other truncate types and decimal bucket remain admitted. I found a new Spark 3.x compilation regression in the updated benchmark, noted inline, so this follow-up is a comment rather than an approval.

The same-corpus equality checks and null variants address those parts of the benchmark request. Could you remove the remaining identical-scan and expression-gain lower-bound claims? Toggling Comet can change scan cost, so shared input files do not make the query ratio a lower bound on expression-only gain. Please retain the scan-plus-projection description and include the executed plans and warning/results output from the reported run.

This pass used exact source, retained evidence and current CI logs. I did not rerun Spark or the benchmarks locally. CI currently has 14 successful checks, six skipped, five running and four failures. The four failed jobs all stop at the same benchmark compilation error, not a Celeborn runtime failure.

*/
private def verifyOutputsMatch(name: String, query: String): Unit = {
def collect(cometEnabled: Boolean): Array[Row] =
withSQLConf(

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.

[P1] Keep the benchmark collection helper compatible with Spark 3.x

This method promises Array[Row], but the inherited Spark 3.5 SQLHelper.withSQLConf takes f: => Unit and returns Unit. Returning the wrapper directly therefore does not compile. Spark 4's generic result-returning helper masks this difference. The current Spark 3.5 CI job reports found: Unit, required: Array[org.apache.spark.sql.Row] at this line. The Spark 3.4 job and both Celeborn compatibility jobs fail on the same error, before their tests can run.

Could you capture the collected rows inside the configuration scope and return them afterward, or use an existing cross-version result-returning helper? Please verify test compilation on Spark 3.4 and 3.5 as well as the Spark 4 profile.

@sunchao sunchao 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.

Rechecked 29f69db3 against a089669d. The existing Spark 3.x compilation P1 remains: the merge leaves the benchmark collection helper and its inheritance unchanged. Current CI reproduces the same found: Unit, required: Array[Row] error in the Spark 3.4/3.5 and both Celeborn compatibility jobs.

No new P1/P2 findings in this incremental review. At 23:34 UTC, CI had 12 successful, 4 failed, 6 running and 6 skipped checks. I reviewed the source/dependency increment and current CI logs, but did not rerun compilation, product tests or benchmarks.

…k 3.x

Spark 3.4 and 3.5 declare `SQLHelper.withSQLConf` as
`(pairs: (String, String)*)(f: => Unit): Unit`; only Spark 4 has the
generic result-returning form. Returning the wrapper from a method typed
`Array[Row]` therefore failed to compile on both 3.x profiles and on the
two Celeborn jobs, before any test could run. Collect into a local inside
the configuration scope and return it after the block.

Also drop the claim that the two arms scan identically. Enabling Comet
replaces the Parquet scan and the projection with native operators as
well as the transform, so each ratio is a query-level scan-plus-projection
result, not a measurement of the transform on its own; the criterion
benchmark in native/spark-expr/benches is what isolates the kernel.
The multi-byte string column came out of the partitioned write test's
spec because the test failed on the Linux CI runners with a missing data
file, which never reproduced locally. The cause was iceberg-rust writing
partition directory names raw where iceberg-java percent-encodes them, so
a name containing multi-byte characters could not be reopened under the
runners' ASCII platform charset. apache/iceberg-rust#2875 fixed that
upstream and apache#5651 bumped the pin, which is now merged into this branch,
so put the column back. The test then also covers the escaping.

Dropping `truncate(1000, i64)` from the spec removes the reason for the
Long.MinValue row filter as well, so the whole corpus is written again.
@andygrove

Copy link
Copy Markdown
Member Author

LLM-assisted: this reply and the changes it describes were written with Claude Code.

@sunchao the P1 is fixed and both claims are gone.

The compile break (f2f0c70). SQLHelper.withSQLConf is (pairs: (String, String)*)(f: => Unit): Unit on 3.4 and 3.5, so a method typed Array[Row] cannot return the wrapper. The helper now collects into a local inside the configuration scope and returns it after the block. I ran test-compile locally on -Pspark-3.4, -Pspark-3.5 and the default 4.1 profile; all three are clean.

The claims. The scaladoc no longer says the two arms scan identically. It now says that enabling Comet also replaces the Parquet scan and the projection with native operators, that each ratio is therefore a query-level scan-plus-projection result, and that it does not isolate the transform. The sentence about the ratios being a floor on the expression-level gain is out of the PR description, which @jordepic had also asked me to rewrite in plainer English, so I rewrote the whole thing. What is left of the Parquet point is only what it should be: keeping the data in Parquet keeps the Iceberg reader out of the Spark arm and lets both arms read the same files.

The run. I re-ran on the current head rather than reusing the numbers, so the table in the description is from this run. One reproduction note worth recording: my first attempt reported Comet at 0.1x-0.3x across the board, because spark/target/classes/org/apache/comet/darwin/aarch64/libcomet.dylib still held the debug library from an earlier test run and the resources plugin skips the copy when the destination is newer than the source. make benchmark-<class> does not hit this because its release prerequisite rebuilds the Rust library first. The numbers below are with the 110 MB release library in place, verified by size.

Apple M3 Max, macOS 26.6.2, OpenJDK 17.0.10, Spark 4.1, Scala 2.13, cargo build --release, -Prelease -Pspark-4.1, 1,048,576 rows, SPARK_GENERATE_BENCHMARK_FILES=1, best of five, as no-null / with-null:

bucket(int)       1.2X / 1.4X    truncate(int)       1.1X / 1.4X    years(date)   2.0X / 2.2X
bucket(long)      1.2X / 1.4X    truncate(long)      1.2X / 1.4X    years(ts)     2.5X / 2.5X
bucket(dec)       3.6X / 3.2X    truncate(str_dict)  1.6X / 1.8X    months(date)  2.0X / 2.4X
bucket(str_dict)  1.6X / 1.7X    truncate(str)       1.2X / 1.3X    months(ts)    2.6X / 2.5X
bucket(str)       1.3X / 1.3X    truncate(bin)       1.4X / 1.4X    days(date)    1.1X / 1.4X
bucket(bin)       1.4X / 1.3X                                       days(ts)      2.9X / 2.8X
bucket(date)      1.2X / 1.5X                                       hours(ts)     2.9X / 2.8X
bucket(ts)        1.6X / 1.7X

These differ from the ones I posted before by at most 0.2x in either direction, which is run-to-run drift on a laptop with other work on it, not a change in the code.

Warnings. None. The results file contains no WARNING block, so neither the not-fully-native check nor the folded-away check fired for any of the 40 cases. All 40 row-by-row comparisons passed as well; a mismatch throws before the case is timed, so the run completing is the assertion.

Executed plans. Captured during the timed run by printing stripAQEPlan(df.queryExecution.executedPlan) for both arms of each case. Operator chains for all 40:

40 cases, Spark plan vs Comet plan
case                       Spark                                 Comet
-------------------------  ------------------------------------  ----------------------------------------------------
bucket(int)                Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(int, nulls)         Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(long)               Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(long, nulls)        Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(dec)                Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(dec, nulls)         Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(str_dict)           Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(str_dict, nulls)    Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(str)                Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(str, nulls)         Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(bin)                Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(bin, nulls)         Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(date)               Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(date, nulls)        Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(ts)                 Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
bucket(ts, nulls)          Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
truncate(int)              Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
truncate(int, nulls)       Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
truncate(long)             Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
truncate(long, nulls)      Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
truncate(str_dict)         Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
truncate(str_dict, nulls)  Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
truncate(str)              Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
truncate(str, nulls)       Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
truncate(bin)              Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
truncate(bin, nulls)       Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
years(date)                Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
years(date, nulls)         Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
years(ts)                  Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
years(ts, nulls)           Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
months(date)               Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
months(date, nulls)        Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
months(ts)                 Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
months(ts, nulls)          Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
days(date)                 Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
days(date, nulls)          Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
days(ts)                   Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
days(ts, nulls)            Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
hours(ts)                  Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan
hours(ts, nulls)           Project -> ColumnarToRow -> FileScan  CometColumnarToRow -> CometProject -> CometNativeScan

Every Comet plan is CometColumnarToRow -> CometProject -> CometNativeScan, so no case fell back. Six representative pairs in full:

Full plans

bucket(dec)

Spark:

*(1) Project [static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketDecimal.invoke(16, c_dec#31)) AS static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketDecimal.invoke(16, c_dec))#1047]
+- *(1) ColumnarToRow
   +- FileScan parquet [c_dec#31] Batched: true, DataFilters: [], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:<tmp>], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<c_dec:decimal(38,10)>

Comet:

*(1) CometColumnarToRow
+- CometProject [static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketDecimal.invoke(16, c_dec))#1051], [static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketDecimal.invoke(16, c_dec#31)) AS static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketDecimal.invoke(16, c_dec))#1051]
   +- CometNativeScan parquet [c_dec#31] Batched: true, DataFilters: [], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:<tmp>], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<c_dec:decimal(38,10)>

truncate(str_dict)

Spark:

*(1) Project [static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateString.invoke(4, c_str_dict#33)) AS static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateString.invoke(4, c_str_dict))#4493]
+- *(1) ColumnarToRow
   +- FileScan parquet [c_str_dict#33] Batched: true, DataFilters: [], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:<tmp>], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<c_str_dict:string>

Comet:

*(1) CometColumnarToRow
+- CometProject [static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateString.invoke(4, c_str_dict))#4497], [static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateString.invoke(4, c_str_dict#33)) AS static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateString.invoke(4, c_str_dict))#4497]
   +- CometNativeScan parquet [c_str_dict#33] Batched: true, DataFilters: [], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:<tmp>], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<c_str_dict:string>

years(ts)

Spark:

*(1) Project [static_invoke(org.apache.iceberg.spark.functions.YearsFunction$TimestampToYearsFunction.invoke(c_ts#41)) AS static_invoke(org.apache.iceberg.spark.functions.YearsFunction$TimestampToYearsFunction.invoke(c_ts))#5855]
+- *(1) ColumnarToRow
   +- FileScan parquet [c_ts#41] Batched: true, DataFilters: [], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:<tmp>], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<c_ts:timestamp>

Comet:

*(1) CometColumnarToRow
+- CometProject [static_invoke(org.apache.iceberg.spark.functions.YearsFunction$TimestampToYearsFunction.invoke(c_ts))#5859], [static_invoke(org.apache.iceberg.spark.functions.YearsFunction$TimestampToYearsFunction.invoke(c_ts#41)) AS static_invoke(org.apache.iceberg.spark.functions.YearsFunction$TimestampToYearsFunction.invoke(c_ts))#5859]
   +- CometNativeScan parquet [c_ts#41] Batched: true, DataFilters: [], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:<tmp>], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<c_ts:timestamp>

months(date)

Spark:

*(1) Project [static_invoke(org.apache.iceberg.spark.functions.MonthsFunction$DateToMonthsFunction.invoke(c_date#39)) AS static_invoke(org.apache.iceberg.spark.functions.MonthsFunction$DateToMonthsFunction.invoke(c_date))#6318]
+- *(1) ColumnarToRow
   +- FileScan parquet [c_date#39] Batched: true, DataFilters: [], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:<tmp>], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<c_date:date>

Comet:

*(1) CometColumnarToRow
+- CometProject [static_invoke(org.apache.iceberg.spark.functions.MonthsFunction$DateToMonthsFunction.invoke(c_date))#6322], [static_invoke(org.apache.iceberg.spark.functions.MonthsFunction$DateToMonthsFunction.invoke(c_date#39)) AS static_invoke(org.apache.iceberg.spark.functions.MonthsFunction$DateToMonthsFunction.invoke(c_date))#6322]
   +- CometNativeScan parquet [c_date#39] Batched: true, DataFilters: [], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:<tmp>], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<c_date:date>

days(ts, nulls)

Spark:

*(1) Project [static_invoke(org.apache.iceberg.spark.functions.DaysFunction$TimestampToDaysFunction.invoke(c_ts_n#42)) AS static_invoke(org.apache.iceberg.spark.functions.DaysFunction$TimestampToDaysFunction.invoke(c_ts_n))#8144]
+- *(1) ColumnarToRow
   +- FileScan parquet [c_ts_n#42] Batched: true, DataFilters: [], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:<tmp>], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<c_ts_n:timestamp>

Comet:

*(1) CometColumnarToRow
+- CometProject [static_invoke(org.apache.iceberg.spark.functions.DaysFunction$TimestampToDaysFunction.invoke(c_ts_n))#8148], [static_invoke(org.apache.iceberg.spark.functions.DaysFunction$TimestampToDaysFunction.invoke(c_ts_n#42)) AS static_invoke(org.apache.iceberg.spark.functions.DaysFunction$TimestampToDaysFunction.invoke(c_ts_n))#8148]
   +- CometNativeScan parquet [c_ts_n#42] Batched: true, DataFilters: [], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:<tmp>], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<c_ts_n:timestamp>

hours(ts)

Spark:

*(1) Project [static_invoke(org.apache.iceberg.spark.functions.HoursFunction$TimestampToHoursFunction.invoke(c_ts#41)) AS static_invoke(org.apache.iceberg.spark.functions.HoursFunction$TimestampToHoursFunction.invoke(c_ts))#8370]
+- *(1) ColumnarToRow
   +- FileScan parquet [c_ts#41] Batched: true, DataFilters: [], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:<tmp>], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<c_ts:timestamp>

Comet:

*(1) CometColumnarToRow
+- CometProject [static_invoke(org.apache.iceberg.spark.functions.HoursFunction$TimestampToHoursFunction.invoke(c_ts))#8374], [static_invoke(org.apache.iceberg.spark.functions.HoursFunction$TimestampToHoursFunction.invoke(c_ts#41)) AS static_invoke(org.apache.iceberg.spark.functions.HoursFunction$TimestampToHoursFunction.invoke(c_ts))#8374]
   +- CometNativeScan parquet [c_ts#41] Batched: true, DataFilters: [], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:<tmp>], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<c_ts:timestamp>

The other 34 pairs differ only in the function class, the column name and the expression ids. I can post them all if you want them verbatim.

@jordepic on your third point: #5651 has landed and is merged into this branch, so the multi-byte string column is back in the partitioned write test's partition spec (8c6a064), together with the Long.MinValue row that had been excluded for the truncate(1000, i64) spec that replaced it. The suite passes locally, but that is macOS with a UTF-8 platform charset, where the failure never reproduced in the first place. The Linux [scans] jobs on this push are the run that actually confirms your diagnosis.

@andygrove

Copy link
Copy Markdown
Member Author

LLM-assisted: posted with Claude Code.

The full spark/benchmarks/CometIcebergSystemFunctionBenchmark-results.txt from the run described in the previous comment, unedited. It is the SPARK_GENERATE_BENCHMARK_FILES=1 artifact, so it carries the JVM and CPU banner per case and would carry any WARNING block the benchmark emitted; there are none.

CometIcebergSystemFunctionBenchmark-results.txt
================================================================================================
Iceberg system functions
================================================================================================

================================================================================================
bucket(int)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(int):                              Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                29             46          15         36.3          27.5       1.0X
Comet                                                23             26           2         44.9          22.3       1.2X


================================================================================================
bucket(int, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(int, nulls):                       Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                33             73          28         32.2          31.0       1.0X
Comet                                                24             26           2         44.3          22.6       1.4X


================================================================================================
bucket(long)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(long):                             Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                27             40          20         38.5          26.0       1.0X
Comet                                                23             24           2         46.2          21.6       1.2X


================================================================================================
bucket(long, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(long, nulls):                      Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                32             45          24         32.3          30.9       1.0X
Comet                                                24             25           1         44.6          22.4       1.4X


================================================================================================
bucket(dec)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(dec):                              Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                               109            120          23          9.6         104.1       1.0X
Comet                                                30             31           1         34.6          28.9       3.6X


================================================================================================
bucket(dec, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(dec, nulls):                       Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                               108            117          14          9.7         102.6       1.0X
Comet                                                33             35           1         31.4          31.8       3.2X


================================================================================================
bucket(str_dict)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(str_dict):                         Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                32             48          31         33.0          30.3       1.0X
Comet                                                20             22           1         52.6          19.0       1.6X


================================================================================================
bucket(str_dict, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(str_dict, nulls):                  Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                38             62          28         27.9          35.9       1.0X
Comet                                                22             23           1         47.7          21.0       1.7X


================================================================================================
bucket(str)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(str):                              Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                               101            110          24         10.4          96.0       1.0X
Comet                                                76             77           1         13.8          72.3       1.3X


================================================================================================
bucket(str, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(str, nulls):                       Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                93            118          36         11.2          89.0       1.0X
Comet                                                75             76           1         14.1          71.1       1.3X


================================================================================================
bucket(bin)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(bin):                              Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                71            116          41         14.9          67.3       1.0X
Comet                                                51             53           1         20.4          48.9       1.4X


================================================================================================
bucket(bin, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(bin, nulls):                       Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                68             85          32         15.4          64.8       1.0X
Comet                                                52             54           1         20.2          49.5       1.3X


================================================================================================
bucket(date)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(date):                             Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                24             39          22         43.0          23.3       1.0X
Comet                                                21             22           1         50.9          19.6       1.2X


================================================================================================
bucket(date, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(date, nulls):                      Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                30             37           7         34.4          29.1       1.0X
Comet                                                21             22           1         50.0          20.0       1.5X


================================================================================================
bucket(ts)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(ts):                               Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                34             53          23         30.6          32.7       1.0X
Comet                                                21             22           1         49.1          20.4       1.6X


================================================================================================
bucket(ts, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
bucket(ts, nulls):                        Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                39             57          28         27.2          36.8       1.0X
Comet                                                23             25           2         45.9          21.8       1.7X


================================================================================================
truncate(int)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
truncate(int):                            Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                22             30          17         48.2          20.8       1.0X
Comet                                                19             21           1         54.8          18.2       1.1X


================================================================================================
truncate(int, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
truncate(int, nulls):                     Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                29             52          36         36.5          27.4       1.0X
Comet                                                21             22           1         50.6          19.8       1.4X


================================================================================================
truncate(long)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
truncate(long):                           Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                24             70          44         44.3          22.6       1.0X
Comet                                                20             21           1         53.6          18.7       1.2X


================================================================================================
truncate(long, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
truncate(long, nulls):                    Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                30             51          33         35.0          28.6       1.0X
Comet                                                22             24           3         48.8          20.5       1.4X


================================================================================================
truncate(str_dict)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
truncate(str_dict):                       Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                41             72          44         25.8          38.8       1.0X
Comet                                                25             26           1         42.0          23.8       1.6X


================================================================================================
truncate(str_dict, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
truncate(str_dict, nulls):                Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                49             81          40         21.2          47.1       1.0X
Comet                                                28             30           1         37.6          26.6       1.8X


================================================================================================
truncate(str)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
truncate(str):                            Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                94            140          39         11.1          89.8       1.0X
Comet                                                77             80           2         13.7          73.2       1.2X


================================================================================================
truncate(str, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
truncate(str, nulls):                     Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                92            155          52         11.4          87.4       1.0X
Comet                                                73             76           2         14.3          69.8       1.3X


================================================================================================
truncate(bin)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
truncate(bin):                            Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                67            142          60         15.6          64.0       1.0X
Comet                                                48             49           1         22.0          45.4       1.4X


================================================================================================
truncate(bin, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
truncate(bin, nulls):                     Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                63             96          47         16.6          60.4       1.0X
Comet                                                47             49           2         22.5          44.5       1.4X


================================================================================================
years(date)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
years(date):                              Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                37             74          55         28.4          35.3       1.0X
Comet                                                19             21           2         56.4          17.7       2.0X


================================================================================================
years(date, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
years(date, nulls):                       Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                45            111          57         23.4          42.7       1.0X
Comet                                                20             22           2         51.8          19.3       2.2X


================================================================================================
years(ts)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
years(ts):                                Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                52            106          50         20.3          49.2       1.0X
Comet                                                20             22           1         51.4          19.4       2.5X


================================================================================================
years(ts, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
years(ts, nulls):                         Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                54            123          58         19.5          51.3       1.0X
Comet                                                21             23           1         49.3          20.3       2.5X


================================================================================================
months(date)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
months(date):                             Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                37             73          52         28.4          35.2       1.0X
Comet                                                19             20           1         56.3          17.8       2.0X


================================================================================================
months(date, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
months(date, nulls):                      Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                49             98          67         21.2          47.1       1.0X
Comet                                                20             21           1         52.0          19.2       2.4X


================================================================================================
months(ts)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
months(ts):                               Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                52             76          56         20.1          49.6       1.0X
Comet                                                20             22           1         52.4          19.1       2.6X


================================================================================================
months(ts, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
months(ts, nulls):                        Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                53             74          49         19.8          50.6       1.0X
Comet                                                21             23           1         49.2          20.3       2.5X


================================================================================================
days(date)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
days(date):                               Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                20             45          53         51.9          19.3       1.0X
Comet                                                19             20           1         55.8          17.9       1.1X


================================================================================================
days(date, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
days(date, nulls):                        Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                28             61          63         38.0          26.3       1.0X
Comet                                                20             22           1         51.4          19.5       1.4X


================================================================================================
days(ts)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
days(ts):                                 Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                59             92          65         17.7          56.5       1.0X
Comet                                                20             22           1         51.2          19.5       2.9X


================================================================================================
days(ts, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
days(ts, nulls):                          Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                60             92          65         17.6          56.9       1.0X
Comet                                                22             23           1         48.6          20.6       2.8X


================================================================================================
hours(ts)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
hours(ts):                                Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                60             66           7         17.4          57.4       1.0X
Comet                                                21             21           1         51.1          19.6       2.9X


================================================================================================
hours(ts, nulls)
================================================================================================

OpenJDK 64-Bit Server VM 17.0.10+7-LTS on Mac OS X 26.6.2
Apple M3 Max
hours(ts, nulls):                         Best Time(ms)   Avg Time(ms)   Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
------------------------------------------------------------------------------------------------------------------------
Spark                                                61             79          35         17.1          58.5       1.0X
Comet                                                22             23           1         48.5          20.6       2.8X

@andygrove

Copy link
Copy Markdown
Member Author

LLM-assisted: posted with Claude Code.

@jordepic your diagnosis is confirmed. With the multi-byte string column back in the partition spec, all five Linux [scans] jobs pass on 8c6a064, including Spark 4.0 and 4.1 — the exact two that failed with a missing data file before, and the only two that reached the test back then. Nothing else about the test changed, so the difference is the escaping that apache/iceberg-rust#2875 added and #5651 pinned.

No failures on the run so far (48 passed, 18 still going).

@sunchao sunchao 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.

Rechecked 8c6a0645 against a089669d. The existing Spark 3.x compilation P1 is fixed by collecting inside withSQLConf and returning afterward. The corrected benchmark attribution and supplied plans/full results address the earlier request.

The restored partition-write test passed in Linux Spark 3.4, 3.5, 4.0 and 4.1 CI on a merge commit whose source tree equals this head. No remaining P1/P2 findings. At 02:01 UTC, CI had 62 successful, 7 skipped and 4 still-running checks, with no failures.

The benchmark timings remain author-reported whole-query results. I did not rerun local compilation, product tests or benchmarks.

@andygrove
andygrove merged commit eaf6426 into apache:main Sep 4, 2026
74 checks passed
@andygrove
andygrove deleted the feat/iceberg-system-functions branch September 4, 2026 10:56
@andygrove

Copy link
Copy Markdown
Member Author

Mertged. Thanks @sunchao

@jordepic

jordepic commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Great job @andygrove ! We're really getting there!

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.

Support Iceberg's system functions (bucket, truncate, years/months/days/hours) as native expressions

4 participants