有什么方法可以直接在堆上分配标准的Rust数组,完全跳过堆栈吗?

时间:2018-12-09 09:36:06

标签: arrays rust heap

关于在堆上分配数组(例如[i32])的问题,Stack Overflow已经有几个问题。一般建议是拳击,例如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,因此我可以在堆栈上分配它,但我真的希望有1千万。

现在考虑使用同一程序,但改为使用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<T>结构本身放慢了堆,而是Vec<T>结构本身。

我也尝试过使用HashMap(特别是HashMap<usize, i32>来模仿数组结构),但这比Vec<T>解决方案要慢得多。

如果我的LENGTH是一千万,那么第一个版本甚至都无法运行。

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

2 个答案:

答案 0 :(得分:7)

摘要:您的基准测试存在缺陷;只需使用Vec(如here所述),可能与into_boxed_slice一起使用,因为它不可能比堆分配的数组慢。


不幸的是,您的基准存在缺陷。首先,您可能没有进行优化编译(对于货物为--release,对于rustc为-O)。因为如果有的话,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<T>的数据部分具有与[T]完全相同的内存布局:内存中仅连续有T个。超级简单。这也意味着它们都表现出相同的缓存行为(特别是非常友好的缓存)。

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

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

但是,智能优化器通常已经删除了边界检查。在我上面发布的基准代码中,程序集中没有边界检查。因此,尽管通过删除边界检查可以使装箱的数组快一些,但(a)这将是次要的性能差异,并且(b)在很多情况下,为数组删除边界检查的可能性很小,但是不适用于Vec /切片。

答案 1 :(得分:0)

如果您确实要使用堆分配的数组,即Box<[i32; LENGTH]>,则可以使用:

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即使在数组上也进行边界检查,因此它不会比使用向量更快,但是它具有较小的接口,这可能在软件设计方面很有意义。