...
 
Commits (4)
  • Matt Jibson's avatar
    opt: add costs for join filters · 284d3855
    Matt Jibson authored
    Previously JOIN ON filters were costed very simply or not at all. This
    adds filter costing for all JOINs and adds a common place to extend those.
    
    This is a prerequisite for #48214.
    
    Add a memo test in join for geolookup so that future cost changes will
    be clearly displayed.
    
    Release notes: None
    284d3855
  • Matt Jibson's avatar
    efdbe38d
  • Radu Berinde's avatar
    opt: elide FK checks in build mode · 766d3832
    Radu Berinde authored
    This is a follow-up to #48620. We modify `OutputColumnIsAlwaysNull` to also
    check unfolded null casts. This makes the FK elision logic work in "build" mode.
    Having to use norm mode was getting annoying with cascades.
    
    Release note: None
    766d3832
  • craig[bot]'s avatar
    Merge #48741 #48797 · a51cbf94
    craig[bot] authored
    48741: opt: add costs for join filters r=mjibson a=mjibson
    
    Add costing for join filters and do initial costing for geo lookup joins.
    
    48797: opt: elide FK checks in build mode r=RaduBerinde a=RaduBerinde
    
    This is a follow-up to #48620. We modify `OutputColumnIsAlwaysNull` to also
    check unfolded null casts. This makes the FK elision logic work in "build" mode.
    Having to use norm mode was getting annoying with cascades.
    
    Release note: None
    Co-authored-by: default avatarMatt Jibson <[email protected]>
    Co-authored-by: default avatarRadu Berinde <[email protected]>
    a51cbf94
......@@ -687,6 +687,19 @@ func ExprIsNeverNull(e opt.ScalarExpr, notNullCols opt.ColSet) bool {
// This could be a logical property but we only care about simple cases (NULLs
// in Projections and Values).
func OutputColumnIsAlwaysNull(e RelExpr, col opt.ColumnID) bool {
isNullScalar := func(scalar opt.ScalarExpr) bool {
switch scalar.Op() {
case opt.NullOp:
return true
case opt.CastOp:
// Normally this cast should have been folded, but we want this to work
// in "build" opttester mode (disabled normalization rules).
return scalar.Child(0).Op() == opt.NullOp
default:
return false
}
}
switch e.Op() {
case opt.ProjectOp:
p := e.(*ProjectExpr)
......@@ -695,7 +708,7 @@ func OutputColumnIsAlwaysNull(e RelExpr, col opt.ColumnID) bool {
}
for i := range p.Projections {
if p.Projections[i].Col == col {
return p.Projections[i].Element.Op() == opt.NullOp
return isNullScalar(p.Projections[i].Element)
}
}
......@@ -706,7 +719,7 @@ func OutputColumnIsAlwaysNull(e RelExpr, col opt.ColumnID) bool {
return false
}
for i := range v.Rows {
if v.Rows[i].(*TupleExpr).Elems[colOrdinal].Op() != opt.NullOp {
if !isNullScalar(v.Rows[i].(*TupleExpr).Elems[colOrdinal]) {
return false
}
}
......
......@@ -21,7 +21,7 @@ SELECT s FROM a INNER JOIN xy ON a.k=xy.x AND i+1=10
----
================================================================================
Initial expression
Cost: 15503.39
Cost: 15503.40
================================================================================
project
├── columns: s:4
......@@ -41,7 +41,7 @@ Initial expression
└── (k:1 = x:6) AND ((i:2 + 1) = 10) [outer=(1,2,6), constraints=(/1: (/NULL - ]; /6: (/NULL - ])]
================================================================================
NormalizeCmpPlusConst
Cost: 15470.06
Cost: 15470.07
================================================================================
project
├── columns: s:4
......@@ -63,7 +63,7 @@ NormalizeCmpPlusConst
+ └── (k:1 = x:6) AND (i:2 = (10 - 1)) [outer=(1,2,6), constraints=(/1: (/NULL - ]; /2: (/NULL - ]; /6: (/NULL - ])]
================================================================================
FoldBinary
Cost: 12203.39
Cost: 12203.40
================================================================================
project
├── columns: s:4
......@@ -85,7 +85,7 @@ FoldBinary
+ └── (k:1 = x:6) AND (i:2 = 9) [outer=(1,2,6), constraints=(/1: (/NULL - ]; /2: [/9 - /9]; /6: (/NULL - ]), fd=()-->(2)]
================================================================================
SimplifyJoinFilters
Cost: 2180.16
Cost: 2180.17
================================================================================
project
├── columns: s:4
......@@ -1753,7 +1753,7 @@ TryDecorrelateScalarGroupBy
└── case:11 [as=r:8, outer=(11)]
================================================================================
TryDecorrelateProjectSelect
Cost: 2280.14
Cost: 2280.15
================================================================================
project
├── columns: r:8
......@@ -1839,7 +1839,7 @@ TryDecorrelateProjectSelect
└── case:11 [as=r:8, outer=(11)]
================================================================================
DecorrelateJoin
Cost: 2280.14
Cost: 2280.15
================================================================================
project
├── columns: r:8
......
......@@ -172,9 +172,8 @@ insert child_nullable
└── column2:5 = parent.p:6
# In this case, we know that we are inserting *only* NULL values, so we don't
# need to check any FKs. We need to use norm because internally the values
# become NULL::INT and the normalization rules are needed to fold the cast.
norm
# need to check any FKs.
build
INSERT INTO child_nullable VALUES (100, NULL), (200, NULL)
----
insert child_nullable
......@@ -184,11 +183,11 @@ insert child_nullable
│ └── column2:4 => p:2
└── values
├── columns: column1:3!null column2:4
├── (100, NULL)
└── (200, NULL)
├── (100, NULL::INT8)
└── (200, NULL::INT8)
# Same as above.
norm
build
INSERT INTO child_nullable (c) VALUES (100), (200)
----
insert child_nullable
......@@ -203,7 +202,7 @@ insert child_nullable
│ ├── (100,)
│ └── (200,)
└── projections
└── CAST(NULL AS INT8) [as=column4:4]
└── NULL::INT8 [as=column4:4]
# Check planning of filter with FULL match (which should be the same on a
# single column).
......@@ -242,7 +241,7 @@ insert child_nullable_full
└── column2:5 = parent.p:6
# No FK check needed.
norm
build
INSERT INTO child_nullable_full (c) VALUES (100), (200)
----
insert child_nullable_full
......@@ -257,7 +256,7 @@ insert child_nullable_full
│ ├── (100,)
│ └── (200,)
└── projections
└── CAST(NULL AS INT8) [as=column4:4]
└── NULL::INT8 [as=column4:4]
# Tests with multicolumn FKs.
exec-ddl
......@@ -382,7 +381,7 @@ insert multi_col_child
└── column4:11 = multi_col_parent.r:14
# No FK check needed - we have only NULL values for a FK column.
norm
build
INSERT INTO multi_col_child VALUES (1, 10, NULL, 10)
----
insert multi_col_child
......@@ -394,7 +393,7 @@ insert multi_col_child
│ └── column4:8 => r:4
└── values
├── columns: column1:5!null column2:6!null column3:7 column4:8!null
└── (1, 10, NULL, 10)
└── (1, 10, NULL::INT8, 10)
exec-ddl
CREATE TABLE multi_col_child_full (
......@@ -507,7 +506,7 @@ insert multi_col_child_full
└── column4:11 = multi_col_parent.r:14
# No FK check needed when all FK columns only have NULL values.
norm
build
INSERT INTO multi_col_child_full VALUES (1, NULL, NULL, NULL)
----
insert multi_col_child_full
......@@ -519,11 +518,11 @@ insert multi_col_child_full
│ └── column4:8 => r:4
└── values
├── columns: column1:5!null column2:6 column3:7 column4:8
└── (1, NULL, NULL, NULL)
└── (1, NULL::INT8, NULL::INT8, NULL::INT8)
# But with MATCH FULL, the FK check is needed when only a subset of the columns
# only have NULL values.
norm
build
INSERT INTO multi_col_child_full VALUES (1, NULL, 2, NULL)
----
insert multi_col_child_full
......@@ -536,7 +535,7 @@ insert multi_col_child_full
├── input binding: &1
├── values
│ ├── columns: column1:5!null column2:6 column3:7!null column4:8
│ └── (1, NULL, 2, NULL)
│ └── (1, NULL::INT8, 2, NULL::INT8)
└── f-k-checks
└── f-k-checks-item: multi_col_child_full(p,q,r) -> multi_col_parent(p,q,r)
└── anti-join (hash)
......@@ -574,7 +573,7 @@ CREATE TABLE multi_ref_child (
----
build
INSERT INTO multi_ref_child VALUES (1, NULL, NULL, NULL)
INSERT INTO multi_ref_child VALUES (1, 1, NULL, NULL), (2, NULL, 2, NULL), (3, NULL, NULL, 3)
----
insert multi_ref_child
├── columns: <none>
......@@ -586,7 +585,9 @@ insert multi_ref_child
├── input binding: &1
├── values
│ ├── columns: column1:5!null column2:6 column3:7 column4:8
│ └── (1, NULL::INT8, NULL::INT8, NULL::INT8)
│ ├── (1, 1, NULL::INT8, NULL::INT8)
│ ├── (2, NULL::INT8, 2, NULL::INT8)
│ └── (3, NULL::INT8, NULL::INT8, 3)
└── f-k-checks
├── f-k-checks-item: multi_ref_child(a) -> multi_ref_parent_a(a)
│ └── anti-join (hash)
......@@ -621,3 +622,17 @@ insert multi_ref_child
└── filters
├── column3:12 = multi_ref_parent_bc.b:14
└── column4:13 = multi_ref_parent_bc.c:15
build
INSERT INTO multi_ref_child VALUES (1, NULL, NULL, NULL)
----
insert multi_ref_child
├── columns: <none>
├── insert-mapping:
│ ├── column1:5 => k:1
│ ├── column2:6 => a:2
│ ├── column3:7 => b:3
│ └── column4:8 => c:4
└── values
├── columns: column1:5!null column2:6 column3:7 column4:8
└── (1, NULL::INT8, NULL::INT8, NULL::INT8)
......@@ -217,9 +217,8 @@ CREATE TABLE child_nullable (c INT PRIMARY KEY, p INT REFERENCES parent(p))
----
# We don't need the FK check in this case because we are only setting NULL
# values. We need to use norm because internally the value becomes NULL::INT
# and the normalization rules are needed to fold the cast.
norm
# values.
build
UPDATE child_nullable SET p = NULL
----
update child_nullable
......@@ -232,7 +231,7 @@ update child_nullable
├── scan child_nullable
│ └── columns: c:3!null p:4
└── projections
└── CAST(NULL AS INT8) [as=column5:5]
└── NULL::INT8 [as=column5:5]
# Multiple grandchild tables
exec-ddl
......@@ -348,7 +347,7 @@ CREATE TABLE child_multicol_simple (
----
# With MATCH SIMPLE, we can elide the FK check if any FK column is NULL.
norm
build
UPDATE child_multicol_simple SET a = 1, b = NULL, c = 1 WHERE k = 1
----
update child_multicol_simple
......@@ -368,7 +367,7 @@ update child_multicol_simple
│ └── k:5 = 1
└── projections
├── 1 [as=column9:9]
└── CAST(NULL AS INT8) [as=column10:10]
└── NULL::INT8 [as=column10:10]
exec-ddl
CREATE TABLE child_multicol_full (
......@@ -379,7 +378,7 @@ CREATE TABLE child_multicol_full (
----
# With MATCH FULL, we can elide the FK check only if all FK columns are NULL.
norm
build
UPDATE child_multicol_full SET a = 1, b = NULL, c = 1 WHERE k = 1
----
update child_multicol_full
......@@ -400,7 +399,7 @@ update child_multicol_full
│ │ └── k:5 = 1
│ └── projections
│ ├── 1 [as=column9:9]
│ └── CAST(NULL AS INT8) [as=column10:10]
│ └── NULL::INT8 [as=column10:10]
└── f-k-checks
└── f-k-checks-item: child_multicol_full(a,b,c) -> parent_multicol(a,b,c)
└── anti-join (hash)
......@@ -418,7 +417,7 @@ update child_multicol_full
├── column10:12 = parent_multicol.b:15
└── column9:13 = parent_multicol.c:16
norm
build
UPDATE child_multicol_full SET a = NULL, b = NULL, c = NULL WHERE k = 1
----
update child_multicol_full
......@@ -437,7 +436,7 @@ update child_multicol_full
│ └── filters
│ └── k:5 = 1
└── projections
└── CAST(NULL AS INT8) [as=column9:9]
└── NULL::INT8 [as=column9:9]
exec-ddl
CREATE TABLE two (a int, b int, primary key (a, b))
......
......@@ -21,6 +21,7 @@ import (
"github.com/cockroachdb/cockroach/pkg/sql/opt/ordering"
"github.com/cockroachdb/cockroach/pkg/sql/opt/props/physical"
"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
"github.com/cockroachdb/cockroach/pkg/util"
"github.com/cockroachdb/cockroach/pkg/util/log"
"github.com/cockroachdb/errors"
"golang.org/x/tools/container/intsets"
......@@ -361,8 +362,26 @@ func (c *coster) computeHashJoinCost(join memo.RelExpr) memo.Cost {
}
cost += memo.Cost(rowsProcessed) * cpuCostFactor
// TODO(rytaft): Add a constant "setup" cost per extra ON condition similar
// to merge join and lookup join.
// Compute filter cost. Fetch the equality columns so they can be
// ignored later.
on := join.Child(2).(*memo.FiltersExpr)
leftEq, rightEq := memo.ExtractJoinEqualityColumns(
join.Child(0).(memo.RelExpr).Relational().OutputCols,
join.Child(1).(memo.RelExpr).Relational().OutputCols,
*on,
)
// Generate a quick way to lookup if two columns are join equality
// columns. We add in both directions because we don't know which way
// the equality filters will be defined.
eqMap := util.FastIntMap{}
for i := range leftEq {
left := int(leftEq[i])
right := int(rightEq[i])
eqMap.Set(left, right)
eqMap.Set(right, left)
}
cost += c.computeFiltersCost(*on, eqMap)
return cost
}
......@@ -382,10 +401,7 @@ func (c *coster) computeMergeJoinCost(join *memo.MergeJoinExpr) memo.Cost {
}
cost += memo.Cost(rowsProcessed) * cpuCostFactor
// Add a constant "setup" cost per ON condition to account for the fact that
// the rowsProcessed estimate alone cannot effectively discriminate between
// plans when RowCount is too small.
cost += cpuCostFactor * memo.Cost(len(join.On))
cost += c.computeFiltersCost(join.On, util.FastIntMap{})
return cost
}
......@@ -452,16 +468,55 @@ func (c *coster) computeLookupJoinCost(
}
cost += memo.Cost(rowsProcessed) * perRowCost
// Add a constant "setup" cost per ON condition to account for the fact that
// the rowsProcessed estimate alone cannot effectively discriminate between
// plans when RowCount is too small.
cost += cpuCostFactor * memo.Cost(len(join.On))
cost += c.computeFiltersCost(join.On, util.FastIntMap{})
return cost
}
func (c *coster) computeGeoLookupJoinCost(join *memo.GeoLookupJoinExpr) memo.Cost {
// TODO(rytaft): add a real cost here.
return 0
lookupCount := join.Input.Relational().Stats.RowCount
// The rows in the (left) input are used to probe into the (right) table.
// Since the matching rows in the table may not all be in the same range, this
// counts as random I/O.
perLookupCost := memo.Cost(randIOCostFactor)
cost := memo.Cost(lookupCount) * perLookupCost
// TODO: support GeoLookupJoinExpr in c.mem.RowsProcessed. See
// computeLookupJoinCost.
cost += c.computeFiltersCost(join.On, util.FastIntMap{})
return cost
}
func (c *coster) computeFiltersCost(filters memo.FiltersExpr, eqMap util.FastIntMap) memo.Cost {
var cost memo.Cost
for i := range filters {
f := &filters[i]
switch f.Condition.Op() {
case opt.EqOp:
eq := f.Condition.(*memo.EqExpr)
leftVar, ok := eq.Left.(*memo.VariableExpr)
if !ok {
break
}
rightVar, ok := eq.Right.(*memo.VariableExpr)
if !ok {
break
}
if val, ok := eqMap.Get(int(leftVar.Col)); ok && val == int(rightVar.Col) {
// Equality filters on some joins are still in
// filters, while others have already removed
// them. They do not cost anything.
continue
}
}
// Add a constant "setup" cost per ON condition to account for the fact that
// the rowsProcessed estimate alone cannot effectively discriminate between
// plans when RowCount is too small.
cost += cpuCostFactor
}
return cost
}
func (c *coster) computeZigzagJoinCost(join *memo.ZigzagJoinExpr) memo.Cost {
......@@ -484,6 +539,8 @@ func (c *coster) computeZigzagJoinCost(join *memo.ZigzagJoinExpr) memo.Cost {
// Double the cost of emitting rows as well as the cost of seeking rows,
// given two indexes will be accessed.
cost := memo.Cost(rowCount) * (2*(cpuCostFactor+seqIOCostFactor) + scanCost)
cost += c.computeFiltersCost(join.On, util.FastIntMap{})
return cost
}
......
......@@ -675,7 +675,7 @@ memo (optimized, ~8KB, required=[presentation: a:1,b:2,c:3,k:5])
├── G1: (inner-join G2 G3 G4) (inner-join G3 G2 G4)
│ └── [presentation: a:1,b:2,c:3,k:5]
│ ├── best: (inner-join G2 G3 G4)
│ └── cost: 12120.05
│ └── cost: 12120.06
├── G2: (scan abc,cols=(1-3)) (scan [email protected],cols=(1-3)) (scan [email protected],cols=(1-3))
│ └── []
│ ├── best: (scan abc,cols=(1-3))
......@@ -1678,6 +1678,73 @@ project
└── projections
└── sum:15 / (st_area(n.geom:14) / 1e+06) [as=popn_per_sqkm:16, outer=(14,15), side-effects]
memo expect=GenerateGeoLookupJoins
SELECT
n.name,
Sum(c.popn_total) / (ST_Area(n.geom) / 1000000.0) AS popn_per_sqkm
FROM nyc_census_blocks AS c
JOIN nyc_neighborhoods AS n
ON ST_Intersects(c.geom, n.geom) AND c.boroname = n.boroname
WHERE n.name = 'Upper West Side'
OR n.name = 'Upper East Side'
GROUP BY n.name, n.geom
----
memo (optimized, ~23KB, required=[presentation: name:13,popn_per_sqkm:16])
├── G1: (project G2 G3 name)
│ └── [presentation: name:13,popn_per_sqkm:16]
│ ├── best: (project G2 G3 name)
│ └── cost: 5110.53
├── G2: (group-by G4 G5 cols=(13,14))
│ └── []
│ ├── best: (group-by G4 G5 cols=(13,14))
│ └── cost: 5110.48
├── G3: (projections G6)
├── G4: (inner-join G7 G8 G9) (inner-join G8 G7 G9) (lookup-join G10 G9 nyc_census_blocks,keyCols=[1],outCols=(3,9,10,12-14))
│ └── []
│ ├── best: (lookup-join G10 G9 nyc_census_blocks,keyCols=[1],outCols=(3,9,10,12-14))
│ └── cost: 4903.53
├── G5: (aggregations G11)
├── G6: (div G12 G13)
├── G7: (scan c,cols=(3,9,10))
│ └── []
│ ├── best: (scan c,cols=(3,9,10))
│ └── cost: 43837.24
├── G8: (select G14 G15)
│ └── []
│ ├── best: (select G14 G15)
│ └── cost: 139.35
├── G9: (filters G16 G17)
├── G10: (geo-lookup-join G8 G18 [email protected]_census_blocks_geo_idx)
│ └── []
│ ├── best: (geo-lookup-join G8 G18 [email protected]_census_blocks_geo_idx)
│ └── cost: 147.36
├── G11: (sum G19)
├── G12: (variable sum)
├── G13: (div G20 G21)
├── G14: (scan n,cols=(12-14))
│ └── []
│ ├── best: (scan n,cols=(12-14))
│ └── cost: 138.05
├── G15: (filters G22)
├── G16: (function G23 st_intersects)
├── G17: (eq G24 G25)
├── G18: (filters)
├── G19: (variable popn_total)
├── G20: (function G26 st_area)
├── G21: (const 1e+06)
├── G22: (or G27 G28)
├── G23: (scalar-list G29 G30)
├── G24: (variable c.boroname)
├── G25: (variable n.boroname)
├── G26: (scalar-list G30)
├── G27: (eq G31 G32)
├── G28: (eq G31 G33)
├── G29: (variable c.geom)
├── G30: (variable n.geom)
├── G31: (variable name)
├── G32: (const 'Upper West Side')
└── G33: (const 'Upper East Side')
# --------------------------------------------------
# GenerateZigZagJoins
# --------------------------------------------------
......@@ -1717,7 +1784,7 @@ memo (optimized, ~13KB, required=[presentation: q:2,r:3])
├── G1: (select G2 G3) (zigzag-join G3 [email protected] [email protected]) (select G4 G5) (select G6 G7) (select G8 G7)
│ └── [presentation: q:2,r:3]
│ ├── best: (zigzag-join G3 [email protected] [email protected])
│ └── cost: 0.22
│ └── cost: 0.24
├── G2: (scan pqr,cols=(2,3))
│ └── []
│ ├── best: (scan pqr,cols=(2,3))
......@@ -1798,7 +1865,7 @@ memo (optimized, ~15KB, required=[presentation: q:2,r:3,s:4])
├── G1: (select G2 G3) (lookup-join G4 G5 pqr,keyCols=[1],outCols=(2-4)) (select G6 G7) (select G8 G9) (select G10 G9)
│ └── [presentation: q:2,r:3,s:4]
│ ├── best: (lookup-join G4 G5 pqr,keyCols=[1],outCols=(2-4))
│ └── cost: 0.84
│ └── cost: 0.86
├── G2: (scan pqr,cols=(2-4))
│ └── []
│ ├── best: (scan pqr,cols=(2-4))
......@@ -1807,7 +1874,7 @@ memo (optimized, ~15KB, required=[presentation: q:2,r:3,s:4])
├── G4: (zigzag-join G3 [email protected] [email protected])
│ └── []
│ ├── best: (zigzag-join G3 [email protected] [email protected])
│ └── cost: 0.22
│ └── cost: 0.24
├── G5: (filters)
├── G6: (index-join G13 pqr,cols=(2-4))
│ └── []
......@@ -1863,7 +1930,7 @@ memo (optimized, ~11KB, required=[presentation: q:2,s:4])
├── G1: (select G2 G3) (zigzag-join G3 [email protected] [email protected]) (select G4 G5) (select G6 G7)
│ └── [presentation: q:2,s:4]
│ ├── best: (zigzag-join G3 [email protected] [email protected])
│ └── cost: 0.22
│ └── cost: 0.24
├── G2: (scan pqr,cols=(2,4))
│ └── []
│ ├── best: (scan pqr,cols=(2,4))
......@@ -1917,7 +1984,7 @@ memo (optimized, ~13KB, required=[presentation: r:3,t:5])
├── G1: (select G2 G3) (zigzag-join G3 [email protected] [email protected]) (select G4 G5) (select G6 G5) (select G7 G8)
│ └── [presentation: r:3,t:5]
│ ├── best: (zigzag-join G3 [email protected] [email protected])
│ └── cost: 0.22
│ └── cost: 0.24
├── G2: (scan pqr,cols=(3,5))
│ └── []
│ ├── best: (scan pqr,cols=(3,5))
......@@ -2008,7 +2075,7 @@ memo (optimized, ~31KB, required=[presentation: p:1,q:2,r:3,s:4])
├── G1: (select G2 G3) (lookup-join G4 G5 pqr,keyCols=[1],outCols=(1-4)) (zigzag-join G3 [email protected] [email protected]) (zigzag-join G3 [email protected] [email protected]) (lookup-join G6 G7 pqr,keyCols=[1],outCols=(1-4)) (select G8 G9) (select G10 G11) (select G12 G7) (select G13 G7)
│ └── [presentation: p:1,q:2,r:3,s:4]
│ ├── best: (zigzag-join G3 [email protected] [email protected])
│ └── cost: 0.01
│ └── cost: 0.04
├── G2: (scan pqr,cols=(1-4))
│ └── []
│ ├── best: (scan pqr,cols=(1-4))
......@@ -2017,12 +2084,12 @@ memo (optimized, ~31KB, required=[presentation: p:1,q:2,r:3,s:4])
├── G4: (zigzag-join G17 [email protected] [email protected])
│ └── []
│ ├── best: (zigzag-join G17 [email protected] [email protected])
│ └── cost: 0.22
│ └── cost: 0.24
├── G5: (filters G16)
├── G6: (zigzag-join G9 [email protected] [email protected])
│ └── []
│ ├── best: (zigzag-join G9 [email protected] [email protected])
│ └── cost: 0.22
│ └── cost: 0.24
├── G7: (filters G14)
├── G8: (index-join G18 pqr,cols=(1-4))
│ └── []
......