TL:DR:个
因此,我们可以说,对于尚未(接近)排序的较小编译时间常数VEC, Select 排序在当前Rustc为-C opt-level=3
的情况下编译为更快的ASM.根据我们在ASM中看到的造成这些结果的明显原因,我预计如果这些因素中的任何一个是不同的,情况就会不同.也许,即使是不同ISA的不同调谐默认设置也可能使其无法完全展开.此外,主流x86-64CPU能够完全有效地处理如此大的循环(通过足够大的uop缓存),这在某种程度上很重要.
selection_sort
inlined and fully unrolled, .sort
didn't
对于这种特定情况,您的测量结果可能是正确的,即编译器可以看到的size=10 as compile-time constant.rustc -C opt-level=3
是fully inlining and fully unrolling your 101,但向标准库的插入排序发出call
.因此.sort()
包括处理运行时可变切片大小的循环开销.
(我不知道如何让Matt Godbolt的编译器资源管理器向我展示实际#[bench]
个函数的ASM,所以我自己做了一个简单的计数循环调用器,假设b.iter(|| { ... })
个循环中的内联 Select 基本上是相同的.Godbolt-更改未注释的呼叫,以查看另一个呼叫的ASM.)
通常,对于像您这样的未排序数据(都是N^2,具有相似的常量因子),插入排序与 Select 排序一样好,但它确实有更多的数据移动,这可能不适合完全展开编译时间常量.len()
.或者它来自标准库的方式与您的selection_sort
在源文件中简单而完整,并专门为i32
编写的方式有什么不同?但是.sort
确实内联并优化了 Select insertion_sort_shift_left
的逻辑,而没有任何大片代码仍然存在,所以没有什么内在地停止编译器,它只是 Select 了不停止.
无论如何,我认为完全展开外部和内部循环是selection_sort
在这里变得快速的原因.将TEST_VEC复制到新分配的VEC后,ASM立即进行比较,如
# rustc nightly -C opt-level=3 for x86-64
# inside the main repeat loop in bench_selection_sort
...
mov edi, 40
mov esi, 4
call rbp # call alloc
test rax, rax
je .LBB0_4 # check for null
# pointer to a slice of TEST_VECS in R15
# pointer to the newly-allocated Vec<i32> in RAX
movups xmm0, xmmword ptr [r15]
movups xmm1, xmmword ptr [r15 + 16]
movups xmmword ptr [rax], xmm0 # with -C target-cpu=x86-64-v3
movups xmmword ptr [rax + 16], xmm1 # just one YMM 32-byte copy is needed, vs. 2x 16-byte with baselien SSE2
mov rdx, qword ptr [r15 + 32]
mov qword ptr [rax + 32], rdx # copy last 8 of the 40 bytes
# and now the sorting
mov ecx, dword ptr [rax]
mov edi, dword ptr [rax + 8]
xor esi, esi
cmp dword ptr [rax + 4], ecx
setl sil # 0 or 1 according to vec[1] < vec[0]
mov r9d, 2
cmp edi, dword ptr [rax + 4*rsi] # compare at a data-dependent address; surprising for Selection Sort
jl .LBB0_9
mov r9, rsi # strange that this isn't a CMOV; the only branch to .LBB0_9 is the preceding line
.LBB0_9:
mov esi, dword ptr [rax + 28] # load several elements
mov edi, dword ptr [rax + 24] # that it's going to cmp/branch on later
mov r10d, dword ptr [rax + 16]
mov r8d, dword ptr [rax + 20]
mov ebx, dword ptr [rax + 12]
mov r11d, 3
cmp ebx, dword ptr [rax + 4*r9]
jge .LBB0_10
... over 400 more instructions before the bottom of the loop, mostly mov / cmp/jcc
测试数据
重复地对相同的数据进行排序或多或少是很好的,尽管"只有"6个10元素的排序可能小到足以让您的CPU学习分支模式.(Modern IT-TAGE predictors使用以前的历史记录来索引预测,从而有可能预测同一分支的模式,因此这不一定会给完全展开的SELECTION_SORT带来很大的优势,特别是因为您要对6个不同的切片进行排序,所以即使是完全展开也不会使分支每次都以相同的方式进行.)您使用perf stat
进行的测试发现只有1.5%左右的误预测率似乎很低,但这包括循环分支.try 将数组加长一点和/或再添加几个.
几年前,我在玩一次代码高尔夫练习中手写的ASM Bubble类游戏,我的Skylake能够学习到它的分支模式,输入大小最高可达14个元素IIRC,几乎没有预测错误,直到我将大小扩大.这就是气泡排序,这是最差的O(N^2)排序之一,加上它的循环分支.我正在与一个调用者进行基准测试,该调用者刚刚从向量正则规则中执行了vmovdqa ymm
个存储,这是为就地排序重新生成小输入的最便宜的可能方式.由于有6个不同的来源,Rust在每次重复循环迭代时都会加载这些数据,但这样做的成本高得可以忽略不计.排序函数中的标量加载可以立即加载,甚至不需要等待向量存储提交到L1d缓存;现代x86CPU对于向量存储的标量重新加载有很好的store-to-load forwarding.所有内容都命中L1d缓存.
另一种 Select 是像What's the fastest way to generate a 1 GB text file containing random digits?中那样使用矢量化的xorShift+,以获得新的数据来对每次迭代进行排序.使用排序后的数组作为LFSR的种子(就像Murasame的答案一样)将是对延迟而不是吞吐量进行基准测试的一种方法,但当您对标量存储写入的数组执行SIMD向量加载时,也会遇到存储转发停滞.(假设编译器自动向量化您的循环.)
像 Select 排序这样的O(N^2)排序在较大的数组上速度较慢
我想你已经知道了,但是Selection Sort has O(N^2) time complexity so it's garbage for larger sizes.对于较大的问题,Ruust的.sort
和.sort_unstable
使用O(N Log N)排序(分别是由timsorte启发的迭代合并,或模式失败的快速排序),但对较小规模的问题或较大排序的子问题使用插入排序.这里的大小是一个编译时常量,Rust的.sort
函数可以内联到足以优化大小上的分支以 Select 策略,因此,对于这种大小的问题,它只是您的SELECTION_SORT和标准库的插入排序(core::slice::sort::insertion_sort_shift_left
).
(令人惊讶的是,.sort_unstable
内联小于(Godbolt),调用core::slice::sort::recurse
在运行时判断大小是否小于21
,否则继续其快速排序.)
插入排序和 Select 排序适用于小数组,在这种情况下,更复杂的算法将花费太多时间来细分已经很小的问题.这就是为什么现实世界中的排序一旦细分和部分排序到足以产生小的子问题,就会使用插入排序.(或者是一个分类网络,可能使用SIMD矢量.)
当要排序的对象是简单的整数,而不是具有键和一些其他数据的 struct 时,稳定排序是不相关的.相等的i32
个物体是无法区分的.但.sort
似乎没有试图检测到这一点,因为我们得到的ASM与.sort
和.sort_unstable
不同.
黑匣子
您不需要做任何事情来阻止对排序进行优化;生成的向量是未使用的.但目前的rustc -C opt-level=3
版本似乎没有实现so your benchmark appears valid.的优化
使用黑匣子(sort_result)
将是safer / future-proof,尽管如果编译器确实完全看透了您的基准测试,它可以只从已经排序的数组中复制,以满足在某个地方实现排序结果的黑匣子
要求.因此,为了完全安全,您将在input片或向量上使用黑匣子
,然后在输出上使用黑匣子
;希望黑匣子
会使编译器忘记那里有什么值,即将其视为可能修改了片的函数调用,而不仅仅是读取它.
避免alloc/dealloc
复制非常便宜,只需2个向量(movups
)和1个标量(mov rdx, [r15 + 32]
/mov [rax+32], rdx
)的加载+存储.
但如果有一个更好的基准,就可以避免分配/取消分配.我在 comments 中错了;我寻找的是call some_longer_name
,但实际上Rustc喜欢将库函数指针加载到寄存器中(如果要在循环内调用它们),所以你必须注意call
助记符才能发现像call rbp
这样的指令.
How can I copy a vector to another location and reuse the existing allocated memory?描述如何使用.clone_from
或.clone_from_slice()
将数据从片复制到已分配的Vec
中.我查看了ASM以确认for i in TEST_VECS {
循环中没有call
条指令,这与原始指令不同.
use core::hint::黑匣子;
// I don't know how to get it to show asm for the #[bench] version
pub
fn xbench_selection_sort(a: i32) {
// allocate correct amount of storage once, and redundantly copy into it because I don't know Rust very well
let mut test_vec = TEST_VECS[0].to_vec();
for _b in 0..a { // just a counted repeat loop
for i in TEST_VECS {
//let mut test_vec = i.to_vec(); // would alloc/free inside the loop
test_vec.clone_from_slice(&i); // copy into already-allocated space
黑匣子(&mut test_vec.as_slice()); // force the data (but not the pointers and size) to exist in memory
selection_sort(&mut test_vec);
黑匣子(&mut test_vec.as_slice()); // force the result data to exist. Apparently costs at least 1 extra asm instruction somewhere, unfortunately.
// actually this does store the pointers+length to the stack, not avoiding it like I hoped.
//黑匣子(&mut test_vec[4]); // or just ask for one element; often enough to stop a compiler from optimizing away a whole loop.
}
}
}
(100)
如果我理解正确的话,Vec
上的.as_slice()
就像std::vector
上的C++.data()
,只是它是一个片,所以它有一个长度.无论如何,这样做是为了避免要求编译器实际存储堆栈空间的指针和长度.但我不确定这是否奏效:我仍然看到像mov [rsp + 24], r14
、mov qword ptr [rsp + 32], 10
等这样的store .
首先使用Vec
是不必要的;对于这些较小的大小,只在堆栈空间中使用一个片就可以了,而且即使我们是从常量片克隆的,也可能会避免任何分配/取消分配开销,因为编译器会知道它可以重用堆栈空间.
此函数在每次外部迭代中对堆栈空间执行TEST_VECS
的240字节memcpy
.(它从那里复制到Vec
对象.)我不知道为什么会发生这种情况,但我几乎不了解拉斯特.它在没有使用黑匣子
的情况下发生,所以它不是由黑匣子
触发的.