Python 3.x 为什么当maxsize是2的幂时python lru_缓存的性能最好?

Python 3.x 为什么当maxsize是2的幂时python lru_缓存的性能最好?,python-3.x,caching,lru,Python 3.x,Caching,Lru,他说: 如果maxsize设置为None,则LRU功能将被禁用,并且缓存可以在不绑定的情况下增长。当maxsize为2的幂时,LRU功能的性能最佳 有谁会碰巧知道“两个人的力量”是从哪里来的?我猜它与实现有关。TL;DR-这是一种优化,对于较小的lru_缓存大小没有太大影响,但是(参见Raymond的回复)随着lru_缓存大小的增大,这种优化会产生更大的影响 所以这激起了我的兴趣,我决定看看这是不是真的 首先,我去读取LRU缓存的源代码。cpython的实现就在这里:我没有看到任何东西在我看来是

他说:

如果maxsize设置为None,则LRU功能将被禁用,并且缓存可以在不绑定的情况下增长。当maxsize为2的幂时,LRU功能的性能最佳


有谁会碰巧知道“两个人的力量”是从哪里来的?我猜它与实现有关。

TL;DR-这是一种优化,对于较小的lru_缓存大小没有太大影响,但是(参见Raymond的回复)随着lru_缓存大小的增大,这种优化会产生更大的影响

所以这激起了我的兴趣,我决定看看这是不是真的

首先,我去读取LRU缓存的源代码。cpython的实现就在这里:我没有看到任何东西在我看来是基于二次幂更好地运行的

因此,我编写了一个简短的python程序来创建各种大小的LRU缓存,然后多次使用这些缓存。代码如下:

from functools import lru_cache
from collections import defaultdict
from statistics import mean
import time

def run_test(i):
    # We create a new decorated perform_calc
    @lru_cache(maxsize=i)
    def perform_calc(input):
        return input * 3.1415

    # let's run the test 5 times (so that we exercise the caching)
    for j in range(5):
        # Calculate the value for a range larger than our largest cache
        for k in range(2000):
            perform_calc(k)

for t in range(10):
    print (t)
    values = defaultdict(list)
    for i in range(1,1025):
        start = time.perf_counter()
        run_test(i)
        t = time.perf_counter() - start
        values[i].append(t)

for k,v in values.items():
    print(f"{k}\t{mean(v)}")
我用Python3.7.7在MacBookPro上轻载运行了这个

结果如下:

随机峰值可能是由于GC暂停或系统中断造成的

此时,我意识到我的代码总是生成缓存未命中,而从不生成缓存命中。如果我们运行相同的东西,但总是命中缓存,会发生什么

我将内环替换为:

    # let's run the test 5 times (so that we exercise the caching)
    for j in range(5):
        # Only ever create cache hits
        for k in range(i):
            perform_calc(k)
这方面的数据与上面的第二个选项卡位于同一个电子表格中

让我们看看:

嗯,但我们并不真正关心这些数字中的大多数。此外,我们在每个测试中做的工作并不相同,因此时间安排似乎并不有用

如果我们只运行2^n2^n+1和2^n-1呢。由于这加快了速度,我们将在100次测试中求出平均值,而不是仅仅10次

我们还将生成一个大的随机列表来运行,因为这样我们将期望有一些缓存命中和缓存未命中

from functools import lru_cache
from collections import defaultdict
from statistics import mean
import time
import random

rands = list(range(128)) + list(range(128)) + list(range(128)) + list(range(128)) + list(range(128)) + list(range(128)) + list(range(128)) + list(range(128))
random.shuffle(rands)

def run_test(i):
    # We create a new decorated perform_calc
    @lru_cache(maxsize=i)
    def perform_calc(input):
        return input * 3.1415

    # let's run the test 5 times (so that we exercise the caching)
    for j in range(5):
        for k in rands:
            perform_calc(k)

for t in range(100):
    print (t)
    values = defaultdict(list)
    # Interesting numbers, and how many random elements to generate
    for i in [15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 128, 129, 255, 256, 257, 511, 512, 513, 1023, 1024, 1025]:
        start = time.perf_counter()
        run_test(i)
        t = time.perf_counter() - start
        values[i].append(t)

for k,v in values.items():
    print(f"{k}\t{mean(v)}")
相关数据在上述电子表格的第三个选项卡中

以下是每个元素/lru缓存大小的平均时间图:

当然,时间会随着缓存大小的增大而减少,因为我们不需要花太多时间执行计算。有趣的是,从15到16、17到31再到32、33似乎都有一个下降。让我们放大更高的数字:

我们不仅在更高的数字中失去了这种模式,而且我们实际上看到,对于某些二次幂(511到512513),性能会下降

编辑:关于二的威力的注释是,但是functools.lru_缓存的算法,因此不幸的是,这推翻了我的理论,即算法已经改变,文档已经过时

编辑:删除我的假设。最初的作者在上面回答说——我的代码的问题是我使用的是“小”缓存——这意味着dicts上的O(n)resize不是很昂贵。使用非常大的lru_缓存和大量缓存未命中进行实验,看看我们是否可以获得显示的效果,这将是一件很酷的事情。

在哪里会出现大小效应 该词典以一种非典型的方式运行其底层词典。在保持总大小不变的同时,缓存未命中会删除最旧的项并插入新项

两个建议的力量是这个删除和插入模式如何与底层交互的产物

词典的工作原理
  • 桌子的大小是二的幂
  • 删除的键将替换为虚拟项
  • 新密钥有时可以重用虚拟插槽,有时不能
  • 使用不同的键重复删除和插入将用伪条目填充表
  • 当表格已满三分之二时,将运行O(N)调整大小操作
  • 由于活动项的数量保持不变,因此调整大小操作实际上不会更改表的大小
  • 调整大小的唯一效果是清除累积的虚拟条目
性能影响 具有
2**n
项的dict具有最多的可用空间用于虚拟项,因此O(n)调整大小的频率较低

而且,比大部分完整的词典更重要。冲突会降低字典性能

重要的时候 lru_cache()仅在缓存未命中时更新字典。此外,当出现未命中时,将调用wrapped函数。因此,只有在未命中率较高且包装函数非常便宜的情况下,调整大小的效果才会有影响

比赋予maxsize二次幂更重要的是使用最大的合理maxsize。缓存越大,缓存命中率越高,这就是大赢家的来源

模拟 一旦lru_cache()已满且第一次调整大小,字典将进入稳定状态,并且永远不会变大。在这里,我们模拟添加新的虚拟条目并定期调整大小以清除它们时接下来会发生什么

steady_state_dict_size = 2 ** 7     # always a power of two

def simulate_lru_cache(lru_maxsize, events=1_000_000):
    'Count resize operations as dummy keys are added'
    resize_point = steady_state_dict_size * 2 // 3
    assert lru_maxsize < resize_point
    dummies = 0
    resizes = 0
    for i in range(events):
        dummies += 1
        filled = lru_maxsize + dummies
        if filled >= resize_point:
           dummies = 0
           resizes += 1
    work = resizes * lru_maxsize    # resizing is O(n)
    work_per_event = work / events
    print(lru_maxsize, '-->', resizes, work_per_event)
这表明,当maxsize尽可能远离resize_点时,缓存所做的工作显著减少

历史 当字典在调整大小时增加了
4个活动\u条目时,
的影响最小

当增长率降至
2 x活动条目时的影响

稍后,将增长率设置为
3 x使用
。这在默认情况下为我们提供了更大的稳态规模,从而显著缓解了问题

两个maxsize的幂仍然是最佳设置,对于给定的稳定状态字典大小,工作最少,但它不再像Python3.2中那样重要


希望这有助于澄清您的理解。:-)

我只想指出,这个问题的作者是添加了问题中引用的原始“两种力量”评论的人:)谢谢你的解释,雷蒙德,在这里有这个实现评论的作者是非常酷的。:)我试着理解以下两句话:一个dict的2个条目被提升到n的幂,它有最可用的spa
for maxsize in range(42, 85):
    simulate_lru_cache(maxsize)

42 --> 23255 0.97671
43 --> 23809 1.023787
44 --> 24390 1.07316
45 --> 25000 1.125
46 --> 25641 1.179486
  ...
80 --> 200000 16.0
81 --> 250000 20.25
82 --> 333333 27.333306
83 --> 500000 41.5
84 --> 1000000 84.0