Thread
Commits
GET /api/v1/messages/:b64id/commits
the thread's linked commits as JSON, with link sources.
API reference →
-
Skip common prefixes during radix sort
- f6bd9f0fe25a 19 (unreleased) landed
-
Perform radix sort on SortTuples with pass-by-value Datums
- ef3c3cf6d021 19 (unreleased) landed
-
tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-10-29T06:28:21Z
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 -
Re: tuple radix sort
Chao Li <li.evan.chao@gmail.com> — 2025-10-29T08:25:03Z
> On Oct 29, 2025, at 14:28, John Naylor <johncnaylorls@gmail.com> 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/
-
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-10-29T11:29:35Z
On Wed, Oct 29, 2025 at 3:25 PM Chao Li <li.evan.chao@gmail.com> wrote: > > On Oct 29, 2025, at 14:28, John Naylor <johncnaylorls@gmail.com> 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 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=# \o /dev/null > 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; > evantest=# set wip_radix_sort = 'off'; > Time: 0.904 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. 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
-
Re: tuple radix sort
Chao Li <li.evan.chao@gmail.com> — 2025-10-30T01:56:06Z
> On Oct 29, 2025, at 19:29, John Naylor <johncnaylorls@gmail.com> wrote: > > On Wed, Oct 29, 2025 at 3:25 PM Chao Li <li.evan.chao@gmail.com> wrote: >>> On Oct 29, 2025, at 14:28, John Naylor <johncnaylorls@gmail.com> 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 > > Thanks for testing. Note it's actually 5 because of rounding. Yes, 0-4, totally 5. > 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 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. > 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 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, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/
-
Re: tuple radix sort
Chao Li <li.evan.chao@gmail.com> — 2025-10-30T02:18:32Z
> On Oct 29, 2025, at 14:28, John Naylor <johncnaylorls@gmail.com> wrote: > > <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, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/
-
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-10-30T03:40:44Z
On Thu, Oct 30, 2025 at 8:56 AM Chao Li <li.evan.chao@gmail.com> wrote: > I changed work_men to 1GB and reran the test. As the high cardinality data are still there, so I first reran with data: > 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. On Thu, Oct 30, 2025 at 9:19 AM Chao Li <li.evan.chao@gmail.com> wrote: > 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 “==" 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
-
Re: tuple radix sort
Chao Li <li.evan.chao@gmail.com> — 2025-10-30T03:45:32Z
> On Oct 30, 2025, at 11:40, John Naylor <johncnaylorls@gmail.com> wrote: > > On Thu, Oct 30, 2025 at 8:56 AM Chao Li <li.evan.chao@gmail.com> wrote: > >> I changed work_men to 1GB and reran the test. As the high cardinality data are still there, so I first reran with data: > >> 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. > Yes, assert is always enabled in my sandbox. I can disable assert and rerun the test later. Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/
-
Re: tuple radix sort
David Rowley <dgrowleyml@gmail.com> — 2025-10-30T05:01:07Z
On Thu, 30 Oct 2025 at 16:46, Chao Li <li.evan.chao@gmail.com> wrote: > > On Oct 30, 2025, at 11:40, John Naylor <johncnaylorls@gmail.com> wrote: > > 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. > > 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
-
Re: tuple radix sort
Chao Li <li.evan.chao@gmail.com> — 2025-10-30T05:43:34Z
> On Oct 30, 2025, at 13:01, David Rowley <dgrowleyml@gmail.com> wrote: > > On Thu, 30 Oct 2025 at 16:46, Chao Li <li.evan.chao@gmail.com> wrote: >>> On Oct 30, 2025, at 11:40, John Naylor <johncnaylorls@gmail.com> wrote: >>> 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. >> >> 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. > 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, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-11-03T13:24:35Z
I wrote: > 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. 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
-
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-11-04T11:27:08Z
I wrote: > 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. 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
-
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-11-12T13:57:49Z
On Mon, Nov 3, 2025 at 8:24 PM I wrote: > 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 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. > - 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 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
-
Re: tuple radix sort
David Geier <geidav.pg@gmail.com> — 2025-11-12T14:28:37Z
On 29.10.2025 07:28, John Naylor wrote: > > 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: 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
-
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-11-13T04:01:18Z
On Wed, Nov 12, 2025 at 9:28 PM David Geier <geidav.pg@gmail.com> wrote: > 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(). 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. > 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. 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
-
Re: tuple radix sort
David Geier <geidav.pg@gmail.com> — 2025-11-14T18:04:57Z
Hi John! On 13.11.2025 05:01, John Naylor wrote: > 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. 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. > > 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. 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
-
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-11-15T02:47:11Z
On Sat, Nov 15, 2025 at 1:05 AM David Geier <geidav.pg@gmail.com> wrote: > 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. 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 On Sat, Nov 15, 2025 at 1:05 AM David Geier <geidav.pg@gmail.com> wrote: > > Hi John! > > On 13.11.2025 05:01, John Naylor wrote: > > 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. > > 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. > > > > > 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. > > 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 > -- John Naylor Amazon Web Services
-
Re: tuple radix sort
David Geier <geidav.pg@gmail.com> — 2025-11-17T15:38:43Z
Hi John! On 15.11.2025 03:47, John Naylor wrote: > On Sat, Nov 15, 2025 at 1:05 AM David Geier <geidav.pg@gmail.com> wrote: >> 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. > > 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. > 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
-
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-11-20T10:57:25Z
I wrote: > 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. 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
-
Re: tuple radix sort
Álvaro Herrera <alvherre@kurilemu.de> — 2025-11-20T11:13:08Z
On 2025-Nov-12, John Naylor wrote: > +/* > + * 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) 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/
-
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-11-20T11:19:36Z
On Thu, Nov 20, 2025 at 6:13 PM Álvaro Herrera <alvherre@kurilemu.de> wrote: > 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, 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
-
Re: tuple radix sort
Chengpeng Yan <chengpeng_yan@outlook.com> — 2025-11-23T05:33:44Z
> On Nov 12, 2025, at 21:57, John Naylor <johncnaylorls@gmail.com> wrote: > > 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. > […] > > 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. > 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 -
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-11-26T06:52:42Z
On Sun, Nov 23, 2025 at 12:33 PM Chengpeng Yan <chengpeng_yan@outlook.com> wrote: > > > On Nov 12, 2025, at 21:57, John Naylor <johncnaylorls@gmail.com> wrote: > 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. Thanks for taking a look! > 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. I wondered about that and then forgot about it. Thanks for bringing that up, I'll look into it. > 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; > ``` Good idea. > 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. 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. > 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. 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.) > 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. 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". > Benefits: > > 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 > counting phase. 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. > 3. Reduced instruction count: We avoid the overhead of full > 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 > pursuing. 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
-
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-11-26T06:55:30Z
On Wed, Nov 26, 2025 at 1:52 PM John Naylor <johncnaylorls@gmail.com> wrote: > 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". 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
-
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-11-26T13:11:52Z
On Thu, Nov 20, 2025 at 6:13 PM Álvaro Herrera <alvherre@kurilemu.de> wrote: > 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, Done. On Sun, Nov 23, 2025 at 12:33 PM Chengpeng Yan <chengpeng_yan@outlook.com> wrote: > ``` > 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) > ``` Done, and removed the local copy since it wasn't doing much else. > 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; > ``` Done. > 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. Added a short-circuit. For v5 I've also added CHECK_FOR_INTERRUPTS and rewrote some comments. -- John Naylor Amazon Web Services
-
Re: tuple radix sort
Chao Li <li.evan.chao@gmail.com> — 2025-11-27T07:48:58Z
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 <johncnaylorls@gmail.com> wrote: > > > For v5 I've also added CHECK_FOR_INTERRUPTS and rewrote some comments. > > > -- > John Naylor > Amazon Web Services > <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 ``` + /* 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; ``` 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, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-11-27T11:45:32Z
On Thu, Nov 27, 2025 at 2:49 PM Chao Li <li.evan.chao@gmail.com> wrote: > 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! > > On Nov 26, 2025, at 21:11, John Naylor <johncnaylorls@gmail.com> 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> > 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. 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. > 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. 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. > 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: 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 -
Re: tuple radix sort
Chao Li <li.evan.chao@gmail.com> — 2025-12-03T08:22:13Z
Hi John, I played with this again today and found an optimization that seems to dramatically improve the performance: ``` +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}; ``` 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: ``` evantest=# select * from test_multi order by category, name; Time: 152.572 ms evantest=# select * from test_multi order by category, name; Time: 143.296 ms evantest=# select * from test_multi order by category, name; 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, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-12-04T05:30:52Z
On Wed, Dec 3, 2025 at 3:22 PM Chao Li <li.evan.chao@gmail.com> wrote: > I played with this again today and found an optimization that seems to dramatically improve the performance: > > ``` > +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}; > ``` > > 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. 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 -
Re: tuple radix sort
Chao Li <li.evan.chao@gmail.com> — 2025-12-04T08:40:18Z
> On Dec 4, 2025, at 13:30, John Naylor <johncnaylorls@gmail.com> wrote: > > On Wed, Dec 3, 2025 at 3:22 PM Chao Li <li.evan.chao@gmail.com> wrote: >> I played with this again today and found an optimization that seems to dramatically improve the performance: >> >> ``` >> +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}; >> ``` >> >> 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. > > 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. > 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, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/ -
Re: tuple radix sort
Chao Li <li.evan.chao@gmail.com> — 2025-12-08T02:52:02Z
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, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/
-
Re: tuple radix sort
John Naylor <johncnaylorls@gmail.com> — 2025-12-08T06:26:38Z
On Mon, Dec 8, 2025 at 9:52 AM Chao Li <li.evan.chao@gmail.com> wrote: > 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. 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
-
Re: tuple radix sort
Chao Li <li.evan.chao@gmail.com> — 2025-12-08T06:31:07Z
> On Dec 8, 2025, at 14:26, John Naylor <johncnaylorls@gmail.com> wrote: > > 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. > Fair point. I wasn’t aware you have created a CF entry. Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/