原文:
towardsdatascience.com/how-to-use-python-built-in-decoration-to-improve-performance-significantly-4eb298f248e1
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/58d7a342065e9269df9c5c5f7ec18f16.png
图片由作者在 Canva 中创建
当谈到提高 Python 执行性能时,尤其是在数据处理方面,有太多第三方库可以帮助我们。如果我们考虑它们的机制,大多数都依赖于优化数据结构或内存利用来达到性能提升。
例如,Dask 利用并行计算和内存优化,Pandas 依赖于数据集的向量化,Modin 也优化了多核 CPU 和内存的利用。
在这篇文章中,我不会介绍任何库。事实上,有一个原生的 Python 装饰可以用来显著提高性能。我们不需要安装任何东西,因为它内置在 Python 中。当然,它不会在所有场景下都使用。所以,在最后一节,我还会讨论我们不应该使用它的情况。
1. 缓存装饰的一个用例
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/5093c86bddadba9b6d0a524b879939f3.png
图片由作者在 Canva 中创建
让我们从大家都很熟悉的简单例子开始,那就是 斐波那契数列。下面是一个使用递归的正常实现。
deffibonacci(n):ifn<2:returnnreturnfibonacci(n-1)+fibonacci(n-2)就像大多数其他编程语言一样,Python 也需要为递归函数构建一个“栈”并在每个栈上计算值。
然而,“缓存”装饰将显著提高性能。而且,这样做并不困难。我们只需要从functools模块导入它,然后将装饰添加到函数中。
fromfunctoolsimportcache@cachedeffibonacci_cached(n):ifn<2:returnnreturnfibonacci_cached(n-1)+fibonacci_cached(n-2)这里是运行结果和性能比较。
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/8c7d77b6675b39746ab2dc0a3bd79740.png
这表明启用缓存的版本的性能大约比未启用缓存的版本快120 倍!
BTW,我已经给%timeit魔法命令设置了-r 1 -n 1参数,以确保函数只会执行一次。否则,缓存的斐波那契函数会非常快。例如,如果我们运行函数 10000 次,除了第一次,其他 9999 次的结果将直接从缓存中加载。因此,这些参数确保测试只执行一次。
2. 缓存装饰如何提高性能?
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/d0cf70a2e204509875190c0014cfe7c5.png
图片由作者在 Canva 中创建
让我们看看这个斐波那契递归函数的调用栈。为了确保它可以在图中演示,我们必须简化场景。图显示了fibonacci(4)的调用栈。
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/19c0a629ed64a05f41b768e852356137.png
无缓存的斐波那契递归函数
当我们调用fibonacci(4)函数时,递归函数将在下一个较低的级别以新的参数调用自身,直到达到基本案例fibonacci(1)==1和fibonacci(0)==0。
在上面的图中,所有步骤都需要计算。例如,即使f(0)、f(1)和f(2)出现多次,它们都是单独计算的。
现在,让我们看看启用缓存的情况。
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/479ca326354d2d70c9633639c21ecc64.png
带缓存的斐波那契递归函数 – 所有步骤
这次,绿色标记的步骤不再需要计算。只要函数f(x)被计算过一次,它就会被缓存。然后,当f(x)再次发生时,结果将直接从内存中加载,因为它已经被缓存。
因此,在上面的图中,灰色步骤甚至不需要计算。所以,实际的调用栈将类似于以下内容。一些f(x)函数将直接从缓存中加载。
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/5589b8c676fe6024f72098a4a734f2cc.png
带缓存的斐波那契递归函数 – 实际步骤
如果我们回到上面代码中的例子,fibonacci_cached(20)将看起来如下。
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/82066234985f05ed128cc670c2aa0726.png
带缓存的斐波那契递归函数 – f(20),部分演示
从图中我们可以轻松理解,只有左边的步骤会被实际计算,每个f(x)只会被计算一次。
这就是为什么启用缓存后的性能比普通递归函数高得多的原因。此外,对于这个特定的例子,斐波那契函数,我们可以推导出,使用的参数数值越大,缓存带来的性能提升就越大。例如,fibonacci_cached(30)的性能将比fibonacci(30)高出 120 多倍。
3. 来自现实世界的实际例子
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/9fa9643e6da224eed570d2177e1ecb84.png
作者在 Canva 中创建的图像
假设我们正在开发一个基于 Python 的数据仪表板,该仪表板拥有许多用户。仪表板显示美国 5 个城市的天气数据,并允许用户筛选和汇总特定城市的温度数据。
以下代码将生成一些模拟数据作为数据集。
importpandasaspdimportnumpyasnpfromdatetimeimportdatetime,timedeltafromfunctoolsimportcache# Create sample data: hourly temperature recordings over 10 days for 5 citiesnp.random.seed(0)date_range=pd.date_range(start="2024-04-01",end="2024-04-10",freq='H')data=pd.DataFrame({'timestamp':np.tile(date_range,5),'city':np.repeat(['New York','Los Angeles','Chicago','Houston','Phoenix'],len(date_range)),'temperature':np.random.normal(loc=15,scale=10,size=(len(date_range)*5,))})这里是样本数据的输出。
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/b7ecee7026d123bf85515b216d716070.png
然后,让我们编写一个计算城市平均温度的方法。当然,我们需要两个版本,一个是正常的,另一个带有“缓存”装饰。
defcompute_avg_daily_temp(date,city):day_data=data[(data['timestamp'].dt.date==pd.to_datetime(date).date())&(data['city']==city)]returnday_data['temperature'].mean()@cachedefcompute_avg_daily_temp_cached(date,city):day_data=data[(data['timestamp'].dt.date==pd.to_datetime(date).date())&(data['city']==city)]returnday_data['temperature'].mean()我们可以调用这些函数来检查它们是否正常工作。
compute_avg_daily_temp('2024-04-09','New York')compute_avg_daily_temp_cached('2024-04-09','New York')https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/cbf3ca113072456aac1c214e350dd7c3.png
现在,让我们看看性能。请注意,如果您也测试了函数的输出,请不要使用相同的日期。在上面的测试中,我使用了日期2024–04–09,因此我将在以下性能测试中使用不同的日期2024–04–10。这是为了避免结果被缓存并导致测试结果不准确。
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/936db753702e95f446ad0c48e637c345.png
在单元格 [9] 和 [10] 中,正常版本和缓存版本的性能相同。这些是这些函数分别执行的第一轮运行。
然后,对于它们的第二次运行,正常函数在单元格 [11] 中的性能几乎没有改进。请注意,8.82ms 和 5.86ms 并不代表后者性能更好,因为我们只运行了一次。操作系统的波动可能会非常常见地造成这种差异。
然而,当我们查看单元格 [12] 时,缓存函数的性能大约是1,733 倍更快。这是因为实际上并没有进行计算。结果是从缓存中加载的。
为什么这个例子是实用的?
您可能会问为什么我们需要为这个例子实现缓存机制。当然,如果我们只想计算一个城市的平均温度,我们根本不需要缓存。然而,考虑到我们正在开发一个数据驱动的仪表板应用程序。因此,缓存功能将推动以下实践。
如果我们有多位用户使用这个仪表板应用程序,他们可能会查询相同的数据库集,并要求相同的聚合或计算指标。因此,缓存功能将帮助第 2 个和所有其他用户在极短的时间内获得结果。
在这个例子中,我们每天使用的数据对于每天都是一致的。也就是说,一旦计算了某一天的平均温度,它就不会改变。
在大多数其他编程语言中,实现这样的缓存机制可能要困难得多。然而,我们已经看到在 Python 中这可以更容易实现。
4. 其他考虑事项和我们应该何时不使用缓存
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/bdd50f61abcb29ec72f297a100c76747.png
由作者在 Canva 中创建的图像
当然,并不建议在所有地方都使用缓存。在您开始在每个 Python 函数上添加@cache之前,以下事项需要考虑。
您的 Python 版本
请注意,@cache装饰器是在 Python 3.9 中引入的。如果您不能使用 3.9 及以上版本,请考虑使用@lru_cache,这是一个更全面的缓存功能。我将在下一篇文章中介绍这个装饰器。
不要在非确定性函数中使用缓存
当函数中存在任何非确定性内容时,我们绝不应该使用缓存。让我们用上面相同的例子来计算平均温度。假设我们将要求改为“获取今天的平均温度”。我们可能需要在这个函数中添加datetime.now()来获取当前的时间戳。获取当前时间戳的行为是非确定性的。
如果你不明白我的意思,请看这个例子。
fromdatetimeimportdatetimefromfunctoolsimportcache@cachedefget_current_time_cached():returndatetime.now()上述函数是最简单的非确定性函数。在我们运行函数之前,让我们同时运行datetime.now()函数和缓存的函数。
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/f89c3c0c1eab44a0a12d8ff1e4b5f536.png
我们可以看到,即使我非常快地运行了两个单元格,如果我们使用datetime.now(),时间戳仍然不同。这是有道理的,因为时间在流逝。然而,无论我们调用缓存的函数多少次,时间戳都不会再改变。我们在我们的应用程序中引入了一个严重的错误!
同样的原则也适用于基于随机数的函数。如果我们的随机函数每次都生成相同的结果,那么它就不再是随机的了。
如果函数有“副作用”,不要使用缓存
“副作用”的意思是指除了返回值之外的动作,例如将一些文本写入文件或更新数据库表。如果我们对这些函数使用缓存,那么“副作用”将永远不会在第二次调用时发生。换句话说,它只会在我们第一次调用函数时起作用。
如果有内存大小方面的担忧,不要使用缓存
Python 将这些缓存结果存储在哪里?当然是在计算内存中。将一个城市的平均温度存储起来,或者甚至存储 100 个城市的平均温度,这当然是可以的。然而,如果我们想缓存过去一年中所有主要城市每小时滚动平均温度,那将是一个糟糕的主意。
然而,只是强调一下,如果我们想缓存许多小的结果,但又担心结果集可能太多,那么再次使用lru_cache将是一个很好的用例。请关注我的个人资料。我稍后会有一篇关于这个装饰器的文章。
如果数据是安全敏感的,不要使用缓存
记住,缓存装饰器不会加密它在内存中缓存的任何结果。这意味着当前操作系统中可能存在其他进程可能会获取到内存中缓存的这些信息。
因此,出于安全考虑,请勿缓存任何敏感数据。
总结
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/b41c2ea0f99255fcb30369033956168f.png
图片由作者在 Canva 中创建
总结一下,我们在本文中介绍了 Python 内置的functools模块中的@cache装饰器。它可以用来提高一些典型递归函数的性能,也可以被认为是实现 Python 应用程序中缓存功能的最简单方法。
当然,这并不是一个完美且适用于所有情况的解决方案。我们应该意识到某些场景是我们绝对不应该使用的,例如与非确定性函数一起使用。同时,我们还需要注意内存的使用,因为缓存的結果将会保留在内存中。如果你对更灵活的缓存功能感兴趣,但觉得使用起来稍微有些困难,请关注我的个人资料,它即将推出。
本文中的所有图片均由作者创作