feat: support NullType output types in codegen dispatch - #5526
Conversation
sunchao
left a comment
There was a problem hiding this comment.
Source review of this pinned change found the two newly admitted broadcast failure paths below. Neither example was executed locally. The supplied snapshot has four action-required workflows and no passing checks; the author-reported local test results were not independently verified.
ce249ca to
db1768f
Compare
sunchao
left a comment
There was a problem hiding this comment.
Re-reviewed db1768fa2aea540821391c7aef4cff2a4b8c43fe. The two earlier witnesses are fixed in source. Two source-derived P2s remain; neither was executed here.
[P2] Reject duplicate names in newly admitted struct outputs. The output gate now admits transform(array(id), x -> named_struct('a', x, 'a', NULL)) over an INT input. Whole-expression ArrayTransform dispatch skips the inner CreateNamedStruct duplicate-name fallback. Spark retains both fields, but Arrow 18.3.0's default struct policy replaces the first a with the later NullVector. Generated output setup still casts child ordinal 0 to IntVector, so the query fails before rows are written. BASE rejected this Null-bearing output. Please retain fallback for duplicate names recursively, or allocate children without losing their ordinals.
[P2] Keep coalescing safe plain null lists. The new broadcast bypass also rejects plain array<null>, although its ListVector appender grows offsets/validity independently of the Null child and finalizes the child count. An already supported build payload such as IF(rand(17L) < 0.5, array(NULL), array(NULL,NULL)), selected after a join over a repartitioned broadcast build, therefore loses coalescing. Returning B original buffers makes each of P consuming tasks open B compression/IPC streams and schema roots instead of one: B×P setups rather than P. This is an operation-count consequence, not a measured speed ratio. Keep the struct/map protection, but allow the safe plain-list case to coalesce.
db1768f to
f39afa6
Compare
|
We re-applied the representation-level serde restriction inside canHandle. |
f39afa6 to
1d0a6f4
Compare
sunchao
left a comment
There was a problem hiding this comment.
Re-reviewed 1d0a6f493bbd60453234748d548c85d77348a98c. The previous duplicate-field, map-key schema, and broadcast-bypass issues are addressed in the inspected source. The five additional P2 cases below remain.
Could you add a small microbenchmark for consumed map(id, NULL) projections and the remaining struct/map broadcast bypass, with a plain array<null> control? Compare this head with Spark and safe prior paths using matched versions, settings, and warmup. Please confirm result equality and the executed native/fallback plans, and report throughput, allocations, peak/retained memory, and IPC setup counts for small/default batches and few/many build buffers.
| */ | ||
| def canHandle(boundExpr: Expression): Option[String] = { | ||
| if (!isSupportedDataType(boundExpr.dataType)) { | ||
| if (!isSupportedDataType(boundExpr.dataType, allowNullType = true)) { |
There was a problem hiding this comment.
[P2] Preserve single evaluation of nullable stateful outputs
With codegen dispatch and ANSI enabled, consider element_at(transform(IF(monotonically_increasing_id() % 2 = 0, array(id), CAST(NULL AS array<bigint>)), x -> named_struct('id', x, 'n', NULL)), 1) over a native LONG batch containing id=0,1,2,3. Spark evaluates the transform once per row, retaining the struct for id=2. This gate now admits that Null-bearing output, but the existing ElementAt ANSI conversion serializes its left subtree into both the CASE predicate and the lookup. Native CASE tests all four rows, then reevaluates the transform on the two selected rows. The second selected row receives an odd counter and becomes NULL. Please materialize the left value once or retain Spark fallback for this composition. Current BASE rejects this output. The previous reviewed head admitted it but lacked the duplicated-left CASE, so the newly failing combination is in this increment. Source-derived witness, not executed.
There was a problem hiding this comment.
Fixed: CometElementAt keeps a non-deterministic nullable collection in Spark under ANSI, since the null guard's two copies each hold their own kernel state and the THEN copy only sees the CASE-selected rows. Reproduced the witness first (Comet returned [null] where Spark keeps [[2,null]]), and it now matches. The same guard is built by CometSize (non-legacy mode), CometArrayAppend, CometMapFromArrays and CometCoalesce, so they share the gate (NullGuard); CometSize drops the guard in legacy mode, where native already answers -1. The coalesce case is reachable on main without NullType (coalesce(IF(monotonically_increasing_id() % 2 = 0, array(id), NULL), array(id)) NPEs in columnar-to-row because the result is declared non-nullable); it is included since the sweep found it and the fix is one line on the shared guard.
Tests: expect_fallback queries in element_at_ansi.sql, map_from_arrays.sql and coalesce.sql, plus the non-deterministic dimension of CometNullTypeCompositionSuite.
There was a problem hiding this comment.
[P2] Residual stateful guard case across different arguments
At 79fc84008a7dca16a4c24f28036649c0af2603b0, the original nullable element_at witness is fixed, but the shared guard still misses a cross-input case. This is source-derived, not an executed reproduction. With a native LONG input t containing ordered ids 0, 1, 2, 3 in one partition/batch and codegen dispatch enabled, consider SELECT id, arrays_zip(transform(array(id), x -> named_struct('i', monotonically_increasing_id(), 'n', NULL)), IF(id % 2 = 0, array(id), CAST(NULL AS ARRAY<BIGINT>))) AS z FROM t. The first array is non-nullable and nondeterministic, while the second is nullable and deterministic, so neither matches this predicate. Spark evaluates the first array even on odd rows where the zip is NULL. CometArraysZip.convert instead puts the value-producing kernel inside a CASE whose THEN batch contains only even rows, changing the stateful child's evaluation sequence. The base rejected this NullType-bearing producer. This guard therefore misses a composition newly admitted by the PR. Could the guard preserve one evaluation before filtering or retain fallback when a nullable sibling filters a stateful child, with coverage for nullability and nondeterminism occurring in different arguments?
There was a problem hiding this comment.
Fixed: reproduced the witness first (Comet returned [[4,null],0] and [[5,null],2] where Spark keeps [[0,null],0] and [[2,null],2]), and the non-NullType flavour diverges the same way, so this is the guard's rule being too narrow rather than a NullType path. The witness's stateful child is a lambda, which runs through the JVM codegen dispatcher; its kernel cache is keyed by the serialized expression, so the guard's predicate copy and THEN branch run one kernel instance and share its counter: the predicate consumes it for the whole batch and the THEN branch continues from there. A natively evaluated stateful child gets its own instance per copy, but native CASE evaluates the THEN branch on the rows the predicate selected, so it diverges as soon as the guard filters. NullGuard now refuses any non-deterministic child inside a guard, whichever argument is the nullable one, and CometArraysZip, CometElementAt, CometArrayAppend, CometMapFromArrays, CometCoalesce and CometSize share it; CometSize builds no guard at all for a non-nullable child (or in legacy mode), so size(filter(arr, x -> x < monotonically_increasing_id())) is evaluated once and stays native.
Tests: expect_fallback witness in arrays_zip.sql; CometNullTypeCompositionSuite gains a cross-input sweep that puts a non-nullable stateful producer (including one whose length records the counter) beside a nullable deterministic sibling under every consumer, and a nullable deterministic sweep so the guards' ELSE branches run natively; the size witnesses are in CometArrayExpressionSuite.
|
Beyond the five cases above, this revision adds The non-NullType flavour of the same item-field problem ( Assisted-by: Claude Code (claude-fable-5) |
sunchao
left a comment
There was a problem hiding this comment.
The five earlier concrete witnesses are addressed in the inspected source at 79fc84008a7dca16a4c24f28036649c0af2603b0. A residual cross-input stateful-guard case remains in the existing discussion, and one newly reachable shuffle failure is detailed inline.
This pass used source analysis only. I did not run a Spark/Comet query, test or runtime reproduction. The current-head snapshot has no check runs or workflow runs, so CI validation is unavailable.
| case Some((sparkRows, _)) => | ||
| compared += 1 | ||
| Try(rowsOf(query, cometEnabled = true, ansi, nativeColumnarToRow)) match { | ||
| case Failure(e) if tolerated.exists(causeText(e).contains) => |
There was a problem hiding this comment.
[P2] Do not suppress shuffle regressions newly admitted by this PR
Source-derived, not executed. Over nonempty primitive Parquet t, use SELECT /*+ REPARTITION(3, c) */ c FROM (SELECT transform(array(id), x -> named_struct('v', x, 'n', NULL)) AS c FROM t) with native scan/codegen dispatch, CometShuffleManager, Comet shuffle enabled, shuffle mode auto, spark.comet.shuffle.convertFromSparkPlan.enabled=false, AQE off and JVM columnar-to-row. At the base the producer falls back, and the disabled conversion leaves its exchange in Spark. This PR admits a CometProject, so the array partition key rejects native shuffle but falls through to JVM columnar shuffle. The unsafe-row list writer then sends its struct's Null field to append_field, which reaches exactly the panic waived here. The writer defect is inherited, but its reachability under these settings is new. Please retain fallback for that schema or support Null struct fields, and do not waive failures that passed at the base under the same settings. With conversion enabled the base can also fail, so that is not a valid regression control.
There was a problem hiding this comment.
Fixed: the exact query above does not fail at this head (reproduced under the listed settings: CometProject into CometColumnarShuffle, results match), because a list of structs goes through the writer's row-major append_field, which has a Null arm. The shape that did panic is a top-level struct column with a Null field, e.g. REPARTITION(3) named_struct('v', id, 'n', NULL), which takes the field-major paths that lacked the arm; that was the failure the sweep tolerated. Both field-major paths now handle Null struct fields (every row null, as the row-major path does), and the sweep tolerates nothing: every producer under repartition, shuffle join, group-by and sort compares against Spark.
Tests: CometNullTypeCompositionSuite operator and nesting sweeps with the waiver removed.
There was a problem hiding this comment.
You're right. Correction to my [P2] example: at 79fc8400, the array-of-struct path reaches append_field, which already has a DataType::Null arm. My assertion that this path reaches the unsupported-struct-field panic was incorrect, and I withdraw that specific witness. Thanks for checking it.
I am reviewing the two field-major fixes and removal of the sweep waiver separately at 496c3f7f. This correction is based on the pinned source, not an executed SQL reproduction.
79fc840 to
496c3f7
Compare
sunchao
left a comment
There was a problem hiding this comment.
Re-reviewed 496c3f7f against ef62b463. The broader nondeterminism guard addresses the cross-input case, and the new field-major NullType arms pass the single-batch controls. I found one additional P2 in builder reuse across batches, detailed inline.
I independently reran the exact-source native component harness: 8 tests passed and 3 failed on the same NullBuilder reuse defect. This is not a full Spark, JNI, or shuffle execution. The planner route is source-traced. Four workflows remain action_required, with no head or merge check results in the current snapshot. No fresh benchmark was run.
| // A Null field carries no data: every row is null, whether or not the struct is. | ||
| DataType::Null => { | ||
| let field_builder = get_field_builder!(struct_builder, NullBuilder, field_idx); | ||
| for _ in row_start..row_end { |
There was a problem hiding this comment.
[P2] Reset NullType builders before reusing them for another batch
These new arms handle the first batch, but process_sorted_row_partition creates its builders outside the batch loop and builder_to_array calls finish() without replacing them. In the pinned Arrow 58.4.0, NullBuilder::finish() leaves its length unchanged. With the exact current conversion functions, two consecutive two-row batches of struct<v:bigint,n:void> panic on the second finish because the struct has length 2 while its Null child has length 4. The single-batch and typed-null two-batch controls pass. A nested struct fails the same way.
SpillSorter sends an entire destination partition to this native call, so one call can exceed spark.comet.shuffle.jvm.batchSize. The PR now admits producers such as element_at(transform(array(id), x -> named_struct('v', x, 'n', NULL)), 1) over native primitive input. With CometShuffleManager, JVM shuffle mode, spark.comet.shuffle.convertFromSparkPlan.enabled=false, and AQE off, source tracing shows their exchange can enter this writer where the base retained Spark fallback.
Could we reset or recreate Null-containing builders between batches, or retain fallback, and add coverage spanning more than one writer batch? I reproduced the builder failure in an isolated harness using production conversion source and pinned dependencies. The SQL/planner route was source-traced, not executed end to end.
There was a problem hiding this comment.
Fixed: reproduced first with the sort-based writer (spark.shuffle.sort.bypassMergeThreshold=0, spark.comet.shuffle.jvm.batchSize=2, jvm shuffle mode, convertFromSparkPlan.enabled=false, AQE off, 16 rows into 2 partitions): named_struct('v', id, 'n', NULL) panics in StructBuilder::finish with (2 != 4) on the second batch, as you describe, and so do a nested struct and element_at(transform(array(id), x -> named_struct('v', x, 'n', NULL)), 1). The same reuse defect hits every other Null-bearing shape: a top-level NULL column fails the batch's row-count check, map(id, NULL) fails "keys and values have unequal length", and array<null> comes back with the wrong row count. Base control at ef62b463 under the same settings: the top-level NULL column and array(named_struct('v', id, 'n', NULL)) fail there too (row-count check and the same (2 != 4) panic through the row-major arm), the struct shapes hit the field-major unreachable instead, and map(id, NULL), array<null> and the element_at shape stayed in Spark on the base, so those three are admitted by this PR. NullBuilder::finish keeps its length in arrow 58.4.0 (a NullArray owns no buffers to hand over), so process_sorted_row_partition now recreates every builder whose type holds a Null anywhere (contains_null_type) after each batch; the other builders reset on finish and are kept. The bypass writer sends at most one batch per native call, which is why the single-batch controls and the sweep's REPARTITION(3) never saw it.
Tests: null_type_builders_start_every_batch_empty in row.rs drives two batches through the production make_builders / builder_to_array / recreate path for a Null field, a nested one and a top-level Null column; CometColumnarShuffleSuite "columnar shuffle spanning several native writer batches with NullType columns" runs the seven shapes above through REPARTITION(300) with jvm.batchSize=2 and the spill threshold lifted, under both AQE settings, comparing with Spark.
132560d to
554d3ca
Compare
Untyped constructors such as map(), map('a', NULL) and array() leave
NullType children in their output type, and the codegen dispatch gate
rejected any output type containing NullType, so the whole operator
fell back to Spark.
Make the type gate asymmetric: canHandle now accepts NullType (top-level
or nested in array/struct/map) for the output type while still rejecting
it for BoundReference inputs, since CometScalaUDFCodegen.specFor cannot
build an ArrowColumnSpec for a NullVector. The output emitter maps
NullType to NullVector and writes it with setNull only.
Also update the Scala/Java UDF guide: NullType arguments remain
unsupported, NullType return types are now supported; CalendarIntervalType
is removed from the unsupported list since it has been supported since
apache#4898.
Closes apache#5525
Assisted-by: Claude Code (claude-fable-5)
Follow-up to the codegen gate change: admitting NullType outputs exposes
four problems on the JVM/FFI paths, fixed here.
* Arrow Java's MinorType.NULL factory drops the field it is handed, so a
NullType map key Comet declared non-nullable comes back nullable and
the map schema fails on read. `Utils.withNonNullableMapKeys` repairs
the key flag, and `Utils.newArrowStreamWriter` — now the only way to
build an `ArrowStreamWriter` (scalastyle-enforced) — applies it on
every IPC writer: broadcast, getByteArrayRdd, the PyArrow UDF runner.
`CometArrowStream.actualFieldOf` repairs the schema handed to native.
* `VectorSchemaRootAppender` loops forever on a NullVector that is a
direct child of a struct (a struct's capacity is the minimum over its
direct children, and `NullVector.reAlloc()` is a no-op).
`Utils.coalesceBroadcastBatches` ships such schemas uncoalesced. A
list insulates whatever sits below it, so `array<null>` and
`map(k, array(NULL))` keep coalescing.
* `CometBatchKernelCodegen.canHandle` rejects duplicate struct field
names, recursively, in the output type and in BoundReference inputs:
Arrow structs key children by name, so `named_struct('a', x, 'a', NULL)`
collapses to one child and the generated ordinal casts fail.
Whole-expression dispatch skipped `CometCreateNamedStruct`'s rule.
* A NullType child (array element, map value, struct field) is always
declared nullable on both sides of the FFI boundary
(`Utils.declaredChildNullability`): Spark leaves `containsNull` false
on `filter(array(), ...)`, and native kernels that rebuild a list
around the input's actual child fail on the nullability mismatch —
`map_entries(map_filter(map(), (k, v) -> true))` panicked.
Tests: UtilsSuite (key repair, IPC round trip, coalesce bypass rule
checked exhaustively over every NullType shape), CometCodegenSourceSuite
(duplicate names rejected, NullType children nullable), CometJoinSuite,
CometColumnarShuffleSuite, test_pyarrow_udf.py, and expect_fallback /
NullType-input queries in create_named_struct.sql, map_entries.sql,
slice.sql, array_repeat.sql, array_union.sql, transform.sql.
Assisted-by: Claude Code (claude-fable-5)
Admitting NullType outputs from the codegen dispatcher lets values reach
native kernels that assume NullType never arrives. The serde now refuses:
* make_array (builds a single row), array_union (drops entries) and
array_intersect (returns the other side's entries; reported
Unsupported so the codegen dispatcher runs it at every setting, since
the Incompatible branch has no dispatcher fallback under
allowIncompatible), array_except's unsupported element types likewise,
array_repeat and slice (non-nullable item promised nullable),
collect_list/collect_set (nested nullability mismatch) and
hash/xxhash64 (no Null arm) over NullType-bearing inputs, and
map_from_arrays with a literal array beside a per-row one (the native
map kernel reads a scalar list through its first row; pre-existing,
found while probing the NullType flavour).
* Any non-deterministic child under the null guards of CometElementAt
(ANSI), CometArrayAppend, CometMapFromArrays, CometArraysZip,
CometCoalesce and CometSize, whichever argument is the nullable one:
native CASE evaluates the THEN copy on the rows the predicate
selected, and a codegen-dispatched child (any lambda) is one cached
kernel shared by both copies, so the kernel never sees the values
Spark's single evaluation produces. CometSize builds no guard for a
non-nullable child or in legacy mode, where native already answers
-1, and needs no gate there.
CometIf, CometCaseWhen and CometCoalesce refuse a NullType result: native
CASE merges its branches' rows through Arrow's merge_n, which cannot build
a NullArray with a validity bitmap. Native GetStructField returns a scalar
for a scalar struct input instead of a one-row array, which a CASE result
builder would slice past ("range end index 2 out of range for slice of
length 1"), and rebuilds a Null-typed field as a fresh NullArray, since a
kernel that grew the struct through MutableArrayData (element_at on an
out-of-range index) hands over a Null child carrying a validity bitmap
that fails validation once projected. The first two shapes surface once
the sweep stops the optimizer from folding them away.
array_union/intersect/except cast both sides to a deeply-nullable element
type, since the native set-op kernel asserts identical nested nullability
and a lambda variable arrives nullable where a literal field does not.
The native row shuffle writer's field-major paths gain the Null struct
field case their row-major path already had, so a struct with a NullType
field now shuffles through the JVM columnar shuffle instead of panicking,
and the writer recreates every Null-bearing builder after each batch:
NullBuilder::finish keeps its length, so a second batch used to panic on
a Null struct field longer than its parent and miscount a top-level Null
column, a Null map value or a Null list element.
Native to_csv yields NULL for a row that renders to an empty string and
reports itself nullable, as Spark does: Spark hands the row to univocity's
writeRowToString with skipEmptyLines on, so a struct with a lone null
field (under the default empty nullValue) is NULL, not "". Pre-existing
and independent of NullType; the allowIncompatible sweep profile found
it on Spark 3.4, whose interpreted StructsToCsv shows the NULL, while
Spark 3.5+ crashes in its own generated code on that NULL and the sweep
counts the case as invalid there.
CometNullTypeCompositionSuite sweeps the NullType producers under
consumers, operators and nesting containers, across ANSI, nullable,
non-deterministic and cross-input (stateful argument beside a nullable
one) settings, with the optimizer's null and comparison simplifications
excluded so no consumer folds to a literal, and under physical profiles
that vary how rows are batched and which exchange path they take (native
batches of two rows, the JVM shuffle's bypass and sort-based writers
with and without forced spills, native shuffle, AQE, native
columnar-to-row) plus one that opts every registered serde into its
native kernel through allowIncompatible, so Incompatible serdes do not
hide behind the codegen dispatcher; the operators include hash partitioning, a null-safe
join key and a scalar subquery over the value itself. It runs with no
tolerated failures and
floors on the compared and natively executed counts, and checks that its
templates reach every registered array, map, struct and any-type
aggregate serde. CometInMemoryCacheSuite round-trips NullType columns and children
through Comet's Arrow cache serializer, and a remote-shuffle decode unit
test covers Null columns and children. UtilsSuite forces
serialization eagerly for Scala 2.12. Verified on Spark 4.1 / Scala
2.13, Spark 3.5 / Scala 2.12 and Spark 3.4 / Scala 2.12.
Assisted-by: Claude Code (claude-fable-5)
…dcast Arrow's VectorSchemaRootAppender loops forever on a NullVector directly under a struct, so coalesceBroadcastBatches shipped such build sides uncoalesced and every consuming task opened one Arrow IPC stream per buffer (CometBatchRDD.compute). The NullType-columns microbenchmark measured that cost: a 64-buffer map<bigint, null> build side over 8 consumer tasks opened 512 streams and ran 0.9x Spark where the fallback path ran 1.2x, and the cost grows with buffers x tasks. CometBroadcastExchangeExec now reports a build side with a NullType directly under a struct or map entry as Unsupported, naming the columns, so the broadcast stays on Spark as it did before the codegen dispatcher admitted NullType outputs. array<null> is insulated by the list and still coalesces natively. The planner gate and the coalescer's now defensive bypass share one predicate, Utils.hasNullTypeUnderStruct, so both lift together once Arrow's appender can grow a NullVector under a struct; UtilsSuite runs the real appender over every shape and pins the predicate to exactly the hanging ones. CometJoinSuite asserts the fallback reason and that the plan carries Spark's BroadcastExchangeExec. Assisted-by: Claude Code (claude-fable-5)
Times NullType map and struct projections consumed by the codegen dispatcher, and broadcast joins whose build side carries a NullType column, against Spark and the prior fallback path (dispatcher off), each at the default and at a small batch size and with few and many broadcast build files. After timing, every case checks that all arms return the same rows and profiles one execution per arm: executed plan, JVM allocation, peak and retained heap, peak task execution memory, retained Arrow memory, and for the broadcast cases the buffer count, coalescing, consumer task count and resulting Arrow IPC stream count. Assisted-by: Claude Code (claude-fable-5)
554d3ca to
4c3b389
Compare
|
Rebased onto main and pushed two commits on top of the fix. The struct/map broadcast bypass is gone. Building the benchmark showed it was slower than the path it replaced: with the bypass in, a The benchmark is the second commit: Cases. Consumed Arms. Spark; Comet (this head); Comet with the codegen dispatcher off, i.e. the prior path where a NullType projection falls back and takes its operator with it; and the two Comet arms again with Result equality and plans. The benchmark computes an order-independent row digest of every arm's result and fails on any difference; all 8 cases report identical results across the 5 arms. It records, per arm, the first non-Comet operator of the final (post-AQE) executed plan: the Comet arm is fully native in every case except the two gated shapes, where it falls back at Throughput (best time, ms):
The gated shapes match the prior path at both sizes; the dispatched IPC setup counts. Every consuming task decodes every broadcast buffer, so streams = buffers x consumer tasks, both read back from the executed plan and the consuming stage's task count. The Allocations and memory (one profiled execution per arm after warmup, JVM-wide). JVM allocation for the joins is 42-556 MB on the Comet arms against 62-563 MB on Spark; the two projections allocate 2.2-2.5 GB on every arm (the row conversion of 10M rows). Heap peak is lower on the Comet arms in every case. Retained heap after GC and retained Arrow memory are within GC jitter (Arrow retained is 0 everywhere). Native join build memory, from the join's Small batches. Assisted-by: Claude Code (claude-fable-5) |
Which issue does this PR close?
Closes #5525.
Rationale for this change
Untyped constructors such as
map(),map(k, NULL)andarray()leaveNullTypechildren in their output type.CometBatchKernelCodegen.canHandlerejected any output type containingNullType, so such expressions fell back to Spark and took their whole operator with them. Only the input side needs that restriction (CometScalaUDFCodegencannot build a spec for aNullVector); on the output side the kernel just has to emit an all-nullNullVector.Admitting those outputs then exposed a set of downstream assumptions that
NullTypenever arrives, on the JVM IPC paths and in native kernels. This PR closes them and adds a differential sweep so the next one is found here rather than in review.What changes are included in this PR?
Codegen gate.
canHandleacceptsNullTypein output types (nested included) while still rejecting it in inputs, and rejects duplicate struct field names, which Arrow'sStructVectorcollapses. ANullTypechild is always declared nullable on both sides of the FFI boundary (Utils.declaredChildNullability), since native kernels that rebuild a list around the input's child fail on the nullability mismatch.JVM IPC paths. Arrow's
MinorType.NULLfactory rebuilds aNullTypemap key as nullable, which the IPC reader rejects;Utils.newArrowStreamWriter(now the only way to build anArrowStreamWriter, scalastyle-enforced) repairs the key on every writer.VectorSchemaRootAppenderloops forever on aNullVectordirectly under a struct; a broadcast build side with that shape stays on Spark's broadcast (CometBroadcastExchangeExecreports itUnsupported, naming the columns), since shipping it uncoalesced costs every consuming task one IPC stream per buffer, andcoalesceBroadcastBatcheskeeps a defensive bypass on the same predicate.array<null>coalesces and stays native.Serde gates. Refused over
NullType-bearing inputs:make_array(single row),array_union(drops entries),array_intersect(returns the other side's entries; reportedUnsupportedso the codegen dispatcher runs it at every setting, since theIncompatiblebranch has no dispatcher fallback underallowIncompatible, andarray_except's unsupported element types likewise),array_repeatandslice(non-nullable item promised nullable),collect_list/collect_set(nested nullability mismatch),hash/xxhash64(no Null arm), andmap_from_arrayswith a literal array beside a per-row one (the native map kernel reads a scalar list through its first row; pre-existing and independent ofNullType, found while probing theNullTypeflavour). The null guards inCometElementAt(ANSI),CometArrayAppend,CometMapFromArrays,CometArraysZip,CometCoalesceandCometSizeserialize their children into a predicate copy and a THEN branch, and a stateful child diverges from Spark's single evaluation either way it runs: native CASE evaluates the THEN copy on the rows the predicate selected, and a codegen-dispatched child (any lambda) is one cached kernel whose state both copies share. Any non-deterministic child inside a guard is refused, whichever argument is the nullable one (NullGuard);CometSizebuilds no guard for a non-nullable child or in legacy mode, where native already answers -1, so those stay native.CometIf,CometCaseWhenandCometCoalescerefuse aNullTyperesult, since native CASE merges its branches' rows through Arrow'smerge_n, which cannot build aNullArraywith a validity bitmap; nativeGetStructFieldreturns a scalar for a scalar struct input instead of a one-row array that a CASE result builder slices past, and rebuilds aNullTypefield as a freshNullArray, sinceelement_aton an out-of-range index hands over a Null child carrying a validity bitmap.array_union/array_intersect/array_exceptcast both sides to a deeply-nullable element type, since the native set-op kernel asserts identical nested nullability and a lambda variable arrives nullable where a literal field does not. Thecoalesce, set-op and non-NullTypeguard cases are reachable on main; they are included because the sweep found them and the fixes are shared.Native
to_csv. Yields NULL for a row that renders to an empty string and reports itself nullable, as Spark does: Spark hands the row to univocity'swriteRowToStringwithskipEmptyLineson, so a struct with a lone null field (under the default emptynullValue) is NULL, not"". Pre-existing and independent ofNullType; theallowIncompatiblesweep profile found it on Spark 3.4, whose interpretedStructsToCsvshows the NULL, while Spark 3.5+ crashes in its own generated code on that NULL (nullSafeCodeGennever marks the result null), so the sweep counts the case as invalid in Spark there.Native shuffle. The row shuffle writer's field-major paths gain the
Nullstruct field case their row-major path already had, so a struct holding aNullTypefield shuffles through the JVM columnar shuffle instead of panicking, and the writer recreates everyNullType-bearing builder after each batch, sinceNullBuilder::finishkeeps its length and a second writer batch used to panic or miscount; the sweep no longer tolerates any failure.Tests.
CometNullTypeCompositionSuitesweeps everyNullTypeproducer under consumers, operators and nesting containers, across ANSI, nullable, non-deterministic and cross-input (a stateful argument beside a nullable one) settings, with the optimizer's null and comparison simplifications excluded so no consumer folds to a literal, and under physical profiles that vary the batching and the exchange path (native batches of two rows, the JVM shuffle's bypass and sort-based writers with and without forced spills, native shuffle, AQE, native columnar-to-row) plus one that opts every registered serde into its native kernel throughallowIncompatible, with operators that partition, join and run a scalar subquery on the value itself, comparing Comet with Spark with no tolerated failures and enforcing floors on the compared and natively executed counts. A registry check fails the suite if a registered array, map, struct or any-type aggregate serde is reached by no template, so a serde added later cannot stay outside the sweep.CometInMemoryCacheSuiteround-tripsNullTypecolumns and children through the Arrow cache serializer. Unit tests inCometCodegenSourceSuite,UtilsSuite(including an exhaustive oracle for the coalesce bypass rule),CometJoinSuite,CometColumnarShuffleSuite,test_pyarrow_udf.py, and SQL-file witnesses that pin each fallback reason.Benchmark.
CometNullTypeColumnsBenchmark(separate commit) times the consumedmap(id, NULL)projection and broadcast joins with aNullTypebuild side against Spark and the prior fallback path, with anarray<null>control, at default and small batch sizes and with 2 and 64 broadcast buffers; it checks that every arm returns the same rows and profiles plan, allocation, memory and IPC stream counts per arm. Every case is on par with or faster than the prior path: the gated build sides match it, and thearray<null>join runs 2.0x Spark at 64 buffers against 1.1x when it fell back.How are these changes tested?
Spark 4.1 / Scala 2.13:
make test-jvmgreen on the earlier revision; on the final commitCometNullTypeCompositionSuite,CometSqlFileTestSuite,CometCodegenSourceSuite,UtilsSuite,CometArrayExpressionSuite,CometMapExpressionSuite,CometJoinSuite,CometAggregateSuiteand the hash tests all pass, pluspytest test_pyarrow_udf.py. Spark 3.5 / Scala 2.12:CometNullTypeCompositionSuite,CometSqlFileTestSuite,UtilsSuite,CometCodegenSourceSuitepass. Spark 3.4 / Scala 2.12:CometNullTypeCompositionSuiteandCometCsvExpressionSuitepass. Disabling any one gate turns the sweep red on the compositions it covers.Assisted-by: Claude Code (claude-fable-5)