Skip to content

Commit 757b197

Browse files
MDEV-40012 PQ: divide the whole join's cost, not the split table's
scale_cost_for_parallel_scan() discounted the driving table's scan and nothing else, so every table joined after it was costed at its ull serial price. But a worker does not only scan its chunk: it runs the whole join over that chunk, so the work of each later table is divided between the workers exactly as the scan is. POSITION gains parallel_workers, set on the driving table when the access finally chosen for it was the scan that was costed as parallel, and left at 0 otherwise, including on a driving table whose scan lost to an index, where nothing is parallel. Each table joined after it divides its cost by that number. This commit was prepared with Claude Code
1 parent 2ae3d16 commit 757b197

16 files changed

Lines changed: 567 additions & 23 deletions

mysql-test/main/mysqld--help.result

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,16 @@ The following specify which files/extra groups are read (specified before remain
10121012
Cost of checking the row against the WHERE clause.
10131013
Increasing this will have the optimizer to prefer plans
10141014
with less row combinations
1015+
--parallel-query-row-cost-ratio=#
1016+
What a row costs a parallel scan relative to a serial
1017+
one: a worker copies the row into a batch, hands it over
1018+
and the manager reads it again, where a serial scan reads
1019+
it once. Increasing this will have the optimizer prefer a
1020+
serial scan
1021+
--parallel-query-setup-cost=#
1022+
Cost of starting one parallel query worker thread.
1023+
Increasing this will have the optimizer prefer fewer
1024+
workers, and a serial scan sooner
10151025
--parallel-worker-threads=#
10161026
Number of worker threads available for parallel query
10171027
execution. 0 means parallel execution is disabled
@@ -2008,6 +2018,8 @@ optimizer-trace
20082018
optimizer-trace-max-mem-size 1048576
20092019
optimizer-use-condition-selectivity 4
20102020
optimizer-where-cost 0.032
2021+
parallel-query-row-cost-ratio 1.16
2022+
parallel-query-setup-cost 22
20112023
parallel-worker-threads 0
20122024
path CURRENT_SCHEMA
20132025
performance-schema FALSE
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
SET @s= @@session.parallel_worker_threads;
2+
SET @t= @@session.optimizer_trace;
3+
# the buffer pool the two tables are measured against
4+
SELECT @@global.innodb_buffer_pool_size <= 16*1024*1024 AS pool_is_the_mtr_default;
5+
pool_is_the_mtr_default
6+
1
7+
CREATE TABLE t_small (pk INT PRIMARY KEY, a INT, f CHAR(50) CHARACTER SET latin1)
8+
ENGINE=InnoDB;
9+
INSERT INTO t_small SELECT seq, seq % 97, REPEAT('x',50) FROM seq_1_to_20000;
10+
CREATE TABLE t_big (pk INT PRIMARY KEY, a INT, f CHAR(250) CHARACTER SET latin1)
11+
ENGINE=InnoDB;
12+
INSERT INTO t_big SELECT seq, seq % 97, REPEAT('y',250) FROM seq_1_to_120000;
13+
ANALYZE TABLE t_small, t_big PERSISTENT FOR ALL;
14+
Table Op Msg_type Msg_text
15+
test.t_small analyze status Engine-independent statistics collected
16+
test.t_small analyze status OK
17+
test.t_big analyze status Engine-independent statistics collected
18+
test.t_big analyze status OK
19+
#
20+
# t_small fits in the pool and t_big is several times its size
21+
#
22+
SELECT (SELECT data_length FROM information_schema.tables
23+
WHERE table_schema='test' AND table_name='t_small')
24+
< @@global.innodb_buffer_pool_size AS small_fits_in_pool,
25+
(SELECT data_length FROM information_schema.tables
26+
WHERE table_schema='test' AND table_name='t_big')
27+
> @@global.innodb_buffer_pool_size AS big_exceeds_pool;
28+
small_fits_in_pool big_exceeds_pool
29+
1 1
30+
SET SESSION parallel_worker_threads= 8;
31+
SET SESSION optimizer_trace='enabled=on';
32+
#
33+
# A table that fits in the pool is still scanned in parallel -- the CPU and
34+
# row-copy terms still divide -- but its I/O term is not discounted at all,
35+
# so the divisor is exactly 1.
36+
#
37+
SELECT pk FROM t_small WHERE a = 5;
38+
SELECT CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_workers'),'$[0]') AS UNSIGNED)
39+
AS workers,
40+
CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_io_divisor'),'$[0]') AS DOUBLE)
41+
AS io_divisor
42+
FROM information_schema.optimizer_trace;
43+
workers io_divisor
44+
8 1
45+
#
46+
# A table several times the pool divides its I/O term, up to the worker
47+
# count. The exact figure follows the table's size on disk, so what is
48+
# checked is the relationship rather than the number.
49+
#
50+
SELECT pk FROM t_big WHERE a = 5;
51+
SELECT CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_workers'),'$[0]') AS UNSIGNED)
52+
AS workers,
53+
CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_io_divisor'),'$[0]') AS DOUBLE) > 1
54+
AS io_term_divides,
55+
CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_io_divisor'),'$[0]') AS DOUBLE) <=
56+
CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_workers'),'$[0]') AS UNSIGNED)
57+
AS at_most_the_workers
58+
FROM information_schema.optimizer_trace;
59+
workers io_term_divides at_most_the_workers
60+
8 1 1
61+
#
62+
# and it grows with the worker count, since it is the workers that overlap
63+
# the reads
64+
#
65+
SET SESSION parallel_worker_threads= 4;
66+
SELECT pk FROM t_big WHERE a = 5;
67+
SELECT CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_io_divisor'),'$[0]') AS DOUBLE)
68+
INTO @d4 FROM information_schema.optimizer_trace;
69+
SET SESSION parallel_worker_threads= 16;
70+
SELECT pk FROM t_big WHERE a = 5;
71+
SELECT CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_io_divisor'),'$[0]') AS DOUBLE)
72+
INTO @d16 FROM information_schema.optimizer_trace;
73+
SELECT @d4 < @d16 AS more_workers_divide_more,
74+
ROUND(@d16/@d4,1) AS ratio_16_to_4_workers;
75+
more_workers_divide_more ratio_16_to_4_workers
76+
1 3.8
77+
#
78+
# with parallelism off there is no parallel scan to cost, so no term
79+
#
80+
SET SESSION parallel_worker_threads= 0;
81+
SELECT pk FROM t_big WHERE a = 5;
82+
SELECT JSON_EXTRACT(trace,'$**.parallel_scan_io_divisor') IS NULL AS no_term
83+
FROM information_schema.optimizer_trace;
84+
no_term
85+
1
86+
DROP TABLE t_small, t_big;
87+
SET SESSION optimizer_trace= @t;
88+
SET SESSION parallel_worker_threads= @s;
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#
2+
# MDEV-39492 Parallel Query: the I/O term divides only by what has to be read.
3+
#
4+
# The workers overlap each other's reads, so the I/O part of a scan's cost is
5+
# divided among them. But a page the engine already holds in its buffer pool is
6+
# fetched from memory, and there is no read latency to overlap, so a scan of a
7+
# table that fits in the pool takes its parallelism from the CPU and row-copy
8+
# terms and nothing from the I/O one. scale_cost_for_parallel_scan() therefore
9+
# divides the I/O term by the fraction of the table that cannot be resident, and
10+
# the other two terms by the worker count as before.
11+
#
12+
# The ratio is taken from innodb_buffer_pool_size and the table's size on disk,
13+
# never from what the pool happens to hold, so costing a query twice gives the
14+
# same answer. That is why the two tables here differ in size rather than the
15+
# test warming or flushing anything.
16+
#
17+
# The divisor reaches the worker count only for a table much larger than the
18+
# pool, which is where a parallel scan is worth the most: measured on TPC-H SF1
19+
# Q6, LINEITEM at 1176 MB against a 128 MB pool went from 885 MB/s serial to
20+
# 4210 MB/s at a hundred workers.
21+
#
22+
#
23+
# Adapted on import: the probe queries select a column rather than COUNT(*).
24+
# COUNT(*) was only a way to run the query without printing rows, and this tree's
25+
# gate does not admit an aggregate.
26+
#
27+
--source include/have_innodb.inc
28+
--source include/have_sequence.inc
29+
--source include/not_embedded.inc
30+
31+
SET @s= @@session.parallel_worker_threads;
32+
SET @t= @@session.optimizer_trace;
33+
34+
--echo # the buffer pool the two tables are measured against
35+
SELECT @@global.innodb_buffer_pool_size <= 16*1024*1024 AS pool_is_the_mtr_default;
36+
37+
CREATE TABLE t_small (pk INT PRIMARY KEY, a INT, f CHAR(50) CHARACTER SET latin1)
38+
ENGINE=InnoDB;
39+
INSERT INTO t_small SELECT seq, seq % 97, REPEAT('x',50) FROM seq_1_to_20000;
40+
41+
CREATE TABLE t_big (pk INT PRIMARY KEY, a INT, f CHAR(250) CHARACTER SET latin1)
42+
ENGINE=InnoDB;
43+
INSERT INTO t_big SELECT seq, seq % 97, REPEAT('y',250) FROM seq_1_to_120000;
44+
45+
ANALYZE TABLE t_small, t_big PERSISTENT FOR ALL;
46+
47+
--echo #
48+
--echo # t_small fits in the pool and t_big is several times its size
49+
--echo #
50+
SELECT (SELECT data_length FROM information_schema.tables
51+
WHERE table_schema='test' AND table_name='t_small')
52+
< @@global.innodb_buffer_pool_size AS small_fits_in_pool,
53+
(SELECT data_length FROM information_schema.tables
54+
WHERE table_schema='test' AND table_name='t_big')
55+
> @@global.innodb_buffer_pool_size AS big_exceeds_pool;
56+
57+
SET SESSION parallel_worker_threads= 8;
58+
SET SESSION optimizer_trace='enabled=on';
59+
60+
--echo #
61+
--echo # A table that fits in the pool is still scanned in parallel -- the CPU and
62+
--echo # row-copy terms still divide -- but its I/O term is not discounted at all,
63+
--echo # so the divisor is exactly 1.
64+
--echo #
65+
--disable_result_log
66+
SELECT pk FROM t_small WHERE a = 5;
67+
--enable_result_log
68+
SELECT CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_workers'),'$[0]') AS UNSIGNED)
69+
AS workers,
70+
CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_io_divisor'),'$[0]') AS DOUBLE)
71+
AS io_divisor
72+
FROM information_schema.optimizer_trace;
73+
74+
--echo #
75+
--echo # A table several times the pool divides its I/O term, up to the worker
76+
--echo # count. The exact figure follows the table's size on disk, so what is
77+
--echo # checked is the relationship rather than the number.
78+
--echo #
79+
--disable_result_log
80+
SELECT pk FROM t_big WHERE a = 5;
81+
--enable_result_log
82+
SELECT CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_workers'),'$[0]') AS UNSIGNED)
83+
AS workers,
84+
CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_io_divisor'),'$[0]') AS DOUBLE) > 1
85+
AS io_term_divides,
86+
CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_io_divisor'),'$[0]') AS DOUBLE) <=
87+
CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_workers'),'$[0]') AS UNSIGNED)
88+
AS at_most_the_workers
89+
FROM information_schema.optimizer_trace;
90+
91+
--echo #
92+
--echo # and it grows with the worker count, since it is the workers that overlap
93+
--echo # the reads
94+
--echo #
95+
SET SESSION parallel_worker_threads= 4;
96+
--disable_result_log
97+
SELECT pk FROM t_big WHERE a = 5;
98+
--enable_result_log
99+
SELECT CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_io_divisor'),'$[0]') AS DOUBLE)
100+
INTO @d4 FROM information_schema.optimizer_trace;
101+
SET SESSION parallel_worker_threads= 16;
102+
--disable_result_log
103+
SELECT pk FROM t_big WHERE a = 5;
104+
--enable_result_log
105+
SELECT CAST(JSON_VALUE(JSON_EXTRACT(trace,'$**.parallel_scan_io_divisor'),'$[0]') AS DOUBLE)
106+
INTO @d16 FROM information_schema.optimizer_trace;
107+
SELECT @d4 < @d16 AS more_workers_divide_more,
108+
ROUND(@d16/@d4,1) AS ratio_16_to_4_workers;
109+
110+
--echo #
111+
--echo # with parallelism off there is no parallel scan to cost, so no term
112+
--echo #
113+
SET SESSION parallel_worker_threads= 0;
114+
--disable_result_log
115+
SELECT pk FROM t_big WHERE a = 5;
116+
--enable_result_log
117+
SELECT JSON_EXTRACT(trace,'$**.parallel_scan_io_divisor') IS NULL AS no_term
118+
FROM information_schema.optimizer_trace;
119+
120+
DROP TABLE t_small, t_big;
121+
SET SESSION optimizer_trace= @t;
122+
SET SESSION parallel_worker_threads= @s;

mysql-test/main/parallel_query_join.result

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,26 +128,27 @@ STATS_AUTO_RECALC=0;
128128
INSERT INTO p1 VALUES (1,6,0),(2,1,0),(3,5,2),(4,8,0);
129129
CREATE TABLE p2 (c INT) ENGINE=InnoDB STATS_AUTO_RECALC=0;
130130
INSERT INTO p2 VALUES (1),(2);
131+
INSERT INTO p2 SELECT 0 FROM seq_1_to_3000;
131132
CREATE TABLE p3 (d INT) ENGINE=InnoDB STATS_AUTO_RECALC=0;
132133
INSERT INTO p3 VALUES (3),(-1),(4);
133134
SET @os2= @@optimizer_switch;
134135
SET optimizer_switch='extended_keys=on';
135136
# serial
136137
SET SESSION parallel_worker_threads= 0;
137-
SELECT pk, a, b FROM p1,p2,p3 WHERE b >= d AND pk < c AND b = '0';
138+
SELECT STRAIGHT_JOIN pk, a, b FROM p2,p1,p3 WHERE b >= d AND pk < c AND b = '0';
138139
pk a b
139140
1 6 0
140141
# with workers: the same one row, not one per unfiltered pair
141142
SET SESSION parallel_worker_threads= 4;
142143
FLUSH STATUS;
143-
SELECT pk, a, b FROM p1,p2,p3 WHERE b >= d AND pk < c AND b = '0';
144+
SELECT STRAIGHT_JOIN pk, a, b FROM p2,p1,p3 WHERE b >= d AND pk < c AND b = '0';
144145
pk a b
145146
1 6 0
146147
# and it still ran in the workers rather than falling back (expect 1)
147148
ran_in_the_workers
148149
1
149150
# p1 still carries a pushed index condition, so the case is really covered
150-
EXPLAIN SELECT pk, a, b FROM p1,p2,p3 WHERE b >= d AND pk < c AND b = '0';
151+
EXPLAIN SELECT STRAIGHT_JOIN pk, a, b FROM p2,p1,p3 WHERE b >= d AND pk < c AND b = '0';
151152
id select_type table type possible_keys key key_len ref rows Extra
152153
1 SIMPLE p2 ALL_parallel NULL NULL NULL NULL #
153154
1 SIMPLE p1 ref PRIMARY,idx1 idx1 5 const # Using index condition

mysql-test/main/parallel_query_join.test

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,17 @@ SET optimizer_trace= @ot;
9696
--echo #
9797
# The pushed half lives in the manager handler's pushed_idx_cond, which a worker
9898
# does not have, so a worker has to filter by the pre-pushdown condition.
99+
# STRAIGHT_JOIN pins the order this needs: p2 scanned by the workers, p1 reached
100+
# by idx1 with pk < c pushed into it.
99101
CREATE TABLE p1 (pk INT PRIMARY KEY, a INT, b INT, INDEX idx1(b)) ENGINE=InnoDB
100102
STATS_AUTO_RECALC=0;
101103
INSERT INTO p1 VALUES (1,6,0),(2,1,0),(3,5,2),(4,8,0);
104+
# p2 is the parallel-scanned table, so it needs more leaf pages than one:
105+
# the engine cannot divide a single page and the scan would run serially.
106+
# Only c=2 satisfies pk < c, so the answer is still the one row.
102107
CREATE TABLE p2 (c INT) ENGINE=InnoDB STATS_AUTO_RECALC=0;
103108
INSERT INTO p2 VALUES (1),(2);
109+
INSERT INTO p2 SELECT 0 FROM seq_1_to_3000;
104110
CREATE TABLE p3 (d INT) ENGINE=InnoDB STATS_AUTO_RECALC=0;
105111
INSERT INTO p3 VALUES (3),(-1),(4);
106112
SET @os2= @@optimizer_switch;
@@ -109,13 +115,13 @@ SET optimizer_switch='extended_keys=on';
109115
--echo # serial
110116
SET SESSION parallel_worker_threads= 0;
111117
--sorted_result
112-
SELECT pk, a, b FROM p1,p2,p3 WHERE b >= d AND pk < c AND b = '0';
118+
SELECT STRAIGHT_JOIN pk, a, b FROM p2,p1,p3 WHERE b >= d AND pk < c AND b = '0';
113119

114120
--echo # with workers: the same one row, not one per unfiltered pair
115121
SET SESSION parallel_worker_threads= 4;
116122
FLUSH STATUS;
117123
--sorted_result
118-
SELECT pk, a, b FROM p1,p2,p3 WHERE b >= d AND pk < c AND b = '0';
124+
SELECT STRAIGHT_JOIN pk, a, b FROM p2,p1,p3 WHERE b >= d AND pk < c AND b = '0';
119125

120126
--echo # and it still ran in the workers rather than falling back (expect 1)
121127
--let $pq= query_get_value(SHOW SESSION STATUS LIKE 'Parallel_queries_executed', Value, 1)
@@ -125,7 +131,7 @@ SELECT pk, a, b FROM p1,p2,p3 WHERE b >= d AND pk < c AND b = '0';
125131

126132
--echo # p1 still carries a pushed index condition, so the case is really covered
127133
--replace_column 9 #
128-
EXPLAIN SELECT pk, a, b FROM p1,p2,p3 WHERE b >= d AND pk < c AND b = '0';
134+
EXPLAIN SELECT STRAIGHT_JOIN pk, a, b FROM p2,p1,p3 WHERE b >= d AND pk < c AND b = '0';
129135

130136
SET optimizer_switch= @os2;
131137
DROP TABLE p1, p2, p3;

mysql-test/suite/sys_vars/r/sysvars_server_notembedded.result

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2992,6 +2992,26 @@ NUMERIC_BLOCK_SIZE NULL
29922992
ENUM_VALUE_LIST NULL
29932993
READ_ONLY NO
29942994
COMMAND_LINE_ARGUMENT REQUIRED
2995+
VARIABLE_NAME PARALLEL_QUERY_ROW_COST_RATIO
2996+
VARIABLE_SCOPE SESSION
2997+
VARIABLE_TYPE DOUBLE
2998+
VARIABLE_COMMENT What a row costs a parallel scan relative to a serial one: a worker copies the row into a batch, hands it over and the manager reads it again, where a serial scan reads it once. Increasing this will have the optimizer prefer a serial scan
2999+
NUMERIC_MIN_VALUE 0
3000+
NUMERIC_MAX_VALUE 1000
3001+
NUMERIC_BLOCK_SIZE NULL
3002+
ENUM_VALUE_LIST NULL
3003+
READ_ONLY NO
3004+
COMMAND_LINE_ARGUMENT REQUIRED
3005+
VARIABLE_NAME PARALLEL_QUERY_SETUP_COST
3006+
VARIABLE_SCOPE SESSION
3007+
VARIABLE_TYPE DOUBLE
3008+
VARIABLE_COMMENT Cost of starting one parallel query worker thread. Increasing this will have the optimizer prefer fewer workers, and a serial scan sooner
3009+
NUMERIC_MIN_VALUE 0
3010+
NUMERIC_MAX_VALUE 100000
3011+
NUMERIC_BLOCK_SIZE NULL
3012+
ENUM_VALUE_LIST NULL
3013+
READ_ONLY NO
3014+
COMMAND_LINE_ARGUMENT REQUIRED
29953015
VARIABLE_NAME PARALLEL_WORKER_THREADS
29963016
VARIABLE_SCOPE SESSION
29973017
VARIABLE_TYPE BIGINT UNSIGNED

sql/handler.h

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3816,6 +3816,24 @@ class handler :public Sql_alloc
38163816
*/
38173817
virtual int parallel_end_coordinator() { return 0; }
38183818

3819+
/*
3820+
How many chunks parallel_init_coordinator() would divide the table into,
3821+
estimated from statistics so the optimizer can cost a parallel scan before
3822+
the coordinator has run. Cheap and approximate: no pages are read, and
3823+
stale statistics give a stale answer. 0 means the engine cannot say, and
3824+
the caller must not draw a bound from it.
3825+
*/
3826+
virtual size_t parallel_chunk_count_estimate() const { return 0; }
3827+
3828+
/*
3829+
Size in bytes of the engine's page cache, or 0 if it has none or will not
3830+
say. This is the configured size and not a measurement of what the cache
3831+
currently holds, so it does not move between two executions of the same
3832+
query -- the same reason DISK_READ_RATIO is a constant rather than a cache
3833+
statistic, see optimizer_defaults.h.
3834+
*/
3835+
virtual ulonglong engine_cache_size() const { return 0; }
3836+
38193837
/* To be called from the master thread to get context data for each worker */
38203838
virtual Parallel_worker_ctx *parallel_get_worker_context(size_t worker_idx)
38213839
{

sql/mysqld.cc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5451,6 +5451,14 @@ static int init_server_components()
54515451
process_optimizer_costs(adjust_optimizer_costs, 0);
54525452
us_to_ms(global_system_variables.optimizer_where_cost);
54535453
us_to_ms(global_system_variables.optimizer_scan_setup_cost);
5454+
/*
5455+
Every COST_ADJUST(1000) variable needs a line here: the startup path stores
5456+
the option default in the units the user gives it in and this is what
5457+
converts it, where SET goes through Sys_var_optimizer_cost::*_update() and
5458+
converts itself. parallel_query_row_cost_ratio is a ratio with
5459+
COST_ADJUST(1) and must not be converted.
5460+
*/
5461+
us_to_ms(global_system_variables.parallel_query_setup_cost);
54545462
}
54555463

54565464
/*

0 commit comments

Comments
 (0)