Skip to content

Commit 429e52a

Browse files
authored
Optimize rewrite performance (#115)
* Optimize rewrite performanc * Add test * fmt * Address comments to add details
1 parent 02bad14 commit 429e52a

1 file changed

Lines changed: 142 additions & 19 deletions

File tree

src/rewrite/normal_form.rs

Lines changed: 142 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -247,32 +247,50 @@ impl SpjNormalForm {
247247
/// This is useful for rewriting queries to use materialized views.
248248
pub fn rewrite_from(
249249
&self,
250-
mut other: &Self,
250+
other: &Self,
251251
qualifier: TableReference,
252252
source: Arc<dyn TableSource>,
253253
) -> Result<Option<LogicalPlan>> {
254254
log::trace!("rewriting from {qualifier}");
255+
256+
// Cache columns() result to avoid repeated Vec allocation in the loop.
257+
// DFSchema::columns() creates a new Vec on each call.
258+
let output_columns = self.output_schema.columns();
259+
255260
let mut new_output_exprs = Vec::with_capacity(self.output_exprs.len());
256261
// check that our output exprs are sub-expressions of the other one's output exprs
257262
for (i, output_expr) in self.output_exprs.iter().enumerate() {
258-
let new_output_expr = other
259-
.predicate
260-
.normalize_expr(output_expr.clone())
261-
.rewrite(&mut other)?
262-
.data;
263-
264-
// Check that all references to the original tables have been replaced.
265-
// All remaining column expressions should be unqualified, which indicates
266-
// that they refer to the output of the sub-plan (in this case the view)
267-
if new_output_expr
268-
.column_refs()
269-
.iter()
270-
.any(|c| c.relation.is_some())
271-
{
272-
return Ok(None);
273-
}
263+
// Fast path for simple Column expressions (most common case).
264+
// This avoids the expensive normalize_expr transform for columns.
265+
let new_output_expr = if let Expr::Column(col) = output_expr {
266+
let normalized_col = other.predicate.normalize_column(col);
267+
match other.find_output_column(&normalized_col) {
268+
Some(rewritten) => rewritten,
269+
None => return Ok(None), // Column not found, can't rewrite
270+
}
271+
} else {
272+
// Slow path: complex expressions need full transform
273+
let new_output_expr = other
274+
.predicate
275+
.normalize_expr(output_expr.clone())
276+
.rewrite(&mut &*other)?
277+
.data;
278+
279+
// Check that all references to the original tables have been replaced.
280+
// All remaining column expressions should be unqualified, which indicates
281+
// that they refer to the output of the sub-plan (in this case the view)
282+
if new_output_expr
283+
.column_refs()
284+
.iter()
285+
.any(|c| c.relation.is_some())
286+
{
287+
return Ok(None);
288+
}
289+
new_output_expr
290+
};
274291

275-
let column = &self.output_schema.columns()[i];
292+
// Use cached columns instead of calling .columns() on each iteration
293+
let column = &output_columns[i];
276294
new_output_exprs.push(
277295
new_output_expr.alias_qualified(column.relation.clone(), column.name.clone()),
278296
);
@@ -299,7 +317,7 @@ impl SpjNormalForm {
299317
.into_iter()
300318
.chain(range_filters)
301319
.chain(residual_filters)
302-
.map(|expr| expr.rewrite(&mut other).unwrap().data)
320+
.map(|expr| expr.rewrite(&mut &*other).unwrap().data)
303321
.reduce(|a, b| a.and(b));
304322

305323
if all_filters
@@ -318,6 +336,20 @@ impl SpjNormalForm {
318336

319337
builder.project(new_output_exprs)?.build().map(Some)
320338
}
339+
340+
/// Fast path: find a column in output_exprs and return rewritten expression.
341+
/// This avoids full tree traversal for simple column lookups.
342+
#[inline]
343+
fn find_output_column(&self, col: &Column) -> Option<Expr> {
344+
self.output_exprs
345+
.iter()
346+
.position(|e| matches!(e, Expr::Column(c) if c == col))
347+
.map(|idx| {
348+
Expr::Column(Column::new_unqualified(
349+
self.output_schema.field(idx).name().clone(),
350+
))
351+
})
352+
}
321353
}
322354

323355
/// Stores information on filters from a Select-Project-Join plan.
@@ -431,6 +463,17 @@ impl Predicate {
431463
.and_then(|&idx| self.eq_classes.get(idx))
432464
}
433465

466+
/// Fast path: normalize a single Column without full tree traversal.
467+
/// This is O(1) lookup instead of O(n) transform.
468+
#[inline]
469+
fn normalize_column(&self, col: &Column) -> Column {
470+
if let Some(eq_class) = self.class_for_column(col) {
471+
eq_class.columns.first().unwrap().clone()
472+
} else {
473+
col.clone()
474+
}
475+
}
476+
434477
/// Add a new column equivalence
435478
fn add_equivalence(&mut self, c1: &Column, c2: &Column) -> Result<()> {
436479
match (
@@ -792,6 +835,15 @@ impl Predicate {
792835
/// Rewrite all expressions in terms of their normal representatives
793836
/// with respect to this predicate's equivalence classes.
794837
fn normalize_expr(&self, e: Expr) -> Expr {
838+
// Fast path: if it's a simple Column, avoid full transform traversal.
839+
// Even though transform() handles Column efficiently, the machinery setup
840+
// (closures, iterators, Transformed<T> wrappers) has overhead that adds up
841+
// when called thousands of times (e.g., 41 columns × 5-7 MVs × every query).
842+
// Direct HashMap lookup + clone is significantly faster.
843+
if let Expr::Column(ref c) = e {
844+
return Expr::Column(self.normalize_column(c));
845+
}
846+
795847
e.transform(&|e| {
796848
let c = match e {
797849
Expr::Column(c) => c,
@@ -1320,4 +1372,75 @@ mod test {
13201372

13211373
Ok(())
13221374
}
1375+
1376+
#[tokio::test]
1377+
async fn test_normalize_column_fast_path() -> Result<()> {
1378+
let ctx = SessionContext::new();
1379+
1380+
ctx.sql("CREATE TABLE t (a INT, b INT, c INT)")
1381+
.await?
1382+
.collect()
1383+
.await?;
1384+
1385+
// Query with column equivalence: a = b
1386+
let plan = ctx
1387+
.sql("SELECT a, b, c FROM t WHERE a = b")
1388+
.await?
1389+
.into_optimized_plan()?;
1390+
1391+
let normal_form = SpjNormalForm::new(&plan)?;
1392+
1393+
// Verify that columns are normalized correctly
1394+
// a and b should be in the same equivalence class
1395+
assert_eq!(normal_form.output_exprs().len(), 3);
1396+
1397+
Ok(())
1398+
}
1399+
1400+
#[tokio::test]
1401+
async fn test_rewrite_from_with_many_columns() -> Result<()> {
1402+
let ctx = SessionContext::new();
1403+
1404+
// Create a wide table to test the columns() caching optimization
1405+
ctx.sql(
1406+
"CREATE TABLE wide_table (
1407+
c0 INT, c1 INT, c2 INT, c3 INT, c4 INT,
1408+
c5 INT, c6 INT, c7 INT, c8 INT, c9 INT
1409+
)",
1410+
)
1411+
.await?
1412+
.collect()
1413+
.await?;
1414+
1415+
let base_plan = ctx
1416+
.sql("SELECT * FROM wide_table WHERE c0 >= 0")
1417+
.await?
1418+
.into_optimized_plan()?;
1419+
1420+
let query_plan = ctx
1421+
.sql("SELECT c0, c1, c2 FROM wide_table WHERE c0 >= 10")
1422+
.await?
1423+
.into_optimized_plan()?;
1424+
1425+
let base_nf = SpjNormalForm::new(&base_plan)?;
1426+
let query_nf = SpjNormalForm::new(&query_plan)?;
1427+
1428+
// Create MV table
1429+
ctx.sql("CREATE TABLE mv AS SELECT * FROM wide_table WHERE c0 >= 0")
1430+
.await?
1431+
.collect()
1432+
.await?;
1433+
1434+
let table_ref = TableReference::bare("mv");
1435+
let provider = ctx.table_provider(table_ref.clone()).await?;
1436+
1437+
// Test that rewrite_from works correctly with cached columns
1438+
let result = query_nf.rewrite_from(&base_nf, table_ref, provider_as_source(provider))?;
1439+
1440+
assert!(result.is_some());
1441+
let rewritten = result.unwrap();
1442+
assert_eq!(rewritten.schema().fields().len(), 3);
1443+
1444+
Ok(())
1445+
}
13231446
}

0 commit comments

Comments
 (0)