土法炼钢 · 系统与基础设施

排序基准测试:比较次数、分支预测与输入分布

文章导航

分类入口
algorithms
标签入口
#sorting#benchmark#introsort#pdqsort#timsort#radix-sort#branch-prediction#libstdc++
继续阅读
返回排序专题

排序专题导航

把 TimSort、pdqsort、radix sort、external sort、parallel sort 和基准测试串成一条阅读顺序。

专题页上一篇:并行排序:排序网络、并行归并、样本排序与 GPU 基数排序

目录

“哪个排序最快”通常有两种回答。一种来自教科书:比较排序的下界是 \(\Omega(n\log n)\),比较次数越少越好。另一种来自博客里的 benchmark 表格:某个算法在某台机器上排第一。两种回答都经不起推敲。对 int32_t 来说,一次比较只要一条指令,决定耗时的往往是比较结果能不能被分支预测器猜中;而一张没有交代输入分布、编译参数和噪声的表格,换一台机器、换一种输入,排名就可能倒过来。

本文用同一套程序回答三个问题:

  1. 在 8 种输入分布上,各算法实际做了多少次比较?这个指标与时钟无关,可以精确复现。
  2. 比较次数与实际耗时的关系有多紧?
  3. 写排序 benchmark 时,哪些做法会系统性地得出错误数字?

所有数字都来自同目录下的 reproduce/:sort_bench.cpp 负责计数与计时,introsort_probe.cpp 探查 libstdc++ 的深度限制,run.sh 一键重跑,summarize.py 汇总,plot_figures.py 画图。本文不讨论多线程排序(见上一篇并行排序),也不讨论字符串、大对象或外部排序。

一、测什么、怎么测

环境

项目 值
CPU 12th Gen Intel Core i9-12900K,WSL2 虚拟机内可见 24 个逻辑 CPU(12 核 × 2 线程)
缓存(lscpu) L1d 48 KiB/核,L2 1.25 MiB/核,L3 30 MiB 共享
内存 WSL2 内可见 31 GiB(free -g)
内核 6.6.87.2-microsoft-standard-WSL2
编译器与库 GCC 16.1.1(libstdc++ 同版本),glibc 2.43
编译参数 -std=c++20 -O2 -Wall -Wextra,不加 -march=native
绑核 taskset -c 12;逻辑 CPU 12 与 13 是同一物理核的两个超线程

计时期间机器上还有十几个其他任务在跑,CPU 13 也分配给了其他任务;它一旦忙起来,就会与本实验共享同一个物理核的执行单元和 L1/L2。WSL2 里也无法关闭睿频或固定频率。所以计时结果只用来看相对趋势和数量级;需要精确复现的结论都用第二节的比较次数来支撑。

算法

名称 来源 类型
std::sort libstdc++ 16.1.1 introsort:三数取中快排,深度上限 \(2\lfloor\log_2 n\rfloor\),超限退回堆排序,最后做一次插入排序
std::stable_sort libstdc++ 16.1.1 归并排序:先对长度 7 的块做插入排序,再用临时缓冲区逐层归并
heapsort libstdc++ 的 std::make_heap + std::sort_heap 堆排序
pdqsort orlp/pdqsort,提交 b1ef26a pattern-defeating quicksort,普通分区
pdqsort_branchless 同上,pdqsort_branchless() 同一算法,BlockQuicksort 式无分支分区
timsort timsort/cpp-TimSort v3.0.1 TimSort,mergeCollapse() 已包含 de Gouw 等人(2015)提出的合并栈不变量修正
radix_lsd 本文自写 LSD 基数排序,8 位一趟共 4 趟,需要 \(n\) 个元素的额外空间;某一趟所有键的该字节相同就跳过
american_flag 本文自写 原地 MSD 基数排序(American flag sort),8 位一趟,桶小于 32 个元素时改用插入排序
insertion 本文自写 插入排序,只测 \(n \le 10^4\)

有两点要说明。第一,orlp 的 pdqsort() 会自己选分区方式:比较器是 std::less/std::greater、元素是算术类型时走无分支版本,否则走普通版本(pdqsort.h 中的 is_default_compare 分派)。表中的 pdqsort 行传入了一个 lambda 比较器,强制走普通分区;两行是同一个算法的两种分区实现。第二,两个基数排序都是本文自写的参考实现。american_flag 按 McIlroy、Bostic 和 McIlroy(1993)描述的”先计数、再循环置换回家”实现,不是 Skarupke 的 ska_sort;本文没有测试原版 ska_sort,因此不对它下结论。

第三方头文件由 run.sh 按固定提交下载并校验 SHA-256,不放进仓库。

输入分布

所有输入都用 std::mt19937_64 生成,主种子 42。

名称 构造
uniform 全 int32_t 范围均匀随机
few_unique \(\lfloor\sqrt{n}\rfloor\) 个不同值(\(n=10^6\) 时 1000 个),均匀随机
sorted \(0, 1, \ldots, n-1\)
reverse \(n-1, \ldots, 1, 0\)
nearly_sorted 有序数组上做 \(n/100\) 次随机位置交换
sorted_append 前 99% 有序,末尾追加 1% 的随机值,模拟”有序日志后追加一批新记录”
runs32 均匀随机后切成 32 段,每段各自排好序
pipe_organ 先升后降:\(0, 1, \ldots, n/2, \ldots, 2, 1\)

计时方法

一次计时采样的流程如下:

flowchart LR
    A["pristine buffer:<br/>batch x n elements,<br/>each array a distinct draw"] -->|"memcpy (not timed)"| B[work buffer]
    B --> C["timed loop:<br/>sort each of the batch arrays"]
    C --> D["ns per sort =<br/>elapsed / batch"]
    B -.->|"warm-up sample only"| E["verify against<br/>std::sort result"]

噪声有多大

summarize.py 对 107 个计时格统计了两种离散度:

指标 中位数 p90 最大值
单轮内的四分位距 / 中位数 4.1% 9.1% 22.3%
3 轮中位数的极差 / 中位数 14.0% 43.8% 103.8%

同一进程内的采样很稳定,不同进程之间却可能差一倍。最差的一格是 pdqsort 排 sorted:3 轮的中位数分别是 0.332、0.346 和 0.691 ms,第 3 轮显然撞上了邻核干扰。只跑一个进程、在进程内部重复采样,会同时低估噪声、高估精度。

本文因此只采信两类计时结论:一类是 3 轮排名一致的结论,另一类是倍数远大于噪声的结论(下文据以下结论的倍数都在 1.5 倍以上,且 3 轮方向一致;差距小于这个量级的,只写”相差不大”)。实际情况是:在 \(n=10^6\) 的 8 种分布上,第一名在 3 轮中完全相同;前三名只有 sorted、sorted_append 和 runs32 这三种分布的第二、三名在某一轮互换。

二、与时钟无关的指标:比较次数

比较次数是确定的:给定输入和实现,每次运行都得到同一个数,不受频率、邻核和调度影响。--mode count 给每个比较排序套上一个计数比较器,在 \(n = 10^6\) 上连续运行 3 次,输出逐字节相同。

参照线是信息论下界。\(n\) 个互不相同的元素有 \(n!\) 种排列,每次比较最多把候选排列数减半,所以最坏情况和平均情况都至少需要

\[ \log_2 n! = n\log_2 n - n\log_2 e + O(\log n) \]

次比较。\(n = 10^6\) 时 \(\log_2 n!/n \approx 18.49\),而 \(\log_2 n \approx 19.93\)。

n=10^6 时 6 种比较排序在 8 种输入分布上的每元素比较次数热力图:颜色表示相对下界 log2(n!)/n=18.49 的倍数,绿色低于下界、红色高于下界;timsort 在 sorted 和 reverse 上为 1.0,std::sort 在 pipe_organ 上高达 54.1

图中数字是每元素比较次数,颜色是它与 18.49 的比值(对数色阶)。几个值得逐一说明的格子:

分布 std::sort std::stable_sort heapsort pdqsort pdqsort_branchless timsort
uniform 24.21 19.82 20.29 22.16 22.20 18.63
few_unique 19.75 19.82 20.29 11.80 11.67 13.82
sorted 25.60 11.02 20.40 2.00 2.00 1.00
sorted_append 43.72 11.10 20.51 22.32 22.03 1.25
runs32 28.20 13.89 20.43 26.83 26.54 6.00
pipe_organ 54.11 10.65 20.48 31.86 31.09 2.00

(每元素比较次数,\(n=10^6\);8 种分布的完整数据见 results/counts.csv。)

随机输入上,TimSort 离下界最近。 18.63 只比 18.49 多 0.8%。它在随机输入上找不到长 run,退化成”二分插入排序生成长度为 minrun 的短段,再逐层归并”。二分插入和归并都接近最优比较数。std::sort 的 24.21 对应三数取中快排的期望值:Sedgewick(1977)给出的主项是 \(\frac{12}{7} n\ln n \approx 1.188\, n\log_2 n\),\(n = 10^6\) 时约为每元素 23.7 次,与实测在同一水平;精确的低阶项还取决于小区间截断阈值和最后一趟插入排序,这里不展开。

libstdc++ 的堆排序约为 \(n\log_2 n\),不是教科书上的 \(2n\log_2 n\)。 教科书的下滤每层要比较两次:先比较两个孩子,再与父节点比较。libstdc++ 的 __adjust_heap()(bits/stl_heap.h)每层只比较两个孩子,直接把空位下移到叶子,再用 __push_heap() 向上回填。这种”先沉到底、再上浮”的变体通常称为自底向上堆排序(bottom-up heapsort)。Wegener(1993)分析过它,平均比较次数约为 \(n\log_2 n\);原始的堆排序出自 Williams(1964)。所以 heapsort 在所有分布上都稳定在 20.3 到 20.7 之间,对输入结构完全不敏感。

自适应排序在有序结构上的收益是数量级的。 TimSort 在 sorted 和 reverse 上各只比较 \(n-1\) 次:扫描一遍认出整段 run,反转降序 run 不需要比较。在 runs32 上是 6.00:先用约 \(n\) 次比较识别出 32 个 run,再做 \(\log_2 32 = 5\) 层平衡归并,每层约 \(n\) 次。这正好对应”按 run 长度计算的熵”\(n\mathcal{H}\) 这一类自适应下界(McIlroy 1993;Munro & Wild 2018)。pdqsort 在 sorted 上是 2.00、在 reverse 上是 3.00:它发现分区时一个元素都没动,就对两侧尝试”部分插入排序”,最多移动 8 个元素,成功就直接返回。

std::sort 在两种常见的结构化输入上比随机输入做了更多比较:pipe_organ 54.11,sorted_append 43.72,而 uniform 只有 24.21。原因在枢轴选择。__unguarded_partition_pivot() 从 first + 1、中点、last - 1 三个位置取中位数(bits/stl_algo.h)。在 pipe_organ 上这三个值是 1、500000、1,中位数是 1,第一次分区几乎什么都分不出去。introsort_probe.cpp 用同一个内部函数 std::__introsort_loop() 做了对照:

输入 深度上限 38(std::sort 的默认值) 深度无上限
pipe_organ 53.09 + 1.02 74.23 + 3.07
sorted_append 42.28 + 1.44 83.33 + 2.08

(每元素比较次数,“快排循环 + 最后一趟插入排序”。)

如果深度上限从未触发,两列应当相同;右列明显更大,说明在默认设置下,这两种输入都有子区间耗尽了深度、退回了堆排序。introsort 的保证只是”最坏 \(O(n\log n)\)“(Musser 1997),并不承诺常数;这里的常数是随机输入的 2.2 倍。pdqsort 在同样的输入上是 31 左右:它对大于 128 个元素的区间用 ninther(九数取中)选枢轴,分区结果一侧不足 \(1/8\) 时(highly_unbalanced)会交换几对固定位置的元素来打破模式,因此受影响较小,但也没有完全免疫。

重复值多时,pdqsort 的比较次数降到约一半(few_unique 上 11.8 对 std::sort 的 19.75)。当新枢轴等于祖先区间用过的枢轴时,pdqsort 用 partition_left 把所有等于枢轴的元素一次性归到左边,此后不再参与排序。Peters(2021)据此证明:有 \(k\) 个不同值时,最坏耗时是 \(O(nk)\)。

三、随机输入:规模曲线

均匀随机 int32 输入下,n 从 100 到 10^7 时 9 种排序的每元素耗时对数坐标曲线,阴影带是 3 轮中位数的最小到最大范围;radix_lsd 始终最低,为 3.6 到 7.8 纳秒每元素,heapsort 在 10^7 处陡升到 269 纳秒每元素,insertion 从 1000 起迅速变差

每元素耗时(ns,3 轮中位数):

\(n\) insertion std::sort stable_sort heapsort pdqsort pdqsort_bl timsort radix_lsd american_flag
\(10^2\) 15.6 16.9 18.5 17.7 15.4 10.9 22.4 5.9 8.8
\(10^3\) 63.2 25.4 27.9 23.8 24.4 13.1 34.9 3.6 9.2
\(10^4\) 539.5 33.2 37.3 24.1 32.7 14.8 44.8 3.9 15.5
\(10^5\) 41.1 48.5 34.0 41.6 16.4 55.5 4.2 13.6
\(10^6\) 49.3 58.4 58.5 50.4 18.9 67.6 5.0 18.8
\(10^7\) 57.2 71.3 269.3 59.0 20.5 77.6 7.8 23.2

四个观察:

  1. 比较排序的每元素耗时大致随 \(\log n\) 线性增长,与 \(n\log n\) 的模型一致。\(n\) 每增大 10 倍,std::sort 每元素多出约 8 ns(7.8 到 8.5),pdqsort_branchless 只多出 1.6 到 2.5 ns。
  2. radix_lsd 在所有规模上都最快。 \(n = 10^6\) 时 5.0 ns/元素,是 std::sort 的约 \(1/10\)(3 轮比值 9.1 到 10.5)。它的代价与 \(n\log n\) 无关,固定是 4 趟计数加分发。\(10^7\) 时升到 7.8 ns;此时输入数组加缓冲区共 80 MB,超过了 30 MiB 的 L3,这是最直接的解释,但本实验没有测缓存缺失。
  3. heapsort 在 \(10^7\) 处从 58.5 跳到 269.3 ns/元素,涨了 4.6 倍,其他比较排序只涨了 8% 到 22%。节点 \(i\) 的孩子位于 \(2i+1\) 和 \(2i+2\),越往下层,父子在内存里相距越远;数据超出 L3 以后,下滤的几乎每一层都要访问一个新的缓存行。本实验没有采集缓存未命中计数,这是依据访问模式做的推断,但”数据量越过 L3 时才陡升”这一时间点与推断吻合。
  4. 插入排序在 \(n = 100\) 时与 std::sort 相当(15.6 对 16.9),到 \(10^3\) 已经慢了 2.5 倍。std::sort 内部对 16 个元素以下的区间做插入排序(_S_threshold = 16),pdqsort 的阈值是 24。

四、输入分布改变排名

n=10^6 时 8 种排序在 8 种输入分布上的耗时热力图:格内是毫秒数,颜色是相对 std::sort 的倍数,绿色更快、红色更慢;radix_lsd 在 uniform、few_unique、runs32 上最快,timsort 在 sorted、reverse、sorted_append、pipe_organ 上最快,pdqsort 在 nearly_sorted 上最快

\(n = 10^6\),单位 ms,3 轮中位数:

分布 std::sort stable_sort heapsort pdqsort pdqsort_bl timsort radix_lsd american_flag
uniform 49.3 58.4 58.5 50.4 18.9 67.6 5.03 18.8
few_unique 29.5 40.1 46.3 26.0 6.00 53.2 3.23 12.6
sorted 4.89 4.92 27.7 0.346 0.353 0.267 11.2 7.10
reverse 3.43 7.75 29.3 0.573 0.906 0.470 11.3 9.78
nearly_sorted 6.46 8.05 30.5 4.64 5.70 5.78 11.1 14.5
sorted_append 21.2 5.75 27.8 10.6 13.3 1.11 4.74 15.6
runs32 25.2 19.0 44.6 24.4 20.4 16.3 5.52 16.6
pipe_organ 27.8 6.54 30.4 15.4 19.3 0.850 11.3 15.0

没有一个算法在所有分布上都最快。第一名分成三组,3 轮完全一致:

有三处结果与直觉不符:

  1. std::stable_sort 在 sorted 上和 std::sort 一样快(4.92 对 4.89 ms),但比 TimSort 慢 18 倍。libstdc++ 的归并排序不检测已有的 run,每层照常归并;在已排序数据上,每次归并都是一侧先耗尽,比较次数减半(每元素 11.02 次),但数据照样要搬动。
  2. radix_lsd 在 sorted、reverse、pipe_organ、nearly_sorted 上约 11 ms,是 uniform 的 2.2 倍,3 轮都一样。这四种输入的键都落在 \([0, n)\) 的连续整数上,最高字节那一趟被跳过,工作量反而更少,时间却更长。sorted_append 的前 99% 与 sorted 完全相同,却只要 4.74 ms。原因是 L1 缓存的组冲突,见本节末尾。
  3. std::sort 在 sorted_append 上比较了更多次(第二节,每元素 43.7 对 24.2),用时却更短(21.2 对 49.3 ms)。下一节解释这一点。

基数排序在有序键上变慢:桶大小恰好是 2 的幂

先把慢的部分定位到具体哪一趟。reproduce/radix_conflict.cpp 的 --mode phases 对 radix_lsd 的直方图和每趟分发分别计时(绑核,21 次取均值,只看相对大小):uniform 上每趟分发约 1.1 ms;sorted 上第 1 趟(最低字节)只要 0.92 ms,第 2、3 趟却各要 4.1 到 4.3 ms;pipe_organ 只有第 2 趟慢(5.18 ms);sorted_append 每趟都在 0.8 到 1.1 ms。同一组调用里还用 getrusage 数了缺页:变慢的三种分布是 0 次,反倒是不慢的 uniform 有 976 次,所以变慢与内存分配无关。

慢的那几趟有一个共同点:桶的大小是 2 的幂,或者接近 2 的幂。键是 \(0, 1, \ldots, 10^6 - 1\) 时:

L1 用地址的第 6 到 11 位选组,这几位只取决于地址对 4 KiB 取模的结果(本机 L1d 为 48 KiB、12 路、64 组,读自 /sys/devices/system/cpu/cpu12/cache/index0/)。起点相距 4 KiB 整数倍的桶,写指针落在同一个组里。键又是按顺序排好的,所有桶的写指针同步前进,于是第 2 趟有 67 条写流同时争抢一个只有 12 路的组,其余 189 条分在另外 4 个组里,每组 47 或 48 条,同样远超 12 路;每写一次,都会把另一条流还没写满的缓存行挤出去。第 1 趟的桶大小是 3906 或 3907,不是 2 的幂,起点散在 64 个组里,不受影响。

三个柱状图,横轴是 L1 的 64 个组,纵轴是落在该组的桶起点个数,橙色虚线是 12 路相联度:sorted 第 2 趟只用了 5 个组,组 0 挤了 67 个桶起点,另外 4 个组各 47 或 48 个;sorted 第 3 趟 16 个桶全在同一个组;sorted_append 第 2 趟的 256 个桶散在全部 64 个组,每组最多 7 个

图中红柱表示该组的写流数超过了 12 路。sorted_append 末尾那 1% 的随机值给每个桶加了几十到几百个元素,把起点打散了。pipe_organ 的值几乎都出现两次,第 2 字节的桶是 4096 或 3584 个元素,同样撞组;它的第 3 字节只有 8 个桶,8 条流没有超过 12 路,所以只慢在第 2 趟。

reproduce/radix_conflict.cpp 从两个方向验证这个解释。--mode sim 把每一趟分发的写地址回放进上述 L1 模型(LRU、写分配、只计写)。这个模型与时钟无关,结果是确定的。--mode time 把同一份代码只改一处,在每个桶后面空出 16 个元素(下表 gap16),和不空(gap0)对比。

分布 第 2 趟桶起点所在的组数 第 3 趟桶起点所在的组数 模拟 L1 写缺失/元素(第 1、2、3 趟) gap16 后(第 1、2、3 趟)
uniform 62 63 0.063、0.063、0.063 0.063、0.063、0.063
sorted 5 1 0.063、1.000、1.000 0.063、0.063、0.063
sorted_append 64 15 0.063、0.063、0.063 0.063、0.063、0.063
pipe_organ 4 2 0.063、0.983、0.063 0.063、0.063、0.063

每元素 0.0625 次是写分配的下限:一个 64 字节的缓存行装 16 个 int32_t,只在第一次写入时缺失。sorted 的第 2、3 趟每写一个元素就缺失一次,是下限的 16 倍(uniform 还有第 4 趟,表中省略,也是 0.063)。

分布 radix_lsd(ms) gap0(ms) gap16(ms)
uniform 4.80 7.38 7.66
sorted 11.21 13.65 6.63
sorted_append 4.40 6.74 6.81
pipe_organ 8.59 13.00 6.81

(3 个进程各 11 次采样,中位数的中位数,绑 CPU 12。gap0 和 gap16 用两块带间隔的缓冲区,最后还要按桶拷回原数组,所以比 radix_lsd 本身慢;只比较同一行里 gap0 与 gap16 的差别。这张表是另外一次运行的结果,绝对值与上面的主表不同。)

只改间隔,sorted 从 13.65 ms 降到 6.63 ms,pipe_organ 从 13.00 降到 6.81 ms,uniform 和 sorted_append 几乎不变。本机的 WSL2 没有暴露硬件性能计数器,所以这里用的是”组冲突模型 + 只改一个变量的对照”,而不是实测缺失数。这个现象给基准测试提了个醒:输入值域”太整齐”,同样会制造特殊的性能行为,而且与算法的比较次数、分支预测都无关。

五、比较次数不能预测耗时

把第二节的比较次数和第四节的耗时放在同一张图上:

n=10^6 时 6 种比较排序在 8 种分布上的散点图,横轴是每元素比较次数,纵轴是每元素纳秒数;点的分布没有单调关系:timsort 在 uniform 上比较最少却最慢,pdqsort_branchless 在 uniform 上比较 22 次只用 19 纳秒,std::sort 在 pipe_organ 上比较 54 次只用 28 纳秒

如果”比较次数 × 单次比较代价”是好模型,这些点应当落在一条过原点的直线附近。实际上它们没有单调关系。最能说明问题的是三组对照:

对照 每元素比较次数 ms
uniform:pdqsort 对 pdqsort_branchless 22.16 对 22.20 50.4 对 18.9
uniform:timsort 对 std::sort 18.63 对 24.21 67.6 对 49.3
std::sort:sorted_append 对 uniform 43.72 对 24.21 21.2 对 49.3

第一组是同一个算法、同一个枢轴规则,比较次数几乎相同,耗时差 2.7 倍,唯一的区别是分区循环怎么写。普通版本(orlp/pdqsort b1ef26a,pdqsort.h,partition_right(),删减):

while (first < last) {
    std::iter_swap(first, last);
    while (comp(*++first, pivot));
    while (!comp(*--last, pivot));
}

每次比较的结果决定循环是否继续。在随机输入上,枢轴接近中位数,每个比较结果都接近五五开,分支预测器基本只能靠猜。无分支版本(同一文件,partition_right_branchless(),只保留左半块、删去循环展开):

for (size_t i = 0; i < block_size;) {
    offsets_l[num_l] = i++; num_l += !comp(*first, pivot); ++first;
}

比较结果不再控制跳转,而是变成一个 0 或 1,加到偏移计数上。一个块(block_size = 64)扫完之后,再按记下的偏移成对交换。循环的次数固定,分支总能预测对;代价是多写一个偏移数组。源码注释说明这段代码源自 Edelkamp 与 Weiß 的 BlockQuicksort。

这一现象最早由 Kaligosi 与 Sanders(ESA 2006)系统分析:枢轴越接近中位数,比较次数越少,但分支越难预测;故意选偏离中位数的枢轴,反而可能更快。Edelkamp 与 Weiß(ESA 2016,期刊版 ACM JEA 2019)的思路是把控制流和数据流分开,让比较结果根本不经过分支,并在随机整数上报告比 GCC std::sort 快 80%(论文数据,硬件与编译器版本与本文不同,不可直接比较)。本文测得的 2.6 倍(std::sort 对 pdqsort_branchless,3 轮比值 2.53 到 2.61)方向一致。

第二组里,TimSort 比较得最少,却是比较排序里最慢的。随机输入上它每层归并都要把一侧拷进临时缓冲区,“谁先输出”这个判断同样无法预测;galloping 模式在随机数据上也很少触发。对 int32_t 这种比较只要一条指令的类型,数据搬运和分支预测失败才是主要开销。反过来,如果比较本身很贵(字符串、Python 对象、通过函数指针调用的比较器),TimSort 省下的比较就会直接转化为时间,这也是 CPython 和 Java 对象数组选它的原因。本文只测了 int32_t,不能用这里的数据评价那种场景。

第三组说明同样的道理可以反向成立。sorted_append 的前 99% 已经有序,分区时绝大多数比较结果相同,分支几乎总能预测对。所以即使深度上限耗尽、退回了堆排序,总时间也只有随机输入的 43%。

六、benchmark 本身的陷阱

反复排序同一个数组

排小数组时,常见的写法是准备一份输入,在循环里”拷贝、排序”几千次。--mode pitfall 把这种写法与”每次用不同输入”做了对照:batch 为 4096,两种写法唯一的区别是 4096 个数组是否由同一个种子生成。

n=16、64、256 时三种排序在两种写法下的每元素耗时柱状图:灰色是反复排序同一数组,蓝色是每次不同数组,蓝柱上标注两者倍数;std::sort 在 n=64 时相差 5.8 倍,insertion 在 n=256 时只差 1.2 倍
\(n\) 算法 同一数组(ns/次) 不同数组(ns/次) 倍数
16 insertion 30.4 118.1 3.9
16 std::sort 26.3 114.1 4.3
16 pdqsort_branchless 26.1 125.1 4.8
64 insertion 435.1 876.7 2.0
64 std::sort 170.0 981.2 5.8
64 pdqsort_branchless 202.8 645.4 3.2
256 insertion 5416.0 6425.4 1.2
256 std::sort 979.1 5368.5 5.5
256 pdqsort_branchless 1031.6 3241.6 3.1

反复排序同一个数组,每一次执行的分支序列完全相同,现代分支预测器能在几轮之后把它记住,于是测到的是”已经背熟答案”的速度。这个误差不是几个百分点,而是 2 到 6 倍,而且各算法受影响的程度不同,足以改变排名:在”同一数组”的写法下,\(n=64\) 时 std::sort 比 pdqsort_branchless 快,换成不同数组后结论反过来。\(n\) 增大时,插入排序的误差最先缩小,因为它的分支大多是”继续向左移”,本来就容易预测。

进程间噪声与估计量之争

第一节已经给出,本环境里进程间的差异(中位数 14%,最坏 104%)远大于进程内的差异(中位数 4%)。Mytkowicz 等人(ASPLOS 2009)说明了更隐蔽的一类偏差:环境变量的总长度、目标文件的链接顺序都会改变栈和代码的对齐,从而系统性地改变测量结果,而且多次运行也消除不了,因为每次运行的布局相同。本文没有随机化链接顺序和环境,这是一个已知的局限。

该用哪个统计量,学界并没有一致意见。Chen 与 Revels(arXiv 1608.04295,预印本)认为,对确定性的基准程序,外部干扰只会让时间变长,因此最小值是一个单峰、稳健的估计量,他们在 Julia 的 BenchmarkTools 里用最小值判断性能回退。Kalibera 与 Jones(ISMM 2013)则主张在”进程、迭代”等多个层次上安排重复,并报告带置信区间的效应大小,而不是只取单个数字。本文默认用中位数,同时在 results/time_summary.csv 里保留了最小值。在本文的数据上,两种估计量的结论基本一致:\(n=10^6\) 的 8 种分布里有 7 种的前三名完全相同;唯一的例外是 uniform,其中 american_flag 与 pdqsort_branchless 相差不到 1%,两个估计量给出的先后相反。std::sort 与 pdqsort_branchless 的比值,用中位数算是 2.60,用最小值算是 2.70。

其他几条

七、谱系与开放问题

三条谱系

本文测到的 8 个实现来自三条谱系,每条都能追到具体的论文或设计文档:

争论一:代价模型该数什么

教科书按比较次数衡量排序,第二节的数据也确实与经典分析吻合。但第五节表明,对廉价比较来说,比较次数与耗时几乎无关。Kaligosi 与 Sanders 的结论是,应该把分支预测失败次数纳入代价模型。BlockQuicksort 和 pdqsort_branchless 则用实现手段把这一项压到接近零,让比较次数重新变得有意义。Edelkamp 与 Weiß 重做了偏斜枢轴实验(BlockQuicksort 论文 Figure 2,32 位随机整数):GCC 的 Hoare 分区确实受益于偏斜枢轴,块分区却在精确中位数附近最快。也就是说,“好枢轴”是好是坏,取决于分区循环怎么写。两种立场都有实测支撑,分歧在于:该优化算法(换一个预测失败更少的分区策略),还是该优化实现(让同一策略不产生可预测性问题)。Kaligosi 与 Sanders 在 2006 年把”找到比偏斜枢轴快排更快的原地排序”列为开放问题,BlockQuicksort 是对它的一个回答。

争论二:基数排序能不能当默认

本文的数据对基数排序很有利:uniform 上 radix_lsd 比最快的比较排序快 3.8 倍。但这个结论依赖三个前提,每一个都可能不成立:键是定长整数、可以直接取字节;允许 \(n\) 个元素的额外空间(american_flag 不需要,但在 uniform 上耗时是 radix_lsd 的 3.7 倍);输入没有顺序结构(sorted 上 radix_lsd 比 TimSort 慢 42 倍)。标准库的排序接口只拿到一个比较器,拿不到键的位表示,这是 C++、Rust、Go 的默认排序都只能是比较排序的一个直接原因。

即使只看性能,结论也有反方。Axtmann、Witt、Ferizovic 与 Sanders(ACM TOPC 2022)在 21 个排序实现、10 种输入分布、4 台机器上做了交叉比较,摘要写道:他们的比较排序 IPS⁴o 在很大范围的情形下胜过最好的整数排序,原地基数排序只在剩下的许多情形中最好,这些情形往往是接近均匀的分布、较短的键或单线程。本文的 uniform 恰好同时满足这三条,所以本文的 3.8 倍只说明”基数排序在它最擅长的区域里赢了”,不能外推成”基数排序更快”。该文摘要还提醒,许多论文都宣称自己的排序”最好”,这些说法不可能同时成立;这与第六节的方法论问题是同一件事。

开放问题

八、在本文口径下怎么选

以下建议只适用于单线程、内存内、int32_t 这类定长整数键:

情况 本文数据支持的选择 依据
不需要稳定,输入无明显结构 pdqsort_branchless,或能取到键时用 LSD 基数排序 uniform 上分别比 std::sort 快 2.6 倍和 9.8 倍
输入常常已排序、逆序或”有序加追加” TimSort 类自适应归并 sorted_append 上比 std::sort 快 19 倍
需要稳定排序 有长 run 时用 TimSort;否则 std::stable_sort 与 TimSort 相差不大 uniform 上 58.4 对 67.6 ms,sorted 上 4.92 对 0.267 ms
重复值很多 pdqsort,或基数排序 few_unique 上 6.00 ms 与 3.23 ms,std::sort 为 29.5 ms
担心对抗性输入 任何带深度上限的 introsort 或 pdqsort,都不会退化到 \(O(n^2)\) 第二节:std::sort 在 pipe_organ 上退回堆排序,比较次数只到随机输入的 2.2 倍
不要直接用 单独的堆排序 所有分布上都不是前三,数据超过 L3 后每元素耗时是 std::sort 的 4.7 倍

如果你的键是字符串、比较器很贵,或者数据来自真实系统,本文的倍数不能直接套用,需要用自己的数据按第六节的方法重测。

九、复现

cd post/algorithms/06-sort-bench/reproduce
CPU=12 PY=/tmp/mplenv/bin/python ./run.sh

run.sh 依次执行:下载并校验两个第三方头文件,编译普通版与 ASan/UBSan 版,运行 66,480 个正确性用例,统计比较次数,运行 introsort_probe 和 radix_conflict --mode sim,然后在 $CPU 上跑 3 轮计时、3 轮 pitfall 实验、3 轮 gap0/gap16 对照和一次分阶段计时,最后汇总并重画本文的 5 张图。构建产物放在 $WORK(默认 /tmp/sort-bench),不写入仓库。需要 GCC 13 及以上或其他支持 C++20 concepts 的编译器、Python 3 和 matplotlib。一轮计时约 50 秒。

results/ 下的文件:env.txt 是环境信息,counts.csv 是比较次数,introsort_probe.txt 是深度上限探针,time_run{1,2,3}.csv 和 pitfall_run{1,2,3}.csv 是全部原始采样,time_summary.csv 和 pitfall_summary.csv 是汇总;radix_conflict_sim.csv、radix_conflict_time_run{1,2,3}.csv、radix_conflict_phases.csv 对应第四节末尾的组冲突实验。比较次数、探针和组冲突模拟的输出在任何机器上都应逐字节相同(前提是使用同一版本的 libstdc++);计时数字不会相同,要比较的是倍数和排名。

十、参考资料

源码(均为本文实际编译或阅读的版本)

核心论文

其他论文

基准测试方法

实验


系列导航: - 上一篇:并行排序:排序网络、并行归并、样本排序与 GPU 基数排序 - 下一篇:哈希表内部:链式、线性探测、Robin Hood 与生产实现的取舍

相关阅读: - TimSort:自然 run、galloping 与从栈不变量到 Powersort 的合并策略 - pdqsort:坏分区计数、重复键分区与块分区如何改造 introsort - 基数排序:绕开比较下界的代价,从 LSD、American flag sort 到 ska_sort - 外部排序:从 I/O 下界到 PostgreSQL 与 GNU sort

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。

2026-04-10 · algorithms

排序算法专题:从 TimSort 到并行排序

把 TimSort、pdqsort、radix sort、external sort、parallel sort 与 benchmark 串成一条阅读路径。先读哪篇、什么时候选哪种排序,这一页讲清。


By .