Theano教程:Python的内存管理
在写大型程序时候的一大挑战是如何保证最少的内存使用率。但是在Python中的内存管理是比较简单的。Python显示分配内存,使用引用计数系统管理对象,当指向某一个对象的引用数变为 0 的时候,该对象所占的内存就会被释放。理论上听起来很不错,也很简单,但是在实践中,我们需要知道一些Python内存管理的知识从而让程序在运行过程中能够更加高效地使用内存。其中一个方面我们需要知道的是基本的Python对象所占空间的大小,另一方面我们需要知道的是Python在内部到底是如何管理内存的。
基本对象
一个 int 对象占多大空间呢? C/C++程序员会说它是由具体的机器决定的,可能是32为或者64位,因此它最多占8个字节(一个字节8位)。那么在Python中也是如此吗?
下面写一个函数来揭示出对象占多大的空间(某些情况下需要递归,比如某一个对象类型不是基本的数据类型):
1 import sys
2
3 def show_sizeof(x, level=0):
4
5 print "\t" * level, x.__class__, sys.getsizeof(x), x
6
7 if hasattr(x, '__iter__'):
8 if hasattr(x, 'items'):
9 for xx in x.items():
10 show_sizeof(xx, level + 1)
11 else:
12 for xx in x:
13 show_sizeof(xx, level + 1)
我们可以用下面的函数调用来观察不同的基本数据类型所占空间大小:
show_sizeof(None)
show_sizeof(3)
show_sizeof(2**63)
show_sizeof(102947298469128649161972364837164)
show_sizeof(918659326943756134897561304875610348756384756193485761304875613948576297485698417)
在64-bit系统和2.7.8 Python上运行的结果:
<type 'NoneType'> 16 None
<type 'int'> 24 3
<type 'long'> 36 9223372036854775808
<type 'long'> 40 102947298469128649161972364837164
<type 'long'> 60 918659326943756134897561304875610348756384756193485761304875613948576297485698417
可以看到None占了16个字节,int 占了24个字节,是64为系统中C的int64_t 的 3 倍,而且是能够被机器识别的整型。长整型(无限制的精确度)用来表示出了大于263 - 1的整数,所占空间最小为36个字节。而且这个所占空间大小会随着算法中整数的大小线性增长。
Python的float是特定实现的,看上去类似于C中double,但是Python中的 float 不会在数据超过8个字节时终止表示:
show_sizeof(3.14159265358979323846264338327950288)
在64为系统输出:
<type 'float'> 24 3.14159265359
可以看到又是C中double类型所占空间(8字节)的3倍.
那么对于字符串呢?
show_sizeof("")
show_sizeof("My hovercraft is full of eels")
在64位系统输出:
<type 'str'> 33
<type 'str'> 62 My hovercraft is full of eels
空字符串占33字节,随着字符串内容增加,所占空间线性增长。
下面测试常用的tuple,list 和 dictionary所占空间大小(均为在64为系统下的输入结果):
show_sizeof([])
show_sizeof([4, "toaster", 230.1])
输出:
<type 'list'> 64 []
<type 'list'> 88 [4, 'toaster', 230.1]
空list占64个字节,而64位系统中的C++ std::list() 只占16个字节,达到了4倍。
对于tuple呢?dictionary?:
show_sizeof({})
show_sizeof({'a':213, 'b':2131})
输出:
<type 'dict'> 272 {}
<type 'dict'> 272 {'a': 213, 'b': 2131}
<type 'tuple'> 64 ('a', 213)
<type 'str'> 34 a
<type 'int'> 24 213
<type 'tuple'> 64 ('b', 2131)
<type 'str'> 34 b
<type 'int'> 24 2131
可以看出,对于字典中的每一个 key/value 对,占64字节,但是注意('a', 213)所占空间是64字节,而 'a' 所占空间是34字节,213 占空间是24字节,所以留出64 -(34+24) = 6字节给key/value本身;另外,我们看到整个字典占272字节,而不是64+64 = 128字节。字典本身是被设计成一个搜索效率高的数据结构,所以会用到必要的额外的空间。如果字典内部采用的是某种树结构,必须考虑到包含每一个值的节点和指向孩子节点的两个指针的空间消耗;如果字典内部采用哈希表实现,我们必须保证有足够的空闲空间从而保证性能。
字典与C++std::map结构对等,而C++的map在创建(空map)时占48个字节, C++空字符串占 8 个字节,整数占4个字节。
观察到了这么多现象,到底是怎么回事?看上去一个空字符串占8个字节还是占37个字节似乎改变不了什么。如果不扩展数据大小,确实如此。我们必须关心的是我们创建多少个对象会到达程序所使用的内存的限制。在实践应用中,这个问题很棘手。要想设计出一个管理内存的好策略,不但需要关心对象所占内存的大小,还需要所创建对象的数量以及这些对象的创建顺序,事实证明这对于Python很重要。一个关键元素就是理解Python是如何在内部分配内存的,也正是下面即将讨论的.
内部内存管理
为了加速内存分配(和重复使用),Python对小型对象使用列表来管理。每个列表包含的对象所占空间大小都很相近:如一个列表包含的对象均占1到8个字节,另一个列表包含的对象均占9到16个字节等。当需要创建一个小型对象时,要么重复使用列表中空闲块,要么分配一块新空间。
事实上,即使一个对象的空间被free了,它做占据的内存空间也不会被返回给Python的全局内存池,而是仅仅被标记为free然后加入到空闲列表。过期的(被消亡)对象的位置空间会在一个新的差不多大小的对象被创建时,进行重复使用,如果没有过期的对象释放的空间存在,那么就直接新分配空间。
如果小型对象的所占内存从未被释放,那么列表所占内存空间就会一直增大,那么内存慢慢就会被这些大量的小型对象占据。
因此,我们应该努力只分配空间给那些有必要的对象,在循环中只创建少量的对象,尽量使用生成器语法。
事实上,列表占据空间的自由增长似乎并不算是一个问题,因为列表所包含的空间仍然允许Python程序进入和使用。但是从操作系统的视角来看,程序所占内存的大小会超过系统分配给Python的总内存的大小。
为了证明上面所述,使用memory_profiler(依赖于 python-psutil包)来证明:
1 import copy
2 import memory_profiler
3
4 #这里加上@profile是来监视具体函数function的内存使用情况
5 @profile
6 def function():
7 x = list(range(1000000)) # allocate a big list
8 y = copy.deepcopy(x)
9 del x
10 return y
11
12 if __name__ == "__main__":
13 function()
在Ubuntu上运行:
程序创建了包含1,000,000个int值(1,000,000*12 bytes = ~11.4MB),建立一个对list的引用变量x(1,000,000 * 8 bytes =~ 3.8MB), 总内存使用量大约为15.2MB.然后copy.deepcopy 进行深度拷贝操作和建立新的引用变量y,同样需要占用内存大约15.2MB,所以第8行的内存使用量增加了15.367MB. 注意第 9 行,del x, 内存使用量仅仅减少了3.824MB,这表明del操作只是释放了指向 list 引用变量的内存空间,而不是list中的整数所占内存空间,这些整数值保留在堆中,导致内存占用多了将近11.4MB.
在这个例子中分配了总共大约15.309 + 15.367 - 3.82 = ~26.8MB, 而我们存储一个list只需要大约11.4MB的内存,超出了1倍多! 所以,在编程中的也许我们不注意的地方,就会导致内存占用增长很快!
pickle
pickle是一种标准的把Python对象序列化到文件和以及从文件解序列化出来的方式。它的内存足迹(memory footprint)是什么? 它创建了额外的数据副本还是用一种更加聪明的方式?考虑:
1 import memory_profiler
2 import pickle
3 import random
4
5 def random_string():
6 return "".join([chr(64 + random.randint(0, 25)) for _ in xrange(20)])
7
8 @profile
9 def create_file():
10 x = [(random.random(),
11 random_string(),
12 random.randint(0, 2 ** 64))
13 for _ in xrange(1000000)]
14
15 pickle.dump(x, open('machin.pkl', 'w'))
16
17 @profile
18 def load_file():
19 y = pickle.load(open('machin.pkl', 'r'))
20 return y
21
22 if __name__=="__main__":
23 create_file()
24 #load_file()
这个程序用来生成一些pickle 数据和读取pickle 数据(pickle数据的读取在这里注释了,首先没用让读取函数运行),使用memory_profiler,生成pickle数据过程中使用了大量内存:
再看看pickle数据的读取(把上面程序中第23行注释掉,把24行的注释去掉):
所以,pickle是非常消耗内存的做法,从上面的图看出,在数据的创建时,大约使用127MB内存,而一个pickle.dump操作就要额外使用差不多与数据相当的内存空间(117MB).
在unpickle操作中(即反序列化操作,从pkl中读取数据),看上去效率还好点,虽然确实占用了比原始数据(127MB)大的内存空间(188MB),但是还没到达有超1倍的程度。
总之,涉及pickle的操作应该在对内存容量要求较高的程序中尽量避免。那么,有没有可以替代的选择呢?我们知道pickle保存了数据结构的结构,即将数据原封不动保存起来(不仅仅保存数据,还要保存数据的结构信息),所以我们才能在需要的时候,将数据从pickle文件中恢复出来。但是,并不是所有时候都需要这样用pickle保存,就像上面例子中的list,完全可以用一个基于文本的文件格式按顺序保存里面的元素,没必要用pickle来保存:
1 import memory_profiler
2 import random
3 import pickle
4
5 def random_string():
6 return "".join([chr(64 + random.randint(0, 25)) for _ in xrange(20)])
7
8 @profile
9 def create_file():
10 x = [(random.random(),
11 random_string(),
12 random.randint(0, 2 ** 64))
13 for _ in xrange(1000000) ]
14 # 这里使用文本来保存数据而不是pickle
15 f = open('machin.flat', 'w')
16 for xx in x:
17 print >>f, xx
18 f.close()
19
20 @profile
21 def load_file():
22 y = []
23 f = open('machin.flat', 'r')
24 for line in f:
25 y.append(eval(line))
26 f.close()
27 return y
28
29 if __name__== "__main__":
30 create_file()
31 #load_file()
建立文件时,内存足迹:
与上面pickle保存数据对比,可以发现,通过文本保存文件值占用几乎可以忽略的内存。
下面再来看看数据的读取时,内存足迹变化(将30行的代码注释,将31行的注释符去掉):
原始数据127MB,读取时占用内存139MB,和原始数据很接近,多出来的约10MB内存空间是分配给循环中产生的临时变量。
这个例子可以启示我们在处理数据的时候不要首先全部读取数据,然后再处理数据,而是每次读取几项,处理完这几项,释放这几项的空间,然后再读取几项处理,以此类推,这样,之前分配过的内存空间就可以重复使用。比如读取数据到一个Numpy的array中,我们可以先创建一个空array,然后逐行读取数据,逐行填入array,这样大约只需要和数据大小差不多的内存空间。如果使用pickle, 至少要分配2倍于数据大小的内存空间:一次是pickle在load时分配占用,一次是创建存储数据的array.
总结
Python 设计的目标根本上就不同于 C 语言设计的目标。后者是以更加复杂和显示的编程为代价让程序员能够更好地控制程序要做的事,而前者设计的目的是让代码更加迅速并且尽量隐藏细节。尽管听起来不错,但是在生产环境中,忽略执行效率会栽大跟头,所以在Python代码设计过程中,知道哪些代码执行的效率很低,从而尽量避免这种低效率编写对于生产环境来说很重要!