【问题标题】:Choosing between continuation passing style and memoization在延续传递风格和记忆之间进行选择
【发布时间】:2013-01-24 18:26:10
【问题描述】:

在用函数式语言编写记忆和延续传递风格 (CPS) 函数的示例时,我最终使用了斐波那契示例。然而,斐波那契并没有真正从 CPS 中受益,因为循环仍然需要经常以指数方式运行,而在记忆化的情况下,第一次只需 O(n),以后每次只需 O(1)。

结合 CPS 和记忆化对 Fibonacci 有一点好处,但是有没有例子说明 CPS 是防止您用完堆栈的最佳方法提高性能以及记忆化不是解决办法?

或者:是否有关于何时选择其中一个或两者兼而有之的指南?

【问题讨论】:

标签: f# functional-programming ocaml memoization continuations


【解决方案1】:

在 CPS 上

虽然 CPS 作为编译器中的中间语言很有用,但在源语言级别,它主要是一种设备,用于 (1) 编码复杂的控制流(与性能无关)和 (2) 转换非尾部调用消耗堆栈空间到一个连续分配尾调用消耗堆空间。例如当你写(代码未经测试)

let rec fib = function
  | 0 | 1 -> 1
  | n -> fib (n-1) + fib (n-2)

let rec fib_cps n k = match n with
  | 0 | 1 -> k 1
  | n -> fib_cps (n-1) (fun a -> fib_cps (n-2) (fun b -> k (a+b)))

先前的非尾调用fib (n-2),它分配了一个新的堆栈帧,变成了尾调用fib (n-2) (fun b -> k (a+b)),它分配闭包fun b -> k (a+b)(在堆上)将它作为参数传递。

不会渐近地减少程序的内存使用量(一些进一步的特定于域的优化可能会,但那是另一回事了)。您只是在用堆栈空间交换堆空间,这在堆栈空间受到操作系统严重限制的系统上很有趣(对于 ML 的某些实现(例如 SML/NJ)不是这种情况,它们在堆上跟踪它们的调用堆栈而不是使用系统堆栈),并且由于额外的 GC 成本和潜在的局部性降低而可能降低性能。

CPS 转换不太可能大大提高性能(尽管您的实现和运行时系统的细节可能会如此),并且是一种普遍适用的转换,可以避免具有深度调用堆栈的递归函数的“堆栈溢出” .

关于记忆

Memoization 对于引入递归函数的子调用共享很有用。递归函数通常通过将其分解为几个严格简单的“子问题”(递归子调用)来解决“问题”(“计算n 的斐波那契”等),其中一些基本情况可以解决问题马上。

对于任何递归函数(或问题的递归表述),您可以观察子问题空间的结构Fib(k) 的哪些更简单的实例将 Fib(n) 需要返回其结果?这些实例又需要哪些更简单的实例?

在一般情况下,这个子问题空间是一个图(通常是非循环的,用于终止目的):有一些节点有几个父节点,它们是几个不同的问题,它们是子问题。例如,Fib(n-2)Fib(n)Fib(n-2) 的子问题。此图中的节点共享量取决于特定的问题/递归函数。在斐波那契的情况下,所有节点都在两个父节点之间共享,因此有很多共享。

没有记忆的直接递归调用将无法观察到这种共享。递归函数的调用结构是一个,而不是一个图,并且Fib(n-2) 等共享子问题将被完全访问多次(与从起始节点到图中的子问题节点)。记忆化通过让一些子调用直接返回“我们已经计算过这个节点,这是结果”来引入共享。对于有很多共享的问题,这可能会导致(无用)计算的显着减少:当引入 memoization 时,Fibonacci 从指数复杂度变为线性复杂度——请注意,还有其他编写函数的方法,不使用 memoization,而是不同的子调用结构,具有线性复杂度。

let rec fib_pair = function
  | 0 -> (1,1)
  | n -> let (u,v) = fib_pair (n-1) in (v,u+v)

使用某种形式的共享(通常通过存储结果的大表)来避免子计算的无用重复的技术在算法社区中是众所周知的,它被称为Dynamic Programming。当您认识到一个问题可以接受这种处理时(您注意到子问题之间的共享),这可以提供很大的性能优势。

比较有意义吗?

两者似乎大多是相互独立的。

有很多问题不适用memoization,因为子问题图结构没有任何共享(它是一棵树)。相反,CPS 转换适用于所有递归函数,但它本身并不会带来性能优势(除了由于您使用的特定实现和运行时系统导致的潜在常量因素,尽管它们很可能使代码更慢而不是更快)。

通过检查非尾部上下文来提高性能

有一些与 CPS 相关的优化技术可以提高递归函数的性能。它们包括在递归调用之后查看“留待完成”的计算(这将变成直接 CPS 样式的函数)并为其找到替代的、更有效的表示,这不会导致系统的闭包分配。例如:

let rec length = function
  | [] -> 0
  | _::t -> 1 + length t

let rec length_cps li k = match li with
  | [] -> k 0
  | _::t -> length_cps t (fun a -> k (a + 1))

您可以注意到非递归调用的上下文,即[_ + 1],具有一个简单的结构:它添加了一个整数。不用将其表示为函数fun a -> k (a+1),您可以只存储对应于该函数的多个应用的​​要相加的整数,使k 成为整数而不是函数。

let rec length_acc li k = match li with
  | [] -> k + 0
  | _::t -> length_acc t (k + 1)

此函数在恒定而非线性空间中运行。通过将尾部上下文的表示从函数转换为整数,我们消除了使内存使用线性化的分配步骤。

仔细检查执行添加的顺序会发现它们现在以不同的方向执行:我们首先添加对应于列表开头的 1,而 cps 版本最后添加它们.这种顺序反转是有效的,因为_ + 1 是一个关联操作(如果您有多个嵌套上下文foo + 1 + 1 + 1,那么从内部((foo+1)+1)+1 或外部foo+(1+(1+1)) 开始计算它们是有效的) .上述优化可用于围绕非尾调用的所有此类“关联”上下文。

当然还有基于相同想法的其他优化(我不是此类优化方面的专家),即查看所涉及的延续的结构并以比在堆上分配的函数更有效的形式表示它们.

这与“去功能化”的转换有关,它将延续的表示从函数更改为数据结构,而不会改变内存消耗(去功能化的程序将分配一个数据节点,而在原来的情况下会分配一个闭包程序),但允许用一阶语言(没有一阶函数)表达尾递归 CPS 版本,并且在数据结构和模式匹配比闭包分配和间接调用更有效的系统上更有效。

type length_cont =
  | Linit
  | Lcons of length_cont

let rec length_cps_defun li k = match li with
  | [] -> length_cont_eval 0 k
  | _::t -> length_cps_defun t (Lcons k)
and length_cont_eval acc = function
  | Linit -> acc
  | Lcons k -> length_cont_eval (acc+1) k

let length li = length_cps_defun li Linit

type fib_cont =
  | Finit
  | Fminus1 of int * fib_cont
  | Fminus2 of fib_cont * int

let rec fib_cps_defun n k = match n with
  | 0 | 1 -> fib_cont_eval 1 k
  | n -> fib_cps_defun (n-1) (Fminus1 (n, k))
and fib_cont_eval acc = function
  | Finit -> acc
  | Fminus1 (n, k) -> fib_cps_defun (n-2) (Fminus2 (k, acc))
  | Fminus2 (k, acc') -> fib_cont_eval (acc+acc') k

let fib n = fib_cps_defun n Finit

【讨论】:

  • 我之前没有回复(旅行),但现在我真的有时间仔细阅读您的广泛回答。优秀且有目共睹。图与树的解释以及节点的重新访问是查看此处不同用例的绝佳方式。谢谢!
【解决方案2】:

CPS 的一个好处是错误处理。如果你需要失败,你只需调用你的失败方法。

我认为最大的情况是当您不谈论计算时,记忆化非常好。如果你说的是 IO 或其他操作,CPS 的好处是存在的,但记忆化不起作用。

至于 CPS 和 memoization 都适用且 CPS 更好的实例,我不确定,因为我认为它们是不同的功能。

最后,CPS 在 F# 中有所降低,因为尾递归已经使整个堆栈溢出问题不再存在。

【讨论】:

  • F# 不会自动将每次调用都转换为尾调用,因此 CPS 仍然需要正确实现某些功能设计模式。
  • 在斐波那契示例中,递归有两个递归返回点,因此无法进行 TCO。树遍历算法也不是。并且没有累加器的阶乘实现不能是 TCO,因为函数的结果仍然需要在函数返回之前用于计算。事实上,TCO 只适用于有限的递归问题集,然后 CPS 就派上用场了。
  • @Jack P. F# 不会自动将每个 tail call 优化成一个循环吗?我同意将任何通用递归函数转换为尾递归函数并不容易,但是当给定尾递归函数时,假设 F# 将优化递归是不正确的(前提是打开了适当的编译器设置)?
  • @Shredderroy :并非总是如此,不——有时它会发出一个 call 操作,然后是一个 tail 操作,此时由 JIT 编译器来优化递归。
  • @Shredderroy 尾部调用不一定优化为循环;它们只是允许 JIT 编译器在对下一个函数进行尾调用时丢弃当前的堆栈帧。由于没有堆栈溢出的可能性,这意味着一个尾调用自身(即自递归)的函数有效地变成了一个循环。 F# 编译器有时可以更进一步,将某些自递归函数优化到显式循环中——也就是说,优化掉了 tail.call 操作,而只使用 br 操作回到方法体的开头。
猜你喜欢
  • 2016-12-01
  • 2012-12-12
  • 2012-04-19
  • 1970-01-01
  • 2011-06-30
  • 2010-12-21
  • 1970-01-01
  • 2021-12-26
  • 1970-01-01
相关资源
最近更新 更多