Anti Performance

SQL
6 min read

Dolt is the first fully version controlled relational database. Every Git command is exposed as a SQL function, allowing users to efficiently branch, diff, and merge tables.

We recently added anti and semi join support. ANTI_JOIN and SEMI_JOIN are special operators drawn from a larger universe of subquery optimizations that perform set existence checks. We will explain partial joins and their performance characteristics more later.

As an example, this query now "unnests" the IN_SUBQUERY and EXISTS filters into ANTI_JOIN and SEMI_JOIN:

select * from table1
where id NOT IN (select id from table2) and
EXISTS (select id from table3 where id = table1.id)
+---------------------------------+
| plan                            |
+---------------------------------+
| AntiJoin                        |
|  ├─ (table1.id = applySubq0.id) |
|  ├─ SemiJoin                    |
|  │   ├─ (table3.id = table1.id) |
|  │   ├─ Table                   |
|  │   │   └─ name: table1        |
|  │   └─ Table                   |
|  │       ├─ name: table3        |
|  │       └─ columns: [id]       |
|  └─ TableAlias(applySubq0)      |
|      └─ Table                   |
|          ├─ name: table2        |
|          └─ columns: [id]       |
+---------------------------------+

Evaluating these operations as joins can lower memory usage and latency of customer queries by more than 10x in many circumstances. To get started, just use IN, EXISTS, or comparison subquery expressions. This blog will be an intro into the internals of subquery unnesting.

semi join memory

Background

SQL queries are rarely written in the most efficient and compact forms. The same way your source code is written to be read by humans and compiled to an optimal format for computers, SQL queries are compiled and simplified for execution. Whether because of ORMs, or intentionally organizing big queries into logical groups, we think in a different frame of reference than is optimal for SQL engines. As a result one of the important functions of a SQL optimizer is to simplify complex operators into constituent components. A trivial example might look like removing redundant filters:

select * from table1 where id = 1 or id = 1;
=>
select * from table1 where id = 1;

You also might (correctly) question whether there is any performance difference after simplification. But SQL is a surprising source of emergent behavior. Subtle interactions and indirections can quickly snowball into NP complete problems.

Joins are the main source of trouble for query simplification. A query on one table will never be as compute intensive as reading multiple tables. We have done a lot of work improving join planning, but "multi-relational" SQL queries can be subdivided and hidden via subqueries. We can hide tables in queries all we want, but the result is still a multi-table indirect join. We have not attempted to optimize between subquery scopes before now, which has caused headaches for some customers.

Consider the query below:

select a.* from table1 a join table2 b on a.id = b.id;

This is a two table join. A lack of indexes might prevent it from being fast, but it is standard and close to the optimal execution format. We can enumerate and cost all join orders, index selections, and physical operators (MERGE, HASH, INNER, LOOKUP).

The next query is equivalent, but reads two tables that communicate only via a subquery:

select * from table1 a where id in (select id from table2)

Before SEMI_JOIN, this query was forced into one execution strategy: load table2 into a hash map and probe while scanning table1.

Lets look at a performance profile for the explicit vs implicit join:

select count(*) from table1;
+----------+
| count(*) |
+----------+
| 10       |
+----------+

select count(*) from table2;
+----------+
| count(*) |
+----------+
| 1000000  |
+----------+

select count(a.*) from table1 a join table2 b on a.id = b.id;
+------------+
| count(a.*) |
+------------+
| 10         |
+------------+

________________________________________________________
Executed in   65.65 millis    fish           external
   usr time   51.86 millis   89.00 micros   51.77 millis
   sys time   24.26 millis  555.00 micros   23.70 millis


select count(*) from table1 a where id in (select id from table2)
+------------+
| count(a.*) |
+------------+
| 10         |
+------------+

________________________________________________________
Executed in  891.40 millis    fish           external
   usr time  1077.81 millis   96.00 micros  1077.71 millis
   sys time  131.76 millis  493.00 micros  131.27 millis

To summarize, table1 has 10 rows and table2 has 1 million rows. The explicit join takes ~100 milliseconds, and the implicit join takes ~1 second, timed using dolt v0.50.0. The queries are equivalent but the first is 10x faster than the second.

If we re-run the same comparison but with 50 million rows in table2, the LOOKUP_JOIN still takes ~100 milliseconds, but the IN_SUBQUERY takes 3 minutes! Index joins are fast, we just have to peel back a few layers sometimes to find them.

SemiJoin and AntiJoin

Dolt now internally converts the second query into the first. In dolt v0.52.16, the performance is the same between the two queries:

select a.* from table1 a join table2 b on a.id = b.id;
...
________________________________________________________
Executed in   75.84 millis    fish           external
   usr time   64.72 millis   95.00 micros   64.63 millis
   sys time   28.01 millis  474.00 micros   27.53 millis

select * from table1 a where id in (select id from table2);
________________________________________________________
Executed in   75.84 millis    fish           external
   usr time   64.72 millis   95.00 micros   64.63 millis
   sys time   28.01 millis  474.00 micros   27.53 millis

This is because we convert the IN_SUBQUERY into a SEMI_JOIN. A SEMI_JOIN is a partial join that selects rows from a left table only if a match is found in the right table. Rows from the right table are not returned. Here is the execution plan structured as a SEMI_JOIN:

explain select * from table1 a
          where id IN (select id from table2);
+------------------------------------+
| plan                               |
+------------------------------------+
| SemiLookupJoin                     |
|  ├─ (a.id = applySubq0.id)         |
|  ├─ TableAlias(a)                  |
|  │   └─ Table                      |
|  │       └─ name: table1           |
|  └─ TableAlias(applySubq0)         |
|      └─ IndexedTableAccess(table2) |
|          ├─ index: [table2.id]     |
|          └─ columns: [id]          |
+------------------------------------+

The algorithm looks something like:

  1. Choose a row from the left side.

  2. Index lookup the corresponding row in table2.

  3. If we find any match, return the left row.

  4. If we do not find a match, continue to the next left row.

The result is the same as the IN_SUBQUERY, except now we are using a join. And because it is a join, we can apply regular join optimizations, like applying a lookup to more quickly find a match.

ANTI_JOIN is the inverse of SEMI_JOIN: we return left rows only if no match is found in the right table. ANTI_JOIN benefits equally from the ability to use a point lookup.

explain select a.* from table1 a
          where id NOT IN (select id from table2);
+------------------------------------+
| plan                               |
+------------------------------------+
| AntiLookupJoin                     |
|  ├─ (a.id = applySubq0.id)         |
|  ├─ TableAlias(a)                  |
|  │   └─ Table                      |
|  │       └─ name: table1           |
|  └─ TableAlias(applySubq0)         |
|      └─ IndexedTableAccess(table2) |
|          ├─ index: [table2.id]     |
|          └─ columns: [id]          |
+------------------------------------+

Joins are a strength that we want to lean into. Joins should greedily absorb nested subqueries. We can reorder joins, infer transitive filter closures, choose specialized operators that take advantage of indexes, hash maps, and sorting. Aggregating relations into one join tree finds one global optimum, rather than scattered local maxima. All of these have significant effects on the memory and runtime of subqueries. And funneling operators into joins lets us focus time and energy on optimizing a narrow part of the codebase, rather than several disparate operators.

Memory effects

IN_SUBQUERY expressions are saved in-memory to avoid re-computing. This can be advantageous, but usually locks us into executing SUBQUERY_INs as HASH_JOINs. Lets consider the query from before:

select * from table1 where id in (select id from table2)

But now table2 is 50M rows. Here is what the memory profile looks like:

IN subquery memory

The old Dolt loads table2 into memory as a mandatory default. Sometimes we do want a memory heavy operator, but it should be considered against trade-offs during join costing. We've had customers even OOM due to subquery memory inflexibility.

The SEMI_JOIN only uses 2MB of memory. That is 3 orders of magnitude, or 1000x less than reading all of table2 into memory.

This is an extreme example chosen to highlight the difference. The important point is that a join tree will only use as much memory as is necessary to read chunks from disk, maintain the execution stack, and spool values to clients.

Correlated subqueries

Lastly, we will touch on a thread of future work. The most interesting kinds of subquery expressions depend on the outer scope. Consider a common warehouse query used in the database literature:

select c custkey from customer c
where 1000000 >
(select sum(o.totalprice)
from orders o
where o.custkey = c.custkey)

This query finds a list of customers whose total spend exceeds $1 million. This "correlated subquery" is more complicated than our previous IN/EXISTS queries. We cannot currently optimize this expression because the orders subquery takes a customer row as a dependency, blocking a join order rearrangement. The only way to evaluate the orders subquery is by first reading a fresh c.custkey. The orders subquery will be evaluated wholly every customer row.

We hope to optimize correlated subqueries in the near future. The process is similar to those used for SEMI_JOIN and ANTI_JOIN. We convert correlated subquery expressions into join equivalents that enforce the dependency ordering. The intermediate representation (IR) of the join facilitates further simplification, unnesting, and rearrangement to find the fastest global join path.

Summary

We added SEMI_JOIN and ANTI_JOIN support for Dolt. We noted how these operators convert subquery expressions like IN, EXISTS, and comparisons into joins. We dug into some of the ways that SEMI_JOIN and ANTI_JOIN are preferable to subqueries. In the future we wish to convert all subquery expressions into join trees. We have more work to do, but joins are a strength that we want to continue to lean into.

If you have any questions about Dolt, databases, or Golang performance reach out to us on Twitter, Discord, and GitHub!

SHARE

JOIN THE DATA EVOLUTION

Get started with Dolt

Or join our mailing list to get product updates.