Rust和Haskell中的迭代器和流

357 阅读22分钟

流媒体数据是我在Haskell中经常玩的一个问题领域。在Haskell中,我们最接近内置的流式数据支持的是默认的懒惰,它不能完全捕捉流式数据。(我今天不打算讨论这些细节,但如果你想更好地理解这一点,在conduit 教程中有大量的信息)。真正的流媒体数据是在Haskell的库级处理的,有许多不同的选择。

Rust的做法不同:它不仅在标准库中,而且在语言本身中加入了一个叫做迭代器的概念:for 循环是迭代器的内置语法。关于在语言本身和库中解决问题,有一些有趣的权衡可以讨论,我不打算讨论这个问题。

另外,Rust的方法采用了状态机设计,而许多Haskell库则使用了循环程序。这一选择对于获得良好的性能是非常关键的,而且在Haskell世界中也适用。事实上,我已经在博客中提到了这个概念,我的名字叫Vegito概念。对于那些熟悉它的人来说:你会在这篇博文中看到一些交叉点,但之前的知识是不必要的。

在研究Rust中迭代器的实现时,我发现它的设计与Haskell的习惯性设计有很大的不同。试图将一种语言的设计照搬到另一种语言中,确实显示了两种语言的一些深刻差异,这也是我今天要尝试和深入研究的。

为了激励这里的例子,我们将尝试多次执行相同的计算:将1到100万的数字进行流式计算,只过滤偶数,将每个数字乘以2,然后将它们相加。你可以在Gist中找到所有的代码。下面是一个基准结果的概述,下面还有很多细节。

另外,每个函数都需要一个整数参数来告诉它应该计算的最高值(总是100万)。Criterion要求有这种参数,以确保Haskell编译器(GHC)不会优化掉我们的函数调用,给我们带来假的基准测试结果。

基准线和作弊

Gist包含了Haskell、C和Rust的代码,对同一类函数有许多不同的实现。国外的Haskell代码同时导入了Rust和C,并使用Criterion基准测试库对其进行基准测试。首先,我实现了每个基准的作弊版本。它不是真正的过滤和翻倍,而只是每次将计数器增加4,然后加到一个总数中。例如,在C语言中,这看起来像。

int c_cheating(int high) {
  int total = 0;
  int i;
  high *= 2;
  for (i = 4; i <= high; i += 4) {
    total += i;
  }
  return total;
}

相比之下,C语言中的非作弊循环版本是:。

int c_loop(int high) {
  int total = 0;
  int i;
  for (i = 1; i <= high; ++i) {
    if (i % 2 == 0) {
      total += i * 2;
    }
  }
  return total;
}

同样地,我们在Rust中也有作弊和循环的实现。

#[no_mangle]
pub extern fn rust_cheating(high: isize) -> isize {
    let mut total = 0;
    let mut i = 4;
    let high = high * 2;
    while i <= high {
        total += i;
        i += 4;
    }
    total
}

#[no_mangle]
pub extern fn rust_loop(mut high: isize) -> isize {
    let mut total = 0;
    while high > 0 {
        if high % 2 == 0 {
            total += high << 1;
        }

        high -= 1;
    }

    total
}

还有在Haskell中。Haskell使用递归来代替循环,但在表面下,编译器在汇编层面上把它变成了一个循环。

haskellCheating :: Int -> Int
haskellCheating high' =
  loop 0 4
  where
    loop !total !i
      | i <= high = loop (total + i) (i + 4)
      | otherwise = total
    high = high' * 2

recursion :: Int -> Int
recursion high =
  loop 1 0
  where
    loop !i !total
      | i > high = total
      | even i = loop (i + 1) (total + i * 2)
      | otherwise = loop (i + 1) total

这两组测试给了我们一些基线数字来比较我们要看的其他一切。首先,作弊的结果。

benchmarking C cheating
time                 87.13 ns   (86.26 ns .. 87.99 ns)
                     0.999 R²   (0.999 R² .. 1.000 R²)
mean                 86.87 ns   (86.08 ns .. 87.57 ns)
std dev              2.369 ns   (1.909 ns .. 3.127 ns)
variance introduced by outliers: 41% (moderately inflated)

benchmarking Rust cheating
time                 174.7 μs   (172.8 μs .. 176.9 μs)
                     0.998 R²   (0.997 R² .. 0.999 R²)
mean                 175.2 μs   (173.3 μs .. 177.3 μs)
std dev              6.869 μs   (5.791 μs .. 8.762 μs)
variance introduced by outliers: 37% (moderately inflated)

benchmarking Haskell cheating
time                 175.2 μs   (172.2 μs .. 178.9 μs)
                     0.998 R²   (0.995 R² .. 0.999 R²)
mean                 174.6 μs   (172.9 μs .. 176.8 μs)
std dev              6.427 μs   (4.977 μs .. 9.365 μs)
variance introduced by outliers: 34% (moderately inflated)

你可能会感到惊讶,C语言的速度大约是Rust和Haskell的两倍。但是再看看。C语言需要87纳秒,而Rust和Haskell都需要大约175微秒。事实证明,GCC能够将其优化为一个向下计数的循环,这极大地提高了性能。我们可以在Rust和Haskell中做类似的事情来达到纳秒级的性能,但这不是我们今天的目标。我不得不说:GCC做得很好。

非作弊的结果仍然有利于C,但程度不一样。

benchmarking C loop
time                 636.3 μs   (631.8 μs .. 640.5 μs)
                     0.999 R²   (0.998 R² .. 0.999 R²)
mean                 636.3 μs   (629.9 μs .. 642.9 μs)
std dev              22.67 μs   (18.76 μs .. 27.87 μs)
variance introduced by outliers: 27% (moderately inflated)

benchmarking Rust loop
time                 632.8 μs   (623.8 μs .. 640.4 μs)
                     0.999 R²   (0.998 R² .. 0.999 R²)
mean                 626.9 μs   (621.4 μs .. 631.9 μs)
std dev              18.45 μs   (14.97 μs .. 23.18 μs)
variance introduced by outliers: 20% (moderately inflated)

benchmarking Haskell recursion
time                 741.9 μs   (733.1 μs .. 755.0 μs)
                     0.998 R²   (0.996 R² .. 0.999 R²)
mean                 748.7 μs   (739.8 μs .. 762.8 μs)
std dev              36.37 μs   (29.18 μs .. 52.40 μs)
variance introduced by outliers: 41% (moderately inflated)

EDIT最初这篇文章列出了一个C语言循环的性能数字,认为它比Rust快。然而,正如Reddit上所指出的,有关代码错误地使用了int ,而不是int64_t ,以匹配Rust和Haskell的行为。这些数字已经更新。

所有的结果都是同一个数量级的。C和Rust并驾齐驱,而Haskell落后了大约15%。了解这些语言的性能差异本身就是一个有趣的话题,但我们今天的目标是比较高级别的API,看看它们如何影响每种语言的性能。因此,在这篇文章的其余部分,我们将专注于比较语言内部的性能数字。

Rust的迭代器

好了,说了这么多,我们来看看Rust使用迭代器的实现。该代码简洁、可读、优雅。

#[no_mangle]
pub extern fn rust_iters(high: isize) -> isize {
    (1..high + 1)
        .filter(|x| x % 2 == 0)
        .map(|x| x * 2)
        .sum()
}

我们可以将其与使用列表或向量的Haskell实现进行相当直接的比较。

list :: Int -> Int
list high =
  sum $ map (* 2) $ filter even $ enumFromTo 1 high

unboxedVector :: Int -> Int
unboxedVector high =
  VU.sum $ VU.map (* 2) $ VU.filter even $ VU.enumFromTo 1 high

这是Haskell和Rust之间第一个有趣的API区别。在Haskell中,sum,map, 和filter 都是应用于现有列表或向量的函数。你会注意到,在向量的情况下,我们需要使用合格的导入VU. ,以确保我们得到函数的正确版本。相比之下,在Rust中,我们只是在调用Iterator 特质的方法。这意味着不需要命名,但另一方面,添加一个新的迭代器适配器将意味着新的函数不会像其他函数那样遵循相同的函数调用语法。(对我来说,这似乎是表达式问题的一个淡化版本)。

EDIT正如Reddit上指出的,一个扩展特性可以允许新的方法被添加到所有迭代器中。

这似乎不是什么大事,但它确实显示了两种语言在处理命名间距方面的内在差异,而且影响相当普遍。我认为这是一个相当表面的区别,但却是一个值得注意的重要区别。

benchmarking Rust iters
time                 919.5 μs   (905.5 μs .. 936.0 μs)
                     0.998 R²   (0.998 R² .. 0.999 R²)
mean                 919.1 μs   (910.4 μs .. 926.7 μs)
std dev              28.63 μs   (24.52 μs .. 33.91 μs)
variance introduced by outliers: 21% (moderately inflated)

benchmarking Haskell unboxed vector
time                 733.3 μs   (722.6 μs .. 745.2 μs)
                     0.998 R²   (0.996 R² .. 0.999 R²)
mean                 742.4 μs   (732.2 μs .. 752.8 μs)
std dev              33.42 μs   (28.01 μs .. 41.24 μs)
variance introduced by outliers: 36% (moderately inflated)

benchmarking Haskell list
time                 714.0 μs   (707.0 μs .. 720.8 μs)
                     0.999 R²   (0.998 R² .. 0.999 R²)
mean                 710.4 μs   (702.7 μs .. 719.4 μs)
std dev              26.49 μs   (21.79 μs .. 33.72 μs)
variance introduced by outliers: 29% (moderately inflated)

有趣的是。虽然Haskell的基准测试与低级别的递归方法差不多,但Rust的迭代器实现明显比低级别的循环慢。关于这一点,我有自己的理论,下面我将分享。不幸的是,我的Rust技术还不够强大,无法正确测试我的假设。

在Haskell中实现迭代器

在Rust中,有一个Iterator 特质,它有一个相关的类型Item 和一个方法next 。撇开一些我们在此不关心的额外方法,它看起来是这样的。

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option;
}

让我们把它直接翻译成Haskell。

class Iterator iter where
  type Item iter
  next :: iter -> Maybe (Item iter)

这看起来非常相似。值得注意的是一些基本的区别。

  • 在Rust中,我们用pub 来表示某物是否公开暴露。在Haskell中,我们在模块上使用导出列表。
  • 而不是Self ,Haskell使用类型变量(我在这里称它为iter )。
  • 函数签名语法是不同的
  • Rust跟踪关于可变性和引用的信息。这是一个很大的区别,在这篇文章中会有很多发挥,所以我在这里就不多说了
  • Rust说Option ,Haskell说Maybe

让我们用Rust做一个简单的实现。

struct PowerUp {
    curr: u32,
}

impl Iterator for PowerUp {
    type Item = u32;

    fn next(&mut self) -> Option {
        if self.curr > 9000 {
            None
        } else {
            let res = self.curr;
            self.curr += 1;
            Some(res)
        }
    }
}

这将遍历起始值和9000之间的所有数字。但有一句话我要特别提请大家注意。

self.curr += 1;

这就是突变,对于熟悉Haskell的人来说,你知道我们不怎么喜欢它。事实上,我们上面的Iterator 类型类根本就不能工作,因为它没有办法突变一个变量。为了使其工作,我们需要修改我们的类。因为我们会有很多这样的类,所以我打算开始给它们编号。

class Iterator1 iter where
  type Item1 iter
  next1 :: iter -> IO (Maybe (Item1 iter))

重点是,每次我们迭代我们的值时,都会产生一些突变变量的副作用。这是Rust和Haskell的一个重要区别。Rust跟踪单个值是否可以被变异。而且它甚至默认了(IMO)正确的不可变性行为。尽管如此,在一个函数的类型签名中,并没有表明它执行了副作用。

让我们在Haskell中发力吧。

data PowerUp = PowerUp (IORef Int)

instance Iterator1 PowerUp where
  type Item1 PowerUp = Int
  next1 (PowerUp ref) = do
    curr <- readIORef ref
    if curr > 9000
      then return Nothing
      else do
        writeIORef ref $! curr + 1
        return $ Just curr

忽略不重要的语法差异。

  • 在Haskell中,我们需要明确地用一个IORef (或类似的可变变量)来包装任何可变字段。
  • 同样地,我们需要使用显式的readIORefwriteIORef 函数来访问值,而不是在Rust中直接获取和修改值。
  • 你可能已经注意到在curr + 1 之前的$! 。如果你在上面仔细注意,在recursion 函数中,我也有类似的东西,loop !i !total 。这些都是Haskell中的特殊运算符和语法,用于强制评估。这是因为Haskell默认懒惰的,而Rust默认严格的

好吧,所以我继续前进,用这个Iterator1 类实现了所有的东西,最后得到了。

iterator1 :: Int -> Int
iterator1 high =
  unsafePerformIO $
  enumFromTo1 1 high >>=
  filter1 even >>=
  map1 (* 2) >>=
  sum1

我们在这里使用了unsafePerformIO ,因为我们想纯粹地运行这个函数,但它在执行副作用。在Haskell中,更好的方法是使用ST 类型,但我在这里是为了简单。我不打算在这里复制类型的实现;如果你好奇,请看一下Gist。

现在我们来看看性能。

benchmarking Haskell iterator 1
time                 5.181 ms   (5.108 ms .. 5.241 ms)
                     0.997 R²   (0.993 R² .. 0.999 R²)
mean                 5.192 ms   (5.140 ms .. 5.267 ms)
std dev              179.5 μs   (125.3 μs .. 294.5 μs)
variance introduced by outliers: 16% (moderately inflated)

这是5毫秒,或5000微秒。这意味着,比递归、列表和向量要慢得多。所以,我们已经遇到了三个障碍。

  • 代码看起来没有列表/向量版本那么干净
  • 我们不得不撤掉unsafePerformIO
  • 而且性能很差

我想习惯性的Rust在Haskell中并不是那么习惯性。

盒式与非盒式

Haskell爱好者可能已经注意到了我所介绍的一个主要的性能瓶颈。IORef盒状的数据结构。意思是:它们所包含的数据实际上是一个指向堆对象的指针。IORef这意味着,每当我们向一个Int ,我们就必须。

  • 分配一个新的堆对象来容纳它。该堆对象需要大到足以容纳有效载荷(一个机器字)和数据构造器(另一个机器字)。
  • 更新IORef 里面的指针到新的堆对象。
  • 垃圾收集旧的堆对象。这不会立即发生,而是每次迭代都会产生开销。

幸运的是,这有一个解决方法:非盒式引用。有一个库提供了它们,在我们的实现中切换到它们会使运行时间下降到。

benchmarking Haskell iterator 1 unboxed
time                 2.966 ms   (2.938 ms .. 2.995 ms)
                     0.999 R²   (0.999 R² .. 1.000 R²)
mean                 2.974 ms   (2.952 ms .. 3.007 ms)
std dev              84.05 μs   (57.67 μs .. 145.2 μs)
variance introduced by outliers: 13% (moderately inflated)

好多了,但仍然不是很好。一个简单的事实是,Haskell并没有为处理可变数据进行优化。在Haskell中,有一些用例仍然可以很好地处理易变数据,但这种低层次的、紧密的内循环并不是其中之一。

顺便提一下:尽管我在暗示盒式引用在Haskell中是个糟糕的东西,但它们有一些很大的优势。最大的是原子操作:像atomicModifyIORef 或整个软件事务性内存(STM)的操作,利用了它们可以在堆上创建一个新的多机字数据结构,然后原子地更新一机字指针的事实。这是很有趣的。

不可变的

好了,可变变量的方法似乎是一个死胡同。让我们用Haskell的方式来实现更多的成语:不可变的值。我们将接收一个迭代器状态,并返回一个更新的状态。

data Step2 iter
  = Done2
  | Yield2 !iter !(Item2 iter)

class Iterator2 iter where
  type Item2 iter
  next2 :: iter -> Step2 iter

我们已经添加了一个辅助数据类型来捕捉这里发生的事情。在每个迭代中,我们可以完成,或者为我们的迭代器产生一个新的值和一个新的状态。IO 位已经消失了,因为没有发生突变的副作用。这个实现被证明是非常低效的。

benchmarking Haskell iterator 2
time                 15.80 ms   (15.06 ms .. 16.64 ms)
                     0.992 R²   (0.987 R² .. 0.998 R²)
mean                 15.16 ms   (14.99 ms .. 15.41 ms)
std dev              561.5 μs   (363.6 μs .. 934.6 μs)
variance introduced by outliers: 11% (moderately inflated)

为什么这么讨厌?事实证明,我们只是加剧了我们之前的问题。之前,每次迭代都会导致一个新的Int 堆对象被创建,一个指针被更新。现在,每次迭代都会导致一堆新的堆对象,也就是我们所有代表各种函数的数据类型。

data EnumFromTo2 a = EnumFromTo2 !a !a
data Filter2 a iter = Filter2 !(a -> Bool) !iter
data Map2 a b iter = Map2 !(a -> b) !iter

这些对象在我们每次迭代时都被建立和拆除,这在性能上是非常可怜的。如果Haskell能在这些数据构造函数中内嵌iter 字段(通过UNPACK pragma),生活会更好,但GHC不能解压多态字段。所以我们每次都在创建3个新的堆对象。

我在Gist中包含了Iterator3 ,它将事物进行了大量的单态化,以允许内联。正如预期的那样,它大大改善了性能。

benchmarking Haskell iterator 3
time                 8.391 ms   (8.161 ms .. 8.638 ms)
                     0.996 R²   (0.994 R² .. 0.999 R²)
mean                 8.397 ms   (8.301 ms .. 8.517 ms)
std dev              300.0 μs   (218.4 μs .. 443.9 μs)
variance introduced by outliers: 14% (moderately inflated)

但它仍然很糟糕。这里有一些更基本的错误。

函数是数据

到目前为止,在Haskell中,我们一直坚持采用Rust的方法。

  • 声明一个trait(类型类)。
  • 为每个操作定义一个数据类型
  • 为每个数据类型实现该特性/建立该类

这对Rust来说似乎非常好用(下面会有更多的介绍)。但它既不是习惯性的Haskell代码,也不能与Haskell的运行时行为和垃圾收集器很好地配合。让我们记住,在Haskell中,函数是数据,并且完全绕过了类型分类。

data Step4 s a
  = Done4
  | Yield4 s a

data Iterator4 a = forall s. Iterator4 s (s -> Step4 s a)

我们的Step4 数据类型有两个类型变量:s 是迭代器的内部状态,而a 是要产生的下一个值。现在最酷的部分是:Iterator4 说 "好吧,外部世界关心a 类型变量,但内部状态是不相关的"。所以它用一个存在式来表示 "这对所有可能的内部状态都有效"。

然后我们有两个字段:状态的当前值,以及一个从当前状态获得下一步的函数。为了真正实现这一点,我们需要看一下一些实现。

enumFromTo4 :: (Ord a, Num a) => a -> a -> Iterator4 a
enumFromTo4 start high =
  Iterator4 start f
  where
    f i
      | i > high  = Done4
      | otherwise = Yield4 (i + 1) i

我们定义了一个辅助函数f 。这个f 函数在enumFromTo4 的整个生命周期中保持不变。只有它所传递的i 值被更新。让我们看看我们将如何调用这些Iterator4s之一。

sum4 :: Num a => Iterator4 a -> a
sum4 (Iterator4 s0 next) =
  loop 0 s0
  where
    loop !total !s1 =
      case next s1 of
        Done4 -> total
        Yield4 s2 x -> loop (total + x) s2

我们捕获一次next 函数,然后在整个循环中使用它。这看起来与之前的代码没有太大区别:我们仍然需要创建一个新的状态值,并在每次迭代时销毁它。然而,情况并非如此。GHC很聪明地意识到我们的状态只是一个单一的机器Int ,并最终将其存储在一个机器寄存器中,完全绕过了堆的分配。

不过先不要太兴奋。虽然我们已经减少了迭代器2和3,但我们的性能仍然很差。

benchmarking Haskell iterator 4
time                 3.614 ms   (3.559 ms .. 3.669 ms)
                     0.998 R²   (0.997 R² .. 0.999 R²)
mean                 3.590 ms   (3.542 ms .. 3.641 ms)
std dev              151.4 μs   (116.4 μs .. 192.4 μs)
variance introduced by outliers: 24% (moderately inflated)

我们的性能袖子里还有一招,在我们的赞助商的消息之后。

为什么Rust喜欢数据类型

我们已经看到,Rust的实现对每个操作都使用单独的数据类型。但可以肯定的是,有了它的第一类函数,它应该可以做和Haskell一样的事情,对吗?我不是Rust专家,但我相信答案是:是的,但性能会受到很大影响。

为了解释原因,请考虑表达式的类型(1..high + 1).filter(|x| x % 2 == 0).map(|x| x * 2)

std::iter::Map<
  std::iter::Filter<
    std::ops::Range,
    [closure@rust.rs:7:17: 7:31]
  >,
  [closure@rust.rs:8:14: 8:23]
>

作为一个Haskeller,当我第一次意识到这个类型被采用时,我相当困惑。相比之下,等价的Haskell表达式map4 (* 2) $ filter4 even $ enumFromTo4 1 high 的类型只是Iterator Int ,这似乎要直接得多。

问题就在这里:Haskell很高兴--也许是太高兴了--只是把数据贴在堆上,然后忘掉它。我们并不关心数据的大小,因为我们只是把一个指针插入数据结构中。相比之下,Rust在数据被存储在堆栈上时表现得非常好。为了实现这一点,它需要知道有关数据的确切大小。因此,Filter 数据结构不是说 "是的,我只是在任何实现Iterator 的数据结构上工作",而是在所有可能的实现上通用。

这与我之前提到的Haskell中缺乏多态解包的情况类似,在很多情况下导致了固有的不同编码风格,包括这个。另外,Rust的这种行为与我们上面用来从类型签名中明确隐藏内部状态的存在式是直接矛盾的,而在Rust中我们是在炫耀它。

一个单一的循环

好了,回到我们的低端性能问题上。我们现在可以做一堆性能分析,看看GHC生成的内核和汇编,然后花几个月时间写一篇关于如何提高性能的论文。幸运的是,已经有人做到了。为了理解这个问题,让我们再看一下我们的Iterator4 。我们已经看到在sum4 的实现中存在一个循环,正如你所期望的那样。让我们看看filter4

filter4 :: (a -> Bool) -> Iterator4 a -> Iterator4 a
filter4 predicate (Iterator4 s0 next) =
  Iterator4 s0 loop
  where
    loop s1 =
      case next s1 of
        Done4 -> Done4
        Yield4 s2 x
          | predicate x -> Yield4 s2 x
          | otherwise   -> loop s2

注意这里也有一个循环:如果谓词失败,我们需要放弃一个值,因此需要再次调用next 。事实证明,GHC在优化有单个循环的代码方面非常出色,但是当你有两个嵌套的循环时,性能就会严重下降,就像我们这里一样。

流融合论文提供了这个问题的解决方案:用一个Skip 构造函数来扩展我们的Step 数据类型,它表示 "用一个新的状态再次循环,但我没有任何新的数据可用"。

data Step5 s a
  = Done5
  | Skip5 s
  | Yield5 s a

然后我们的实现就有了一些变化。filter5 成为。

filter5 :: (a -> Bool) -> Iterator5 a -> Iterator5 a
filter5 predicate (Iterator5 s0 next) =
  Iterator5 s0 noloop
  where
    noloop s1 =
      case next s1 of
        Done5 -> Done5
        Skip5 s2 -> Skip5 s2
        Yield5 s2 x
          | predicate x -> Yield5 s2 x
          | otherwise   -> Skip5 s2

请注意,完全没有循环。如果谓词失败了,我们只需Skip5sum5 的实现也必须改变。

sum5 :: Num a => Iterator5 a -> a
sum5 (Iterator5 s0 next) =
  loop 0 s0
  where
    loop !total !s1 =
      case next s1 of
        Done5 -> total
        Skip5 s2 -> loop total s2
        Yield5 s2 x -> loop (total + x) s2

提示鼓声......我们的性能现在是。

benchmarking Haskell iterator 5
time                 744.5 μs   (732.1 μs .. 761.7 μs)
                     0.996 R²   (0.994 R² .. 0.998 R²)
mean                 768.6 μs   (757.9 μs .. 780.8 μs)
std dev              38.18 μs   (31.22 μs .. 48.98 μs)
variance introduced by outliers: 41% (moderately inflated)

呼,我们已经回到了递归级别的性能。精明的读者可能会想,既然列表和向量都有类似的性能,我们为什么还要这么做呢?有几件事。

  • 向量在表面下使用这种流融合技术
  • 列表使用了一个不同的融合框架(build/foldr),它与流融合有不同的权衡,因此处理其他一些情况要差得多。
  • 我们可以扩展这个Iterator5 ,以包括IO ,并在动作之间执行副作用(如从文件中读取),这在列表中是做不到的。

我们最终得到了成语式的Haskell代码,不涉及不必要的数据类型或类型类,利用一流的函数,并处理不可变的数据。我们增加了一个专门为GHC的首选代码结构定制的优化。我们得到了相对简单的高水平代码,并具有很好的性能。

最后,在这一过程中,我们看到了一些地方,Rust和Haskell对同一个问题采取了非常不同的方法。我个人的收获是,凭借其堆友好、垃圾收集的特性,Haskell代码的性能可以与Rust竞争,这是非常令人震惊的。

为什么Rust的迭代器比循环慢?

如果你还记得,从Rust循环到Rust迭代器时,有一个实质性的减慢。这让我有点失望。我想了解一下原因。不幸的是,我现在没有答案,只有一种预感。而这个预感就是双内循环的问题在作祟。这只是现在的猜想。

我试着在Rust中实现了一个 "流融合 "式的实现,看起来像这样。

enum Step {
    Done,
    Skip,
    Yield(T),
}

trait Stream {
    type Item;
    fn next(&mut self) -> Step;
}

几乎与Iterator 相同,只是它使用Step ,而不是Option ,允许Skipping的可能性。不幸的是,我看到那里的速度变慢了。

benchmarking Rust stream
time                 958.7 μs   (931.2 μs .. 1.007 ms)
                     0.968 R²   (0.925 R² .. 0.999 R²)
mean                 968.0 μs   (944.3 μs .. 1.019 ms)
std dev              124.1 μs   (45.79 μs .. 212.7 μs)
variance introduced by outliers: 82% (severely inflated)

这可能有很多原因,包括对Option 相对于我的Step 枚举有更好的优化,或者只是我没有能力写出高性能的Rust代码。(或者说我的理论就是大错特错,而跳过只是碍事)。

然后我决定尝试一个类似的方法,用不可变的状态值代替可变的状态值,看起来像。

enum StepI {
    Done,
    Skip(S),
    Yield(S, T),
}

trait StreamI where Self: Sized {
    type Item;
    fn next(self) -> StepI;
}

这个实现比易变的那个快一点,很可能是由于我的用户错误。

benchmarking Rust stream immutable
time                 878.4 μs   (866.9 μs .. 888.9 μs)
                     0.998 R²   (0.997 R² .. 0.999 R²)
mean                 889.1 μs   (878.7 μs .. 906.0 μs)
std dev              44.75 μs   (27.17 μs .. 86.29 μs)
variance introduced by outliers: 41% (moderately inflated)

我在这里的一个重要收获是Rust中移动语义的影响。完全 "消费 "一个输入值并防止它再次被使用的能力是我经常想在Haskell中陈述的,但无法做到。另一方面:在Rust中处理移动的值感觉很棘手,但这可能只是缺乏经验的表现。

我在Rust中尝试的最终实现是明确地传递闭包,就像我们在Haskell中所做的那样(尽管包括可移动变量)。我不确定我是否选择了最好的表示方法,但最后还是选择了。

struct NoTrait {
    next: Box<(FnMut() -> Option)>,
}

作为一个例子,range 函数看起来像这样。

fn range_nt(mut low: isize, high: isize) -> NoTrait {
    NoTrait {
        next: Box::new(move || {
            if low >= high {
                None
            } else {
                let res = low;
                low += 1;
                Some(res)
            }
        })
    }
}

这在精神上与我们在Haskell中的做法非常接近,而且如果需要的话,可以通过显式状态传递来修改为完全非变异的。不管怎么说,结果是(如我所料)性能很差。

benchmarking Rust no trait
time                 4.206 ms   (4.148 ms .. 4.265 ms)
                     0.998 R²   (0.998 R² .. 0.999 R²)
mean                 4.197 ms   (4.155 ms .. 4.237 ms)
std dev              134.4 μs   (109.6 μs .. 166.0 μs)
variance introduced by outliers: 15% (moderately inflated)

GHC为这类情况进行了优化,因为传递闭包和部分应用函数是Haskell的标准做法。在我们的Iterator5 实现中,GHC最终会内联所有的中间函数,然后看穿所有的闭包魔法,把我们的代码变成一个紧密的内循环。这是非规范的Rust,因此(AFAICT)编译器没有进行任何此类优化。

考虑到它在迭代的每一步都要进行显式函数调用,我想说的是,Rust的实现只比迭代器慢一个数量级,这一点令人印象深刻。

总结

我发现这两种语言之间的对比是非常有意义的。在进行了这个分析之后,我对Rust有了更好的理解。在更高的层面上,我认为Haskell生态系统可以从Rust的零成本抽象库设计中学习更多。

我很想听听Rustaceans的意见,为什么迭代器版本的代码比循环的慢。如果可以用流融合的一些想法来帮助消除这种速度上的差异,我将特别感兴趣。

最后。GCC值得一赞,因为它优化了它的代码,并且用疯狂的汇编迷惑了我,直到Chris Done帮我解决了这个问题:)。

通过电子邮件订阅我们的博客
电子邮件订阅来自我们的Atom feed,由Blogtrottr处理。你只会收到博客文章的通知,并且可以随时取消订阅。

你喜欢这篇博文并需要DevOps、Rust或函数式编程方面的帮助吗?请联系我们