SQLite User Forum

Complex CTE queries ~50x slower since v3.41.0
Login

Complex CTE queries ~50x slower since v3.41.0

(1) By Jean-Noël Mayor (atgeflu) on 2025-01-02 16:19:57 [link] [source]

Hi, I've noticed that since I upgraded to a newer version of sqlite at work, that complex queries with several common table expressions have become significantly slower.

At first I thought this might be a python issue since I didn't run into any issues on my private laptop.

But now I also ran into similar issues at home, when I had a view which I created using the DB Browser for SQLite that was running very slowly when accessed through PHP. The issue persisted when using sqlite on the command line.

The sqlite versions of the different programs are as follows:

Program Version
DB Browser for SQLite 3.39.2
PHP 3.45.1
sqlite3 3.45.1

I downloaded and compiled previous versions of sqlite to figure out when the issue first appears. As far as I can tell the change happened between

Date Version
2023-02-21 3.41.0
2022-12-28 3.40.1

The issue can be tested as follows:

 CREATE TABLE events (
    id INTEGER PRIMARY KEY,
    activity TEXT,
    finished INTEGER
);

Adding random records:

WITH RECURSIVE 
    cnt(x) AS (
        SELECT 1
        UNION ALL
        SELECT x+1 FROM cnt 
        LIMIT 30000
    )
INSERT INTO events (activity, finished)
SELECT 
    (random() % 100),
    ABS(RANDOM() % 2)
FROM cnt;

The following query is a simplified version of the actual query I was using. The goal is not to improve this specific query, but to show that the same query executed on the same data using different sqlite versions has a significant performance difference:

with BASE as (
select e.*,
	CASE 
	    WHEN activity like '1%' THEN 'No. 1'
		ELSE activity
	END AS extracted_name
	from EVENTS e
), finished_books as (
select * from BASE
where finished = 1
), entries_same_activity_after_finished as (
select b.*, f.id as id_before from BASE b
inner join finished_books f
on b.extracted_name = f.extracted_name and b.id > f.id
), details_relevant_activities as (
select b.* from base b
inner join (select distinct extracted_name, id from entries_same_activity_after_finished) e
on b.extracted_name = e.extracted_name and b.id = e.id
)
select b.*, 
	b.extracted_name || 
			case when d.id is null then ''
				else 'D' end as extracted_name_dupl from BASE b
left join details_relevant_activities d
on b.id = d.id

System Information:

OS: Fedora release 40 (Forty) x86_64
Host: HP ProBook 450 G2 A3009DD10303
Kernel: 6.12.6-100.fc40.x86_64
CPU: Intel i5-4210U (4) @ 2.700GHz 

Timing comparison (for different versions and sizes of test set):

version 100'000 rows 30'000 rows 15'0000 rows 7'0000 rows 3'0000 rows
3.40.1 Run Time: real 46.275 user 42.535744 sys 0.672017 Run Time: real 3.648 user 3.430770 sys 0.056322 Run Time: real 1.015 user 0.942798 sys 0.041030 Run Time: real 0.282 user 0.247160 sys 0.024698 Run Time: real 0.082 user 0.072963 sys 0.005846
3.41.0 Run Time: real 2691.661 user 2607.005232 sys 9.414320 Run Time: real 236.377 user 229.112614 sys 0.941067 Run Time: real 64.587 user 61.214468 sys 0.288662 Run Time: real 12.489 user 12.131109 sys 0.081793 Run Time: real 2.364 user 2.308785 sys 0.011427
3.47.2 Run Time: real 2500.289 user 2452.733755 sys 4.346740 Run Time: real 228.898 user 222.533332 sys 0.609439 Run Time: real 57.119 user 55.574498 sys 0.146679 Run Time: real 13.813 user 13.109306 sys 0.071106 Run Time: real 2.321 user 2.263598 sys 0.003925
3.48.0 Run Time: real 2346.836 user 2294.874719 sys 8.053405 Run Time: real 204.010 user 201.922576 sys 0.572177 Run Time: real 51.152 user 50.353303 sys 0.163441 Run Time: real 11.715 user 11.379347 sys 0.059721 Run Time: real 2.124 user 2.081919 sys 0.010906

The query plans are as follows:

Version 3.40.1:

QUERY PLAN
|--MATERIALIZE BASE
|  `--SCAN e
|--MATERIALIZE details_relevant_activities
|  |--MATERIALIZE e
|  |  |--SCAN BASE
|  |  |--SEARCH b USING AUTOMATIC COVERING INDEX (extracted_name=?)
|  |  `--USE TEMP B-TREE FOR DISTINCT
|  |--SCAN b
|  `--SEARCH e USING AUTOMATIC COVERING INDEX (id=? AND extracted_name=?)
|--SCAN b
`--SEARCH d USING AUTOMATIC COVERING INDEX (id=?) LEFT-JOIN

Version 3.48.0:

QUERY PLAN
|--MATERIALIZE details_relevant_activities
|  |--CO-ROUTINE e
|  |  |--SCAN e
|  |  |--BLOOM FILTER ON e (finished=?)
|  |  `--SEARCH e USING AUTOMATIC PARTIAL COVERING INDEX (finished=?)
|  |--SCAN e
|  |--BLOOM FILTER ON e (extracted_name=? AND id=?)
|  `--SEARCH e USING AUTOMATIC COVERING INDEX (extracted_name=? AND id=?)
|--SCAN e
|--BLOOM FILTER ON d (id=?)
`--SEARCH d USING AUTOMATIC COVERING INDEX (id=?) LEFT-JOIN

Since I use sqlite to analyze data locally I'm used to write fairly complex SQL statements with many CTEs (usually accessing a lot of data). Currently my workaround for dealing with such long running queries is to generate tables for the intermediate steps.

I did find it rather surprising that this example query would run more than 3 minutes for 30'000 rows.

Can you investigate what the cause of this significant performance difference between the different sqlite versions is?

(2) By Richard Hipp (drh) on 2025-01-02 18:15:51 in reply to 1 [link] [source]

Maybe add the MATERIALIZED keyword to the "base" CTE?

with BASE as MATERIALIZED ( ...

(3) By Jean-Noël Mayor (atgeflu) on 2025-01-02 18:56:51 in reply to 2 [link] [source]

This is fantastic, it clearly massively speeds up the query.

This looks like it could also fix the issues I'm having at work.

Thank you very much for this advice.

(4) By Richard Hipp (drh) on 2025-01-02 18:59:42 in reply to 3 [link] [source]

Thanks for providing an actual test case!

I might put up a new 3.48.0 beta later today with tweaks to the query planner so that SQLite can figure out to put in the MATERIALIZED there by itself, without you having to modify your query. Emphasis on "might". Analysis is on-going.

(5) By Jean-Noël Mayor (atgeflu) on 2025-01-02 19:03:02 in reply to 4 [link] [source]

Having the query planner do it automatically would be very convenient.

But having learned about MATERIALIZED was already a great start for the new year.

(8) By Richard Hipp (drh) on 2025-01-02 21:07:12 in reply to 4 [link] [source]

I might put up a new 3.48.0 beta later today....

I'm thinking that "might" is now "probably not".

My idea was to force materialization of CTEs that are used more than once. The BASE CTE in the original query is used 4 separate times, so that would force it to be materialized, thus solving the problem.

But it turns out that SQLite used to do exactly that: force materialization for CTEs that are used more than once. But that caused other performance problems described at Forum post 8f38fc9878a92aa9 by Grunthos about 2 years ago. As things stand right now, I can one or the other of:

  • Make Grunthos's query go fast
  • Make Jean-Noël's query go fast

But so far, I have not come up with a way to do both at the same time.

(9) By Jean-Noël Mayor (atgeflu) on 2025-01-02 21:41:02 in reply to 8 [link] [source]

Thanks for the update.

The linked forum post was pretty interesting. I guess that different people will run into different issues when using complex CTEs.

Given the performance issues you've outlined, it makes sense to leave it as is for now.

Knowing about the possibility to use MATERIALIZED I believe that I should be able to mitigate the issues I've been having so far.

Thank you again for looking into the issue.

(6) By jose isaias cabrera (jicman) on 2025-01-02 20:40:17 in reply to 2 [link] [source]

Newbie question: How does one know when to use that "MATERIALIZED" keyword?

(7) By jose isaias cabrera (jicman) on 2025-01-02 20:43:23 in reply to 6 [link] [source]

Never mind. Found it here.

(10) By Max (Maxulite) on 2025-01-03 07:14:28 in reply to 6 [link] [source]

I met one of possible cases for CTE materialization when played with fts5vocab queries involving joins between virtual tables. As I understand, virtual tables are still out of luck for automatic indexes in joins, at least most of the time. But there are at least two ways for force it to be used

  • Dan's hint for using LIMIT -1 (or probably LIMIT 0) in sub-queries and similar cases. Side question to the developers: in 2020 the post was marked "such a trick could easily break next release though", three years after it still works. Is it still not recommended to use?
  • To wrap the virtual table query in CTE with MATERIALIZED so the following query will use automatic index (and bloom filter) for "fi2mat fi2" part
    CREATE VIRTUAL TABLE FtsIndexInstance USING fts5vocab(FtsIndex, instance)
    .....
    with fi2mat as materialized
    (
      select *, doc as fi2doc from FtsIndexInstance fi2 where fi2.col='text' and fi2.term='for'
    )
    select distinct fi2doc from
    (
      select * from 
        (select * from FtsIndexInstance fi1 where fi1.col='text' and fi1.term='if') fi1
      join 
        fi2mat fi2
      on 
        fi1.doc=fi2.doc and fi2.offset-fi1.offset=5
    )
    

(11) By Richard Hipp (drh) on 2025-01-03 11:26:57 in reply to 1 [source]

Simplified Test Case

CREATE TABLE events (id INTEGER PRIMARY KEY, activity TEXT, finished BOOLEAN); --INTEGER);
WITH RECURSIVE cnt(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM cnt WHERE x<500)
  INSERT INTO events (activity, finished) SELECT (random()%100), abs(random()%2) FROM cnt;
.mode count
.timer on
.eqp on
.testctrl opt -bloomfilter
WITH
  base AS (
   SELECT e.*,	CASE WHEN activity like '1%' THEN 'No. 1' ELSE activity	END AS extracted_name
     FROM events AS e
  ),
  finished_books AS (
    SELECT * FROM base WHERE finished=1
  ),
  entries_same_activity_after_finished AS (
    SELECT b.*, f.id as id_before
      FROM base AS b JOIN finished_books AS f ON b.extracted_name = f.extracted_name AND b.id > f.id
  ),
  details_relevant_activities AS (
    SELECT b.* 
      FROM base AS b JOIN (SELECT DISTINCT extracted_name, id
                              FROM entries_same_activity_after_finished) AS e
        ON b.extracted_name = e.extracted_name AND b.id = e.id
  )
SELECT b.*, b.extracted_name || case when d.id is null then ''	else 'D' end as extracted_name_dupl
  FROM base AS b LEFT JOIN details_relevant_activities AS d ON b.id = d.id;

Make if fast by adding MATERIALIZED to the definition of the "base" CTE.

Analysis

The entries_same_activity_after_finished CTE is essentially a self-join on the events table, using the computed extracted_name column as the join constraint on both sides. If base is materialized, then extracted_name is materialized rather than being computed and SQLite is able to create a query-time index on that column, making the join reasonably efficient. Possible ways to improve SQLite to address situations like this:

  • Add a heuristic that causes CTEs to prefer to be materialized if they are used in a self-join on a computed column.

  • Add the ability to construct a query-time index on an expression.

I do not expect any of this to happen for version 3.48.0. Maybe I'll get a chance to look into it for version 3.49.0 or later. This post is a summary of my findings, recorded so that I can easily revisit the problem later.