理解代码为什么会慢
在前面的内容中,我们学习了测试代码时间和分析代码性能的工具。为了解决同样的
问题,某个函数可能非常快,而另一个函数可能会很慢。理解代码为什么会慢,对编程是
很有帮助的。
首先,R 是一个动态编程语言。它本身提供了非常灵活的数据结构和代码运行机制。
因此,在函数被调用之前,代码解释器很难提前知道如何处理调用。对于 C 和 C++ 这种强
类型的静态编程语言来说,情况并非如此。很多事情都是在编译时而非运行时确定的,所
以程序事先就确定了如何进行,而且可以进行集中优化。相比之下,R 舍弃性能换来了灵
活性,虽然它的性能不是很好,但是良好的 R 代码仍然可以表现出可接受的性能。
R 代码运行速度慢的主要原因是它可能会频繁地创建、分配或复制数据结构。这也正
是在输入数据量更大时,my_cumsum1( ) 和 my_cumsum2( ) 的性能表现出巨大差异
的原因。my_cumsum1( ) 函数总是增加向量,这也意味着在每次迭代中,向量都会被复
制到一个新地址,并追加一个新元素。因此,迭代次数越多,需要复制的元素也越多,代
码也就越慢。
我们可以通过下面这个基准测试来说明这一点,其中 grow_by_index 是指要先初始
化的一个空列表,preallocated 函数是指给新建列表预分配位置,即给 n 个位置都赋
值为 NULL。在这两种情况下,我们都修改列表的第 i 个元素。不同的是,每一次迭代中,
第 1 个列表的长度都会增加,而第 2 个列表的位置已经被完全分配,因此迭代过程中其长
度不会改变:
n <- 10000
microbenchmark(grow_by_index = {
x <- list( )
for (i in 1:n) x[[i]] <- i
}, preallocated = {
x <- vector("list", n)
for (i in 1:n) x[[i]] <- i
}, times = 20)
## Unit: milliseconds
## expr min lq mean median
## grow_by_index 258.584783 261.639465 299.781601 263.896162
## preallocated 7.151352 7.222043 7.372342 7.257661
## uq max neval cld
## 351.887538 375.447134 20 b
## 7.382103 8.612665 20 a
结果很明显,频繁地增长列表会显著地减慢代码速度,而在长度范围内修改预分配的
列表则很快。同样的逻辑也适用于原子向量和矩阵。在 R 中,增长数据结构通常很慢,因
为这会导致重分配,也即将原始数据结构复制到新的内存地址中。这种操作非常耗时,尤
其是在数据量很大的时候。
然而,准确的预分配并不总是可行的,因为我们需要在迭代之前确定列表长度。有时,
只能重复储存结果,而不知道确切的长度。在这种情况下,也许给列表或向量预分配一个
合适的长度仍是个好主意。当迭代结束后,如果迭代次数没有达到预分配的长度,我们对
列表和向量构建子集即可。这样就可以避免对数据结构进行频繁地重分配了。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】凌霞软件回馈社区,携手博客园推出1Panel与Halo联合会员
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步