Yang Jie created SPARK-58902:
--------------------------------

             Summary: Evaluate a multi-referenced common expression lazily 
instead of pre-evaluating it in a Project
                 Key: SPARK-58902
                 URL: https://issues.apache.org/jira/browse/SPARK-58902
             Project: Spark
          Issue Type: Improvement
          Components: SQL
    Affects Versions: 5.0.0
            Reporter: Yang Jie


`With` promises a common expression is evaluated only once even when referenced 
more
than once (see the scaladoc on `With`). `RewriteWithExpression` keeps that 
promise by
hoisting the definition into a `Project`, which works because a `Project` is 
evaluated
for every row. Inside a conditional branch that is not true: the branch may not 
be
evaluated at all, so the rule inlines instead, and inlining hands each 
reference its own
evaluation.

For a nondeterministic definition that is wrong, not just wasteful. SPARK-58818 
fixed the
case that is reachable through the generators which cannot raise, by 
pre-evaluating them
in the child `Project` anyway. That fix is bounded by an allowlist
(`canPreEvaluateInBranch`): pre-evaluation happens on every row, so an 
expression that can
raise would turn a wrong result into a spurious error. Everything the allowlist 
turns down
keeps the old inlining and the old wrong result, which the code says in as many 
words.

h3. What is still wrong

{code:sql}
-- randstr is referenced twice by BETWEEN and inlined, so the two comparisons 
see
-- two different strings
SELECT CASE WHEN a < 0 THEN false
            ELSE randstr(3, 0) BETWEEN 'a' AND 'b' END
FROM t
{code}

The same shape with {{nullif}}, and the same for {{uniform(lo, hi)}}, 
{{shuffle(arr)}},
{{reflect(...)}}, {{rand() / col}}, {{cast(rand() as int)}} -- anything 
nondeterministic
that is not one of the six leaf generators on the allowlist. Note the allowlist
approximates "cannot raise" by syntactic shape, so {{randstr(3, 0)}} is turned 
down for
being a {{BinaryLike}} even though only {{randstr(-1, 0)}} raises.

Outside a conditional branch these are correct, because the main rewrite hoists 
any
multi-referenced non-cheap definition without consulting the allowlist. The 
wrong results
are specific to a definition that is both inside a branch and turned down by 
the allowlist.

There is a second, older instance of the same root cause. When a join 
condition's common
expression references columns from both sides, no single child plan can hold 
the column,
so `RewriteWithExpression` force-inlines it. The rule carries a TODO admitting 
this goes
wrong for a nondeterministic definition and is kept only to match the old buggy 
behavior.

h3. What lazy memoization would give

An evaluable expression that caches its value per row, so a reference reads the 
value the
first time it is reached and reuses it after. Then:

* The allowlist can go. Evaluation happens only where the original expression 
would have
  evaluated, so an expression that can raise raises on exactly the rows it did 
before, and
  the wrong results above are fixed as one class rather than one generator at a 
time.
* The evaluation domain is per reference reached, not per branch reached. A 
reference
  behind a short-circuiting operator (`a > 0 AND rand() BETWEEN 0.4 AND 0.6`) 
or inside a
  nested conditional is not read on every row of the branch; pre-evaluation 
cannot express
  that, memoization does.
* The join TODO goes away, since the definition no longer has to be placed in a 
child
  plan.
* The supporting machinery in `RewriteWithExpression` -- child projects, column 
naming,
  the per-child registry, projecting the extra columns away, the interaction 
with
  `CollapseProject` -- is no longer needed for these cases.

h3. Why the existing machinery does not cover it

Subexpression elimination cannot be reused. 
`EquivalentExpressions.updateExprInMap` is
gated on `expr.deterministic`, so `rand`/`randn` never become common 
subexpressions, and
`updateExprTree` additionally skips every `LeafExpression`, which excludes 
`uuid`,
`monotonically_increasing_id`, `spark_partition_id` and `input_file_name` twice 
over.
Where it does apply it is eager rather than lazy: codegen emits one `subExpr` 
function per
common expression and calls them all up front, and 
`SubExprEliminationState.children` only
orders dependencies. Interpreted evaluation does have per-row memoization in
`SubExprEvaluationRuntime`, but its proxies come from the same gated map.

So this needs a new expression with per-row caching plus codegen support.
`With`, `CommonExpressionDef` and `CommonExpressionRef` are all `Unevaluable` 
today, which
is why `RewriteWithExpression` has to eliminate them before execution.

h3. A negative result worth recording

SPARK-58818 also tried the closest thing reachable in the plan: keep the eager
pre-evaluation but wrap the column in `If(branch is reached, definition, 
default)`, so a
stateful generator is only advanced on the rows that reach the branch. It does 
not hold up.
A guard in a plain `Project` is outside conditional evaluation, so subexpression
elimination hoists a part repeated across guards to the top of the projection 
and evaluates
it eagerly (`spark.sql.subexpressionElimination.skipForShortcutExpr` is false 
by default),
and a condition that can raise then raises on rows the branch never reached:

{code:sql}
SELECT CASE WHEN a = 0 THEN false
            WHEN 6 / a > 2 THEN rand(1) BETWEEN 0 AND 1
            WHEN 6 / a < -2 THEN rand(2) BETWEEN 0 AND 1
            ELSE false END
FROM t   -- t contains a = 0
{code}

gives DIVIDE_BY_ZERO with the frame in `project_subExpr_0$`. Restricting guards 
to nodes
that cannot raise fixes that but leaves only plain column comparisons: `a + b > 
0`,
`abs(a) > 0`, `upper(s) = 'X'`, `cast(a as long) > 0` all lose the guard, 
silently. The
guard is a dead end; memoization is not affected by any of this, since it does 
not put the
condition anywhere.




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

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

Reply via email to