惰性求值:它为什么更快,优缺点,机制(为什么它使用更少的 CPU;例子?)以及简单的概念验证示例。

14 浏览
0 Comments

惰性求值:它为什么更快,优缺点,机制(为什么它使用更少的 CPU;例子?)以及简单的概念验证示例。

关闭。这个问题需要更加聚焦。它目前无法接受答案。


想要改进这个问题吗?通过编辑这篇文章,让问题聚焦于一个问题。

改善这个问题

惰性求值被认为是一种推迟进程直到第一次需要它的方法。这往往可以避免重复的评估,这就是我想象它执行得更快的原因。

像Haskell(和JavaScript..?)这样的函数语言内置了这个功能。

然而,我不明白为什么其他“正常”的方法(即具有相同功能但不使用惰性求值的方法)会更慢..这些其他方法如何执行重复评估?有人可以通过提供简单的示例并解释每种方法的机制来详细说明吗?

而根据维基百科关于懒惰评估的页面(lazy evaluation),这种方法的优点包括:

  1. 通过避免冗余计算和错误条件来提高性能。
  2. 能够构建潜在无限的数据结构。
  3. 能够将控制流(结构)定义为抽象,而不是基本元素。

但是,我们是否可以仅控制必要的计算并避免重复的计算呢?(1)我们可以使用链表来创建无限数据结构(2)我们已经能做到(3)了吗?我们可以定义类/模板/对象并使用这些来替代基本元素(例如JavaScript)。

此外,据我所见(至少在我看到的情况下),懒惰评估与递归和使用“头”和“尾”(以及其他概念)的方法密切相关。当然,有些情况下递归很有用,但懒惰评估是否比这更多?它是否超越了解决问题的递归方法?Streamjs是一个使用递归和其他一些简单操作(head,tail等)来执行懒惰评估的JavaScript库。

似乎我无法理解它...

提前感谢任何贡献。

admin 更改状态以发布 2023年5月20日
0
0 Comments

惰性求值一般而言并非更快。当人们说惰性求值更有效率时,指的是当你将 Lambda 演算(一旦编译器把它们所包含的语法糖去掉,你的 Haskell 程序本质上就是 Lambda 演算)视为一种术语集和规约规则系统时,按照带共享调用和按值调用的规则所指定的顺序应用规则时,前者总是应用同样或更少的规约规则。

这个理论结果之所以不能使惰性求值本身一般而言更快,是因为将其转化为具有内存访问瓶颈的线性顺序机器模型时,通常会导致所有规约规则的执行变得更昂贵!初次尝试在计算机上实现此模型的结果是,程序执行的速度比典型的急切求值语言实现慢得多。需要进行大量研究和工程技术以有效地实现惰性求值,才让 Haskell 的性能达到了今天的水平。最快的 Haskell 程序利用了一种称为“严格性分析”的静态分析形式,试图在编译时确定哪些表达式将总是需要使用,以便它们可以被迫切地求值而不是惰性求值。

还有一些情况下,由于只对用于结果的术语进行求值,因此在 Haskell 中实现算法的简洁实现会更快,但即使在急切求值的语言中,也总是有一些用于按需求值的表达式求值功能。条件语句和断路布尔表达式是普遍的例子,在许多急切求值语言中,还可以通过将表达式包裹在匿名函数或其他延迟形式中来延迟求值。因此,您通常可以使用这些机制(或者甚至更为麻烦的重写)来避免在急切求值的语言中求值不必要的昂贵事物。

Haskell的惰性求值的真正优点并不是与性能有关。Haskell使得更容易分离表达式,以不同的方式重新组合它们,并通常根据数学方程组的方式来思考代码,而不是按一组逐步评估的机器指令。通过不指定任何评估顺序,它迫使语言开发人员避免依赖于简单评估顺序的副作用,例如突变或IO。这反过来导致了一系列优雅的抽象,这些抽象通常是有用的,否则可能不会被开发成可用性。

现在Haskell的状况是,您可以编写高级,优雅的算法,利用比几乎任何其他高级类型语言更好的现有高阶函数和数据结构进行重复使用。一旦您熟悉了惰性求值的成本和效益以及如何控制其发生的时间,您可以确保优雅的代码也具有非常良好的性能。但是,将优雅的代码提高到高性能状态并不一定是自动的,可能需要比类似但急切评估的语言更多的思考。

0
0 Comments

我将在Python 2.7和Haskell中展示示例。

例如,假设你想要对从0到10,000,000的所有数字进行一次非常低效的求和。你可以用Python中的for循环来实现:

total = 0
for i in range(10000000):
    total += i
print total

在我的电脑上,这需要大约1.3秒的时间来执行。如果我改用xrangerange的生成器形式,惰性地生成一系列数字),则需要1.2秒,仅比range稍快。但是,如果我检查所使用的内存(使用memory_profiler包),则使用range的版本使用了约155MB的RAM,而xrange版本仅使用了1MB的RAM(这两个数字都不包括Python使用的大约11MB)。这是一个非常明显的巨大差异,我们也可以使用这个工具来看到它来自哪里:

Mem usage     Increment       Line Contents
===========================================
10.875 MiB    0.004 MiB       total = 0
165.926 MiB  155.051 MiB       for i in range(10000000):
165.926 MiB    0.000 MiB           total += i
                               return total

这表示在我们开始之前,我们使用了10.875MB,total = 0增加了0.004MB,然后for i in range(10000000):在生成整个数字列表[0..9999999]时添加了155.051MB。如果我们将其与xrange版本进行比较:

Mem usage     Increment       Line Contents
===========================================
11.000 MiB    0.004 MiB       total = 0
11.109 MiB    0.109 MiB       for i in xrange(10000000):
11.109 MiB    0.000 MiB           total += i
                              return total

所以我们开始使用11MB,for i in xrange(10000000):仅增加了0.109MB。这是通过在代码中添加一个字母而获得的巨大内存节省。虽然这个例子有点牵强,但它展示了不在需要元素之前就不计算整个列表可以使内存效率更高。


Python有迭代器和生成器,它们作为一种“懒惰”的编程方式,当需要产生数据序列时使用(尽管没有什么阻止你将它们用于单个值),但Haskell中每个值都内置了惰性,甚至用户自定义的值也是如此。这使您能够利用无法适应内存的数据结构,而无需编写复杂的解决方案。其中标志性的例子是斐波那契数列:

fibs = 1 : 1 : zipWith (+) fibs (tail fibs)

这非常优雅地表示了定义递归无限列表生成所有斐波那契数列的著名序列。它是CPU高效的,因为所有值都被缓存,所以每个元素只需要计算一次(与天真递归实现相比),但如果你计算太多元素,你的计算机最终会耗尽RAM,因为你现在存储了这个巨大的数字列表。这是一个例子,懒惰编程让你拥有CPU效率,但不具备RAM效率。不过,有一种方法可以解决这个问题。如果你写了:

fib :: Int -> Integer
fib n = let fibs = 1 : 1 : zipWith (+) fibs (tail fibs) in fibs !! n

那么这将在几乎恒定的内存中运行,并且速度非常快,但记忆化会丢失,因为对fib的后续调用必须重新计算fibs

更复杂的例子可以在这里找到。作者展示了如何在Haskell中使用懒惰编程和递归执行动态规划操作,这是大多数人最初认为非常困难且需要变异的事情,但Haskell使用“tying the knot”样式递归非常容易完成。它在CPU和RAM效率方面都很出色,而且所需的代码行数比我在C / C ++中预期的要少。


尽管如此,还有许多情况下懒惰编程非常令人恼火。通常,您可以积累大量的thunk而不是随着计算逐步计算(我在看你,foldl),并且必须引入一些严格性才能达到效率。它还通过IO特别影响很多人,当您将文件作为thunk读取到字符串中,关闭文件,然后尝试对该字符串进行操作时。只有在文件关闭后,thunk才会被评估,导致IO错误发生并崩溃您的程序。与任何事物一样,懒惰编程也有其缺陷、陷阱和风险。需要时间学习如何与它很好地配合使用,并了解其局限性。

1) 通过“naive recursive implementation”,我意味着像以下所示实现斐波那契数列:

fib :: Integer -> Integer
fib 0 = 1
fib 1 = 1
fib n = fib (n-1) + fib (n-2)

使用这种实现方式,你可以清晰地看到数学定义,这非常像归纳证明的风格,你先展示基本情况,然后总体情况。然而,如果我调用fib 5,这将“扩展”为类似以下的内容:

fib 5 = fib 4 + fib 3
      = fib 3 + fib 2 + fib 2 + fib 1
      = fib 2 + fib 1 + fib 1 + fib 0 + fib 1 + fib 0 + fib 1
      = fib 1 + fib 0 + fib 1 + fib 1 + fib 0 + fib 1 + fib 0 + fib 1
      = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1
      = 8

相反,我们想共享其中的一些计算,这样fib 3只会计算一次,fib 2只会计算一次,等等。

通过在Haskell中使用递归定义的列表,我们可以避免这种情况。内部计算,列表类似以下内容:

fibs = 1 : 1 : zipWith (+) fibs (tail fibs)
     = 1 : 1 : zipWith (+) (f1:f2:fs) (f2:fs)
       ^--------------------^  ^       ^
           ^-------------------|-------|
     = 1 : 1 : 2 : zipWith (+) (f2:f3:fs) (f3:fs)
           ^--------------------^  ^       ^
               ^-------------------|-------|
     = 1 : 1 : 2 : 3 : zipWith (+) (f3:f4:fs) (f4:fs)
               ^--------------------^  ^       ^
                   ^-------------------|-------|

因此,希望你能看到这里正在形成的模式,因为在构建列表时,它会保留指向生成的最后两个元素的指针,以计算下一个元素。这意味着对于计算的第n个元素,执行了n-2次加法。即使对于朴素的fib 5,你仍然可以看到执行的加法比这多,而且加法的数量将继续呈指数级增长。这个定义通过惰性和递归成为可能,让我们将一个O(2 ^ n)算法转化为一个O(n)算法,但我们必须放弃RAM。如果将其定义在顶层,则值将在程序的生命周期内被缓存。这也意味着如果你需要重复引用第1000个元素,你不必重新计算它,只需索引即可。

另一方面,定义

fib :: Int -> Integer
fib n =
    let fibs = 1 : 1 : zipWith (+) fibs (tail fibs)
    in fibs !! n

每次调用 fib 函数时,它都会使用一个本地副本的 fibs,这样我们在调用 fib 函数时无法获得缓存,但我们可以得到本地缓存,这使得时间复杂度为 O(n)。此外,GHC 足够聪明,知道我们在计算下一个元素后就不需要继续保留列表的开头,因此当我们遍历 fibs 查找第 n 个元素时,它只需要保存 2-3 个元素和一个指向下一个元素的延迟计算。这在计算过程中节省了 RAM,因为它不是在全局定义的,所以在程序生命周期内不会占用 RAM。这是在内存和 CPU 周期上权衡取舍的一个例子,不同的方法适用于不同的情况。这些技术适用于 Haskell 编程的大部分内容,不仅仅是针对这个序列!

0