Skip to content

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) #9192

Description

@tomaszdubiel18

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:

  1. 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.
  2. 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.
  3. 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:

  1. 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.
  2. The peer group is scanned once; the result is used both for the window end and for rangePending (new helper findPeerGroupEnd()).
  3. The stream is moved back to the current row only if it was actually moved.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions