cockroachdb / cockroachdb/cockroach

sql/opt: optimizer materializes unused CROSS JOIN inputs under DISTINCT instead of using existence checks

Open
#172,389 2 comments 0 reactions 0 assignees View on GitHub
C-enhancement O-community T-sql-queries X-blathers-triaged
Dominant language
Go
Stars
32.5k
Forks
4.1k
PR merge metrics
PR metrics pending

Description

**Title**

sql/opt: optimizer materializes unused CROSS JOIN inputs under DISTINCT instead of using existence checks

**Describe the problem**

I observed a missed optimization for a `DISTINCT` query over a Cartesian product.

The query is:

```sql
SELECT DISTINCT t1.c4 AS ref0
FROM t2, t0, t1;
```

Only `t1.c4` is projected. No column from `t0` or `t2` is used in the select list, filters, grouping, ordering, or any other expression. Therefore, when `t0` and `t2` are non-empty, the result is determined only by the distinct values of `t1.c4`. The other two joined inputs only need to be checked for row existence.

However, CockroachDB currently plans the query as a full Cartesian product followed by duplicate elimination. In the attached testcase, the table sizes are:

```text
t0: 85 rows
t1: 81 rows
t2: 155 rows
```

So the full Cartesian product has:

```text
85 * 81 * 155 = 1,067,175 rows
```

The baseline query produces all `1,067,175` intermediate rows before the `distinct` operator reduces the result to 45 rows.

Repeated `EXPLAIN ANALYZE` results from the attached output:

```text
Q1 baseline Cartesian DISTINCT:
run 1: execution time 99ms, cross join actual row count 1,067,175
run 2: execution time 96ms, cross join actual row count 1,067,175
run 3: execution time 96ms, cross join actual row count 1,067,175

Q2 explicit CROSS JOIN spelling:
run 1: execution time 46ms, cross join actual row count 1,067,175
run 2: execution time 45ms, cross join actual row count 1,067,175
run 3: execution time 46ms, cross join actual row count 1,067,175
```

A semantically equivalent rewrite using `EXISTS` is much cheaper:

```sql
SELECT DISTINCT t1.c4 AS ref0
FROM t1
WHERE EXISTS (SELECT 1 FROM t0 LIMIT 1)
AND EXISTS (SELECT 1 FROM t2 LIMIT 1);
```

This rewrite scans `t1` and checks `t0` and `t2` using limited scans with `limit: 1`.

Repeated `EXPLAIN ANALYZE` results for the rewrite:

```text
Q3 EXISTS early-out rewrite:
run 1: execution time 860µs, rows decoded from KV: 83
run 2: execution time 812µs, rows decoded from KV: 83
run 3: execution time 752µs, rows decoded from KV: 83
```

The result-equivalence check in the attached SQL returns no rows:

```text
side | ref0
-----+-----
(0 rows)
```

This suggests that the optimizer is missing an existence-check / early-out transformation for unused cross-joined inputs under `DISTINCT`.

**To Reproduce**

[distinct_cross_join_unused_tables_early_out_repro.sql](https://github.com/user-attachments/files/29842745/distinct_cross_join_unused_tables_early_out_repro.sql)
[distinct_cross_join_unused_tables_early_out_repro_result.txt](https://github.com/user-attachments/files/29842746/distinct_cross_join_unused_tables_early_out_repro_result.txt)

1. Set up a CockroachDB cluster. I reproduced this on a single-node local cluster.
2. Run the attached SQL file:

```bash
cockroach sql --file=distinct_cross_join_unused_tables_early_out_repro.sql
```

3. Inspect the `EXPLAIN ANALYZE` output, or redirect it to a file:

```bash
cockroach sql --file=distinct_cross_join_unused_tables_early_out_repro.sql \
> distinct_cross_join_unused_tables_early_out_repro_result.txt
```

4. Observe that the baseline query:

```sql
EXPLAIN ANALYZE
SELECT DISTINCT t1.c4 AS ref0
FROM t2, t0, t1;
```

produces the full Cartesian product:

```text
cross join actual row count: 1,067,175
distinct actual row count: 45
```

5. Observe that the semantically equivalent rewrite:

```sql
EXPLAIN ANALYZE
SELECT DISTINCT t1.c4 AS ref0
FROM t1
WHERE EXISTS (SELECT 1 FROM t0 LIMIT 1)
AND EXISTS (SELECT 1 FROM t2 LIMIT 1);
```

uses limited scans for `t0` and `t2`:

```text
table: t0
spans: LIMITED SCAN
limit: 1

table: t2
spans: LIMITED SCAN
limit: 1
```

and runs much faster.

**Expected behavior**

The optimizer should avoid materializing the full Cartesian product when some cross-joined inputs are unused except for row existence.

For a query of this form:

```sql
SELECT DISTINCT t1.c4
FROM t2, t0, t1;
```

where:

- the projected expression only references `t1`,
- there are no predicates involving `t0` or `t2`,
- there is no grouping, ordering, or other expression depending on `t0` or `t2`,
- and `t0` / `t2` only affect whether the result is empty or not,

the optimizer could consider a plan equivalent to:

```sql
SELECT DISTINCT t1.c4
FROM t1
WHERE EXISTS (SELECT 1 FROM t0 LIMIT 1)
AND EXISTS (SELECT 1 FROM t2 LIMIT 1);
```

This would avoid producing `|t0| * |t1| * |t2|` intermediate rows and would only check that the unused joined inputs are non-empty.

**Additional data / screenshots**

I will attach:

- `distinct_cross_join_unused_tables_early_out_repro.sql`
- `distinct_cross_join_unused_tables_early_out_repro_result.txt`

The attached SQL file includes:

- schema and data setup,
- `ANALYZE` statements,
- row-count sanity checks,
- three `EXPLAIN ANALYZE` runs for the baseline Cartesian `DISTINCT` query,
- three `EXPLAIN ANALYZE` runs for the explicit `CROSS JOIN` spelling,
- three `EXPLAIN ANALYZE` runs for the `EXISTS` early-out rewrite,
- and a result-equivalence check.

The key query is:

```sql
SELECT DISTINCT t1.c4 AS ref0
FROM t2, t0, t1;
```

The equivalent rewrite used for comparison is:

```sql
SELECT DISTINCT t1.c4 AS ref0
FROM t1
WHERE EXISTS (SELECT 1 FROM t0 LIMIT 1)
AND EXISTS (SELECT 1 FROM t2 LIMIT 1);
```

Important output excerpts:

```text
t0_rows | t1_rows | t2_rows | full_cartesian_rows
--------+---------+---------+--------------------
85 | 81 | 155 | 1067175
```

Baseline plan excerpt:

```text
• distinct
│ actual row count: 45
│ distinct on: c4

└── • cross join
│ actual row count: 1,067,175
│ estimated row count: 1,067,175
```

EXISTS rewrite excerpt:

```text
table: t0
spans: LIMITED SCAN
limit: 1

table: t2
spans: LIMITED SCAN
limit: 1
```

Result-equivalence check:

```text
side | ref0
-----+-----
(0 rows)
```

**Environment:**

- CockroachDB version: 26.2.2
- Server OS: Ubuntu 22.04.4 LTS
- Client app: `cockroach sql`

**Additional context**

This testcase is intentionally simple. It has:

- no `ORDER BY`
- no `LIMIT`
- no filter predicates
- a plain `DISTINCT` over a Cartesian product
- joined inputs that are unused by the projected expression and are semantically only row-existence checks

The practical impact is that the current plan can scale with the full Cartesian product size even though the result only depends on one input table plus the non-emptiness of the other inputs. On this small testcase, the baseline query repeatedly takes tens of milliseconds to around 100ms, while the equivalent `EXISTS` rewrite takes less than 1ms.

With larger input tables, the gap can grow quickly because the baseline plan scales with `|t0| * |t1| * |t2|`, while the rewritten form only needs to scan `t1` and check one row from each unused input.

Jira issue: CRDB-65625

Contributor guide

Open the contributing guide

Research direction

Start by running distinct_cross_join_unused_tables_early_out_repro.sql with cockroach sql and inspect the EXPLAIN ANALYZE plans for the baseline and EXISTS queries. Trace the optimizer entry points responsible for DISTINCT over Cartesian products; done means unused inputs are checked for existence without materializing the full product, while preserving the result and existing equivalence check.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, sql
Domain
databases
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.