Python 使用lru_cache
保存计算结果,使用@lru_cache
装饰器能加快函数运行,其中LRU指最近使用的(least recent used) ——最近使用过的计算结果会保留在这个容量有限的池子中。为了避免池中内容无限制地增加,会清除不常用的计算结果。
作为装饰器,可以将它应用于需要保存之前计算结果的任何函数,示例如下:
from functools import lru_cache
@lru_cache(128)
def fibc(n: int) -> int:
if n == 0: return 0
if n == 1: return 1
return fibc(n - 1) + fibc(n - 2)
该示例基于前面的斐波那契数列算法实现。由于对简单的斐波那契数列算法应用了@lru_cache
装饰器,每次调用fibc(n)
时,都会检查由装饰器维护的缓存池。如果参数n
在缓存池中,直接使用对应的计算结果,避免了重复计算导致的成本增加。每次计算的结果都会保存到缓存池中。
上例意在展示斐波那契数列的简单递归实现的计算量是非常大的,每次计算给定的斐波那契数 F_n 时,不仅要计算 F_{n-1},而且要计算 F_{n-2},这棵递归树的计算复杂度是 O(2^n)。
装饰器中的参数128定义了缓存池的大小,用于限制内存的使用上限。当缓存池满时,LRU计算结果会被新值覆盖。
下面通过timeit
模块验证该装饰器提升性能的幅度。将两个实现各运行1000次,比较所用的时间。分别运行fib(20)
和fibc(20)
,就可以看到不用缓存的计算极慢,由于计算耗费的时间过多,这里把timeit
重复计算的次数仅设置为1000,计算消耗的时间如下所示。
- 非缓存实现:3.23
- 带缓存实现:0.0779
请注意,不能将timeit
模块直接应用于函数fibc()
,由于缓存的存在,直接应用时只有一次真正计算,计算结果保存在缓存池中,其他999次计算直接从缓存中取结果,并没有再次计算。每次计算后需要清空缓存,否则总计算时间将接近0。清空计算结果由装饰器的fibc.cache_clear()
方法实现。
缓存是个很强大的工具,很多算法都可以通过缓存结果提升性能。
在包含 p 个元素的集合中抽取 r 个元素的公式如下:
该二项分布函数包含了3个阶乘计算。在阶乘函数上使用@lru_cache
很有必要,借助缓存计算二项分布值就不必重复计算阶乘了。如果计算过程包含大量重复计算,使用缓存可以显著缩短计算时间,但在计算结果很少被复用的场景中,维护缓存的开销可能会抵消速度提升。
对于上面这个包含大量重复计算的场景,有无缓存消耗的时间分别如下。
- 无缓存阶乘:0.174
- 有缓存阶乘:0.046
需要指出的是,缓存是有状态对象,基于缓存的实现方案不符合纯函数式编程要求。理想的函数式编程应尽量避免状态变化,例如函数式编程中多使用递归取代值有状态的变量,当前状态由函数的参数定义,而不是保存在值可变的变量里。前面讲过运用尾递归优化技术,即使只用性能有限的处理器和内存,也能显著提升性能。在Python中,我们使用for
循环手动实现尾递归优化。缓存是一种类似的优化技术,如有需要便手动实现它,但它本身不是纯函数式编程。
原则上调用带LRU的函数有两个结果:返回本次计算结果,并将其保存到缓存池中供后续使用。被缓存的值封装在带装饰器的fibc()
函数内部,无法查看或操作。
缓存技术不是灵丹妙药,涉及大量实数计算的应用中,由于实数的近似效应,缓存的效果往往不好,往往将实数的最小有效数字视作随机噪声,导致@lru_cache
装饰器的目标查找难以正常工作。
后面会介绍解决这一问题的方法。