有没有办法直接在堆上分配一个标准的 Rust 数组,完全跳过堆栈?

Posted

技术标签:

【中文标题】有没有办法直接在堆上分配一个标准的 Rust 数组,完全跳过堆栈?【英文标题】:Is there any way to allocate a standard Rust array directly on the heap, skipping the stack entirely? 【发布时间】:2019-05-10 11:42:49 【问题描述】:

Stack Overflow 上已经有几个关于在堆上分配数组(比如[i32])的问题。一般建议是拳击,例如Box<[i32]>。但是,虽然装箱对于较小的数组来说足够好,但问题是被装箱的数组必须首先在堆栈上分配。

因此,如果数组太大(例如 1000 万个元素),即使使用装箱,您也会出现堆栈溢出(不太可能有那么大的堆栈)。

然后建议改用Vec<T>,在我们的示例中为Vec<i32>。虽然这确实起到了作用,但它确实会对性能产生影响。

考虑以下程序:

fn main() 
    const LENGTH: usize = 10_000;
    let mut a: [i32; LENGTH] = [0; LENGTH];
    for j in 0..LENGTH 
        for i in 0..LENGTH 
            a[i] = j as i32;
        
    

time 告诉我这个程序运行大约需要 2.9 秒。我在这个例子中使用了 10'000,所以我可以在堆栈上分配它,但我真的想要一个有 1000 万的。

现在考虑相同的程序,但改用Vec<T>

fn main() 
    const LENGTH: usize = 10_000;
    let mut a: Vec<i32> = vec![0; LENGTH];
    for j in 0..LENGTH 
        for i in 0..LENGTH 
            a[i] = j as i32;
        
    

time 告诉我这个程序需要大约 5 秒才能运行。现在time 不是很精确,但是对于这样一个简单的程序来说,大约 2 秒的差异并不是一个微不足道的影响。

存储就是存储,带数组的程序在装箱的情况下也一样快。所以不是堆减慢了Vec&lt;T&gt; 版本,而是Vec&lt;T&gt; 结构本身。

我也尝试使用HashMap(特别是HashMap&lt;usize, i32&gt; 来模拟数组结构),但这比Vec&lt;T&gt; 解决方案要慢得多。

如果我的LENGTH 是 1000 万,第一个版本甚至都不会运行。

如果这不可能,那么是否有一种结构在堆上的行为类似于数组(和Vec&lt;T&gt;),但可以匹配数组的速度和性能?

【问题讨论】:

Stack Overflow 上已经有几个关于分配数组的问题这个 有何不同?仅仅因为您不喜欢现有答案并不意味着您可以打开副本。 @Shepmaster 我的问题也是关于性能的。其他问题从未提出过这个问题。下面的答案也探讨了这方面,回答的很好。 【参考方案1】:

如果你真的想要一个堆分配的数组,即Box&lt;[i32; LENGTH]&gt;,那么你可以使用:

fn main() 
    const LENGTH: usize = 10_000_000;

    let mut a = 
        let mut v: Vec<i32> = Vec::with_capacity(LENGTH);

        // Explicitly set length which is safe since the allocation is
        // sized correctly.
        unsafe  v.set_len(LENGTH); ;

        // While not required for this particular example, in general
        // we want to initialize elements to a known value.
        let mut slice = v.into_boxed_slice();
        for i in &mut slice[..] 
            *i = 0;
        

        let raw_slice = Box::into_raw(slice);

        // Using `from_raw` is safe as long as the pointer is
        // retrieved using `into_raw`.
        unsafe 
            Box::from_raw(raw_slice as *mut [i32; LENGTH])
        
    ;

    // This is the micro benchmark from the question.
    for j in 0..LENGTH 
        for i in 0..LENGTH 
            a[i] = j as i32;
        
    

它不会比使用向量快,因为 Rust 甚至对数组进行边界检查,但它有一个更小的接口,这在软件设计方面可能是有意义的。

【讨论】:

任何时候你使用unsafe,尤其是在 Stack Overflow 上,你都应该添加一个注释来解释你为什么认为它是正确的。在这种特殊情况下,为什么可以不初始化 Vec 的元素,为什么可以在强制转换的指针上使用 Box::from_raw【参考方案2】:

总结:你的基准有缺陷;只需使用Vec(如here 所述),也可能使用into_boxed_slice,因为它不太可能比堆分配的数组慢。


很遗憾,您的基准测试存在缺陷。首先,您可能没有进行优化编译(--release 用于 cargo,-O 用于 rustc)。因为如果你愿意,Rust 编译器会删除你所有的代码。见the assembly here。为什么?因为你从不观察向量/数组,所以没有必要一开始就做所有这些工作。

此外,您的基准测试并未测试您真正想要测试的内容。您正在将堆栈分配的数组与堆分配的向量进行比较。您应该将Vec 与堆分配的数组进行比较。

不过不要难过:编写基准测试非常困难,原因有很多。请记住:如果您对编写基准测试知之甚少,最好不要在没有先询问其他人的情况下相信自己的基准测试。


我修复了您的基准并包含了所有三种可能性:Vec、堆栈上的数组和堆上的数组。你可以找到完整的代码here。结果是:

running 3 tests
test array_heap  ... bench:   9,600,979 ns/iter (+/- 1,438,433)
test array_stack ... bench:   9,232,623 ns/iter (+/- 720,699)
test vec_heap    ... bench:   9,259,912 ns/iter (+/- 691,095)

惊喜:版本之间的差异远小于测量的方差。所以我们可以假设它们都相当快。

请注意,这个基准仍然非常糟糕。这两个循环可以被一个循环替换,将所有数组元素设置为LENGTH - 1。通过快速查看程序集(以及相当长的 9ms 时间),我认为 LLVM 不够聪明,无法实际执行此优化。但是这样的事情很重要,应该意识到这一点。


最后,让我们讨论一下为什么两种解决方案的速度应该一样快,以及速度上是否真的存在差异。

Vec&lt;T&gt; 的数据部分与[T] 具有完全相同的内存布局:只有许多Ts 在内存中连续。超级简单。这也意味着两者都表现出相同的缓存行为(特别是对缓存非常友好)。

唯一的区别是Vec 可能比元素有更多的容量。所以Vec 本身存储(pointer, length, capacity)。这比一个简单的(盒装)切片(存储(pointer, length))多一个字。装箱数组不需要存储长度,因为它已经在类型中,所以它只是一个简单的指针。当你将拥有数百万个元素时,我们是否存储一个、两个或三个单词并不重要。

访问一个元素对于所有三个都是相同的:我们首先进行边界检查,然后通过base_pointer + size_of::&lt;T&gt;() * index 计算目标指针。但重要的是要注意,将其长度存储在类型中的数组意味着优化器可以更轻松地删除边界检查!这可能是一个真正的优势。

但是,智能优化器通常已经删除了边界检查。在我上面发布的基准代码中,程序集中没有边界检查。因此,虽然通过删除边界检查,盒装数组可能会更快一些,(a)这将是一个很小的性能差异,并且(b)你不太可能有很多情况下删除数组的边界检查但是不适用于 Vec/slice。

【讨论】:

你说得对,优化器是有区别的。使用-O 编译我的较大程序,使用Vec&lt;T&gt; 不会影响性能。感谢您的深入回答。所以我的问题的答案就是继续使用Vec&lt;T&gt;

以上是关于有没有办法直接在堆上分配一个标准的 Rust 数组,完全跳过堆栈?的主要内容,如果未能解决你的问题,请参考以下文章

如何确定 Rust 中的 new() 何时在堆栈或堆上分配

Unix系统编程()在堆上分配内存

Rust 是不是将添加到向量中的单个项目装箱?

Go 语言中的变量究竟是分配在栈上还是分配在堆上?

Go 语言中的变量究竟是分配在栈上还是分配在堆上?

在堆上“分解” c++ 数组是不是安全?