This is a tracking issue for refactoring expression optimization.
Motivation
(Logical) expression optimization currently operates on an unbound Expression and a root DType and is centered around scalar functions. For every scalar-function node, it repeatedly invokes three hooks in this order:
ScalarFnVTable::simplify_untyped;
ScalarFnVTable::simplify, using SimplifyCtx for type lookup; and
ScalarFnVTable::reduce, using ExpressionReduceNode.
It first optimizes the root, then its children, and then the root again. A separate find_between
pass runs afterward.
Moreover, expression constructors also call Expression::optimize before returning. At other points in the codebase, Expression::optimize_recursive is also called.
There are several problems with this design:
- Optimization policy enlarges both
ScalarFnVTable and the private DynScalarFn object-safe
interface.
- A function can provide only one implementation for each hook, so multiple independently named
and ordered rules are awkward.
- Cross-function rules do not fit a function vtable, which is why
find_between is a separate
top-level pass.
- The optimizer operates on untyped expressions even though we have
BoundExpressions now. This creates the need for a dtype cache (SimplifyCache) for performance.
- We want to optimize other types of expressions beyond
ScalarFns, like incoming Higher Order Functions.
- We should optimize the entire tree only once.
- Adding generic constant folding, normalization, or common-subexpression elimination would add
more special phases rather than extending one optimizer.
In general, it would be beneficial to have a rule-based expression optimizer that operates on a bound expression tree.
Design
BoundExpression Is the Optimizer IR
BoundExpression is already a type-checked tree whose nodes store their result dtype. It becomes
the only expression representation accepted and produced by the optimizer.
Expression
-> bind against Scope
-> BoundExpression
-> optimize
-> optimized BoundExpression
Rules read node.dtype() and child dtypes directly. Creating or rebuilding a scalar node goes
through BoundExpression::try_new, which recomputes and validates its return dtype.
This removes the need for SimplifyCtx, SimplifyCache, and simplify_untyped.
Rule Interface
The type-erased rule interface is intentionally small:
pub trait ExprRewriteRule: Debug + Send + Sync + 'static {
fn name(&self) -> &'static str;
fn rewrite(
&self,
node: &BoundExpression,
ctx: &RewriteContext,
) -> VortexResult<Rewrite>;
}
pub enum Rewrite {
Unchanged,
Rewritten(BoundExpression),
}
RewriteContext gives a rule access to the VortexSession, optimizer configuration, and tracing
facilities. It does not expose arrays or an ExecutionCtx.
Most rules target a concrete scalar-function vtable and need typed access to its options. A typed
interface and erased adapter provide this without adding methods to DynScalarFn:
pub trait TypedExprRewriteRule<V: ScalarFnVTable>:
Debug + Send + Sync + 'static
{
fn name(&self) -> &'static str;
fn rewrite(
&self,
options: &V::Options,
node: &BoundExpression,
ctx: &RewriteContext,
) -> VortexResult<Rewrite>;
}
The adapter checks node.as_opt::<V>(), obtains V::Options, and calls the typed rule. Rule source
files may remain next to their scalar functions; moving a rule outside the vtable does not require
moving it far from the function implementation.
Rule Registry
Expression rules live in a session-scoped ExpressionOptimizerSession, separate from
ScalarFnSession:
session
.expression_optimizer()
.register::<Binary>(BooleanLiteralIdentities);
session
.expression_optimizer()
.register::<Binary>(FindBetween);
The registry stores an ordered rule list keyed by root ScalarFnId. Rules registered for a root are
the only function-specific rules considered at that node. A short ordered fallback list supports
rules that apply to every scalar function, such as future generic constant folding.
Registration order is evaluation order. When a rule rewrites a node, evaluation restarts at the
first rule for the replacement root. The registry is snapshotted at the beginning of an optimization
so concurrent registrations cannot change a rewrite halfway through.
Plug-in initialization registers scalar functions and expression rules separately. A missing rule
may reduce performance but must never affect correctness. Replacing a scalar-function registration
does not make an old typed rule unsafe: its adapter declines nodes whose concrete vtable does not
match.
Built-in rules are installed by ExpressionOptimizerSession::default. ScalarFnSession remains
the registry for scalar-function serialization and deserialization only.
ExpressionOptimizerSession::snapshot returns an immutable ExpressionOptimizer containing the
ordered rule lists used for one optimization run. This keeps registry mutation out of the rewrite
driver and gives the public optimization API a small explicit dependency rather than the entire
session.
Rewrite Driver
The driver performs deterministic bottom-up rewriting with an explicit work stack rather than Rust
recursion:
- Visit children in argument order.
- Rebuild the parent if any child changed.
- Try the rules registered for the parent root, followed by fallback rules.
- If a rule fires, validate the replacement and enqueue the replacement subtree.
- Revisit the parent after any replacement child reaches a fixpoint.
- Stop when the root and every descendant are stable.
Reprocessing a replacement subtree is essential. A rule may introduce a node whose children were
not present during the first traversal.
The optimizer maintains a global rewrite budget. Exceeding it returns an error containing the most
recent rule names and expression roots. Returning a structurally identical replacement is also an
optimizer error rather than a change.
The initial driver uses first-match rule ordering. It does not enumerate alternatives or assign
costs. If Vortex later needs cost-based selection, the explicit rule and optimizer boundary provides
a place to add it without changing scalar functions.
Steps
Unresolved questions
Implementation history
This is a tracking issue for refactoring expression optimization.
Motivation
(Logical) expression optimization currently operates on an unbound
Expressionand a rootDTypeand is centered around scalar functions. For every scalar-function node, it repeatedly invokes three hooks in this order:ScalarFnVTable::simplify_untyped;ScalarFnVTable::simplify, usingSimplifyCtxfor type lookup; andScalarFnVTable::reduce, usingExpressionReduceNode.It first optimizes the root, then its children, and then the root again. A separate
find_betweenpass runs afterward.
Moreover, expression constructors also call
Expression::optimizebefore returning. At other points in the codebase,Expression::optimize_recursiveis also called.There are several problems with this design:
ScalarFnVTableand the privateDynScalarFnobject-safeinterface.
and ordered rules are awkward.
find_betweenis a separatetop-level pass.
BoundExpressions now. This creates the need for a dtype cache (SimplifyCache) for performance.ScalarFns, like incoming Higher Order Functions.more special phases rather than extending one optimizer.
In general, it would be beneficial to have a rule-based expression optimizer that operates on a bound expression tree.
Design
BoundExpression Is the Optimizer IR
BoundExpressionis already a type-checked tree whose nodes store their result dtype. It becomesthe only expression representation accepted and produced by the optimizer.
Rules read
node.dtype()and child dtypes directly. Creating or rebuilding a scalar node goesthrough
BoundExpression::try_new, which recomputes and validates its return dtype.This removes the need for
SimplifyCtx,SimplifyCache, andsimplify_untyped.Rule Interface
The type-erased rule interface is intentionally small:
RewriteContextgives a rule access to theVortexSession, optimizer configuration, and tracingfacilities. It does not expose arrays or an
ExecutionCtx.Most rules target a concrete scalar-function vtable and need typed access to its options. A typed
interface and erased adapter provide this without adding methods to
DynScalarFn:The adapter checks
node.as_opt::<V>(), obtainsV::Options, and calls the typed rule. Rule sourcefiles may remain next to their scalar functions; moving a rule outside the vtable does not require
moving it far from the function implementation.
Rule Registry
Expression rules live in a session-scoped
ExpressionOptimizerSession, separate fromScalarFnSession:session .expression_optimizer() .register::<Binary>(BooleanLiteralIdentities); session .expression_optimizer() .register::<Binary>(FindBetween);The registry stores an ordered rule list keyed by root
ScalarFnId. Rules registered for a root arethe only function-specific rules considered at that node. A short ordered fallback list supports
rules that apply to every scalar function, such as future generic constant folding.
Registration order is evaluation order. When a rule rewrites a node, evaluation restarts at the
first rule for the replacement root. The registry is snapshotted at the beginning of an optimization
so concurrent registrations cannot change a rewrite halfway through.
Plug-in initialization registers scalar functions and expression rules separately. A missing rule
may reduce performance but must never affect correctness. Replacing a scalar-function registration
does not make an old typed rule unsafe: its adapter declines nodes whose concrete vtable does not
match.
Built-in rules are installed by
ExpressionOptimizerSession::default.ScalarFnSessionremainsthe registry for scalar-function serialization and deserialization only.
ExpressionOptimizerSession::snapshotreturns an immutableExpressionOptimizercontaining theordered rule lists used for one optimization run. This keeps registry mutation out of the rewrite
driver and gives the public optimization API a small explicit dependency rather than the entire
session.
Rewrite Driver
The driver performs deterministic bottom-up rewriting with an explicit work stack rather than Rust
recursion:
Reprocessing a replacement subtree is essential. A rule may introduce a node whose children were
not present during the first traversal.
The optimizer maintains a global rewrite budget. Exceeding it returns an error containing the most
recent rule names and expression roots. Returning a structurally identical replacement is also an
optimizer error rather than a change.
The initial driver uses first-match rule ordering. It does not enumerate alternatives or assign
costs. If Vortex later needs cost-based selection, the explicit rule and optimizer boundary provides
a place to add it without changing scalar functions.
Steps
BoundExpressionOptimizerUnresolved questions
Implementation history