Tricks of Python
—— 论如何让纯python程序跑得更快,内存使用得更少
编程的哲学
Do One Thing and Do It Well
做一件事,并把他做好。
这原本是UNIX社区推崇的哲学,但是很显然可以延伸到软件开发领域甚至是日常生活中。
在编程中,每个函数应该只处理一件事,如果一个函数做了两件事,那么应该把它拆分成两个函数。
但是这里有一个问题,如何判断一个函数应不应该拆分成两个函数呢?
Don’t Repeat Yourself
这是软件开发的基本逻辑之一,在不同的层面都有其意义,你可以自由延伸其含义,这里我讲几个认为的含义:
- 不要重复造轮子,除非别人的轮子无法满足你的要求;
- 不要复制代码,除非你喜欢打字;
- 不要重复处理数据,有必要的时候可以使用内存或者缓存处理结果;
- …
代码即注释
好的代码是不需要注释,代码的可读性比性能更重要,除非你在这里真的需要提高性能。
- 变量名要清晰易懂,不要使用a,b,c,i,j,k等作为有含义的变量的变量名;
- 方法名/函数名应该能够包含这个操作的确切涵义;
- 语句要尽量Pythonic,for循环、列表表达式等等;
- 不要写多重嵌套的循环代码,如果嵌套太多,可以考虑把内存的代码重构成一个函数;
- …
认识计算机和Python
一个优秀的剑手,从认识剑开始;一个身经百战而不死的老兵,枪必然已经成为其身体的自然延申。
想写出好的代码,认识我们的工具是第一步。如果把程序员比喻成水手,那么计算机相当于大海,船相当于语言,你只有知道大海哪里有鱼,知道如何在不同条件下驾驭船只,你才能够顺利出海,满载而归。
计算单元
- CPU
- GPU
存储单元
- 机械硬盘
- 固态硬盘SSD
- 内存RAM
- L1/L2缓存
以上四个存储单元,计算机对其的读写速度依次从慢到快!
通信层
总线是各种硬件之间传送数据的连线的总称。
各种总线
64位机
总线的位数,64位机的内存地址为64位。
抢答1: 32位机器的最大内存是多少?
Python虚拟机
动态语言
不需要编译,没有类型检查,运行时报错。
内存管理
使用一个双重链表进行存储管理,python需要内存新建一个对象的时候总是从链表中找到一块大小介于需要的内存大小1到2倍之间的内存碎片,如果没有,就找到比需要的内存大的最小的内存碎片,然后不断将其对半切分直到分出一个满足1-2倍之间的大小内存块。
人生苦短,我用Python!
优秀的标准库
unicode
array.array
bisect
collections
稳定的第三方库
numpy
scipy
pandas
scikit-learn
性能分析的工具
timeit
python -m timeit -n 10 -r 8 test.py
n 循环次数
r 重复次数
timeit 会对语句循环执行n次并计算平均值作为一个结果,重复r次并选出最好的哪个结果。
cProfile / profile
python -m cProfile -o profile.stats test.py
查看每个函数的调用时间和调用次数,适用于找到代码运行速度的瓶颈。
line_profiler
在需要检测的函数上使用@profile
装饰器
查看一个函数每一行的运行时间和调用的次数,适用于找到一个函数运行速度的瓶颈。
kernprof.py -l -v test.py
memory_profiler
同上,在需要检测的函数上使用@profile
装饰器
memory_profiler模块能够逐行测量内存占用率。
python -m memory_profiler test.py
Tricks
Python层面的
数组
- 动态数组: 列表list
- 静态数组: 元表tuple
- 连续内存: array.array
- 数值计算: numpy.array
HashMap
散射表是使用一段连续的内存,然后根据哈希值跟长度的余将对象的地址填入到对应的内存中,这样取数据的时候直接使用哈希值就可以快速的判断这个值在不在集合中。
对于集合来讲,相应的位置只需要保存对象的地址就可以了;对于字典来讲,既需要保存key的地址,还需要保存value的地址。
- 集合: set
- 字典: dict
由于字典和集合都用到了散射表,所以只有可以哈希的元素(对于字典来说是key)才能够存入set和字典。
如果有成千上万个同一个类型的字典或者实例的话, 它们对内存的消耗是非常大的。
这时候就要用到namedtuple类来代替字典保存记录的属性,或者给类添加__slots__属性来禁止类的动态属性功能。
python中类的实例会使用__dict__属性来保存内的属性和值,这个属性的值是一个字典。
字符串
- unicode字符串
每个字符两个字节,但是可以表示大多数字符。python3中默认的字符串类型。 - byte字符串
每个字符一个字节,但是只能保存ASCII字符。
每个字符串对象,python需要保存一个开始指针,长度,以及使用一段连续的内存保存字符串内存,基于python的内存管理原理以及使得+=操作更快,python会使用稍大一点的内存保存字符串,如果是一个64位机器,指针和长度都是需要8个字节,那么一个字符串,占用的内存最小位16+字符长度*2个字节。
所以如果有很多碎片化的字符串,那么python对内存的消耗是非常大的。
抢答2: 在这种情况下,如何减小内存的占用呢?
迭代器和生成器
无穷迭代
def fibonacci():
a = 0
ap1 = 1
while True:
yield ap1
a, ap1 = ap1, a + ap1
延迟估值
def fetch_dict_line(self, filename):
with open(filename, "r") as f:
for line in f:
word, freq = line.rstrip("\n").split("\t")[:2]
yield word, float(freq)
def gen_FREQ(self):
FREQ = {}
for word, freq in self.fetch_dict_line(self.dict_file):
FREQ[word] = freq
for j in range(len(word)-2, 0, -1):
suf = word[:j]
if suf not in FREQ:
FREQ[suf] = 0
else:
break
self.FREQ = FREQ
原地操作
a = np.arange(10).reshape(2, 5)
b = np.ones(10).reshape(2, 5)
# a = a.__add__(b)
a = a + b
# a.__iadd__(b)
a += b
原地操作减少了内存分配的次数,可以使用print(id(a))
来查看a的内存地址的变化。
numpy
包装了BLAS(基础线性代数子例程),所以在矢量计算上比原生的C程序还要快,除非C程序也链接BLAS。
算法层面的
贪婪算法 (greedy algorithm)
贪婪算法,顾名思义,就是每一步都选择最好的结果作为当前的结果。如:
- 最小生成树
- 求N个集合的并集
分而治之
将一个问题分成几个规模更小的同类型问题。如:
- 归并排序
- 二分查找
动态规划
动态规划时通过解决很多的小问题而解决大问题。动态规划的效率取决于两个因素:子问题的数量和子问题的解决效率。如:
- 可编辑距离
- 最短路径
- KMP算法
- 维特比算法
概率数据结构
如:
Morris计数器
from random import random
counter = 0
for i in range(50000):
if random() < 1.0 / 2**counter:
counter += 1
print(2 ** counter)
KMinValues
利用了哈希值均匀分布的特性,算法保存了k个最小的哈希值,然后利用这k个哈希值的大小来估算整个数据集的大小以及不同数据集之间交和并的大小。
from countmemaybe import KMinValues
kmv1 = KMinValues(k=1024)
kmv2 = KMinValues(k=1024)
for i in range(0, 50000):
kmv1.add(i)
for i in range(25000, 75000):
kmv2.add(i)
print(len(kmv1))
print(len(kmv2))
# 交
print(kmv1.cardinality_intersection(kmv2))
# 并
print(kmv1.cardinality_union(kmv2))
BLoomFilter
这个andy之前讲过,同样时利用hash值均匀分布的特性。
LogLog计数器
模式匹配中常用的数据结构和算法
- trie树
- 前缀状态字典
- kmp算法
- AC自动机
trie树
trie树可以使用很小的内存存储大量的文本,前可以根据前缀高效查出所有的词,but不能使用纯python实现。
抢答3 为什么pure python不能节省内存?
- marisa_trie
- datrie
- hat_trie
KMP算法
def kmp_next(word):
k = -1
Next = [k]
for c in word:
while k != -1 and word[k] != c:
k = Next[k]
k += 1
Next.append(k)
return Next
def kmp_match_by_next(text, word_next):
wlenm1 = len(word_next) - 2
j = 0
for i, c in enumerate(text):
while c != word[j] and j != -1:
j = word_next[j]
if j == wlenm1:
return i-j
j += 1
return -1
def kmp_match(text, word):
Next = kmp_next(word)
return kmp_match_by_next(text, Next)
trie树+kmp算法=AC自动机
前缀状态字典
实现的功能跟AC自动机类似,但是用的时内存换效率的方法,但是无法根据前缀查所有包含前缀的词。
业务层面的
连续的内存
上面抢答题3的答案
import tmpfle
# 生成结构
indptr = array.array("I")
indptr.append(0)
tmp_filename = tempfile.mktemp()
tmf = open(tmp_filename, "w")
for s in text_generator:
indptr.append(indptr[-1]+len(s))
tmf.write(text)
tmf.close()
with open(tmp_filename, "r") as f:
text = f.read()
# 查找第k个字符串
start, stop = indptr[k:k+2]
s = text[start:stop]
上面直接用脚本把算法写出来了,实际用的时候应该把这些写成一个类。
抢答4 如果需要判断一个字符串在不在列表中并返回字符串在列表中的索引,应该如何办?
静态内存分配
对于某些函数中需要反复用到的比较大中间变量,我们可以在函数外申请一个变量,然后把变量作为参数传递给函数,在函数处理完结果返回后(或者前)将该变量恢复成默认值。
这样可以减少python不停的查找内存碎片并分配给程序的事件。
Tensorflow使用静态图就是一个实例。
抢答5 你可以想起来一个你在工作中遇到的或者其他人写的使用静态内存分配的实际的例子吗?
使用数据流
让你的输入按照一次一个数据点的方式倍访问和处理,目的是使用一个小而固定的内存占用空间。这就是上面讲的生成器的应用场景。
深度学习中的min-batch的思想就源于此。
减少数据的重复处理
把中间结果保存起来。
减小问题的搜索空间
当使用的正则表达式很多的时候,我们先根据正则表达式的特性使用AC自动机或者前缀字典找到潜在的正则表达,然后再使用正则匹配。
def get_pat_dag(self, sentence):
pat_dag = {}
fetch_pat_sub_words = set(list(
self._pat_sub_word_kwdfm.match_all(sentence)))
reserve_pat_idxes = self._reserve_pat_idxes
pat_idx_count = defaultdict(int)
for x in fetch_pat_sub_words:
for pat_idx in self._pat_sub_word_dict[x]:
if pat_idx in reserve_pat_idxes:
pat_idx_count[pat_idx] += 1
for pat_idx, words_len in iteritems(self._idx2words_len):
if words_len == pat_idx_count[pat_idx]:
patcpl = self._idx2patcpl[pat_idx]
pat_tpl = self._idx2pattpl[pat_idx]
for mtch in patcpl.finditer(sentence):
start = mtch.start(0)
if start not in pat_dag:
pat_dag[start] = []
pat_dag[start].append((mtch.end(0), pat_tpl))
for start_idx, offsets in iteritems(pat_dag):
offsets.sort()
new_offsets = []
pre_end_idx = start_idx
pre_tpls = []
for end_idx, pat_tpl in offsets:
if end_idx == pre_end_idx:
pre_tpls.append(pat_tpl)
else:
if pre_tpls:
new_offsets.append(
(pre_end_idx, max(pre_tpls, key=lambda it: it[2]))
)
pre_end_idx = end_idx
pre_tpls = [pat_tpl]
if pre_tpls:
new_offsets.append((pre_end_idx, max(pre_tpls, key=lambda it: it[2])))
pat_dag[start_idx] = new_offsets
return pat_dag
参考文献
[1] array.array
[2] marisa_trie
[3] python高性能编程
[3] numpy