Skip to content

[improvement](load) sort memtable with the vectorized ColumnSorter - #66588

Draft
liaoxin01 wants to merge 2 commits into
apache:masterfrom
liaoxin01:memtable-vectorized-sort
Draft

[improvement](load) sort memtable with the vectorized ColumnSorter#66588
liaoxin01 wants to merge 2 commits into
apache:masterfrom
liaoxin01:memtable-vectorized-sort

Conversation

@liaoxin01

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Stacked on #66545. Until that one merges, the diff here shows its commit as
well. The change this PR is about is the second commit,
[improvement](load) sort memtable with the vectorized ColumnSorter.

Problem Summary:

MemTable::_sort() ran its own multi-key sort: pdqsort over the row array,
with the comparator passed as a std::function that called the virtual
IColumn::compare_at once per comparison. Every comparison therefore paid an
indirect call, a virtual dispatch and two random accesses into the block before
it could even look at the key.

The query engine already has the sort this needs. ColumnSorter
(be/src/exec/sort/sort_block.h) implements the same sort-and-tie algorithm --
sort by a key column, mark equal ranges, refine within them using the next key
column -- but keeps an inline copy of the key next to the row id, so a comparison
becomes a typed, inlinable operation on a compact array instead of a chain of
random accesses.

This PR makes _sort() and _sort_by_cluster_keys() build an
IColumn::Permutation and run it through ColumnSorter. _sort_one_column()
and class Tie have no users left and are removed. _sort_by_cluster_keys()
no longer needs a row object per row either: the LSN sidecar it was carrying is
already indexed by row position, so the permutation can reorder it directly.

Measured with a micro-benchmark on 7.05M rows, one key column, Release build:

key type current ColumnSorter speedup
int32 13.2 s 0.96 s 13.7x
int64 13.3 s 1.07 s 12.4x
decimal128(20,2) 13.7 s 1.28 s 10.7x
varchar ~4B 19.6 s 3.03 s 6.5x
varchar ~40B 21.9 s 3.75 s 5.8x
nullable int32 16.0 s 1.04 s 15.5x
nullable varchar ~4B 25.4 s 2.81 s 9.0x

Fixed-width keys gain the most because their inline value is the value itself;
string keys still dereference the arena for the memcmp, so the longer the key
the smaller the gain. No key type regressed.

The motivating case was a load whose profile showed MemTableSortTime at 83 s
across 7 memtables of a single tablet, with the sink blocked in
WaitFlushLimitTime for 45 s behind it.

Memory

Keeping a copy of the key next to each row id costs memory in proportion to the
key width: 8 B/row for INT32, 16 B for INT64, 24 B for a StringRef, 32 B
for Decimal128 after alignment. It is a std::vector local to
ColumnSorter::_sort_by_inline_permutation, so it is released between key
columns and the peak holds one of them, plus 8 B/row for the permutation and
1 B/row for the equal flags.

That is why #66545 comes first. Against the 24 bytes per row the memtable spends
on the rows themselves after that change, the peak of the two together is below
what the previous sort needed, for every key type:

key type before both PRs after both
int32 592 MB 288 MB
int64 592 MB 345 MB
varchar 592 MB 401 MB
decimal128(20,2) 592 MB 458 MB

Merging this one without #66545 would instead take the peak up, to 683 MB for
an int32 key and 853 MB for a decimal128 one, which is why they are ordered.

Tie-break

Ordering is byte-for-byte identical to before, tie-break included: rows whose
whole key is equal are stabilised on descending row position for DUP_KEYS
and on ascending row position for everything else, exactly as the previous
is_dup ? lhs->_row_pos > rhs->_row_pos : lhs->_row_pos < rhs->_row_pos did.

That DUP_KEYS direction has no semantics behind it -- it reproduces the
iteration order of the skip list MemTable used before #18686, where
SkipList::Insert linked a new node ahead of the existing equal keys, so
iterating yielded equal keys in reverse insertion order. The skip list is long
gone and #18686, #19099 and #20392 each carried the ternary along without it
meaning anything.

Dropping it is nevertheless not free: a run of P0 with the tie-break normalised
to ascending fails 39 cases, and only about half of those are a missing
ORDER BY. In the rest the content changes rather than the order -- which row
wins in a UNIQUE table fed by an unordered select, which element survives
collect_set(k, 1), the element order inside array_agg, the auto-increment
id-to-row mapping, first_value over a window whose ORDER BY has ties. Those
can only be "fixed" by rewriting the expected output. So the direction is kept
here, and removing it is left as its own change.

Release note

None

Check List (For Author)

  • Test
    • Unit Test
    • Regression test
    • Manual test (add detailed scripts or steps below)
    • No need to test or manual test. Explain why:
      • This is a refactor/code format and no logic has been changed.
      • Previous test can cover this change.
      • No code files have been changed.
      • Other reason

The MemTable-driven cases in be/test/load/memtable/memtable_sort_test.cpp
come from #66545 and cover the ordering this change has to preserve. This PR
adds one more, for a permutation that is a single long cycle rather than the
short swaps the other cases happen to produce, since the permutation is applied
to the row array in place by following its cycles. The tie-break case was
verified to fail when the direction is flipped, so it does discriminate.

  • Behavior changed:

    • No.
    • Yes.
  • Does this need documentation?

    • No.

MemTable keeps one RowInBlock per loaded row in _row_in_blocks, held as a
shared_ptr, so there is one make_shared per row. Measured at 80 bytes per
row: 16 for the pointer in the vector, plus a 64 byte heap chunk holding
the 16 byte control block and the 40 byte struct.

Two of the struct's five fields do not need to be there.
_agg_state_offset held _offsets_of_aggregate_states.data(), the same
pointer for every row, so it belongs on the MemTable. _has_init_agg said
exactly what _agg_mem being non-null already says. That leaves 24 bytes,
small enough to keep in the vector directly and drop the per-row
allocation with it.

For a 7.05M row memtable that is 564 MB down to 169 MB, and building the
array drops from 401 ms to 111 ms.

Dropping the shared_ptr means the rows in _row_in_blocks and the copies
_aggregate() works on are no longer the same object. Two places relied on
that aliasing:

  - prev_row now points into temp_row_in_blocks, which is where
    _finalize_one_row() will read it from.
  - _aggregate() adopts temp_row_in_blocks unconditionally, so the
    entries left behind cannot name an aggregate state that
    _finalize_one_row() has already released. Without this, a memtable
    that aggregates across a shrink_memtable_by_agg() round and then
    again in to_block() destroys those states twice -- confirmed by
    instrumenting ~MemTable.

memtable_sort_test.cpp only covered class Tie. It now also drives a
MemTable through insert()/to_block() and covers multi-column and nullable
key ordering, the DUP_KEYS tie-break direction, batching independence,
UNIQUE_KEYS last-writer-wins, AGG_KEYS aggregation, and aggregate state
surviving shrink_memtable_by_agg() rounds. The AGG_KEYS schema carries a
BITMAP BITMAP_UNION column so those last cases run over an aggregate
state that owns heap memory rather than a trivially destroyed one.
MemTable::_sort() ran its own multi-key sort: pdqsort over the row array,
with the comparator passed as a std::function that called the virtual
IColumn::compare_at once per comparison. Every comparison paid an
indirect call, a virtual dispatch and two random accesses into the block
before it could look at the key.

The query engine already has the sort this needs. ColumnSorter
(be/src/exec/sort/sort_block.h) implements the same sort-and-tie
algorithm, but keeps an inline copy of the key next to the row id, so a
comparison is a typed, inlinable operation on a compact array.
_sort() and _sort_by_cluster_keys() now build an IColumn::Permutation and
run it through ColumnSorter; _sort_one_column() and class Tie have no
users left and are removed. _sort_by_cluster_keys() no longer needs a row
object per row either -- the LSN sidecar it was carrying is already
indexed by row position, so the permutation can reorder it directly.

Measured on this data shape (7.05M rows, one key column, Release build):

    key type                current    ColumnSorter    speedup
    int32                   13.2 s     0.96 s          13.7x
    int64                   13.3 s     1.07 s          12.4x
    decimal128(20,2)        13.7 s     1.28 s          10.7x
    varchar ~4B             19.6 s     3.03 s           6.5x
    varchar ~40B            21.9 s     3.75 s           5.8x
    nullable int32          16.0 s     1.04 s          15.5x
    nullable varchar ~4B    25.4 s     2.81 s           9.0x

Fixed-width keys gain the most because their inline value is the value
itself; string keys still dereference the arena for the memcmp, so the
longer the key the smaller the gain.

The inline permutation costs memory in proportion to the key width: 8
bytes per row for INT32, 16 for INT64, 24 for a StringRef, 32 for
Decimal128 after alignment. It is a std::vector local to
ColumnSorter::_sort_by_inline_permutation, so it is released between key
columns and the peak holds one of them, plus 8 bytes per row for the
permutation and 1 for the equal flags. Against the 24 bytes per row the
memtable now spends on the rows themselves, the peak of the two together
still comes out below what the previous sort needed.

Ordering is unchanged, tie-break included: rows whose whole key is equal
are still stabilised on descending row position for DUP_KEYS and on
ascending row position for everything else.
@liaoxin01
liaoxin01 requested a review from gavinchou as a code owner August 7, 2026 16:48
Copilot AI lite review requested due to automatic review settings August 7, 2026 16:48
@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@liaoxin01

Copy link
Copy Markdown
Contributor Author

run buildall

@liaoxin01
liaoxin01 marked this pull request as draft August 7, 2026 16:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves BE load-path MemTable sorting performance by replacing the memtable’s bespoke multi-key pdqsort + virtual IColumn::compare_at comparator with the vectorized query-engine ColumnSorter, and updates MemTable’s row bookkeeping to avoid per-row heap allocations. It also significantly expands unit tests to validate ordering, tie-break behavior, and aggregation correctness across shrink rounds.

Changes:

  • Switch MemTable::_sort() / _sort_by_cluster_keys() to build and sort an IColumn::Permutation via ColumnSorter, then apply it in-place (cycle-following) to reorder rows/sidecars.
  • Store RowInBlock by value (instead of shared_ptr) and remove the now-unused Tie / _sort_one_column() machinery.
  • Replace the previous minimal Tie test with MemTable-driven tests covering multi-key ordering, DUP/UNIQUE/AGG semantics, NULL ordering, batching independence, and a long-cycle permutation case.

Review Checkpoints (skill Part 1.3)

  • Goal & proof: The stated goal (faster memtable sort while preserving byte-identical ordering/tie-break) is implemented; unit tests in be/test/load/memtable/memtable_sort_test.cpp provide targeted coverage for the ordering invariants.
  • Change scope: Changes are focused on memtable sorting/row bookkeeping and the associated unit tests.
  • Concurrency: No new concurrency primitives/locks introduced in the diffs reviewed; changes appear confined to memtable’s single-threaded build/sort/aggregate lifecycle.
  • Lifecycle/memory safety: Row aggregation state ownership changes (by-value rows) are handled by adopting finalized rows back into _row_in_blocks to avoid double-destroy; however, the new ColumnSorter path introduces index-size constraints that should be asserted (see stored comment).
  • Configs/compatibility/observability: No new configuration knobs, persistence formats, or protocol-visible changes identified in these diffs.
  • Test coverage: BE unit tests were expanded meaningfully for the touched behavior; no regression tests added in this PR.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
be/test/load/memtable/memtable_sort_test.cpp Replaces Tie-only test with MemTable end-to-end tests validating ordering/tie-break, batching, and aggregation/shrink behavior.
be/src/load/memtable/memtable.h Stores RowInBlock by value, removes Tie/_sort_one_column, adjusts aggregation APIs to refs, and adds permutation-sort helper declaration.
be/src/load/memtable/memtable.cpp Implements permutation-based sorting via ColumnSorter, applies permutation in-place, updates aggregation paths for by-value rows, and simplifies cluster-key sorting (incl. LSN sidecar reorder).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +403 to +409
const size_t num_rows = perm.size();
if (num_rows == 0) {
return 0;
}
EqualFlags flags(num_rows, 1);
EqualRange range {0, static_cast<int>(num_rows)};
HybridSorter hybrid_sorter;
@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 28841 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit 89e908d722a2936e67f81417dfad378e5bc32952, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17635	3858	3962	3858
q2	2015	320	200	200
q3	10305	1368	803	803
q4	4684	468	335	335
q5	7468	835	552	552
q6	174	171	136	136
q7	737	784	598	598
q8	9368	1494	1600	1494
q9	5763	4025	4013	4013
q10	6720	1591	1347	1347
q11	515	344	315	315
q12	724	566	455	455
q13	18125	3270	2767	2767
q14	261	258	251	251
q15	q16	728	724	658	658
q17	966	1027	1052	1027
q18	6524	5613	5579	5579
q19	1183	1197	1094	1094
q20	764	669	626	626
q21	5580	2558	2430	2430
q22	429	352	303	303
Total cold run time: 100668 ms
Total hot run time: 28841 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	4330	4181	4173	4173
q2	272	334	204	204
q3	4505	4894	4314	4314
q4	2152	2220	1426	1426
q5	4239	4104	4110	4104
q6	231	177	132	132
q7	1705	1581	1414	1414
q8	2451	2128	2175	2128
q9	7277	7223	7211	7211
q10	4283	4287	3914	3914
q11	540	435	368	368
q12	704	724	514	514
q13	3136	3501	3142	3142
q14	292	309	282	282
q15	q16	713	717	631	631
q17	1350	1294	1275	1275
q18	12221	11047	11827	11047
q19	1149	1182	1207	1182
q20	2227	2216	1914	1914
q21	5655	4906	4568	4568
q22	551	483	412	412
Total cold run time: 59983 ms
Total hot run time: 54355 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 158531 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit 89e908d722a2936e67f81417dfad378e5bc32952, data reload: false

query5	4339	588	451	451
query6	487	230	205	205
query7	5015	600	344	344
query8	327	164	157	157
query9	8746	4076	4044	4044
query10	500	362	285	285
query11	5798	2173	2023	2023
query12	151	109	104	104
query13	1236	633	428	428
query14	6094	4405	4117	4117
query14_1	3918	3927	3847	3847
query15	203	196	177	177
query16	990	477	441	441
query17	939	711	588	588
query18	2429	484	335	335
query19	236	193	178	178
query20	103	99	104	99
query21	241	157	135	135
query22	13141	13033	12791	12791
query23	15698	15035	14585	14585
query23_1	14651	14635	14803	14635
query24	7568	1689	1201	1201
query24_1	1262	1217	1225	1217
query25	531	415	354	354
query26	1306	340	211	211
query27	2607	589	387	387
query28	4579	2007	2043	2007
query29	1046	614	460	460
query30	358	267	224	224
query31	1170	1108	1039	1039
query32	123	62	57	57
query33	515	297	253	253
query34	1168	1175	654	654
query35	732	744	645	645
query36	768	771	697	697
query37	157	114	93	93
query38	1841	1784	1671	1671
query39	835	819	783	783
query39_1	793	813	800	800
query40	282	177	148	148
query41	66	65	63	63
query42	98	91	91	91
query43	333	327	282	282
query44	1438	772	786	772
query45	186	170	177	170
query46	1120	1234	729	729
query47	1567	1593	1469	1469
query48	415	426	287	287
query49	587	409	297	297
query50	1092	442	356	356
query51	10761	10667	10287	10287
query52	87	88	75	75
query53	262	294	205	205
query54	292	255	221	221
query55	77	72	67	67
query56	304	310	310	310
query57	1009	990	911	911
query58	295	274	256	256
query59	1566	1653	1418	1418
query60	319	275	251	251
query61	148	148	148	148
query62	418	319	264	264
query63	243	200	205	200
query64	2901	1144	1017	1017
query65	3905	3867	3875	3867
query66	1883	494	376	376
query67	20042	20079	19982	19982
query68	3181	1595	1031	1031
query69	415	307	276	276
query70	868	810	813	810
query71	402	346	311	311
query72	3205	2810	2231	2231
query73	889	800	443	443
query74	4647	4488	4274	4274
query75	2371	2330	1994	1994
query76	2298	1127	768	768
query77	354	374	286	286
query78	11178	11061	10556	10556
query79	1405	1160	769	769
query80	1250	553	460	460
query81	553	325	283	283
query82	654	176	141	141
query83	381	351	303	303
query84	319	161	138	138
query85	996	604	570	570
query86	441	235	220	220
query87	1967	1969	1814	1814
query88	3763	2820	2807	2807
query89	405	326	281	281
query90	1892	208	186	186
query91	205	191	161	161
query92	63	62	58	58
query93	1746	1541	1076	1076
query94	721	361	298	298
query95	820	584	487	487
query96	1114	805	343	343
query97	2436	2440	2361	2361
query98	198	189	180	180
query99	750	735	611	611
Total cold run time: 246323 ms
Total hot run time: 158531 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 24.01 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit 89e908d722a2936e67f81417dfad378e5bc32952, data reload: false

query1	0.00	0.00	0.00
query2	0.09	0.04	0.05
query3	0.25	0.13	0.13
query4	1.60	0.14	0.14
query5	0.24	0.22	0.22
query6	1.16	0.85	0.84
query7	0.05	0.01	0.01
query8	0.06	0.04	0.04
query9	0.38	0.32	0.33
query10	0.55	0.56	0.55
query11	0.20	0.13	0.14
query12	0.19	0.14	0.15
query13	0.46	0.47	0.48
query14	1.00	0.99	0.98
query15	0.60	0.58	0.58
query16	0.32	0.32	0.31
query17	1.10	1.12	1.08
query18	0.20	0.20	0.20
query19	2.07	2.00	1.95
query20	0.02	0.02	0.01
query21	15.42	0.22	0.13
query22	4.85	0.05	0.05
query23	16.14	0.29	0.12
query24	2.94	0.40	0.33
query25	0.10	0.05	0.04
query26	0.72	0.21	0.14
query27	0.05	0.03	0.04
query28	3.52	0.78	0.35
query29	12.46	4.08	3.22
query30	0.28	0.16	0.16
query31	2.77	0.58	0.32
query32	3.22	0.58	0.48
query33	3.28	3.23	3.22
query34	15.64	3.94	3.29
query35	3.22	3.22	3.19
query36	0.55	0.44	0.44
query37	0.09	0.06	0.06
query38	0.05	0.04	0.04
query39	0.04	0.03	0.03
query40	0.17	0.15	0.14
query41	0.08	0.03	0.03
query42	0.04	0.02	0.03
query43	0.04	0.04	0.04
Total cold run time: 96.21 s
Total hot run time: 24.01 s

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 95.79% (182/190) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 75.99% (32324/42536)
Line Coverage 60.83% (362472/595903)
Region Coverage 57.45% (304643/530249)
Branch Coverage 58.94% (137877/233938)

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants