Rust中的Collat​​z猜想:功能性命令式方法

时间:2016-09-05 15:10:45

标签: optimization functional-programming rust

我想和一些好的Collatz conjecture一起玩,并决定以(非常)功能的方式做这件事很有趣,所以我实现了一个unfoldr函数,靠近一个Haskell has

fn unfoldr<F, T>(foo: F, seed: T, mut vec: Vec<T>) -> Vec<T>
    where F: Fn(T) -> Option<(T, T)>
{
    if let Some((x, y)) = foo(seed) {
        vec.push(x);
        unfoldr(foo, y, vec)
    } else {
        vec
    }
}

其余的非常简单:

fn collatz_next(n: u64) -> u64 {
    if n % 2 == 0 { n / 2 } else { 3 * n + 1 }
}

pub fn collatz_seq_f(n: u64) -> Vec<u64> {
    unfoldr(|n| if n == 1 { None } else { Some((n, collatz_next(n))) }, n, Vec::new())
}

collatz_seq_f返回Vec tor,其序列以给定数字n开头。

但是,我想知道,如果Rust赞同这种风格,并实施了一个简单的命令式对应物:

pub fn collatz_seq_i(n: u64, mut vec: Vec<u64>) -> Vec<u64> {
    let mut c = n;
    while c != 1 {
        vec.push(c);
        c = collatz_next(c);
    }
    vec
}

并将它们与cargo bench(0.13.0-nightly(2ef3cde 2016-09-04))进行比较。我有点失望的是,我的乐趣unfoldr方法只是命令式实施的一半:

running 3 tests
test tests::it_works ... ignored
test tests::bench_collatz_functional ... bench:         900 ns/iter (+/- 47)
test tests::bench_collatz_imperative ... bench:         455 ns/iter (+/- 29)

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

我知道unfoldr版本更抽象,但我没想到差别太大;有什么我可以改变以使其更快?

以下完整代码:

#![feature(test)]

extern crate test;

fn unfoldr<F, T>(foo: F, seed: T, mut vec: Vec<T>) -> Vec<T>
    where F: Fn(T) -> Option<(T, T)>
{
    if let Some((x, y)) = foo(seed) {
        vec.push(x);
        unfoldr(foo, y, vec)
    } else {
        vec
    }
}

fn collatz_next(n: u64) -> u64 {
    if n % 2 == 0 { n / 2 } else { 3 * n + 1 }
}

pub fn collatz_seq_f(n: u64) -> Vec<u64> {
    unfoldr(|n| if n == 1 { None } else { Some((n, collatz_next(n))) }, n, Vec::new())
}

pub fn collatz_seq_i(n: u64, mut vec: Vec<u64>) -> Vec<u64> {
    let mut c = n;
    while c != 1 {
        vec.push(c);
        c = collatz_next(c);
    }
    vec
}

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

    #[test]
    fn it_works() {
        assert_eq!(110, collatz_seq_f(27).len());
        assert_eq!(110, collatz_seq_i(27, Vec::new()).len());
    }

    #[bench]
    fn bench_collatz_functional(b: &mut Bencher) {
        b.iter(|| collatz_seq_f(27));
    }

    #[bench]
    fn bench_collatz_imperative(b: &mut Bencher) {
        b.iter(|| collatz_seq_i(27, Vec::new()));
    }
}

2 个答案:

答案 0 :(得分:5)

这不是一个答案,而是一个额外的测试,以缩小性能影响的来源。我通过编写递归函数

展开Some开销
pub fn collatz_seq_r(n: u64, mut vec: Vec<u64>) -> Vec<u64> {
    if n == 1 {
        vec
    } else {
        vec.push(n);
        collatz_seq_r(collatz_next(n), vec)
    } 
}

我获得了与collatz_seq_f示例几乎相同的性能。似乎LLVM没有展开这个递归调用。

替代

在考虑如何在Rust中执行此操作之后,我很可能已经实现了一个迭代器,其作用是使用函数连续组合先前的值,从而提供非终止的sequection:n, f(n), f(f(n)), ..., f^k(n), ...。这可以这样做:

struct Compose<T, F> {
    value: T,
    func: F
}

impl<T, F> Iterator for Compose<T, F> 
    where T: Copy,
          F: Fn(T) -> T {
    type Item = T;

    fn next(&mut self) -> Option<T> {
        let res = self.value;                    // f^k(n)
        self.value = (self.func)(self.value);    // f^{k+1}(n)
        Some(res)
    }
}

impl<T, F> Compose<T, F> {
    fn new(seed: T, func: F) -> Compose<T, F> {
        Compose { 
            value: seed,
            func: func
        }
    }
}

所以在这里我可以调用Compose::new(seed_value, function)来获取组合的迭代器。然后生成一个Collat​​z序列变为:

pub fn collatz_seq_iter(n: u64) -> Vec<u64> {
    Compose::new(n, collatz_next)
             .take_while(|&n| n != 1)
             .collect::<Vec<_>>()
}

有了这个,我得到了基准:

test tests::bench_collatz_functional ... bench:         867 ns/iter (+/- 28)
test tests::bench_collatz_imperative ... bench:         374 ns/iter (+/- 9)
test tests::bench_collatz_iterators  ... bench:         473 ns/iter (+/- 9)
test tests::bench_collatz_recursive  ... bench:         838 ns/iter (+/- 29)

但有趣的是,如果你决定只关心大小,那么调用:Compose::new(n, collatz_next).take_while(|&n| n != 1).count() as u64与在命令式方法中删除vec.push(c)行几乎完全相同:

test tests::bench_collatz_imperative ... bench:         162 ns/iter (+/- 6)
test tests::bench_collatz_iterators  ... bench:         163 ns/iter (+/- 4)

答案 1 :(得分:4)

这将包含为什么unfoldr有点慢的实现细节。

我提出了一个不同的变体,@ breeden帮助我验证它是一种改进,使其与性能命令式变体相匹配。它确实保留了递归,但我们不能再将其称为函数.. [^ 1]

fn unfoldr2<F, T>(foo: F, seed: T, vec: &mut Vec<T>)
    where F: Fn(T) -> Option<(T, T)>
{
    if let Some((x, y)) = foo(seed) {
        vec.push(x);
        unfoldr2(foo, y, vec)
    }
}

fn collatz_next(n: u64) -> u64 {
    if n % 2 == 0 { n / 2 } else { 3 * n + 1 }
}

pub fn collatz_seq_f(n: u64) -> Vec<u64> {
    let mut v = Vec::new();
    unfoldr2(|n| if n == 1 { None } else { Some((n, collatz_next(n))) }, n, &mut v);
    v
}

这里的差异将说明第一个版本出了什么问题。在unfoldr中,有一个vec值被携带,而在unfoldr2中,只有一个可变的向量引用。

vec值在unfoldr中有效,你发现它限制了编译器:unwinding。放松是一个功能恐慌时会发生的事情。如果它通过unfoldr函数展开,则必须删除所有局部变量,这意味着vec。插入一些特殊代码来处理这个问题(称为“登陆垫”)和可能发生恐慌的函数调用插入指令以便在恐慌时转移到着陆点。

所以在unfoldr

  1. 有一个局部变量,它有一个析构函数vec
  2. 有一个可能出现恐慌的函数调用(vec.push对容量溢出造成恐慌)
  3. 有一个降落vec并恢复展开的着陆垫
  4. 此外,还有移动Vec值的代码。 (它被复制到堆栈以用于登陆垫代码)。

    unfoldr2没有得到任何魔法递归 - 只是一个循环优化,但它仍然有较少的代码,因为它不需要处理展开或移动Vec。

    [^ 1]:我们可以通过将vec.push(x)想象为流/生成器/输出器的接口,或仅仅是回调来挽救功能性的吗?