Below are the results of analysis performed by AI.
Title: Window functions with ORDER BY are up to 1.8 times slower than in 3.0, because the frame is evaluated for every row, also for functions that do not use it (4.0, 5.0, master)
Summary
Since 4.0, window functions with ORDER BY in the window (ROW_NUMBER, RANK, DENSE_RANK, LAG, running SUM/COUNT, ...) are slower than in 3.0.
The test case below shows 1.28–1.40 times slower execution when the window buffer fits in memory, and 1.74 times slower execution when it is spilled to the temporary file.
The sort that feeds the window is not slower, and windows without ORDER BY are affected only marginally.
The cause is in WindowedStream::WindowStream::internalGetRecord(), which reads every row from the window buffer about 7 times instead of once.
A patch for v5.0-release is included below.
It does not change any results, makes ROW_NUMBER faster than in 3.0, and reduces the time of RANK and running SUM by about a quarter.
Environment
- Firebird 3.0.14.33856, 4.0.8.3328 (97f2656) and 5.0.5.1903 (snapshot, 14bfcdf): official Linux x64 packages.
- Two own builds of v5.0-release at the same commit 14bfcdf (gcc 13.3, release build): one unchanged and one with the patch below. The unchanged own build and the official 5.0.5.1903 package differ by at most about 8%.
- SuperServer,
DefaultDbCachePages = 32768, all other settings default (TempCacheLimit 64 MB), page size 16 KB, 2 vCPU.
- isql over TCP; times are "Elapsed time" from
SET STATS ON, median of 5 runs on a warm cache.
Test case
-- 1,000,000 rows, 10,000 partitions of 100 rows (column k), unique sort key (d, id)
create table t (
id integer not null primary key,
k integer,
d date,
v numeric(15,2)
);
commit;
set term ^;
execute block as
declare i integer = 1;
begin
while (i <= 1000000) do
begin
insert into t (id, k, d, v)
values (:i, mod(:i * 7919, 10000), dateadd(mod(:i * 31, 2000) day to date '2020-01-01'),
mod(:i * 13, 100000) / 100.00);
i = i + 1;
end
end^
set term ;^
commit;
-- 150,000 rows, 1,500 partitions of 100 rows (the window buffer fits into the default TempCacheLimit)
create table t2 (
id integer not null primary key,
k integer,
d date,
v numeric(15,2)
);
commit;
insert into t2 select * from t where k < 1500;
commit;
set stats on;
-- Q1: the same sort without a window (reference)
select count(*), sum(x.id) from (select first 2000000 id from t2 order by k, d, id) x;
-- Q2: ROW_NUMBER with ORDER BY
select count(*), sum(x.w) from (select row_number() over (partition by k order by d, id) w from t2) x;
-- Q3: ROW_NUMBER without ORDER BY
select count(*), sum(x.w) from (select row_number() over (partition by k) w from t2) x;
-- Q4: RANK
select count(*), sum(x.w) from (select rank() over (partition by k order by d, id) w from t2) x;
-- Q5: running SUM, default frame (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
select count(*), sum(x.w) from (select sum(v) over (partition by k order by d, id) w from t2) x;
-- Q6: ROW_NUMBER with ORDER BY, 1,000,000 rows (the window buffer is spilled to the temporary file)
select count(*), sum(x.w) from (select row_number() over (partition by k order by d, id) w from t) x;
-- Q7 (4.0+): running SUM with an explicit ROWS frame
select count(*), sum(x.w) from (select sum(v) over (partition by k order by d, id rows between unbounded preceding and current row) w from t2) x;
Results
All versions return the same results.
| Query |
3.0.14 |
4.0.8 |
5.0.5 |
5.0.5 / 3.0.14 |
5.0 own build |
5.0 own build + patch |
| Q1 sort without window |
0.119 s |
0.117 s |
0.116 s |
0.97 |
0.117 s |
0.106 s |
| Q2 ROW_NUMBER, ORDER BY |
0.246 s |
0.317 s |
0.314 s |
1.28 |
0.325 s |
0.209 s |
| Q3 ROW_NUMBER, no ORDER BY |
0.147 s |
0.152 s |
0.152 s |
1.03 |
0.154 s |
0.148 s |
| Q4 RANK |
0.237 s |
0.309 s |
0.312 s |
1.32 |
0.330 s |
0.251 s |
| Q5 running SUM (RANGE) |
0.256 s |
0.345 s |
0.358 s |
1.40 |
0.376 s |
0.282 s |
| Q6 ROW_NUMBER, 1,000,000 rows |
3.336 s |
5.777 s |
5.816 s |
1.74 |
6.026 s |
2.675 s |
| Q7 running SUM (ROWS) |
– |
0.290 s |
0.291 s |
– |
0.315 s |
0.234 s |
4.0 and 5.0 behave the same, so the slowdown came with the 4.0 rework of window functions (frame support).
A wider set of measurements on the same data (Python client, 3–7 runs) gives the same picture.
With 1,000,000 rows, ROW_NUMBER, RANK, DENSE_RANK, running SUM and running COUNT are 1.7–1.8 times slower than in 3.0, and LAG is 1.36 times slower.
With TempCacheLimit = 1G, Q6 takes 1.66 s in 3.0.14 and 2.11 s in 5.0.5.
With the patch:
ROW_NUMBER is 36% faster in memory and 56% faster with the temporary file, which makes it faster than in 3.0.
RANK and running SUM are 24–26% faster in memory and about 35% faster with the temporary file, but still 6–21% slower than in 3.0.
- Queries without
ORDER BY in the window are not affected.
Cause (based on reading the source code)
Functions that do not respect the frame (ROW_NUMBER, RANK, DENSE_RANK, PERCENT_RANK, CUME_DIST, NTILE, LAG, LEAD) always get the default frame RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (ExprNodes.cpp).
For this frame, WindowStream::internalGetRecord() does the following for each row:
- To find the window end (L713-L739), it reads the following rows until the order key changes, then moves back with
locate() and reads the current row again.
- To compute
rangePending (L759-L783), it repeats the same peer-group scan with the same result, then moves back and reads the current row again.
- In the aggregation block (L785-L853), it extends the previous window by the new row: it calls
locate(), reads the row, calls aggPass(), then calls locate() and reads the current row once more. This is done even when the window has no aggregate functions; the code says so in a comment: //// TODO: There is no need to pass record by record when m_aggSources.isEmpty().
ROW_NUMBER, LAG, LEAD and NTILE use neither the frame nor the aggregation; they need only the partition boundaries and the row position.
With a unique order key, every row is thus read from the window buffer about 7 times.
Each BufferedStream::internalGetRecord() copies all fields of the record, and when the buffer is in the temporary file, each read is also a read() + lseek() system call, which explains the larger slowdown of Q6.
Callgrind measurement on 20,000 rows (200 partitions of 100), own builds; the window part is the query minus Q1 on the same data:
| Query |
build |
BufferedStream::getRecord calls |
BufferedStream::locate calls |
instructions, window part |
| Q3 ROW_NUMBER, no ORDER BY |
unchanged |
120,503 |
20,801 |
96 M |
| Q2 ROW_NUMBER, ORDER BY |
unchanged |
219,703 |
100,201 |
380 M |
| Q2 ROW_NUMBER, ORDER BY |
patched |
100,103 |
20,201 |
166 M |
| Q4 RANK |
unchanged → patched |
219,703 → 140,303 |
100,201 → 40,601 |
383 M → 249 M |
| Q5 running SUM |
unchanged → patched |
219,703 → 140,303 |
100,201 → 40,601 |
459 M → 304 M |
The call counts include filling the buffers.
The same code is in master (e1e14ac), extended with GROUPS and EXCLUDE; master was not measured.
Proposed change
The patch for v5.0-release (below) changes WindowedStream.cpp and RecordSource.h:
- If a window has no aggregate functions and its other functions are only
ROW_NUMBER, LAG, LEAD and NTILE, the frame and the aggregation are not evaluated. aggInit() is still called on the first row of each partition, where the original code called it. The flag is computed once, in the WindowStream constructor.
- The peer group is scanned once; the result is used both for the window end and for
rangePending (new helper findPeerGroupEnd()).
- The stream is moved back to the current row only if it was actually moved.
- When the frame grows by exactly the current row (same start, previous end
position - 1, new end position), the current record, which is already fetched, is passed to aggPass() directly. This covers running aggregates with a ROWS frame and with a RANGE frame over a unique order key.
RANK, DENSE_RANK, PERCENT_RANK, CUME_DIST, FIRST_VALUE, LAST_VALUE, NTH_VALUE and the aggregate functions keep the frame evaluation and benefit only from changes 2–4.
Notes:
- Frame-independent functions are recognized by
aggInfo.name, because the engine is built without RTTI. A cleaner way would be a new capability flag in AggNode, next to CAP_SUPPORTS_WINDOW_FRAME and CAP_RESPECTS_WINDOW_FRAME; it was not added, to avoid changing Nodes.h. An unrecognized function simply stays on the original path.
- For master, the patch needs to be adapted to
GROUPS and EXCLUDE. For example, change 4 must apply only when m_exclusion == Exclusion::NO_OTHERS.
Verification of the patch
The patched build was compared with the official 5.0.5.1903 on 70 queries, and all results were identical:
- 41 queries over a 20,000-row table with ties and NULLs in the order key and NULLs in the arguments: all window functions, windows with and without
PARTITION BY / ORDER BY, ROWS and RANGE frames with offsets, DESC and NULLS FIRST/LAST, several functions in one window, empty and one-row input;
- 20 queries with less common usage: correlated subqueries with windows, a selectable procedure with windows called for each outer row, a view with windows read with
FIRST/SKIP, windows over GROUP BY (including SUM(SUM(...)) OVER), different windows in one query, one-row partitions, peers defined by a case-insensitive collation, an invalid NTILE argument (the same error is raised);
- 9 checksum queries over 1,000,000 rows with the window buffer in the temporary file.
The firebird-qa test files containing OVER ( (183 files) give identical results on the unchanged and the patched build: 147 passed, 4 skipped, 27 deselected.
7 of them fail on both builds and also on the official 5.0.5.1903, for reasons unrelated to window functions (environment, plan expectations).
The only other observable difference is in the profiler statistics: the record source under Window Partition is fetched fewer times (for Q2 on 20,000 rows, FETCH_COUNTER of that Record Buffer drops from 179,424 to 59,498).
Expected behaviour
ROW_NUMBER, LAG, LEAD and NTILE with ORDER BY in the window cost the same as without it, apart from the sort.
For the other window functions, each row is read from the window buffer only as many times as the frame actually requires.
Below are the results of analysis performed by AI.
Title: Window functions with ORDER BY are up to 1.8 times slower than in 3.0, because the frame is evaluated for every row, also for functions that do not use it (4.0, 5.0, master)
Summary
Since 4.0, window functions with
ORDER BYin the window (ROW_NUMBER,RANK,DENSE_RANK,LAG, runningSUM/COUNT, ...) are slower than in 3.0.The test case below shows 1.28–1.40 times slower execution when the window buffer fits in memory, and 1.74 times slower execution when it is spilled to the temporary file.
The sort that feeds the window is not slower, and windows without
ORDER BYare affected only marginally.The cause is in
WindowedStream::WindowStream::internalGetRecord(), which reads every row from the window buffer about 7 times instead of once.A patch for v5.0-release is included below.
It does not change any results, makes
ROW_NUMBERfaster than in 3.0, and reduces the time ofRANKand runningSUMby about a quarter.Environment
DefaultDbCachePages = 32768, all other settings default (TempCacheLimit64 MB), page size 16 KB, 2 vCPU.SET STATS ON, median of 5 runs on a warm cache.Test case
Results
All versions return the same results.
4.0 and 5.0 behave the same, so the slowdown came with the 4.0 rework of window functions (frame support).
A wider set of measurements on the same data (Python client, 3–7 runs) gives the same picture.
With 1,000,000 rows,
ROW_NUMBER,RANK,DENSE_RANK, runningSUMand runningCOUNTare 1.7–1.8 times slower than in 3.0, andLAGis 1.36 times slower.With
TempCacheLimit = 1G, Q6 takes 1.66 s in 3.0.14 and 2.11 s in 5.0.5.With the patch:
ROW_NUMBERis 36% faster in memory and 56% faster with the temporary file, which makes it faster than in 3.0.RANKand runningSUMare 24–26% faster in memory and about 35% faster with the temporary file, but still 6–21% slower than in 3.0.ORDER BYin the window are not affected.Cause (based on reading the source code)
Functions that do not respect the frame (
ROW_NUMBER,RANK,DENSE_RANK,PERCENT_RANK,CUME_DIST,NTILE,LAG,LEAD) always get the default frameRANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW(ExprNodes.cpp).For this frame,
WindowStream::internalGetRecord()does the following for each row:locate()and reads the current row again.rangePending(L759-L783), it repeats the same peer-group scan with the same result, then moves back and reads the current row again.locate(), reads the row, callsaggPass(), then callslocate()and reads the current row once more. This is done even when the window has no aggregate functions; the code says so in a comment://// TODO: There is no need to pass record by record when m_aggSources.isEmpty().ROW_NUMBER,LAG,LEADandNTILEuse neither the frame nor the aggregation; they need only the partition boundaries and the row position.With a unique order key, every row is thus read from the window buffer about 7 times.
Each
BufferedStream::internalGetRecord()copies all fields of the record, and when the buffer is in the temporary file, each read is also aread()+lseek()system call, which explains the larger slowdown of Q6.Callgrind measurement on 20,000 rows (200 partitions of 100), own builds; the window part is the query minus Q1 on the same data:
BufferedStream::getRecordcallsBufferedStream::locatecallsThe call counts include filling the buffers.
The same code is in master (e1e14ac), extended with
GROUPSandEXCLUDE; master was not measured.Proposed change
The patch for v5.0-release (below) changes
WindowedStream.cppandRecordSource.h:ROW_NUMBER,LAG,LEADandNTILE, the frame and the aggregation are not evaluated.aggInit()is still called on the first row of each partition, where the original code called it. The flag is computed once, in theWindowStreamconstructor.rangePending(new helperfindPeerGroupEnd()).position - 1, new endposition), the current record, which is already fetched, is passed toaggPass()directly. This covers running aggregates with aROWSframe and with aRANGEframe over a unique order key.RANK,DENSE_RANK,PERCENT_RANK,CUME_DIST,FIRST_VALUE,LAST_VALUE,NTH_VALUEand the aggregate functions keep the frame evaluation and benefit only from changes 2–4.Notes:
aggInfo.name, because the engine is built without RTTI. A cleaner way would be a new capability flag inAggNode, next toCAP_SUPPORTS_WINDOW_FRAMEandCAP_RESPECTS_WINDOW_FRAME; it was not added, to avoid changingNodes.h. An unrecognized function simply stays on the original path.GROUPSandEXCLUDE. For example, change 4 must apply only whenm_exclusion == Exclusion::NO_OTHERS.Verification of the patch
The patched build was compared with the official 5.0.5.1903 on 70 queries, and all results were identical:
PARTITION BY/ORDER BY,ROWSandRANGEframes with offsets,DESCandNULLS FIRST/LAST, several functions in one window, empty and one-row input;FIRST/SKIP, windows overGROUP BY(includingSUM(SUM(...)) OVER), different windows in one query, one-row partitions, peers defined by a case-insensitive collation, an invalidNTILEargument (the same error is raised);The firebird-qa test files containing
OVER ((183 files) give identical results on the unchanged and the patched build: 147 passed, 4 skipped, 27 deselected.7 of them fail on both builds and also on the official 5.0.5.1903, for reasons unrelated to window functions (environment, plan expectations).
The only other observable difference is in the profiler statistics: the record source under
Window Partitionis fetched fewer times (for Q2 on 20,000 rows,FETCH_COUNTERof thatRecord Bufferdrops from 179,424 to 59,498).Expected behaviour
ROW_NUMBER,LAG,LEADandNTILEwithORDER BYin the window cost the same as without it, apart from the sort.For the other window functions, each row is read from the window buffer only as many times as the frame actually requires.