我对Rust还很陌生,所以我可能错过了一些简单的东西.我每晚都在使用铁 rust 1.70.0.以下是必要的代码:

fn selection_sort(original_vec: &mut Vec<i32>) -> Vec<i32> {
    for i in 0..original_vec.len()-1 {
        let mut smallest: usize = i;
        for j in i+1..original_vec.len() {
            if original_vec[j] < original_vec[smallest] {
                smallest = j;
            }
        }
        if smallest != i {
            original_vec.swap(i, smallest);
        }
    };
    original_vec.to_vec()
}

// helper function for testing (uses builtin sort function)
fn rust_sort<A, T>(mut array: A) -> A
where
    A: AsMut<[T]>,
    T: Ord,
{
    let slice = array.as_mut();
    slice.sort();

    array
}

const TEST_VECS: [[i32; 10]; 6] = [
    [1, 3, 2, 9, 6, 7, 4, 10, 8, 5],
    [1, 2, 7, 2, 9, 9, 7, 10, 2, 1],
    [0, 4, 1, 3, 9, 12, 3, 0, 13, 8],
    [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
    [-1, -5, -10, 1, 10, 2, -123, -34, 0, -32],
    [i32::MAX, -32984, i32::MIN, 31648, 5349857, -30954, -2343285, 0, 1, i32::MIN],
];

#[bench]
fn bench_rust_sort(b: &mut Bencher) {
    b.iter(|| {
        for i in TEST_VECS {
            (&mut i.to_vec()).sort()
        }
    })
}

#[bench]
fn bench_selection_sort(b: &mut Bencher) {
    b.iter(|| {
        for i in TEST_VECS {
            selection_sort(&mut i.to_vec());
        }
    })
}

当我跑cargo bench米的时候:

$ cargo bench
  Compiling rust-algs v0.1.0 (/home/josia/projects/rust-algs)
    Finished bench [optimized] target(s) in 0.25s
     Running unittests src/lib.rs (target/release/deps/rustalgs-ae260c07593c3aad)

running 3 tests
test test_selection_sort ... ignored
test bench_rust_sort      ... bench:         106 ns/iter (+/- 8)
test bench_selection_sort ... bench:         102 ns/iter (+/- 9)

test result: ok. 0 passed; 0 failed; 1 ignored; 2 measured; 0 filtered out; finished in 7.65s

我try 了很多次,甚至重命名了测试函数以更改测试顺序.无论如何,我的自定义 Select 排序功能仍然执行得更快.我猜问题在于我必须调用一个函数来包装主要的默认排序函数.调用实际的函数将不起作用,因为即使我将阶跃函数中的TEST_VECS常量克隆为向量,排序函数也会继续对其进行排序,这不会让其他阶跃迭代对其进行排序.如果我克隆BENCHING闭包中的常量,它将极大地影响BASCH迭代的性能,并且我将不能仅对我试图运行的代码进行基准测试.

是我对这些函数进行基准测试的方式有问题,还是我的自定义函数只是速度更快?

推荐答案

TL:DR:

  • Rustc -C opt-level=3完全内联,而对于这个编译时常量SIZE=10,则是fully unrolls Your selection_sort.使用opt-level=2,它会内联,但会编译成与源代码类似的循环.

  • 对于这么小的尺寸,Rust‘s .sort使用插入排序,但出于某种原因,它是内联的doesn't.因此,这实际上是在运行一个未展开的循环,其大小是一个运行时变量(据它所知).注意呼叫者正在执行mov esi, 10/.../call core::slice::sort::insertion_sort_shift_left.我认为这是让.sort稍微慢下来的关键.

  • TEST_VECS开始复制很好,尽管可能有点小;现代CPU可能正在学习使用分支预测器进行分支的模式,并且在处理这种大小的未排序输入的更大程序中表现得比您预期的要好.您可能有6个以上的存储片,但仍未获得和L1d缓存未命中.

    最好避免在Repeat循环中使用alalc/dealc.(使用.clone_from_slice(as described here)表示在Repeat循环外部分配的切片或向量).两个循环都在为相同的分配/取消分配开销买单,这应该是平衡的.编译器已经在这里高效地执行了实际复制.

    对于这么小的切片,Ruust的.sort不会在内部分配任何东西(使用插入排序),传递/返回VEC会优化为只进行就地排序,而不会进行额外的复制.

  • 黑匣子 could make this more robust对击败重复循环的潜在优化,但目前的版本似乎没有发生这种情况.

  • 时间复杂度为O(N^2).对于已经排序的数组,插入排序是O(N),这是它是小问题(以及较大数组的分治算法的子问题)的标准 Select 的原因之一,但通常是O(N^2)平均和最坏情况. 对于大型问题,所有O(N^2)排序都是垃圾,所以.sort.sort_unstable使用O(N log N)算法,比selection_sort快得多.Murasame的答案测量了大小=https://doc.rust-lang.org/std/vec/struct.Vec.html#method.sort0的15倍速度差. (https://doc.rust-lang.org/std/vec/struct.Vec.html#method.sort / https://doc.rust-lang.org/std/primitive.slice.html#method.sort_unstable)

  • 如果你的CPU是Skylake系列的,你应该寻找相当于How can I mitigate the impact of the Intel jcc erratum on gcc?的铁 rust --如果不控制这个因素,一个微基准可能会被它咬住,而另一个不会.

因此,我们可以说,对于尚未(接近)排序的较小编译时间常数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=3fully 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], r14mov qword ptr [rsp + 32], 10等这样的store .

首先使用Vec是不必要的;对于这些较小的大小,只在堆栈空间中使用一个片就可以了,而且即使我们是从常量片克隆的,也可能会避免任何分配/取消分配开销,因为编译器会知道它可以重用堆栈空间.

此函数在每次外部迭代中对堆栈空间执行TEST_VECS的240字节memcpy.(它从那里复制到Vec对象.)我不知道为什么会发生这种情况,但我几乎不了解拉斯特.它在没有使用黑匣子的情况下发生,所以它不是由黑匣子触发的.

Rust相关问答推荐

如果成员都实现特征,是否在多态集合上实现部分重叠的特征?

从Type::new()调用函数

默认特征实现中的生命周期问题

在自定义序列化程序中复制serde(With)的行为

为什么铁 rust S似乎有内在的易变性?

如何循环遍历0..V.len()-1何时v可能为空?

像这样的铁 rust 图案除了‘选项’之外,还有其他 Select 吗?

装箱特性如何影响传递给它的参数的生命周期 ?(举一个非常具体的例子)

了解Rust';s特征对象和不同函数签名中的生存期注释

使用占位符获取用户输入

Rust 如何将链表推到前面?

为什么我们有两种方法来包含 serde_derive?

为什么 Rust 字符串没有短字符串优化 (SSO)?

为什么要这样编译?

一个函数调用会产生双重borrow 错误,而另一个则不会

如何连接 Rust 中的相邻切片

如何将 Rust 中的树状 struct 展平为 Vec<&mut ...>?

如何在 Rust Polars 中可靠地连接 LazyFrames

使用 `.` 将 T 转换为 &mut T?

相互调用的递归异步函数:检测到循环