Python内存管理

编写(有些)大规模Python程序的主要挑战之一是将内存使用率降至最低。但是,如果你不在乎,在Python中管理内存很容易 。Python透明地分配内存,使用引用计数系统管理对象,并在对象的引用计数降为零时释放内存。在理论上,这非常好。在实践中,你需要知道一些关于Python内存管理的事情,以获得一个内存高效的运行程序。你应该知道的事情之一,或者至少得到一个好的感觉,是基本的Python对象的大小。另一件事是Python如何在内部管理它的内存。

所以让我们从基本对象的大小开始。在Python中,原始数据类型没有很多:有int、long(int的无限精度版本)、float(双精度)、元组、字符串、列表、字典和类。

基本对象

int的大小是多少?具有C或C背景的程序员可能猜测机器特定int的大小类似于32位,也许是64位;因此它最多占用8个字节。但是在Python中是这样吗?

让我们先写一个显示对象大小的函数(如果需要将用递归):

import sys

def show_sizeof(x, level=0):

    print "\t" * level, x.__class__, sys.getsizeof(x), x

    if hasattr(x, '__iter__'):
        if hasattr(x, 'items'):
            for xx in x.items():
                show_sizeof(xx, level + 1)
        else:
            for xx in x:
                show_sizeof(xx, level + 1)

我们现在可以使用该函数来检查不同基本数据类型的大小:

show_sizeof(None)
show_sizeof(3)
show_sizeof(2**63)
show_sizeof(102947298469128649161972364837164)
show_sizeof(918659326943756134897561304875610348756384756193485761304875613948576297485698417)

如果你有一个32位2.7x的Python,你会看到:

8 None
12 3
22 9223372036854775808
28 102947298469128649161972364837164
48 918659326943756134897561304875610348756384756193485761304875613948576297485698417

如果你有一个64位的2.7x Python,你会看到:

16 None
24 3
36 9223372036854775808
40 102947298469128649161972364837164
60 918659326943756134897561304875610348756384756193485761304875613948576297485698417

让我们专注于64位版本(主要是因为在我们的例子这是我们最经常需要的)。None需要16个字节。int占用24字节,三倍于C int64_t的内存,尽管是某种“机器友好”的整数。用于表示大于263-1的整数的长整数(精度无限制)的最小大小为36字节。然后,它以所表示的整数的对数线性增长。

Python的浮点数于具体实现有关,但似乎是C的双精度浮点数。然而,它们不只吃掉8个字节:

show_sizeof(3.14159265358979323846264338327950288)

输出

16 3.14159265359

在32位平台上,以及

24 3.14159265359

在64位平台上。同样是一个C程序员所期望的大小的三倍。现在,字符串呢?

show_sizeof("")
show_sizeof("My hovercraft is full of eels")

在32位平台上输出:

21
50 My hovercraft is full of eels

以及

37
66 My hovercraft is full of eels

在64位环境中,字符串的占用37个字节!内存使用的字符串然后随着(有用的)字符串的长度线性增长。

* * *

其他常用的结构,元组、列表和字典值得研究。列表(其实现为数组列表而不是链接列表来包含的所有内容)是对Python对象的引用的数组,允许它们是异质的。让我们看看大小:

show_sizeof([])
show_sizeof([4, "toaster", 230.1])

outputs

32 []
44 [4, 'toaster', 230.1]

在32位平台上,以及

72 []
96 [4, 'toaster', 230.1]

on a 64-bit platform. An empty list eats up 72 bytes. The size of an empty, 64-bit C++ std::list() is only 16 bytes, 4-5 times less. What about tuples? (以及字典?):

show_sizeof({})
show_sizeof({'a':213, 'b':2131})

在32位上输出,

136 {}
 136 {'a': 213, 'b': 2131}
        32 ('a', 213)
                22 a
                12 213
        32 ('b', 2131)
                22 b
                12 2131

以及

280 {}
 280 {'a': 213, 'b': 2131}
        72 ('a', 213)
                38 a
                24 213
        72 ('b', 2131)
                38 b
                24 2131

对于64位。

这最后一个例子特别有趣,因为它“不累加”。如果我们查看单个键/值对,它们需要72个字节(而其组件需要38+24=62字节,为该键/值对本身留下10个字节) ,但字典需要280个字节(而不是严格的最小值144=72×2字节)。The dictionary is supposed to be an efficient data structure for search and the two likely implementations will use more space that strictly necessary. If it’s some kind of tree, then we should pay the cost of internal nodes that contain a key and two pointers to children nodes; if it’s a hash table, then we must have some room with free entries to ensure good performance.

(有点)等效的std::map C++结构在创建时(即为空)需要48个字节。空的C ++字符串需要8个字节(然后分配的大小随着字符串的大小线性增长)。An integer takes 4 bytes (32 bits).

* * *

Why does all this matter? 似乎一个空字符串需要8个字节还是37个字节不会改变任何东西。That’s true. 直到你需要扩展,确实如此。Then, you need to be really careful about how many objects you create to limit the quantity of memory your program uses. It is a problem in real-life applications. 然而,为了设计一个关于内存管理的真正好的策略,我们不仅要考虑对象的大小,还要考虑它们的创建数目和顺序。原来,它对Python程序是如此重要。One key element to understand is how Python allocates its memory internally, which we will discuss next.

内部存储器管理

To speed-up memory allocation (and reuse) Python uses a number of lists for small objects. 每个列表包含大小相似的对象:一个大小为1到8个字节的对象的列表,一个用于9到16个字节的对象的列表等。当一个小对象需要被创建时,我们重用列表中的一个空闲块,或者分配一个新的。

关于Python如何管理这些列表为块、池和“竞技场”,有一些内部细节,:一些块形成一个池,池被收集到竞技场等,但它们与我们想要的点不是非常相关(如果你真的想知道,阅读Evan Jones的关于如何改进Python的内存分配的想法)。The important point is that those lists never shrink.

事实上:如果一个项目(大小为x)被释放(由于缺少引用被释放),它的位置不会返回到Python的全局内存池(同样不会返回给系统),而只是标记为free并添加到大小为x的项目的空闲列表中。如果需要另一个大小与之兼容的对象,则这个死掉的对象的位置将被重用。If there are no dead objects available, new ones are created.

如果小对象内存从不被释放,那么不可避免的结论是,像金鱼一样,这些小对象列表只保持增长,从不缩小,并且你的应用程序的内存占用由分配的小对象的最大数目给定。

* * *

因此,一个任务应该努力只分配的小对象的必要的数量,有利于(unpythonèsque)循环,其仅创建/处理少量元素,而不是(更pythonèsque)模式,其使用列表生成语法创建列表,然后处理。

虽然第二个模式更à la Python,但它是最糟的情况:你最终创建了很多小对象将填充小对象列表,即使一旦列表死了,死对象(现在都在自由列表中)仍然会占用大量的内存。

* * *

事实上,自由列表增长似乎不是一个问题,因为它包含的内存对于Python程序仍然可以访问。But from the OS’s perspective, your program’s size is the total (maximum) memory allocated to Python. 由于Python只在Windows上将内存返回给操作系统的堆上(分配除小对象之外的其他对象),如果你在Linux上运行,你只能看到你的程序使用的总内存增加。

* * *

让我们用memory_profiler来证明我的观点,它是Fabian Pedregosa编写的一个Python插件模块(模块的github网页)(依赖于python-psutil包)。This add-on provides the decorator @profile that allows one to monitor one specific function memory usage. It is extremely simple to use. Let us consider the following program:

import copy
import memory_profiler

@profile
def function():
    x = list(range(1000000))  # allocate a big list
    y = copy.deepcopy(x)
    del x
    return y

if __name__ == "__main__":
    function()

invoking

python -m memory_profiler memory-profile-me.py

在64位计算机上打印,

Filename: memory-profile-me.py

Line #    Mem usage    Increment   Line Contents
================================================
     4                             @profile
     5      9.11 MB      0.00 MB   def function():
     6     40.05 MB     30.94 MB       x = list(range(1000000)) # allocate a big list
     7     89.73 MB     49.68 MB       y = copy.deepcopy(x)
     8     82.10 MB     -7.63 MB       del x
     9     82.10 MB      0.00 MB       return y

这个程序创建一个n=1,000,000的int(n x 24字节 = 〜23 MB)列表和一个额外的引用列表(n x 8字节 = 〜7.6 MB)的列表,这相当于总内存使用量约31 MB。copy.deepcopy copies both lists, which allocates again ~50 MB (I am not sure where the additional overhead of 50 MB - 31 MB = 19 MB comes from). The interesting part is del x: it deletes x, but the memory usage only decreases by 7.63 MB! This is because del only deletes the reference list, not the actual integer values, which remain on the heap and cause a memory overhead of ~23 MB.

This example allocates in total ~73 MB, which is more than twice the amount of memory needed to store a single list of ~31 MB. 你可以看到,如果你不小心,内存可以惊人地增加!

Note that you might get different results on a different platform or with a different python version.

Pickle

相关的一个说法是:pickle浪费吗?

Pickle是将Python对象(反)序列化为文件的标准方式。What is its memory footprint? 它会创建额外的数据副本还是会更精巧?Consider this short example:

import memory_profiler
import pickle
import random

def random_string():
    return "".join([chr(64 + random.randint(0, 25)) for _ in xrange(20)])

@profile
def create_file():
    x = [(random.random(),
          random_string(),
          random.randint(0, 2 ** 64))
         for _ in xrange(1000000)]

    pickle.dump(x, open('machin.pkl', 'w'))

@profile
def load_file():
    y = pickle.load(open('machin.pkl', 'r'))
    return y

if __name__=="__main__":
    create_file()
    #load_file()

调用一次来创建pickled数据,并再调用一次重新读取它(注释掉函数让它不被调用)。Using memory_profiler, the creation uses a lot of memory:

Filename: test-pickle.py

Line #    Mem usage    Increment   Line Contents
================================================
     8                             @profile
     9      9.18 MB      0.00 MB   def create_file():
    10      9.33 MB      0.15 MB       x=[ (random.random(),
    11                                      random_string(),
    12                                      random.randint(0,2**64))
    13    246.11 MB    236.77 MB           for _ in xrange(1000000) ]
    14
    15    481.64 MB    235.54 MB       pickle.dump(x,open('machin.pkl','w'))

然后重读一次:

Filename: test-pickle.py

Line #    Mem usage    Increment   Line Contents
================================================
    18                             @profile
    19      9.18 MB      0.00 MB   def load_file():
    20    311.02 MB    301.83 MB       y=pickle.load(open('machin.pkl','r'))
    21    311.02 MB      0.00 MB       return y

所以不知何故,pickling非常不利于内存消耗。初始列表占用大约230MB,但pickling它创建一个额外的230MB左右的内存分配。

另一方面,unpickling似乎相当有效。它创建比原始列表(300MB而不是230左右)更多的内存,但它分配的内存量不会翻倍。

总的来说,应该避免对内存敏感的应用程序进行(un)pickling。有其它方法吗?Pickling preserves all the structure of a data structure, so you can recover it exactly from the pickled file at a later time. However, that might not always be needed. 如果文件是要包含如上例所示的列表,那么也许一个简单的、基于文本的文件格式就可以。Let us see what it gives.

简单的实现如下:

import memory_profiler
import random
import pickle

def random_string():
    return "".join([chr(64 + random.randint(0, 25)) for _ in xrange(20)])

@profile
def create_file():
    x = [(random.random(),
          random_string(),
          random.randint(0, 2 ** 64))
         for _ in xrange(1000000) ]

    f = open('machin.flat', 'w')
    for xx in x:
        print >>f, xx
    f.close()

@profile
def load_file():
    y = []
    f = open('machin.flat', 'r')
    for line in f:
        y.append(eval(line))
    f.close()
    return y

if __name__== "__main__":
    create_file()
    #load_file()

Creating the file:

Filename: test-flat.py

Line #    Mem usage    Increment   Line Contents
================================================
     8                             @profile
     9      9.19 MB      0.00 MB   def create_file():
    10      9.34 MB      0.15 MB       x=[ (random.random(),
    11                                      random_string(),
    12                                      random.randint(0, 2**64))
    13    246.09 MB    236.75 MB           for _ in xrange(1000000) ]
    14
    15    246.09 MB      0.00 MB       f=open('machin.flat', 'w')
    16    308.27 MB     62.18 MB       for xx in x:
    17                                     print >>f, xx

and reading the file back:

Filename: test-flat.py

Line #    Mem usage    Increment   Line Contents
================================================
    20                             @profile
    21      9.19 MB      0.00 MB   def load_file():
    22      9.34 MB      0.15 MB       y=[]
    23      9.34 MB      0.00 MB       f=open('machin.flat', 'r')
    24    300.99 MB    291.66 MB       for line in f:
    25    300.99 MB      0.00 MB           y.append(eval(line))
    26    301.00 MB      0.00 MB       return y

Memory consumption on writing is now much better. 它仍然创建了很多临时的小对象(60MB),但内存使用没有翻倍。读取消耗的内存和之前差不多(只使用稍微少一点的内存)。

这个特定的例子很普通,但它可以泛化到这个策略:不要加载所有的东西然后处理它,而是读取几个项目处理它们并重用分配的内存。Loading data to a Numpy array, for example, one could first create the Numpy array, then read the file line by line to fill the array: this allocates one copy of the whole data. Using pickle, you would allocate the whole data (at least) twice: once by pickle, and once through Numpy.

Or even better yet: use Numpy (or PyTables) arrays. But that’s a different topic. 同时,你可以在Theano/doc/tutorial目录中查看加载并保存的另一个教程。

* * *

Python design goals are radically different than, say, C design goals. 后者被设计为给你所做的内容更好的控制,但是需要更复杂和明确的编程,前者被设计为让你的代码快速,而隐藏大多数(如果不是所有)底层实现细节。虽然这听起来不错,在生产环境中,忽略一个语言的实现低效率可能会让产生严重影响,有时候为时已晚。我认为需要很好地感受Python在内存管理方面是如何低效(设计如此!)将对你的代码是否满足生产要求、易扩展好发挥重要作用,反之内存将是一个燃烧的地狱。