在谈到提高 Python 执行性能,尤其是对于数据处理时,有太多的第三方库可以帮助我们。如果我们考虑一下它们的机制,它们中的大多数都依赖于优化数据结构或利用内存来实现性能提升。
例如,Dask 利用并行计算和内存优化,Pandas 依赖于数据集的矢量化,而 Modin 也优化了多核 CPU 和内存的利用率。
在本文中,我不会介绍任何库。事实上,有一个原生的 Python 装饰可以用来显著提高性能。我们不需要安装任何东西,因为它内置于 Python 中。当然,它不会用于所有场景。因此,在最后一节中,我还将讨论何时不应使用它。
1. 缓存装饰的用例
让我们从一个我们都熟悉的普通例子开始,斐波那契数列。下面是一个使用递归的正常实现。
def fibonacci(n): if n < 2: return n return fibonacci(n-1) + fibonacci(n-2)
像大多数其他编程语言一样,Python 还需要为递归函数构建一个“堆栈”,并计算每个堆栈的值。
但是,“缓存”装饰将显著提高性能。此外,这样做并不难。我们只需要从模块中导入它,然后将装饰添加到函数中。functools
from functools import cache @cache def fibonacci_cached(n): if n < 2: return n return fibonacci_cached(n-1) + fibonacci_cached(n-2)
以下是运行结果和性能比较。
它表明,启用缓存的版本的性能大约比没有缓存的版本快 120 倍!
顺便说一句,我已经为魔术命令提供了参数,以确保该函数只会执行一次。否则,缓存的斐波那契函数将占主导地位。例如,如果我们运行函数 10000 次,除了第一次,所有其他 9999 次的结果都将直接从缓存中加载。因此,这些参数确保测试只执行一次。-r 1 -n 1
%timeit
2. 缓存装饰如何提高性能?
让我们看一下这个斐波那契递归函数的堆栈。为了确保它可以在图表中演示,我们必须简化场景。该图显示了 的堆栈。fibonacci(4)
无缓存的斐波那契递归函数
当我们调用函数时,递归函数将在下一个较低级别使用新参数调用自身,直到它达到基本情况 和 。fibonacci(4)
fibonacci(1)==1
fibonacci(0)==0
在上图中,需要计算所有步骤。例如,即使 和 多次出现,它们都是单独计算的。f(0)
f(1)
f(2)
现在,让我们看一下启用缓存的场景。
带缓存的斐波那契递归函数 — 所有步骤
这一次,不再需要计算以绿色显示的步长。只要函数计算过一次,它就会被缓存。然后,当再次发生时,结果将直接从内存中加载,因为它已被缓存。f(x)
f(x)
因此,在上图中,甚至不需要计算灰色步长。因此,真正的堆栈将如下所示。部分函数将直接从缓存中加载。f(x)
带缓存的斐波那契递归函数 — 实际步骤
如果我们回到上面代码中的示例,将如下所示。fibonacci_cached(20)
带缓存的斐波那契递归函数 — f(20),部分演示
从图中,我们可以很容易地理解,只有左边的步骤会被实际计算,而每个步骤只会被计算一次。f(x)
这就是为什么启用缓存时的性能远高于正常递归函数的原因。此外,对于这个特定示例,斐波那契函数,我们可以得出,用作参数的数字越大,缓存将给我们带来更多的性能改进。例如,的性能将比 高 120 倍以上。fibonacci_cached(30)
fibonacci(30)
3. 来自现实世界的实际例子
假设我们正在开发一个基于 Python 的数据仪表板,该仪表板有许多用户。该仪表板显示美国 5 个城市的天气数据,并允许用户过滤和汇总某些城市的温度数据。
下面的代码将生成一些虚拟数据作为数据集。
import pandas as pd import numpy as np from datetime import datetime, timedelta from functools import cache # Create sample data: hourly temperature recordings over 10 days for 5 cities np.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,)) })
然后,我们来编写一个计算城市平均温度的方法。当然,我们需要它的两个版本,一个是正常的,另一个带有“缓存”装饰。
def compute_avg_daily_temp(date, city): day_data = data[(data['timestamp'].dt.date == pd.to_datetime(date).date()) & (data['city'] == city)] return day_data['temperature'].mean() @cache def compute_avg_daily_temp_cached(date, city): day_data = data[(data['timestamp'].dt.date == pd.to_datetime(date).date()) & (data['city'] == city)] return day_data['temperature'].mean()
我们可以调用函数来检查函数是否运行正常。
compute_avg_daily_temp('2024-04-09', 'New York') compute_avg_daily_temp_cached('2024-04-09', 'New York')
现在,让我们来看看性能。请注意,如果您还如上所述测试了函数的输出,请不要使用相同的日期。在上面的测试中,我使用了日期,因此在接下来的性能测试中我将使用不同的日期。这是为了避免结果被缓存并导致测试结果不准确。2024–04–09
2024–04–10
在单元格 [9] 和 [10] 中,正常单元格和缓存单元格的性能相同。这些是分别执行的这些函数的首次运行。
然后,对于它们的第二次运行,正常功能在细胞中的性能几乎没有改善[11]。请注意,8.82 毫秒和 5.86 毫秒并不意味着后者具有更好的性能,因为我们只运行了一次。操作系统的波动可能会导致这种差异,这种情况很常见。
但是,当我们查看单元格 [12] 时,缓存函数的性能大约快 1.733 倍。那是因为计算实际上并没有发生。结果是从缓存中加载的。
为什么这个例子是可行的?
你可能会问,为什么我们需要在这个例子中实现缓存机制。当然,如果我们只想计算一个城市的平均温度,我们根本不需要缓存。但是,考虑到我们在这里正在开发一个数据驱动的仪表板应用程序。因此,缓存功能将推动以下做法。
如果我们有此仪表板应用的多个用户,他们可以查询相同的数据集并请求相同的聚合或计算指标。因此,缓存功能将帮助第二个用户和所有其他用户在很短的时间内获得结果。
在此示例中,我们每天使用的数据与每天的数据是一致的。也就是说,平均温度一旦计算一天就不会改变。
在大多数其他编程语言中,实现这样的缓存机制可能要困难得多。但是,我们已经看到,在 Python 中它可以更容易。
4. 其他注意事项以及何时不应使用缓存
当然,不建议到处都使用缓存。在开始添加到每个 Python 函数之上之前,需要考虑以下事项。@cache
Python 的版本
请注意,装饰是在 Python 3.9 中引入的。如果您无法使用版本 3.9+,请考虑使用 ,这是一个更全面的缓存功能。我将在下一篇文章中介绍这种装饰。@cache
@lru_cache
不要在非确定性函数中使用缓存
当函数中存在任何不确定性的东西时,我们永远不应该使用缓存。让我们使用与上面相同的示例来计算平均温度。假设我们将要求更改为“获取今天的平均温度”。我们可能需要添加 a 来获取此函数中的当前时间戳。获取当前时间戳的操作是不确定的。datetime.now()
如果你不明白我的意思,请看这个例子。
from datetime import datetime from functools import cache @cache def get_current_time_cached(): return datetime.now()
上述函数是最简单的非确定性函数。在运行函数之前,让我们将该函数与缓存的函数一起运行。datetime.now()
我们可以看到,即使我非常快速地运行这两个单元格,但如果我们使用 .这是有道理的,因为时间在流逝。但是,无论我们调用缓存函数多少次,时间戳都不会再次更改。我们在应用程序中引入了一个严重的错误!datetime.now()
同样的原则也适用于基于随机的函数。如果每次我们的随机函数都生成相同的结果,那么它就不再是随机的了。
如果函数有“副作用”,请不要使用缓存
我所说的“副作用”是指除了返回值之外的操作,例如将一些文本写入文件或更新数据库表。如果我们对这些函数使用缓存,那么“副作用”将永远不会第二次发生。换句话说,它仅在我们第一次调用函数时才起作用。
如果存在内存大小问题,请不要使用缓存
Python 将这些缓存结果存储在哪里?当然,在计算内存中。存储一个城市甚至 100 个城市的平均温度肯定是可以的。但是,如果我们想缓存世界上所有主要城市去年每小时的滚动平均温度,那将是一个坏主意。
但是,只需强调一下,如果我们想缓存许多小结果,但又担心结果集太多,那么这将是一个很好的用例。请密切关注我的个人资料。我稍后会为这个装饰写另一篇文章。lru_cache
如果数据对安全敏感,请不要使用缓存
请记住,缓存装饰不会对缓存在内存中的结果进行加密。这意味着当前操作系统中的其他进程可能会获取内存中缓存的信息。
因此,出于安全原因,请不要缓存任何敏感数据。
总结
综上所述,我们在本文中介绍了 Python 内置的模块中的装饰。它可以用来提高一些典型的递归函数的性能,也可以被认为是在我们的 Python 应用程序中实现缓存功能的最简单方法。@cache
functools