谈到提高Python执行性能,特别是数据处理时,有太多第三方库可以帮助我们。如果我们考虑它们的机制,大多数依赖于优化数据结构或内存利用来实现性能改进。
例如,Dask利用并行计算和内存优化,Pandas依赖于数据集的向量化,Modin优化了多核CPU和内存的利用。
在本文中,我不会介绍任何库。实际上,有一个原生的Python装饰器可以显著提高性能。我们不需要安装任何东西,因为它是Python内置的。当然,并不是所有情况都适用。因此,在最后一节中,我还会讨论什么情况下不应该使用它。
让我们从一个我们都熟悉的基本示例开始,即斐波那契数列。下面是使用递归的常规实现。
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)
这里是运行结果和性能比较:
缓存版本的性能比非缓存的版本快大约138倍!
顺便说一句,我给%timeit魔术命令添加了-r 1 -n 1参数,这是为了确保函数只会被执行一次。否则,缓存版的斐波那契函数将会非常快。例如,如果我们运行该函数10000次,除了第一次外,其他9999次结果都将直接从缓存中加载。因此,这些参数确保测试只会被执行一次。
让我们来看看这个斐波那契递归函数的调用栈。为了方便演示,我们给一个比较小的参数。下图展示了 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(x) 只会被计算一次。
这就是为什么启用缓存后性能比普通递归函数要高得多的原因。特别是对于斐波那契函数,我们可以得出这样的结论:参数越大,缓存所带来的性能提升就越明显。例如,fibonacci_cached(30) 的性能将比 fibonacci(30) 快10000倍以上。
“Watch out⚠️:
fibonacci_cached.cache_clear()的作用是清除缓存,否则它会使用前面fibonacci_cached(20)的缓存结果,从而影响性能比较。
假设我们正在开发一个基于Python的数据仪表板,该仪表板有许多用户。该仪表板显示中国5个城市的天气数据,并允许用户筛选和汇总特定城市的温度数据。
下面的代码将生成一些虚拟数据作为数据集。
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from functools import cache
# 创建样本数据:记录5个城市10天内的每小时温度
np.random.seed(0)
date_range = pd.date_range(start='2024-04-01', end='2024-04-10', freq='h')
temp_df = pd.DataFrame({
'timestamp': np.tile(date_range, 5),
'city': np.repeat(['北京', '上海', '广州', '深圳', '天津'], len(date_range)),
'temperature': np.random.normal(loc=15, scale=10, size=(len(date_range)*5, ))
})
样本数据概览:
接下来,我将写一个方法来计算一个城市的平均温度。当然,需要两个版本,一个是普通版本,另一个带有“cache”装饰。
def compute_avg_daily_temp(date, city):
daily_temp_df = temp_df[(temp_df['timestamp'].dt.date == pd.to_datetime(date).date())
& (temp_df['city'] == city)]
return daily_temp_df['temperature'].mean()
@cache
def compute_avg_daily_temp_cached(date, city):
daily_temp_df = temp_df[(temp_df['timestamp'].dt.date == pd.to_datetime(date).date())
& (temp_df['city'] == city)]
return daily_temp_df['temperature'].mean()
我们可以调用上面定义的函数来看看它们是否可以正常发挥作用:
现在,我们一起来看一下性能。请注意,如果您也测试了上述函数的输出,请勿使用相同的日期。在上面的测试中,我使用了日期2024年4月8日,因此我将在以下性能测试中使用不同的日期2024年4月10日。这是为了避免结果被缓存,导致测试结果不准确。
从上面的结果可以看出,第一次运行时,普通函数和缓存函数的性能几乎一样。
然后,第二次运行,在单元[17]中,普通函数的性能几乎没有改善。请注意,1.82毫秒和1.97毫秒并不意味着后者性能更好,因为我们只运行了一次。操作系统的波动可能导致这种很常见地差异。
然而,当我们查看第[18]单元格时,缓存函数的性能快了1350倍。这是因为这一次的计算实际上并没有发生。结果是直接从缓存中加载的。
您可能会问,在这个示例中为什么需要实现缓存机制。当然,如果我们只是想计算一个城市的平均温度,完全不需要缓存。但是,假设我们正在开发一个数据驱动的仪表板应用,缓存功能将推动以下实践。
如果这个仪表板应用有多个用户,他们可能会查询相同的数据集并请求相同的汇总或计算指标。因此,缓存功能将帮助第二个及后续所有用户在更短的时间内得到结果。
在这个示例中,我们使用的每日数据是固定的。也就是说,一旦某天的平均温度被计算出来后,它就不会改变了。
在其他大多数编程语言中,实现这样的缓存机制可能会更加困难。然而,通过前面的实例,我们可以看到,在Python中,实现这种机制会更简单一些。
当然,并不是所有情况都推荐使用缓存。在你开始在每个Python函数上添加@cache之前,需要考虑以下几点。
请注意,@cache装饰器是在Python 3.9版本中引入的。如果你不能使用3.9或更高版本,请考虑使用@lru_cache,这是一个更全面的缓存功能。我将在下一篇文章中介绍这个装饰器。
当函数中包含任何非确定性内容时,我们绝对不能使用缓存。还是用上面计算平均温度的例子来说明。假设我们将需求修改为“获取今天的平均温度”。我们可能需要在此函数中添加一个datetime.now()来获取当前的时间戳。获取当前时间戳的行为是非确定性的。
如果你还不明白我的意思,请看下面这个例子。
from datetime import datetime
from functools import cache
@cache
def get_current_time_cached():
return datetime.now()
上述函数是最简单的非确定性函数。在运行函数之前,我们先运行 datetime.now() 函数获取当前时间戳。
从上面的结果中我们可以看到,第一次用datetime.now()和缓存函数获取的时间戳打印结果一样只是因为两行代码运行很快,并且受当前精度的影响,使得结果看起来一致。实际上二者应该不同,因为时间是时刻流动的。但是对于缓存函数,无论你运行多少次,时间戳都不会有所改变,因为从第一次之后的每一次都是取第一次的缓存结果。对于时间敏感的应用来说,这将会引入及其严重的bug,所以必须得慎之又慎!
相同的原则也适用于基于随机的函数。如果我们的随机函数每次生成相同的结果,那它就不再具有随机性啦!
所谓“副作用”,我指的是除了返回值之外的操作,例如将文本写入文件或更新数据库表。如果我们对这些函数使用缓存,那么“副作用”将不会在第二次发生。换句话说,它只会在我们第一次调用函数时起作用。
假设我们有一个函数,其任务是将消息追加到日志文件中,并返回该消息的长度。如果我们对这样一个具有副作用的函数使用缓存,那么在首次执行写入操作后,后续相同输入的调用将直接从缓存中获取结果,而不会再次执行写入操作。
from functools import cache
import os
# 定义一个简单的日志函数,每次调用时都会向文件写入一条消息
@cache
def log_message(message):
with open("log.txt", "a") as file:
file.write(message + "\n")
return len(message)
# 第一次调用函数,写入消息
log_message("Hello!")
# 尝试再次调用相同的消息
log_message("Hello!")
# 查看文件内容
with open("log.txt", "r") as file:
contents = file.readlines()
print(contents)
# 只输出一行:Hello!
在这个例子中,虽然我们调用了 log_message("Hello!") 两次,但由于使用了 @cache 装饰器,"Hello!" 只被写入文件一次。文件内容的输出将显示这一点,即使调用函数两次,文件中也只有一行 "Hello!"。
这种行为表明,如果函数具有除返回值以外的其他副作用,那么使用 @cache 是不合适的。因为缓存会阻止那些副作用在后续的函数调用中重复执行。在这种情况下,应避免使用缓存或者仅对那些纯粹计算并返回结果的函数使用缓存。
Python将这些缓存结果存储在哪里?当然是在计算机内存中。存储一个城市的平均温度,甚至100个城市的平均温度当然都是没问题的。然而,如果我们想缓存过去一年中全球所有主要城市每小时的滚动平均温度,那就不是一个好主意了。
不过,只需强调一点,如果我们想缓存许多小结果,但担心结果集可能太多,那么使用 lru_cache 将是一个很不错的选择。functools.lru_cache 是一个特别有用的装饰器,它提供了一个缓存机制,可以存储最近使用过的结果,并在达到缓存限制时自动丢弃最少使用的数据。这对于那些需要缓存大量但不希望无限增长内存使用的场景非常适合。
下面的例子展示了如何使用 lru_cache 来缓存一个计算函数的结果,该函数计算过去一定时间范围内的温度滚动平均值。在这个例子中,我们模拟缓存多个城市每小时的温度滚动平均值。
from functools import lru_cache
import random
# 模拟获取某城市某小时的温度
def get_temperature(city, hour):
return random.uniform(20, 30)
# 使用lru_cache装饰器定义一个缓存温度平均值的函数
@lru_cache(maxsize=1000) # 设定缓存大小为1000
def get_rolling_average(city, start_hour, end_hour):
total_temp = 0
count = end_hour - start_hour
for hour in range(start_hour, end_hour):
total_temp += get_temperature(city, hour)
return total_temp / count
# 测试函数
# 获取并打印某城市连续多小时的温度平均值
city = "New York"
start_hour = 0
end_hour = 24
average_temp = get_rolling_average(city, start_hour, end_hour)
print(f"Average temperature from {start_hour} to {end_hour} in {city} is {average_temp:.2f}")
# 调用相同参数的函数多次以观察缓存效果
print(get_rolling_average(city, start_hour, end_hour)) # 直接从缓存中获取结果
print(get_rolling_average(city, start_hour + 1, end_hour + 1)) # 计算新的平均温度,并缓存结果
# 检查缓存的状态
print(get_rolling_average.cache_info())
这种方法非常适用于需要频繁重复计算相同或类似结果的情况,而 lru_cache 的限定大小则可以防止内存使用过多。这是一个高效利用计算资源的示例,尤其是在数据集较大时仍能保持性能。
请记住,缓存装饰不会加密其在内存中缓存的结果。这意味着当前操作系统中的其他进程可能会获取内存中缓存的信息。因此,请出于安全考虑,不要缓存任何敏感数据。
总的来说,我们在这篇文章中介绍了Python内置的 functools 模块中的 @cache 装饰器。它可以用来提高某些典型递归函数的性能,并且可以被认为是在我们的Python应用程序中实现缓存功能的最简单方法。
当然,它并不是万能的解决方案。我们应该意识到某些情况下绝对不应该使用它,例如在非确定性函数中。我们还需要注意内存使用,因为缓存的结果将被保留在内存中(使用 @lru_cache 作为替代)。
“如果你觉得这篇文章对你有用,请“点赞➕关注”支持我!关注我,带你解锁更多Python实用技能、数据分析知识以及更多AI新玩法!
Thank you for reading!
- END -
推荐阅读👇👇👇
Python中的return和yield关键字的用法和区别你知道吗?
Python小技巧:连接字符串不要再使用+号了,用这个方法更快
原文作者:数据派探险家;如有侵权,请联系管理员删除。