perf: resolve schema fields by name index instead of scanning - #24316
perf: resolve schema fields by name index instead of scanning#24316zhuqi-lucas wants to merge 3 commits into
Conversation
Field lookup by name was linear in the schema width, and the alias branch of `Expr::to_field` paid for it twice. Together that made deriving a projection's schema quadratic in the number of columns. Two independent changes: - `DFSchema` gains a lazily built map from field name to the ascending indices carrying it. `index_of_column_by_name` and `qualified_fields_with_unqualified_name` consult it instead of walking every field, and the latter no longer allocates a `Vec` per lookup. Every arm of the lookup rules already required the field name to match, so restricting the walk to same-named candidates and applying the qualifier rules in index order returns exactly what the scan returned, including which duplicate wins. - `Expr::to_field`'s `Expr::Alias` arm resolved the aliased expression twice: `Expr::metadata` is itself `to_field(..).1.metadata()`, so calling it alongside `to_field` walked the inner expression, and thus the schema, a second time for no extra information. The index is derived state and takes no part in equality, and `Debug` is now written by hand so it keeps printing exactly the three real fields. Plan snapshots compare that string and a `HashMap`'s iteration order is not deterministic. `Clone` starts a fresh cache rather than copying one. Timings for `to_field` over W `col AS col` aliases against a W-column schema, which is the shape wide view-matcher style projections produce (per iteration, 3000 iterations): | W | before | after | |-----|----------|----------| | 18 | 26.74 us | 12.03 us | | 40 | 75.40 us | 18.64 us | | 100 | 394.8 us | 47.21 us | | 300 | 3124 us | 141.4 us | Per-expression cost goes from 0.98 us at W=18 to 5.32 us at W=300 before, and holds at about 0.47 us after, i.e. the quadratic term is gone. At narrow widths the alias change is what pays; the index takes over as the schema widens. `name_index_matches_linear_scan` pins the equivalence by keeping the previous scan as a reference implementation and comparing both lookups across qualifier and name combinations, over a schema with the same name under two relations, qualified and unqualified fields, a non-ASCII name and absent names. `name_index_is_derived_state` covers clone, `strip_qualifiers` and `replace_qualifier`.
There was a problem hiding this comment.
Pull request overview
This PR reduces the cost of deriving expression/projection schemas by (1) adding a lazily-built name→field-index accelerator to DFSchema to avoid linear scans during column resolution, and (2) avoiding duplicate resolution work in Expr::to_field for Expr::Alias by computing the inner field once and reusing its metadata.
Changes:
- Add
DFSchema::name_index(OnceLock<HashMap<String, Vec<usize>>>) and update name-based lookup helpers to consult it rather than scanning all fields. - Update
Expr::to_field’sExpr::Aliashandling to resolve the inner expression once and merge metadata from the resolved field. - Add unit tests ensuring the new name-indexed lookups match the previous linear-scan behavior and that the index remains derived state across schema operations.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| datafusion/expr/src/expr_schema.rs | Avoids double schema/expr traversal in Expr::Alias by reusing the resolved inner field and its metadata. |
| datafusion/common/src/dfschema.rs | Adds a lazy name→indices cache for faster column lookup and adjusts trait impls/tests to keep behavior stable. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// Lazily built accelerator for name lookups: maps a field name to the | ||
| /// ascending list of indices carrying it. Purely derived from `inner`, so | ||
| /// it takes no part in equality or `Debug`. | ||
| name_index: OnceLock<HashMap<String, Vec<usize>>>, | ||
| } |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #24316 +/- ##
========================================
Coverage 81.14% 81.15%
========================================
Files 1110 1110
Lines 386168 386323 +155
Branches 386168 386323 +155
========================================
+ Hits 313368 313517 +149
- Misses 54343 54347 +4
- Partials 18457 18459 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
`merge` is the one method that mutates a `DFSchema` in place, replacing `inner` and extending `field_qualifiers`. A name index built before the merge kept describing the old field set, so every field merged in was invisible to later lookups: `index_of_column_by_name` returned `None` for them and `qualified_fields_with_unqualified_name` left them out. Drop the index at the end of `merge` and let the next lookup rebuild it. `name_index_survives_merge` covers it, comparing against the linear-scan reference after a merge that both adds a new name and reuses an existing one under a different qualifier. It fails without this change.
|
run benchmark sql_planner |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing dfschema-name-index (cea1b11) to 3985bd5 (merge-base) diff Run configurationrun benchmark sql_plannerResults will be posted here when complete File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: Comparing dfschema-name-index (cea1b11) to 3985bd5 (merge-base) diff Run configurationrun benchmark sql_plannerCPU Details (lscpu)Details
Resource Usagesql_planner — base (merge-base)
sql_planner — branch
File an issue against this benchmark runner |
Which issue does this close?
Related to #24264. This is an alternative to #24281 that stays entirely inside the "make it cheaper" lane: it does not change any schema that is produced, so it does not depend on the semantics question raised in #24284.
Rationale for this change
Deriving a projection's output schema is quadratic in the number of columns, and the constant is doubled for aliases.
Two independent causes:
Name lookup is a linear scan.
DFSchemahas no name to index map, soindex_of_column_by_nameandqualified_fields_with_unqualified_namewalk every field.Expr::Column'sto_fieldgoes throughfield_from_columnto the latter, which additionally allocates aVecper lookup. With N expressions over an M-column schema that is O(N*M).The alias arm resolves twice. In
Expr::to_field,Expr::Aliascallsexpr.metadata(schema)and thenexpr.to_field(schema).Expr::metadatais defined asto_field(..).1.metadata(), so the inner expression, and therefore the schema, is walked a second time for no extra information.This shows up on plans with many wide
col AS colalias projections, where an alias is not a bareColumnsois_projection_unnecessarykeeps the projection and its schema is derived again on every pass.What changes are included in this PR?
DFSchemagains a lazily builtname_indexmapping a field name to the ascending indices carrying it.index_of_column_by_nameandqualified_fields_with_unqualified_nameconsult it instead of scanning, and the latter stops allocating aVecon every lookup.Expr::to_field'sExpr::Aliasarm resolves the aliased expression once and takes the metadata from the resulting field.The index is derived state: it takes no part in
PartialEq,Clonestarts a fresh cache rather than copying one, andDebugis now hand written so it prints exactly the three real fields as before. That last point matters because plan snapshots compare theDebugstring and aHashMap's iteration order is not deterministic; derivingDebugwith the new field madedatafusion-sql'stest_avoid_add_aliasfail nondeterministically.Correctness
Every arm of the lookup rules already required the field name to match, so restricting the walk to same-named candidates and applying the qualifier rules in index order returns exactly what the full scan returned, including which duplicate wins and which lookups miss.
name_index_matches_linear_scanpins this by keeping the previous scan as a reference implementation and comparing both lookups across qualifier and name combinations, over a schema with the same name under two relations, qualified and unqualified fields, a non-ASCII name and absent names.name_index_is_derived_statecoversclone,strip_qualifiersandreplace_qualifier, where the qualifiers change and stale answers would be visible.Both tests were checked against deliberate mutations: reversing the candidate order and making the qualifier comparison always true each make them fail.
Performance
to_fieldover Wcol AS colaliases against a W-column schema, per iteration over 3000 iterations:Per-expression cost goes from 0.98 us at W=18 to 5.32 us at W=300 before, and holds at about 0.47 us after, so the quadratic term is gone. At narrow widths the alias change is what pays; the index takes over as the schema widens.
This is a microbenchmark of
to_fieldin isolation. An end to end optimizer pass will see a smaller number, since it includes work neither change touches.The two changes are independent and can be split if you would prefer to review them separately.
Are there any user-facing changes?
No. Lookup results, schemas and
Debugoutput are unchanged; this is purely a cost reduction.