reidkaufmann opened a new pull request, #24389:
URL: https://github.com/apache/datafusion/pull/24389

   ## Which issue does this close?
   
   Complements #19792.  Part of the wide-`UnionExec` planning-cost work.
   
   ## Rationale for this change
   
   `union_schema` builds the output schema for `UnionExec` and `InterleaveExec`
   by coercing field metadata and nullability across **every** child.  That 
merge
   is quadratic in the number of children: for each output field it walks all
   inputs, and for each input it walks *every other* input to union field-level
   metadata.  For a union of `n` children with `f` fields the construction cost 
is
   `O(n^2 * f)` (worse when fields carry metadata).
   
   For narrow unions this is insignificant.  It matters when a plan fans
   a single source out into many identically-shaped children and unions them 
back
   together -- e.g. a union assembled from repartitioned copies of the same 
input.
   An instance like this occurred with InfluxDB: every child schema was the 
same,
   so the merge, guaranteed to reproduce the first child's schema, unnecessarily
   incurs the planning latency penalty from `O(n^2 * f)` complexity.
   
   ### Relationship to #19792
   
   `UnionExec` construction has two quadratic halves:
   
   - **`with_new_children` / `PlanProperties`** -- addressed by #19792
     (`with_new_children_and_same_properties`, `Arc<PlanProperties>`, the
     properties fast path).  Already on `main`.
   - **`union_schema`** -- *not* covered by #19792 and still quadratic on 
`main`.
   
   This PR complements it by making `union_schema` skip the merge
   when it can't change the result.  It deliberately doesn't touch
   `with_new_children`; that path is already handled.
   
   ## What changes are included?
   
   A fast path at the top of `union_schema`: after taking `inputs[0].schema()`,
   if every remaining child's schema is either the **same allocation**
   (`Arc::ptr_eq`) or **structurally equal** (`==`) to the first, return the 
first
   schema immediately.  Otherwise we fall through to the existing full merge, so
   behavior for genuinely heterogeneous unions is byte-for-byte unchanged.
   
   ```rust
   let first_schema = inputs[0].schema();
   
   if inputs[1..].iter().all(|input| {
       let schema = input.schema();
       Arc::ptr_eq(&schema, &first_schema) || schema == first_schema
   }) {
       return Ok(first_schema);
   }
   ```
   
   `InterleaveExec` shares `union_schema`, so it gets the same speedup for free.
   
   ## On the `==` cost
   
   The natural objection: doesn't the deep `==` make the *unequal* case slower?
   Here's why it doesn't matter.
   
   - **The equal case never runs the merge, and its check is cheap.**  The
     shared-`Arc` case is settled by pointer comparison.  The distinct-but-equal
     case runs `Schema::eq`, which is allocation-free and short-circuits on the
     first difference.  Benchmarks show a small loss versus a 
pointer-equality-only
     control (the theoretical floor) but it still beats the full merge by a
     wide margin, and that advantage grows with schema complexity.
   
   - **The adversarial worst case is bounded.**  The one shape where the scan is
     pure overhead is `last_differs`: children `0..n-1` are equal and the last
     diverges, so we scan `n` schemas, fail on the last, then merge anyway.  
That's
     a single linear `==` pass bounded by the merge that follows -- a constant
     fraction, not another factor of `n` -- and it takes *thousands* of
     near-identical children differing only in the last to hit.
   
   - **Ordinary unequal unions fail fast.**  `SELECT a ... UNION ALL SELECT b 
...`
     differs at field 0, so `==` rejects on the first field (see 
`names_differ`).
     And `UnionExec::try_new` already rejects misaligned children, so the only
     divergence `union_schema` ever sees is top-level (caught in the first 
pass).
   
   ## Benchmark results
   
   New bench `datafusion/physical-plan/benches/union_schema.rs` measures
   `UnionExec::try_new` construction over a flat schema and a nested/struct 
schema,
   for the four child shapes above.  Run interleaved (baseline / patched 
alternated
   per cell) on a fixed-clock T2D VM.
   
   ### `union_schema` construction (lower is better)
   
   | scenario | n | baseline | patched | change |
   |---|---|---|---|---|
   | union_exec_try_new/shared_arc | 100 | 263 µs | 42 µs | 6.2× |
   | union_exec_try_new/shared_arc | 1000 | 2.60 ms | 429 µs | 6.1× |
   | union_exec_try_new/shared_arc | 4000 | 10.5 ms | 1.74 ms | 6.0× |
   | union_exec_try_new/content_equal | 100 | 262 µs | 42 µs | 6.2× |
   | union_exec_try_new/content_equal | 1000 | 2.61 ms | 433 µs | 6.0× |
   | union_exec_try_new/content_equal | 4000 | 10.5 ms | 1.74 ms | 6.0× |
   | union_exec_try_new/last_differs | 4000 | ~98 ms | ~97 ms | flat (tight 
interleave: patched ≈ baseline) |
   | union_exec_try_new/names_differ | 4000 | ~87 ms | ~87 ms | flat (±1%) |
   | union_exec_try_new_nested/content_equal | 1000 | 2.68 ms | 431 µs | 6.2× |
   | union_exec_try_new_nested/content_equal | 4000 | 10.8 ms | 1.73 ms | 6.2× |
   
   The `last_differs` (adversarial: N-1 children equal, deep compare then full
   merge) and `names_differ` (typical unequal: fails on the first field) cells
   were re-measured with **tight per-cell interleaving** (baseline/patched
   adjacent, 4 rounds) to control for variance: both are within ±1.5%, 
straddling
   zero.  Interpretation: the deep compare cost isn't observable end to end.
   
   ### End-to-end planning: no regression (`sql_planner`)
   
   `cargo bench --bench sql_planner` (TPC-H + ClickBench) run baseline vs 
patched
   on the fixed-clock T2D VM.  Every case lands within ±1% -- run-to-run noise 
--
   with no case regressing beyond that noise.  Notable rows, including the 
union-heavy
   `sorted_union` cases the fast path is meant to help:
   
   | case | baseline | patched |
   |---|---|---|
   | physical_plan_tpcds_all | 995.9 ± 1.4 ms | 991.9 ± 1.9 ms |
   | physical_plan_tpch_all | 60.4 ± 0.2 ms | 60.3 ± 0.1 ms |
   | physical_sorted_union_order_by_50 | 349.9 ± 2.4 ms | 346.3 ± 2.7 ms |
   | physical_sorted_union_order_by_10 | 12.3 ± 0.03 ms | 12.2 ± 0.07 ms |
   | physical_select_all_from_1000 | 30.8 ± 0.25 ms | 30.7 ± 0.08 ms |
   
   The full TPC-H q1-q22 and ClickBench sets are all flat (ratio is 1.00-1.01 in
   both directions).  Separately, interleaving tests per-cell (baseline and 
patched back-to-back, so
   run-to-run variance -- e.g. thermal -- cancels rather than favoring one) for
   `physical_join_distinct` + eight ClickBench queries (4 rounds) confirmed the
   same thing: patched and baseline straddle zero; no systematic regression 
from the
   deep compare.
   
   ## Testing
   
   - `cargo test -p datafusion-physical-plan --lib union` -- all pass, 
including a
     new `test_union_schema_fast_path_content_equal` that exercises the `==` 
branch
     with pointer-distinct-but-equal schemas and asserts the result matches the
     shared schema (i.e. identical to the slow-path merge).
   - `cargo clippy -p datafusion-physical-plan --lib -- -D warnings` -- clean.
   - `cargo bench --bench union_schema` -- compiles and runs.
   
   ## Are there any user-facing changes?
   
   No: planning-time performance change only, results and schema are identical.
   


-- 
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]

Reply via email to