从 4.4.0 版本开始,R通过函数Tailcall
。我自然而然地认为这意味着对使用尾递归的代码进行了改进。
但是,请考虑以下简单示例(用二分法求2的平方根):
tolerance <- 1e-15
bisect <- function(l, u) {
if((u - l) < tolerance) return(c(l, u))
mid <- (l + u)/2
if(mid^2 < 2) bisect(mid, u) else bisect(l, mid)
}
bisectTR <- function(l, u) {
if((u - l) < tolerance) return(c(l, u))
mid <- (l + u)/2
if(mid^2 < 2) Tailcall(bisectTR, mid, u) else Tailcall(bisectTR, l, mid)
}
我的问题是,bench::mark(mean(bisect(1.4, 1.5)), mean(bisectTR(1.4, 1.5)))
带有尾递归的版本在我的计算机上的运行速度慢了三倍!
对代码进行字节编译不会改变这种情况:
bisectComp <- compiler::cmpfun(bisect)
bisectTRComp <- compiler::cmpfun(bisectTR)
bench::mark(bisectComp(1.4, 1.5), bisectTRComp(1.4, 1.5))
再次,尾递归“优化”版本实际上慢了三倍……(并且运行时间实际上与以前的相同,即,在这种情况下字节编译实际上没有任何区别。)
怎么可能?还是我忽略了什么……?
4
最佳答案
1
R 中的尾调用优化不会重用堆栈框架
R 中的尾调用优化 (TCO)Tailcall()
允许递归函数通过展开调用堆栈并从每次递归调用的全局环境开始来避免堆栈溢出。但是,与其他一些语言中的 TCO 不同,R 的实现不会重用相同的堆栈框架或将递归重写为循环。它仍然会以相同的次数调用递归函数,并且每次调用该函数时都会创建一个新的环境。这意味着虽然可以Tailcall()
防止堆栈溢出,但可能不会显著提高性能。事实上,与实现相关的开销Tailcall()
意味着它似乎比标准递归函数慢。
基准测试Tailcall()
为了公平起见,我们需要进行深度超过 48 次迭代的递归。让我们用 R 语言编写一个与 JavaScript 中递归求和函数等效的函数,什么
# Without tail recursion
recsum <- function(x) {
if (x == 0) {
return(0)
} else {
force(x) # to make benchmark fair
return(x + recsum(x - 1))
}
}
# With tail recursion
tailrecsum <- function(x, running_total = 0) {
if (x == 0) {
return(running_total)
} else {
force(running_total) # you have to force evaluation to trigger tail recursion
Tailcall(tailrecsum, x - 1, running_total + x)
}
}
让我们比较一下结果。为了增加最大递归深度,这是一个 R 会话,以
options(expressions = 5e5) # increase max recursion depth R option
bench::mark(recsum(4e3), tailrecsum(4e3), relative = TRUE)
# expression min median `itr/sec` mem_alloc `gc/sec` n_itr n_gc total_time
# <bch:expr> <dbl> <dbl> <dbl> <dbl> <dbl> <int> <dbl> <bch:tm>
# 1 recsum(4000) 1 1 2.11 NaN 1 469 27 427ms
# 2 tailrecsum(4000) 2.20 2.13 1 Inf 1.16 206 29 396ms
即使深度为4001
(这大概是最大深度recsum()
),TCO 版本的速度仍然大约是后者的两倍。这是怎么回事?关键在帮助页面上:
Tailcall
由 traceback 或 sys.calls 生成的 [S]stack 跟踪将仅显示或指定的调用Exec
,而不显示堆栈条目已被替换的前一个调用。
(重点是我的。)
该过程大致如下:
该图的关键点在于,虽然堆栈不会随着每次额外的函数调用而增长,但 R 不会对每次调用重复使用相同的堆栈框架。每次调用函数时,它都会展开堆栈并创建一个新环境。为了理解这种差异,让我们看一下 C 中的比较。
TCO如何呢?
我们用tailrecsum()
C 语言来写:
uint64_t tailrecsum(uint64_t x, uint64_t running_total) {
if (x == 0) {
return running_total;
} else {
return tailrecsum(x - 1, running_total + x);
}
}
如果我们编译它并gcc -O0
将递归调用,即:
tailrecsum:
push rbp
; <some other instructions>
call tailrecsum
然而,如果我们使用优化方法进行编译O2
,看起来就会有很大不同:
tailrecsum:
mov rax, rsi ; move 'running_total' into 'rax' (accumulator)
test rdi, rdi ; test if 'x' == 0
je .L5 ; if 'x' == 0, jump to .L5 (base case)
lea rdx, [rdi - 1] ; calculate x - 1 and store it in rdx for later use.
test dil, 1 ; test if the least significant bit of 'x' is 1 (odd or even)
je .L2 ; if even, jump to label .L2
add rax, rdi ; rax += x (accumulate the sum)
mov rdi, rdx ; x = x - 1
test rdx, rdx ; test if x == 0
je .L17 ; if x == 0, jump to label .L17 to return
; Fall through to .L2
.L2:
lea rax, [rax - 1 + rdi * 2] ; rax = rax - 1 + 2 * x
sub rdi, 2 ; x = x - 2
jne .L2 ; if x != 0, jump back to .L2 (loop)
; If x == 0, fall through to .L5
.L5:
ret ; return from function
.L17:
ret ; return from function
没有递归调用。编译器已将代码转换为循环。该函数不调用自身,因此仅使用一个堆栈框架。
R 的 TCO 不会将递归函数优化为循环
相反,即使如此Tailcall()
,R 每次函数调用都会创建一个新的堆栈框架。我们可以通过编写一个 TCO 函数来计算环境的数量,从而观察到这一点:
tailrecsumenv <- function(x, running_total = 0, env_list) {
if (x == 0) {
return(
list(
result = running_total,
n_environments = length(unique(env_list))
)
)
} else {
force(running_total) # force evaluation to trigger TCO
force(env_list)
Tailcall(tailrecsumenv, x - 1, running_total + x, append(env_list, environment()))
}
}
如果我们运行它,我们可以看到它创建了4001
环境:
tailrecsumenv(4e3, env_list = list(environment()))
# $result
# [1] 8002000
# $n_environments
# [1] 4001
Tailcall()
那么在 R 中如何工作?
R显示了使用时所做的检查Tailcall()
:
Rboolean jump_OK =
(R_GlobalContext->conexit == R_NilValue &&
R_GlobalContext->callflag & CTXT_FUNCTION &&
R_GlobalContext->cloenv == rho &&
TYPEOF(R_GlobalContext->callfun) == CLOSXP &&
checkTailPosition(call, BODY_EXPR(R_GlobalContext->callfun), rho));
它检查是否存在未决on.exit
表达式、当前上下文是否为函数、闭包环境是否匹配、被调用的函数是否为闭包以及调用是否处于尾部位置。这些检查会产生一些开销。如果可以应用 TCO,它会执行以下操作(略微简化并附有我的注释):
if (jump_OK) {
// construct the first argument of `Tailcall()` into a function call
SEXP fun = CAR(expr);
// ensure function can be properly resolved
fun = eval(fun, env);
// package the function into a list containing...
SEXP val = allocVector(VECSXP, 4);
SET_VECTOR_ELT(val, 0, R_exec_token); // an execution token
SET_VECTOR_ELT(val, 1, expr); // the expression to evaluate
SET_VECTOR_ELT(val, 2, env); // the environment in which to evaluate it
SET_VECTOR_ELT(val, 3, fun); // the function to call
// Jump back to the global environment
R_jumpctxt(R_GlobalContext, CTXT_FUNCTION, val);
}
关键部分是R_jumpctxt()
。这实际上将堆栈展开到全局环境,并用新函数调用替换当前函数调用。这意味着可以在不增加调用堆栈深度的情况下调用另一个函数。但是,与 C 示例中的 TCO 不同,每次递归调用都会Tailcall()
导致创建一个新环境。
这相当于在调用递归函数之前从顶层函数返回。它允许深度递归而不会超过最大堆栈大小。但是,您不会获得与 C 代码中相同类型的优化。需要创建一个新环境,这是一个相对昂贵的操作。这些环境消耗堆上的内存,而不是调用堆栈上的内存,但它们不会立即被重用或删除。它们将继续增加内存,并会一直存在,直到垃圾回收。
那么如果Tailcall()
速度较慢,意义何在?
公平地说,从来没有声称这Tailcall()
更快:
这种尾调用优化的优点是不会增加调用堆栈并允许任意深度的尾递归。
虽然recsum()
和在 R 之类的语言中tailrecsum()
是一种荒谬的计算方式,但的优点是它确实可以防止深度递归导致的堆栈溢出:sum(1:4e3)
Tailcall()
recsum(1e6)
# Error in force(x) : node stack overflow
tailrecsum(1e6)
# [1] 500000500000
Tailcall()
是一种优化,因为它允许运行原本无法运行的代码。但它不会产生使用 TCO 的其他语言中看到的效率。R 仍然必须以与递归函数相同的方式创建环境。它还有额外的工作,因为运行Tailcall()
需要解析代码以评估 TCO 是否合适,然后在进行下一个递归调用之前展开调用堆栈。因此,虽然Tailcall()
可以防止调用堆栈无限增长,但不要指望它比不会导致堆栈溢出的标准递归函数更快。它不会。
4
-
1太棒了,非常感谢你如此详细的回答!我唯一想知道的是,R 核心团队是否会在未来某个时候解决这个问题,即实施完整的 TCO,或者这应该被视为最终决定,因为他们的目标只是允许此类代码运行……但这可能只有他们才能回答。
– -
@TamasFerenci 我也想知道。我建议先把这个问题搁置一段时间——我花了几天时间才有时间深入研究它。希望有更专业的人能回答。
– -
好的!我已经赞同你的问题,但我需要等待一段时间才会接受。
– -
谢谢。经过深思熟虑,我认为不太可能发生重大变化,因为 C 中可以实现的优化类型在 R 中无法实现。每次函数调用都要创建新环境,这是一大开销。但 gcc 进行的循环展开优化可能会破坏 R 中的非标准求值。C 已经在函数体之前求值了参数,但我不确定在不
Tailcall()
创建环境或执行类似操作的情况下检查参数在适当范围内的求值方式是否可行,甚至是否可行。
–
|
bisect <- function(l, u, counter = 1)
然后设置后续调用以增加计数器,例如bisect(l, mid, counter + 1)
,您将看到它只有 48 次迭代。如果您使用较小的容差来强制进行更多迭代,您可能会看到好处(并且在某些时候您将无法运行原始函数)。我还会看看是否compiler::cpmfun()
有区别。–
1e-15
容差已经是最小值(较小的值将低于 R 可以用数字表示的值,因此u - l
永远不会更小)。关于你的第二个想法,谢谢你的建议,我会用答案更新这个问题。–
Tailcall()
首先,它有助于确保它能够运行大量输入而不会崩溃,仅此而已。现在,如果它不减慢现有代码的速度,那就太好了(事实上,我认为当前状态是一个 QoI/性能错误)。–
Tailcall()
会使代码变慢,尽管我没有在实际示例中使用它,所以我不确定速度差异是否真的很明显。我认为文档可能可以更明确一些,但目前它显然是实验性的。–
|