mzabaluev opened a new issue, #26581:
URL: https://github.com/apache/datafusion/issues/26581

   ### Describe the bug
   
   `CaseExpr` (the `NoExpression`, `WithExpression` and 
`ExpressionOrExpression` eval methods)
   evaluates each `WHEN` branch on a filtered copy of the batch. Per branch, it 
calls
   `filter_record_batch` once to get the rows the branch takes, and once more 
to get the rows left
   for the next branch. `CaseBody::project()` (#18329) limits this to the 
columns the `CASE`
   references. But it collects whole top-level `Column`s, so a `THEN s['a']` 
keeps the entire struct
   column `s` in the projected batch.
   
   When `s` is wide or nested (lists of structs, maps), every branch copies all 
of `s`, twice, to
   read one scalar field. The cost becomes proportional to branches × struct 
size rather than
   branches × field size.
   
   SQL planned through the logical optimizer usually avoids this: leaf 
expression pushdown
   (`datafusion.optimizer.enable_leaf_expression_pushdown`) moves the 
`get_field` calls into a
   projection below the `CASE`. Physical plans that are not built from an 
optimized DataFusion
   logical plan hit it. One example is engines like Comet, that translate 
another system's query plans into
   DataFusion physical expressions. So does SQL with that option turned off.
   
   ### To Reproduce
   
   With the `datafusion` Python package (55.0.0) and `pyarrow`: table `t(id, k, 
s)`, where
   `k = id % 9` picks the branch and `s` is a struct of eight `bigint` fields 
`a`..`h` plus
   `items: list<struct<x: bigint, y: string>>` with N items per row. Each 
`WHEN` reads a different
   scalar field of `s`; none reads `items`.
   
   ```python
   import time, datafusion, pyarrow as pa, pyarrow.compute as pc
   
   ROWS, ITEMS, BATCH = 4_000_000, 32, 8192
   item_t = pa.struct([("x", pa.int64()), ("y", pa.string())])
   
   def make_batch(start, n):
       ids = pa.array(range(start, start + n), pa.int64())
       fields = [pc.multiply(ids, i + 1) for i in range(8)]
       xs = pa.array(range(n * ITEMS), pa.int64())
       items = pa.ListArray.from_arrays(
           pa.array([i * ITEMS for i in range(n + 1)], pa.int32()),
           pa.StructArray.from_arrays([xs, pc.cast(xs, pa.string())], 
fields=list(item_t)))
       s = pa.StructArray.from_arrays(fields + [items], names=list("abcdefgh") 
+ ["items"])
       return pa.record_batch([ids, pc.remainder(ids, 9), s], names=["id", "k", 
"s"])
   
   ctx = datafusion.SessionContext()
   ctx.sql("SET datafusion.optimizer.enable_leaf_expression_pushdown = 
false").collect()
   ctx.register_record_batches("t", [[make_batch(i, min(BATCH, ROWS - i)) for i 
in range(0, ROWS, BATCH)]])
   
   case = "CASE " + " ".join(f"WHEN k = {i} THEN {{{c}}}" for i, c in 
enumerate("abcdefgh")) + " ELSE -1 END"
   on_struct = "SELECT sum(" + case.format(**{c: f"s['{c}']" for c in 
"abcdefgh"}) + ") FROM t"
   on_fields = ("SELECT sum(" + case.format(**{c: c for c in "abcdefgh"}) + ") 
FROM (SELECT k, "
                + ", ".join(f"s['{c}'] AS {c}" for c in "abcdefgh") + " FROM 
t)")
   
   for name, sql in [("CASE over s['a'] .. s['h']", on_struct), ("CASE over 
extracted fields", on_fields)]:
       times = []
       for _ in range(3):
           t0 = time.perf_counter(); ctx.sql(sql).collect(); 
times.append(time.perf_counter() - t0)
       print(f"{name}: {min(times):.3f} s")
   ```
   
   The two queries return the same result. `EXPLAIN` shows `CASE WHEN t.k = 
Int64(0) THEN t.s[a] …`
   evaluated straight over the scan in the first, and over a `ProjectionExec` of
   `get_field(s@1, a) as a, …` in the second.
   
   Best of 3, 4M rows, 14-core Apple M4 Pro:
   
   | `items` per row | `CASE` over `s['a']`..`s['h']` | `CASE` over extracted 
fields | Ratio |
   | --- | --- | --- | --- |
   | 0 | 0.031 s | 0.017 s | 1.8× |
   | 8 | 0.069 s | 0.020 s | 3.5× |
   | 32 | 0.260 s | 0.022 s | 12× |
   | 128 | 1.073 s | 0.024 s | 46× |
   
   With `enable_leaf_expression_pushdown` left at its default (`true`), both 
take about 0.02 s.
   
   ### Expected behavior
   
   The cost of evaluating `CASE` should depend on the fields the branches read, 
not on the size of
   the struct those fields live in.
   
   In a real workload this matters a lot. We hit it in a Spark-compatible 
engine that builds
   DataFusion physical plans directly: a production-derived query projects 
about 90 `CASE`
   expressions after an `explode`, and about 30 of them read fields of large 
nested structs (lists
   of structs, maps). A `perf` profile of that stage shows `CASE` evaluation 
dominated by
   `filter_struct` on those structs. That build also turned off 
`CaseBody::project()` because of
   #21231, which widens the copy to every column. But even with the projection, 
each such `CASE`
   still copies its whole struct twice per branch.
   
   ### Possible solutions
   
   1. **Project field accesses, not just columns.** In `CaseBody::project()`, 
treat a struct field
      access chain on a column (`get_field(col, 'a')`, possibly nested) as a 
projected leaf: compute
      it once on the input batch, put the result in the projected batch, and 
rewrite the body to
      reference it. The per-branch filters then copy only the fields that are 
read. This is the same
      rewrite leaf expression pushdown does in the logical plan, applied inside 
`CaseExpr` for plans
      that don't go through it.
   2. **Skip input filtering for cheap branches.** When every `THEN`/`ELSE` is 
cheap and infallible
      (column references, field accesses, literals), evaluate the branch values 
on the full batch
      and merge the results by the `WHEN` masks, instead of filtering the 
inputs.
   3. Separately, filtering nested list/struct arrays through 
`MutableArrayData` row ranges is
      expensive in itself (`set_bits` and the per-child `build_extend` 
closures), which is arrow-rs
      territory.
   
   ### Additional context
   
   - #18329 added the `CASE` input projection.
   - #21231 tracks the projection's handling of custom column expressions.
   - #22487 is a similar pattern: a sort key over `get_field` is evaluated 
against the materialized
     struct rather than the base column.
   


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