关于性能:懒惰评估:为什么它更快,优点和缺点,机制(为什么它使用更少的CPU;示例?)和简单的概念证明示例

Lazy Evaluation: Why is it faster, advantages vs disadvantages, mechanics (why it uses less cpu; examples?) and simple proof of concept examples

懒惰的评估被认为是一种将一个过程推迟到第一次需要它的时候的方法。这往往会避免重复的评估,这就是为什么我会想象它的执行速度会更快。像haskell(和javascript…)这样的功能语言内置此功能。

但是,我不理解其他"正常"方法(即相同的功能,但不使用延迟评估)的速度如何和为什么会变慢。这些其他方法如何以及为什么要进行重复评估?有人能通过给出简单的例子和解释每种方法的原理来详细阐述这一点吗?

另外,根据维基百科关于懒惰评估的页面,这些被认为是这种方法的优势:

  • 通过避免不必要的计算和错误提高性能计算复合表达式的条件
  • 构建潜在无限数据结构的能力
  • 将控制流(结构)定义为抽象的能力而不是原语
  • 但是,我们是否可以控制所需的计算,避免重复相同的计算?(1)我们可以使用链表来创建一个无限的数据结构(2)我们能不能已经……????我们可以定义类/模板/对象,并使用它们而不是原语(即javascript)。

    此外,在我看来(至少从我所看到的情况来看),懒惰的评估使用递归和"head"和"tail"(连同其他概念)概念进行交互。当然,有些情况下递归是有用的,但是懒惰的评估是否比这更重要?不仅仅是解决问题的递归方法……streamjs是一个javascript库,它使用递归和一些其他简单操作(head、tail等)来执行延迟评估。

    看来我没法改变主意…

    提前感谢您的贡献。


    我将在python 2.7和haskell中展示示例。好的。

    比如说,你想对所有从0到10000000的数字做一个非常低效的求和。您可以使用Python中的for循环好的。

    1
    2
    3
    4
    total = 0
    for i in range(10000000):
        total += i
    print total

    在我的电脑上,这需要大约1.3秒的时间来执行。相反,我把range改为xrange(range的生成器形式,懒洋洋地生成一个数字序列),它需要1.2秒,只是稍微快一点。但是,如果我检查使用的内存(使用memory_profiler包),使用range的版本使用大约155MB的RAM,而使用xrange的版本仅使用1MB的RAM(这两个数字不包括python使用的大约11MB)。这是一个令人难以置信的巨大差异,我们也可以通过这个工具看到它的来源:好的。

    1
    2
    3
    4
    5
    6
    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版本进行比较:好的。

    1
    2
    3
    4
    5
    6
    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在语言中的每个值中都内置了懒惰,甚至是用户定义的值。这使您可以利用诸如数据结构之类的不适合内存的东西,而无需围绕这一事实编程复杂的方法。典型的例子是斐波那契序列:好的。

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

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

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

    然后,它在几乎恒定的内存中运行,并且运行得非常快,但是由于随后调用fib必须重新计算fibs,memoization会丢失。好的。

    这里有一个更复杂的例子,作者展示了如何在haskell中使用懒惰的编程和递归来用数组执行动态编程,这是一个最初认为非常困难并且需要突变的技巧,但是haskell使用"结"式递归很容易做到。它导致CPU和RAM的效率,并且比我在C/C++中所期望的更少。好的。

    尽管如此,在很多情况下,懒惰的编程很烦人。通常情况下,你可以建立大量的thunk而不是按你的方式计算(我看你,foldl),为了达到效率,必须引入一些严格性。它还咬了很多使用IO的人,当您以thunk的形式将文件读到字符串时,关闭该文件,然后尝试对该字符串进行操作。只有在关闭文件后,才会对thunk进行评估,从而导致发生IO错误并使程序崩溃。与任何事情一样,懒惰的编程也并非没有缺陷、缺陷和陷阱。学习如何很好地处理它,并了解它的局限性需要时间。好的。

    1)通过"幼稚的递归实现",我的意思是将斐波那契序列实现为好的。

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

    通过这个实现,您可以非常清楚地看到数学定义,它非常类似于归纳证明的样式,先显示基本情况,然后显示一般情况。然而,如果我称之为fib 5,这将"扩展"为好的。

    1
    2
    3
    4
    5
    6
    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中使用递归定义的列表,我们可以避免这种情况。在内部,这个列表是这样表示的:好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    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个元素,就不必重新计算它,只需索引它。好的。

    另一方面,定义好的。

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

    每次调用fib时使用fibs的本地副本。我们不能在调用fib之间获得缓存,但是我们确实获得了本地缓存,这就导致了我们的复杂性O(n)。此外,ghc足够聪明,知道在我们使用它计算下一个元素后,不必保留列表的开头,因此当我们遍历fibs查找nth元素时,它只需要保留2-3个元素和指向下一个元素的thunk。这在计算内存时节省了我们的内存,而且由于它不是在全局级别定义的,所以它不会在程序的整个生命周期中耗尽RAM。当我们想要花费RAM和CPU周期时,这是一种权衡,不同的方法对于不同的情况更好。这些技术通常适用于大多数haskell编程,而不仅仅是这个序列!好的。好啊。


    一般来说,懒惰的评估不会更快。如果说延迟计算更有效,那是因为当你把lambda演算(当编译器完成对lambda演算后,haskell程序本质上就是这样的)看作是一个术语和约简规则系统,然后按照名称调用规则指定的顺序应用这些规则,并共享评估订单。Licy始终应用与按值调用计算指定的顺序遵循规则时相同或更少的缩减规则。

    一般来说,这个理论结果并不能使延迟评估更快的原因是,转换为具有内存访问瓶颈的线性顺序机模型往往会使所有的减少执行的成本更高!最初在计算机上实现这个模型的尝试导致程序执行的速度比典型的热切地评估语言实现的速度慢得多。为了使Haskell的性能达到今天的水平,高效地实现懒惰的评估,需要大量的研究和工程技术。最快的haskell程序利用一种称为"严格性分析"的静态分析形式,这种分析形式试图在编译时确定总是需要哪些表达式,以便能够热切地对它们进行评估,而不是懒惰地进行评估。

    在Haskell中,仍然有一些情况下,由于只评估结果所需的术语,简单的算法实现将更快地执行,但即使是热切的语言也总是有一些根据需要评估某些表达式的功能。条件表达式和短路布尔表达式是常见的例子,在许多热切的语言中,也可以通过将表达式包装在匿名函数或其他某种延迟形式中来延迟计算。因此,您通常可以使用这些机制(甚至更尴尬的重写)来避免评估那些在渴望的语言中不必要的昂贵事物。

    哈斯凯尔的懒惰评估的真正优势不是与性能相关的。Haskell使得将表达式分开、以不同的方式重新组合它们变得更容易,并且通常会将代码解释为一个数学方程系统,而不是一组顺序评估的机器指令。通过不指定任何评估顺序,它强制语言的开发人员避免依赖简单的评估顺序的副作用,例如突变或IO。这反过来又导致了大量优雅的抽象,这些抽象通常是有用的,否则可能无法开发成可用性。

    现在,haskell的状态是这样的,您可以编写高级、优雅的算法,使现有的高阶函数和数据结构比几乎任何其他高级类型语言都能更好地重用。一旦您熟悉了懒惰评估的成本和好处,以及如何控制它发生的时间,您就可以确保优雅的代码也能很好地执行。但是,将优雅的代码转换为高性能的状态并不一定是自动的,并且可能需要比使用类似但经过热切评估的语言进行更多的思考。


    "懒惰评估"的概念只涉及一件事,而且只涉及这一件事:

    The ability to postpone evaluation of something until needed

    就是这样。

    维基百科文章中的其他所有内容都是从它开始的。

    无限的数据结构?不是问题。我们只是确保在您真正要求下一个元素之前,我们不会真正弄清楚它是什么。例如,如果要执行的操作只是将x增加1,那么询问一些代码x之后的下一个值是什么,将被填充。如果创建一个包含所有这些值的列表,它将填充计算机中可用的内存。如果你只知道下一个值是什么,而不是太多。

    不必要的计算?当然。您可以返回一个包含许多属性的对象,当被询问时,这些属性将为您提供一些值。如果您不要求(即永远不要检查给定属性的值),那么计算出该属性值所必需的计算将永远不会完成。

    控制流…?完全不确定这是怎么回事。

    懒惰地评估某物的目的正是我所说的,从一开始就避免评估某物,直到你真正需要它为止。无论它是某个东西的下一个值,属性的值,不管怎样,添加对延迟评估的支持可能会节省CPU周期。

    另一种选择是什么?

    我想将一个对象返回到调用代码,其中包含任意数量的属性,其中一些属性可能计算起来很昂贵。如果没有延迟的评估,我将不得不计算所有这些属性的值:

  • 在构造对象之前
  • 构造对象后,第一次检查属性时
  • 在构造对象之后,每次检查该属性时
  • 对于懒惰的评估,你通常以2结尾。您可以推迟评估该属性的值,直到某些代码检查它。请注意,您可能会缓存一次评估后的值,这将在多次检查同一属性时节省CPU周期,但这是缓存,并不完全相同,而是在同一行工作中:优化。