零成本抽象:for循环与迭代器的性能比较

13

阅读零成本抽象和观看介绍Rust:带有高级抽象的低级语言,我尝试比较计算向量点积的两种方法:一种使用for循环,另一种使用迭代器。

#![feature(test)]

extern crate rand;
extern crate test;

use std::cmp::min;

fn dot_product_1(x: &[f64], y: &[f64]) -> f64 {
    let mut result: f64 = 0.0;
    for i in 0..min(x.len(), y.len()) {
        result += x[i] * y[i];
    }
    return result;
}

fn dot_product_2(x: &[f64], y: &[f64]) -> f64 {
    x.iter().zip(y).map(|(&a, &b)| a * b).sum::<f64>()
}

#[cfg(test)]
mod bench {
    use test::Bencher;
    use rand::{Rng,thread_rng};
    use super::*;

    const LEN: usize = 30;

    #[test]
    fn test_1() {
        let x = [1.0, 2.0, 3.0];
        let y = [2.0, 4.0, 6.0];
        let result = dot_product_1(&x, &y);
        assert_eq!(result, 28.0);
    }

    #[test]
    fn test_2() {
        let x = [1.0, 2.0, 3.0];
        let y = [2.0, 4.0, 6.0];
        let result = dot_product_2(&x, &y);
        assert_eq!(result, 28.0);
    }

    fn rand_array(cnt: u32) -> Vec<f64> {
        let mut rng = thread_rng();
        (0..cnt).map(|_| rng.gen::<f64>()).collect()

    }

    #[bench]
    fn bench_small_1(b: &mut Bencher) {
        let samples = rand_array(2*LEN as u32);
        b.iter(|| {
            dot_product_1(&samples[0..LEN], &samples[LEN..2*LEN])
        })
    }

    #[bench]
    fn bench_small_2(b: &mut Bencher) {
        let samples = rand_array(2*LEN as u32);
        b.iter(|| {
            dot_product_2(&samples[0..LEN], &samples[LEN..2*LEN])
        })
    }
}

上述链接中后面的那个声称,带有迭代器的版本应该具有类似的性能“实际上会更快一点”。然而,当对这两者进行基准测试时,我得到了非常不同的结果:

running 2 tests
test bench::bench_small_loop ... bench:          20 ns/iter (+/- 1)
test bench::bench_small_iter ... bench:          24 ns/iter (+/- 2)

test result: ok. 0 passed; 0 failed; 0 ignored; 2 measured; 0 filtered out

那么,“零成本抽象”去哪了?

更新: 添加了@wimh提供的foldr示例,并使用split_at代替切片,得到以下结果。

running 3 tests
test bench::bench_small_fold ... bench:          18 ns/iter (+/- 1)
test bench::bench_small_iter ... bench:          21 ns/iter (+/- 1)
test bench::bench_small_loop ... bench:          24 ns/iter (+/- 1)

test result: ok. 0 passed; 0 failed; 0 ignored; 3 measured; 0 filtered out

看起来额外的时间直接或间接地来自于在测量代码内部构建这些片段。为了确认这确实是情况,我尝试了以下两种方法,结果相同(以下示例展示了foldr案例和使用map + sum):

因此,看起来额外的时间是由测量代码内部构建这些片段所导致的,无论是直接还是间接的。为了验证这个情况,我尝试了以下两种方法,结果相同(下面是foldr的案例和使用map + sum的示例)。

#[bench]
fn bench_small_iter(b: &mut Bencher) {
    let samples = rand_array(2 * LEN);
    let s0 = &samples[0..LEN];
    let s1 = &samples[LEN..2 * LEN];
    b.iter(|| dot_product_iter(s0, s1))
}

#[bench]
fn bench_small_fold(b: &mut Bencher) {
    let samples = rand_array(2 * LEN);
    let (s0, s1) = samples.split_at(LEN);
    b.iter(|| dot_product_fold(s0, s1))
}

1
非常有趣!就我所看到的(https://rust.godbolt.org/z/8HCeTI):(a)“loop”版本仍然具有边界检查,(b)“iter”版本的汇编代码检查长度是否为4的倍数,然后使用循环一次性处理4个浮点数(部分循环展开)。即使如此,在我的机器上,我仍然看到大致相同的时间(20ms / 23ms)。相当一致。 - Lukas Kalbertodt
dot_product_iter 调用末尾的分号应该被移除,否则编译器可能会决定完全消除它(虽然它似乎没有这样做)。 - David Brown
1
随着 LEN 的增加,我发现运行时间越来越接近,如果我使两个函数都 #[inline(never)],那么循环变量始终比较慢(内联版本可能可以避免循环边界检查)。 - David Brown
1
@DavidBrown 当增加 LEN 时,iter 获得了净优势。 - Stargateur
@LukasKalbertodt 是的,我编译了迭代器版本循环版本,并注意到它们的表现相同。我不明白的是,为什么它们的性能差异如此之大,即使迭代器版本使用了循环展开。对于流水线来说,这应该非常高效,但出于某种原因却不是这样。 - Mats Kindahl
显示剩余5条评论
2个回答

7

对我来说,这似乎是零成本的。我稍微更加地符合语言习惯地编写了您的代码,并使用相同的随机值进行了多次测试:

fn dot_product_1(x: &[f64], y: &[f64]) -> f64 {
    let mut result: f64 = 0.0;
    for i in 0..min(x.len(), y.len()) {
        result += x[i] * y[i];
    }
    result
}

fn dot_product_2(x: &[f64], y: &[f64]) -> f64 {
    x.iter().zip(y).map(|(&a, &b)| a * b).sum()
}

fn rand_array(cnt: usize) -> Vec<f64> {
    let mut rng = rand::rngs::StdRng::seed_from_u64(42);
    rng.sample_iter(&rand::distributions::Standard).take(cnt).collect()
}

#[bench]
fn bench_small_1(b: &mut Bencher) {
    let samples = rand_array(2 * LEN);
    let (s0, s1) = samples.split_at(LEN);
    b.iter(|| dot_product_1(s0, s1))
}

#[bench]
fn bench_small_2(b: &mut Bencher) {
    let samples = rand_array(2 * LEN);
    let (s0, s1) = samples.split_at(LEN);
    b.iter(|| dot_product_2(s0, s1))
}

bench_small_1   20 ns/iter (+/- 6)
bench_small_2   17 ns/iter (+/- 1)

bench_small_1   19 ns/iter (+/- 3)
bench_small_2   17 ns/iter (+/- 2)

bench_small_1   19 ns/iter (+/- 5)
bench_small_2   17 ns/iter (+/- 3)

bench_small_1   19 ns/iter (+/- 3)
bench_small_2   24 ns/iter (+/- 7)

bench_small_1   28 ns/iter (+/- 1)
bench_small_2   24 ns/iter (+/- 1)

bench_small_1   27 ns/iter (+/- 1)
bench_small_2   25 ns/iter (+/- 1)

bench_small_1   28 ns/iter (+/- 1)
bench_small_2   25 ns/iter (+/- 1)

bench_small_1   28 ns/iter (+/- 1)
bench_small_2   25 ns/iter (+/- 1)

bench_small_1   28 ns/iter (+/- 0)
bench_small_2   25 ns/iter (+/- 1)

bench_small_1   28 ns/iter (+/- 1)
bench_small_2   17 ns/iter (+/- 1)

在10次运行中,有9次习惯用法的代码比for循环更快。这是在2.9 GHz Core i9(I9-8950HK)上完成的,配备32 GB RAM,编译使用 rustc 1.31.0-nightly (fc403ad98 2018-09-30)


确实非常有趣。我在这两个基准测试中始终得到20和24的分数。同时使用1.31.0版本。 - Mats Kindahl
Intel(R) Core(TM) i7-6600U CPU @ 2.60GHz,内存为16 GB RAM。 - Mats Kindahl
我拿了你的代码,在另一台机器上(Intel(R) Core(TM) i3-4160 CPU @ 3.60GHz)尝试了一下。出于某种原因,我得到了20ns和16ns。为什么? - Mats Kindahl
请注意,这很可能是由于Rust在“正常”代码中引入了低效性,例如需要对每个数组索引操作进行边界检查。如果正确使用,原始迭代在实践中总是更快的。 - Lemon Drop

3

为了好玩,我重新编写了基准测试,使用criterion,这是Haskell基准测试库的一个移植版本。

Cargo.toml

[package]
name = "mats-zero-cost-rust"
version = "0.1.0"
authors = ["mats"]

[dev-dependencies]
criterion = "0.2"
rand = "0.4"

[[bench]]
name = "my_benchmark"
harness = false

benches/my_benchmark.rs

#[macro_use]
extern crate criterion;
extern crate rand;

use std::cmp::min;

use criterion::Criterion;

use rand::{thread_rng, Rng};

const LEN: usize = 30;

fn dot_product_loop(x: &[f64], y: &[f64]) -> f64 {
    let mut result: f64 = 0.0;
    for i in 0..min(x.len(), y.len()) {
        result += x[i] * y[i];
    }
    return result;
}

fn dot_product_iter(x: &[f64], y: &[f64]) -> f64 {
    x.iter().zip(y).map(|(&a, &b)| a * b).sum()
}

fn rand_array(cnt: u32) -> Vec<f64> {
    let mut rng = thread_rng();
    (0..cnt).map(|_| rng.gen()).collect()
}

fn criterion_loop_with_slice(c: &mut Criterion) {
    c.bench_function("loop with slice", |b| {
        let samples = rand_array(2 * LEN as u32);
        b.iter(|| dot_product_loop(&samples[0..LEN], &samples[LEN..2 * LEN]))
    });
}

fn criterion_loop_without_slice(c: &mut Criterion) {
    c.bench_function("loop without slice", |b| {
        let samples = rand_array(2 * LEN as u32);
        let (s0, s1) = samples.split_at(LEN);
        b.iter(|| dot_product_loop(s0, s1))
    });
}

fn criterion_iter_with_slice(c: &mut Criterion) {
    c.bench_function("iterators with slice", |b| {
        let samples = rand_array(2 * LEN as u32);
        b.iter(|| dot_product_iter(&samples[0..LEN], &samples[LEN..2 * LEN]))
    });
}

fn criterion_iter_without_slice(c: &mut Criterion) {
    c.bench_function("iterators without slice", |b| {
        let samples = rand_array(2 * LEN as u32);
        let (s0, s1) = samples.split_at(LEN);
        b.iter(|| dot_product_iter(s0, s1))
    });
}

criterion_group!(benches, criterion_loop_with_slice, criterion_loop_without_slice, criterion_iter_with_slice, criterion_iter_without_slice);
criterion_main!(benches);

我观察到以下结果:
kolmodin@blin:~/code/mats-zero-cost-rust$ cargo bench
   Compiling mats-zero-cost-rust v0.1.0 (/home/kolmodin/code/mats-zero-cost-rust)                                                                                                                                  
    Finished release [optimized] target(s) in 1.16s                                                                                                                                                                
     Running target/release/deps/my_benchmark-6f00e042fd40bc1d
Gnuplot not found, disabling plotting
loop with slice         time:   [7.5794 ns 7.6843 ns 7.8016 ns]                             
Found 14 outliers among 100 measurements (14.00%)
  1 (1.00%) high mild
  13 (13.00%) high severe

loop without slice      time:   [24.384 ns 24.486 ns 24.589 ns]                                
Found 3 outliers among 100 measurements (3.00%)
  2 (2.00%) low severe
  1 (1.00%) low mild

iterators with slice    time:   [13.842 ns 13.852 ns 13.863 ns]                                  
Found 6 outliers among 100 measurements (6.00%)
  1 (1.00%) low mild
  4 (4.00%) high mild
  1 (1.00%) high severe

iterators without slice time:   [13.420 ns 13.424 ns 13.430 ns]                                     
Found 12 outliers among 100 measurements (12.00%)
  1 (1.00%) low mild
  1 (1.00%) high mild
  10 (10.00%) high severe

Gnuplot not found, disabling plotting

使用 rustc 1.30.0 (da5f414c2 2018-10-24) 在 AMD Ryzen 7 2700X 上。

在使用 slicesplit_at 时,迭代器的实现结果类似。

循环实现的结果非常不同。使用 slice 的版本明显更快。


非常感谢。我在2023年4月使用最新的Rust编译器rustc 1.68.2在Ryzen 3900X上运行,获得了以下结果:loop with slice = 10.863 ns, loop without slice = 11.533 ns, iterators with slice = 10.984 ns, iterators without slice = 11.696 ns - 因此,在Rust中使用迭代器和函数式编程回调是零成本的。太棒了! :) - Mitch McMabers

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