Hi everyone,

one of the ssesion I've attended on PgDay last week was Heikki's session
about statistics in PostgreSQL. One of the issues he mentioned (and one
I regularly run into) is the absence of cross-column stats. When the
columns are not independent, this usually result in poor estimates (and
then in suboptimal plans).

I was thinking about this issue before, but that session was the last
impulse that pushed me to try to hack up a proof of concept. So here it
is ;-)

Lets talk about one special case - I'll explain how the proposed
solution works, and then I'll explain how to make it more general, what
improvements are possible, what issues are there. Anyway this is by no
means a perfect or complete solution - it's just a starting point.

------------------------ Short introduction ------------------------

Say we have a table with two INT columns - col_a and col_b, and we want
to estimate number of rows for a condition involving both columns:

   WHERE (col_a BETWEEN m AND n) AND (col_b BETWEEN p AND q)

When the columns are independent, doing the estimate is just a matter of
multiplication. When the columns are dependent, the estimate may be way off.

Lets assume there are histograms with 5 bins for both columns. What I
propose is basically building a 2D histogram. It kind of resembles
contingency table.

So we do have a table like this

col_b \ col_a  || 20% | 20% | 20% | 20% | 20% |
===============||==============================
          20%  ||  4% |  4% |  4% |  4% |  4% |
          20%  ||  4% |  4% |  4% |  4% |  4% |
          20%  ||  4% |  4% |  4% |  4% |  4% |
          20%  ||  4% |  4% |  4% |  4% |  4% |
          20%  ||  4% |  4% |  4% |  4% |  4% |
===============||==============================

where each column / row represents a bin in the original histograms, and
each cell represents an expected number of rows in it (for really
independent columns, it's 4%).

For dependent columns the actual values may be actually very different,
of course - e.g. for strongly dependent columns it might be like this

col_b \ col_a  || 20% | 20% | 20% | 20% | 20% |
===============||==============================
          20%  || 20% |  0% |  0% |  0% |  0% |
          20%  ||  0% | 20% |  0% |  0% |  0% |
          20%  ||  0% |  0% | 20% |  0% |  0% |
          20%  ||  0% |  0% |  0% | 20% |  0% |
          20%  ||  0% |  0% |  9% |  0% | 20% |
===============||==============================

To estimate the number of rows matching the condition, you'd sum
estimates for cells matching the condition. I.e. when the condition on
col_a matches the lowest 20% (the first histogram bin) and the condition
on col_b matches the lowest 20% of values, this corresponds to the first
cell (20% of rows).

Current optimizer estimates this to be 4% as it believes the columns are
independent.

I'm not sure whether I've explained it well enough, but the essence of
the proposal is to build N-dimensional histograms (where N is the number
of columns covered by the statistics) just as we are building histograms
today.

----------------------- Proof of concept ---------------------------

I've hacked a nasty proof of concept in PL/pgSQL (see the attachment).
It creates two tables - test_table and cross_stats. The former contains
the data (in two columns), the latter is used to store cross-column
statistics of test_table.

Then there are several PL/pgSQL functions - two of them are really
important:

collect_stats(p_bins_a INT, p_bins_b INT)
  - this one is used to collect the stats, the parameters represent
    number of bins for the columns (sides of the contingency table)
  - collect_stats(10,10) will build contingency table with 100 cells

get_estimate(p_from_a INT, p_to_a INT, p_from_b INT, p_to_b INT)
  - computes estimate for the condition listed above (ranges on both
    columns)

So to run the PoC, just do something like this:

1) fill the table with some data

   INSERT INTO test_table SELECT round(random()*1000),
   round(random()*1000) FROM generate_series(1,100000);

2) collect the cross-column statistics

   SELECT collect_stats(10,10);

3) see current estimated and actual number of rows

   EXPLAIN ANALYZE SELECT * FROM test_table
                           WHERE (col_a BETWEEN 30 AND 129)
                             AND (col_b BETWEEN 234 AND 484);

4) see the estimate based on contingency table

   SELECT get_estimate(30, 129, 234, 484);

Just two very simple tests for now - col_a/col_b contain the range from
the query, then there are actual number of matching rows and a current
estimate, And finally the new estimate based on contingency table with
various number of bins.

A) independent columns (proof that it produces just as good estimates
   as the current code)

   col_a   |   col_b   | actual | expected | 10x10 | 20x20 |
  [50,70]  |  [50,70]  |    41  |     40   |   41  |    47 |
  [50,250] |  [50,250] |  4023  |   4024   | 4436  |  3944 |
  [50,250] | [750,950] |  4023  |   3955   | 4509  |  3933 |

B) strongly positively dependent columns (actually col_a = col_b)

   col_a   |   col_b   | actual | expect | 10x10 | 20x20 | 100x100 |
  [50,70]  |  [50,70]  |   2143 |     57 |   391 |   729 |    1866 |
  [50,250] |  [50,250] |  20181 |   4111 | 15401 | 19983 |   19991 |
  [50,250] | [750,950] |      0 |   3977 |     0 |     0 |       0 |

  Which proves that it actually significantly improves the estimates.

Again, I've hacked that in about 1 hour, so it's really slow and I think
some of the estimates may be further improved. And the precision
obviously depends on the number of cells.

----------------------- Additional notes ---------------------------

1) The whole thing may be easily extended to more than two columns,
   just build N-dimensional cubes.

2) I really don't think we should collect stats for all combinations of
   columns of a table - I do like the Oracle-like approach where a DBA
   has to enable cross-column stats using an ALTER TABLE (for a
   particular list of columns).

   The only exception might be columns from a multi-column index. It
   might be quite efficient I guess?

3) There are independence tests for contingency tables (e.g. Pearson's
   Chi-squared test), so that it's easy to find out whether the columns
   are independent. In that case we can just throw away these stats and
   use the simple estimation.

   http://mathworld.wolfram.com/Chi-SquaredTest.html

4) Or we could store just those cells where expected and observed values
   differ significantly (may help if most of the values are indendent,
   but there's a small glitch somewhere).

5) It does not make sense to collect stats for two list of columns that
   share several columns - just collect cross stats for union of the
   column lists and then 'dripp up' as needed. An extreme case might be
   collecting stats for all columns in a table. But there are practical
   issues with this approach (huge tables, small values within cells).

6) The precision increases with the number of cells, but the number of
   cells grows exponentionally. So it's necessary to choose a
   reasonable number of bins for the columns (might be part of the
   ALTER COMMAND, should not be firmly bound to the statistics target).

   At a cell level, the simple 'multiplication' estimates are actually
   used (but only when the cell is divided by the range).

7) All this is done under the assumption that all the columns are in a
   single table - when doing research in the archives I've noticed
   suggestions to use this to optimize joins. Maybe it can be done but
   I really don't know how to do that.

8) I'm not sure where to store this and in what form - generally the
   table does not need to be significantly more complex than cross_stats
   table from the PoC.

9) I've noticed demands to collect data about columns used frequently
   in a single WHERE condition. Not sure how to do that and how to
   analyze the collected data. I think collecting data about expected
   and observed number of columns should be just fine - but it's kinda
   independent from this proposal.

regards
Tomas
-- a test table - just two integer columns
-- we'll fill it with data, collect stats and see what is the estimated / 
actual number of rows
CREATE TABLE test_table (
    col_a INT,
    col_b INT
);

-- just to speed up the stats collection
CREATE INDEX test_table_a_idx ON test_table(col_a);
CREATE INDEX test_table_b_idx ON test_table(col_b);

-- table used to store statistics
CREATE TABLE cross_stats (
    histogram_a INT[],
    histogram_b INT[],
    cross_histogram INT[]
);

/*
* Collects statistics.
*
* This is a very stupid / slow implementation.
*/
CREATE OR REPLACE FUNCTION collect_stats(p_bins_a INT, p_bins_b INT) RETURNS 
void AS $$
DECLARE
  v_value INT;
  v_count INT;
  v_histogram_a INT[];
  v_histogram_b INT[];
  v_contingency_table INT[];
  v_row RECORD;
  v_bin_idx INT;
BEGIN

  -- count all rows
  SELECT count(*) INTO v_count FROM test_table;

  /* build histogram s */

  -- lower borders
  SELECT MIN(col_a) INTO v_value FROM test_table;
  v_histogram_a := array_append(v_histogram_a, v_value);

  SELECT MIN(col_b) INTO v_value FROM test_table;
  v_histogram_b := array_append(v_histogram_b, v_value);

  -- inner borders
  FOR v_idx IN 1..(p_bins_a-1) LOOP
      SELECT col_a INTO v_value FROM test_table ORDER BY col_a LIMIT 1 OFFSET 
floor(v_idx * v_count / p_bins_a);
      v_histogram_a := array_append(v_histogram_a, v_value);
  END LOOP;

  FOR v_idx IN 1..(p_bins_b-1) LOOP
      SELECT col_b INTO v_value FROM test_table ORDER BY col_b LIMIT 1 OFFSET 
floor(v_idx * v_count / p_bins_b);
      v_histogram_b := array_append(v_histogram_b, v_value);
  END LOOP;

  -- upper borders
  SELECT MAX(col_a) INTO v_value FROM test_table;
  v_histogram_a := array_append(v_histogram_a, v_value);

  SELECT MAX(col_b) INTO v_value FROM test_table;
  v_histogram_b := array_append(v_histogram_b, v_value);

  /* build the contingency table */
  
  -- init
  FOR v_idx_a IN 1..(p_bins_a*p_bins_b) LOOP
    v_contingency_table := array_append(v_contingency_table, 0);
  END LOOP;

  FOR v_row IN (SELECT * FROM test_table) LOOP
    v_bin_idx := get_contingency_bin(v_histogram_a, v_histogram_b, v_row.col_a, 
v_row.col_b);
    v_contingency_table[v_bin_idx] := v_contingency_table[v_bin_idx] + 1;
  END LOOP;

  -- save stats
  DELETE FROM cross_stats;
  INSERT INTO cross_stats VALUES (v_histogram_a, v_histogram_b, 
v_contingency_table);

END;
$$ LANGUAGE plpgsql;

-- get ID of the bin in a (linearized) contingency table
CREATE OR REPLACE FUNCTION get_contingency_bin(p_histogram_a INT[], 
p_histogram_b INT[], p_value_a INT, p_value_b INT) RETURNS INT AS $$
DECLARE
  v_idx_a INT;
  v_idx_b INT;
BEGIN

  v_idx_a := get_histogram_bin(p_histogram_a, p_value_a);
  v_idx_b := get_histogram_bin(p_histogram_b, p_value_b);

  RETURN (v_idx_b - 1) * (array_upper(p_histogram_a,1)-1) + v_idx_a;

END;
$$ LANGUAGE plpgsql;

-- get bin in a histogram
CREATE OR REPLACE FUNCTION get_histogram_bin(p_histogram INT[], p_value INT) 
RETURNS INT AS $$
DECLARE
  v_tmp INT;
BEGIN

  -- slow, bisection should be used ...
  FOR v_idx IN 1..(array_upper(p_histogram,1)-1) LOOP
    IF (p_value >= p_histogram[v_idx]) THEN
      v_tmp := v_idx;
    END IF;
  END LOOP;

  RETURN v_tmp;

END;
$$ LANGUAGE plpgsql;

-- compute the estimate when there are range conditions on both columns, i.e. 
something like
-- ... WHERE (col_a BETWEEN 40 AND 75) AND (col_b BETWEEN 75 AND 1293)
CREATE OR REPLACE FUNCTION get_estimate(p_from_a INT, p_to_a INT, p_from_b INT, 
p_to_b INT) RETURNS INT AS $$
DECLARE

  -- bin indexes for col_a
  v_from_a_bin INT;
  v_to_a_bin INT;

  -- bin indexes for col_b
  v_from_b_bin INT;
  v_to_b_bin INT;

  -- the estimate
  v_estimate INT := 0;

  -- histograms (loaded from cross_stats)
  v_histogram_a INT[];
  v_histogram_b INT[];
  v_contingency INT[];
  v_cont_idx INT;

  -- coefficients (used to compute area of a single bin)
  v_coeff_a FLOAT;
  v_coeff_b FLOAT;

BEGIN

  SELECT histogram_a INTO v_histogram_a FROM cross_stats;
  SELECT histogram_b INTO v_histogram_b FROM cross_stats;
  SELECT cross_histogram INTO v_contingency FROM cross_stats;

  v_from_a_bin := get_histogram_bin(v_histogram_a, p_from_a);
  v_to_a_bin := get_histogram_bin(v_histogram_a, p_to_a);

  v_from_b_bin := get_histogram_bin(v_histogram_b, p_from_b);
  v_to_b_bin := get_histogram_bin(v_histogram_b, p_to_b);

  FOR v_idx_a IN v_from_a_bin..v_to_a_bin LOOP

    IF (v_from_a_bin = v_to_a_bin) THEN
      -- single bin
      v_coeff_a := (p_to_a - p_from_a)::float / (v_histogram_a[v_from_a_bin+1] 
- v_histogram_a[v_from_a_bin]);
    ELSIF (v_idx_a = v_from_a_bin) THEN
      -- starting bin
      v_coeff_a := (v_histogram_a[v_from_a_bin+1] - p_from_a)::float / 
(v_histogram_a[v_from_a_bin+1] - v_histogram_a[v_from_a_bin]);
    ELSIF (v_idx_a = v_to_a_bin) THEN
      -- last bin
      v_coeff_a := (p_to_a - v_histogram_a[v_to_a_bin])::float / 
(v_histogram_a[v_to_a_bin+1] - v_histogram_a[v_to_a_bin]);
    ELSE
      -- inner bins
      v_coeff_a := 1;
    END IF;

    FOR v_idx_b IN v_from_b_bin..v_to_b_bin LOOP

      IF (v_from_b_bin = v_to_b_bin) THEN
        -- single bin
        v_coeff_b := (p_to_b - p_from_b)::float / 
(v_histogram_b[v_from_b_bin+1] - v_histogram_b[v_from_b_bin]);
      ELSIF (v_idx_b = v_from_b_bin) THEN
        -- starting bin
        v_coeff_b := (v_histogram_b[v_from_b_bin+1] - p_from_b)::float / 
(v_histogram_b[v_from_b_bin+1] - v_histogram_b[v_from_b_bin]);
      ELSIF (v_idx_a = v_to_a_bin) THEN
        -- last bin
        v_coeff_b := (p_to_b - v_histogram_a[v_to_b_bin])::float / 
(v_histogram_a[v_to_b_bin+1] - v_histogram_a[v_to_b_bin]);
      ELSE
        -- inner bins
        v_coeff_b := 1;
      END IF;

      v_cont_idx := (v_idx_b - 1) * (array_upper(v_histogram_a,1)-1) + v_idx_a;

      v_estimate := v_estimate + round(v_contingency[v_cont_idx] * v_coeff_a * 
v_coeff_b);

    END LOOP;
  END LOOP;

  RETURN v_estimate;

END;
$$ LANGUAGE plpgsql;

/*
  independent columns

   col_a   |   col_b   | actual | expected | 10x10 | 20x20 |
  [50,70]  |  [50,70]  |    41  |     40   |   41  |    47 |
  [50,250] |  [50,250] |  4023  |   4024   | 4436  |  3944 |
  [50,250] | [750,950] |  4023  |   3955   | 4509  |  3933 |

*/
INSERT INTO test_table SELECT round(random()*1000), round(random()*1000) FROM 
generate_series(1,100000);

/*
  positively dependent columns

   col_a   |   col_b   | actual | expected | 10x10 | 20x20 | 40x40 | 100x100 |
  [50,70]  |  [50,70]  |   2143 |     57   |   391 |   729 |  1468 |    1866 |
  [50,250] |  [50,250] |  20181 |   4111   | 15401 | 19983 | 19985 |   19991 |
  [50,250] | [750,950] |      0 |   3977   |     0 |     0 |     0 |       0 |

*/
INSERT INTO test_table SELECT val, val FROM (SELECT round(random()*1000) AS val 
FROM generate_series(1,100000)) foo;
-- 
Sent via pgsql-hackers mailing list (pgsql-hackers@postgresql.org)
To make changes to your subscription:
http://www.postgresql.org/mailpref/pgsql-hackers

Reply via email to