python程序提高性能的技术(一)
首先明确四个问题,并分四个部分来讨论。
1.程序运行时衡量性能的基本方法是什么?
2.如何通过分析代码来识别性能瓶颈?
3.如何使用memory_profiler包来进行基本内容分析?
4.如何使用大O来表示计算复杂度?
第一部分
提高程序性能,笼统的第一个想法就是提高cpu使用量和内存效率、减少网络上的延迟传输或消耗等,这样会使程序运行的更快。
先新编写一个程序,以此为测试对象进行解释。
《宝藏猎人程序》
假设你是一个宝藏猎人,路过一片充满金币的半径为10,(直径为20)的圆形区域,你只能沿着区域的直径行走并收集金币。收集方法是每走一步(1个单位),收集半径为1的一个圆内的金币。如图:
可以将大圆的圆心设为坐标(0,0),十等分直径,你的搜索金币的半径为1。图中每个小圆的圆心,就是你停留并搜索金币的位置,如果在此范围内则收集此金币,最后汇总数量。
此程序逻辑为:a.对每个小搜索圈,获取中心坐标(你的位置)b.计算每个黄金和你所在位置(小圆心)的距离。c.收集小于等于你搜索半径的金币,即小圆内的金币。d.你走到下一个搜索圈中心,重复刚才步骤。e.计算你获得金币的总数。
import math
import random
class GoldHunt:
def __init__(self, field_coins=5000, field_radius=10, search_radius=1):
self.field_coins = field_coins # 区域金币总数
self.field_radius = field_radius # 总区域半径
self.search_radius = search_radius # 搜索半径
self.your_x = -(self.field_radius - self.search_radius) # 你的初始位置x坐标
self.your_y = 0
self.movedistance = 2 * search_radius # 从第一个小圆开始,每次移动距离为2
def generate_random_points(self, tmp_radius, total_points): # 在大圆创建随机点,即金币位置,参数为圆区域半径、金币总数
coins_x = []
coins_y = []
for i in range(total_points):
theta = random.uniform(0, 2 * math.pi) # 随机创建0~360度内夹角
r = tmp_radius * math.sqrt(random.uniform(0, 1)) # 随机创建的点的半径,用r=random.uniform(1,10)无法保证随机点在圆内
coins_x.append(r * math.cos(theta)) # 计算金币x坐标并追加到x坐标列表
coins_y.append(r * math.sin(theta))
return coins_x, coins_y
def find_coins(self, x_list, y_list):
collected_coins = []
for x, y in zip(x_list, y_list):
tmp_x = self.your_x - x
tmp_y = self.your_y - y
dist = math.sqrt(tmp_x * tmp_x + tmp_y * tmp_y) # 计算你的当前坐标和硬币坐标的距离
if dist <= self.search_radius: # 如果小于搜索半径则加入到收集列表
collected_coins.append((x, y))
return collected_coins
def play(self): # 程序逻辑
total_collected_coins = [] # 收集金币总数
x_list, y_list = self.generate_random_points(self.field_radius, self.field_coins)
while self.your_x <= 9: # 自己的x坐标小于9
coins = self.find_coins(x_list, y_list) # 收集硬币收集收集数量
print("坐标:", self.your_x, "收集硬币数:", len(coins))
total_collected_coins.extend(coins) # 列表追加到总记录
self.your_x += self.movedistance # 向右移动一次
print("总金币收集数:", len(total_collected_coins))
if __name__ == \'__main__\':
game = GoldHunt()
game.play()
这个程序完成后,测试发现当增大范围内的金币数量,或减少搜索半径,都会显著增加程序的运行时间。
如何准确测量时间?可以借助python内置时间模块。
更改代码:
if __name__ == \'__main__\': start = time.perf_counter() # 记录开始时刻 game = GoldHunt() game.play() end = time.perf_counter() # 程序结束时刻 print("代码断总时间为:", end - start) # 统计
也可以借助timeit模块来监测时间。用法:python -m timeit [--number=自定义代码执行次数] "语句或命令"
例子:python -m timeit \'goldhunt\' (只需要模块名不需后缀)
这些计时器,测量整个程序还能用法,但如果在整个程序各个模块实现多个计时器,无疑是很麻烦的,此时就需要代码分析技术(cProfile、pstats、line_profile包)出场了。能够统计各种函数调用频率和时间,用于识别出代码的性能瓶颈。
编写测试程序ex.py:
1 def test1(): 2 return 100 * 100 3 4 5 def test2(): 6 x = [] 7 for i in range(10000): 8 temp = i / 1000 9 x.append(temp * temp) 10 return x 11 12 13 def test3(condition=False): 14 if condition: 15 test3()
命令行中运行:python -m cProfile ex.py ,结果如下:
10007 function calls (10006 primitive calls) in 0.002 seconds #显示函数调用总数 primitive----原始的,调用不涉及递归 Ordered by: standard name ncalls tottime percall cumtime percall filename:lineno(function) #ncalls函数调用的数量、tottime显示给定的函数花费总时间、percall=totcall/ncalls、cumtime累计时间(包括其子函数花费时间)、 1 0.002 0.002 0.002 0.002 ex.py:13(test2) 2/1 0.000 0.000 0.000 0.000 ex.py:21(test3) 1 0.000 0.000 0.002 0.002 ex.py:9(<module>) 1 0.000 0.000 0.000 0.000 ex.py:9(test1) 1 0.000 0.000 0.002 0.002 {built-in method builtins.exec} 10000 0.001 0.000 0.001 0.000 {method \'append\' of \'list\' objects} 1 0.000 0.000 0.000 0.000 {method \'disable\' of \'_lsprof.Profiler\' objects}
看tottime列,能发现test2模块耗费时间最长,为0.002s
现在可以试着用cProfile分析goldhunt问题了,并将其重定向到一个文件。python -m cProfile goldhunt.py >1.txt
通过命令 python -m cProfile -o profile_output goldhunt.py 可以用pstats模块对cProfile重定向中的文件进行进更美观直接的分析,此时profile_output文件不可读,供pstats使用。
如果在python程序中测试,则添加两个模块后更改的地方:
1 import cProfile 2 import pstats 3 4 """goldhunt源代码""" 5 6 def play_game(): 7 game = GoldHunt() 8 game.play() 9 10 11 def view_stats(file, text_restriction): # 第一个参数为 要分析的文件名 12 stats = pstats.Stats(file) 13 stats.strip_dirs() # 从文件名中删除所有路径前缀信息字符串,简化输出文件 14 sorted_stats = stats.sort_stats("tottime") 15 sorted_stats.print_stats("goldhunt") # 从全部内容筛选并打印出关于goldhunt的行信息 16 17 18 if __name__ == \'__main__\': 19 filename = "profile_output" 20 cProfile.run(\'play_game()\', filename) # 用run来运行cProfile,参数为监控的函数、设置输出的文件名 21 view_stats(filename, "goldhunt")
结果为:
Thu Oct 22 16:48:12 2020 profile_output 95588 function calls in 0.033 seconds Ordered by: internal time List reduced from 17 to 5 due to restriction <\'goldhunt\'> ncalls tottime percall cumtime percall filename:lineno(function) 10 0.016 0.002 0.021 0.002 goldhunt.py:38(find_coins) 1 0.006 0.006 0.012 0.012 goldhunt.py:27(generate_random_points) 1 0.000 0.000 0.033 0.033 goldhunt.py:60(play_game) 1 0.000 0.000 0.033 0.033 goldhunt.py:48(play) 1 0.000 0.000 0.000 0.000 goldhunt.py:17(__init__)
查看结果显示出最费时的两个函数模块为:find_coins与generate_random_points
既然找到了最费时的模块,能否对费时的模块进一步进行分析,找到内部问题呢?答案就是line_profiler包。这个包可以逐行的监视函数的性能。通过pip安装。安装方法
如果手动下载模块,则将需要的whl文件并解压到Python/Lib/site-packages中。在cmd窗口运行 pip install 带.whl文件的路径。注意和python版本对应。python3.8就下载(line_profiler-3.0.2-cp38)
安装完毕后,需要对待测试函数进行一些修改,即在其前加修饰@profile,然后运行kernprof -v -l goldhunt.py (-v表示在终端显示分析结果,-l表示使用分析包中的line-by-line分析器).
结果如下(在find_coins函数上一行加入@profile,然后在终端运行kernprof):
Wrote profile results to goldhunt.py.lprof Timer unit: 1e-07 s Total time: 0.0967073 s File: goldhunt.py Function: find_coins at line 37 Line # Hits Time Per Hit % Time Line Contents ============================================================== 37 @profile 38 def find_coins(self, x_list, y_list): 39 10 149.0 14.9 0.0 collected_coins = [] 40 50010 163313.0 3.3 16.9 for x, y in zip(x_list, y_list): 41 50000 183230.0 3.7 18.9 tmp_x = self.your_x - x 42 50000 178229.0 3.6 18.4 tmp_y = self.your_y - y 43 50000 264055.0 5.3 27.3 dist = math.sqrt(tmp_x * tmp_x + tmp_y * tmp_y) # 计算你的当前坐标和硬币坐标的距离 44 50000 175174.0 3.5 18.1 if dist <= self.search_radius: # 如果小于搜索半径则加入到收集列表 45 481 2833.0 5.9 0.3 collected_coins.append((x, y)) 46 10 90.0 9.0 0.0 return collected_coins
能看到第43行计算距离的代码耗时最高。
注意如果不再使用line_profiler后,一定要去掉@profile修饰符。否则程序无法正常运行。
以上查看了程序所运行的时间情况,如何查看内存占用情况呢?需要安装两个模块memory_profiler与pautil。安装完毕后用法和line_profiler类似,也是在函数前加前缀@profile,
然后命令行调用python -m memory_profiler gold_hunt.py ,会产生内存分析器的输入。
如果在运行的时候出现如下的gbk解码错误,解决方案是首先进入 memory_profiler.py文件中,找到第1131行,把with open(filename) as f: 更改成 with open(filename, encoding=\'utf-8\') as f:!!!
测试generate_random_points函数,结果如下:
Filename: goldhunt.py Line # Mem usage Increment Occurences Line Contents ============================================================ 28 40.637 MiB 40.637 MiB 1 @profile 29 def generate_random_points(self, tmp_radius, total_points): # 在大圆创建随机点,即金币位置,参数为圆区域半径、金币 总数 30 40.637 MiB 0.000 MiB 1 coins_x = [] 31 40.637 MiB 0.000 MiB 1 coins_y = [] 32 41.148 MiB 0.273 MiB 5001 for i in range(total_points): 33 41.148 MiB 0.004 MiB 5000 theta = random.uniform(0, 2 * math.pi) # 随机创建0~360度内夹角 34 35 41.148 MiB 0.000 MiB 5000 r = tmp_radius * math.sqrt(random.uniform(0, 1)) # 随机创建的点的半径,为什么不用r=random.uniform(1,10)直 接生成? 36 41.148 MiB 0.172 MiB 5000 coins_x.append(r * math.cos(theta)) # 计算金币x坐标并追加到x坐标列表 37 41.148 MiB 0.062 MiB 5000 coins_y.append(r * math.sin(theta)) 38 41.148 MiB 0.000 MiB 1 return coins_x, coins_y
黄色标注明显内存增长,表示内存主要在for循环语句中被使用。
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
以上内容介绍了如何测量程序时间的方法。现在来看算法和复杂度问题。
算法是解决特定问题的一组指令。资源消耗越低,效率越高。
假如一个算法五分钟内可以处理一些数据,如果增大待处理数据量,程序的时间就会出现各种变化,即不同的算法复杂度也会不同。
需要说明的是及时两个算法有相同的大O时间复杂度,性能也不是一样的(有可能受其他影响,比如说乘以一个常数,因为常数在计算复杂度时常被忽略)
大O复杂度表示最坏情况的复杂度。
直观的大O复杂度排序(需要记住):O(1)<O(lgn)<O(n)<O(n*lgn)<O(n*n)<O(n*n*n)-------->常数<对数<线性<对数*线性<平方<三次方
对数O(lgn)的例子为二分查找;对数*线性O(n*lgn)的例子为快速排序;平方O(n*n)的例子为冒泡排序
为什么说快速排序的最差时间复杂度是O(n*n)?----最差情况退化到了冒泡排序情况