jayzhan211 commented on code in PR #26589:
URL: https://github.com/apache/datafusion/pull/26589#discussion_r4498937541
##########
datafusion/physical-expr/src/expressions/case.rs:
##########
@@ -175,10 +215,25 @@ impl CaseBody {
.collect::<IndexMap<usize, usize>>();
// Construct the projected body by rewriting each expression from the
original body
- // using the column index mapping.
+ // using the column index mapping. Precomputed expressions are
replaced with
+ // references to the columns appended after the projected ones.
let project = |expr: &Arc<dyn PhysicalExpr>| -> Result<Arc<dyn
PhysicalExpr>> {
Arc::clone(expr)
.transform_down(|e| {
+ if !precomputed.is_empty()
+ && is_precomputable(&e)
Review Comment:
This makes planning exponential in nested CASE depth when levels read
different fields. Replacing a field access rebuilds each enclosing `CaseExpr`
through `try_new`, which runs `project()` again on the rewritten subtree. With
`CASE WHEN s['f1'] = 1 THEN 0 ELSE CASE WHEN s['f2'] = 2 ...` at depth 20 in a
debug build, planning goes from 10 ms to 47 s under the default config, and
about 4x every two levels in release.
Could you derive the projection on first evaluation instead? This fixed it
when I tried it, and also fixes an existing blow-up on main for direct columns
nested in reverse order. Please add a test too; the one below passes with this
change.
```rs
#[derive(Debug, Default)]
struct LazyProjectedBody(
OnceLock<std::result::Result<ProjectedCaseBody, Arc<DataFusionError>>>,
);
impl LazyProjectedBody {
fn get(&self, body: &CaseBody) -> Result<&ProjectedCaseBody> {
self.0
.get_or_init(|| body.project().map_err(Arc::new))
.as_ref()
.map_err(|e| DataFusionError::Shared(Arc::clone(e)))
}
}
```
`EvalMethod` then holds `LazyProjectedBody::default()`, and `evaluate` calls
`p.get(&self.body)?`. It needs no-op `Hash` and `Eq` impls, like
`LiteralLookupTable`.
```rs
#[test]
fn nested_case_with_distinct_struct_fields() -> Result<()> {
let fields = (0..=40)
.map(|i| Field::new(format!("f{i}"), DataType::Int32, true))
.collect::<Vec<_>>();
let columns = (0..=40)
.map(|i| Arc::new(Int32Array::from(vec![i, i + 1])) as ArrayRef)
.collect::<Vec<_>>();
let s = StructArray::new(fields.into(), columns, None);
let schema = Arc::new(Schema::new(vec![Field::new(
"s",
s.data_type().clone(),
true,
)]));
let batch = RecordBatch::try_new(Arc::clone(&schema),
vec![Arc::new(s)])?;
let s = col("s", &schema)?;
let mut expr = get_field_expr(Arc::clone(&s), "f0", &schema)?;
for i in 1..=40 {
let when = binary(
get_field_expr(Arc::clone(&s), &format!("f{i}"), &schema)?,
Operator::Eq,
lit(-1i32),
&schema,
)?;
expr = Arc::new(CaseExpr::try_new(None, vec![(when, lit(1i32))],
Some(expr))?);
}
let result = expr.evaluate(&batch)?.into_array(batch.num_rows())?;
assert_eq!(as_int32_array(&result)?, &Int32Array::from(vec![0, 1]));
Ok(())
}
```
##########
datafusion/physical-expr/src/expressions/case.rs:
##########
@@ -224,10 +279,46 @@ impl CaseBody {
Ok(ProjectedCaseBody {
projection,
+ precomputed: precomputed.into_iter().collect(),
body: projected_body,
supports_projection,
})
}
+
+ /// Iterates over all the top-level expressions of this case body.
+ fn exprs(&self) -> impl Iterator<Item = &Arc<dyn PhysicalExpr>> {
+ self.expr
+ .iter()
+ .chain(self.when_then_expr.iter().flat_map(|(w, t)| [w, t]))
+ .chain(self.else_expr.iter())
+ }
+}
+
+/// Returns true if `expr` is a cheap, non-volatile expression over columns
and literals,
+/// such as a struct field access, that can be evaluated for the whole input
batch ahead of
+/// the `CASE` branches.
+///
+/// Expressions with [`ExpressionPlacement::MoveTowardsLeafNodes`] placement
may be pushed
+/// below filters by the optimizer, so evaluating them for rows a branch does
not select is
+/// expected to be safe.
+fn is_precomputable(expr: &Arc<dyn PhysicalExpr>) -> bool {
Review Comment:
`get_field` on a map isn't the cheap case this assumes: it scans entries for
every row and can fail. `SELECT CASE WHEN id < 0 THEN m[7] WHEN id > 100 THEN
m[8] ELSE 0 END FROM mt` returns 0 on main and errors here. With 10 entries and
8 branches, a batch where no branch matches goes from 11 µs to 518 µs, though
uniform branches do get faster (1005 to 738 µs).
Could you leave map lookups out? With the lazy projection, the batch schema
is available to `project()`, so `is_precomputable` can also return false when
`crosses_map(expr, schema)` is true. Struct, `Dictionary<Struct>` and `Null`
bases stay precomputed, and only a path that reaches a `Map` is excluded. I
tried this: it fixes the error and the slowdowns, at the cost of the
uniform-branch win.
```rs
fn crosses_map(expr: &Arc<dyn PhysicalExpr>, schema: &Schema) -> bool {
expr.exists(|e| {
let Some(func) = e.downcast_ref::<ScalarFunctionExpr>() else {
return Ok(false);
};
let [base, path @ ..] = func.args() else {
return Ok(false);
};
let mut data_type = base.data_type(schema)?;
for key in path {
let name = key
.downcast_ref::<Literal>()
.and_then(|l|
l.value().try_as_str().flatten().map(str::to_string));
data_type = match (&data_type, name) {
(DataType::Map(..), _) => return Ok(true),
(DataType::Struct(fields), Some(name)) => {
match fields.iter().find(|f| *f.name() == name) {
Some(f) => f.data_type().clone(),
None => return Ok(false),
}
}
_ => return Ok(false),
};
}
Ok(false)
})
.unwrap_or(false)
}
```
Please add tests for the query above, plus `CASE WHEN s['a'] = 1 THEN
s['m']['k'] WHEN s['a'] = 2 THEN s['m']['k'] END` with `m` a map field. The
flat `get_field(s, 'm', 'k')` should give an empty `precomputed` and keep `s`
in `projection`, while the nested `get_field(get_field(s, 'm'), 'k')` should
precompute `s['a']` and `s['m']`.
##########
datafusion/physical-expr/src/expressions/case.rs:
##########
@@ -272,16 +372,43 @@ struct ProjectedCaseBody {
}
impl ProjectedCaseBody {
- /// Returns a projection when evaluating the derived body can avoid unused
input columns.
- fn projection_for(&self, batch: &RecordBatch) -> Option<Vec<usize>> {
+ /// Returns the batch to evaluate the derived body against, when doing so
can avoid
+ /// filtering unused input columns.
+ fn project_batch(&self, batch: &RecordBatch) ->
Result<Option<RecordBatch>> {
+ if !self.supports_projection {
+ return Ok(None);
+ }
let projection = self
.projection
.iter()
.copied()
.filter(|index| *index < batch.num_columns())
.collect::<Vec<_>>();
- (self.supports_projection && projection.len() < batch.num_columns())
- .then_some(projection)
+ if self.precomputed.is_empty() {
+ return if projection.len() < batch.num_columns() {
+ Ok(Some(batch.project(&projection)?))
+ } else {
+ Ok(None)
+ };
+ }
+
+ let schema = batch.schema();
+ let mut fields = Vec::with_capacity(projection.len() +
self.precomputed.len());
+ let mut columns = Vec::with_capacity(fields.capacity());
+ for index in projection {
+ fields.push(Arc::clone(&schema.fields()[index]));
+ columns.push(Arc::clone(batch.column(index)));
+ }
+ for expr in &self.precomputed {
+ fields.push(expr.return_field(&schema)?);
+ columns.push(expr.evaluate(batch)?.into_array(batch.num_rows())?);
Review Comment:
This adds a failure main doesn't have. The field comes from the plan while
the array comes from the batch, and `try_new_with_options` rejects any
difference in nested types or nullability. A plan that declares `s` non-null
over a batch with null structs fails with `Column 'a' is declared as
non-nullable`, and on main it works. I could only reach this with directly
built physical plans, which is the engine case from the issue. This change
fixed both probes:
```suggestion
let column = expr.evaluate(batch)?.into_array(batch.num_rows())?;
// The array may not match the plan-time field exactly (nested
nullability or
// metadata); only the expressions in the body read this schema.
let field = expr.return_field(&schema)?;
let nullable = field.is_nullable() || column.null_count() > 0;
fields.push(Arc::new(
field
.as_ref()
.clone()
.with_data_type(column.data_type().clone())
.with_nullable(nullable),
));
columns.push(column);
```
##########
datafusion/physical-expr/src/expressions/case.rs:
##########
@@ -132,40 +136,76 @@ struct CaseBody {
impl CaseBody {
/// Derives a [ProjectedCaseBody] from this [CaseBody].
fn project(&self) -> Result<ProjectedCaseBody> {
- // Determine the set of columns that are used in all the expressions
of the case body.
+ // Lambda bodies are evaluated against batches of a different shape, so
+ // expressions are only precomputed when the body contains no lambdas.
+ let can_precompute = !self.exprs().any(|e| {
+ e.exists(|e| Ok(e.is::<LambdaExpr>() || e.is::<LambdaVariable>()))
+ .expect("Closure cannot fail")
+ });
+
+ // Unknown leaves may read input columns without exposing a Column
child.
+ // This is checked over all the nodes, independently of precomputation.
+ let supports_projection = !self.exprs().any(|e| {
+ e.exists(|e| {
+ Ok(e.children().is_empty()
+ && !e.is::<Column>()
+ && !e.is::<LambdaVariable>()
+ && !e.is::<Literal>()
+ && !e.is::<ScalarFunctionExpr>())
+ })
+ .expect("Closure cannot fail")
+ });
+
+ // Find the candidates for precomputation and the columns used outside
of them.
+ let mut direct_column_indices = HashSet::<usize>::new();
+ let mut precompute_candidates = IndexSet::<Arc<dyn
PhysicalExpr>>::new();
+ if can_precompute {
+ self.exprs().for_each(|expr| {
+ expr.apply(|expr| {
+ if is_precomputable(expr) {
+ precompute_candidates.insert(Arc::clone(expr));
+ return Ok(TreeNodeRecursion::Jump);
+ }
+ if let Some(column) = expr.downcast_ref::<Column>() {
+ direct_column_indices.insert(column.index());
+ }
+ Ok(TreeNodeRecursion::Continue)
+ })
+ .expect("Closure cannot fail");
+ });
+ }
+
+ // Precomputing only pays off when it spares filtering a column that
is not
+ // otherwise used. Candidates referring only to used columns are
evaluated in place.
+ let precomputed = precompute_candidates
+ .into_iter()
+ .filter(|expr| {
+ collect_columns(expr)
+ .iter()
+ .any(|column|
!direct_column_indices.contains(&column.index()))
+ })
+ .collect::<IndexSet<_>>();
+
+ // Determine the set of columns that are used by the expressions of
the case body
+ // outside of the precomputed expressions.
// Use an ordered set so lambda variables continue to be positioned
after columns
let mut used_column_indices = BTreeSet::<usize>::new();
- let mut supports_projection = true;
- let mut collect_column_indices = |expr: &Arc<dyn PhysicalExpr>| {
+ self.exprs().for_each(|expr| {
expr.apply(|expr| {
+ if !precomputed.is_empty() && precomputed.contains(expr) {
Review Comment:
Optional cleanup: this hashes every visited node, and a nested `CaseExpr`
hashes its whole subtree. Checking `is_precomputable` first, as the rewrite
below does, limits hashing to real candidates.
```suggestion
if !precomputed.is_empty()
&& is_precomputable(expr)
&& precomputed.contains(expr)
{
```
--
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]