问题描述
我了解到有些地方懒惰的评估会阻止进行计算。例如,将两个数字相加然后将它们传递到一个函数参数中,该参数最终将永远不会被使用。
但是在我看来,存储和加载稍后要使用的操作会产生大量开销,而且这种开销往往会抵消任何收益。
有人可以解决这个问题吗?
解决方法
您是对的。惰性评估确实会产生大量开销,并且大多数时候您不会从中获得实际的性能提升。延迟评估的主要原因是它很方便-它使Haskell的语言语义更整洁,并且(例如)延迟/无限列表有时对于程序员很方便。
幸运的是,编译器通常可以在内部循环之外优化惰性机制,否则幼稚的实现会导致性能大幅下降。
,出乎意料的是,事实证明,GHC的惰性评估实现不会在类似的严格运行时系统上引入可观的开销。创建用于延迟评估的thunk(即“存储”以后使用的操作)并最终强制其评估(即“加载”它)的表面上的“开销”取代了创建堆栈框架的严格评估“开销”。 ,调用函数并返回。结果,函数调用的成本会及时转移,但并没有显着增加。
的确,严格性(由程序员明确引入或由编译器自动识别)有时对于获得良好的性能是必需的,但这通常是因为严格性允许取消装箱和相关优化,或者在某些情况下避免了代价高昂的内存泄漏,导致过多的垃圾回收开销。惰性评估本身并不比严格评估昂贵得多。
在this answer中,我对GHC RTS中的函数调用和典型的Java VM实现进行了较为详细的比较。答案集中在内存使用上(因为问题是关于垃圾回收的),但是很多讨论都更笼统地适用于性能。
如果要确定调用两个数字相乘的函数的开销,请汇总相关位:
bar :: Int -> Int -> Int
bar a b = a * b
由其他功能调用:
foo :: Int -> Int -> Int -> Int
foo x y z = let u = bar y z in x + u
然后在典型的严格实现中(例如Java JVM),字节代码可能类似于:
public static int bar(int,int);
Code:
stack=2,locals=2,args_size=2
0: iload_0 // push a
1: iload_1 // push b
2: imul // multiply and push result
3: ireturn // pop result and return it
public static int foo(int,int,locals=4,args_size=3
0: iload_1 // push y
1: iload_2 // push z
2: invokestatic bar // call bar,pushing result
5: istore_3 // pop and save to "u"
6: iload_0 // push x
7: iload_3 // push u
8: iadd // add and push result
9: ireturn // pop result and return it
bar
函数调用的开销(即,上面和如果插入bar
之间的区别)看起来像是两次参数推送,调用本身和返回。
对于惰性版本,GHC(无优化)将这些代码编译为以下伪代码:
foo [x,y,z] =
u = new THUNK(sat_u) // thunk,32 bytes on heap
jump: (+) x u
sat_u [] = // saturated closure for "bar y z"
push UPDATE(sat_u) // update frame,16 bytes on stack
jump: bar y z
bar [a,b] =
jump: (*) a b
惰性bar
函数调用的开销是在凹凸堆上创建一个thunk(与堆栈一样快),该thunk包含两个参数和一个指向sat_u
的指针(还有返回空间)值,尽管没有“成本”,并且在(+)
函数通过跳转到u
来强制值sat_u
时会出现“调用”(在上面的代码中不可见)。更新框架或多或少替换了返回值。 (在这种情况下,可以对其进行优化。)
最重要的是,至少在一个近似值上,GHC中实施的惰性评估与严格评估一样快,即使实际上已对所有评估进行评估。
,之所以起作用,是因为编译器优化会在不需要时消除懒惰。
如果看到下一个计算将消耗前一个计算的结果,它只会生成严格的代码。
,由于Haskell是纯语言,因此懒惰评估起着重要作用。这不仅仅是语言的一种功能,它使程序员可以以声明式的方式编写而不必担心计算顺序。
让我们举个例子。
`sum $ map (^2) [0..100]`
严格的语义在这里会发生什么。首先评估map
函数。它将使用整个输入列表并生成(有分配,因为Haskell是纯净的)输出列表。然后sum
将计算结果。
但是在惰性语义中,将不会构造此示例中的中间列表。因此,不会有不必要的内存工作。意味着减少垃圾收集器的工作。
因此,对于纯语言,惰性语义是避免构造中间对象的开销的一种方法。