andygrove opened a new issue, #5355:
URL: https://github.com/apache/datafusion-comet/issues/5355
## Background
We have reports that the scan + shuffle-write (map-side) stage is slower
than Spark
when the schema contains deeply nested types. This issue collects a
static-analysis
pass over both halves of that stage so the candidates can be measured and
split into
separate fixes.
**Status: unverified.** Everything below is code reading with exact call
sites — no
benchmark numbers yet. The point of the issue is to record the candidates
and the
reasoning so they can be confirmed or dismissed one at a time. Some of these
may
turn out to be noise next to the actual data copy.
A common thread runs through most of them: work whose cost scales with the
*size of
the schema tree* is being done per batch instead of once per plan/writer.
That is
invisible on flat schemas and grows with nesting depth and width, which
matches the
shape of the reports.
## Scan side
### Candidate A: a trivial nested-type difference re-converts the whole
column every batch
`native/core/src/parquet/schema_adapter.rs:589` (and the same gate at
`:886`) decide
whether to wrap a column based on:
```rust
if logical_field.data_type() != physical_field.data_type() {
```
That is a whole-tree comparison, and arrow's `Field::eq`
(`arrow-schema-58.4.0/src/field.rs:108`) compares `name`, `data_type`,
`nullable`
**and `metadata`**. So a single difference anywhere in the tree wraps the
*entire*
top-level nested column in `CometCastColumnExpr`, which then reconverts the
whole
subtree on every batch.
Known triggers that differ only cosmetically:
- `PARQUET:field_id` metadata. arrow-rs attaches it per field only when the
parquet
schema actually carries IDs (`parquet-58.4.0/src/arrow/schema/mod.rs:513`,
guarded
by `basic_info.has_id()`). So this applies to Iceberg-written files and to
Spark
with `spark.sql.parquet.fieldId.write.enabled`, **not** to vanilla Spark
parquet.
- INT96 timestamps, where `coerce_int96_tz` attaches a UTC timezone.
- Inner field nullability differences at any level.
- Inner field naming (`element` / `key_value`, per
`parquet-58.4.0/src/arrow/schema/mod.rs:535-536`).
The deeper and wider the type, the more chances something mismatches, and
the more a
wrap costs once it fires. I have **not** confirmed which trigger (if any)
fires on the
reported workloads — that is the first thing to check, and it decides
whether this
candidate matters at all.
### Candidate B: the per-batch conversion redoes plan-time work
Once Candidate A fires, `parquet_convert_struct_to_struct` rebuilds two
`HashMap`s and
allocates a `String` per field name — per struct, per nesting level, per
batch:
- `native/core/src/parquet/parquet_support.rs:290` — `from_id_to_index`
- `native/core/src/parquet/parquet_support.rs:302` — `normalize_name`
returns `String`
- `native/core/src/parquet/parquet_support.rs:309-311` —
`field_name_to_index_map`
None of this depends on the data. The field-matching plan could be computed
once and
reused. This is only worth fixing if Candidate A turns out to be unavoidable
in some
cases; if we can stop wrapping cosmetic mismatches, this cost disappears
with it.
## Shuffle side — JVM / columnar path (`process_sorted_row_partition`)
### Candidate C: `ShuffleBlockWriter::try_new` is inside the per-batch loop
`native/shuffle/src/spark_unsafe/row.rs:1422` constructs a new
`ShuffleBlockWriter`
for every batch, inside the `while current_row < row_num` loop.
`try_new` calls `schema.flattened_fields()` (walks the full nested field
tree) and
pre-encodes the entire IPC schema flatbuffer. Its own doc comment says the
whole point
is to encode the schema *once* per writer rather than per block:
> For the common case of a schema with no dictionary types, the schema
flatbuffer is
> encoded once in `Self::try_new` and written verbatim at the start of every
block,
> rather than being re-serialized per block as `StreamWriter::try_new` would
do.
Calling it per batch defeats exactly that. Every other call site in the tree
builds it
once — e.g. the native path at `native/shuffle/src/shuffle_writer.rs:213`.
Cost scales
with schema tree size, so nested schemas pay the most. This looks like the
most
clear-cut item here and the fix is contained (hoist above the loop).
Adjacent, same function: `make_batch` (`row.rs:1490`) rebuilds the `Schema`
per batch,
and the following `RecordBatch::try_new_with_options` then deep-compares
every nested
`DataType` against it.
### Candidate D: field-major struct reads re-walk the row buffers per leaf
field
`append_struct_fields_field_major`
(`native/shuffle/src/spark_unsafe/row.rs:805`) hoists
type dispatch out of the row loop, but re-runs `read_row_at!` + `get_struct`
*inside*
each field's row loop. The traversal became one full sweep over all N
`UnsafeRow`
buffers **per leaf field** rather than one sweep total — cache-hostile, and
it
compounds with depth via `append_nested_struct_fields_field_major`.
It also allocates three `Vec`s of length `num_rows` per nested struct field
(`row.rs:968-970`).
Worth noting this is a deliberate optimization that traded dispatch cost for
traversal
cost; on deep/wide schemas that trade may go the wrong way. Needs measuring
against the
row-major predecessor rather than assuming.
## Shuffle side — native path
### Candidate E: `Map` columns are repartitioned row by row
`interleave_record_batch` is the repartition primitive
(`native/shuffle/src/partitioners/partitioned_batch_iterator.rs:111`).
arrow 58.4's `interleave` dispatch has dedicated arms for `Struct` and `List`
(`arrow-select-58.4.0/src/interleave.rs:108-110`) but **none for `Map`** or
`FixedSizeList`, so those fall through to `interleave_fallback` at `:111` →
`MutableArrayData::extend` per contiguous run, which for hash-scattered
indices is
effectively per row.
So struct- and list-heavy schemas are fine on this path; map-typed columns
are not.
Fix likely belongs upstream in arrow-rs.
### Candidate F (minor): per-batch walk of the whole ArrayData tree
`count_new_buffers`
(`native/shuffle/src/partitioners/multi_partition.rs:429`) calls
`to_data()` per column and walks the full nested tree with a `HashSet`
insert per
buffer, once per batch. Probably small, listed for completeness — and note
the existing
doc comment explains why the cheaper alternatives were rejected, so this one
should not
be "optimized" without reading that reasoning first.
## Suggested order
1. Confirm whether Candidate A fires on a real affected plan (count
`CometCastColumnExpr`
in the explain output). This decides whether the scan half matters at all.
2. Candidate C — clear-cut and self-contained, independent of the rest.
3. Candidate E — real gap, but only for map-typed columns.
4. Candidates B / D — only once there are numbers justifying them.
There is an existing `shuffle_block_schema_encoding` benchmark group in
`native/shuffle/benches/shuffle_writer.rs` covering flat vs. deeply nested
schemas that
can be extended to measure Candidate C directly.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]