Skip to main content

inillucent_sql/
cost.rs

1//! What a plan costs, and which order to visit the FROM terms in.
2//!
3//! Invariant: cost is measured in the same currency SQLite measures it in - the
4//! base-2 logarithm of the number of rows a step touches, scaled - so that a
5//! plan comparison means the same thing in both engines. That is not a stylistic
6//! choice. The acceptance test for this phase compares which *plan* each engine
7//! picks, and two engines using different currencies disagree on ties for
8//! reasons that are not defects.
9//!
10//! Absent statistics the numbers are SQLite's own guesses: a table holds about
11//! a million rows, an equality selects a tenth of them, a range a quarter, and a
12//! unique index exactly one. Those defaults are what make an unanalysed plan
13//! match the reference's, so they are written out here rather than being
14//! whatever seemed reasonable.
15
16/// The row count assumed for a table nothing has measured.
17///
18/// SQLite's `SQLITE_DEFAULT_ROWEST`. It is deliberately large: with a small
19/// guess every index looks pointless, and the plan for an unanalysed schema
20/// would be a full scan of everything.
21pub const DEFAULT_ROWS: f64 = 1_048_576.0;
22
23/// The share of a table an equality on an indexed column is assumed to select.
24///
25/// Used only where no index is involved. An equality *on an index* is estimated
26/// by [`default_equality_rows`] instead, which is an absolute count rather than
27/// a share - see the note there for why the difference matters.
28pub const EQUALITY_SHARE: f64 = 10.0;
29
30/// How many rows an equality on an unmeasured index is assumed to match.
31///
32/// SQLite's `sqlite3DefaultRowEst` fills an unanalysed index's estimates with
33/// these, as LogEst 33, 32, 30, 28, 26, 23 - about twenty rows for the first
34/// equality column, falling to ten and staying there. They are *counts*, not
35/// fractions of the table, and that distinction is the whole point: somebody
36/// who indexed a column and then compared it for equality was pinning down a
37/// row, not selecting a tenth of the table, and the bigger the table the more
38/// true that is.
39///
40/// Getting this wrong is not a rounding error, it reverses join orders. With a
41/// tenth-of-the-table estimate the planner priced a seek into a 25,000 row
42/// table at 2,500 rows, decided the seek was not worth it, and scanned that
43/// table once per outer row instead - which took a benchmark round from seconds
44/// to the better part of an hour, and put a `SCAN` where the reference had a
45/// `SEARCH ... USING INDEX`.
46const DEFAULT_EQUALITY_ROWS: [f64; 6] = [20.0, 18.0, 15.0, 13.0, 11.0, 10.0];
47
48/// Returns how many rows an equality on an unmeasured index is assumed to match.
49///
50/// Never more than the table holds: a two-row table cannot return twenty.
51/// @param equalities - how many leading index columns the search pins down
52/// @param rows - how many rows the table is estimated to hold
53pub fn default_equality_rows(equalities: usize, rows: f64) -> f64 {
54    let at = equalities.max(1).saturating_sub(1);
55    let estimate = DEFAULT_EQUALITY_ROWS
56        .get(at)
57        .copied()
58        .unwrap_or_else(|| DEFAULT_EQUALITY_ROWS.last().copied().unwrap_or(10.0));
59    estimate.min(rows.max(1.0))
60}
61
62/// The share a range on an indexed column is assumed to select.
63pub const RANGE_SHARE: f64 = 4.0;
64
65/// What it costs to fetch a table row once an index has found its key.
66///
67/// A second descent of a second B-tree, so it is charged per matching row and
68/// is what makes a covering index worth having.
69pub const FETCH_PENALTY: f64 = 3.0;
70
71/// What sorting a row costs relative to visiting one.
72pub const SORT_FACTOR: f64 = 3.0;
73
74/// Returns how much of a row's width one index entry is.
75///
76/// An entry holds the indexed columns and the row's key; a row holds every
77/// column. Cost here is bytes touched, so the ratio of the two is what a
78/// covering path saves over reading the rows - and it is what makes a covering
79/// scan of a narrow index beat a scan of a wide table when neither has a
80/// predicate to narrow it.
81///
82/// The floor stops a one-column index over a fifty-column table from looking
83/// fifty times cheaper than it is: pages, not just bytes, are what a scan
84/// touches, and a b-tree of any width has a per-entry cost that does not shrink
85/// with the entry.
86/// @param index_columns - how many columns the index is over
87/// @param table_columns - how many the table has
88pub fn entry_share(index_columns: usize, table_columns: usize) -> f64 {
89    let entry = index_columns.saturating_add(1) as f64;
90    let row = table_columns.max(1) as f64;
91    (entry / row).clamp(ENTRY_SHARE_FLOOR, 1.0)
92}
93
94/// The least a covering entry is allowed to be worth relative to a row.
95pub const ENTRY_SHARE_FLOOR: f64 = 0.25;
96
97/// Returns the estimated cost of visiting a number of rows through a scan.
98///
99/// One visit per row, and no descent per row: a scan walks the leaves in order
100/// and each step is to the next entry rather than from the root. Charging it a
101/// descent per row - the obvious reading of "reading a row costs a descent" -
102/// makes a scan of a thousand rows cost ten thousand, and then an index search
103/// that returns *every* row of the table still looks cheaper than reading the
104/// table. Nothing would ever choose a scan again.
105pub fn scan_cost(rows: f64) -> f64 {
106    rows.max(1.0)
107}
108
109/// Returns the estimated cost of a search that returns some of the rows.
110///
111/// One descent to find the first match, then one visit per match, plus a second
112/// descent per match when the index does not carry the columns the query wants.
113pub fn search_cost(rows: f64, matches: f64, covering: bool) -> f64 {
114    let rows = rows.max(1.0);
115    let matches = matches.max(1.0);
116    let fetch = if covering { 0.0 } else { FETCH_PENALTY };
117    log2(rows) + matches * (1.0 + fetch)
118}
119
120/// Returns the estimated cost of sorting a number of rows.
121///
122/// This one *is* `n log n`, because a sort really does compare each row against
123/// a logarithmic number of others.
124pub fn sort_cost(rows: f64) -> f64 {
125    let rows = rows.max(1.0);
126    rows * log2(rows) * SORT_FACTOR
127}
128
129/// Returns a base-2 logarithm that never goes below one.
130///
131/// A B-tree of one row still costs a descent, and a cost of zero would make a
132/// search over an empty table free - which is how a planner ends up preferring
133/// a path over a table it has not measured to one it has.
134pub fn log2(rows: f64) -> f64 {
135    rows.max(2.0).log2()
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    /// A search that returns one row of a million beats a scan, and by a lot.
143    #[test]
144    fn a_selective_search_beats_a_scan() {
145        let rows = 1_000_000.0;
146        assert!(search_cost(rows, 1.0, false) < scan_cost(rows) / 1000.0);
147    }
148
149    /// A search that returns every row does not - it pays a second descent per
150    /// row for the privilege of reading the same table.
151    #[test]
152    fn an_unselective_search_does_not() {
153        let rows = 1_000.0;
154        assert!(search_cost(rows, rows, false) > scan_cost(rows));
155    }
156
157    /// The crossover is where it should be: a search that returns a fifth of a
158    /// table is still worth it, and one that returns half is not.
159    #[test]
160    fn the_crossover_is_a_fraction_of_the_table() {
161        let rows = 10_000.0;
162        assert!(search_cost(rows, rows / 5.0, false) < scan_cost(rows));
163        assert!(search_cost(rows, rows / 2.0, false) > scan_cost(rows));
164    }
165
166    /// A covering index is cheaper than the same search that has to fetch.
167    #[test]
168    fn covering_is_cheaper_than_fetching() {
169        assert!(search_cost(1_000.0, 100.0, true) < search_cost(1_000.0, 100.0, false));
170    }
171
172    /// An empty table still costs a descent, so nothing is free.
173    #[test]
174    fn nothing_costs_nothing() {
175        assert!(scan_cost(0.0) > 0.0);
176        assert!(search_cost(0.0, 0.0, true) > 0.0);
177    }
178}