为什么VecDeque比Vec慢?

5
我开始对一个板条箱进行性能优化,将Vec替换为VecDeque。容器以排序的方式维护元素(它应该相当小,所以我还没有尝试堆),偶尔被分成两个独立的容器(这是我还没有尝试堆的另一个原因),使用drain方法。
我期望这个第二个操作会更快:我可以将集合的前半部分复制出来,然后简单地旋转并缩短原始(现在是第二个)集合的长度。然而,当我运行我的#[bench]测试时,在执行上述操作多次后(以下显示每百万次的纳秒数/迭代次数),我观察到了VecDeque的性能下降:
测试a 测试b 测试c 测试d
Vec 12.6 5.9 5.9 3.8
VecDeque 13.6 8.9 7.3 5.8

一个可重现的示例(gist):

#![feature(test)]
extern crate test;

use std::collections::VecDeque;

fn insert_in_sorted_order_vec<E: Ord + Eq>(t: &mut Vec<E>, k: E) {
    match t.binary_search(&k) {
        Ok(i) => t[i] = k,
        Err(i) => t.insert(i, k),
    }
}

fn insert_in_sorted_order_vecdeque<E: Ord + Eq>(t: &mut VecDeque<E>, k: E) {
    match t.binary_search(&k) {
        Ok(i) => t[i] = k,
        Err(i) => t.insert(i, k),
    }
}

fn split_vec<T>(mut t: Vec<T>) -> (Vec<T>, Vec<T>) {
    let a = t.drain(0..(t.len() / 2)).collect();
    (a, t)
}

fn split_vecdeque<T>(mut t: VecDeque<T>) -> (VecDeque<T>, VecDeque<T>) {
    let a = t.drain(0..(t.len() / 2)).collect();
    (a, t)
}

#[cfg(test)]
mod tests {
    use super::*;
    use test::Bencher;

    static ITERS_BEFORE_SPLIT: u32 = 50;
    static ITERS_TIME: u32 = 10_000;

    #[bench]
    fn vec_manip(b: &mut Bencher) {
        b.iter(|| {
            let mut v = Vec::new();
            for i in 0..(ITERS_TIME / ITERS_BEFORE_SPLIT) {
                for j in 1..(ITERS_BEFORE_SPLIT + 1) {
                    insert_in_sorted_order_vec(&mut v, i * j / (i + j)); // 'random'-ish illustrative number
                }

                v = split_vec(v).1;
            }
        });
    }

    #[bench]
    fn vecdeque_manip(b: &mut Bencher) {
        b.iter(|| {
            let mut v = VecDeque::new();
            for i in 0..(ITERS_TIME / ITERS_BEFORE_SPLIT) {
                for j in 1..(ITERS_BEFORE_SPLIT + 1) {
                    insert_in_sorted_order_vecdeque(&mut v, i * j / (i + j)); // 'random'-ish illustrative number
                }

                v = split_vecdeque(v).1;
            }
        });
    }
}

“Vec”实现需要69.2k ns/iter,而“VecDeque”实现需要91.8k。我已经重复并验证了这些结果 - 为什么使用这种更灵活的数据结构会导致性能下降呢?这些结果是通过运行“cargo bench”获得的。
  • Linux 5.11
  • 3900X(12核,3.8-4.6 GHz)
  • 32GB 3200 MHz RAM
  • rustc 1.55.0-nightly
  • 默认的选项(优化,没有调试符号等)

编辑

我将split_vecdeque方法更改为使用split_off而不是drain().collect()(见下文)。看起来这种方法保证不会重新分配或移动任何内容,而只是移动headtail指针; 请参阅文档实现。然而,它的性能甚至比原始的VecDeque还要差,为98.2k ns/iter。对于更大的值(ITERS_BEFORE_SPLIT = 50_000,ITERS_TIME = 5_000_000),尽管性能(21.8m ns/iter)优于drain(23.1 ns/iter),但劣于Vec(19.1 ns/iter)。
fn split_vecdeque<T>(mut t: VecDeque<T>) -> (VecDeque<T>, VecDeque<T>) {
    let a = t.split_off(t.len() / 2);
    (t, a)
}

4
为什么使用这种更灵活的数据结构会导致性能下降?难道不是预期使用更灵活的数据结构会导致性能下降吗?也就是说,除非你利用了VecVecDeque之间的差异,并使其对后者更有利,例如弹出元素从前面开始。由于您似乎没有做任何这方面的工作,并且将VecDeque用作Vec,因此您正在承受使用更复杂类型而不获得其好处的代价。 - user4815162342
@user4815162342 我相信我正在利用这些差异 - 分割操作通过旋转缓冲区来实现,而不像Vec一样将所有东西向后滑动。据我所知。 - user8866053
我那时误读了代码。也许这只是利大于弊的情况。你试过增加尺寸,以促使VecDeque显示出优势吗? - user4815162342
@user4815162342 随着更大的分割,增量看起来会变小,但VecDeque永远不会变得更快 - 你能详细说明一下缺点吗?也许在回答中解释一下?谢谢! - user8866053
3
我的意思是VecDeque相对于Vec而言更加复杂,其索引更难实现。在进行边界检查后,Vec只需要一个单一指针间接访问,而VecDeque需要额外的尾部包装算术运算。因此,我预计二分搜索在Vec上比在VecDeque上更快。但在详细分析您的代码之前,我不想写出确切的答案,而我可能没有时间(和可能也没有足够的知识)进行分析。 - user4815162342
2
如果 VecDeque 在支持 Vec 的每个操作时表现相同,那么拥有这两种数据结构就没有意义了。按需付费:如果您不使用 VecDeque 的双端性,则使用 Vec - Peter Hall
1个回答

14

VecDeque 类型与 Vec 类型相似,但支持高效地从两端推入和弹出元素。为了实现这一点,它使用一个单一的连续缓冲区(就像 Vec 一样),但将其视为两个分区:头部和尾部。

该结构的布局如下:

pub struct VecDeque<T> {
    tail: usize,
    head: usize,
    buf: RawVec<T>,
}

缓冲区中的项目按照以下顺序排序:

[[tail: 5, 6, 7] ...unused... [head: 1, 2, 3, 4]] 

将项目添加到集合末尾将追加到尾部,使用一些未使用的空间。将其添加到集合开头将添加到头部的开头,耗用相同的空间。当头部和尾部在中间相遇时,VecDeque已满并需要重新分配。

Vec相比:

pub struct Vec<T> {
    buf: RawVec<T>,
    len: usize,
}

它使用其缓冲区像这样:

[1, 2, 4, 5, 6, 7 ...unused...] 
在末尾添加一个项目很快,但在开头添加一个项目需要复制所有现有项目以腾出空间。这种布局会使VecDeque的大多数操作变得更加复杂,这将略微降低其性能。甚至检索其长度也更加复杂。
pub fn len(&self) -> usize {
    count(self.tail, self.head, self.cap())
}
VecDeque 的整个意义在于使某些操作更快,特别是推入和弹出集合的开始。如果有很多项,则 Vec 非常慢,因为它涉及移动所有其他项以腾出空间。 VecDeque 的结构使这些操作快速进行,但与 Vec 相比,会以其他操作的性能为代价。
由于测试中存在对 insert 的调用,这涉及到两种情况下许多项目的昂贵复制,因此您的测试似乎没有充分利用 VecDeque 的设计。

VecDeque与Vec在删除元素时的区别我曾经在某处读到过这样的内容,但是在撰写本文时无法再次找到。请问,以下说法是否准确:在一个足够大(具体值我无法确定)的向量中,使用VecDeque执行remove()比使用Vec更有效率,因为在后者中,所选索引之后的所有元素都会向左移动,而在前者中,会发生这种情况 - MikeTheSapien
谢谢。我想知道的一件事是为什么Vec在从数组开头删除元素时效率低下。我本以为这只需要将指向数组开头的指针向前移动并释放该内存,不明白为什么需要进行任何内存移动(无论是向前还是向后)。 - Dominic
1
@Dominic 这是由分配器所施加的限制造成的。如果你只是将起始指针向上移动,那么你现在会有一个更小的分配。很酷。但是当你稍后释放内存时,分配器将无法识别指针地址,因为它不会与它最初给出的地址相同。这将触发未定义的行为。 - Peter Hall
为了发出这个声音,你需要跟踪两个指针,一个是它最初给出的指针,另一个是你现在用作第一个项目的指针。这会增加额外的簿记开销,更重要的是,它会使内存使用的推理变得困难。 - Peter Hall
1
VecDeque是订单簿的更好选择。如果您使用了像您描述的数据结构,它会逐渐减少其容量,那么您将不得不在某个时候重新分配,这将导致明显的延迟峰值。基于VecDeque的订单簿应该能够在中等规格的硬件上处理每秒5-10百万条消息。您还可以考虑BTreeMap,在考虑订单取消时可能会给出更好的最坏情况延迟,因为订单取消通常比实际交易要常见得多。一如既往,测试性能并且永远不要假设,因为您经常会感到惊讶 :) - Peter Hall
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接