Subject: tuple radix sort

From: John Naylor <[email-masked]>
Date: Wed, 29 Oct 2025 13:28:21 +0700

First, a quick demonstration of what this PoC can do on 1 million
random not-NULL bigints:

set wip_radix_sort = 'off'; select * from test order by a offset 1_000_000_000;
240ms

set wip_radix_sort = 'on'; select * from test order by a offset 1_000_000_000;
140ms

Background: Peter Geoghegan recently mentioned to me off-list an
interesting set of techniques for sorting in the context of databases.
I'm not yet sure how to approach certain aspects of that architecture,
so I won't go into the full picture at this point. However, there is
one piece that already fits well within our existing architecture, and
that is using radix sort on datum1. The basic sequence is:

1. Partition tuples on first key NULL and not-NULL, according to NULLS
FIRST or NULLS LAST.
2. Do normal qsort on the NULL partition using the tiebreak comparator.
3. Create a "conditioned" or "normalized" datum that encodes datum1
such that unsigned comparison is order-preserving, accounting for ASC
/ DESC as well. I've reused space now unused during in-memory not-NULL
sorts:

typedef struct
{
  void     *tuple;      /* the tuple itself */
  Datum    datum1;      /* value of first key column */

  union
  {
    struct
    {
      bool    isnull1;    /* is first key column NULL? */
      int     srctape;    /* source tape number */
    };
    Datum    cond_datum1; /* sort key for radix sort */
  };
} SortTuple;

4. Radix sort on cond_datum1. For the PoC I've based it on the
implementation in "ska sort" [1] (C++, Boost license). For
medium-sized sorts it uses "American flag sort" (there is a paper [3]
co-authored by M. D. McIlroy, same as in the paper we reference for
quicksort). For larger sorts it's similar, but performs multiple
passes, which takes better advantage of modern CPUs. Upon recursion,
sorts on small partitions divert to quicksort. Any necessary tiebreaks
are handled by quicksort, either after the end of radix sort, or when
diverting to small quicksort.
5. Reset isnull1 to "false" before returning to the caller. This also
must be done when diverting to quicksort.

Next steps: Try to find regressions (help welcome here). The v1 patch
has some optimizations, but in other ways things are simple and/or
wasteful. Exactly how things fit together will be informed by what, if
anything, has to be done to avoid regressions. I suspect the challenge
will be multikey sorts when the first key has low cardinality. This is
because tiebreaks are necessarily postponed rather than taken care of
up front. I'm optimistic, since low cardinality cases can be even
faster than our B&M qsort, so we have some headroom:

drop table if exists test;
create unlogged table test (a bigint);
insert into test select
(1_000_000_000 * random())::bigint % 8 -- mod
-- (1_000_000_000 * random())::bigint  -- random, for the case at the top
from generate_series(1,1_000_000,1) i;
vacuum freeze test;

select pg_prewarm('test');
set work_mem = '64MB';

set wip_radix_sort = 'off'; select * from test order by a offset 1_000_000_000;
95ms

set wip_radix_sort = 'on'; select * from test order by a offset 1_000_000_000;
84ms

[1] https://github.com/skarupke/ska_sort/tree/master
[2] https://probablydance.com/2016/12/27/i-wrote-a-faster-sorting-algorithm/
[3] http://static.usenix.org/publications/compsystems/1993/win_mcilroy.pdf

--
John Naylor
Amazon Web Services

---

From: Chao Li <[email-masked]>
Date: Wed, 29 Oct 2025 16:25:03 +0800

> On Oct 29, 2025, at 14:28, John Naylor <[email-masked]> wrote:
> 
> I suspect the challenge
> will be multikey sorts when the first key has low cardinality.

As you predicted, when the first key has very low cardinality, radix is a little bit slower. I built a test that proves that:

```
evantest=# drop table if exists test_multi;
evantest=# create unlogged table test_multi (category int, name text);
— first column has only 4 distinct values
evantest=# insert into test_multi select (random() * 4)::int as category, md5(random()::text) || md5(random()::text) as name from generate_series(1, 1000000);
evantest=# vacuum freeze test_multi;
evantest=# select count(*) from test_multi;
evantest=# set work_mem = '64MB’;

evantest-# \timing on
Timing is on.
evantest=# set wip_radix_sort = 'off';
Time: 0.403 ms
evantest=# \o /dev/null
evantest=# select * from test_multi order by category, name;
Time: 5607.336 ms (00:05.607)
evantest=# select * from test_multi order by category, name;
Time: 5703.555 ms (00:05.704)
evantest=# select * from test_multi order by category, name;
Time: 5692.644 ms (00:05.693)

evantest=# set wip_radix_sort = 'on';
Time: 0.859 ms
evantest=# select * from test_multi order by category, name;
Time: 5822.979 ms (00:05.823)
evantest=# select * from test_multi order by category, name;
Time: 5881.256 ms (00:05.881)
evantest=# select * from test_multi order by category, name;
Time: 5976.351 ms (00:05.976)
```

Roughly 5% slower for this corner case.

However, when I recreate the test table with high cardinality first column, wip_radix_sort seems still slower:

```
evantest=# \o
evantest=# drop table if exists test_multi;
DROP TABLE
evantest=# create unlogged table test_multi (category int, name text);
CREATE TABLE
evantest=# insert into test_multi
evantest-# select (random() * 1000000)::int as category,  md5(random()::text) || md5(random()::text) as name from generate_series(1, 1000000);
INSERT 0 1000000
evantest=# vacuum freeze test_multi;
VACUUM
evantest=# select count(*) from test_multi;
  count
---------
 1000000
(1 row)

evantest=# select * from test_multi limit 5;
 category |                               name
----------+------------------------------------------------------------------
   607050 | c555126a5afea9f5ffe3880248c89944d211bc378f8c3b6d125b4360fe8619b7
   843579 | 69b5a1dba76f52ff238566a3f88315a7425116d5d271fb54701b6e49d4afd8ce
   106298 | a96e8674db219e12463ecdbb405b99c631767972e489093045c97976c17c6561
   621860 | 5e6739ea9f533f9cdb0b8db76e3d4ce63be6b2b612c8aff06c4b80451f8f2edc
   290110 | 56944320e5abd3a854fffdd185b969727e8d414448d440725a392cda4c6355c4
(5 rows)

evantest=# \timing on
Timing is on.

evantest=# \o /dev/null
evantest=# set wip_radix_sort = 'off';
Time: 0.904 ms
evantest=# select * from test_multi limit 5;
Time: 0.983 ms
evantest=# select * from test_multi order by category, name;
Time: 593.578 ms
evantest=# select * from test_multi order by category, name;
Time: 597.329 ms
evantest=# select * from test_multi order by category, name;
Time: 600.050 ms

evantest=# set wip_radix_sort = 'on';
Time: 0.737 ms
evantest=# select * from test_multi order by category, name;
Time: 611.604 ms
evantest=# select * from test_multi order by category, name;
Time: 613.115 ms
evantest=# select * from test_multi order by category, name;
Time: 615.003 ms
```

This seems like a real regression.

Then I tried to only sort on the first column, yes, now radix is faster:

```
evantest=# set wip_radix_sort = 'off’;
evantest=# select * from test_multi order by category;
Time: 445.498 ms
evantest=# select * from test_multi order by category;
Time: 451.834 ms
evantest=# select * from test_multi order by category;
Time: 454.531 ms

evantest=# set wip_radix_sort = 'on';
Time: 0.329 ms
evantest=# select * from test_multi order by category;
Time: 402.829 ms
evantest=# select * from test_multi order by category;
Time: 408.014 ms
evantest=# select * from test_multi order by category;
Time: 415.340 ms
evantest=# select * from test_multi order by category;
Time: 413.969 ms
```

Hope the test helps. (The test was run a MacBook M4. )

Best regards,
--
Chao Li (Evan)
HighGo Software Co., Ltd.
https://www.highgo.com/

---

From: John Naylor <[email-masked]>
Date: Wed, 29 Oct 2025 18:29:35 +0700

> — first column has only 4 distinct values

Thanks for testing. Note it's actually 5 because of rounding. Your
text also seems to have em-dashes and unicode apostrophes where it
should have dashes / single quotes. That's not great if you expect
others to try to reproduce. I'm also not thrilled about having to
remove your psql prompt.

drop table if exists test_multi;
create unlogged table test_multi (category int, name text);
insert into test_multi select (random() * 4)::int as category,
md5(random()::text) || md5(random()::text) as name from
generate_series(1, 1000000);
vacuum freeze test_multi;

Anyway, because this table is larger than my first example, the input
no longer fits into 64MB of work_mem and it switches to an external
merge sort. Normally I set work_mem to 1GB for testing sorts so I
don't have to think about it, but neglected to in my first email. I
don't know if that explains the disparity, but I've made that change
for my quick tests below.

> evantest=# select * from test_multi order by category, name;
[...]
> Roughly 5% slower for this corner case.

Seems fine for me on this old Intel laptop (min of 5 runs):

set wip_radix_sort = 'off';
2368.369

set wip_radix_sort = 'on';
2290.654

It's close enough that I'll want to test more closely at a range of
low-cardinality inputs. I haven't done any rigorous scripted testing
yet, so take this with a grain of salt.

> However, when I recreate the test table with high cardinality first column, wip_radix_sort seems still slower:

drop table if exists test_multi;
create unlogged table test_multi (category int, name text);
insert into test_multi select (random() * 1000000)::int as category,
md5(random()::text) || md5(random()::text) as name from
generate_series(1, 1000000);
vacuum freeze test_multi;

> This seems like a real regression.

It's better for me here (min of 5 again), although the time scanning
the table probably dominates:

off:
536.257

on:
471.345

--
John Naylor
Amazon Web Services

---

From: Chao Li <[email-masked]>
Date: Thu, 30 Oct 2025 09:56:06 +0800

> On Oct 29, 2025, at 19:29, John Naylor <[email-masked]> wrote:
> 
> Thanks for testing. Note it's actually 5 because of rounding.

Yes, 0-4, totally 5.

> Your
> others to try to reproduce.

I just copied the content from psql (running in iTerm). I did a Google search, and found that was because of Mac Mail’s “smart quotes” substitution. Looks like even I manually type in a pair of single quotes, it still does the substitution. I will try to see how to disable that, but I don’t want to switch to another mail app.

> I'm also not thrilled about having to
> remove your psql prompt.
> 

I just wanted to show my entire test process, so I simply copied all contents from psql. In future, I will remove psql prompts from reproduce procedure.

> don't have to think about it, but neglected to in my first email. 

I changed work_men to 1GB and reran the test. As the high cardinality data are still there, so I first reran with data:

```
evantest=# set work_mem = '1GB';
Time: 0.301 ms
evantest=#
evantest=# select * from test_multi order by category, name;
Time: 575.247 ms
evantest=# select * from test_multi order by category, name;
Time: 554.351 ms
evantest=# select * from test_multi order by category, name;
Time: 565.100 ms
evantest=#
evantest=# set wip_radix_sort = 'on';
Time: 0.752 ms
evantest=# select * from test_multi order by category, name;
Time: 558.057 ms
evantest=# select * from test_multi order by category, name;
Time: 565.542 ms
evantest=# select * from test_multi order by category, name;
Time: 559.973 ms
```

With radix_sort on and off, execution time are almost the same.

Then I restore the data to low cardinality, off is still faster than on:
```
evantest=# set wip_radix_sort = ‘off';
Time: 0.549 ms
evantest=# select * from test_multi order by category, name;
Time: 5509.075 ms (00:05.509)
evantest=# select * from test_multi order by category, name;
Time: 5553.566 ms (00:05.554)
evantest=# select * from test_multi order by category, name;
Time: 5598.595 ms (00:05.599)
evantest=# set wip_radix_sort = ‘on';
Time: 0.786 ms
evantest=#
evantest=# select * from test_multi order by category, name;
Time: 5770.964 ms (00:05.771)
evantest=# select * from test_multi order by category, name;
Time: 5779.755 ms (00:05.780)
evantest=# select * from test_multi order by category, name;
Time: 5851.134 ms (00:05.851)
evantest=#
evantest=# set work_mem = '2GB’; # increasing work_mem to 2GB doesn’t help
Time: 0.404 ms
evantest=#
evantest=# select * from test_multi order by category, name;
Time: 5781.005 ms (00:05.781)
evantest=# select * from test_multi order by category, name;
Time: 5826.025 ms (00:05.826)
evantest=# select * from test_multi order by category, name;
Time: 5937.919 ms (00:05.938)
```

Best regards,

---

From: Chao Li <[email-masked]>
Date: Thu, 30 Oct 2025 10:18:32 +0800

> <v1-0001-Use-radix-sort-when-datum1-is-an-integer-type.patch>

I just quick went through the code change. I guess I need more time to understand the entire logic, but I find a typo that might effect the tests:

```
+	Assert(last = first);
```

“=“ should be “=="

Best regards,

---

From: John Naylor <[email-masked]>
Date: Thu, 30 Oct 2025 10:40:44 +0700

> With radix_sort on and off, execution time are almost the same.

Are you by chance running with asserts on? It's happened before, so I
have to make sure. That makes a big difference here because I disabled
diversion thresholds in assert builds so that regression tests (few
cases with large inputs) cover the paths I want, in addition to my
running a standalone stress test.

Speaking of tests, I forgot to mention that regression tests will fail
since in-place radix sort is an unstable sort, as qsort is as well,
but in a different way -- this is expected. In assert builds, the
patch verifies the sort after the fact with the standard comparator,
and will throw an error if it's wrong.

> I just quick went through the code change. I guess I need more time to understand the entire logic, but I find a typo that might effect the tests:

Yes, you're quite right, thanks for spotting! I reran my stress test
that has randomly distributed NULLs and the assert still holds, so
nothing further to fix yet. The NULL partitioning part of the code
hasn't been well tested in its current form, and I may arrange things
so that that step and the datum conditioning step happen in a single
pass. I'm not yet sure if it's important enough to justify the
additional complexity.

--
John Naylor
Amazon Web Services

---

From: Chao Li <[email-masked]>
Date: Thu, 30 Oct 2025 11:45:32 +0800

> On Oct 30, 2025, at 11:40, John Naylor <[email-masked]> wrote:
> 

Yes, assert is always enabled in my sandbox. I can disable assert and rerun the test later.

Best regards,

---

From: David Rowley <[email-masked]>
Date: Thu, 30 Oct 2025 18:01:07 +1300

> Yes, assert is always enabled in my sandbox. I can disable assert and rerun the test later.

Never expect anything meaningful to come from running performance
tests on Assert builds. You should always be rebuilding without
Asserts before doing performance tests.

David

---

From: Chao Li <[email-masked]>
Date: Thu, 30 Oct 2025 13:43:34 +0800

> On Oct 30, 2025, at 13:01, David Rowley <[email-masked]> wrote:
> 

Sure, good to learn. Actually I am very new to PG development, so any guidance is greatly appreciated.

I just made a distclean, then configure without any parameter. Now, the overall execution time reduced ~10% than with asserts. With the low cardinality data, off and on are very close:

```
evantest=# set wip_radix_sort = 'off';
Time: 0.206 ms
evantest=# select * from test_multi order by category, name;
Time: 5070.277 ms (00:05.070)
evantest=# select * from test_multi order by category, name;
Time: 5158.748 ms (00:05.159)
evantest=# select * from test_multi order by category, name;
Time: 5072.708 ms (00:05.073)

evantest=# set wip_radix_sort = 'on';
Time: 0.177 ms
evantest=# select * from test_multi order by category, name;
Time: 4992.516 ms (00:04.993)
evantest=# select * from test_multi order by category, name;
Time: 5145.361 ms (00:05.145)
evantest=# select * from test_multi order by category, name;
Time: 5101.800 ms (00:05.102)

evantest=# \o
evantest=# show work_mem;
 work_mem
----------
 1GB
(1 row)

Time: 0.186 ms
evantest=# explain select * from test_multi order by category, name;
                                QUERY PLAN
---------------------------------------------------------------------------
 Sort  (cost=122003.84..124503.84 rows=1000000 width=69)
   Sort Key: category, name
   ->  Seq Scan on test_multi  (cost=0.00..22346.00 rows=1000000 width=69)
(3 rows)
```

And with high cardinality test data, on has a big win:
```
evantest=# set wip_radix_sort = 'off';
Time: 0.174 ms
evantest=# select * from test_multi order by category, name;
Time: 353.702 ms
evantest=# select * from test_multi order by category, name;
Time: 375.549 ms
evantest=# select * from test_multi order by category, name;
Time: 367.967 ms
evantest=# set wip_radix_sort = 'on';
Time: 0.147 ms
evantest=# select * from test_multi order by category, name;
Time: 279.537 ms
evantest=# select * from test_multi order by category, name;
Time: 278.114 ms
evantest=# select * from test_multi order by category, name;
Time: 284.273 ms
```

Best regards,

---

From: John Naylor <[email-masked]>
Date: Mon, 3 Nov 2025 20:24:35 +0700

I wrote:

> The v1 patch
> anything, has to be done to avoid regressions.

In v1, radix sort diverts to qsort_tuple for small partitions (similar
to how quicksort diverts to insertion sort), but qsort_tuple is
inefficient because the comparator is called via a function pointer.

I also thought having two different radix sorts was too complex, so I
wondered if it'd be better to get rid of the smaller radix sort (whose
control flow I find harder to understand, even ignoring the unsightly
goto) and have the larger sort divert to a new quicksort
specialization that compares on the conditioned datum. That allows
skipping branches for NULL comparisons and order reversal. I've done
this in v2. It makes sense to replace the three current
integer-comparison quicksorts with one.

v1 was careful to restore isnull1 to false when diverting to quicksort
for the tiebreak. v2 doesn't bother, since the only tiebreak in core
that looks at isnull1 is comparetup_datum_tiebreak, which is not
reachable by radix sort, requiring a pass-by-value datum that compares
like an integer. This is a bit of a risk, since it's possible a third
party fork could be doing something weird. Seems unlikely, but
something to keep in mind.

I used a standalone program (attached) to microbenchmark this new
fallback qsort vs. a pass of radix sort on one byte to get a decent
threshold value. This is not quite fair, since the quicksort will then
be finished, but the radix sort could still need to recurse to the
next byte(s), so these number could underestimate the threshold. This
is just to get an idea.

The numbers are in RDTSC ticks per element sorted.

cardinality: 256
number of elements:  100   qsort: 35.4 radix: 49.2
number of elements:  200   qsort: 34.9 radix: 38.1
number of elements:  400   qsort: 42.4 radix: 34.4
number of elements:  800   qsort: 95.0 radix: 29.2
number of elements: 1600   qsort: 115.0 radix: 22.4
number of elements: 3200   qsort: 125.5 radix: 19.4
number of elements: 6400   qsort: 128.1 radix: 17.6

With the highest cardinality possible on a single byte, radix sort is
actually not bad at low inputs. Notice that the time per element is
consistently going down with larger inputs. Smaller inputs have large
constant overheads, made worse by my unrolling the counting step.

cardinality: 2
number of elements:  100   qsort: 09.2 radix: 28.0
number of elements:  200   qsort: 09.1 radix: 19.5
number of elements:  400   qsort: 10.4 radix: 15.7
number of elements:  800   qsort: 10.1 radix: 14.5
number of elements: 1600   qsort: 10.4 radix: 13.7
number of elements: 3200   qsort: 15.8 radix: 13.6
number of elements: 6400   qsort: 22.2 radix: 13.8

This is an extreme best case for B&M quicksort, which is basically
O(n) -- the point at which the per-element time goes up seems purely
due to exceeding L1 cache. Radix sort takes a big input to catch up,
but it doesn't seem awful, either.

cardinality: 16
number of elements:  100   qsort: 19.5 radix: 34.5
number of elements:  200   qsort: 18.7 radix: 22.6
number of elements:  400   qsort: 18.5 radix: 17.2
number of elements:  800   qsort: 25.0 radix: 14.8
number of elements: 1600   qsort: 43.8 radix: 13.8
number of elements: 3200   qsort: 51.2 radix: 13.2
number of elements: 6400   qsort: 59.0 radix: 12.8

This is still low cardinality, but behaves more like the high cardinality case.

I've set the threshold to 400 for now, but I'm not claiming that's the
end story. In addition to the underestimation mentioned above,
unrolling the counting step is a factor. Unrolling makes smaller
inputs worse (which we can reach by recursing from larger inputs), but
unrolling seems important for large inputs with low cardinality (a few
percent, but I haven't shared numbers yet). We've found that a large
input with only 4-5 distinct values just barely wins with radix sort.
I'll be curious to see if unrolling is actually needed to prevent
regressions there.

Other things to consider:

- I don't quite like how the NULL partitioning step looks, and it
could be costly when the distribution of NULL is not predictable, so
I'm thinking of turning part of that into a branch-free cyclic
permutation, similar to
https://www.postgresql.org/message-id/CANWCAZbAmaZ7P%2BARjS97sJLXsBB5CPZyzFgqNDiqe-L%2BBqXzug%40mail.gmail.com

- The quicksort on the NULL partition still compares isnull1 -- the
branches are predictable but perhaps it's worth it to add a
specialization that skips that.

--
John Naylor
Amazon Web Services
From 8d643a61386d10a7996f9cddb0965f18ec0fe832 Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Fri, 17 Oct 2025 09:57:43 +0700
Subject: [PATCH v2] Use radix sort when datum1 is an integer type

XXX regression tests don't pass for underspecified queries; this
is expected
---
 src/backend/utils/misc/guc_parameters.dat |   7 +
 src/backend/utils/sort/tuplesort.c        | 506 +++++++++++++++++++++-
 src/include/utils/guc.h                   |   1 +
 src/include/utils/tuplesort.h             |  12 +-
 4 files changed, 506 insertions(+), 20 deletions(-)

diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index d6fc8333850..f8fc6c88082 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -681,6 +681,13 @@
   boot_val => 'false',
 },
 
+{ name => 'wip_radix_sort', type => 'bool', context => 'PGC_USERSET', group => 'DEVELOPER_OPTIONS',
+  short_desc => 'Test radix sort for debugging.',
+  flags => 'GUC_NOT_IN_SAMPLE',
+  variable => 'wip_radix_sort',
+  boot_val => 'true',
+},
+
 # this is undocumented because not exposed in a standard build
 { name => 'trace_syncscan', type => 'bool', context => 'PGC_USERSET', group => 'DEVELOPER_OPTIONS',
   short_desc => 'Generate debugging output for synchronized scanning.',
diff --git a/src/backend/utils/sort/tuplesort.c b/src/backend/utils/sort/tuplesort.c
index 5d4411dc33f..ac91089ac42 100644
--- a/src/backend/utils/sort/tuplesort.c
+++ b/src/backend/utils/sort/tuplesort.c
@@ -104,6 +104,7 @@
 #include "commands/tablespace.h"
 #include "miscadmin.h"
 #include "pg_trace.h"
+#include "port/pg_bitutils.h"
 #include "storage/shmem.h"
 #include "utils/guc.h"
 #include "utils/memutils.h"
@@ -122,6 +123,7 @@
 
 /* GUC variables */
 bool		trace_sort = false;
+bool		wip_radix_sort = true;
 
 #ifdef DEBUG_BOUNDED_SORT
 bool		optimize_bounded_sort = true;
@@ -490,6 +492,25 @@ static void tuplesort_updatemax(Tuplesortstate *state);
  * abbreviations of text or multi-key sorts.  There could be!  Is it worth it?
  */
 
+/* Used for conditioned datums, so we can ignore NULLs and sort direction. */
+static pg_attribute_always_inline int
+qsort_tuple_conditioned_compare(SortTuple *a, SortTuple *b, Tuplesortstate *state)
+{
+	if (a->cond_datum1 < b->cond_datum1)
+		return -1;
+	if (a->cond_datum1 > b->cond_datum1)
+		return 1;
+
+	/*
+	 * No need to waste effort calling the tiebreak function when there are no
+	 * other keys to sort on.
+	 */
+	if (state->base.onlyKey != NULL)
+		return 0;
+
+	return state->base.comparetup_tiebreak(a, b, state);
+}
+
 /* Used if first key's comparator is ssup_datum_unsigned_cmp */
 static pg_attribute_always_inline int
 qsort_tuple_unsigned_compare(SortTuple *a, SortTuple *b, Tuplesortstate *state)
@@ -567,6 +588,15 @@ qsort_tuple_int32_compare(SortTuple *a, SortTuple *b, Tuplesortstate *state)
  * common comparison functions on pass-by-value leading datums.
  */
 
+#define ST_SORT qsort_tuple_conditioned
+#define ST_ELEMENT_TYPE SortTuple
+#define ST_COMPARE(a, b, state) qsort_tuple_conditioned_compare(a, b, state)
+#define ST_COMPARE_ARG_TYPE Tuplesortstate
+#define ST_CHECK_FOR_INTERRUPTS
+#define ST_SCOPE static
+#define ST_DEFINE
+#include "lib/sort_template.h"
+
 #define ST_SORT qsort_tuple_unsigned
 #define ST_ELEMENT_TYPE SortTuple
 #define ST_COMPARE(a, b, state) qsort_tuple_unsigned_compare(a, b, state)
@@ -615,6 +645,235 @@ qsort_tuple_int32_compare(SortTuple *a, SortTuple *b, Tuplesortstate *state)
 #define ST_DEFINE
 #include "lib/sort_template.h"
 
+
+/*
+ * WIP: For now prefer test coverage of radix sort in Assert builds.
+ */
+#ifdef USE_ASSERT_CHECKING
+#define QSORT_THRESHOLD 0
+#else
+#define QSORT_THRESHOLD 400
+#endif
+
+typedef struct PartitionInfo
+{
+	union
+	{
+		size_t		count;
+		size_t		offset;
+	};
+	size_t		next_offset;
+}			PartitionInfo;
+
+static inline uint8_t
+extract_key(Datum key, int level)
+{
+	return (key >> (((SIZEOF_DATUM - 1) - level) * 8)) & 0xFF;
+}
+
+static inline void
+swap(SortTuple *a, SortTuple *b)
+{
+	SortTuple	tmp = *a;
+
+	*a = *b;
+	*b = tmp;
+}
+
+/*
+ * Condition datum to work with pure unsigned comparison,
+ * taking ASC/DESC into account as well.
+ */
+static inline Datum
+condition_datum(Datum orig, SortSupport ssup)
+{
+	Datum		cond_datum1;
+
+	if (ssup->comparator == ssup_datum_signed_cmp)
+	{
+		/* it was already cast to unsigned when stored */
+		cond_datum1 = orig ^ (UINT64CONST(1) << 63);
+	}
+	else if (ssup->comparator == ssup_datum_int32_cmp)
+	{
+		/*
+		 * First normalize to uint32. Technically, we don't need to do this,
+		 * but it forces the upper bytes to remain the same regardless of
+		 * sign.
+		 */
+		uint32		u32 = DatumGetUInt32(orig) ^ ((uint32) 1 << 31);
+
+		cond_datum1 = UInt32GetDatum(u32);
+	}
+	else
+	{
+		Assert(ssup->comparator == ssup_datum_unsigned_cmp);
+		cond_datum1 = orig;
+	}
+
+	if (ssup->ssup_reverse)
+		cond_datum1 = ~cond_datum1;
+
+	return cond_datum1;
+}
+
+/*
+ * Based on implementation in https://github.com/skarupke/ska_sort (Boost license),
+ * with the following changes:
+ *  - unroll loop in counting step
+ *  - count sorted partitions in every pass, rather than maintaining list of unsorted partitions
+ * TODO: match qsort API with number of elements rather than end pointer
+ */
+static void
+ska_byte_sort(SortTuple *begin,
+			  SortTuple *end, int level, Tuplesortstate *state)
+{
+	/* size_t		counts0[256] = {0}; */
+	size_t		counts1[256] = {0};
+	size_t		counts2[256] = {0};
+	size_t		counts3[256] = {0};
+	PartitionInfo partitions[256] = {0};
+	uint8_t		remaining_partitions[256] = {0};
+	size_t		total = 0;
+	int			num_partitions = 0;
+	int			num_remaining;
+	SortTuple  *ctup;
+
+	/* count key chunks, unrolled for speed */
+
+	for (ctup = begin; ctup + 4 < end; ctup += 4)
+	{
+		uint8		key_chunk0 = extract_key((ctup + 0)->cond_datum1, level);
+		uint8		key_chunk1 = extract_key((ctup + 1)->cond_datum1, level);
+		uint8		key_chunk2 = extract_key((ctup + 2)->cond_datum1, level);
+		uint8		key_chunk3 = extract_key((ctup + 3)->cond_datum1, level);
+
+		partitions[key_chunk0].count++;
+		counts1[key_chunk1]++;
+		counts2[key_chunk2]++;
+		counts3[key_chunk3]++;
+
+	}
+
+	for (size_t i = 0; i < 256; i++)
+		partitions[i].count += counts1[i] + counts2[i] + counts3[i];
+
+	for (; ctup < end; ctup++)
+	{
+		uint8		key_chunk;
+
+		key_chunk = extract_key(ctup->cond_datum1, level);
+		partitions[key_chunk].count++;
+	}
+
+	/* compute partition offsets */
+	for (int i = 0; i < 256; ++i)
+	{
+		size_t		count = partitions[i].count;
+
+		if (count)
+		{
+			partitions[i].offset = total;
+			total += count;
+			remaining_partitions[num_partitions] = i;
+			++num_partitions;
+		}
+		partitions[i].next_offset = total;
+	}
+
+	num_remaining = num_partitions;
+
+	/*
+	 * Permute tuples to correct partition. If we started with one partition,
+	 * there is nothing to do. If a permutation from a previous iteration
+	 * results in a single partition that hasn't been marked as sorted, we
+	 * know it's actually sorted.
+	 */
+	while (num_remaining > 1)
+	{
+		/*
+		 * We can only exit the loop when all partitions are sorted, so must
+		 * reset every iteration
+		 */
+		num_remaining = num_partitions;
+
+		for (int i = 0; i < num_partitions; i++)
+		{
+			uint8		idx = remaining_partitions[i];
+
+			PartitionInfo part = partitions[idx];
+
+			for (SortTuple *st = begin + part.offset;
+				 st < begin + part.next_offset;
+				 st++)
+			{
+				uint8		this_partition = extract_key(st->cond_datum1, level);
+				size_t		offset = partitions[this_partition].offset++;
+
+				Assert(begin + offset < end);
+				swap(st, begin + offset);
+			};
+
+			if (part.offset == part.next_offset)
+			{
+				/* partition is sorted; skip */
+				num_remaining--;
+			}
+		}
+	}
+
+	{
+		size_t		start_offset = 0;
+		SortTuple  *partition_begin = begin;
+
+		for (uint8_t *it = remaining_partitions, *end = remaining_partitions + num_partitions;
+			 it != end;
+			 ++it)
+		{
+			size_t		end_offset = partitions[*it].next_offset;
+			SortTuple  *partition_end = begin + end_offset;
+			ptrdiff_t	num_elements = end_offset - start_offset;
+
+			if (num_elements > 1)
+			{
+				if (level < SIZEOF_DATUM - 1)
+				{
+					if (num_elements < QSORT_THRESHOLD)
+					{
+						qsort_tuple_conditioned(partition_begin,
+												num_elements,
+												state);
+					}
+					else
+					{
+						ska_byte_sort(partition_begin,
+									  partition_end,
+									  level + 1,
+									  state);
+					}
+				}
+				else if (state->base.onlyKey == NULL)
+				{
+					/*
+					 * Finished radix sort on all bytes of cond_datum1
+					 * (possibily abbreviated), now qsort with tiebreak
+					 * comparator.
+					 * XXX comparetup_tiebreak cannot inspect isnull1.
+					 * In core, the only tiebreak that does so is comparetup_datum_tiebreak,
+					 * but we wouldn't have gotten here if that's the case.
+					 */
+					qsort_tuple(partition_begin,
+								num_elements,
+								state->base.comparetup_tiebreak,
+								state);
+				}
+			}
+			start_offset = end_offset;
+			partition_begin = partition_end;
+		}
+	}
+}
+
 /*
  *		tuplesort_begin_xxx
  *
@@ -2663,8 +2922,203 @@ sort_bounded_heap(Tuplesortstate *state)
 	state->boundUsed = true;
 }
 
+/* WIP: allow turning common prefix skipping off for testing */
+#define COMMON_PREFIX
+
+/*
+ * Compute conditioned datums for SortTuples so that a single
+ * unsigned comparison resolves the sort order up to comparetup_*_tiebreak.
+ * Then dispatch to either radix sort or a specialized qsort.
+ */
+static void
+sort_tuple_conditioned(Tuplesortstate *state)
+{
+	SortSupportData ssup = state->base.sortKeys[0];
+
+	bool		nulls_first = ssup.ssup_nulls_first;
+	SortTuple  *first = state->memtuples;
+	SortTuple  *last = state->memtuples + state->memtupcount;
+	SortTuple  *not_null_start;
+	size_t		d1,
+				d2,
+				not_null_count;
+#ifdef COMMON_PREFIX
+	Datum		first_datum = 0;
+	Datum		common_upper_bits = 0;
+#endif
+	int			common_prefix;
+
+	/*
+	 * Partition by isnull1, since we can only radix sort on non-NULL
+	 * elements.
+	 */
+
+	/*
+	 * Find the leftmost NOT NULL tuple if NULLS FIRST, or leftmost NULL
+	 * element if NULLS LAST.
+	 */
+	while (first < last && first->isnull1 == nulls_first)
+		first++;
+
+	/*
+	 * XXX We must start "last" after the final tuple to maintain the
+	 * invariant that it ends up one after the first partition, and the first
+	 * partition may correspond to the entire array. If "first" isn't gotten
+	 * this far, we need to pre-decrement "last" before beginning its loop.
+	 */
+	if (first < last)
+		last--;
+
+	/*
+	 * Find the rightmost NULL tuple if NULLS FIRST, or rightmost NOT NULL
+	 * tuple if NULLS LAST.
+	 */
+	while (first < last && last->isnull1 != nulls_first)
+		last--;
+
+	/* swap pairs of tuples that are in the wrong order */
+	while (first < last)
+	{
+		swap(first, last);
+		while (first < last && first->isnull1 == nulls_first)
+			first++;
+		while (first < last && last->isnull1 != nulls_first)
+			last--;
+	}
+
+	d1 = last - state->memtuples;
+	d2 = state->memtupcount - d1;
+
+	Assert(last == first);
+	Assert(last + d2 == state->memtuples + state->memtupcount);
+	for (SortTuple *pm = state->memtuples;
+		 pm < state->memtuples + d1;
+		 pm++)
+		Assert(pm->isnull1 == nulls_first);
+	for (SortTuple *pm = last;
+		 pm < last + d2;
+		 pm++)
+		Assert(pm->isnull1 != nulls_first);
+
+	/*
+	 * Sort null partition using tiebreak comparator. XXX this will repeat the
+	 * NULL check for abbreviated keys.
+	 */
+	if (nulls_first)
+	{
+		qsort_tuple(state->memtuples,
+					d1,
+					state->base.comparetup_tiebreak,
+					state);
+		not_null_start = last;
+		not_null_count = d2;
+	}
+	else
+	{
+		qsort_tuple(last,
+					d2,
+					state->base.comparetup_tiebreak,
+					state);
+		not_null_start = state->memtuples;
+		not_null_count = d1;
+	}
+
+	/*
+	 * Condition datum so that unsigned comparision is order-preserving, and
+	 * compute the common prefix to skip unproductive recursion steps during
+	 * radix sort.
+	 */
+	for (SortTuple *tup = not_null_start;
+		 tup < not_null_start + not_null_count;
+		 tup++)
+	{
+		Datum		cond_datum1 = condition_datum(tup->datum1, &ssup);
+#ifdef COMMON_PREFIX
+		if (tup == not_null_start)
+		{
+			/* Need to start with some value, may as well be the first one. */
+			first_datum = cond_datum1;
+		}
+		else
+		{
+			Datum		this_common_bits;
+
+			/* The bits in common will be zero */
+			this_common_bits = first_datum ^ cond_datum1;
+
+			/*
+			 * We're really only interested in the case where the rightmost
+			 * one bit is further right, but this branch should be rare enough
+			 * not to waste cycles trying harder.
+			 */
+			if (this_common_bits > common_upper_bits)
+				common_upper_bits = this_common_bits;
+		}
+#endif
+		tup->cond_datum1 = cond_datum1;
+	}
+
+	if (not_null_count < QSORT_THRESHOLD)
+		qsort_tuple_conditioned(not_null_start,
+								not_null_count,
+								state);
+	else
+	{
+
+		/*
+		 * The upper bits are zero where all values are the same, if any. Turn
+		 * the byte position of the rightmost one bit into the byte where
+		 * radix sort should start bucketing. OR-ing in the lowest bit guards
+		 * against undefined behavior without changing the result.
+		 */
+#ifdef COMMON_PREFIX
+		common_prefix = sizeof(Datum) - 1 -
+			(pg_leftmost_one_pos64(common_upper_bits | 1) / BITS_PER_BYTE);
+#else
+		common_prefix = 0;
+#endif
+		/* perform the radix sort on the not-NULL partition */
+		ska_byte_sort(not_null_start,
+					  not_null_start + not_null_count,
+					  common_prefix,
+					  state);
+	}
+
+	/*
+	 * Restore fields that were overwritten with temporary conditioned datum1
+	 */
+	for (SortTuple *tup = not_null_start;
+		 tup < not_null_start + not_null_count;
+		 tup++)
+	{
+		/* need to restore NOT NULL */
+		tup->isnull1 = false;
+		/* be tidy */
+		tup->srctape = 0;
+	}
+}
+
+/* Verify sort using standard comparator. */
+static void
+check_sorted(Tuplesortstate *state)
+{
+#ifdef USE_ASSERT_CHECKING
+	for (SortTuple *pm = state->memtuples + 1;
+		 pm < state->memtuples + state->memtupcount;
+		 pm++)
+	{
+#if 0
+		Assert(COMPARETUP(state, pm - 1, pm) <= 0);
+#else
+		if (COMPARETUP(state, pm - 1, pm) > 0)
+			elog(ERROR, "SORT FAILED");
+#endif
+	}
+#endif
+}
+
 /*
- * Sort all memtuples using specialized qsort() routines.
+ * Sort all memtuples using specialized routines.
  *
  * Quicksort is used for small in-memory sorts, and external sort runs.
  */
@@ -2681,26 +3135,42 @@ tuplesort_sort_memtuples(Tuplesortstate *state)
 		 */
 		if (state->base.haveDatum1 && state->base.sortKeys)
 		{
-			if (state->base.sortKeys[0].comparator == ssup_datum_unsigned_cmp)
-			{
-				qsort_tuple_unsigned(state->memtuples,
-									 state->memtupcount,
-									 state);
-				return;
-			}
-			else if (state->base.sortKeys[0].comparator == ssup_datum_signed_cmp)
+			SortSupportData ssup = state->base.sortKeys[0];
+
+			if (wip_radix_sort)
 			{
-				qsort_tuple_signed(state->memtuples,
-								   state->memtupcount,
-								   state);
-				return;
+				if ((ssup.comparator == ssup_datum_unsigned_cmp ||
+					 ssup.comparator == ssup_datum_signed_cmp ||
+					 ssup.comparator == ssup_datum_int32_cmp))
+				{
+					sort_tuple_conditioned(state);
+					check_sorted(state);
+					return;
+				}
 			}
-			else if (state->base.sortKeys[0].comparator == ssup_datum_int32_cmp)
+			else
 			{
-				qsort_tuple_int32(state->memtuples,
-								  state->memtupcount,
-								  state);
-				return;
+				if (state->base.sortKeys[0].comparator == ssup_datum_unsigned_cmp)
+				{
+					qsort_tuple_unsigned(state->memtuples,
+										 state->memtupcount,
+										 state);
+					return;
+				}
+				else if (state->base.sortKeys[0].comparator == ssup_datum_signed_cmp)
+				{
+					qsort_tuple_signed(state->memtuples,
+									   state->memtupcount,
+									   state);
+					return;
+				}
+				else if (state->base.sortKeys[0].comparator == ssup_datum_int32_cmp)
+				{
+					qsort_tuple_int32(state->memtuples,
+									  state->memtupcount,
+									  state);
+					return;
+				}
 			}
 		}
 
diff --git a/src/include/utils/guc.h b/src/include/utils/guc.h
index f21ec37da89..bc6f7fa60f3 100644
--- a/src/include/utils/guc.h
+++ b/src/include/utils/guc.h
@@ -324,6 +324,7 @@ extern PGDLLIMPORT int tcp_user_timeout;
 extern PGDLLIMPORT char *role_string;
 extern PGDLLIMPORT bool in_hot_standby_guc;
 extern PGDLLIMPORT bool trace_sort;
+extern PGDLLIMPORT bool wip_radix_sort;
 
 #ifdef DEBUG_BOUNDED_SORT
 extern PGDLLIMPORT bool optimize_bounded_sort;
diff --git a/src/include/utils/tuplesort.h b/src/include/utils/tuplesort.h
index ef79f259f93..b2ecbbc9e51 100644
--- a/src/include/utils/tuplesort.h
+++ b/src/include/utils/tuplesort.h
@@ -149,8 +149,16 @@ typedef struct
 {
 	void	   *tuple;			/* the tuple itself */
 	Datum		datum1;			/* value of first key column */
-	bool		isnull1;		/* is first key column NULL? */
-	int			srctape;		/* source tape number */
+
+	union
+	{
+		struct
+		{
+			bool		isnull1;		/* is first key column NULL? */
+			int			srctape;		/* source tape number */
+		};
+		Datum		cond_datum1;		/* sort key for radix sort */
+	};
 } SortTuple;
 
 typedef int (*SortTupleComparator) (const SortTuple *a, const SortTuple *b,
-- 
2.51.0

---

From: John Naylor <[email-masked]>
Date: Tue, 4 Nov 2025 18:27:08 +0700

> I'll be curious to see if unrolling is actually needed to prevent

Looking more closely at my development history, it turns out I added
loop unrolling before adding common prefix detection. Most real-world
non-negative integers have the upper bytes the same, especially since
the datum is 8 bytes regardless of underlying type. For those bytes,
the radix sort finds only one unique byte and continues on to the next
byte. By detecting the common prefix as we condition the datums, it
matters less how fast we can count since we simply skip some useless
work. (This is not as relevant when we have an abbreviated datum)

Repeating part of the microbenchmark from last time with no unrolling,
a threshold of 200 works for all but the lowest cardinality inputs:

cardinality: 256
number of elements:  100   qsort: 34.8 radix: 38.3
number of elements:  200   qsort: 34.9 radix: 29.7
number of elements:  400   qsort: 40.8 radix: 29.2

cardinality: 16
number of elements:  100   qsort: 19.3 radix: 26.2
number of elements:  200   qsort: 18.9 radix: 18.2
number of elements:  400   qsort: 18.5 radix: 14.5

cardinality: 2
number of elements:  100   qsort: 09.3 radix: 21.6
number of elements:  200   qsort: 08.9 radix: 15.4
number of elements:  400   qsort: 10.3 radix: 14.0

To test further, I dug up a test from [1] that stresses low
cardinality on multiple sort keys (attached in a form to allow turing
radix sort on and off via a command line argument), and found no
regression with or without loop unrolling:

V2:
off:
4 ^ 8: latency average = 101.070 ms
5 ^ 8: latency average = 704.862 ms
6 ^ 8: latency average = 3651.015 ms
7 ^ 8: latency average = 15141.412 ms

on:
4 ^ 8: latency average = 99.939 ms
5 ^ 8: latency average = 683.018 ms
6 ^ 8: latency average = 3545.626 ms
7 ^ 8: latency average = 14095.677 ms

V3:
off:
4 ^ 8: latency average = 99.486 ms
5 ^ 8: latency average = 693.434 ms
6 ^ 8: latency average = 3607.940 ms
7 ^ 8: latency average = 14602.325 ms

on:
4 ^ 8: latency average = 97.664 ms
5 ^ 8: latency average = 678.752 ms
6 ^ 8: latency average = 3361.765 ms
7 ^ 8: latency average = 14121.190 ms

So v3 gets rid of loop unrolling, reduces the threshold to 200.

[1] https://www.postgresql.org/message-id/flat/CAApHDvqkHZsT2gaAWFM7D%3D7qyQ%3DeKXQvvn%2BBkwCn4Rvj1w4EKQ%40mail.gmail.com#1c67321e09be15e031d3995d45a1b8fd

TODOs:
- adding a "sorted pre-check" to keep up with our qsort for ascending inputs
- further performance validation
- possibly doing NULL partitioning differently
- possibly specializing qsort on the NULL partition
- code cleanup

--
John Naylor
Amazon Web Services

---

From: John Naylor <[email-masked]>
Date: Wed, 12 Nov 2025 20:57:49 +0700

> something to keep in mind.

I decided I wasn't quite comfortable with the full normalized datum
sharing space in SortTuple with isnull1. There's too much of a
cognitive burden involved in deciding when we do or don't need to
reset isnull1, and there's a non-zero risk of difficult-to-detect
bugs. For v4 I've instead used one byte of padding space in SortTuple
to store only the byte used for the current pass. That means we must
compute the normalized datum on every pass. That's actually better
than it sounds, since that one byte can now be used directly during
the "deal" step, rather than having to extract the byte from the
normalized datum by shifting and masking. That extraction step might
add significant cycles in cases where a pass requires multiple
iterations through the "deal" loop. It doesn't seem to make much
difference in practice, performance-wise, even with the following
pessimization:

I had to scrap the qsort specialization on the normalized datum for
small sorts, since it's no longer stored. It could still be worth it
to compute the "next byte of the normalized datum" and perform a qsort
on that (falling back to the comparator function in the usual way),
but I haven't felt the need to resort to that yet. For v4, I just
divert to qsort_tuple in non-assert builds, with a threshold of 40.

> https://www.postgresql.org/message-id/CANWCAZbAmaZ7P%2BARjS97sJLXsBB5CPZyzFgqNDiqe-L%2BBqXzug%40mail.gmail.com

This is done. Even though the inner loop is mysterious at first
glance, it's really quite elegant.

I made an attempt at clean-up, but it's still under-commented. The
common prefix detection has moved to a separate patch (v4-0004).

I've been forcing all eligible sorts to use radix sort in assert
builds, even when small enough that qsort would be faster. Since both
qsort and in-place radix sort are unstable, it's expected that some
regression tests need adjustment (v4-0002). One thing surprised me,
however: The pg_upgrade TAP test that runs regression tests on the old
cluster showed additional failures that I can't explain. I haven't
seen this before, but it's possible I never ran TAP tests when testing
new sort algorithms previously. This doesn't happen if you change the
current insertion sort threshold, so I haven't been able to reproduce
it aside from this patch. For that reason I can't completely rule out
an actual bug, although I actually have more confidence in the
verification of correct sort order in v4, since isnull1 now never
changes, just as in master. I found that changing some tests to have
additional sort keys seems to fix it (v4-0003). I did this in a rather
quick and haphazard fashion. There's probably a longer conversation to
be had about making test output more deterministic while still
covering the intended executor paths.

Aside from that, this seems like a good place to settle down, so I'm
going to create a CF entry for this. I'll start more rigorous
performance testing in the near future.

--
John Naylor
Amazon Web Services
From d816f92e1c290c771bfd323ffba6f33319a4eb72 Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Wed, 12 Nov 2025 14:31:24 +0700
Subject: [PATCH v4 4/4] Detect common prefix to avoid wasted work during radix
 sort

This is particularly useful for integers, since they commonly
have some zero upper bytes.
---
 src/backend/utils/sort/tuplesort.c | 53 +++++++++++++++++++++++++++++-
 1 file changed, 52 insertions(+), 1 deletion(-)

diff --git a/src/backend/utils/sort/tuplesort.c b/src/backend/utils/sort/tuplesort.c
index 3efc22de24e..68fa9c13ca8 100644
--- a/src/backend/utils/sort/tuplesort.c
+++ b/src/backend/utils/sort/tuplesort.c
@@ -104,6 +104,7 @@
 #include "commands/tablespace.h"
 #include "miscadmin.h"
 #include "pg_trace.h"
+#include "port/pg_bitutils.h"
 #include "storage/shmem.h"
 #include "utils/guc.h"
 #include "utils/memutils.h"
@@ -2980,9 +2981,59 @@ sort_byvalue_datum(Tuplesortstate *state)
 		}
 		else
 		{
+			int			common_prefix;
+			Datum		first_datum = 0;
+			Datum		common_upper_bits = 0;
+
+			/*
+			 * Compute the common prefix to skip unproductive recursion steps
+			 * during radix sort.
+			 */
+			for (SortTuple *tup = not_null_start;
+				 tup < not_null_start + not_null_count;
+				 tup++)
+			{
+				Datum		this_datum = tup->datum1;
+
+				if (tup == not_null_start)
+				{
+					/*
+					 * Need to start with some value, may as well be the first
+					 * one.
+					 */
+					first_datum = this_datum;
+				}
+				else
+				{
+					Datum		this_common_bits;
+
+					/* The bits in common will be zero */
+					this_common_bits = first_datum ^ this_datum;
+
+					/*
+					 * We're really only interested in the case where the
+					 * leftmost one bit is further left, but this branch
+					 * should be predictable enough not to waste cycles trying
+					 * harder.
+					 */
+					if (this_common_bits > common_upper_bits)
+						common_upper_bits = this_common_bits;
+				}
+			}
+
+			/*
+			 * The upper bits of common_upper_bits are zero where all values
+			 * have the same bits. The byte position of the leftmost one bit
+			 * is the byte where radix sort should start bucketing. OR-ing in
+			 * the lowest bit guards against undefined behavior without
+			 * changing the result.
+			 */
+			common_prefix = sizeof(Datum) - 1 -
+				(pg_leftmost_one_pos64(common_upper_bits | 1) / BITS_PER_BYTE);
+
 			radix_sort_tuple(not_null_start,
 							 not_null_count,
-							 0,
+							 common_prefix,
 							 state);
 		}
 	}
-- 
2.51.1

From 12d0603a3aa94b033b4111d1944f2b054f00628d Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Tue, 11 Nov 2025 17:41:26 +0700
Subject: [PATCH v4 2/4] WIP Adjust regression tests

Regression tests don't pass for underspecified queries; this
is expected since both qsort and in-place radix sort are
unstable sorts. For the query

SELECT a, b from mytable ORDER BY a;

...a stable sort would guarantee the relative position of 'b'
for each group of 'a', compared to the input.

This is separated out since the relative order changes with
the qsort threshold, same as it would for qsort and its
insertion sort threshold. Assert builds always do radix
sort regardless of input size, if the data type allows it.
The final commit will have the same threshold for all builds.
---
 contrib/pg_stat_statements/expected/dml.out   |   6 +-
 .../postgres_fdw/expected/postgres_fdw.out    |   8 +-
 src/backend/utils/sort/tuplesort.c            |   1 -
 src/test/regress/expected/groupingsets.out    |   2 +-
 src/test/regress/expected/inet.out            |   4 +-
 src/test/regress/expected/join.out            |  20 +-
 src/test/regress/expected/plancache.out       |   8 +-
 src/test/regress/expected/sqljson.out         |   8 +-
 src/test/regress/expected/tsrf.out            |  38 +-
 src/test/regress/expected/tuplesort.out       |   6 +-
 src/test/regress/expected/window.out          | 500 +++++++++---------
 11 files changed, 300 insertions(+), 301 deletions(-)

diff --git a/contrib/pg_stat_statements/expected/dml.out b/contrib/pg_stat_statements/expected/dml.out
index 347cb8699e4..aa6e91e1c7f 100644
--- a/contrib/pg_stat_statements/expected/dml.out
+++ b/contrib/pg_stat_statements/expected/dml.out
@@ -44,12 +44,12 @@ SELECT *
 SELECT * FROM pgss_dml_tab ORDER BY a;
  a |          b           
 ---+----------------------
- 1 | a                   
  1 | 111                 
- 2 | b                   
+ 1 | a                   
  2 | 222                 
- 3 | c                   
+ 2 | b                   
  3 | 333                 
+ 3 | c                   
  4 | 444                 
  5 | 555                 
  6 | 666                 
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index cd28126049d..95f8fd388e6 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -2626,15 +2626,15 @@ SELECT * FROM ft1, ft4, ft5, local_tbl WHERE ft1.c1 = ft4.c1 AND ft1.c1 = ft5.c1
  12 |  2 | 00012 | Tue Jan 13 00:00:00 1970 PST | Tue Jan 13 00:00:00 1970 | 2  | 2          | foo | 12 | 13 | AAA012 | 12 | 13 | AAA012 |  2 |  2 | 0002
  42 |  2 | 00042 | Thu Feb 12 00:00:00 1970 PST | Thu Feb 12 00:00:00 1970 | 2  | 2          | foo | 42 | 43 | AAA042 | 42 | 43 | AAA042 |  2 |  2 | 0002
  72 |  2 | 00072 | Sat Mar 14 00:00:00 1970 PST | Sat Mar 14 00:00:00 1970 | 2  | 2          | foo | 72 | 73 | AAA072 | 72 | 73 |        |  2 |  2 | 0002
- 24 |  4 | 00024 | Sun Jan 25 00:00:00 1970 PST | Sun Jan 25 00:00:00 1970 | 4  | 4          | foo | 24 | 25 | AAA024 | 24 | 25 | AAA024 |  4 |  4 | 0004
  54 |  4 | 00054 | Tue Feb 24 00:00:00 1970 PST | Tue Feb 24 00:00:00 1970 | 4  | 4          | foo | 54 | 55 | AAA054 | 54 | 55 |        |  4 |  4 | 0004
+ 24 |  4 | 00024 | Sun Jan 25 00:00:00 1970 PST | Sun Jan 25 00:00:00 1970 | 4  | 4          | foo | 24 | 25 | AAA024 | 24 | 25 | AAA024 |  4 |  4 | 0004
  84 |  4 | 00084 | Thu Mar 26 00:00:00 1970 PST | Thu Mar 26 00:00:00 1970 | 4  | 4          | foo | 84 | 85 | AAA084 | 84 | 85 | AAA084 |  4 |  4 | 0004
- 96 |  6 | 00096 | Tue Apr 07 00:00:00 1970 PST | Tue Apr 07 00:00:00 1970 | 6  | 6          | foo | 96 | 97 | AAA096 | 96 | 97 | AAA096 |  6 |  6 | 0006
+  6 |  6 | 00006 | Wed Jan 07 00:00:00 1970 PST | Wed Jan 07 00:00:00 1970 | 6  | 6          | foo |  6 |  7 | AAA006 |  6 |  7 | AAA006 |  6 |  6 | 0006
  36 |  6 | 00036 | Fri Feb 06 00:00:00 1970 PST | Fri Feb 06 00:00:00 1970 | 6  | 6          | foo | 36 | 37 | AAA036 | 36 | 37 |        |  6 |  6 | 0006
  66 |  6 | 00066 | Sun Mar 08 00:00:00 1970 PST | Sun Mar 08 00:00:00 1970 | 6  | 6          | foo | 66 | 67 | AAA066 | 66 | 67 | AAA066 |  6 |  6 | 0006
-  6 |  6 | 00006 | Wed Jan 07 00:00:00 1970 PST | Wed Jan 07 00:00:00 1970 | 6  | 6          | foo |  6 |  7 | AAA006 |  6 |  7 | AAA006 |  6 |  6 | 0006
- 48 |  8 | 00048 | Wed Feb 18 00:00:00 1970 PST | Wed Feb 18 00:00:00 1970 | 8  | 8          | foo | 48 | 49 | AAA048 | 48 | 49 | AAA048 |  8 |  8 | 0008
+ 96 |  6 | 00096 | Tue Apr 07 00:00:00 1970 PST | Tue Apr 07 00:00:00 1970 | 6  | 6          | foo | 96 | 97 | AAA096 | 96 | 97 | AAA096 |  6 |  6 | 0006
  18 |  8 | 00018 | Mon Jan 19 00:00:00 1970 PST | Mon Jan 19 00:00:00 1970 | 8  | 8          | foo | 18 | 19 | AAA018 | 18 | 19 |        |  8 |  8 | 0008
+ 48 |  8 | 00048 | Wed Feb 18 00:00:00 1970 PST | Wed Feb 18 00:00:00 1970 | 8  | 8          | foo | 48 | 49 | AAA048 | 48 | 49 | AAA048 |  8 |  8 | 0008
  78 |  8 | 00078 | Fri Mar 20 00:00:00 1970 PST | Fri Mar 20 00:00:00 1970 | 8  | 8          | foo | 78 | 79 | AAA078 | 78 | 79 | AAA078 |  8 |  8 | 0008
 (13 rows)
 
diff --git a/src/backend/utils/sort/tuplesort.c b/src/backend/utils/sort/tuplesort.c
index c2b9625ae88..3efc22de24e 100644
--- a/src/backend/utils/sort/tuplesort.c
+++ b/src/backend/utils/sort/tuplesort.c
@@ -104,7 +104,6 @@
 #include "commands/tablespace.h"
 #include "miscadmin.h"
 #include "pg_trace.h"
-#include "port/pg_bitutils.h"
 #include "storage/shmem.h"
 #include "utils/guc.h"
 #include "utils/memutils.h"
diff --git a/src/test/regress/expected/groupingsets.out b/src/test/regress/expected/groupingsets.out
index 398cf6965e0..02abebdb0d7 100644
--- a/src/test/regress/expected/groupingsets.out
+++ b/src/test/regress/expected/groupingsets.out
@@ -94,8 +94,8 @@ select a, b, grouping(a,b), sum(v), count(*), max(v)
  1 |   |        1 |  60 |     5 |  14
  1 | 1 |        0 |  21 |     2 |  11
  2 |   |        1 |  15 |     1 |  15
- 3 |   |        1 |  33 |     2 |  17
  1 | 2 |        0 |  25 |     2 |  13
+ 3 |   |        1 |  33 |     2 |  17
  1 | 3 |        0 |  14 |     1 |  14
  4 |   |        1 |  37 |     2 |  19
  4 | 1 |        0 |  37 |     2 |  19
diff --git a/src/test/regress/expected/inet.out b/src/test/regress/expected/inet.out
index 1705bff4dd3..85a3a6a7de5 100644
--- a/src/test/regress/expected/inet.out
+++ b/src/test/regress/expected/inet.out
@@ -465,9 +465,9 @@ SELECT * FROM inet_tbl WHERE i < '192.168.1.0/24'::cidr ORDER BY i;
       c      |      i      
 -------------+-------------
  10.0.0.0/8  | 9.1.2.3/8
- 10.0.0.0/32 | 10.1.2.3/8
  10.0.0.0/8  | 10.1.2.3/8
  10.0.0.0/8  | 10.1.2.3/8
+ 10.0.0.0/32 | 10.1.2.3/8
  10.1.0.0/16 | 10.1.2.3/16
  10.1.2.0/24 | 10.1.2.3/24
  10.1.2.3/32 | 10.1.2.3
@@ -613,9 +613,9 @@ SELECT * FROM inet_tbl WHERE i < '192.168.1.0/24'::cidr ORDER BY i;
       c      |      i      
 -------------+-------------
  10.0.0.0/8  | 9.1.2.3/8
- 10.0.0.0/32 | 10.1.2.3/8
  10.0.0.0/8  | 10.1.2.3/8
  10.0.0.0/8  | 10.1.2.3/8
+ 10.0.0.0/32 | 10.1.2.3/8
  10.1.0.0/16 | 10.1.2.3/16
  10.1.2.0/24 | 10.1.2.3/24
  10.1.2.3/32 | 10.1.2.3
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 0e82ca1867a..9b6a9f536d3 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -220,8 +220,8 @@ SELECT t1.a, t2.e
 ---+----
  0 |   
  1 | -1
- 2 |  2
  2 |  4
+ 2 |  2
  3 | -3
  5 | -5
  5 | -5
@@ -1575,8 +1575,8 @@ SELECT *
 ---+---+-------+----
  0 |   | zero  |   
  1 | 4 | one   | -1
- 2 | 3 | two   |  2
  2 | 3 | two   |  4
+ 2 | 3 | two   |  2
  3 | 2 | three | -3
  5 | 0 | five  | -5
  5 | 0 | five  | -5
@@ -1589,8 +1589,8 @@ SELECT *
 ---+---+-------+----
  0 |   | zero  |   
  1 | 4 | one   | -1
- 2 | 3 | two   |  2
  2 | 3 | two   |  4
+ 2 | 3 | two   |  2
  3 | 2 | three | -3
  5 | 0 | five  | -5
  5 | 0 | five  | -5
@@ -1683,8 +1683,8 @@ SELECT *
 ---+---+-------+----
  0 |   | zero  |   
  1 | 4 | one   | -1
- 2 | 3 | two   |  2
  2 | 3 | two   |  4
+ 2 | 3 | two   |  2
  3 | 2 | three | -3
  5 | 0 | five  | -5
  5 | 0 | five  | -5
@@ -1696,8 +1696,8 @@ SELECT *
 ---+---+-------+----
  0 |   | zero  |   
  1 | 4 | one   | -1
- 2 | 3 | two   |  2
  2 | 3 | two   |  4
+ 2 | 3 | two   |  2
  3 | 2 | three | -3
  5 | 0 | five  | -5
  5 | 0 | five  | -5
@@ -1720,8 +1720,8 @@ SELECT *
 ---+---+-------+----
  0 |   | zero  |   
  1 | 4 | one   | -1
- 2 | 3 | two   |  2
  2 | 3 | two   |  4
+ 2 | 3 | two   |  2
  3 | 2 | three | -3
  5 | 0 | five  | -5
  5 | 0 | five  | -5
@@ -1736,8 +1736,8 @@ SELECT *
 ---+---+-------+---+----
  0 |   | zero  | 0 |   
  1 | 4 | one   | 1 | -1
- 2 | 3 | two   | 2 |  2
  2 | 3 | two   | 2 |  4
+ 2 | 3 | two   | 2 |  2
  3 | 2 | three | 3 | -3
  5 | 0 | five  | 5 | -5
  5 | 0 | five  | 5 | -5
@@ -1820,8 +1820,8 @@ SELECT *
 ---+---+-------+----
  0 |   | zero  |   
  1 | 4 | one   | -1
- 2 | 3 | two   |  2
  2 | 3 | two   |  4
+ 2 | 3 | two   |  2
  3 | 2 | three | -3
  5 | 0 | five  | -5
  5 | 0 | five  | -5
@@ -1835,8 +1835,8 @@ SELECT *
 ---+---+-------+----
  0 |   | zero  |   
  1 | 4 | one   | -1
- 2 | 3 | two   |  2
  2 | 3 | two   |  4
+ 2 | 3 | two   |  2
  3 | 2 | three | -3
  5 | 0 | five  | -5
  5 | 0 | five  | -5
@@ -2776,8 +2776,8 @@ select * from
 ---+---+-------+---+----
    |   |       |   |  0
    |   |       |   |   
-   | 0 | zero  |   |   
    |   | null  |   |   
+   | 0 | zero  |   |   
  8 | 8 | eight |   |   
  7 | 7 | seven |   |   
  6 | 6 | six   |   |   
diff --git a/src/test/regress/expected/plancache.out b/src/test/regress/expected/plancache.out
index 4e59188196c..41440c10cdd 100644
--- a/src/test/regress/expected/plancache.out
+++ b/src/test/regress/expected/plancache.out
@@ -38,8 +38,8 @@ EXECUTE prepstmt;
  4567890123456789 | -4567890123456789
  4567890123456789 |               123
               123 |               456
-              123 |  4567890123456789
  4567890123456789 |  4567890123456789
+              123 |  4567890123456789
 (5 rows)
 
 EXECUTE prepstmt2(123);
@@ -64,8 +64,8 @@ EXECUTE prepstmt;
  4567890123456789 | -4567890123456789
  4567890123456789 |               123
               123 |               456
-              123 |  4567890123456789
  4567890123456789 |  4567890123456789
+              123 |  4567890123456789
 (5 rows)
 
 EXECUTE prepstmt2(123);
@@ -86,8 +86,8 @@ EXECUTE vprep;
  4567890123456789 | -4567890123456789
  4567890123456789 |               123
               123 |               456
-              123 |  4567890123456789
  4567890123456789 |  4567890123456789
+              123 |  4567890123456789
 (5 rows)
 
 CREATE OR REPLACE TEMP VIEW pcacheview AS
@@ -98,8 +98,8 @@ EXECUTE vprep;
  4567890123456789 | -2283945061728394
  4567890123456789 |                61
               123 |               228
-              123 |  2283945061728394
  4567890123456789 |  2283945061728394
+              123 |  2283945061728394
 (5 rows)
 
 -- Check basic SPI plan invalidation
diff --git a/src/test/regress/expected/sqljson.out b/src/test/regress/expected/sqljson.out
index c7b9e575445..13fa4f2262d 100644
--- a/src/test/regress/expected/sqljson.out
+++ b/src/test/regress/expected/sqljson.out
@@ -870,10 +870,10 @@ FROM
    4 | [4, 4]
    4 | [4, 4]
    2 | [4, 4]
-   5 | [5, 3, 5]
-   3 | [5, 3, 5]
-   1 | [5, 3, 5]
-   5 | [5, 3, 5]
+   3 | [3, 5, 5]
+   1 | [3, 5, 5]
+   5 | [3, 5, 5]
+   5 | [3, 5, 5]
      | 
      | 
      | 
diff --git a/src/test/regress/expected/tsrf.out b/src/test/regress/expected/tsrf.out
index c4f7b187f5b..fd3914b0fad 100644
--- a/src/test/regress/expected/tsrf.out
+++ b/src/test/regress/expected/tsrf.out
@@ -354,18 +354,18 @@ SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(d
  a     | foo | 1 |     1
  a     |     | 1 |     2
  b     | bar | 1 |     1
- b     |     | 1 |     1
-       |     | 1 |     3
        | bar | 1 |     2
        | foo | 1 |     1
-       | foo | 2 |     1
+       |     | 1 |     3
+ b     |     | 1 |     1
  a     | bar | 2 |     1
- b     |     | 2 |     1
  a     | foo | 2 |     1
-       | bar | 2 |     2
  a     |     | 2 |     2
-       |     | 2 |     3
  b     | bar | 2 |     1
+       | bar | 2 |     2
+       | foo | 2 |     1
+ b     |     | 2 |     1
+       |     | 2 |     3
 (16 rows)
 
 SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(dataa, datab, g);
@@ -433,26 +433,26 @@ SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(d
  a     | foo | 1 |     1
  b     | bar | 1 |     1
        | bar | 1 |     2
-       | foo | 1 |     1
+       |     | 1 |     3
  a     |     | 1 |     2
  b     |     | 1 |     1
-       |     | 1 |     3
+       | foo | 1 |     1
+ a     | bar | 2 |     1
+ a     | foo | 2 |     1
+ b     | bar | 2 |     1
+       | bar | 2 |     2
  a     |     | 2 |     2
  b     |     | 2 |     1
-       | bar | 2 |     2
        |     | 2 |     3
        | foo | 2 |     1
- a     | bar | 2 |     1
- a     | foo | 2 |     1
- b     | bar | 2 |     1
- a     |     |   |     4
- b     | bar |   |     2
- b     |     |   |     2
+ a     | bar |   |     2
+       | foo |   |     2
        |     |   |     6
  a     | foo |   |     2
- a     | bar |   |     2
+ a     |     |   |     4
        | bar |   |     4
-       | foo |   |     2
+ b     | bar |   |     2
+ b     |     |   |     2
 (24 rows)
 
 reset enable_hashagg;
@@ -600,8 +600,8 @@ FROM (VALUES (3, 2), (3,1), (1,1), (1,4), (5,3), (5,1)) AS t(a, b);
  a | b | g 
 ---+---+---
  3 | 2 | 1
- 5 | 1 | 2
- 3 | 1 | 3
+ 3 | 2 | 2
+ 3 | 2 | 3
 (3 rows)
 
 -- LIMIT / OFFSET is evaluated after SRF evaluation
diff --git a/src/test/regress/expected/tuplesort.out b/src/test/regress/expected/tuplesort.out
index 6dd97e7427a..fc1321bf443 100644
--- a/src/test/regress/expected/tuplesort.out
+++ b/src/test/regress/expected/tuplesort.out
@@ -304,9 +304,9 @@ FROM abbrev_abort_uuids
 ORDER BY ctid DESC LIMIT 5;
   id   |           abort_increasing           |           abort_decreasing           |          noabort_increasing          |          noabort_decreasing          
 -------+--------------------------------------+--------------------------------------+--------------------------------------+--------------------------------------
-     0 |                                      |                                      |                                      | 
  20002 |                                      |                                      |                                      | 
  20003 |                                      |                                      |                                      | 
+     0 |                                      |                                      |                                      | 
  10009 | 00000000-0000-0000-0000-000000010008 | 00000000-0000-0000-0000-000000009992 | 00010008-0000-0000-0000-000000010008 | 00009992-0000-0000-0000-000000009992
  10008 | 00000000-0000-0000-0000-000000010007 | 00000000-0000-0000-0000-000000009993 | 00010007-0000-0000-0000-000000010007 | 00009993-0000-0000-0000-000000009993
 (5 rows)
@@ -335,9 +335,9 @@ FROM abbrev_abort_uuids
 ORDER BY ctid DESC LIMIT 5;
   id   |           abort_increasing           |           abort_decreasing           |          noabort_increasing          |          noabort_decreasing          
 -------+--------------------------------------+--------------------------------------+--------------------------------------+--------------------------------------
-     0 |                                      |                                      |                                      | 
- 20003 |                                      |                                      |                                      | 
  20002 |                                      |                                      |                                      | 
+ 20003 |                                      |                                      |                                      | 
+     0 |                                      |                                      |                                      | 
   9993 | 00000000-0000-0000-0000-000000009992 | 00000000-0000-0000-0000-000000010008 | 00009992-0000-0000-0000-000000009992 | 00010008-0000-0000-0000-000000010008
   9994 | 00000000-0000-0000-0000-000000009993 | 00000000-0000-0000-0000-000000010007 | 00009993-0000-0000-0000-000000009993 | 00010007-0000-0000-0000-000000010007
 (5 rows)
diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out
index 9e2f53726f5..b3cdeaea4b3 100644
--- a/src/test/regress/expected/window.out
+++ b/src/test/regress/expected/window.out
@@ -95,13 +95,13 @@ SELECT depname, empno, salary, rank() OVER w FROM empsalary WINDOW w AS (PARTITI
 -----------+-------+--------+------
  develop   |     7 |   4200 |    1
  personnel |     5 |   3500 |    1
- sales     |     3 |   4800 |    1
  sales     |     4 |   4800 |    1
- personnel |     2 |   3900 |    2
+ sales     |     3 |   4800 |    1
  develop   |     9 |   4500 |    2
- sales     |     1 |   5000 |    3
+ personnel |     2 |   3900 |    2
  develop   |    11 |   5200 |    3
  develop   |    10 |   5200 |    3
+ sales     |     1 |   5000 |    3
  develop   |     8 |   6000 |    5
 (10 rows)
 
@@ -394,12 +394,12 @@ SELECT first_value(ten) OVER (PARTITION BY four ORDER BY ten), ten, four FROM te
 SELECT last_value(four) OVER (ORDER BY ten), ten, four FROM tenk1 WHERE unique2 < 10;
  last_value | ten | four 
 ------------+-----+------
-          0 |   0 |    0
-          0 |   0 |    2
-          0 |   0 |    0
-          1 |   1 |    1
+          2 |   0 |    0
+          2 |   0 |    0
+          2 |   0 |    2
           1 |   1 |    3
           1 |   1 |    1
+          1 |   1 |    1
           3 |   3 |    3
           0 |   4 |    0
           1 |   7 |    1
@@ -821,14 +821,14 @@ SELECT sum(unique1) over (order by four range between current row and unbounded
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  45 |       0 |    0
-  45 |       8 |    0
   45 |       4 |    0
-  33 |       5 |    1
-  33 |       9 |    1
+  45 |       8 |    0
+  45 |       0 |    0
   33 |       1 |    1
-  18 |       6 |    2
+  33 |       9 |    1
+  33 |       5 |    1
   18 |       2 |    2
+  18 |       6 |    2
   10 |       3 |    3
   10 |       7 |    3
 (10 rows)
@@ -940,14 +940,14 @@ SELECT first_value(unique1) over (ORDER BY four rows between current row and 2 f
 FROM tenk1 WHERE unique1 < 10;
  first_value | unique1 | four 
 -------------+---------+------
-           8 |       0 |    0
-           4 |       8 |    0
-           5 |       4 |    0
-           9 |       5 |    1
-           1 |       9 |    1
-           6 |       1 |    1
-           2 |       6 |    2
-           3 |       2 |    2
+           8 |       4 |    0
+           0 |       8 |    0
+           1 |       0 |    0
+           9 |       1 |    1
+           5 |       9 |    1
+           2 |       5 |    1
+           6 |       2 |    2
+           3 |       6 |    2
            7 |       3 |    3
              |       7 |    3
 (10 rows)
@@ -957,14 +957,14 @@ SELECT first_value(unique1) over (ORDER BY four rows between current row and 2 f
 FROM tenk1 WHERE unique1 < 10;
  first_value | unique1 | four 
 -------------+---------+------
-             |       0 |    0
-           5 |       8 |    0
-           5 |       4 |    0
-             |       5 |    1
-           6 |       9 |    1
-           6 |       1 |    1
-           3 |       6 |    2
+             |       4 |    0
+           1 |       8 |    0
+           1 |       0 |    0
+             |       1 |    1
+           2 |       9 |    1
+           2 |       5 |    1
            3 |       2 |    2
+           3 |       6 |    2
              |       3 |    3
              |       7 |    3
 (10 rows)
@@ -974,14 +974,14 @@ SELECT first_value(unique1) over (ORDER BY four rows between current row and 2 f
 FROM tenk1 WHERE unique1 < 10;
  first_value | unique1 | four 
 -------------+---------+------
-           0 |       0 |    0
-           8 |       8 |    0
            4 |       4 |    0
-           5 |       5 |    1
-           9 |       9 |    1
+           8 |       8 |    0
+           0 |       0 |    0
            1 |       1 |    1
-           6 |       6 |    2
+           9 |       9 |    1
+           5 |       5 |    1
            2 |       2 |    2
+           6 |       6 |    2
            3 |       3 |    3
            7 |       7 |    3
 (10 rows)
@@ -991,14 +991,14 @@ SELECT last_value(unique1) over (ORDER BY four rows between current row and 2 fo
 FROM tenk1 WHERE unique1 < 10;
  last_value | unique1 | four 
 ------------+---------+------
-          4 |       0 |    0
-          5 |       8 |    0
-          9 |       4 |    0
-          1 |       5 |    1
-          6 |       9 |    1
-          2 |       1 |    1
-          3 |       6 |    2
-          7 |       2 |    2
+          0 |       4 |    0
+          1 |       8 |    0
+          9 |       0 |    0
+          5 |       1 |    1
+          2 |       9 |    1
+          6 |       5 |    1
+          3 |       2 |    2
+          7 |       6 |    2
           7 |       3 |    3
             |       7 |    3
 (10 rows)
@@ -1008,14 +1008,14 @@ SELECT last_value(unique1) over (ORDER BY four rows between current row and 2 fo
 FROM tenk1 WHERE unique1 < 10;
  last_value | unique1 | four 
 ------------+---------+------
-            |       0 |    0
-          5 |       8 |    0
-          9 |       4 |    0
-            |       5 |    1
-          6 |       9 |    1
-          2 |       1 |    1
-          3 |       6 |    2
-          7 |       2 |    2
+            |       4 |    0
+          1 |       8 |    0
+          9 |       0 |    0
+            |       1 |    1
+          2 |       9 |    1
+          6 |       5 |    1
+          3 |       2 |    2
+          7 |       6 |    2
             |       3 |    3
             |       7 |    3
 (10 rows)
@@ -1025,14 +1025,14 @@ SELECT last_value(unique1) over (ORDER BY four rows between current row and 2 fo
 FROM tenk1 WHERE unique1 < 10;
  last_value | unique1 | four 
 ------------+---------+------
-          0 |       0 |    0
-          5 |       8 |    0
-          9 |       4 |    0
-          5 |       5 |    1
-          6 |       9 |    1
-          2 |       1 |    1
-          3 |       6 |    2
-          7 |       2 |    2
+          4 |       4 |    0
+          1 |       8 |    0
+          9 |       0 |    0
+          1 |       1 |    1
+          2 |       9 |    1
+          6 |       5 |    1
+          3 |       2 |    2
+          7 |       6 |    2
           3 |       3 |    3
           7 |       7 |    3
 (10 rows)
@@ -1093,14 +1093,14 @@ SELECT sum(unique1) over (w range between current row and unbounded following),
 FROM tenk1 WHERE unique1 < 10 WINDOW w AS (order by four);
  sum | unique1 | four 
 -----+---------+------
-  45 |       0 |    0
-  45 |       8 |    0
   45 |       4 |    0
-  33 |       5 |    1
-  33 |       9 |    1
+  45 |       8 |    0
+  45 |       0 |    0
   33 |       1 |    1
-  18 |       6 |    2
+  33 |       9 |    1
+  33 |       5 |    1
   18 |       2 |    2
+  18 |       6 |    2
   10 |       3 |    3
   10 |       7 |    3
 (10 rows)
@@ -1110,14 +1110,14 @@ SELECT sum(unique1) over (w range between unbounded preceding and current row ex
 FROM tenk1 WHERE unique1 < 10 WINDOW w AS (order by four);
  sum | unique1 | four 
 -----+---------+------
-  12 |       0 |    0
-   4 |       8 |    0
    8 |       4 |    0
-  22 |       5 |    1
-  18 |       9 |    1
+   4 |       8 |    0
+  12 |       0 |    0
   26 |       1 |    1
-  29 |       6 |    2
+  18 |       9 |    1
+  22 |       5 |    1
   33 |       2 |    2
+  29 |       6 |    2
   42 |       3 |    3
   38 |       7 |    3
 (10 rows)
@@ -1127,14 +1127,14 @@ SELECT sum(unique1) over (w range between unbounded preceding and current row ex
 FROM tenk1 WHERE unique1 < 10 WINDOW w AS (order by four);
  sum | unique1 | four 
 -----+---------+------
-     |       0 |    0
-     |       8 |    0
      |       4 |    0
-  12 |       5 |    1
-  12 |       9 |    1
+     |       8 |    0
+     |       0 |    0
   12 |       1 |    1
-  27 |       6 |    2
+  12 |       9 |    1
+  12 |       5 |    1
   27 |       2 |    2
+  27 |       6 |    2
   35 |       3 |    3
   35 |       7 |    3
 (10 rows)
@@ -1144,14 +1144,14 @@ SELECT sum(unique1) over (w range between unbounded preceding and current row ex
 FROM tenk1 WHERE unique1 < 10 WINDOW w AS (order by four);
  sum | unique1 | four 
 -----+---------+------
-   0 |       0 |    0
-   8 |       8 |    0
    4 |       4 |    0
-  17 |       5 |    1
-  21 |       9 |    1
+   8 |       8 |    0
+   0 |       0 |    0
   13 |       1 |    1
-  33 |       6 |    2
+  21 |       9 |    1
+  17 |       5 |    1
   29 |       2 |    2
+  33 |       6 |    2
   38 |       3 |    3
   42 |       7 |    3
 (10 rows)
@@ -1163,14 +1163,14 @@ FROM tenk1 WHERE unique1 < 10
 WINDOW w AS (order by four range between current row and unbounded following);
  first_value | nth_2 | last_value | unique1 | four 
 -------------+-------+------------+---------+------
-           0 |     8 |          7 |       0 |    0
-           0 |     8 |          7 |       8 |    0
-           0 |     8 |          7 |       4 |    0
-           5 |     9 |          7 |       5 |    1
-           5 |     9 |          7 |       9 |    1
-           5 |     9 |          7 |       1 |    1
-           6 |     2 |          7 |       6 |    2
-           6 |     2 |          7 |       2 |    2
+           4 |     8 |          7 |       4 |    0
+           4 |     8 |          7 |       8 |    0
+           4 |     8 |          7 |       0 |    0
+           1 |     9 |          7 |       1 |    1
+           1 |     9 |          7 |       9 |    1
+           1 |     9 |          7 |       5 |    1
+           2 |     6 |          7 |       2 |    2
+           2 |     6 |          7 |       6 |    2
            3 |     7 |          7 |       3 |    3
            3 |     7 |          7 |       7 |    3
 (10 rows)
@@ -1367,14 +1367,14 @@ SELECT sum(unique1) over (order by four range between 2::int8 preceding and 1::i
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-     |       0 |    0
-     |       8 |    0
      |       4 |    0
-  12 |       5 |    1
-  12 |       9 |    1
+     |       8 |    0
+     |       0 |    0
   12 |       1 |    1
-  27 |       6 |    2
+  12 |       9 |    1
+  12 |       5 |    1
   27 |       2 |    2
+  27 |       6 |    2
   23 |       3 |    3
   23 |       7 |    3
 (10 rows)
@@ -1386,14 +1386,14 @@ FROM tenk1 WHERE unique1 < 10;
 -----+---------+------
      |       3 |    3
      |       7 |    3
-  10 |       6 |    2
   10 |       2 |    2
+  10 |       6 |    2
   18 |       9 |    1
   18 |       5 |    1
   18 |       1 |    1
-  23 |       0 |    0
-  23 |       8 |    0
   23 |       4 |    0
+  23 |       8 |    0
+  23 |       0 |    0
 (10 rows)
 
 SELECT sum(unique1) over (order by four range between 2::int8 preceding and 1::int2 preceding exclude no others),
@@ -1401,14 +1401,14 @@ SELECT sum(unique1) over (order by four range between 2::int8 preceding and 1::i
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-     |       0 |    0
-     |       8 |    0
      |       4 |    0
-  12 |       5 |    1
-  12 |       9 |    1
+     |       8 |    0
+     |       0 |    0
   12 |       1 |    1
-  27 |       6 |    2
+  12 |       9 |    1
+  12 |       5 |    1
   27 |       2 |    2
+  27 |       6 |    2
   23 |       3 |    3
   23 |       7 |    3
 (10 rows)
@@ -1418,14 +1418,14 @@ SELECT sum(unique1) over (order by four range between 2::int8 preceding and 1::i
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-     |       0 |    0
-     |       8 |    0
      |       4 |    0
-  12 |       5 |    1
-  12 |       9 |    1
+     |       8 |    0
+     |       0 |    0
   12 |       1 |    1
-  27 |       6 |    2
+  12 |       9 |    1
+  12 |       5 |    1
   27 |       2 |    2
+  27 |       6 |    2
   23 |       3 |    3
   23 |       7 |    3
 (10 rows)
@@ -1435,14 +1435,14 @@ SELECT sum(unique1) over (order by four range between 2::int8 preceding and 1::i
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-     |       0 |    0
-     |       8 |    0
      |       4 |    0
-  12 |       5 |    1
-  12 |       9 |    1
+     |       8 |    0
+     |       0 |    0
   12 |       1 |    1
-  27 |       6 |    2
+  12 |       9 |    1
+  12 |       5 |    1
   27 |       2 |    2
+  27 |       6 |    2
   23 |       3 |    3
   23 |       7 |    3
 (10 rows)
@@ -1452,14 +1452,14 @@ SELECT sum(unique1) over (order by four range between 2::int8 preceding and 1::i
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-     |       0 |    0
-     |       8 |    0
      |       4 |    0
-  12 |       5 |    1
-  12 |       9 |    1
+     |       8 |    0
+     |       0 |    0
   12 |       1 |    1
-  27 |       6 |    2
+  12 |       9 |    1
+  12 |       5 |    1
   27 |       2 |    2
+  27 |       6 |    2
   23 |       3 |    3
   23 |       7 |    3
 (10 rows)
@@ -1469,14 +1469,14 @@ SELECT sum(unique1) over (order by four range between 2::int8 preceding and 6::i
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  33 |       0 |    0
-  41 |       8 |    0
   37 |       4 |    0
-  35 |       5 |    1
-  39 |       9 |    1
+  41 |       8 |    0
+  33 |       0 |    0
   31 |       1 |    1
-  43 |       6 |    2
+  39 |       9 |    1
+  35 |       5 |    1
   39 |       2 |    2
+  43 |       6 |    2
   26 |       3 |    3
   30 |       7 |    3
 (10 rows)
@@ -1486,14 +1486,14 @@ SELECT sum(unique1) over (order by four range between 2::int8 preceding and 6::i
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  33 |       0 |    0
-  33 |       8 |    0
   33 |       4 |    0
-  30 |       5 |    1
-  30 |       9 |    1
+  33 |       8 |    0
+  33 |       0 |    0
   30 |       1 |    1
-  37 |       6 |    2
+  30 |       9 |    1
+  30 |       5 |    1
   37 |       2 |    2
+  37 |       6 |    2
   23 |       3 |    3
   23 |       7 |    3
 (10 rows)
@@ -1539,13 +1539,13 @@ select sum(salary) over (order by enroll_date range between '1 year'::interval p
  34900 |   5000 | 10-01-2006
  34900 |   6000 | 10-01-2006
  38400 |   3900 | 12-23-2006
- 47100 |   4800 | 08-01-2007
  47100 |   5200 | 08-01-2007
+ 47100 |   4800 | 08-01-2007
  47100 |   4800 | 08-08-2007
  47100 |   5200 | 08-15-2007
  36100 |   3500 | 12-10-2007
- 32200 |   4500 | 01-01-2008
  32200 |   4200 | 01-01-2008
+ 32200 |   4500 | 01-01-2008
 (10 rows)
 
 select sum(salary) over (order by enroll_date desc range between '1 year'::interval preceding and '1 year'::interval following),
@@ -1557,8 +1557,8 @@ select sum(salary) over (order by enroll_date desc range between '1 year'::inter
  36100 |   3500 | 12-10-2007
  47100 |   5200 | 08-15-2007
  47100 |   4800 | 08-08-2007
- 47100 |   4800 | 08-01-2007
  47100 |   5200 | 08-01-2007
+ 47100 |   4800 | 08-01-2007
  38400 |   3900 | 12-23-2006
  34900 |   5000 | 10-01-2006
  34900 |   6000 | 10-01-2006
@@ -1573,8 +1573,8 @@ select sum(salary) over (order by enroll_date desc range between '1 year'::inter
      |   3500 | 12-10-2007
      |   5200 | 08-15-2007
      |   4800 | 08-08-2007
-     |   4800 | 08-01-2007
      |   5200 | 08-01-2007
+     |   4800 | 08-01-2007
      |   3900 | 12-23-2006
      |   5000 | 10-01-2006
      |   6000 | 10-01-2006
@@ -1587,13 +1587,13 @@ select sum(salary) over (order by enroll_date range between '1 year'::interval p
  29900 |   5000 | 10-01-2006
  28900 |   6000 | 10-01-2006
  34500 |   3900 | 12-23-2006
- 42300 |   4800 | 08-01-2007
  41900 |   5200 | 08-01-2007
+ 42300 |   4800 | 08-01-2007
  42300 |   4800 | 08-08-2007
  41900 |   5200 | 08-15-2007
  32600 |   3500 | 12-10-2007
- 27700 |   4500 | 01-01-2008
  28000 |   4200 | 01-01-2008
+ 27700 |   4500 | 01-01-2008
 (10 rows)
 
 select sum(salary) over (order by enroll_date range between '1 year'::interval preceding and '1 year'::interval following
@@ -1603,13 +1603,13 @@ select sum(salary) over (order by enroll_date range between '1 year'::interval p
  23900 |   5000 | 10-01-2006
  23900 |   6000 | 10-01-2006
  34500 |   3900 | 12-23-2006
- 37100 |   4800 | 08-01-2007
  37100 |   5200 | 08-01-2007
+ 37100 |   4800 | 08-01-2007
  42300 |   4800 | 08-08-2007
  41900 |   5200 | 08-15-2007
  32600 |   3500 | 12-10-2007
- 23500 |   4500 | 01-01-2008
  23500 |   4200 | 01-01-2008
+ 23500 |   4500 | 01-01-2008
 (10 rows)
 
 select sum(salary) over (order by enroll_date range between '1 year'::interval preceding and '1 year'::interval following
@@ -1619,13 +1619,13 @@ select sum(salary) over (order by enroll_date range between '1 year'::interval p
  28900 |   5000 | 10-01-2006
  29900 |   6000 | 10-01-2006
  38400 |   3900 | 12-23-2006
- 41900 |   4800 | 08-01-2007
  42300 |   5200 | 08-01-2007
+ 41900 |   4800 | 08-01-2007
  47100 |   4800 | 08-08-2007
  47100 |   5200 | 08-15-2007
  36100 |   3500 | 12-10-2007
- 28000 |   4500 | 01-01-2008
  27700 |   4200 | 01-01-2008
+ 28000 |   4500 | 01-01-2008
 (10 rows)
 
 select first_value(salary) over(order by salary range between 1000 preceding and 1000 following),
@@ -1710,13 +1710,13 @@ select first_value(salary) over(order by enroll_date range between unbounded pre
         5000 |       5200 |   5000 | 10-01-2006
         6000 |       5200 |   6000 | 10-01-2006
         5000 |       3500 |   3900 | 12-23-2006
-        5000 |       4200 |   4800 | 08-01-2007
-        5000 |       4200 |   5200 | 08-01-2007
-        5000 |       4200 |   4800 | 08-08-2007
-        5000 |       4200 |   5200 | 08-15-2007
-        5000 |       4200 |   3500 | 12-10-2007
-        5000 |       4200 |   4500 | 01-01-2008
-        5000 |       4200 |   4200 | 01-01-2008
+        5000 |       4500 |   5200 | 08-01-2007
+        5000 |       4500 |   4800 | 08-01-2007
+        5000 |       4500 |   4800 | 08-08-2007
+        5000 |       4500 |   5200 | 08-15-2007
+        5000 |       4500 |   3500 | 12-10-2007
+        5000 |       4500 |   4200 | 01-01-2008
+        5000 |       4500 |   4500 | 01-01-2008
 (10 rows)
 
 select first_value(salary) over(order by enroll_date range between unbounded preceding and '1 year'::interval following
@@ -1729,13 +1729,13 @@ select first_value(salary) over(order by enroll_date range between unbounded pre
         5000 |       5200 |   5000 | 10-01-2006
         6000 |       5200 |   6000 | 10-01-2006
         5000 |       3500 |   3900 | 12-23-2006
-        5000 |       4200 |   4800 | 08-01-2007
-        5000 |       4200 |   5200 | 08-01-2007
-        5000 |       4200 |   4800 | 08-08-2007
-        5000 |       4200 |   5200 | 08-15-2007
-        5000 |       4200 |   3500 | 12-10-2007
-        5000 |       4500 |   4500 | 01-01-2008
+        5000 |       4500 |   5200 | 08-01-2007
+        5000 |       4500 |   4800 | 08-01-2007
+        5000 |       4500 |   4800 | 08-08-2007
+        5000 |       4500 |   5200 | 08-15-2007
+        5000 |       4500 |   3500 | 12-10-2007
         5000 |       4200 |   4200 | 01-01-2008
+        5000 |       4500 |   4500 | 01-01-2008
 (10 rows)
 
 select first_value(salary) over(order by enroll_date range between unbounded preceding and '1 year'::interval following
@@ -1748,13 +1748,13 @@ select first_value(salary) over(order by enroll_date range between unbounded pre
         3900 |       5200 |   5000 | 10-01-2006
         3900 |       5200 |   6000 | 10-01-2006
         5000 |       3500 |   3900 | 12-23-2006
-        5000 |       4200 |   4800 | 08-01-2007
-        5000 |       4200 |   5200 | 08-01-2007
-        5000 |       4200 |   4800 | 08-08-2007
-        5000 |       4200 |   5200 | 08-15-2007
-        5000 |       4200 |   3500 | 12-10-2007
-        5000 |       3500 |   4500 | 01-01-2008
+        5000 |       4500 |   5200 | 08-01-2007
+        5000 |       4500 |   4800 | 08-01-2007
+        5000 |       4500 |   4800 | 08-08-2007
+        5000 |       4500 |   5200 | 08-15-2007
+        5000 |       4500 |   3500 | 12-10-2007
         5000 |       3500 |   4200 | 01-01-2008
+        5000 |       3500 |   4500 | 01-01-2008
 (10 rows)
 
 select first_value(salary) over(order by enroll_date range between unbounded preceding and '1 year'::interval following
@@ -1767,13 +1767,13 @@ select first_value(salary) over(order by enroll_date range between unbounded pre
         6000 |       5200 |   5000 | 10-01-2006
         5000 |       5200 |   6000 | 10-01-2006
         5000 |       3500 |   3900 | 12-23-2006
-        5000 |       4200 |   4800 | 08-01-2007
-        5000 |       4200 |   5200 | 08-01-2007
-        5000 |       4200 |   4800 | 08-08-2007
-        5000 |       4200 |   5200 | 08-15-2007
-        5000 |       4200 |   3500 | 12-10-2007
-        5000 |       4200 |   4500 | 01-01-2008
+        5000 |       4500 |   5200 | 08-01-2007
+        5000 |       4500 |   4800 | 08-01-2007
+        5000 |       4500 |   4800 | 08-08-2007
+        5000 |       4500 |   5200 | 08-15-2007
+        5000 |       4500 |   3500 | 12-10-2007
         5000 |       4500 |   4200 | 01-01-2008
+        5000 |       4200 |   4500 | 01-01-2008
 (10 rows)
 
 -- RANGE offset PRECEDING/FOLLOWING with null values
@@ -1828,8 +1828,8 @@ window w as
   (order by x desc nulls first range between 2 preceding and 2 following);
  x | y  | first_value | last_value 
 ---+----+-------------+------------
-   | 43 |          43 |         42
-   | 42 |          43 |         42
+   | 42 |          42 |         43
+   | 43 |          42 |         43
  5 |  5 |           5 |          3
  4 |  4 |           5 |          2
  3 |  3 |           5 |          1
@@ -2751,10 +2751,10 @@ window w as (order by f_timestamptz desc range between
   7 | Wed Oct 19 02:23:54 2005 PDT |           8 |          6
   6 | Tue Oct 19 02:23:54 2004 PDT |           7 |          5
   5 | Sun Oct 19 02:23:54 2003 PDT |           6 |          4
-  4 | Sat Oct 19 02:23:54 2002 PDT |           5 |          2
-  3 | Fri Oct 19 02:23:54 2001 PDT |           4 |          1
+  4 | Sat Oct 19 02:23:54 2002 PDT |           5 |          3
   2 | Fri Oct 19 02:23:54 2001 PDT |           4 |          1
-  1 | Thu Oct 19 02:23:54 2000 PDT |           3 |          1
+  3 | Fri Oct 19 02:23:54 2001 PDT |           4 |          1
+  1 | Thu Oct 19 02:23:54 2000 PDT |           2 |          1
   0 | -infinity                    |           0 |          0
 (12 rows)
 
@@ -2862,10 +2862,10 @@ window w as (order by f_timestamp desc range between
   7 | Wed Oct 19 10:23:54 2005 |           8 |          6
   6 | Tue Oct 19 10:23:54 2004 |           7 |          5
   5 | Sun Oct 19 10:23:54 2003 |           6 |          4
-  4 | Sat Oct 19 10:23:54 2002 |           5 |          2
-  3 | Fri Oct 19 10:23:54 2001 |           4 |          1
+  4 | Sat Oct 19 10:23:54 2002 |           5 |          3
   2 | Fri Oct 19 10:23:54 2001 |           4 |          1
-  1 | Thu Oct 19 10:23:54 2000 |           3 |          1
+  3 | Fri Oct 19 10:23:54 2001 |           4 |          1
+  1 | Thu Oct 19 10:23:54 2000 |           2 |          1
   0 | -infinity                |           0 |          0
 (12 rows)
 
@@ -2983,14 +2983,14 @@ SELECT sum(unique1) over (order by four groups between unbounded preceding and c
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  12 |       0 |    0
-  12 |       8 |    0
   12 |       4 |    0
-  27 |       5 |    1
-  27 |       9 |    1
+  12 |       8 |    0
+  12 |       0 |    0
   27 |       1 |    1
-  35 |       6 |    2
+  27 |       9 |    1
+  27 |       5 |    1
   35 |       2 |    2
+  35 |       6 |    2
   45 |       3 |    3
   45 |       7 |    3
 (10 rows)
@@ -3000,14 +3000,14 @@ SELECT sum(unique1) over (order by four groups between unbounded preceding and u
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  45 |       0 |    0
-  45 |       8 |    0
   45 |       4 |    0
-  45 |       5 |    1
-  45 |       9 |    1
+  45 |       8 |    0
+  45 |       0 |    0
   45 |       1 |    1
-  45 |       6 |    2
+  45 |       9 |    1
+  45 |       5 |    1
   45 |       2 |    2
+  45 |       6 |    2
   45 |       3 |    3
   45 |       7 |    3
 (10 rows)
@@ -3017,14 +3017,14 @@ SELECT sum(unique1) over (order by four groups between current row and unbounded
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  45 |       0 |    0
-  45 |       8 |    0
   45 |       4 |    0
-  33 |       5 |    1
-  33 |       9 |    1
+  45 |       8 |    0
+  45 |       0 |    0
   33 |       1 |    1
-  18 |       6 |    2
+  33 |       9 |    1
+  33 |       5 |    1
   18 |       2 |    2
+  18 |       6 |    2
   10 |       3 |    3
   10 |       7 |    3
 (10 rows)
@@ -3034,14 +3034,14 @@ SELECT sum(unique1) over (order by four groups between 1 preceding and unbounded
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  45 |       0 |    0
-  45 |       8 |    0
   45 |       4 |    0
-  45 |       5 |    1
-  45 |       9 |    1
+  45 |       8 |    0
+  45 |       0 |    0
   45 |       1 |    1
-  33 |       6 |    2
+  45 |       9 |    1
+  45 |       5 |    1
   33 |       2 |    2
+  33 |       6 |    2
   18 |       3 |    3
   18 |       7 |    3
 (10 rows)
@@ -3051,14 +3051,14 @@ SELECT sum(unique1) over (order by four groups between 1 following and unbounded
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  33 |       0 |    0
-  33 |       8 |    0
   33 |       4 |    0
-  18 |       5 |    1
-  18 |       9 |    1
+  33 |       8 |    0
+  33 |       0 |    0
   18 |       1 |    1
-  10 |       6 |    2
+  18 |       9 |    1
+  18 |       5 |    1
   10 |       2 |    2
+  10 |       6 |    2
      |       3 |    3
      |       7 |    3
 (10 rows)
@@ -3068,14 +3068,14 @@ SELECT sum(unique1) over (order by four groups between unbounded preceding and 2
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  35 |       0 |    0
-  35 |       8 |    0
   35 |       4 |    0
-  45 |       5 |    1
-  45 |       9 |    1
+  35 |       8 |    0
+  35 |       0 |    0
   45 |       1 |    1
-  45 |       6 |    2
+  45 |       9 |    1
+  45 |       5 |    1
   45 |       2 |    2
+  45 |       6 |    2
   45 |       3 |    3
   45 |       7 |    3
 (10 rows)
@@ -3085,14 +3085,14 @@ SELECT sum(unique1) over (order by four groups between 2 preceding and 1 precedi
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-     |       0 |    0
-     |       8 |    0
      |       4 |    0
-  12 |       5 |    1
-  12 |       9 |    1
+     |       8 |    0
+     |       0 |    0
   12 |       1 |    1
-  27 |       6 |    2
+  12 |       9 |    1
+  12 |       5 |    1
   27 |       2 |    2
+  27 |       6 |    2
   23 |       3 |    3
   23 |       7 |    3
 (10 rows)
@@ -3102,14 +3102,14 @@ SELECT sum(unique1) over (order by four groups between 2 preceding and 1 followi
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  27 |       0 |    0
-  27 |       8 |    0
   27 |       4 |    0
-  35 |       5 |    1
-  35 |       9 |    1
+  27 |       8 |    0
+  27 |       0 |    0
   35 |       1 |    1
-  45 |       6 |    2
+  35 |       9 |    1
+  35 |       5 |    1
   45 |       2 |    2
+  45 |       6 |    2
   33 |       3 |    3
   33 |       7 |    3
 (10 rows)
@@ -3119,14 +3119,14 @@ SELECT sum(unique1) over (order by four groups between 0 preceding and 0 followi
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  12 |       0 |    0
-  12 |       8 |    0
   12 |       4 |    0
-  15 |       5 |    1
-  15 |       9 |    1
+  12 |       8 |    0
+  12 |       0 |    0
   15 |       1 |    1
-   8 |       6 |    2
+  15 |       9 |    1
+  15 |       5 |    1
    8 |       2 |    2
+   8 |       6 |    2
   10 |       3 |    3
   10 |       7 |    3
 (10 rows)
@@ -3136,14 +3136,14 @@ SELECT sum(unique1) over (order by four groups between 2 preceding and 1 followi
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  27 |       0 |    0
-  19 |       8 |    0
   23 |       4 |    0
-  30 |       5 |    1
-  26 |       9 |    1
+  19 |       8 |    0
+  27 |       0 |    0
   34 |       1 |    1
-  39 |       6 |    2
+  26 |       9 |    1
+  30 |       5 |    1
   43 |       2 |    2
+  39 |       6 |    2
   30 |       3 |    3
   26 |       7 |    3
 (10 rows)
@@ -3153,14 +3153,14 @@ SELECT sum(unique1) over (order by four groups between 2 preceding and 1 followi
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  15 |       0 |    0
-  15 |       8 |    0
   15 |       4 |    0
-  20 |       5 |    1
-  20 |       9 |    1
+  15 |       8 |    0
+  15 |       0 |    0
   20 |       1 |    1
-  37 |       6 |    2
+  20 |       9 |    1
+  20 |       5 |    1
   37 |       2 |    2
+  37 |       6 |    2
   23 |       3 |    3
   23 |       7 |    3
 (10 rows)
@@ -3170,14 +3170,14 @@ SELECT sum(unique1) over (order by four groups between 2 preceding and 1 followi
 FROM tenk1 WHERE unique1 < 10;
  sum | unique1 | four 
 -----+---------+------
-  15 |       0 |    0
-  23 |       8 |    0
   19 |       4 |    0
-  25 |       5 |    1
-  29 |       9 |    1
+  23 |       8 |    0
+  15 |       0 |    0
   21 |       1 |    1
-  43 |       6 |    2
+  29 |       9 |    1
+  25 |       5 |    1
   39 |       2 |    2
+  43 |       6 |    2
   26 |       3 |    3
   30 |       7 |    3
 (10 rows)
@@ -3258,14 +3258,14 @@ select first_value(salary) over(order by enroll_date groups between 1 preceding
 -------------+------+-----------+--------+-------------
         5000 | 6000 |      5000 |   5000 | 10-01-2006
         5000 | 3900 |      5000 |   6000 | 10-01-2006
-        5000 | 4800 |      5000 |   3900 | 12-23-2006
-        3900 | 5200 |      3900 |   4800 | 08-01-2007
+        5000 | 5200 |      5000 |   3900 | 12-23-2006
         3900 | 4800 |      3900 |   5200 | 08-01-2007
-        4800 | 5200 |      4800 |   4800 | 08-08-2007
+        3900 | 4800 |      3900 |   4800 | 08-01-2007
+        5200 | 5200 |      5200 |   4800 | 08-08-2007
         4800 | 3500 |      4800 |   5200 | 08-15-2007
-        5200 | 4500 |      5200 |   3500 | 12-10-2007
-        3500 | 4200 |      3500 |   4500 | 01-01-2008
-        3500 |      |      3500 |   4200 | 01-01-2008
+        5200 | 4200 |      5200 |   3500 | 12-10-2007
+        3500 | 4500 |      3500 |   4200 | 01-01-2008
+        3500 |      |      3500 |   4500 | 01-01-2008
 (10 rows)
 
 select last_value(salary) over(order by enroll_date groups between 1 preceding and 1 following),
@@ -3275,14 +3275,14 @@ select last_value(salary) over(order by enroll_date groups between 1 preceding a
 ------------+------+--------+-------------
        3900 |      |   5000 | 10-01-2006
        3900 | 5000 |   6000 | 10-01-2006
-       5200 | 6000 |   3900 | 12-23-2006
-       4800 | 3900 |   4800 | 08-01-2007
-       4800 | 4800 |   5200 | 08-01-2007
-       5200 | 5200 |   4800 | 08-08-2007
+       4800 | 6000 |   3900 | 12-23-2006
+       4800 | 3900 |   5200 | 08-01-2007
+       4800 | 5200 |   4800 | 08-01-2007
+       5200 | 4800 |   4800 | 08-08-2007
        3500 | 4800 |   5200 | 08-15-2007
-       4200 | 5200 |   3500 | 12-10-2007
-       4200 | 3500 |   4500 | 01-01-2008
-       4200 | 4500 |   4200 | 01-01-2008
+       4500 | 5200 |   3500 | 12-10-2007
+       4500 | 3500 |   4200 | 01-01-2008
+       4500 | 4200 |   4500 | 01-01-2008
 (10 rows)
 
 select first_value(salary) over(order by enroll_date groups between 1 following and 3 following
@@ -3295,14 +3295,14 @@ select first_value(salary) over(order by enroll_date groups between 1 following
 -------------+------+-----------+--------+-------------
         3900 | 6000 |      3900 |   5000 | 10-01-2006
         3900 | 3900 |      3900 |   6000 | 10-01-2006
-        4800 | 4800 |      4800 |   3900 | 12-23-2006
-        4800 | 5200 |      4800 |   4800 | 08-01-2007
+        5200 | 5200 |      5200 |   3900 | 12-23-2006
         4800 | 4800 |      4800 |   5200 | 08-01-2007
+        4800 | 4800 |      4800 |   4800 | 08-01-2007
         5200 | 5200 |      5200 |   4800 | 08-08-2007
         3500 | 3500 |      3500 |   5200 | 08-15-2007
-        4500 | 4500 |      4500 |   3500 | 12-10-2007
-             | 4200 |           |   4500 | 01-01-2008
-             |      |           |   4200 | 01-01-2008
+        4200 | 4200 |      4200 |   3500 | 12-10-2007
+             | 4500 |           |   4200 | 01-01-2008
+             |      |           |   4500 | 01-01-2008
 (10 rows)
 
 select last_value(salary) over(order by enroll_date groups between 1 following and 3 following
@@ -3314,13 +3314,13 @@ select last_value(salary) over(order by enroll_date groups between 1 following a
        4800 |      |   5000 | 10-01-2006
        4800 | 5000 |   6000 | 10-01-2006
        5200 | 6000 |   3900 | 12-23-2006
-       3500 | 3900 |   4800 | 08-01-2007
-       3500 | 4800 |   5200 | 08-01-2007
-       4200 | 5200 |   4800 | 08-08-2007
-       4200 | 4800 |   5200 | 08-15-2007
-       4200 | 5200 |   3500 | 12-10-2007
-            | 3500 |   4500 | 01-01-2008
-            | 4500 |   4200 | 01-01-2008
+       3500 | 3900 |   5200 | 08-01-2007
+       3500 | 5200 |   4800 | 08-01-2007
+       4500 | 4800 |   4800 | 08-08-2007
+       4500 | 4800 |   5200 | 08-15-2007
+       4500 | 5200 |   3500 | 12-10-2007
+            | 3500 |   4200 | 01-01-2008
+            | 4200 |   4500 | 01-01-2008
 (10 rows)
 
 -- Show differences in offset interpretation between ROWS, RANGE, and GROUPS
-- 
2.51.1

From 0525e6f284779da7f68f35bc415f3da0848ec0df Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Wed, 12 Nov 2025 18:56:29 +0700
Subject: [PATCH v4 3/4] WIP make some regression tests' sort order more
 deterministic

The previous commit still results in failures in the TAP test
002_pg_upgrade.pl, namely that the regression tests fail on
the old cluster.

XXX it's not clear why only some tests fail this way
---
 src/test/regress/expected/tsrf.out   | 16 +++----
 src/test/regress/expected/window.out | 72 ++++++++++++++--------------
 src/test/regress/sql/tsrf.sql        |  2 +-
 src/test/regress/sql/window.sql      | 20 ++++----
 4 files changed, 55 insertions(+), 55 deletions(-)

diff --git a/src/test/regress/expected/tsrf.out b/src/test/regress/expected/tsrf.out
index fd3914b0fad..f5647ee561c 100644
--- a/src/test/regress/expected/tsrf.out
+++ b/src/test/regress/expected/tsrf.out
@@ -397,26 +397,24 @@ SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(d
        |     | 2 |     3
 (24 rows)
 
-SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(dataa, datab, g) ORDER BY dataa;
+SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(dataa, datab, g) ORDER BY dataa, datab, g;
  dataa |  b  | g | count 
 -------+-----+---+-------
- a     | foo |   |     2
- a     |     |   |     4
- a     |     | 2 |     2
  a     | bar | 1 |     1
  a     | bar | 2 |     1
  a     | bar |   |     2
  a     | foo | 1 |     1
  a     | foo | 2 |     1
+ a     | foo |   |     2
  a     |     | 1 |     2
+ a     |     | 2 |     2
+ a     |     |   |     4
  b     | bar | 1 |     1
- b     |     |   |     2
- b     |     | 1 |     1
  b     | bar | 2 |     1
  b     | bar |   |     2
+ b     |     | 1 |     1
  b     |     | 2 |     1
-       |     | 2 |     3
-       |     |   |     6
+ b     |     |   |     2
        | bar | 1 |     2
        | bar | 2 |     2
        | bar |   |     4
@@ -424,6 +422,8 @@ SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(d
        | foo | 2 |     1
        | foo |   |     2
        |     | 1 |     3
+       |     | 2 |     3
+       |     |   |     6
 (24 rows)
 
 SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(dataa, datab, g) ORDER BY g;
diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out
index b3cdeaea4b3..8a38417e721 100644
--- a/src/test/regress/expected/window.out
+++ b/src/test/regress/expected/window.out
@@ -18,13 +18,13 @@ INSERT INTO empsalary VALUES
 ('sales', 3, 4800, '2007-08-01'),
 ('develop', 8, 6000, '2006-10-01'),
 ('develop', 11, 5200, '2007-08-15');
-SELECT depname, empno, salary, sum(salary) OVER (PARTITION BY depname) FROM empsalary ORDER BY depname, salary;
+SELECT depname, empno, salary, sum(salary) OVER (PARTITION BY depname) FROM empsalary ORDER BY depname, salary, empno;
   depname  | empno | salary |  sum  
 -----------+-------+--------+-------
  develop   |     7 |   4200 | 25100
  develop   |     9 |   4500 | 25100
- develop   |    11 |   5200 | 25100
  develop   |    10 |   5200 | 25100
+ develop   |    11 |   5200 | 25100
  develop   |     8 |   6000 | 25100
  personnel |     5 |   3500 |  7400
  personnel |     2 |   3900 |  7400
@@ -33,18 +33,18 @@ SELECT depname, empno, salary, sum(salary) OVER (PARTITION BY depname) FROM emps
  sales     |     1 |   5000 | 14600
 (10 rows)
 
-SELECT depname, empno, salary, rank() OVER (PARTITION BY depname ORDER BY salary) FROM empsalary;
+SELECT depname, empno, salary, rank() OVER (PARTITION BY depname ORDER BY salary, empno) FROM empsalary;
   depname  | empno | salary | rank 
 -----------+-------+--------+------
  develop   |     7 |   4200 |    1
  develop   |     9 |   4500 |    2
- develop   |    11 |   5200 |    3
  develop   |    10 |   5200 |    3
+ develop   |    11 |   5200 |    4
  develop   |     8 |   6000 |    5
  personnel |     5 |   3500 |    1
  personnel |     2 |   3900 |    2
  sales     |     3 |   4800 |    1
- sales     |     4 |   4800 |    1
+ sales     |     4 |   4800 |    2
  sales     |     1 |   5000 |    3
 (10 rows)
 
@@ -75,33 +75,33 @@ GROUP BY four, ten ORDER BY four, ten;
     3 |   9 | 7500 |     9.0000000000000000
 (20 rows)
 
-SELECT depname, empno, salary, sum(salary) OVER w FROM empsalary WINDOW w AS (PARTITION BY depname);
+SELECT depname, empno, salary, sum(salary) OVER w FROM empsalary WINDOW w AS (PARTITION BY depname) ORDER BY depname, empno;
   depname  | empno | salary |  sum  
 -----------+-------+--------+-------
- develop   |    11 |   5200 | 25100
  develop   |     7 |   4200 | 25100
- develop   |     9 |   4500 | 25100
  develop   |     8 |   6000 | 25100
+ develop   |     9 |   4500 | 25100
  develop   |    10 |   5200 | 25100
- personnel |     5 |   3500 |  7400
+ develop   |    11 |   5200 | 25100
  personnel |     2 |   3900 |  7400
- sales     |     3 |   4800 | 14600
+ personnel |     5 |   3500 |  7400
  sales     |     1 |   5000 | 14600
+ sales     |     3 |   4800 | 14600
  sales     |     4 |   4800 | 14600
 (10 rows)
 
-SELECT depname, empno, salary, rank() OVER w FROM empsalary WINDOW w AS (PARTITION BY depname ORDER BY salary) ORDER BY rank() OVER w;
+SELECT depname, empno, salary, rank() OVER w FROM empsalary WINDOW w AS (PARTITION BY depname ORDER BY salary) ORDER BY rank() OVER w, empno;
   depname  | empno | salary | rank 
 -----------+-------+--------+------
- develop   |     7 |   4200 |    1
- personnel |     5 |   3500 |    1
- sales     |     4 |   4800 |    1
  sales     |     3 |   4800 |    1
- develop   |     9 |   4500 |    2
+ sales     |     4 |   4800 |    1
+ personnel |     5 |   3500 |    1
+ develop   |     7 |   4200 |    1
  personnel |     2 |   3900 |    2
- develop   |    11 |   5200 |    3
- develop   |    10 |   5200 |    3
+ develop   |     9 |   4500 |    2
  sales     |     1 |   5000 |    3
+ develop   |    10 |   5200 |    3
+ develop   |    11 |   5200 |    3
  develop   |     8 |   6000 |    5
 (10 rows)
 
@@ -3754,7 +3754,7 @@ FROM empsalary;
 SELECT
     empno,
     depname,
-    row_number() OVER (PARTITION BY depname ORDER BY enroll_date) rn,
+    row_number() OVER (PARTITION BY depname ORDER BY enroll_date, empno) rn,
     rank() OVER (PARTITION BY depname ORDER BY enroll_date ROWS BETWEEN
                  UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) rnk,
     count(*) OVER (PARTITION BY depname ORDER BY enroll_date RANGE BETWEEN
@@ -3765,8 +3765,8 @@ FROM empsalary;
      8 | develop   |  1 |   1 |   1
     10 | develop   |  2 |   2 |   1
     11 | develop   |  3 |   3 |   1
-     9 | develop   |  4 |   4 |   2
-     7 | develop   |  5 |   4 |   2
+     7 | develop   |  4 |   4 |   2
+     9 | develop   |  5 |   4 |   2
      2 | personnel |  1 |   1 |   1
      5 | personnel |  2 |   2 |   1
      1 | sales     |  1 |   1 |   1
@@ -4202,7 +4202,7 @@ SELECT * FROM
 
 -- Ensure we correctly filter out all of the run conditions from each window
 SELECT * FROM
-  (SELECT *,
+  (SELECT depname,
           count(salary) OVER (PARTITION BY depname || '') c1, -- w1
           row_number() OVER (PARTITION BY depname) rn, -- w2
           count(*) OVER (PARTITION BY depname) c2, -- w2
@@ -4210,10 +4210,10 @@ SELECT * FROM
           ntile(2) OVER (PARTITION BY depname) nt -- w2
    FROM empsalary
 ) e WHERE rn <= 1 AND c1 <= 3 AND nt < 2;
-  depname  | empno | salary | enroll_date | c1 | rn | c2 | c3 | nt 
------------+-------+--------+-------------+----+----+----+----+----
- personnel |     5 |   3500 | 12-10-2007  |  2 |  1 |  2 |  2 |  1
- sales     |     3 |   4800 | 08-01-2007  |  3 |  1 |  3 |  3 |  1
+  depname  | c1 | rn | c2 | c3 | nt 
+-----------+----+----+----+----+----
+ personnel |  2 |  1 |  2 |  2 |  1
+ sales     |  3 |  1 |  3 |  3 |  1
 (2 rows)
 
 -- Ensure we remove references to reduced outer joins as nulling rels in run
@@ -4498,23 +4498,23 @@ SELECT * FROM
           empno,
           salary,
           enroll_date,
-          row_number() OVER (PARTITION BY depname ORDER BY enroll_date) AS first_emp,
-          row_number() OVER (PARTITION BY depname ORDER BY enroll_date DESC) AS last_emp
+          row_number() OVER (PARTITION BY depname ORDER BY enroll_date, empno) AS first_emp,
+          row_number() OVER (PARTITION BY depname ORDER BY enroll_date DESC, empno) AS last_emp
    FROM empsalary) emp
 WHERE first_emp = 1 OR last_emp = 1;
-                                                         QUERY PLAN                                                         
-----------------------------------------------------------------------------------------------------------------------------
+                                                                 QUERY PLAN                                                                  
+---------------------------------------------------------------------------------------------------------------------------------------------
  Subquery Scan on emp
    Filter: ((emp.first_emp = 1) OR (emp.last_emp = 1))
    ->  WindowAgg
-         Window: w2 AS (PARTITION BY empsalary.depname ORDER BY empsalary.enroll_date ROWS UNBOUNDED PRECEDING)
+         Window: w2 AS (PARTITION BY empsalary.depname ORDER BY empsalary.enroll_date, empsalary.empno ROWS UNBOUNDED PRECEDING)
          ->  Incremental Sort
-               Sort Key: empsalary.depname, empsalary.enroll_date
+               Sort Key: empsalary.depname, empsalary.enroll_date, empsalary.empno
                Presorted Key: empsalary.depname
                ->  WindowAgg
-                     Window: w1 AS (PARTITION BY empsalary.depname ORDER BY empsalary.enroll_date ROWS UNBOUNDED PRECEDING)
+                     Window: w1 AS (PARTITION BY empsalary.depname ORDER BY empsalary.enroll_date, empsalary.empno ROWS UNBOUNDED PRECEDING)
                      ->  Sort
-                           Sort Key: empsalary.depname, empsalary.enroll_date DESC
+                           Sort Key: empsalary.depname, empsalary.enroll_date DESC, empsalary.empno
                            ->  Seq Scan on empsalary
 (12 rows)
 
@@ -4523,14 +4523,14 @@ SELECT * FROM
           empno,
           salary,
           enroll_date,
-          row_number() OVER (PARTITION BY depname ORDER BY enroll_date) AS first_emp,
-          row_number() OVER (PARTITION BY depname ORDER BY enroll_date DESC) AS last_emp
+          row_number() OVER (PARTITION BY depname ORDER BY enroll_date, empno) AS first_emp,
+          row_number() OVER (PARTITION BY depname ORDER BY enroll_date DESC, empno) AS last_emp
    FROM empsalary) emp
 WHERE first_emp = 1 OR last_emp = 1;
   depname  | empno | salary | enroll_date | first_emp | last_emp 
 -----------+-------+--------+-------------+-----------+----------
  develop   |     8 |   6000 | 10-01-2006  |         1 |        5
- develop   |     7 |   4200 | 01-01-2008  |         5 |        1
+ develop   |     7 |   4200 | 01-01-2008  |         4 |        1
  personnel |     2 |   3900 | 12-23-2006  |         1 |        2
  personnel |     5 |   3500 | 12-10-2007  |         2 |        1
  sales     |     1 |   5000 | 10-01-2006  |         1 |        3
diff --git a/src/test/regress/sql/tsrf.sql b/src/test/regress/sql/tsrf.sql
index 7c22529a0db..af7bd4bdd95 100644
--- a/src/test/regress/sql/tsrf.sql
+++ b/src/test/regress/sql/tsrf.sql
@@ -96,7 +96,7 @@ SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(d
 SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(dataa, datab) ORDER BY dataa;
 SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(dataa, datab) ORDER BY g;
 SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(dataa, datab, g);
-SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(dataa, datab, g) ORDER BY dataa;
+SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(dataa, datab, g) ORDER BY dataa, datab, g;
 SELECT dataa, datab b, generate_series(1,2) g, count(*) FROM few GROUP BY CUBE(dataa, datab, g) ORDER BY g;
 reset enable_hashagg;
 
diff --git a/src/test/regress/sql/window.sql b/src/test/regress/sql/window.sql
index 37d837a2f66..cb28d552fe8 100644
--- a/src/test/regress/sql/window.sql
+++ b/src/test/regress/sql/window.sql
@@ -21,17 +21,17 @@ INSERT INTO empsalary VALUES
 ('develop', 8, 6000, '2006-10-01'),
 ('develop', 11, 5200, '2007-08-15');
 
-SELECT depname, empno, salary, sum(salary) OVER (PARTITION BY depname) FROM empsalary ORDER BY depname, salary;
+SELECT depname, empno, salary, sum(salary) OVER (PARTITION BY depname) FROM empsalary ORDER BY depname, salary, empno;
 
-SELECT depname, empno, salary, rank() OVER (PARTITION BY depname ORDER BY salary) FROM empsalary;
+SELECT depname, empno, salary, rank() OVER (PARTITION BY depname ORDER BY salary, empno) FROM empsalary;
 
 -- with GROUP BY
 SELECT four, ten, SUM(SUM(four)) OVER (PARTITION BY four), AVG(ten) FROM tenk1
 GROUP BY four, ten ORDER BY four, ten;
 
-SELECT depname, empno, salary, sum(salary) OVER w FROM empsalary WINDOW w AS (PARTITION BY depname);
+SELECT depname, empno, salary, sum(salary) OVER w FROM empsalary WINDOW w AS (PARTITION BY depname) ORDER BY depname, empno;
 
-SELECT depname, empno, salary, rank() OVER w FROM empsalary WINDOW w AS (PARTITION BY depname ORDER BY salary) ORDER BY rank() OVER w;
+SELECT depname, empno, salary, rank() OVER w FROM empsalary WINDOW w AS (PARTITION BY depname ORDER BY salary) ORDER BY rank() OVER w, empno;
 
 -- empty window specification
 SELECT COUNT(*) OVER () FROM tenk1 WHERE unique2 < 10;
@@ -1145,7 +1145,7 @@ FROM empsalary;
 SELECT
     empno,
     depname,
-    row_number() OVER (PARTITION BY depname ORDER BY enroll_date) rn,
+    row_number() OVER (PARTITION BY depname ORDER BY enroll_date, empno) rn,
     rank() OVER (PARTITION BY depname ORDER BY enroll_date ROWS BETWEEN
                  UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) rnk,
     count(*) OVER (PARTITION BY depname ORDER BY enroll_date RANGE BETWEEN
@@ -1366,7 +1366,7 @@ SELECT * FROM
 
 -- Ensure we correctly filter out all of the run conditions from each window
 SELECT * FROM
-  (SELECT *,
+  (SELECT depname,
           count(salary) OVER (PARTITION BY depname || '') c1, -- w1
           row_number() OVER (PARTITION BY depname) rn, -- w2
           count(*) OVER (PARTITION BY depname) c2, -- w2
@@ -1507,8 +1507,8 @@ SELECT * FROM
           empno,
           salary,
           enroll_date,
-          row_number() OVER (PARTITION BY depname ORDER BY enroll_date) AS first_emp,
-          row_number() OVER (PARTITION BY depname ORDER BY enroll_date DESC) AS last_emp
+          row_number() OVER (PARTITION BY depname ORDER BY enroll_date, empno) AS first_emp,
+          row_number() OVER (PARTITION BY depname ORDER BY enroll_date DESC, empno) AS last_emp
    FROM empsalary) emp
 WHERE first_emp = 1 OR last_emp = 1;
 
@@ -1517,8 +1517,8 @@ SELECT * FROM
           empno,
           salary,
           enroll_date,
-          row_number() OVER (PARTITION BY depname ORDER BY enroll_date) AS first_emp,
-          row_number() OVER (PARTITION BY depname ORDER BY enroll_date DESC) AS last_emp
+          row_number() OVER (PARTITION BY depname ORDER BY enroll_date, empno) AS first_emp,
+          row_number() OVER (PARTITION BY depname ORDER BY enroll_date DESC, empno) AS last_emp
    FROM empsalary) emp
 WHERE first_emp = 1 OR last_emp = 1;
 
-- 
2.51.1

From 4564bbf46e40834368975e0cea528d6077437576 Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Fri, 17 Oct 2025 09:57:43 +0700
Subject: [PATCH v4 1/4] Use radix sort when datum1 is an integer type

For now this only works for signed and unsigned ints
with the usual comparison semantics, the same types
for which we previously had separate qsort
specializations.

Temporary GUC wip_radix_sort for testing
---
 src/backend/utils/misc/guc_parameters.dat |   7 +
 src/backend/utils/sort/tuplesort.c        | 399 ++++++++++++++++++++--
 src/include/utils/guc.h                   |   1 +
 src/include/utils/tuplesort.h             |   1 +
 4 files changed, 389 insertions(+), 19 deletions(-)

diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index 1128167c025..c9167eb4bb4 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -3469,6 +3469,13 @@
   max => 'INT_MAX',
 },
 
+{ name => 'wip_radix_sort', type => 'bool', context => 'PGC_USERSET', group => 'DEVELOPER_OPTIONS',
+  short_desc => 'Test radix sort for debugging.',
+  flags => 'GUC_NOT_IN_SAMPLE',
+  variable => 'wip_radix_sort',
+  boot_val => 'true',
+},
+
 { name => 'work_mem', type => 'int', context => 'PGC_USERSET', group => 'RESOURCES_MEM',
   short_desc => 'Sets the maximum memory to be used for query workspaces.',
   long_desc => 'This much memory can be used by each internal sort operation and hash table before switching to temporary disk files.',
diff --git a/src/backend/utils/sort/tuplesort.c b/src/backend/utils/sort/tuplesort.c
index 5d4411dc33f..c2b9625ae88 100644
--- a/src/backend/utils/sort/tuplesort.c
+++ b/src/backend/utils/sort/tuplesort.c
@@ -104,6 +104,7 @@
 #include "commands/tablespace.h"
 #include "miscadmin.h"
 #include "pg_trace.h"
+#include "port/pg_bitutils.h"
 #include "storage/shmem.h"
 #include "utils/guc.h"
 #include "utils/memutils.h"
@@ -122,6 +123,7 @@
 
 /* GUC variables */
 bool		trace_sort = false;
+bool		wip_radix_sort = true;	/* FIXME not for commit */
 
 #ifdef DEBUG_BOUNDED_SORT
 bool		optimize_bounded_sort = true;
@@ -615,6 +617,25 @@ qsort_tuple_int32_compare(SortTuple *a, SortTuple *b, Tuplesortstate *state)
 #define ST_DEFINE
 #include "lib/sort_template.h"
 
+
+#ifdef USE_ASSERT_CHECKING
+/* WIP: for now prefer test coverage of radix sort in Assert builds. */
+#define QSORT_THRESHOLD 0
+#else
+/* WIP: low because qsort_tuple() is slow -- we could raise this with a new specialization */
+#define QSORT_THRESHOLD 40
+#endif
+
+typedef struct RadixPartitionInfo
+{
+	union
+	{
+		size_t		count;
+		size_t		offset;
+	};
+	size_t		next_offset;
+}			RadixPartitionInfo;
+
 /*
  *		tuplesort_begin_xxx
  *
@@ -2663,10 +2684,334 @@ sort_bounded_heap(Tuplesortstate *state)
 	state->boundUsed = true;
 }
 
+static inline uint8_t
+extract_byte(Datum key, int level)
+{
+	return (key >> (((SIZEOF_DATUM - 1) - level) * 8)) & 0xFF;
+}
+
+/*
+ * Normalize datum to work with pure unsigned comparison,
+ * taking ASC/DESC into account as well.
+ */
+static inline Datum
+normalize_datum(Datum orig, SortSupport ssup)
+{
+	Datum		norm_datum1;
+
+	if (ssup->comparator == ssup_datum_signed_cmp)
+	{
+		norm_datum1 = orig + ((uint64) PG_INT64_MAX) + 1;
+	}
+	else if (ssup->comparator == ssup_datum_int32_cmp)
+	{
+		/*
+		 * First truncate to uint32. Technically, we don't need to do this,
+		 * but it forces the upper bytes to remain the same regardless of
+		 * sign.
+		 */
+		uint32		u32 = DatumGetUInt32(orig) + ((uint32) PG_INT32_MAX) + 1;
+
+		norm_datum1 = UInt32GetDatum(u32);
+	}
+	else
+	{
+		Assert(ssup->comparator == ssup_datum_unsigned_cmp);
+		norm_datum1 = orig;
+	}
+
+	if (ssup->ssup_reverse)
+		norm_datum1 = ~norm_datum1;
+
+	return norm_datum1;
+}
+
+/*
+ * Based on implementation in https://github.com/skarupke/ska_sort (Boost license),
+ * with the following noncosmetic change:
+ *  - count sorted partitions in every pass, rather than maintaining a
+ *    list of unsorted partitions
+ */
+static void
+radix_sort_tuple(SortTuple *begin, size_t n_elems, int level, Tuplesortstate *state)
+{
+	RadixPartitionInfo partitions[256] = {0};
+	uint8_t		remaining_partitions[256] = {0};
+	size_t		total = 0;
+	int			num_partitions = 0;
+	int			num_remaining;
+	SortSupport ssup = &state->base.sortKeys[0];
+	size_t		start_offset = 0;
+	SortTuple  *partition_begin = begin;
+
+	/* count key chunks */
+	for (SortTuple *tup = begin; tup < begin + n_elems; tup++)
+	{
+		uint8		current_byte;
+
+		/* extract the byte for this level from the normalized datum */
+		current_byte = extract_byte(normalize_datum(tup->datum1, ssup),
+									level);
+
+		/* save it for the permutation step */
+		tup->current_byte = current_byte;
+
+		partitions[current_byte].count++;
+	}
+
+	/* compute partition offsets */
+	for (int i = 0; i < 256; i++)
+	{
+		size_t		count = partitions[i].count;
+
+		if (count)
+		{
+			partitions[i].offset = total;
+			total += count;
+			remaining_partitions[num_partitions] = i;
+			num_partitions++;
+		}
+		partitions[i].next_offset = total;
+	}
+
+	num_remaining = num_partitions;
+
+	/*
+	 * Permute tuples to correct partition. If we started with one partition,
+	 * there is nothing to do. If a permutation from a previous iteration
+	 * results in a single partition that hasn't been marked as sorted, we
+	 * know it's actually sorted.
+	 */
+	while (num_remaining > 1)
+	{
+		/*
+		 * We can only exit the loop when all partitions are sorted, so must
+		 * reset every iteration
+		 */
+		num_remaining = num_partitions;
+
+		for (int i = 0; i < num_partitions; i++)
+		{
+			uint8		idx = remaining_partitions[i];
+
+			RadixPartitionInfo part = partitions[idx];
+
+			for (SortTuple *st = begin + part.offset;
+				 st < begin + part.next_offset;
+				 st++)
+			{
+				size_t		offset = partitions[st->current_byte].offset++;
+				SortTuple	tmp;
+
+				/* swap current tuple with destination position */
+				Assert(offset < n_elems);
+				tmp = *st;
+				*st = begin[offset];
+				begin[offset] = tmp;
+			};
+
+			if (part.offset == part.next_offset)
+			{
+				/* partition is sorted */
+				num_remaining--;
+			}
+		}
+	}
+
+	/* recurse */
+	for (uint8_t *rp = remaining_partitions;
+		 rp < remaining_partitions + num_partitions;
+		 rp++)
+	{
+		size_t		end_offset = partitions[*rp].next_offset;
+		SortTuple  *partition_end = begin + end_offset;
+		ptrdiff_t	num_elements = end_offset - start_offset;
+
+		if (num_elements > 1)
+		{
+			if (level < SIZEOF_DATUM - 1)
+			{
+				if (num_elements < QSORT_THRESHOLD)
+				{
+					qsort_tuple(partition_begin,
+								num_elements,
+								state->base.comparetup,
+								state);
+				}
+				else
+				{
+					radix_sort_tuple(partition_begin,
+									 num_elements,
+									 level + 1,
+									 state);
+				}
+			}
+			else if (state->base.onlyKey == NULL)
+			{
+				/*
+				 * We've finished radix sort on all bytes of the pass-by-value
+				 * datum (possibly abbreviated), now qsort with the tiebreak
+				 * comparator.
+				 */
+				qsort_tuple(partition_begin,
+							num_elements,
+							state->base.comparetup_tiebreak,
+							state);
+			}
+		}
+
+		start_offset = end_offset;
+		partition_begin = partition_end;
+	}
+}
+
 /*
- * Sort all memtuples using specialized qsort() routines.
+ * Partition tuples by NULL and NOT NULL first sort key.
+ * Then dispatch to either radix sort or qsort.
+ */
+static void
+sort_byvalue_datum(Tuplesortstate *state)
+{
+	SortSupportData ssup = state->base.sortKeys[0];
+
+	bool		nulls_first = ssup.ssup_nulls_first;
+	SortTuple  *data = state->memtuples;
+	SortTuple  *null_start;
+	SortTuple  *not_null_start;
+	size_t		d1 = 0,
+				d2,
+				null_count,
+				not_null_count;
+
+	/*
+	 * First, partition by NULL-ness of the leading sort key, since we can
+	 * only radix sort on NOT NULL pass-by-value datums.
+	 */
+
+	/*
+	 * Find the first NOT NULL tuple if NULLS FIRST, or first NULL element if
+	 * NULLS LAST. This is a quick check for the common case where all tuples
+	 * are NOT NULL in the first sort key.
+	 */
+	while (d1 < state->memtupcount && data[d1].isnull1 == nulls_first)
+		d1++;
+
+	/*
+	 * If we have more than one tuple left after the quick check, partition
+	 * the remainder using branchless cyclic permutation, based on
+	 * https://orlp.net/blog/branchless-lomuto-partitioning/
+	 */
+	if (d1 < state->memtupcount - 1)
+	{
+		size_t		j = d1;
+		SortTuple	save = data[d1];	/* create gap at front */
+
+		/* WIP: more comments */
+		while (j < state->memtupcount - 1)
+		{
+			data[j] = data[d1];
+			j += 1;
+			data[d1] = data[j];
+			d1 += (data[d1].isnull1 == nulls_first);
+		}
+
+		data[j] = data[d1];
+		data[d1] = save;
+		d1 += (data[d1].isnull1 == nulls_first);
+	}
+
+	/* d1 is now the number of elements in the left partition */
+	d2 = state->memtupcount - d1;
+
+	/* set pointers and counts for each partition */
+	if (nulls_first)
+	{
+		null_start = state->memtuples;
+		null_count = d1;
+		not_null_start = state->memtuples + d1;
+		not_null_count = d2;
+	}
+	else
+	{
+		not_null_start = state->memtuples;
+		not_null_count = d1;
+		null_start = state->memtuples + d1;
+		null_count = d2;
+	}
+
+	for (SortTuple *tup = null_start;
+		 tup < null_start + null_count;
+		 tup++)
+		Assert(tup->isnull1 == true);
+	for (SortTuple *tup = not_null_start;
+		 tup < not_null_start + not_null_count;
+		 tup++)
+		Assert(tup->isnull1 == false);
+
+	/*
+	 * Sort the NULL partition using tiebreak comparator, if necessary. XXX
+	 * this will repeat the comparison on isnull1 for abbreviated keys.
+	 */
+	if (state->base.onlyKey == NULL && null_count > 1)
+	{
+		qsort_tuple(null_start,
+					null_count,
+					state->base.comparetup_tiebreak,
+					state);
+	}
+
+	/*
+	 * Sort the NOT NULL partition, using radix sort if large enough,
+	 * otherwise fall back to quicksort.
+	 */
+	if (not_null_count > 1)
+	{
+		if (not_null_count < QSORT_THRESHOLD)
+		{
+			/*
+			 * WIP: We could compute the common prefix, save the following
+			 * byte in current_byte, and use a new qsort specialization for
+			 * that. Same for the diversion to qsort while recursing during
+			 * radix sort.
+			 */
+			qsort_tuple(not_null_start,
+						not_null_count,
+						state->base.comparetup,
+						state);
+		}
+		else
+		{
+			radix_sort_tuple(not_null_start,
+							 not_null_count,
+							 0,
+							 state);
+		}
+	}
+}
+
+/* Verify sort using standard comparator. */
+static void
+verify_sorted_memtuples(Tuplesortstate *state)
+{
+#ifdef USE_ASSERT_CHECKING
+	for (SortTuple *tup = state->memtuples + 1;
+		 tup < state->memtuples + state->memtupcount;
+		 tup++)
+	{
+#if 0
+		Assert(COMPARETUP(state, tup - 1, tup) <= 0);
+#else
+		if (COMPARETUP(state, tup - 1, tup) > 0)
+			elog(ERROR, "SORT FAILED");
+#endif
+	}
+#endif
+}
+
+/*
+ * Sort all memtuples using specialized routines.
  *
- * Quicksort is used for small in-memory sorts, and external sort runs.
+ * Quicksort or radix sort is used for small in-memory sorts, and external sort runs.
  */
 static void
 tuplesort_sort_memtuples(Tuplesortstate *state)
@@ -2681,26 +3026,42 @@ tuplesort_sort_memtuples(Tuplesortstate *state)
 		 */
 		if (state->base.haveDatum1 && state->base.sortKeys)
 		{
-			if (state->base.sortKeys[0].comparator == ssup_datum_unsigned_cmp)
-			{
-				qsort_tuple_unsigned(state->memtuples,
-									 state->memtupcount,
-									 state);
-				return;
-			}
-			else if (state->base.sortKeys[0].comparator == ssup_datum_signed_cmp)
+			SortSupportData ssup = state->base.sortKeys[0];
+
+			if (wip_radix_sort)
 			{
-				qsort_tuple_signed(state->memtuples,
-								   state->memtupcount,
-								   state);
-				return;
+				if ((ssup.comparator == ssup_datum_unsigned_cmp ||
+					 ssup.comparator == ssup_datum_signed_cmp ||
+					 ssup.comparator == ssup_datum_int32_cmp))
+				{
+					sort_byvalue_datum(state);
+					verify_sorted_memtuples(state);
+					return;
+				}
 			}
-			else if (state->base.sortKeys[0].comparator == ssup_datum_int32_cmp)
+			else
 			{
-				qsort_tuple_int32(state->memtuples,
-								  state->memtupcount,
-								  state);
-				return;
+				if (state->base.sortKeys[0].comparator == ssup_datum_unsigned_cmp)
+				{
+					qsort_tuple_unsigned(state->memtuples,
+										 state->memtupcount,
+										 state);
+					return;
+				}
+				else if (state->base.sortKeys[0].comparator == ssup_datum_signed_cmp)
+				{
+					qsort_tuple_signed(state->memtuples,
+									   state->memtupcount,
+									   state);
+					return;
+				}
+				else if (state->base.sortKeys[0].comparator == ssup_datum_int32_cmp)
+				{
+					qsort_tuple_int32(state->memtuples,
+									  state->memtupcount,
+									  state);
+					return;
+				}
 			}
 		}

 #ifdef DEBUG_BOUNDED_SORT
 extern PGDLLIMPORT bool optimize_bounded_sort;
diff --git a/src/include/utils/tuplesort.h b/src/include/utils/tuplesort.h
index 0bf55902aa1..e40c6e52f81 100644
--- a/src/include/utils/tuplesort.h
+++ b/src/include/utils/tuplesort.h
@@ -150,6 +150,7 @@ typedef struct
 	void	   *tuple;			/* the tuple itself */
 	Datum		datum1;			/* value of first key column */
 	bool		isnull1;		/* is first key column NULL? */
+	uint8		current_byte;	/* chunk of datum1 conditioned for radix sort */
 	int			srctape;		/* source tape number */
 } SortTuple;
 
-- 
2.51.1

---

From: David Geier <[email-masked]>
Date: Wed, 12 Nov 2025 15:28:37 +0100

> faster than our B&M qsort, so we have some headroom:

Hi John,

I've also been looking into radix sort the last days to accelerate GIN
index builds. Ordering and removing duplicates requires a fast sort in
generate_trgm(). My own implementation (likely slower than the
algorithms you used) also showed a decent speedup.

Beyond that there are many more places in the code base that could be
changed to use radix sort instead of qsort.

What would be great is if we could build a generic radix sort
implementation, similarly to sort_template.h that can be used in other
places. We would have to think a bit about the interface because instead
of a comparator we would require some radix extraction callback.

If you're open to that idea I could give abstracting the code a try.

--
David Geier

---

From: John Naylor <[email-masked]>
Date: Thu, 13 Nov 2025 11:01:18 +0700

On Wed, Nov 12, 2025 at 9:28 PM David Geier <[email-masked]> wrote:
> generate_trgm().

If that's the case then I suggest first seeing if dfd8e6c73ee made
things any worse. A simpler possible improvement is to use a similar
normalization step for the chars, if needed, then do the sort and
quinique with a specialization for unsigned chars. (We don't yet
specialize qunique, but that can be remedied). If you're interested,
please start a separate thread for that.

> of a comparator we would require some radix extraction callback.

That's moving the goalposts too far IMO. I want to get to a place
where I feel comfortable with the decisions made, and that already
requires a lot of testing. Also, I don't want to risk introducing
abstractions that make future improvements to tuplesort more
cumbersome.

--
John Naylor
Amazon Web Services

---

From: David Geier <[email-masked]>
Date: Fri, 14 Nov 2025 19:04:57 +0100

Hi John!

> please start a separate thread for that.

It did but only a bit. I worked around it by having two sort
specializations, one for signed and one for unsigned. I also wanted to
try to use a hash table to filter out duplicates and then only sort the
remaining unique trigams, which are, most of the times, a lot less.

Generally speaking, the GIN code is death by a thousand cuts. I've got a
patch coming up that cuts CREATE INDEX runtime in half for columns with
relatively short strings and yields even better results for columns with
longer strings. But that's not only changing the sort but requires a few
 changes in a couple of places. More details in the upcoming thread.

I thought qunique() is already pretty optimal because it's defined in a
header file. I believe that even the comparator gets inlined. What would
be useful though is if qunique() used an equality comparator which only
returns true/false instead of a sort comparator. In the GIN code this
also shaved off a few percent. I'll take a closer look at qunique() at
open a thread with the findings / ideas for changes.

Anyways. In this context GIN was just one example for where a generic
radix sort would be useful and there are certainly more.

> abstractions that make future improvements to tuplesort more

On a quick glance it looks like you didn't specialize much. So the
testing seems related to if the new algo introduces regressions, not if
the abstraction would cause problems. So it should be possible to
extract out the code fairly easily without invalidating your existing
benchmark results.

I understand that you want to make progress with the use case at hand
but I feel like we're missing out on a lot of opportunity where the
introduced code would also be very beneficial. Beyond that we could
nicely test the new sort code in the spirit of test_rbtree.c and
friends. Maybe you want to give it a 2nd thought.

--
David Geier

---

From: John Naylor <[email-masked]>
Date: Sat, 15 Nov 2025 09:47:11 +0700

On Sat, Nov 15, 2025 at 1:05 AM David Geier <[email-masked]> wrote:
> introduced code would also be very beneficial.

The patch is independently beneficial, but is also just a stepping
stone toward something larger, and I don't yet know exactly how it's
going to look. Premature abstractions are just going to get in the
way. I'd be open to hear proposals for possible wider application
after the dust settles, but that's not going to happen during the PG19
cycle.

-- 
John Naylor
Amazon Web Services

-- 
John Naylor
Amazon Web Services

---

From: David Geier <[email-masked]>
Date: Mon, 17 Nov 2025 16:38:43 +0100

Hi John!

> after the dust settles, but that's not going to happen during the PG19

That sounds like a good compromise. Let's see what else can profit from
the new sorting code once we've got the tuple sort in.

--
David Geier

---

From: John Naylor <[email-masked]>
Date: Thu, 20 Nov 2025 17:57:25 +0700

> performance testing in the near future.

Here's the first systematic test results, with scripts. Overall, I'm
very pleased. With extremely low cardinality, it's close enough to our
B&M quicksort that any difference (a hair slower or faster) can be
discarded as insignificant. It's massively faster with most other
inputs, so I'll just highlight the exceptions:

"ascending" - Our qsort runs a "presorted check" before every
partitioning step, and I hadn't done this for radix sort yet because I
wanted to see what the "natural" difference is. I'm inclined to put in
a single precheck at the beginning (people have come to expect that to
be there), but not one at every recursion because I don't think that's
useful. (Aside: that precheck at every recursion should be replaced by
something that detects ascending/descending runs at the very start,
but that's a separate thread)

"stagger" with multiplier = no. records / 2 - This seems to be a case
where the qsort's presorted check happens to get lucky. As I said
above, we should actually detect more sorted runs with something more
comprehensive.

"p5" - This is explicitly designed to favor the B&M qsort. The input
is 95% zeros, 2.5% negative numbers, and 2.5% positive numbers. The
first qsort pivot is pretty much guaranteed to be zero, and the first
partitioning step completes very quickly. Radix sort must do a lot
more work, but it not different than the amount of work it does with
other patterns -- it's much less sensitive to the input distribution
than qsort. In this case, there's a mix of negative and positive
bigints. That defeats common prefix detection, and the first iteration
deals into two piles: negative and non-negative. Then a few recursions
happen where there is only a single distinct byte, so no useful work
happens. I suppose I could try common prefix detection at every
recursion, but I don't think that's widely beneficial for integers.
Maybe the single-byte-plus comparator small qsort would help a little,
and I'm considering adding that anyway. In one sense this is the most
worrying, since there doesn't seem to be a widely-useful mitigation,
but in another sense it's the least worrying, since this case is
deliberately constructed to be at a disadvantage.

--
John Naylor
Amazon Web Services

---

From: Álvaro Herrera <[email-masked]>
Date: Thu, 20 Nov 2025 12:13:08 +0100

> +radix_sort_tuple(SortTuple *begin, size_t n_elems, int level, Tuplesortstate *state)

I think given https://www.boost.org/LICENSE_1_0.txt you should include a
copy of the Boost license in this comment, as well as the copyright
statement from the hpp file,

//          Copyright Malte Skarupke 2016.
// Distributed under the Boost Software License, Version 1.0.
//    (See http://www.boost.org/LICENSE_1_0.txt)

-- 
Álvaro Herrera         PostgreSQL Developer  —  https://www.EnterpriseDB.com/

---

From: John Naylor <[email-masked]>
Date: Thu, 20 Nov 2025 18:19:36 +0700

> statement from the hpp file,

Will do, next time I do some polishing.

(While thinking about it, I need to sprinkle in some
CHECK_FOR_INTERRUPTS(), too).

-- 
John Naylor
Amazon Web Services

---

From: Chengpeng Yan <[email-masked]>
Date: Sun, 23 Nov 2025 05:33:44 +0000

> On Nov 12, 2025, at 21:57, John Naylor <[email-masked]> wrote:
> 
> […]

Hi John,

I have reviewed the v4 patch set. I applied the patches and ran
"make check" on macOS 14.2.1 (M1), and all regression tests passed.

Overall, the implementation looks solid and the feature is a great
addition. I have a few suggestions regarding code optimization and one
discussion point regarding the data structure design.

Minor Comments / Optimizations:

1. Optimization in radix_sort_tuple (v4-0001)

In radix_sort_tuple, there is a check:

```
if (part.offset == part.next_offset)
```

Since "part" is a local copy of the struct, this check might not
reflect the latest state updated inside the loop. It might be slightly
more efficient to check the array directly:

```
if (partitions[idx].offset == partitions[idx].next_offset)
```

This allows us to detect if a partition has been fully consumed/sorted
within the current pass, potentially saving an iteration of the
"while (num_remaining > 1)" loop.

1. Branchless calculation in Common Prefix (v4-0004)

In sort_byvalue_datum, when calculating the common bits:

```
if (this_common_bits > common_upper_bits)
common_upper_bits = this_common_bits;
```

Since we are looking for the leftmost bit difference, we could
accumulate the differences using bitwise OR. This avoids a conditional
branch inside the loop:

```
common_upper_bits |= this_common_bits;
```

3. Short-circuit for identical keys (v4-0004)

When calculating common_prefix, if common_upper_bits is 0, it implies
that all non-null keys are identical (for the bits we care about). In
this case, we might be able to skip the radix sort entirely or handle
it as a single partition. Currently, the code handles it by passing
"common_upper_bits | 1" to pg_leftmost_one_pos64, which is safe but
perhaps not the most optimal path for identical keys.

-----

Discussion: SortTuple Layout and normalize_datum

I have a concern regarding the addition of "uint8 current_byte" to the
SortTuple struct.

1. Struct Size and Abstraction:

SortTuple is the generic atom for the entire sorting module. Adding a
field specific to integer Radix Sort feels like a bit of a leaky
abstraction. While it might fit into padding on some 64-bit platforms
(keeping the size at 32 bytes), relying on padding is fragile. If the
struct size increases, it reduces the number of tuples work_mem can
hold, affecting all sort operations, not just integers.

2. Cache Invalidation (Read vs. Write):

Currently, radix_sort_tuple writes to tup->current_byte in every pass.
This turns what could be a read-only access to the tuple array into a
read-write access, potentially dirtying cache lines and causing
unnecessary memory traffic.

Proposal: On-the-fly Normalization

We can avoid storing current_byte by calculating the relevant byte on
the fly. While this means re-calculating the byte extraction, we can
optimize normalize_datum.

The normalization logic (mapping signed integers to unsigned space)
essentially flips the sign bit. Instead of doing a full 64-bit
normalization for every byte extraction, we can apply this
transformation only when extracting the most significant byte (MSB).

The logic could look like this:

```
/*
 * Extract the byte at 'level' from 'key'.
 * level 0 denotes the Most Significant Byte.
 */
static inline uint8_t
extract_raw_byte(Datum key, int level)
{
    return (key >> (((SIZEOF_DATUM - 1) - level) * 8)) & 0xFF;
}

/*
 * Extract the "logically normalized" byte at 'level'.
 *
 * This effectively replaces the need to call normalize_datum() on
 * the full key.
 * - For non-MSB levels: return the raw byte.
 * - For the MSB level: flip the sign bit if the comparator
 * requires it.
 * - If sorting DESC: invert the result.
 */
static inline uint8_t
radix_extract_byte(Datum orig, int level, SortSupport ssup)
{
    uint8_t byte;

    /* 1. Extract raw byte first */
    byte = extract_raw_byte(orig, level);

    /*
     * 2. Apply normalization only to the Most Significant Byte.
     *
     * Mathematically, normalizing a signed integer for unsigned
     * comparison is equivalent to flipping the sign bit (adding
     * 2^(Width-1)).
     * In the byte domain, this means XORing the MSB with 0x80.
     */
    if (level == 0)
    {
        if (ssup->comparator == ssup_datum_signed_cmp ||
            ssup->comparator == ssup_datum_int32_cmp)
        {
            byte ^= 0x80;
        }
        else
        {
            Assert(ssup->comparator == ssup_datum_unsigned_cmp);
        }
    }

    /*
     * 3. Handle Reverse Sort
     * Instead of inverting the whole Datum, we just invert the
     * current byte.
     */
    if (ssup->ssup_reverse)
        byte = ~byte;

    return byte;
}
```

Benefits:

1. Keeps SortTuple minimal: No risk of struct bloat.
2. Read-only passes: The tuple array remains clean in cache during the
counting phase.
3. Reduced instruction count: We avoid the overhead of full
normalize_datum calls for lower bytes.

I'm happy to help verify this approach if you think it's worth
pursuing.

Best regards,
Chengpeng Yan

---

From: John Naylor <[email-masked]>
Date: Wed, 26 Nov 2025 13:52:42 +0700

> discussion point regarding the data structure design.

Thanks for taking a look!

> "while (num_remaining > 1)" loop.

I wondered about that and then forgot about it. Thanks for bringing
that up, I'll look into it.

> common_upper_bits |= this_common_bits;

Good idea.

> perhaps not the most optimal path for identical keys.

Right. If all values of the first sort key are the same, v4 still
wastes a bit of time counting the lowest byte. This shouldn't happen
for abbreviated keys since low cardinality will cause abbreviation to
abort, but it's still possible to hit that case if the first key is an
integer.

> abstraction.

I would say the only generic part of SortTuple is the "void* tuple"
pointer. Most of the rest of the fields are a cached copy of something
extracted from the actual tuple. The exception is "srctape", which is
only used for bookkeeping during external merges. I'm not sure I would
call srctape a leaky abstraction (I would call this a "fat struct"),
but either way we have some precedence for both cached info and for
info specific to one mode of sorting.

> While it might fit into padding on some 64-bit platforms
> (keeping the size at 32 bytes), relying on padding is fragile.

I don't see how any platform of any pointer size can fail to have a
padding byte since "isnull1" is a boolean. A long time ago we used to
allow platforms to have 32-bit booleans, but no longer, so I think
it's actually not fragile. (FYI: 24 bytes.)

> optimize normalize_datum.

That's a interesting idea to consider.

There's one possible disadvantage I can think of: It makes it
difficult to add a fallback qsort specialization that includes the
next "current_byte". How would you teach the qsort comparator to look
at the right "level"?

(Aside: Long term, a cached current byte (or more than one byte) could
also come from something other than the pass-by-value datum. I'm
thinking specifically of a blob of bytes made from normalizing a set
of keys. In that case we won't want to fetch the byte every time. The
pass-by-value datum case can remain independent of that, of course.)

This made me think of something tangential: v4's common prefix
skipping doesn't take into account that the upper 4 bytes can't
matter. With a mix of positive and negative integers, I think it will
do the radix sort on all 8 bytes of "datum1".

> 1. Keeps SortTuple minimal: No risk of struct bloat.

As I mentioned, I don't believe there is any risk at all.

> 2. Read-only passes: The tuple array remains clean in cache during the

I think the "deal" step stresses the cache and TLB a lot more than
sequential writes would, but reducing overall write traffic is a good
design choice. We'd need to weigh that against the other things I
mentioned.

> normalize_datum calls for lower bytes.

I'm not quite sure that's a net reduction, since every iteration of
the deal step has to at least re-extract the raw byte. An earlier
version stored the normalized datum and extracted the byte as needed,
but that also diverted to a qsort that compared the normalized datum.
There wasn't a big difference in my limited testing, but it could
matter for some inputs.

> I'm happy to help verify this approach if you think it's worth

I suspect it won't make a big difference, but I could be wrong so feel
free. First let me update the patch with some of your other review
items.

--
John Naylor
Amazon Web Services

---

From: John Naylor <[email-masked]>
Date: Wed, 26 Nov 2025 13:55:30 +0700

> do the radix sort on all 8 bytes of "datum1".

I accidentally edited out some context: The above is referring to SQL
"int" sort keys, i.e. 32-bit signed integers.

-- 
John Naylor
Amazon Web Services

---

From: John Naylor <[email-masked]>
Date: Wed, 26 Nov 2025 20:11:52 +0700

> statement from the hpp file,

Done.

> if (partitions[idx].offset == partitions[idx].next_offset)

Done, and removed the local copy since it wasn't doing much else.

> common_upper_bits |= this_common_bits;

Done.

> perhaps not the most optimal path for identical keys.

Added a short-circuit.

For v5 I've also added CHECK_FOR_INTERRUPTS and rewrote some comments.

--
John Naylor
Amazon Web Services

---

From: Chao Li <[email-masked]>
Date: Thu, 27 Nov 2025 15:48:58 +0800

Hi John,

I did an initial test before, but I didn’t read the code at the time. Today, I spent time reviewing 0001. Overall, I believe the implementation is solid. I just got a few comments/suggestions.

> On Nov 26, 2025, at 21:11, John Naylor <[email-masked]> wrote:
> 
> 
> <v5-0001-Use-radix-sort-when-SortTuple-contains-a-pass-by-.patch><v5-0004-Detect-common-prefix-to-avoid-wasted-work-during-.patch><v5-0003-WIP-make-some-regression-tests-sort-order-more-de.patch><v5-0002-WIP-Adjust-regression-tests.patch>

1 - 0001
```

We recompute normalize_datum(tup->datum1, ssup) for every tuple in every level, why don’t cache the result in SortTuple. As we have cached current_byte in SortTuple, it shouldn’t be a big deal to add one more field to it.

2 - 0001
```
+	while (num_remaining > 1)
+	{
+		/* start the count over */
+		num_remaining = num_partitions;
```

The swap loop always start the count over, so that sorted partitions are re-scanned as well. I think we can do an optimization like:

```
num_active = num_partitions;
while (num_active > 1)
{
    for (int i = 0; i < num_active; )
    {
        uint8 idx = remaining_partitions[i];
        // do the swaps for the partition …

        if (partitions[idx].offset == partitions[idx].next_offset)
        {
            remaining_partitions[i] = remaining_partitions[num_active - 1];
            num_active--;
        }
        else
            i++;
    }
}
```

This way we move out sorted partitions, so they will not be re-scanned.

3 - 0001

In sort_byvalue_datum, we can add a fast-path for all NULL and all NOT NULL cases, so that they won’t need to run the branchless cyclic permutation and the two “for” loops of assertions. Something like:

```
while (d1 < state->memtupcount && data[d1].isnull1 == nulls_first)
    d1++;

null_count = d1;
not_null_count = state->memtupcount - d1;

/* fast paths: all on one side */
if (null_count == 0 || not_null_count == 0)
{
    if (nulls_first)
    {
        null_start = data;
        not_null_start = data + null_count;
    }
    else
    {
        not_null_start = data;
        null_start = data + not_null_count;
    }

    /* only one partition is non-empty; sort it and return */
    if (not_null_count > 1)
    {
        if (not_null_count < QSORT_THRESHOLD)
            qsort_tuple(not_null_start, not_null_count, state->base.comparetup, state);
        else
            radix_sort_tuple(not_null_start, not_null_count, 0, state);
    }
    else if (null_count > 1 && state->base.onlyKey == NULL)
    {
        qsort_tuple(null_start, null_count, state->base.comparetup_tiebreak, state);
    }
    return;
}

/* ... existing branchless cyclic permutation ... */

```

Best regards,

---

From: John Naylor <[email-masked]>
Date: Thu, 27 Nov 2025 18:45:32 +0700

> I did an initial test before, but I didn’t read the code at the time. Today, I spent time reviewing 0001. Overall, I believe the implementation is solid. I just got a few comments/suggestions.

Thanks for looking!

> We recompute normalize_datum(tup->datum1, ssup) for every tuple in every level, why don’t cache the result in SortTuple. As we have cached current_byte in SortTuple, it shouldn’t be a big deal to a…

Actually it is a big deal, because the memtuples array counts against work_mem.

I saw two ways to store the full normalized without increasing the
size, and I've already rejected them:
- share space with isnull1/srctape -- v3 did this, and I already
explained the reason for changing when I shared v4.
- share space with datum1 -- that would require additional code to
restore the original datum and makes it more difficult to verify
correctness

There is also a proposal upthread to not store anything, and that's
still up in the air.

> This way we move out sorted partitions, so they will not be re-scanned.

I don't think that's going to work without additional bookkeeping, so
I'm not sure it's worth it. See the recursion step.

> In sort_byvalue_datum, we can add a fast-path for all NULL and all NOT NULL cases, so that they won’t need to run the branchless cyclic permutation and the two “for” loops of assertions. Something…

This is too clever and yet doesn't go far enough.

There is already one fast path, which happens to cover the common ASC
+ all NOT NULL case. The right way to skip the permutation step would
be to add a second loop that starts from the end and stops at the
first tuple that needs to swap. That should work not just for all NULL
and all NOT NULL, but also where there is a mix of the two and some
(or all) are already in place. All these cases can be treated the same
and they will continue to the exact same paths.

I haven't yet bothered to try harder, but it may be necessary in order
to avoid introducing regressions, so I'll look into it.

--
John Naylor
Amazon Web Services

---

From: Chao Li <[email-masked]>
Date: Wed, 3 Dec 2025 16:22:13 +0800

Hi John,

I played with this again today and found an optimization that seems to dramatically improve the performance:

```
+static void

Here partitions and remaining_partitions are just temporary buffers, allocating memory from stack and initialize them seems slow. By passing them as function parameters are much faster. See attached diff for my change.

V5 patch: by the way, v5 is very faster than v1.
```
evantest=# select * from test_multi order by category, name;
Time: 299.912 ms
evantest=# select * from test_multi order by category, name;
Time: 298.793 ms
evantest=# select * from test_multi order by category, name;
Time: 300.306 ms
evantest=# select * from test_multi order by category, name;
Time: 302.140 ms
```

v5 + my change:
```
Time: 152.572 ms
Time: 143.296 ms
Time: 151.606 ms
```

The test I did today is just the high cardinality first column test I had done before:
```
drop table if exists test_multi;
create unlogged table test_multi (category int, name text);
insert into test_multi select (random() * 1000000)::int as category,  md5(random()::text) || md5(random()::text) as name from generate_series(1, 1000000);
vacuum freeze test_multi;
\timing on
\o /dev/null
set wip_radix_sort = ‘on;
set work_mem = ‘2GB’;
select * from test_multi order by category, name;
```

Best regards,

---

From: John Naylor <[email-masked]>
Date: Thu, 4 Dec 2025 12:30:52 +0700

> Here partitions and remaining_partitions are just temporary buffers, allocating memory from stack and initialize them seems slow. By passing them as function parameters are much faster. See attache…

The lesson here is: you can make it as fast as you like if you
accidentally blow away the state that we needed for this to work
correctly.

--
John Naylor
Amazon Web Services

---

From: Chao Li <[email-masked]>
Date: Thu, 4 Dec 2025 16:40:18 +0800

> On Dec 4, 2025, at 13:30, John Naylor <[email-masked]> wrote:
> 

Yeah, I quickly realized I was wrong after I clicked “send". I was trying the firs two optimizations as I suggested in my previous email, but the first didn’t help much, and the second just didn’t work. After several hours debugging, I guess my brain got stuck and came out the weird idea.

I continued playing with this again today. I think the execution time is mainly spent on the in-place element switching, which uses 3 levels of loops (while->for->for). If we can use an extra temp array to hold the sorted result, then the 3-level loop can be optimized to 1-level, but that will cost a lot of extra memory which I am afraid not affordable.

Anyway, it’s a fun of playing with this optimization thing. I may play with it again once I get some time.

Best regards,

---

From: Chao Li <[email-masked]>
Date: Mon, 8 Dec 2025 10:52:02 +0800

Hi John,

I played the radix sort again during the weekend.

First, I changed my direction and implemented the in-place switching in the other way, where I did a way like chained-switching. Say starting from item0, for example, switching item0 to item5, then check where item5 should be switched to, and makes the switch, till an item is switch to position 0. See my implementation in other-implemation.diff if you are interested in it. This time, I eyeball checked the sort result and confirmed the correction. But my implementation is slightly slower than your implementation, based on the same test procedure I described in my previous email, my implementation is roughly ~3% slower your implementation. So I think that at least proves your current implementation in v5 has been perfectly fine tuned.

Then I went back to read your implementation again, I found a tiny optimization, where we can move “count sorted partitions” to before the “for” loop, which avoid sorted partition to go through the “for” loop, and my test shows that the movement may lead to ~1% improvement. See the change in radixsort_tiny_optimizeation.diff.

I also noticed that, there could be cases where target element is already in the right partition, so that switching could be unnecessary. However if we want to avoid such unnecessary switches, then we will need to add a “if” check. Given the total number of such cases is tiny, the “if” check would be more expensive than performing blindly switching. I tried to add such a check like:
```
if (offset == (size_t) (st - begin))
 continue; /* already in correct position */
```
With my test, it just makes the query ~3% slower.

So, now I think I can wrap up this round of playing. My only suggestion is radixsort_tiny_optimizeation.diff. I may revisit this patch again once you make the entire patch set ready for review.

Best regards,

---

From: John Naylor <[email-masked]>
Date: Mon, 8 Dec 2025 13:26:38 +0700

> First, I changed my direction and implemented the in-place switching in the other way, where I did a way like chained-switching. Say starting from item0, for example, switching item0 to item5, then…

That shouldn't be surprising, since the way you describe is basically
"American flag sort", which is much older, and the innovation of
ska_byte_sort was to recognize that this is bad for CPU pipelining.
That was explained in detail in the blog post I linked to in my first
email.

Also notice that by attaching a .diff, the CF bot tries and fails to
apply that to master, and has been complaining that my patch needs a
rebase. Please don't do that again.

--
John Naylor
Amazon Web Services

---

From: Chao Li <[email-masked]>
Date: Mon, 8 Dec 2025 14:31:07 +0800

> On Dec 8, 2025, at 14:26, John Naylor <[email-masked]> wrote:
> 

Fair point. I wasn’t aware you have created a CF entry.

Best regards,

---

From: John Naylor <[email-masked]>
Date: Wed, 21 Jan 2026 13:40:11 +0700

Attached is v6, which seems pretty close to committable. The temporary
GUC is gone, as are all the integer-like qsort specializations. There
are a few cosmetic rearrangements, but the algorithm is pretty much
the same as v5. The only visible behavior difference should be the
addition of a presorted check just like qsort has.

* The only things I have doubts about are the user-visible messages
mentioning "qsort". For trace_sort, it seemed logical to re-use the
term "internal sort" from other such messages, so I've done that for
v6. EXPLAIN ANALYZE is a bit harder: Do we want to even change
anything here? When things like this come up, there is the question of
tools that parse the output. It doesn't seem particularly important to
try to display exact info here, especially since radix sort must
divert to qsort for multiple keys etc.

Other updates:

1) I wanted a better test for small input, in case the threshold
needed adjusting. I lost the test during a fumbled rebase, but it's
easy to describe: instead of calling tuplesort_sort_memtuples(), call
both radix sort and qsort on copies of the input, with RDTSC calls
around each. I've attached the results, which are average ticks per
element sorted, averaging a million runs. The threshold seems to be
fine at 40, even down to a cardinality of 6. It's important to be as
low as we can get away with, since "qsort" here is qsort_tuple, not
the former specialized variants for integer comparators.

2) We don't need to try harder in the NULL partitioning step

For example, the case below with all NULL in the first key. The
underlying behavioral difference is, with qsort the comparator tries
the NULL first key (which ties every time), and then tries the second
key. With radix sort, the first key NULLs are partitioned first, doing
no useful work since they're all NULL, then qsort is called with the
tiebreak comparator on the whole input.

By this test, there is only noise difference with low cardinality
(before I removed the GUC):

drop table if exists test;
create unlogged table test (a bigint, b bigint not null);
insert into test select
  NULL,
  (1_000_000_000 * random())::bigint % 8
from generate_series(1,1_000_000,1) i;
vacuum freeze test;
select pg_prewarm('test');

set work_mem = '500MB';
\timing

set wip_radix_sort = 'off'; select * from test order by a, b offset
1_000_000_000;
280ms

set wip_radix_sort = 'on'; select * from test order by a, b offset
1_000_000_000;
277ms

Speaking of the NULL partitioning step, I've tried to add some
comments that make sense, but the pictures in the linked blog are more
helpful than any words I can come up with. It's easier to understand
than to describe.

--
John Naylor
Amazon Web Services

---

From: "zengman" <[email-masked]>
Date: Wed, 21 Jan 2026 16:12:42 +0800

Hi,

I wanted to point out a small detail: the line `extern PGDLLIMPORT bool wip_radix_sort;`
 in v6-0001 appears to not have been fully cleaned up — I suspect it might have been overlooked.

--
Regards,
Man Zeng

---

From: John Naylor <[email-masked]>
Date: Tue, 10 Feb 2026 20:37:59 +0700

> addition of a presorted check just like qsort has.

I did some more self-review after a couple weeks and made some more
minor cosmetic adjustments for v7. I will commit 0001 and 0002 in a
few days unless there are objections.

> divert to qsort for multiple keys etc.

I decided to split this out to a separate patch, 0003. It doesn't seem
important, and may not be desirable after all. There is also a 0004
which is just verifying sort order in assert builds on the existing
qsort calls and not just the new sort. Looks better this way.

> helpful than any words I can come up with. It's easier to understand

I tried to improve readability here and added missing CHECK_FOR_INTERRUPTS().

Also ran UB sanitizer and Valgrind.

>  in v6-0001 appears to not have been fully cleaned up — I suspect it might have been overlooked.

Fixed as well.

--
John Naylor
Amazon Web Services
From dfefff9d2e28979dee92f3b61e954c3c8b1ee236 Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Wed, 12 Nov 2025 14:31:24 +0700
Subject: [PATCH v7 2/4] Detect common prefix to avoid wasted work during radix
 sort

Start radix sort at the most significant byte position that has more
than one distinct byte in the input. This skips passes where radix
sort would count the distinct bytes just to find only a single one,
in which case there is nothing further to do for that pass. This can
give a few percent speedup for integers that have some zero upper
bytes, which is common for those that didn't arrive via abbreviation.

Reviewed-by: Chengpeng Yan <[email-masked]>
Discussion: https://postgr.es/m/[email-masked]
---
 src/backend/utils/sort/tuplesort.c | 66 ++++++++++++++++++++++++++++--
 1 file changed, 62 insertions(+), 4 deletions(-)

diff --git a/src/backend/utils/sort/tuplesort.c b/src/backend/utils/sort/tuplesort.c
index 07fa83c7944..7b22546a811 100644
--- a/src/backend/utils/sort/tuplesort.c
+++ b/src/backend/utils/sort/tuplesort.c
@@ -104,6 +104,7 @@
 #include "commands/tablespace.h"
 #include "miscadmin.h"
 #include "pg_trace.h"
+#include "port/pg_bitutils.h"
 #include "storage/shmem.h"
 #include "utils/guc.h"
 #include "utils/memutils.h"
@@ -2928,10 +2929,67 @@ sort_byvalue_datum(SortTuple *data, size_t n, Tuplesortstate *state)
 	}
 	else
 	{
-		radix_sort_tuple(not_null_start,
-						 not_null_count,
-						 0,
-						 state);
+		int			common_prefix;
+		Datum		first_datum = 0;
+		Datum		common_upper_bits = 0;
+
+		/*
+		 * Compute the common prefix to skip unproductive recursion steps
+		 * during radix sort.
+		 */
+		for (SortTuple *st = not_null_start;
+			 st < not_null_start + not_null_count;
+			 st++)
+		{
+			Datum		this_datum = st->datum1;
+
+			if (st == not_null_start)
+			{
+				/*
+				 * Need to start with some value, may as well be the first
+				 * one.
+				 */
+				first_datum = this_datum;
+			}
+			else
+			{
+				/*
+				 * Accumulate bits that represent a difference from the
+				 * reference datum.
+				 */
+				common_upper_bits |= first_datum ^ this_datum;
+			}
+		}
+
+		if (common_upper_bits == 0)
+		{
+			/*
+			 * All NOT NULL tuples have the same datum, so we can skip radix
+			 * sort. Sort using the tiebreak comparator if necessary.
+			 */
+			if (state->base.onlyKey == NULL)
+			{
+				qsort_tuple(not_null_start,
+							not_null_count,
+							state->base.comparetup_tiebreak,
+							state);
+			}
+		}
+		else
+		{
+			/*
+			 * The upper bits of common_upper_bits are zero where all datums
+			 * have the same bits. The byte position of the leftmost one bit
+			 * is the byte where radix sort should start.
+			 */
+			common_prefix = SIZEOF_DATUM - 1 -
+				(pg_leftmost_one_pos64(common_upper_bits) / BITS_PER_BYTE);
+
+			radix_sort_tuple(not_null_start,
+							 not_null_count,
+							 common_prefix,
+							 state);
+		}
 	}
 }
 
-- 
2.53.0

From 81be72e69208b155f5619630043690157871a755 Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Fri, 17 Oct 2025 09:57:43 +0700
Subject: [PATCH v7 1/4] Perform radix sort on SortTuples with pass-by-value
 Datums
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Radix sort can be much faster than quicksort, but for our purposes it
is limited to sequences of unsigned bytes. To make tuples with other
types amenable to this technique, several features of tuple comparison
must be accounted for, i.e. the sort keys must be "normalized":

1. Signedness -- It's possible to modify a signed integer such that
it can be compared as unsigned. For example, a signed char has range
-128 to 127. If we cast that to unsigned char and add 128, the range
of values becomes 0 to 255 while preserving order.

2. Direction -- SQL allows specification of ASC or DESC. The
descending case is easily handled by taking the complement of the
unsigned representation.

3. NULL values -- NULLS FIRST and NULLS LAST must work correctly.

This commmit only handles the case where datum1 is pass-by-value
Datum (possibly abbreviated) that compares like an ordinary
integer. (Abbreviations of values of type "numeric" are a convenient
counterexample.) First, tuples are partitioned by nullness in the
correct NULL ordering. Then the NOT NULL tuples are sorted with radix
sort on datum1. For tiebreaks on subsequent sortkeys (including the
first sort key if abbreviated), we divert to the usual qsort.

ORDER BY queries on pre-warmed buffers are up to 2x faster on high
cardinality inputs with radix sort than the sort specializations added
by commit 697492434, so get rid of them. It's sufficient to fall back
to qsort_tuple() for small arrays. Moderately low cardinality inputs
show more modest improvents. Our qsort is strongly optimized for very
low cardinality inputs, but radix sort is usually comparable there.

The changes to the regression tests are caused by under-specified sort
orders, e.g. "SELECT a, b from mytable order by a;". For unstable
sorts, such as our qsort and this in-place radix sort, there is no
guarantee of the order of "b" within each group of "a".

The implementation is taken from ska_byte_sort() (Boost licensed),
which is similar to American flag sort (an in-place radix sort) with
modifications to make it better suited for modern pipelined CPUs.

The technique of normalization described above can also be extended
to the case of multiple keys. That is left for future work (Thanks
to Peter Geoghegan for the suggestion to look into this area).

Reviewed-by: Chengpeng Yan <[email-masked]>
Reviewed-by: Álvaro Herrera <[email-masked]>
Reviewed-by: zengman <[email-masked]>
Reviewed-by: Chao Li <[email-masked]> (earlier version)
Discussion: https://postgr.es/m/[email-masked]
---
 src/backend/utils/sort/tuplesort.c      | 556 ++++++++++++++++++------
 src/include/utils/sortsupport.h         | 101 -----
 src/include/utils/tuplesort.h           |   1 +
 src/test/regress/expected/tuplesort.out |   6 +-
 src/tools/pgindent/typedefs.list        |   1 +
 5 files changed, 427 insertions(+), 238 deletions(-)

diff --git a/src/backend/utils/sort/tuplesort.c b/src/backend/utils/sort/tuplesort.c
index 1edcad89c88..07fa83c7944 100644
--- a/src/backend/utils/sort/tuplesort.c
+++ b/src/backend/utils/sort/tuplesort.c
@@ -7,8 +7,8 @@
  * applied to different kinds of sortable objects.  Implementation of
  * the particular sorting variants is given in tuplesortvariants.c.
  * This module works efficiently for both small and large amounts
- * of data.  Small amounts are sorted in-memory using qsort().  Large
- * amounts are sorted using temporary files and a standard external sort
+ * of data.  Small amounts are sorted in-memory.  Large amounts are
+ * sorted using temporary files and a standard external sort
  * algorithm.
  *
  * See Knuth, volume 3, for more than you want to know about external
@@ -26,16 +26,16 @@
  * Historically, we divided the input into sorted runs using replacement
  * selection, in the form of a priority tree implemented as a heap
  * (essentially Knuth's Algorithm 5.2.3H), but now we always use quicksort
- * for run generation.
+ * or radix sort for run generation.
  *
  * The approximate amount of memory allowed for any one sort operation
  * is specified in kilobytes by the caller (most pass work_mem).  Initially,
  * we absorb tuples and simply store them in an unsorted array as long as
  * we haven't exceeded workMem.  If we reach the end of the input without
- * exceeding workMem, we sort the array using qsort() and subsequently return
+ * exceeding workMem, we sort the array in memory and subsequently return
  * tuples just by scanning the tuple array sequentially.  If we do exceed
  * workMem, we begin to emit tuples into sorted runs in temporary tapes.
- * When tuples are dumped in batch after quicksorting, we begin a new run
+ * When tuples are dumped in batch after in-memory sorting, we begin a new run
  * with a new output tape.  If we reach the max number of tapes, we write
  * subsequent runs on the existing tapes in a round-robin fashion.  We will
  * need multiple merge passes to finish the merge in that case.  After the
@@ -476,121 +476,15 @@ static void free_sort_tuple(Tuplesortstate *state, SortTuple *stup);
 static void tuplesort_free(Tuplesortstate *state);
 static void tuplesort_updatemax(Tuplesortstate *state);
 
-/*
- * Specialized comparators that we can inline into specialized sorts.  The goal
- * is to try to sort two tuples without having to follow the pointers to the
- * comparator or the tuple.
- *
- * XXX: For now, there is no specialization for cases where datum1 is
- * authoritative and we don't even need to fall back to a callback at all (that
- * would be true for types like int4/int8/timestamp/date, but not true for
- * abbreviations of text or multi-key sorts.  There could be!  Is it worth it?
- */
-
-/* Used if first key's comparator is ssup_datum_unsigned_cmp */
-static pg_attribute_always_inline int
-qsort_tuple_unsigned_compare(SortTuple *a, SortTuple *b, Tuplesortstate *state)
-{
-	int			compare;
-
-	compare = ApplyUnsignedSortComparator(a->datum1, a->isnull1,
-										  b->datum1, b->isnull1,
-										  &state->base.sortKeys[0]);
-	if (compare != 0)
-		return compare;
-
-	/*
-	 * No need to waste effort calling the tiebreak function when there are no
-	 * other keys to sort on.
-	 */
-	if (state->base.onlyKey != NULL)
-		return 0;
-
-	return state->base.comparetup_tiebreak(a, b, state);
-}
-
-/* Used if first key's comparator is ssup_datum_signed_cmp */
-static pg_attribute_always_inline int
-qsort_tuple_signed_compare(SortTuple *a, SortTuple *b, Tuplesortstate *state)
-{
-	int			compare;
-
-	compare = ApplySignedSortComparator(a->datum1, a->isnull1,
-										b->datum1, b->isnull1,
-										&state->base.sortKeys[0]);
-
-	if (compare != 0)
-		return compare;
-
-	/*
-	 * No need to waste effort calling the tiebreak function when there are no
-	 * other keys to sort on.
-	 */
-	if (state->base.onlyKey != NULL)
-		return 0;
-
-	return state->base.comparetup_tiebreak(a, b, state);
-}
-
-/* Used if first key's comparator is ssup_datum_int32_cmp */
-static pg_attribute_always_inline int
-qsort_tuple_int32_compare(SortTuple *a, SortTuple *b, Tuplesortstate *state)
-{
-	int			compare;
-
-	compare = ApplyInt32SortComparator(a->datum1, a->isnull1,
-									   b->datum1, b->isnull1,
-									   &state->base.sortKeys[0]);
-
-	if (compare != 0)
-		return compare;
-
-	/*
-	 * No need to waste effort calling the tiebreak function when there are no
-	 * other keys to sort on.
-	 */
-	if (state->base.onlyKey != NULL)
-		return 0;
-
-	return state->base.comparetup_tiebreak(a, b, state);
-}
 
 /*
  * Special versions of qsort just for SortTuple objects.  qsort_tuple() sorts
  * any variant of SortTuples, using the appropriate comparetup function.
  * qsort_ssup() is specialized for the case where the comparetup function
  * reduces to ApplySortComparator(), that is single-key MinimalTuple sorts
- * and Datum sorts.  qsort_tuple_{unsigned,signed,int32} are specialized for
- * common comparison functions on pass-by-value leading datums.
+ * and Datum sorts.
  */
 
-#define ST_SORT qsort_tuple_unsigned
-#define ST_ELEMENT_TYPE SortTuple
-#define ST_COMPARE(a, b, state) qsort_tuple_unsigned_compare(a, b, state)
-#define ST_COMPARE_ARG_TYPE Tuplesortstate
-#define ST_CHECK_FOR_INTERRUPTS
-#define ST_SCOPE static
-#define ST_DEFINE
-#include "lib/sort_template.h"
-
-#define ST_SORT qsort_tuple_signed
-#define ST_ELEMENT_TYPE SortTuple
-#define ST_COMPARE(a, b, state) qsort_tuple_signed_compare(a, b, state)
-#define ST_COMPARE_ARG_TYPE Tuplesortstate
-#define ST_CHECK_FOR_INTERRUPTS
-#define ST_SCOPE static
-#define ST_DEFINE
-#include "lib/sort_template.h"
-
-#define ST_SORT qsort_tuple_int32
-#define ST_ELEMENT_TYPE SortTuple
-#define ST_COMPARE(a, b, state) qsort_tuple_int32_compare(a, b, state)
-#define ST_COMPARE_ARG_TYPE Tuplesortstate
-#define ST_CHECK_FOR_INTERRUPTS
-#define ST_SCOPE static
-#define ST_DEFINE
-#include "lib/sort_template.h"
-
 #define ST_SORT qsort_tuple
 #define ST_ELEMENT_TYPE SortTuple
 #define ST_COMPARE_RUNTIME_POINTER
@@ -612,6 +506,23 @@ qsort_tuple_int32_compare(SortTuple *a, SortTuple *b, Tuplesortstate *state)
 #define ST_DEFINE
 #include "lib/sort_template.h"
 
+/* state for radix sort */
+typedef struct RadixSortInfo
+{
+	union
+	{
+		size_t		count;
+		size_t		offset;
+	};
+	size_t		next_offset;
+} RadixSortInfo;
+
+/*
+ * Threshold below which qsort_tuple() is generally faster than a radix sort.
+ */
+#define QSORT_THRESHOLD 40
+
+
 /*
  *		tuplesort_begin_xxx
  *
@@ -1363,7 +1274,7 @@ tuplesort_performsort(Tuplesortstate *state)
 			 */
 			if (SERIAL(state))
 			{
-				/* Just qsort 'em and we're done */
+				/* Sort in memory and we're done */
 				tuplesort_sort_memtuples(state);
 				state->status = TSS_SORTEDINMEM;
 			}
@@ -2337,7 +2248,7 @@ dumptuples(Tuplesortstate *state, bool alltuples)
 
 	/*
 	 * Sort all tuples accumulated within the allowed amount of memory for
-	 * this run using quicksort
+	 * this run.
 	 */
 	tuplesort_sort_memtuples(state);
 
@@ -2652,10 +2563,395 @@ sort_bounded_heap(Tuplesortstate *state)
 	state->boundUsed = true;
 }
 
+
+/* radix sort routines */
+
+/*
+ * Retrieve byte from datum, indexed by 'level': 0 for LSB, 7 for MSB
+ */
+static inline uint8
+current_byte(Datum key, int level)
+{
+	int			shift = (SIZEOF_DATUM - 1 - level) * BITS_PER_BYTE;
+
+	return (key >> shift) & 0xFF;
+}
+
 /*
- * Sort all memtuples using specialized qsort() routines.
+ * Normalize datum such that unsigned comparison is order-preserving,
+ * taking ASC/DESC into account as well.
+ */
+static inline Datum
+normalize_datum(Datum orig, SortSupport ssup)
+{
+	Datum		norm_datum1;
+
+	if (ssup->comparator == ssup_datum_signed_cmp)
+	{
+		norm_datum1 = orig + ((uint64) PG_INT64_MAX) + 1;
+	}
+	else if (ssup->comparator == ssup_datum_int32_cmp)
+	{
+		/*
+		 * First truncate to uint32. Technically, we don't need to do this,
+		 * but it forces the upper half of the datum to be zero regardless of
+		 * sign.
+		 */
+		uint32		u32 = DatumGetUInt32(orig) + ((uint32) PG_INT32_MAX) + 1;
+
+		norm_datum1 = UInt32GetDatum(u32);
+	}
+	else
+	{
+		Assert(ssup->comparator == ssup_datum_unsigned_cmp);
+		norm_datum1 = orig;
+	}
+
+	if (ssup->ssup_reverse)
+		norm_datum1 = ~norm_datum1;
+
+	return norm_datum1;
+}
+
+/*
+ * radix_sort_tuple
+ *
+ * Radix sort by the pass-by-value datum in datum1. This is a modification of
+ * ska_byte_sort() from https://github.com/skarupke/ska_sort
+ * The original copyright notice follows:
+ *
+ * Copyright Malte Skarupke 2016.
+ * Distributed under the Boost Software License, Version 1.0.
+ *
+ * Boost Software License - Version 1.0 - August 17th, 2003
+ *
+ * Permission is hereby granted, free of charge, to any person or organization
+ * obtaining a copy of the software and accompanying documentation covered by
+ * this license (the "Software") to use, reproduce, display, distribute,
+ * execute, and transmit the Software, and to prepare derivative works of the
+ * Software, and to permit third-parties to whom the Software is furnished to
+ * do so, all subject to the following:
+ *
+ * The copyright notices in the Software and this entire statement, including
+ * the above license grant, this restriction and the following disclaimer,
+ * must be included in all copies of the Software, in whole or in part, and
+ * all derivative works of the Software, unless such copies or derivative
+ * works are solely in the form of machine-executable object code generated by
+ * a source language processor.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
+ * SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
+ * FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
+ * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ */
+static void
+radix_sort_tuple(SortTuple *begin, size_t n_elems, int level, Tuplesortstate *state)
+{
+	RadixSortInfo partitions[256] = {0};
+	uint8		remaining_partitions[256];
+	size_t		total = 0;
+	int			num_partitions = 0;
+	int			num_remaining;
+	SortSupport ssup = &state->base.sortKeys[0];
+	size_t		start_offset = 0;
+	SortTuple  *partition_begin = begin;
+
+	/* count number of occurrences of each byte */
+	for (SortTuple *st = begin; st < begin + n_elems; st++)
+	{
+		uint8		this_partition;
+
+		/* extract the byte for this level from the normalized datum */
+		this_partition = current_byte(normalize_datum(st->datum1, ssup),
+									  level);
+
+		/* save it for the permutation step */
+		st->curbyte = this_partition;
+
+		partitions[this_partition].count++;
+
+		CHECK_FOR_INTERRUPTS();
+	}
+
+	/* compute partition offsets */
+	for (int i = 0; i < 256; i++)
+	{
+		size_t		count = partitions[i].count;
+
+		if (count != 0)
+		{
+			partitions[i].offset = total;
+			total += count;
+			remaining_partitions[num_partitions] = i;
+			num_partitions++;
+		}
+		partitions[i].next_offset = total;
+	}
+
+	/*
+	 * Swap tuples to correct partition.
+	 *
+	 * In traditional American flag sort, a swap sends the current element to
+	 * the correct partition, but the array pointer only advances if the
+	 * partner of the swap happens to be an element that belongs in the
+	 * current partition. That only requires one pass through the array, but
+	 * the disadvantage is we don't know if the pointer can advance until the
+	 * swap completes. Here lies the most interesting innovation from the
+	 * upstream ska_byte_sort: After initiating the swap, we immediately
+	 * proceed to the next element. This makes better use of CPU pipelining,
+	 * but also means that we will often need multiple iterations of this
+	 * loop. ska_byte_sort() maintains a separate list of which partitions
+	 * haven't finished, which is updated every loop iteration. Here we simply
+	 * check each partition during every iteration.
+	 *
+	 * If we started with a single partition, there is nothing to do. If a
+	 * previous loop iteration results in only one partition that hasn't been
+	 * counted as sorted, we know it's actually sorted and can exit the loop.
+	 */
+	num_remaining = num_partitions;
+	while (num_remaining > 1)
+	{
+		/* start the count over */
+		num_remaining = num_partitions;
+
+		for (int i = 0; i < num_partitions; i++)
+		{
+			uint8		idx = remaining_partitions[i];
+
+			for (SortTuple *st = begin + partitions[idx].offset;
+				 st < begin + partitions[idx].next_offset;
+				 st++)
+			{
+				size_t		offset = partitions[st->curbyte].offset++;
+				SortTuple	tmp;
+
+				/* swap current tuple with destination position */
+				Assert(offset < n_elems);
+				tmp = *st;
+				*st = begin[offset];
+				begin[offset] = tmp;
+
+				CHECK_FOR_INTERRUPTS();
+			};
+
+			/* Is this partition sorted? */
+			if (partitions[idx].offset == partitions[idx].next_offset)
+				num_remaining--;
+		}
+	}
+
+	/* recurse */
+	for (uint8 *rp = remaining_partitions;
+		 rp < remaining_partitions + num_partitions;
+		 rp++)
+	{
+		size_t		end_offset = partitions[*rp].next_offset;
+		SortTuple  *partition_end = begin + end_offset;
+		ptrdiff_t	num_elements = end_offset - start_offset;
+
+		if (num_elements > 1)
+		{
+			if (level < SIZEOF_DATUM - 1)
+			{
+				if (num_elements < QSORT_THRESHOLD)
+				{
+					qsort_tuple(partition_begin,
+								num_elements,
+								state->base.comparetup,
+								state);
+				}
+				else
+				{
+					radix_sort_tuple(partition_begin,
+									 num_elements,
+									 level + 1,
+									 state);
+				}
+			}
+			else if (state->base.onlyKey == NULL)
+			{
+				/*
+				 * We've finished radix sort on all bytes of the pass-by-value
+				 * datum (possibly abbreviated), now sort using the tiebreak
+				 * comparator.
+				 */
+				qsort_tuple(partition_begin,
+							num_elements,
+							state->base.comparetup_tiebreak,
+							state);
+			}
+		}
+
+		start_offset = end_offset;
+		partition_begin = partition_end;
+	}
+}
+
+/*
+ * Sort tuples having a pass-by-value Datum in datum1
+ *
+ * Partition tuples by NULL and NOT NULL first sort key.
+ * Then dispatch to either radix sort or quicksort.
+ */
+static void
+sort_byvalue_datum(SortTuple *data, size_t n, Tuplesortstate *state)
+{
+	bool		nulls_first = state->base.sortKeys[0].ssup_nulls_first;
+	SortTuple  *null_start;
+	SortTuple  *not_null_start;
+	size_t		d1 = 0,
+				d2,
+				null_count,
+				not_null_count;
+	bool		presorted = true;
+
+	/* presorted check */
+	for (SortTuple *st = data + 1; st < data + n; st++)
+	{
+		CHECK_FOR_INTERRUPTS();
+
+		if (COMPARETUP(state, st - 1, st) > 0)
+		{
+			presorted = false;
+			break;
+		}
+	}
+	if (presorted)
+		return;
+
+	/*
+	 * Partition by NULL-ness of the leading sort key, since we can only radix
+	 * sort on NOT NULL pass-by-value datums.
+	 */
+
+	/*
+	 * Find the first NOT NULL if NULLS FIRST, or first NULL if NULLS LAST.
+	 * This also serves as a quick check for the common case where all tuples
+	 * are NOT NULL in the first sort key.
+	 */
+	while (d1 < n && data[d1].isnull1 == nulls_first)
+	{
+		CHECK_FOR_INTERRUPTS();
+		d1++;
+	}
+
+	/*
+	 * If we have more than one tuple left after the quick check, partition
+	 * the remainder using branchless cyclic permutation, based on
+	 * https://orlp.net/blog/branchless-lomuto-partitioning/
+	 */
+	Assert(n > 0);
+	if (d1 < n - 1)
+	{
+		size_t		i = d1,
+					j = d1;
+		SortTuple	tmp = data[d1]; /* create gap at front */
+
+		while (j < n - 1)
+		{
+			CHECK_FOR_INTERRUPTS();
+
+			/* gap is at j, move i's element to gap */
+			data[j] = data[i];
+			/* advance j to the first unknown element */
+			j += 1;
+			/* move the first unknown element back to i */
+			data[i] = data[j];
+			/* advance i if this element belongs in the left partition */
+			i += (data[i].isnull1 == nulls_first);
+		}
+
+		/* place gap between left and right partitions */
+		data[j] = data[i];
+		/* restore the saved element */
+		data[i] = tmp;
+		/* assign it to the correct partition */
+		i += (data[i].isnull1 == nulls_first);
+
+		/* d1 is now the number of elements in the left partition */
+		d1 = i;
+	}
+
+	d2 = n - d1;
+
+	/* set pointers and counts for each partition */
+	if (nulls_first)
+	{
+		null_start = state->memtuples;
+		null_count = d1;
+		not_null_start = state->memtuples + d1;
+		not_null_count = d2;
+	}
+	else
+	{
+		not_null_start = state->memtuples;
+		not_null_count = d1;
+		null_start = state->memtuples + d1;
+		null_count = d2;
+	}
+
+	for (SortTuple *st = null_start;
+		 st < null_start + null_count;
+		 st++)
+		Assert(st->isnull1 == true);
+	for (SortTuple *st = not_null_start;
+		 st < not_null_start + not_null_count;
+		 st++)
+		Assert(st->isnull1 == false);
+
+	/*
+	 * Sort the NULL partition using tiebreak comparator, if necessary. This
+	 * will repeat the comparison on isnull1 if the first sort key was
+	 * abbreviated, but it's not worth adding complexity to avoid that.
+	 */
+	if (state->base.onlyKey == NULL && null_count > 1)
+	{
+		qsort_tuple(null_start,
+					null_count,
+					state->base.comparetup_tiebreak,
+					state);
+	}
+
+	/*
+	 * Sort the NOT NULL partition, using radix sort if large enough,
+	 * otherwise fall back to quicksort.
+	 */
+	if (not_null_count < QSORT_THRESHOLD)
+	{
+		qsort_tuple(not_null_start,
+					not_null_count,
+					state->base.comparetup,
+					state);
+	}
+	else
+	{
+		radix_sort_tuple(not_null_start,
+						 not_null_count,
+						 0,
+						 state);
+	}
+}
+
+/* Verify in-memory sort using standard comparator. */
+static void
+verify_memtuples_sorted(Tuplesortstate *state)
+{
+#ifdef USE_ASSERT_CHECKING
+	for (SortTuple *st = state->memtuples + 1;
+		 st < state->memtuples + state->memtupcount;
+		 st++)
+		Assert(COMPARETUP(state, st - 1, st) <= 0);
+#endif
+}
+
+/*
+ * Sort all memtuples using specialized routines.
  *
- * Quicksort is used for small in-memory sorts, and external sort runs.
+ * Quicksort or radix sort is used for small in-memory sorts,
+ * and external sort runs.
  */
 static void
 tuplesort_sort_memtuples(Tuplesortstate *state)
@@ -2665,30 +2961,22 @@ tuplesort_sort_memtuples(Tuplesortstate *state)
 	if (state->memtupcount > 1)
 	{
 		/*
-		 * Do we have the leading column's value or abbreviation in datum1,
-		 * and is there a specialization for its comparator?
+		 * Do we have the leading column's value or abbreviation in datum1?
 		 */
 		if (state->base.haveDatum1 && state->base.sortKeys)
 		{
-			if (state->base.sortKeys[0].comparator == ssup_datum_unsigned_cmp)
-			{
-				qsort_tuple_unsigned(state->memtuples,
-									 state->memtupcount,
-									 state);
-				return;
-			}
-			else if (state->base.sortKeys[0].comparator == ssup_datum_signed_cmp)
+			SortSupportData ssup = state->base.sortKeys[0];
+
+			/* Does it compare as an integer? */
+			if (state->memtupcount >= QSORT_THRESHOLD &&
+				(ssup.comparator == ssup_datum_unsigned_cmp ||
+				 ssup.comparator == ssup_datum_signed_cmp ||
+				 ssup.comparator == ssup_datum_int32_cmp))
 			{
-				qsort_tuple_signed(state->memtuples,
+				sort_byvalue_datum(state->memtuples,
 								   state->memtupcount,
 								   state);
-				return;
-			}
-			else if (state->base.sortKeys[0].comparator == ssup_datum_int32_cmp)
-			{
-				qsort_tuple_int32(state->memtuples,
-								  state->memtupcount,
-								  state);
+				verify_memtuples_sorted(state);
 				return;
 			}
 		}
diff --git a/src/include/utils/sortsupport.h b/src/include/utils/sortsupport.h
index 0083756bbdb..a8f8f9f026a 100644
--- a/src/include/utils/sortsupport.h
+++ b/src/include/utils/sortsupport.h
@@ -229,107 +229,6 @@ ApplySortComparator(Datum datum1, bool isNull1,
 	return compare;
 }
 
-static inline int
-ApplyUnsignedSortComparator(Datum datum1, bool isNull1,
-							Datum datum2, bool isNull2,
-							SortSupport ssup)
-{
-	int			compare;
-
-	if (isNull1)
-	{
-		if (isNull2)
-			compare = 0;		/* NULL "=" NULL */
-		else if (ssup->ssup_nulls_first)
-			compare = -1;		/* NULL "<" NOT_NULL */
-		else
-			compare = 1;		/* NULL ">" NOT_NULL */
-	}
-	else if (isNull2)
-	{
-		if (ssup->ssup_nulls_first)
-			compare = 1;		/* NOT_NULL ">" NULL */
-		else
-			compare = -1;		/* NOT_NULL "<" NULL */
-	}
-	else
-	{
-		compare = datum1 < datum2 ? -1 : datum1 > datum2 ? 1 : 0;
-		if (ssup->ssup_reverse)
-			INVERT_COMPARE_RESULT(compare);
-	}
-
-	return compare;
-}
-
-static inline int
-ApplySignedSortComparator(Datum datum1, bool isNull1,
-						  Datum datum2, bool isNull2,
-						  SortSupport ssup)
-{
-	int			compare;
-
-	if (isNull1)
-	{
-		if (isNull2)
-			compare = 0;		/* NULL "=" NULL */
-		else if (ssup->ssup_nulls_first)
-			compare = -1;		/* NULL "<" NOT_NULL */
-		else
-			compare = 1;		/* NULL ">" NOT_NULL */
-	}
-	else if (isNull2)
-	{
-		if (ssup->ssup_nulls_first)
-			compare = 1;		/* NOT_NULL ">" NULL */
-		else
-			compare = -1;		/* NOT_NULL "<" NULL */
-	}
-	else
-	{
-		compare = DatumGetInt64(datum1) < DatumGetInt64(datum2) ? -1 :
-			DatumGetInt64(datum1) > DatumGetInt64(datum2) ? 1 : 0;
-		if (ssup->ssup_reverse)
-			INVERT_COMPARE_RESULT(compare);
-	}
-
-	return compare;
-}
-
-static inline int
-ApplyInt32SortComparator(Datum datum1, bool isNull1,
-						 Datum datum2, bool isNull2,
-						 SortSupport ssup)
-{
-	int			compare;
-
-	if (isNull1)
-	{
-		if (isNull2)
-			compare = 0;		/* NULL "=" NULL */
-		else if (ssup->ssup_nulls_first)
-			compare = -1;		/* NULL "<" NOT_NULL */
-		else
-			compare = 1;		/* NULL ">" NOT_NULL */
-	}
-	else if (isNull2)
-	{
-		if (ssup->ssup_nulls_first)
-			compare = 1;		/* NOT_NULL ">" NULL */
-		else
-			compare = -1;		/* NOT_NULL "<" NULL */
-	}
-	else
-	{
-		compare = DatumGetInt32(datum1) < DatumGetInt32(datum2) ? -1 :
-			DatumGetInt32(datum1) > DatumGetInt32(datum2) ? 1 : 0;
-		if (ssup->ssup_reverse)
-			INVERT_COMPARE_RESULT(compare);
-	}
-
-	return compare;
-}
-
 /*
  * Apply a sort comparator function and return a 3-way comparison using full,
  * authoritative comparator.  This takes care of handling reverse-sort and
diff --git a/src/include/utils/tuplesort.h b/src/include/utils/tuplesort.h
index 5fe229e211b..da68f45acf2 100644
--- a/src/include/utils/tuplesort.h
+++ b/src/include/utils/tuplesort.h
@@ -116,6 +116,7 @@ typedef struct
 	void	   *tuple;			/* the tuple itself */
 	Datum		datum1;			/* value of first key column */
 	bool		isnull1;		/* is first key column NULL? */
+	uint8		curbyte;		/* chunk of datum1 for current radix sort pass */
 	int			srctape;		/* source tape number */
 } SortTuple;
 
diff --git a/src/test/regress/expected/tuplesort.out b/src/test/regress/expected/tuplesort.out
index 6dd97e7427a..fc1321bf443 100644
--- a/src/test/regress/expected/tuplesort.out
+++ b/src/test/regress/expected/tuplesort.out
@@ -304,9 +304,9 @@ FROM abbrev_abort_uuids
 ORDER BY ctid DESC LIMIT 5;
   id   |           abort_increasing           |           abort_decreasing           |          noabort_increasing          |          noabort_decreasing          
 -------+--------------------------------------+--------------------------------------+--------------------------------------+--------------------------------------
-     0 |                                      |                                      |                                      | 
  20002 |                                      |                                      |                                      | 
  20003 |                                      |                                      |                                      | 
+     0 |                                      |                                      |                                      | 
  10009 | 00000000-0000-0000-0000-000000010008 | 00000000-0000-0000-0000-000000009992 | 00010008-0000-0000-0000-000000010008 | 00009992-0000-0000-0000-000000009992
  10008 | 00000000-0000-0000-0000-000000010007 | 00000000-0000-0000-0000-000000009993 | 00010007-0000-0000-0000-000000010007 | 00009993-0000-0000-0000-000000009993
 (5 rows)
@@ -335,9 +335,9 @@ FROM abbrev_abort_uuids
 ORDER BY ctid DESC LIMIT 5;
   id   |           abort_increasing           |           abort_decreasing           |          noabort_increasing          |          noabort_decreasing          
 -------+--------------------------------------+--------------------------------------+--------------------------------------+--------------------------------------
-     0 |                                      |                                      |                                      | 
- 20003 |                                      |                                      |                                      | 
  20002 |                                      |                                      |                                      | 
+ 20003 |                                      |                                      |                                      | 
+     0 |                                      |                                      |                                      | 
   9993 | 00000000-0000-0000-0000-000000009992 | 00000000-0000-0000-0000-000000010008 | 00009992-0000-0000-0000-000000009992 | 00010008-0000-0000-0000-000000010008
   9994 | 00000000-0000-0000-0000-000000009993 | 00000000-0000-0000-0000-000000010007 | 00009993-0000-0000-0000-000000009993 | 00010007-0000-0000-0000-000000010007
 (5 rows)
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 9f5ee8fd482..4e494bdc860 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4058,6 +4058,7 @@ qsort_comparator
 query_pathkeys_callback
 radius_attribute
 radius_packet
+RadixSortInfo
 rangeTableEntry_used_context
 rank_context
 rbt_allocfunc
-- 
2.53.0

From 872819673cfd85153cc5e3b8482321a6dfb98f32 Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Tue, 10 Feb 2026 19:10:20 +0700
Subject: [PATCH v7 4/4] WIP Call verify_memtuples_sorted() after qsort for
 consistency

The regression test changes are from a user defined function that
is now called a couple more times.
---
 src/backend/utils/sort/tuplesort.c      | 1 +
 src/test/regress/expected/namespace.out | 2 ++
 2 files changed, 3 insertions(+)

diff --git a/src/backend/utils/sort/tuplesort.c b/src/backend/utils/sort/tuplesort.c
index deb02eb5ee5..4bb7bcf7814 100644
--- a/src/backend/utils/sort/tuplesort.c
+++ b/src/backend/utils/sort/tuplesort.c
@@ -3052,6 +3052,7 @@ tuplesort_sort_memtuples(Tuplesortstate *state)
 						state->base.comparetup,
 						state);
 		}
+		verify_memtuples_sorted(state);
 	}
 }
 
diff --git a/src/test/regress/expected/namespace.out b/src/test/regress/expected/namespace.out
index dbbda72d395..b78abdf21ea 100644
--- a/src/test/regress/expected/namespace.out
+++ b/src/test/regress/expected/namespace.out
@@ -149,6 +149,8 @@ NOTICE:  current search_path: pg_catalog, pg_temp
 NOTICE:  current search_path: pg_catalog, pg_temp
 NOTICE:  current search_path: pg_catalog, pg_temp
 NOTICE:  current search_path: pg_catalog, pg_temp
+NOTICE:  current search_path: pg_catalog, pg_temp
+NOTICE:  current search_path: pg_catalog, pg_temp
 REFRESH MATERIALIZED VIEW test_maint_search_path.test_maint_mv;
 NOTICE:  current search_path: pg_catalog, pg_temp
 NOTICE:  current search_path: pg_catalog, pg_temp
-- 
2.53.0

From 7f2e95ca483ce34e26f22d9bf466ccf9f4182241 Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Tue, 10 Feb 2026 20:02:19 +0700
Subject: [PATCH v7 3/4] WIP Add possible message changes

Not sure if necessary so kept out of main commit
---
 src/backend/utils/sort/tuplesort.c | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/backend/utils/sort/tuplesort.c b/src/backend/utils/sort/tuplesort.c
index 7b22546a811..deb02eb5ee5 100644
--- a/src/backend/utils/sort/tuplesort.c
+++ b/src/backend/utils/sort/tuplesort.c
@@ -2243,7 +2243,7 @@ dumptuples(Tuplesortstate *state, bool alltuples)
 	state->currentRun++;
 
 	if (trace_sort)
-		elog(LOG, "worker %d starting quicksort of run %d: %s",
+		elog(LOG, "worker %d starting internal sort of run %d: %s",
 			 state->worker, state->currentRun,
 			 pg_rusage_show(&state->ru_start));
 
@@ -2254,7 +2254,7 @@ dumptuples(Tuplesortstate *state, bool alltuples)
 	tuplesort_sort_memtuples(state);
 
 	if (trace_sort)
-		elog(LOG, "worker %d finished quicksort of run %d: %s",
+		elog(LOG, "worker %d finished internal sort of run %d: %s",
 			 state->worker, state->currentRun,
 			 pg_rusage_show(&state->ru_start));
 
-- 
2.53.0

---

From: "zengman" <[email-masked]>
Date: Wed, 11 Feb 2026 18:25:07 +0800

Hi John,

I'm wondering if we should replace `state->memtuples` with `data` in the `sort_byvalue_datum()` function here.
```
	if (nulls_first)
	{
		null_start = state->memtuples;
		null_count = d1;
		not_null_start = state->memtuples + d1;
		not_null_count = d2;
	}
	else
	{
		not_null_start = state->memtuples;
		not_null_count = d1;
		null_start = state->memtuples + d1;
		null_count = d2;
	}
```
If my understanding is wrong, please ignore this.

--
regards,
Man Zeng

---

From: "cca5507" <[email-masked]>
Date: Wed, 11 Feb 2026 19:46:56 +0800

Hi,

> I'm wondering if we should replace `state->memtuples` with `data` in the `sort_byvalue_datum()` function here.

+1. Now data == state->memtuples, how about remove the parameter "data" and "n" and just
get them from "Tuplesortstate"?

Some comments for v7:

1)

```
/*
 * Retrieve byte from datum, indexed by 'level': 0 for LSB, 7 for MSB
 */
static inline uint8
current_byte(Datum key, int level)
{
	int			shift = (SIZEOF_DATUM - 1 - level) * BITS_PER_BYTE;

	return (key >> shift) & 0xFF;
}
```

Maybe "0 for MSB, 7 for LSB"? If level == 0, this function will return the Most Significant Byte.

2) radix_sort_tuple()

```
		size_t		end_offset = partitions[*rp].next_offset;
		SortTuple  *partition_end = begin + end_offset;
		ptrdiff_t	num_elements = end_offset - start_offset;
```

Why the type of "num_elements" is "ptrdiff_t"? Maybe just "size_t"?

3) tuplesort_sort_memtuples()

```
		/*
		 * Do we have the leading column's value or abbreviation in datum1?
		 */
		if (state->base.haveDatum1 && state->base.sortKeys)
		{
			SortSupportData ssup = state->base.sortKeys[0];
```

I think we should avoid the copy of SortSupportData. We can just use a pointer.

4)

Many places just consider "Datum" as integer, do we need to add a DatumGetUInt**() for them?

--
Regards,
ChangAo Chen

---

From: John Naylor <[email-masked]>
Date: Fri, 13 Feb 2026 09:32:29 +0700

>                 null_start = state->memtuples;

Sounds good.

> get them from "Tuplesortstate"?

The parameter list currently looks like a sort function. I'm not sure
why you think that's objectionable?

> Maybe "0 for MSB, 7 for LSB"? If level == 0, this function will return the Most Significant Byte.

Will fix, thanks.

> Why the type of "num_elements" is "ptrdiff_t"? Maybe just "size_t"?

That's from upstream. It's pretty rare in our codebase for in-core
code, so I'll go ahead and change it.

> I think we should avoid the copy of SortSupportData. We can just use a pointer.

Will do, for consistency.

> Many places just consider "Datum" as integer, do we need to add a DatumGetUInt**() for them?

It already does that for the case where it specifically needs uint32.
For the general case, I don't think so. We can treat a Datum as an
unsigned integer and don't care about the actual size or what it
contains. There is one exception that I can see: 0002 has a call to
pg_leftmost_one_pos64(), so a macro for that would be more appropriate
than an implicit cast.

Now I'm wondering if 0002 should normalize the datum as it determines
the common prefix. That should only matter for int32 where the input
has a mix of positive and negative, but it could be worth the
additional calculation. I'll hold off on committing 0002 for a bit
longer, but 0001 (plus the changes that I've agreed to) is still on
schedule.

Also, looking at commit 6aebedc38, it's preferred to use
"sizeof(Datum)" rather than the vestigial SIZEOF_DATUM, so I'll change
that in 0001/2.

--
John Naylor
Amazon Web Services

---

From: "cca5507" <[email-masked]>
Date: Fri, 13 Feb 2026 11:25:17 +0800

Hi John,

One additional comment:

```
	/* presorted check */
	for (SortTuple *st = data + 1; st < data + n; st++)
	{
		CHECK_FOR_INTERRUPTS();

		if (COMPARETUP(state, st - 1, st) > 0)
		{
			presorted = false;
			break;
		}
	}
	if (presorted)
		return;
```

I think we need to add a comment to explain why we do the
check. The cost of this check is not small.

--
Regards,
ChangAo Chen

---

From: John Naylor <[email-masked]>
Date: Sun, 15 Feb 2026 15:40:35 +0700

I've committed v7-0001 with the above review and a couple more
cosmetic adjustments. Most notably, I've never really liked the name
sort_byvalue_datum(). It really is the entry point to radix sort, and
it only diverts to qsort if after the NULL partitioning phase there
aren't enough non-null tuples to justify the overhead of radix sort.
So for better symmetry with qsort_tuple, I've renamed
sort_byvalue_datum() to radix_sort_tuple(), which then calls out to
now-named radix_sort_recursive(), which will call itself until it
completes or diverts. Another change down below:

> check. The cost of this check is not small.

(presorted check) The cost should only matter for pathological inputs,
and I haven't found it to matter much overall. I've left it
uncommented for now. However, while rebasing 0002 to deal with recent
review comments, I had an idea: In yesterday's commit, I moved the
presorted check down to just before invoking the actual radix sort.
That way, with the attached v8-0002 the common prefix detection is
done at the same time as the presorted check. That makes 0002 a
smaller patch and by doing both in the same for-loop it's easier to
read and can reduce the number of memory reads. We can consider more
commentary here, but the motivation do avoid unnecessary work should
be fairly obvious.

Next step : Test whether it's worth it for the common prefix detection
to use the normalized datum.

--
John Naylor
Amazon Web Services
From 14df5854de4b73da8792c015859e8d7ecf09d4d0 Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Tue, 10 Feb 2026 19:10:20 +0700
Subject: [PATCH v8 4/4] WIP Call verify_memtuples_sorted() after qsort for
 consistency

diff --git a/src/backend/utils/sort/tuplesort.c b/src/backend/utils/sort/tuplesort.c
index 629d598d8b7..dea3347667f 100644
--- a/src/backend/utils/sort/tuplesort.c
+++ b/src/backend/utils/sort/tuplesort.c
@@ -3033,6 +3033,7 @@ tuplesort_sort_memtuples(Tuplesortstate *state)
 						state->base.comparetup,
 						state);
 		}
+		verify_memtuples_sorted(state);
 	}
 }

From a7dfc1c9fa535dfceac48f8e91ad2cb516872855 Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Tue, 10 Feb 2026 20:02:19 +0700
Subject: [PATCH v8 3/4] WIP Add possible message changes

index 2203cfb725d..629d598d8b7 100644
 
 	if (trace_sort)

 	if (trace_sort)
 
-- 
2.53.0

From ab8ba71b018423779289c442f05a369e373dc41d Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Sat, 14 Feb 2026 11:41:34 +0700
Subject: [PATCH v8 2/4] Detect common prefix to avoid wasted work during radix
 sort

Reviewed-by: Chengpeng Yan <[email-masked]>
Discussion: https://postgr.es/m/[email-masked]
---
 src/backend/utils/sort/tuplesort.c | 48 ++++++++++++++++++++++++++----
 1 file changed, 43 insertions(+), 5 deletions(-)

index 1fc440ea6ca..2203cfb725d 100644
@@ -2909,28 +2910,65 @@ radix_sort_tuple(SortTuple *data, size_t n, Tuplesortstate *state)
+		Datum		ref_datum;
 		bool		presorted = true;
 
+		Assert(not_null_count > 0);
+		ref_datum = not_null_start[0].datum1;
+
+		/* compute the common prefix of all datums */
 		for (SortTuple *st = not_null_start + 1;
 			 st < not_null_start + not_null_count;
 			 st++)
 		{
-			if (COMPARETUP(state, st - 1, st) > 0)
-			{
+			Datum		this_datum = st->datum1;
+
+			/*
+			 * Accumulate bits that represent a difference from the reference
+			 * datum.
+			 */
+			common_upper_bits |= ref_datum ^ this_datum;
+
+			/* do a presorted check while we're at it */
+			if (presorted && COMPARETUP(state, st - 1, st) > 0)
 				presorted = false;
-				break;
-			}
 
 			CHECK_FOR_INTERRUPTS();
 		}
 
 		if (presorted)
 			return;
+		else if (common_upper_bits == 0)
+		{
+			/*
+			 * All NOT NULL tuples have the same datum, so we can skip radix
+			 * sort. Sort using the tiebreak comparator if necessary.
+			 */
+			if (state->base.onlyKey == NULL)
+			{
+				qsort_tuple(not_null_start,
+							not_null_count,
+							state->base.comparetup_tiebreak,
+							state);
+			}
+		}
 		else
 		{
+			int			diffpos;
+
+			/*
+			 * The upper bits of common_upper_bits are zero where all datums
+			 * have the same bits. The byte position of the leftmost one bit
+			 * is the byte where radix sort should start.
+			 */
+			diffpos = pg_leftmost_one_pos64(DatumGetUInt64(common_upper_bits));
+			common_prefix = sizeof(Datum) - 1 - (diffpos / BITS_PER_BYTE);
+
 			radix_sort_recursive(not_null_start,
 								 not_null_count,
-								 0,
+								 common_prefix,
 								 state);
 		}
 	}
-- 
2.53.0

---

From: John Naylor <[email-masked]>
Date: Mon, 30 Mar 2026 12:57:52 +0700

> to use the normalized datum.

This turned out to be a loser, but in the course of trying it a better
idea occurred to me. v8's prefix detection was really a special-case
optimization where the sort key is all non-negative integers (or all
negative, but that's not common). It's wasted work when the input is
mixed in sign, and for abbreviated keys. It's not much of a waste, but
we can do better.

v9 computes the common prefix during every recursion at the same time
we populate the SortTuple's current byte. That should be practically
free given a modest amount of instruction-level parallelism.

As a demo, I slightly modified the "p5" test (an adversarial case for
radix sort) from

https://www.postgresql.org/message-id/CANWCAZb0STJRAnn8fcVccQmektizNkgqBD_cg%2B0cs1%3Db4WWajQ%40mail.gmail.com

...so that all bytes differ only in the least significant byte or in
the sign bit.

select setseed(0.935349);
with r (rand, i) as (select random(-100, 100), i
from generate_series(1,1_000_000,1) i )
insert into tsort
select case
 when r.rand < -95 or r.rand > 95
 then r.rand
 else 0 end
 from r;

vacuum freeze tsort ;

cat bench.sql
select * from tsort order by i offset 100000000;

pgbench -n -t 500 -f bench.sql | grep latency
(on pre-warmed buffers with work_mem 64MB)

master:
latency average = 99.663 ms
v8:
latency average = 102.040 ms
v9:
latency average = 84.856 ms

Here we can see that common prefix detection is useless in v8. It adds
work, but it's not worse than noise in this benchmark, so could be
disregarded. In v9 we proceed directly to performing a radix sort pass
on the most significant byte, which effectively partitions into signed
and unsigned. The next recursion step detects that all bytes are the
same and simultaneously calculates how far to skip so that the next
recursion is productive.

For the case of all non-negative integers:

select setseed(0.935349);
insert into tsort
select random(0, 100)
from generate_series(1,1_000_000,1) i;
vacuum freeze tsort ;

...in v9 the first pass computes the current byte from the normalized
datum and stores it, but this pass is unproductive. There was a risk
that this is more expensive than v8's up-front common prefix detection
because of memory writes. I only see noise-level differences:

master:
latency average = 98.725 ms
v8:
latency average = 75.572 ms
v9:
latency average = 76.635 ms

I intend to commit v9-0001 this week.

I've decided not to commit v8-0003 (message changes) from my last
message since it doesn't really add any value IMO. That can always be
revisited. I'm on the fence about v8-0004 (assert the correct order
also for qsort), but it seems good for consistency. I'd like to at
least continue asserting this for radix sort since it's new this
cycle. To save CI cycles etc, maybe we can eventually hide the check
behind a debug symbol.

-- 
John Naylor
Amazon Web Services
From 6e94b0a260bc81248375c7d45568a52f17274bf9 Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Mon, 30 Mar 2026 12:26:58 +0700
Subject: [PATCH v9] Detect common prefixes during radix sort

During the counting step, keep track of the bits that are the same
for the entire input.  If we counted only a single distinct byte,
the next recursion will start at the next byte position that has
more than one distinct byte in the input. This allows us to skip over
multiple passes where the byte is the same for the entire input.

This provides a significant speedup for integers that have some upper
bytes with all-zeros or all-ones, which is common.

Reviewed-by: Chengpeng Yan <[email-masked]>
Discussion: https://postgr.es/m/[email-masked]
---
 src/backend/utils/sort/tuplesort.c | 44 +++++++++++++++++++++++++++---
 1 file changed, 40 insertions(+), 4 deletions(-)

diff --git a/src/backend/utils/sort/tuplesort.c b/src/backend/utils/sort/tuplesort.c
index 1fc440ea6ca..72c2c2995d8 100644
--- a/src/backend/utils/sort/tuplesort.c
+++ b/src/backend/utils/sort/tuplesort.c
@@ -104,6 +104,7 @@
 #include "commands/tablespace.h"
 #include "miscadmin.h"
 #include "pg_trace.h"
+#include "port/pg_bitutils.h"
 #include "storage/shmem.h"
 #include "utils/guc.h"
 #include "utils/memutils.h"
@@ -2659,17 +2660,25 @@ radix_sort_recursive(SortTuple *begin, size_t n_elems, int level, Tuplesortstate
 	int			num_partitions = 0;
 	int			num_remaining;
 	SortSupport ssup = &state->base.sortKeys[0];
+	Datum		ref_datum;
+	Datum		common_upper_bits = 0;
 	size_t		start_offset = 0;
 	SortTuple  *partition_begin = begin;
+	int			next_level;
 
 	/* count number of occurrences of each byte */
+	ref_datum = normalize_datum(begin[0].datum1, ssup);
 	for (SortTuple *st = begin; st < begin + n_elems; st++)
 	{
+		Datum		this_datum;
 		uint8		this_partition;
 
+		this_datum = normalize_datum(st->datum1, ssup);
+		/* accumulate bits different from the reference datum */
+		common_upper_bits |= ref_datum ^ this_datum;
+
 		/* extract the byte for this level from the normalized datum */
-		this_partition = current_byte(normalize_datum(st->datum1, ssup),
-									  level);
+		this_partition = current_byte(this_datum, level);
 
 		/* save it for the permutation step */
 		st->curbyte = this_partition;
@@ -2747,6 +2756,33 @@ radix_sort_recursive(SortTuple *begin, size_t n_elems, int level, Tuplesortstate
 	}
 
 	/* recurse */
+
+	if (num_partitions == 1)
+	{
+		/*
+		 * There is only one distinct byte at the current level. It can happen
+		 * that some subsequent bytes are also the same for all input values,
+		 * such as the upper bytes of small integers. To skip unproductive
+		 * passes for that case, we compute the level where the input has more
+		 * than one distinct byte, so that the next recursion can start there.
+		 */
+		if (common_upper_bits == 0)
+			next_level = sizeof(Datum);
+		else
+		{
+			int			diffpos;
+
+			/*
+			 * The upper bits of common_upper_bits are zero where all datums
+			 * have the same bits.
+			 */
+			diffpos = pg_leftmost_one_pos64(DatumGetUInt64(common_upper_bits));
+			next_level = sizeof(Datum) - 1 - (diffpos / BITS_PER_BYTE);
+		}
+	}
+	else
+		next_level = level + 1;
+
 	for (uint8 *rp = remaining_partitions;
 		 rp < remaining_partitions + num_partitions;
 		 rp++)
@@ -2757,7 +2793,7 @@ radix_sort_recursive(SortTuple *begin, size_t n_elems, int level, Tuplesortstate
 
 		if (num_elements > 1)
 		{
-			if (level < sizeof(Datum) - 1)
+			if (next_level < sizeof(Datum))
 			{
 				if (num_elements < QSORT_THRESHOLD)
 				{
@@ -2770,7 +2806,7 @@ radix_sort_recursive(SortTuple *begin, size_t n_elems, int level, Tuplesortstate
 				{
 					radix_sort_recursive(partition_begin,
 										 num_elements,
-										 level + 1,
+										 next_level,
 										 state);
 				}
 			}
-- 
2.53.0

---

From: "cca5507" <[email-masked]>
Date: Tue, 31 Mar 2026 12:36:32 +0800

Hi John,

> free given a modest amount of instruction-level parallelism.

+1 and v9-0001 LGTM.

--
Regards,
ChangAo Chen

---

From: John Naylor <[email-masked]>
Date: Wed, 1 Apr 2026 14:20:47 +0700

> > free given a modest amount of instruction-level parallelism.

Pushed, thanks for looking!

-- 
John Naylor
Amazon Web Services

---

From: "cca5507" <[email-masked]>
Date: Wed, 1 Apr 2026 17:27:13 +0800

Hi John,

How about adding an assertion here:

```
diff --git a/src/backend/utils/sort/tuplesort.c b/src/backend/utils/sort/tuplesort.c
index 72c2c2995d8..bcc534136d7 100644
--- a/src/backend/utils/sort/tuplesort.c
+++ b/src/backend/utils/sort/tuplesort.c
@@ -2783,6 +2783,8 @@ radix_sort_recursive(SortTuple *begin, size_t n_elems, int level, Tuplesortstate
        else
                next_level = level + 1;
 
+       Assert(next_level > level);
+
        for (uint8 *rp = remaining_partitions;
                 rp < remaining_partitions + num_partitions;
                 rp++)
```

It can help us catch some unexpected behaviors.

--
Regards,
ChangAo Chen

---

From: John Naylor <[email-masked]>
Date: Wed, 8 Apr 2026 14:17:22 +0700

Good idea.

I also thought we should change this cast:

        if (ssup->comparator == ssup_datum_signed_cmp)
        {
-               norm_datum1 = orig + ((uint64) PG_INT64_MAX) + 1;
+               norm_datum1 = orig + (Int64GetDatum(PG_INT64_MAX)) + 1;
        }

Upthread you mention something else about treating Datum as an
integer, but I'm not sure if this is what you meant since you didn't
say. If you have concrete suggestions, feel free to share them.

--
John Naylor
Amazon Web Services

---

From: "cca5507" <[email-masked]>
Date: Wed, 8 Apr 2026 16:20:47 +0800

> say. If you have concrete suggestions, feel free to share them.

I think we can keep it as is now. The idea of don't treat datum as an integer comes from:

https://www.postgresql.org/message-id/flat/8246d7ff-f4b7-4363-913e-827dadfeb145%40eisentraut.org

--
Regards,
ChangAo Chen

---

From: John Naylor <[email-masked]>
Date: Wed, 29 Apr 2026 16:24:50 +0700

On Wed, Apr 8, 2026 at 3:20 PM cca5507 <[email-masked]> wrote:
> I think we can keep it as is now.

It's harmless, but see commit ff89e182d.

I included the assert along with the above and some small cosmetic
fixes I've been saving.

--
John Naylor
Amazon Web Services