Skip to content

Comet native Parquet scan reads full nested columns despite pruned Spark ReadSchema #4859

Description

@mixermt

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

  1. Use Spark 3.5.x with Comet native Parquet scans enabled.
  2. Read a Parquet dataset with a wide nested column, for example an array<struct<...>> with many child fields.
  3. Run a query that only needs a subset of nested fields from that complex column.
  4. Compare against plain Spark using the same query and selected files.
  5. In Spark UI, verify both plans show the same pruned ReadSchema.
  6. 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:

  1. Workaround: set spark.comet.scan.enabled=false for this workload. That keeps Spark's Parquet scan path and avoids the native scan behavior.
  2. 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.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

Labels

performancepriority:mediumFunctional bugs, performance regressions, broken features

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions