Describe the bug
Comet's native Parquet scan appears to ignore nested schema pruning for complex columns. In a production Spark 3.5.6 comparison job, plain Spark and Comet produced the same Spark physical-plan ReadSchema, same input row count, same selected files, and same downstream shuffle output, but Comet read/decompressed far more data during the scan.
Spark 3.5.6, Comet 0.17.0
The query reads Parquet from HDFS and writes Parquet back to HDFS. Column names below are anonymized. The relevant scan reads four top-level fields:
is_flagged
source_type
events
event_hour
The Spark UI shows the same pruned nested schema under events for both plans:
ReadSchema: struct<
is_flagged:boolean,
source_type:string,
events:array<struct<
is_available:boolean,
event_time_ms:bigint,
is_active:boolean,
items:array<struct<
group_id:bigint,
entity_id:bigint,
has_amount:boolean,
item_type:string,
metric_value:double,
actor_id:bigint,
is_skipped:boolean
>>
>>,
event_hour:timestamp
>
The physical input schema is much wider than the requested schema. A representative anonymized shape is:
InputSchema: struct<
is_flagged:boolean,
source_type:string,
event_hour:timestamp,
dimension_id:bigint,
region_code:string,
client_type:string,
ingestion_time:timestamp,
events:array<struct<
is_available:boolean,
event_time_ms:bigint,
is_active:boolean,
event_token:string,
source_name:string,
secondary_id:bigint,
debug_flags:array<string>,
latency_parts:struct<
queue_time_ms:bigint,
fetch_time_ms:bigint,
render_time_ms:bigint,
retry_count:int
>,
items:array<struct<
group_id:bigint,
entity_id:bigint,
has_amount:boolean,
item_type:string,
metric_value:double,
actor_id:bigint,
is_skipped:boolean,
auxiliary_id:bigint,
class_code:string,
mode:string,
raw_score:double,
normalized_score:double,
reason_codes:array<string>,
audit_blob:binary,
feature_map:map<string,double>,
diagnostics:struct<
module_id:string,
module_version:string,
trace_id:string,
extra_payload:binary
>
>>
>>,
raw_payload:binary,
metadata_json:string
>
The expected behavior is to read only the requested nested leaves, not all child fields under events / items.
However, the observed scan metrics were very different:
| Metric |
Plain Spark scan |
Comet native scan |
| Spark version |
3.5.6 |
3.5.6 |
| SQL duration |
120,642 ms |
1,688,780 ms |
| Scan tasks |
20,012 |
20,012 |
| Files selected |
10,300 |
10,300 |
| Input records |
310,897,758 |
310,897,758 |
| Stage input bytes |
30,945,027,031 |
1,351,791,550,588 |
| Shuffle records written |
157,270,190 |
157,270,190 |
| Shuffle bytes written |
3,216,674,929 |
3,216,674,929 |
Comet bytes_scanned metric |
n/a |
1259.0 GiB |
size of files read = 2.2 TiB appeared in both UIs, but that is the selected file length metric, not physical bytes fetched. The physical read evidence is Spark stage inputBytes and Comet's native bytes_scanned metric.
This strongly suggests the logical Spark-side pruning is present, but Comet's native reader still reads a much wider nested Parquet schema for the projected complex column.
Steps to reproduce
- Use Spark 3.5.x with Comet native Parquet scans enabled.
- Read a Parquet dataset with a wide nested column, for example an
array<struct<...>> with many child fields.
- Run a query that only needs a subset of nested fields from that complex column.
- Compare against plain Spark using the same query and selected files.
- In Spark UI, verify both plans show the same pruned
ReadSchema.
- Compare stage input bytes and Comet native scan
Number of bytes scanned.
Minimal shape of the reproducer:
val df = spark.read.schema(prunedSchema).parquet(path)
df
.filter(!$"is_flagged")
.selectExpr(
"event_hour",
"posexplode_outer(events) as (event_idx, event)"
)
.filter("event.is_available and (event.is_active is null or event.is_active)")
.selectExpr(
"event_hour",
"posexplode_outer(event.items) as (item_idx, item)",
"event.event_time_ms"
)
.filter("item.entity_id is not null")
.groupBy(
$"event_hour",
$"item.actor_id",
$"item.group_id",
$"item.entity_id"
)
.count()
.write.mode("overwrite").parquet(outputPath)
The important part is that the query projects only selected child fields from a nested Parquet column while the original physical Parquet files contain many more children under that same top-level column.
Expected behavior
Comet native Parquet scan should honor Spark's pruned nested ReadSchema and avoid reading/decompressing unrequested nested child fields, matching Spark's Parquet scan behavior as closely as possible.
For this workload, Comet should not read ~1.35 TB of stage input when plain Spark reads ~30.9 GB for the same files, same rows, and same displayed ReadSchema.
Additional context
Local code inspection points to the native V1 Parquet scan path using full data_schema plus a top-level projection vector, instead of giving DataFusion a nested-pruned Parquet read schema.
Relevant code path:
- Spark writes the pruned
requiredSchema into Parquet read config:
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFileFormat.scala
ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA
- Spark clips nested Parquet schemas in:
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetReadSupport.scala
clipParquetSchema, clipParquetType, clipParquetListType, clipParquetMapType
- Comet serializes both the pruned schema and the full data schema:
spark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scala
requiredSchema = schema2Proto(scan.requiredSchema.fields)
dataSchema = schema2Proto(scan.relation.dataSchema.fields)
projectionVector is built with scan.relation.dataSchema.fieldIndex(field.name), so it is top-level only.
- Native planner passes both schemas into the native scan:
native/core/src/execution/planner.rs
init_datasource_exec(required_schema, Some(data_schema), ..., Some(projection_vector), ...)
- Native Parquet setup chooses the full
data_schema as the base schema when present:
native/core/src/parquet/parquet_exec.rs
(Some(schema), Some(proj)) => (Arc::clone(schema), Some(proj.clone()))
- then applies
with_projection_indices(Some(projection))
That can prune top-level columns, but it does not appear to prune nested leaves inside a projected complex column such as events.
Possible fixes / workarounds:
- Workaround: set
spark.comet.scan.enabled=false for this workload. That keeps Spark's Parquet scan path and avoids the native scan behavior.
- Optional workaround to test: combine
spark.comet.scan.enabled=false with spark.comet.convert.parquet.enabled=true, so Spark performs the Parquet read and Comet can still convert the resulting batches after the scan.
- Code fix direction: for native V1 Parquet scan, initialize DataFusion's Parquet reader with the nested-pruned
required_schema when Spark has already produced the read schema, instead of using full data_schema with only a top-level projection vector.
- Add a regression test with
array<struct<...many fields...>>, select only a subset of nested fields, verify CometNativeScanExec is used, and assert the native scan does not read unrequested nested leaves.
Describe the bug
Comet's native Parquet scan appears to ignore nested schema pruning for complex columns. In a production Spark 3.5.6 comparison job, plain Spark and Comet produced the same Spark physical-plan
ReadSchema, same input row count, same selected files, and same downstream shuffle output, but Comet read/decompressed far more data during the scan.Spark 3.5.6, Comet 0.17.0
The query reads Parquet from HDFS and writes Parquet back to HDFS. Column names below are anonymized. The relevant scan reads four top-level fields:
The Spark UI shows the same pruned nested schema under
eventsfor both plans:The physical input schema is much wider than the requested schema. A representative anonymized shape is:
The expected behavior is to read only the requested nested leaves, not all child fields under
events/items.However, the observed scan metrics were very different:
bytes_scannedmetricsize of files read = 2.2 TiBappeared in both UIs, but that is the selected file length metric, not physical bytes fetched. The physical read evidence is Spark stageinputBytesand Comet's nativebytes_scannedmetric.This strongly suggests the logical Spark-side pruning is present, but Comet's native reader still reads a much wider nested Parquet schema for the projected complex column.
Steps to reproduce
array<struct<...>>with many child fields.ReadSchema.Number of bytes scanned.Minimal shape of the reproducer:
The important part is that the query projects only selected child fields from a nested Parquet column while the original physical Parquet files contain many more children under that same top-level column.
Expected behavior
Comet native Parquet scan should honor Spark's pruned nested
ReadSchemaand avoid reading/decompressing unrequested nested child fields, matching Spark's Parquet scan behavior as closely as possible.For this workload, Comet should not read ~1.35 TB of stage input when plain Spark reads ~30.9 GB for the same files, same rows, and same displayed
ReadSchema.Additional context
Local code inspection points to the native V1 Parquet scan path using full
data_schemaplus a top-level projection vector, instead of giving DataFusion a nested-pruned Parquet read schema.Relevant code path:
requiredSchemainto Parquet read config:sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFileFormat.scalaParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMAsql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetReadSupport.scalaclipParquetSchema,clipParquetType,clipParquetListType,clipParquetMapTypespark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scalarequiredSchema = schema2Proto(scan.requiredSchema.fields)dataSchema = schema2Proto(scan.relation.dataSchema.fields)projectionVectoris built withscan.relation.dataSchema.fieldIndex(field.name), so it is top-level only.native/core/src/execution/planner.rsinit_datasource_exec(required_schema, Some(data_schema), ..., Some(projection_vector), ...)data_schemaas the base schema when present:native/core/src/parquet/parquet_exec.rs(Some(schema), Some(proj)) => (Arc::clone(schema), Some(proj.clone()))with_projection_indices(Some(projection))That can prune top-level columns, but it does not appear to prune nested leaves inside a projected complex column such as
events.Possible fixes / workarounds:
spark.comet.scan.enabled=falsefor this workload. That keeps Spark's Parquet scan path and avoids the native scan behavior.spark.comet.scan.enabled=falsewithspark.comet.convert.parquet.enabled=true, so Spark performs the Parquet read and Comet can still convert the resulting batches after the scan.required_schemawhen Spark has already produced the read schema, instead of using fulldata_schemawith only a top-level projection vector.array<struct<...many fields...>>, select only a subset of nested fields, verifyCometNativeScanExecis used, and assert the native scan does not read unrequested nested leaves.