This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git


The following commit(s) were added to refs/heads/main by this push:
     new 47794ff246 Improve planning speed: Fast path for `union_schema` when 
all children share a schema (#24389)
47794ff246 is described below

commit 47794ff2462e66ee28567ceee49a246056077bb0
Author: Reid Kaufmann <[email protected]>
AuthorDate: Sat Aug 15 13:11:31 2026 +0000

    Improve planning speed: Fast path for `union_schema` when all children 
share a schema (#24389)
    
    ## Which issue does this close?
    
    Complements #19792. Fits with the wide-`UnionExec` planning-cost work,
    but originates from an InfluxDB issue.
    
    ## 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 identical-schema 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 incurred 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 of the deep compare...
    
    The natural objection (which came up before this PR): **doesn't the deep
    `==` make the *unequal* case slower?** I'll paraphrase the prior
    conclusions, risking verbosity to avoid rehashing the discussion.
    Spoiler: it's not an issue.
    
    - The equal case avoids 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 (ms) | patched (ms) | change |
    |---|---|---|---|---|
    | union_exec_try_new/shared_arc | 100 | 0.263 | 0.042 | 6.2× |
    | union_exec_try_new/shared_arc | 1000 | 2.60 | 0.429 | 6.1× |
    | union_exec_try_new/shared_arc | 4000 | 10.5 | 1.74 | 6.0× |
    | union_exec_try_new/content_equal | 100 | 0.262 | 0.042 | 6.2× |
    | union_exec_try_new/content_equal | 1000 | 2.61 | 0.433 | 6.0× |
    | union_exec_try_new/content_equal | 4000 | 10.5 | 1.74 | 6.0× |
    | union_exec_try_new/last_differs | 4000 | ~98 | ~97 | flat (±1.5%) |
    | union_exec_try_new/names_differ | 4000 | ~87 | ~87 | flat (±1%) |
    | union_exec_try_new_nested/content_equal | 1000 | 2.68 | 0.431 | 6.2× |
    | union_exec_try_new_nested/content_equal | 4000 | 10.8 | 1.73 | 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 (ms) | patched (ms) |
    |---|---|---|
    | physical_plan_tpcds_all | 995.9 ± 1.4 | 991.9 ± 1.9 |
    | physical_plan_tpch_all | 60.4 ± 0.2 | 60.3 ± 0.1 |
    | physical_sorted_union_order_by_50 | 349.9 ± 2.4 | 346.3 ± 2.7 |
    | physical_sorted_union_order_by_10 | 12.3 ± 0.03 | 12.2 ± 0.07 |
    | physical_select_all_from_1000 | 30.8 ± 0.25 | 30.7 ± 0.08 |
    
    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.
    
    ---------
    
    Signed-off-by: Reid Kaufmann <[email protected]>
---
 datafusion/physical-plan/Cargo.toml              |   4 +
 datafusion/physical-plan/benches/union_schema.rs | 159 +++++++++++++++++++++++
 datafusion/physical-plan/src/union.rs            |  46 +++++++
 3 files changed, 209 insertions(+)

diff --git a/datafusion/physical-plan/Cargo.toml 
b/datafusion/physical-plan/Cargo.toml
index 0f72b74840..552b979c78 100644
--- a/datafusion/physical-plan/Cargo.toml
+++ b/datafusion/physical-plan/Cargo.toml
@@ -108,6 +108,10 @@ tokio = { workspace = true, features = [
 harness = false
 name = "partial_ordering"
 
+[[bench]]
+harness = false
+name = "union_schema"
+
 [[bench]]
 harness = false
 name = "spill_io"
diff --git a/datafusion/physical-plan/benches/union_schema.rs 
b/datafusion/physical-plan/benches/union_schema.rs
new file mode 100644
index 0000000000..8cffe47466
--- /dev/null
+++ b/datafusion/physical-plan/benches/union_schema.rs
@@ -0,0 +1,159 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Benchmark for `UnionExec` construction cost as a function of child count.
+//!
+//! Scenarios (run against a flat and a nested/struct schema):
+//! - `shared_arc`: every child returns the same `Arc<Schema>`
+//! - `content_equal`: pointer-distinct but identical schemas per child
+//! - `last_differs`: all children equal except extra metadata on the last
+//!   child's last top-level field. Worst case for the equality fast path:
+//!   the scan runs, fails on the final child, then the full merge runs anyway.
+//! - `names_differ`: the first field is renamed in every child but the first,
+//!   the common real-world unequal union where equality fails immediately.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
+use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
+use datafusion_physical_plan::ExecutionPlan;
+use datafusion_physical_plan::empty::EmptyExec;
+use datafusion_physical_plan::union::UnionExec;
+
+const NUM_FIELDS: usize = 10;
+const NESTED_CHILDREN: usize = 5;
+const METADATA_PER_FIELD: usize = 2;
+
+fn metadata(tag: &str, i: usize) -> HashMap<String, String> {
+    (0..METADATA_PER_FIELD)
+        .map(|m| (format!("key_{m}"), format!("value_{tag}_{i}_{m}")))
+        .collect()
+}
+
+fn flat_schema() -> Schema {
+    let fields: Vec<Field> = (0..NUM_FIELDS)
+        .map(|i| {
+            Field::new(format!("col_{i}"), DataType::Int64, true)
+                .with_metadata(metadata("f", i))
+        })
+        .collect();
+    Schema::new(fields)
+}
+
+fn nested_schema() -> Schema {
+    let fields: Vec<Field> = (0..NUM_FIELDS)
+        .map(|i| {
+            let children: Vec<Field> = (0..NESTED_CHILDREN)
+                .map(|c| {
+                    Field::new(format!("sub_{i}_{c}"), DataType::Int64, true)
+                        .with_metadata(metadata("n", i * NESTED_CHILDREN + c))
+                })
+                .collect();
+            Field::new(format!("col_{i}"), DataType::Struct(children.into()), 
true)
+                .with_metadata(metadata("s", i))
+        })
+        .collect();
+    Schema::new(fields)
+}
+
+/// Clone `schema` with extra metadata on its last field. Divergence must stay
+/// at the top level: differing nested fields change the field `DataType`
+/// itself, which `UnionExec::try_new` rejects ("Schemas have to be aligned").
+fn divergent(schema: &Schema) -> Schema {
+    let mut fields: Vec<Field> =
+        schema.fields().iter().map(|f| f.as_ref().clone()).collect();
+    let last = fields.pop().unwrap();
+    let mut md = last.metadata().clone();
+    md.insert("divergent".to_string(), "true".to_string());
+    fields.push(last.with_metadata(md));
+    Schema::new(fields)
+}
+
+fn child(schema: SchemaRef) -> Arc<dyn ExecutionPlan> {
+    Arc::new(EmptyExec::new(schema))
+}
+
+fn children_shared_arc(schema: &Schema, n: usize) -> Vec<Arc<dyn 
ExecutionPlan>> {
+    let schema: SchemaRef = Arc::new(schema.clone());
+    (0..n).map(|_| child(Arc::clone(&schema))).collect()
+}
+
+fn children_content_equal(schema: &Schema, n: usize) -> Vec<Arc<dyn 
ExecutionPlan>> {
+    (0..n).map(|_| child(Arc::new(schema.clone()))).collect()
+}
+
+/// First child keeps `schema`; the rest rename the first field — the common
+/// real-world unequal union (`SELECT a .. UNION ALL SELECT b ..`), where any
+/// equality check fails immediately.
+fn children_names_differ(schema: &Schema, n: usize) -> Vec<Arc<dyn 
ExecutionPlan>> {
+    let mut fields: Vec<Field> =
+        schema.fields().iter().map(|f| f.as_ref().clone()).collect();
+    let first = fields.remove(0);
+    let renamed = first.clone().with_name(format!("renamed_{}", first.name()));
+    fields.insert(0, renamed);
+    let alt = Schema::new(fields);
+    let mut children = vec![child(Arc::new(schema.clone()))];
+    children.extend((1..n).map(|_| child(Arc::new(alt.clone()))));
+    children
+}
+
+fn children_last_differs(schema: &Schema, n: usize) -> Vec<Arc<dyn 
ExecutionPlan>> {
+    let mut children = children_content_equal(schema, n - 1);
+    children.push(child(Arc::new(divergent(schema))));
+    children
+}
+
+fn bench_union_construction(c: &mut Criterion) {
+    for (suffix, schema, sizes) in [
+        ("", flat_schema(), &[100usize, 1000, 4000][..]),
+        ("_nested", nested_schema(), &[1000, 4000][..]),
+    ] {
+        let mut group = 
c.benchmark_group(format!("union_exec_try_new{suffix}"));
+        for &n in sizes {
+            let shared = children_shared_arc(&schema, n);
+            let content = children_content_equal(&schema, n);
+            let differs = children_last_differs(&schema, n);
+
+            group.bench_with_input(
+                BenchmarkId::new("shared_arc", n),
+                &shared,
+                |b, ch| b.iter(|| UnionExec::try_new(ch.clone()).unwrap()),
+            );
+            group.bench_with_input(
+                BenchmarkId::new("content_equal", n),
+                &content,
+                |b, ch| b.iter(|| UnionExec::try_new(ch.clone()).unwrap()),
+            );
+            group.bench_with_input(
+                BenchmarkId::new("last_differs", n),
+                &differs,
+                |b, ch| b.iter(|| UnionExec::try_new(ch.clone()).unwrap()),
+            );
+            let names = children_names_differ(&schema, n);
+            group.bench_with_input(
+                BenchmarkId::new("names_differ", n),
+                &names,
+                |b, ch| b.iter(|| UnionExec::try_new(ch.clone()).unwrap()),
+            );
+        }
+        group.finish();
+    }
+}
+
+criterion_group!(benches, bench_union_construction);
+criterion_main!(benches);
diff --git a/datafusion/physical-plan/src/union.rs 
b/datafusion/physical-plan/src/union.rs
index c1cc5da31a..160772dc22 100644
--- a/datafusion/physical-plan/src/union.rs
+++ b/datafusion/physical-plan/src/union.rs
@@ -916,6 +916,22 @@ fn union_schema(inputs: &[Arc<dyn ExecutionPlan>]) -> 
Result<SchemaRef> {
     }
 
     let first_schema = inputs[0].schema();
+
+    // Fast path: when every input already shares the first input's schema, the
+    // field-by-field metadata/nullability merge below is redundant work that
+    // scales as O(n^2 * fields). This is common in practice: unions built from
+    // repartitioned copies of the same plan (e.g. observed in InfluxDB) hand 
us
+    // children that all carry the exact same schema. A pointer-equality check
+    // catches the shared-`Arc` case for free, and a content `==` comparison
+    // catches distinct-but-equal schemas; both let us return early and hand 
back
+    // the first schema unchanged.
+    if inputs[1..].iter().all(|input| {
+        let schema = input.schema();
+        Arc::ptr_eq(&schema, &first_schema) || schema == first_schema
+    }) {
+        return Ok(first_schema);
+    }
+
     let first_field_count = first_schema.fields().len();
 
     // validate that all inputs have the same number of fields
@@ -1627,6 +1643,36 @@ mod tests {
         Ok(())
     }
 
+    #[test]
+    fn test_union_schema_fast_path_content_equal() -> Result<()> {
+        // Inputs whose schemas are pointer-distinct but structurally equal 
must
+        // take the content-equality (`==`) fast path and still produce a 
schema
+        // equal to the shared one, matching the slow-path merge exactly.
+        let schema = create_test_schema()?;
+        let distinct: SchemaRef = Arc::new((*schema).clone());
+        // Guard the branch under test: these must NOT be the same allocation, 
so
+        // the fast path is reached via `==` rather than `Arc::ptr_eq`.
+        assert!(!Arc::ptr_eq(&schema, &distinct));
+
+        let memory_exec1 =
+            Arc::new(TestMemoryExec::try_new(&[], Arc::clone(&schema), None)?);
+        let memory_exec2 =
+            Arc::new(TestMemoryExec::try_new(&[], Arc::clone(&distinct), 
None)?);
+        let memory_exec3 =
+            Arc::new(TestMemoryExec::try_new(&[], Arc::clone(&distinct), 
None)?);
+
+        // Capture the first child's schema before it is moved into the union.
+        let first_input_schema = memory_exec1.schema();
+        let union_plan =
+            UnionExec::try_new(vec![memory_exec1, memory_exec2, 
memory_exec3])?;
+
+        // The fast path returns the first child's schema Arc unchanged. Assert
+        // pointer equality (not just `==`): a slow-path merge would build a 
new,
+        // merely-equal Schema, so only ptr-eq proves the merge was skipped.
+        assert!(Arc::ptr_eq(&union_plan.schema(), &first_input_schema));
+        Ok(())
+    }
+
     #[test]
     fn test_union_schema_mismatch() {
         // Test that UnionExec properly rejects inputs with different field 
counts


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to