fix(lambda): only push referenced params into the merged batch#22853
fix(lambda): only push referenced params into the merged batch#22853Adam-Alani wants to merge 2 commits into
Conversation
Per maintainer request on PR apache#22689, the `LambdaExpr::try_new` / `expressions::lambda(...)` signature change (adding `outer_columns_count`) is being reviewed separately in apache#22853 because it's a breaking change to the physical-expr public API and warrants its own attention, distinct from this additive UDF. This commit reverts the `lambda.rs` / `higher_order_function.rs` / `planner.rs` files to their `upstream/main` state, removes the sqllogictest file (every query in it uses `(k, v) -> body` lambdas that require the upstream fix), and marks the unit tests that exercise multi-parameter lambdas with `#[ignore = "blocked on apache#22853: multi-param lambda projection fix"]`. `transform_values_uses_keys_via_case` and `transform_values_all_null_rows_returns_null_array` still pass because the former references both `k` and `v` (so projection is a no-op) and the latter short-circuits before evaluating the lambda. This PR will be rebased onto main once apache#22853 merges, at which point the ignore markers will be removed and the sqllogictest file restored.
|
@gstvg can you please take a look? |
|
Thanks @Adam-Alani for unconvering and fixing this. This looks great to me, but I wonder if we want to fix this without breaking changes so that it can be trivially backported to the 54 branch? If you and @rluvaton agree on this, is possible to:
That's how it was implemented in the first version of the lambda PR |
@rluvaton let me know if this plan sounds good to you, happy to do that. |
|
I imagined another approach:
Because this skips the evaluation of declared but not used parameters, I personally prefer this one, WDYT? |
b9f816f to
f3f6504
Compare
`LambdaExpr` compresses the body's column-index projection by enumerating
every referenced `Column`/`LambdaVariable` index and packing them into a
dense range. That compression is correct for outer captures, but it
silently broke multi-parameter lambdas: a body like `(k, v) -> v` (where
`k` is unused) would have its `LambdaVariable("v")` re-projected from
index 1 to index 0 and then, at runtime, read the slot the higher-order
function had filled with `k`.
Per maintainer feedback on apache#22853, fix it without a breaking
change to `LambdaExpr::try_new` / `expressions::lambda(...)`:
* `LambdaExpr` now tracks `used_params: HashSet<String>` — the subset of
its own declared parameters that the body actually references. The set
is computed during a single walk of the body in `LambdaExpr::new`,
with a shadow stack that ignores `LambdaVariable`s bound by nested
lambdas. For
`(k, v) -> func(col, (k, v2) -> k + v2 + v)` the inner `k` shadows the
outer `k`, so only `v` flows up as used by the outer lambda.
* `LambdaArgument` gets an `Option<HashSet<String>>` for used parameter
names plus a non-breaking `new_with_used_params(...)` constructor.
The existing `new(...)` calls it with `None`, which preserves the old
"push every declared parameter" behavior.
* `LambdaArgument::evaluate` (through `merge_captures_with_variables`)
only evaluates and pushes the closures whose parameter name appears
in `used_params`, preserving the original declaration order. Unused
declared parameters therefore leave no slot in the merged batch, so
the body's compressed indices line up directly with the columns the
evaluator actually built.
* `HigherOrderFunctionExpr::evaluate` calls `new_with_used_params` and
forwards `lambda.used_params().clone()`, so all in-tree higher-order
UDFs benefit automatically without any callsite change.
No public API breakage: `LambdaExpr::try_new`, `expressions::lambda(...)`
and `LambdaArgument::new` keep their existing signatures. Two new
tests cover the unused-parameter case and the nested-lambda shadowing
case; existing tests in `physical-expr` and `expr` continue to pass.
`LambdaExpr` compresses the body's column-index projection by enumerating
every referenced `Column`/`LambdaVariable` index and packing them into a
dense range. That compression is correct for outer captures, but it
silently broke multi-parameter lambdas: a body like `(k, v) -> v` (where
`k` is unused) would have its `LambdaVariable("v")` re-projected from
index 1 to index 0 and then, at runtime, read the slot the higher-order
function had filled with `k`.
Per maintainer feedback on apache#22853, fix it without a breaking
change to `LambdaExpr::try_new` / `expressions::lambda(...)`:
* `LambdaExpr` now tracks `used_params: HashSet<String>` — the subset of
its own declared parameters that the body actually references. The set
is computed during a single walk of the body in `LambdaExpr::new`,
with a shadow stack that ignores `LambdaVariable`s bound by nested
lambdas. For
`(k, v) -> func(col, (k, v2) -> k + v2 + v)` the inner `k` shadows the
outer `k`, so only `v` flows up as used by the outer lambda.
* `LambdaArgument` gets an `Option<HashSet<String>>` for used parameter
names plus a non-breaking `new_with_used_params(...)` constructor.
The existing `new(...)` calls it with `None`, which preserves the old
"push every declared parameter" behavior.
* `LambdaArgument::evaluate` (through `merge_captures_with_variables`)
only evaluates and pushes the closures whose parameter name appears
in `used_params`, preserving the original declaration order. Unused
declared parameters therefore leave no slot in the merged batch, so
the body's compressed indices line up directly with the columns the
evaluator actually built.
* `HigherOrderFunctionExpr::evaluate` calls `new_with_used_params` and
forwards `lambda.used_params().clone()`, so all in-tree higher-order
UDFs benefit automatically without any callsite change.
No public API breakage: `LambdaExpr::try_new`, `expressions::lambda(...)`
and `LambdaArgument::new` keep their existing signatures. Two new
tests cover the unused-parameter case and the nested-lambda shadowing
case; existing tests in `physical-expr` and `expr` continue to pass.
f3f6504 to
559c8be
Compare
|
@gstvg applied the changes. |
gstvg
left a comment
There was a problem hiding this comment.
Thanks @Adam-Alani, LGTM
cc @rluvaton
`LambdaExpr` compresses the body's column-index projection by enumerating
every referenced `Column`/`LambdaVariable` index and packing them into a
dense range. That compression is correct for outer captures, but it
silently broke multi-parameter lambdas: a body like `(k, v) -> v` (where
`k` is unused) would have its `LambdaVariable("v")` re-projected from
index 1 to index 0 and then, at runtime, read the slot the higher-order
function had filled with `k`.
Per maintainer feedback on apache#22853, fix it without a breaking
change to `LambdaExpr::try_new` / `expressions::lambda(...)`:
* `LambdaExpr` now tracks `used_params: HashSet<String>` — the subset of
its own declared parameters that the body actually references. The set
is computed during a single walk of the body in `LambdaExpr::new`,
with a shadow stack that ignores `LambdaVariable`s bound by nested
lambdas. For
`(k, v) -> func(col, (k, v2) -> k + v2 + v)` the inner `k` shadows the
outer `k`, so only `v` flows up as used by the outer lambda.
* `LambdaArgument` gets an `Option<HashSet<String>>` for used parameter
names plus a non-breaking `new_with_used_params(...)` constructor.
The existing `new(...)` calls it with `None`, which preserves the old
"push every declared parameter" behavior.
* `LambdaArgument::evaluate` (through `merge_captures_with_variables`)
only evaluates and pushes the closures whose parameter name appears
in `used_params`, preserving the original declaration order. Unused
declared parameters therefore leave no slot in the merged batch, so
the body's compressed indices line up directly with the columns the
evaluator actually built.
* `HigherOrderFunctionExpr::evaluate` calls `new_with_used_params` and
forwards `lambda.used_params().clone()`, so all in-tree higher-order
UDFs benefit automatically without any callsite change.
No public API breakage: `LambdaExpr::try_new`, `expressions::lambda(...)`
and `LambdaArgument::new` keep their existing signatures. Two new
tests cover the unused-parameter case and the nested-lambda shadowing
case; existing tests in `physical-expr` and `expr` continue to pass.
559c8be to
1fe79ba
Compare
|
@rluvaton small bump on this, thanks! |
LiaCastaneda
left a comment
There was a problem hiding this comment.
I don't want to block the fix, but wondering if there is a non user facing way to fix the issue @Adam-Alani @gstvg lmk what you think and if it makes sense!
| /// [`Self::evaluate`] skips evaluating and pushing the closures for the | ||
| /// parameters not listed here, so unused declared parameters do not shift | ||
| /// the columns the body's compressed indices expect. | ||
| used_param_indices: Option<Vec<usize>>, |
There was a problem hiding this comment.
If we pass None in new() and assume all parameters are used, would it be better to compute the indices directly from params: Vec<FieldRef>? That way this field doesn't need to be an Option it always holds the used parameter indices, which happen to be all of them when called via new()
| /// column indices line up directly. When `used_params` is `None`, | ||
| /// behavior is identical to [`Self::new`]. |
There was a problem hiding this comment.
Should we just require used_params to be set in this function? Otherwise the behavior is the same as new, so I'm not sure why we'd need both for that case
There was a problem hiding this comment.
Providing an api where we have to specify the indices of the parameters that will be used in the body feels a bit unergonomic. Have you considered somehow extracting this from the body and handling this internally so it's invisible to the caller?
There was a problem hiding this comment.
Also, the body is dynamic, which technically means for higher order functions indices positions can differ for each query no? for example if you have a Higher Order function with parameters (x,y,x) for a given query you can use x,y or y or all of them. This essentially means external callers of LambdaArgument::new_with_used_params would have to walk the body themselves to figure out which params are referenced.
edit: is it actually possible to build a LambdaArgument like as an api? they are technically build in DF before calling the invoke_with_args api 🤔 so I guess new_with_used_params would only be called in higher_order_function.rs in evaluate inside DataFusion. If that's the case, I would consider resolving this inside LambdaExpr instead, keeping the fix self-contained there
There was a problem hiding this comment.
Yes, is dynamic and require walking the body, but at least for this PR, it's folded with the walk that collect used indices.
is it actually possible to build a LambdaArgument like as an api?
Technically yes but not because I imagined a usecase for it outside datafusion, but only to be able to use it in evaluate which is within another crate
Some other ideas besides #22853 (comment):
- Providing the
LambdaExpritself (instead of it's body) as theArc<dyn PhyiscalExpr>parameter ofLambdaArgument::new, soevaluatecan get the used params by downcasting the expr toLambdaExpr(or returning an error if it fails) - computing the lambda used params in
LambdaArgument::evaluate, at the small cost of a tree traversal per evaluation instead of only during planning
| body, | ||
| projected_body, | ||
| projection, | ||
| used_params: used_param_names, |
There was a problem hiding this comment.
Since used_param_names is already computed here during construction, could the fix live entirely in projected_body instead? Rather than threading used_params through to LambdaArgument, then LambdaArgument stays unchanged and external callers don't need to think about used params at all
Left a rough idea here LiaCastaneda@993ac37
There was a problem hiding this comment.
The initial version of this PR b9f816f did that, but introduced a breaking change by adding a new argument outer_columns_count in LambdaExpr::try_new. I then proposed non-breaking alternatives #22853 (comment) (which is self-contained but uses null arrays to avoid uncaptured columns copies) and #22853 (comment) (the current version, which has the small advantage of skipping the evaluation of declared but unused parameters, for example (x, y) -> x+1). Another self-contained fix that stills uses projection, and maybe also skip unused params would be great. Using the first suggestion is fine by me as well. WDYT?
There was a problem hiding this comment.
ok thanks for explaining, so the correctness fix can be self contained in datafusion-physical-expr with no new public API, but the optimization of skipping unused param evaluation requires crossing into datafusion-expr, which forces new_with_used_params to be public (even if it's only meant to be used internally).
My approach is non-breaking but does not skip unused param evaluation. I'm not sure this is achievable while keeping the fix self contained in datafusion-physical-expr, since the evaluate lives in datafusion-expr.
I think the optimization (therefore this approach) is worth keeping, the cost of evaluating the unused param will grow with the number of map entries, so for large inputs skipping an unused param closure might be meaningful
Which issue does this PR close?
Rationale for this change
Extracted from #22689 per maintainer request: the original UDF and the underlying lambda fix deserve separate review threads. This PR carries only the lambda fix.
LambdaExprpreviously compressed the column-index projection by enumerating every referencedColumn/LambdaVariableindex and packing them into a dense range. That collapse is correct for outer captures (and is a no-op for single-parameter lambdas, which is whyarray_transformwas never affected), but it also moves lambda parameters around. A two-parameter lambda like(k, v) -> v(withkunused) would have itsLambdaVariableforvre-projected from index 1 to index 0 — so at runtime the body reads the slot the higher-order function had filled withkand silently returns the wrong column.This is a latent bug today — no in-tree higher-order function exercises it — but it blocks #22689 (
transform_values, which uses(k, v) -> bodylambdas) and any future HOF that takes more than one parameter.What changes are included in this PR?
Per @gstvg's suggested non-breaking approach in the discussion thread:
LambdaExprnow tracksused_params: HashSet<String>— the subset of its own declared parameters that the body actually references. It is computed during a single walk of the body inLambdaExpr::new, with a shadow stack that ignoresLambdaVariables bound by nested lambdas. For(k, v) -> func(col, (k, v2) -> k + v2 + v)the innerkshadows the outerk, so onlyvflows up as used by the outer lambda.LambdaArgumentgets anOption<HashSet<String>>for the used parameter names plus a non-breakingLambdaArgument::new_with_used_params(...)constructor. The existingLambdaArgument::new(...)calls it withNone, which preserves the old "push every declared parameter" behavior — so external callers that buildLambdaArgumentdirectly keep working unchanged.LambdaArgument::evaluate(throughmerge_captures_with_variables) only evaluates and pushes the closures whose parameter name appears inused_params, preserving the original declaration order. Unused declared parameters therefore leave no slot in the merged batch, so the body's compressed indices line up directly with the columns the evaluator actually built.HigherOrderFunctionExpr::evaluatecallsLambdaArgument::new_with_used_params(...)and forwardslambda.used_params().clone(), so all in-tree higher-order UDFs pick up the fix automatically with no callsite change.Compared to the previous revision of this PR (which added an
outer_columns_count: usizeparameter toLambdaExpr::try_newandexpressions::lambda(...)), this revision:LambdaExpr::try_new,expressions::lambda(...)andLambdaArgument::newkeep their existing signatures.cargo-semver-checksshould be clean now.Are these changes tested?
Yes, two new unit tests in
datafusion/physical-expr/src/expressions/lambda.rs:test_used_params_collects_only_referenced_param— a(k, v) -> vlambda reports only{\"v\"}as used.test_used_params_handles_shadowing_inside_nested_lambda— for(k, v) -> col + (k, v2) -> k + v2 + v, the outer lambda'sused_paramsis{\"v\"}only; the innerkdoes not flag the outerkas used.The existing
test_lambda_evaluate,test_lambda_duplicate_name, andtest_higher_order_function_*tests continue to pass.cargo test -p datafusion-expr higher_order(11 tests) andcargo test -p datafusion-physical-expr lambda(7 tests) both pass.Are there any user-facing changes?
No breaking changes.
LambdaArgumentgains a new non-breaking constructor and a new optional field; the rest is purely internal correctness.