Skip to content
MaxHalfordPublic

About

πŸ¦† DuckDB extension for better cardinality estimates, using Bayesian networks

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

cardinal β€” better cardinality estimates for DuckDB

CI MIT license

A duck dressed as a cardinal, wearing a medallion with a bar chart

DuckDB picks a join order from estimates of how many rows each join produces. Those estimates assume columns are independent and every join key matches as many rows as any other, so on real data they can be off by orders of magnitude, and DuckDB picks slow plans.

cardinal is a DuckDB extension that learns Bayesian networks from your tables, and gives DuckDB their estimates instead. It needs no configuration: load it, and queries are planned with better numbers. Results never change, only plans. On the STATS-CEB benchmark, total runtime drops from 118 s to 70 s.


Quick start

Cardinal is a DuckDB community extension, for DuckDB v2.0.0. Install and load it from any DuckDB client:

INSTALL cardinal FROM community;
LOAD cardinal;

Then query as usual. With a database file, e.g. duckdb my.duckdb:

CREATE TABLE a AS SELECT range AS id, range % 10 AS x FROM range(1000);
CREATE TABLE b AS SELECT range % 1000 AS a_id, range % 7 AS y FROM range(10000);
SELECT count(*) FROM a, b WHERE a.id = b.a_id AND a.x = 3 AND b.y = 2;

The join's first run fits a model per table. They show up in cardinal_models():

SELECT * FROM cardinal_models();
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ schema_name β”‚ table_name β”‚ row_count β”‚     columns      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ main        β”‚ a          β”‚      1000 β”‚ ['F(b.a_id)', x] β”‚
β”‚ main        β”‚ b          β”‚     10000 β”‚ [a_id, y]        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Models are saved in ~/.duckdb/extension_data/cardinal/, never in your database, and refitted when a table's row count changes by more than 10%, or when it is rewritten or altered. A query that needs a model waits for its fit: milliseconds for small tables, about a second for large ones.

Example

STATS-CEB's query 107 counts the comments, votes and badges of users, with filters on each:

SELECT count(*) FROM comments AS c, votes AS v, badges AS b, users AS u
WHERE u.Id = b.UserId AND u.Id = c.UserId AND u.Id = v.UserId
  AND c.Score = 1 AND c.CreationDate >= '2010-08-27 14:12:07'::TIMESTAMP
  AND v.VoteTypeId = 5
  AND v.CreationDate BETWEEN '2010-07-19'::TIMESTAMP AND '2014-09-13'::TIMESTAMP
  AND b.Date <= '2014-08-19 10:32:13'::TIMESTAMP AND u.Reputation >= 1;
DuckDB alone With cardinal
Estimated result 1,865 rows 28 million rows
True result 537 million rows 537 million rows
Estimate error 288,000Γ— too low 19Γ— too low
Planning time 1.0 ms 1.1 ms
Total time 12.9 s 0.45 s

Cardinal's estimate is still low, but close enough for DuckDB to pick a plan 29 times faster.

To compare on your own queries, turn cardinal off and on for the connection:

SET cardinal_estimator = 'off';
SET cardinal_estimator = 'on';

How the plans differ

DuckDB runs each join as a hash join: it stores one input, the build side, in a hash table, and streams the other input through it. It builds on the side it thinks is smaller. Each join shows its estimated and true rows:

DuckDB alone                          estimated          true
β‹ˆ u.Id = v.UserId                         1,865   537,352,263
β”œβ”€β”€ users                                40,325        40,325
└── build: β‹ˆ v.UserId = c.UserId          1,711   537,352,263
    β”œβ”€β”€ votes                            25,236        33,690
    └── build: β‹ˆ b.UserId = c.UserId      2,508     3,203,614
        β”œβ”€β”€ badges                       15,970        77,535
        └── build: comments               5,811        24,733

With cardinal                         estimated          true
β‹ˆ v.UserId = u.Id                    28,146,412   537,352,263
β”œβ”€β”€ β‹ˆ v.UserId = c.UserId               610,081     2,087,379
β”‚   β”œβ”€β”€ votes                            46,598        33,690
β”‚   └── build: comments                  44,187        24,733
└── build: β‹ˆ b.UserId = u.Id            155,707        77,535
    β”œβ”€β”€ badges                           76,106        77,535
    └── build: users                      6,905        40,325

Alone, DuckDB thinks joining comments, badges and votes gives 1,711 rows, fewer than the 40,325 users. So it builds the last hash table on that join, which stores 537 million rows. With cardinal, DuckDB sees that this join explodes. It joins votes with comments (2 million rows) and badges with users (78,000 rows), and builds on the smaller one. The last join then streams its 537 million rows into count(*) and stores none of them.

Why DuckDB's estimate is off

DuckDB estimates with almost no statistics:

  • x = 'a' keeps the rows divided by the distinct values of x.
  • Any other filter keeps 20% of the rows.
  • A join gives the product of its inputs, divided by the distinct join keys.

The join rule assumes every key matches as many rows as any other. Here, an average user has 0.6 matching comments, 0.8 votes and 1.9 badges, so the 40,325 users would give about 40,000 rows. But active users comment, vote and earn badges at once, and their counts multiply. User 805 alone gives 1,845 Γ— 482 Γ— 294 = 261 million rows. Ten users give 97% of the result.

Why cardinal's estimate is closer

Cardinal fits a Bayesian network per table. It learns how the columns that queries filter on correlate, instead of treating each filter alone. Following BayesCard, each table also gets fanout columns: for each user, the count of its comments, votes and badges. The network learns these counts together, so it knows that a user with many comments also has many votes. A join's size is then the sum, over the users that pass the filters, of comments Γ— votes Γ— badges.

Benchmarks

The same queries, with DuckDB v2.0.0 alone and with cardinal, on five standard benchmarks:

Benchmark Queries Total time Faster / same / slower queries Median estimate error
STATS-CEB 146 118 s β†’ 70 s 38 / 107 / 1 23Γ— β†’ 1.8Γ—
JOB 113 4.0 s β†’ 3.5 s 32 / 64 / 17 19Γ— β†’ 2.2Γ—
JOB-light 70 5.3 s β†’ 5.1 s 11 / 57 / 2 5.3Γ— β†’ 1.5Γ—
DSB, scale factor 10 219 2.8 s β†’ 3.2 s 0 / 196 / 23 4.1Γ— β†’ 2.0Γ—
TPC-H, scale factor 10 22 1.4 s β†’ 1.4 s 1 / 20 / 1 1.3Γ— β†’ 1.0Γ—
  • Total time: the whole workload, each query's median over several runs. Planning time included.
  • Faster / same / slower: a query is faster or slower when its time changes by more than 10% and 10 ms, the same otherwise.
  • Median estimate error: how far off the row counts DuckDB plans with are from the true ones, as a ratio, over every join DuckDB considers. Half the estimates are closer than this.

Estimates improve everywhere. Time follows where join order matters: STATS-CEB's slowest queries count billions of rows, and run twice as fast. DSB's queries take 10 ms each at this scale: its better plans make no difference, and small physical changes cost a few milliseconds. TPC-H, uniform data DuckDB already estimates well, is a check that cardinal does no harm.

Measured on an Apple M5 Pro. How, and more: research/.

Limits

  • Only DuckDB tables: queries that read Parquet or CSV files, or attached PostgreSQL tables, are planned by DuckDB alone.
  • Joins in a cycle, joins on several columns at once, and conditions such as a.x < b.y are left to DuckDB's own estimates.
  • Cardinal relies on DuckDB internals, so each DuckDB release needs a new build of it.

How it works, settings, opting in per connection, and fitting ahead of time: docs/extension.md.

Development

Building needs what DuckDB's build needs: Git, CMake and a C++ compiler. It compiles DuckDB v2.0.0 itself: about 15 minutes on an Apple M5 Pro.

git clone https://github.com/MaxHalford/cardinal
cd cardinal
git submodule update --init duckdb extension-ci-tools
make release
./build/release/test/unittest "test/*"

build/release/duckdb is a DuckDB CLI with cardinal built in.

Porting to a new DuckDB release: docs/upgrading.md. The evaluation (benchmarks, accuracy, plan quality, runtime) lives in research/.

License

MIT.

About

πŸ¦† DuckDB extension for better cardinality estimates, using Bayesian networks

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Contributors

Languages