Python的内存优化问题

463 阅读8分钟
原文链接: zhuanlan.zhihu.com

文章介绍了Python中如何获得一个对象的内存占用大小,以及如何将内存占用压缩一个量级。


问题来源:如果要写一个股票行情获取模块,请问返回的股票行情数据应该使用什么样的数据格式?

pytdx应该是最流行的股票和期货行情模块了,它使用的数据结构是List[OrderedDict]。这种数据结构的优点是简单易懂,数据自解释,但缺点是对内存占用较大。

假设我们以并发方式获取全部股票及指数一天的分钟线数据(即240个周期),那么以这种方式临时存储(即在内存中暂存,直到写入数据库后被gc掉),将会占用多少内存?

当前A股股票及指数加起来是约5000个品种,这样我们最坏情况下,内存中会存在5000个List,每个List中又各有240个OrderedDict,下面我们来计算这将会占用多少内存。

Python对象的内存

根据Python 3的文档,各内置类型的内存大小如下:

Bytes  type        scaling notes
28     int         +4 bytes about every 30 powers of 2
37     bytes       +1 byte per additional byte
49     str         +1-4 per additional character (depending on max width)
48     tuple       +8 per additional item
64     list        +8 for each additional
224    set         5th increases to 736; 21nd, 2272; 85th, 8416; 341, 32992
240    dict        6th increases to 368; 22nd, 1184; 43rd, 2280; 86th, 4704; 171st, 9320
136    func def    does not include default args and other attrs
1056   class def   no slots
56     class inst  has a __dict__ attr, same scaling as dict above
888    class def   with slots
16     __slots__   seems to store in mutable tuple-like structure
                   first slot grows to 48, and so on.

文档中的__slots__是定义简单数据类时可以运用的一种内存优化方式,它比普通类占用的内存更小,运行速度也更快。关于这一点,可以阅读这篇文章。本文稍后也会再次提到这种方法。

上述文档中没有提到对象引用。对象引用占用的内存大小取决于操作系统,在64位系统中为8字节,我们在下面将看到这一点。

Python在sys模块里提供了getsizeof函数,以获取对象的浅层拷贝的内存占用大小。即当你通过这个函数来获取一个数组的内存大小时,它只会返回该数组本身及元素引用(指针)的大小,而不会将元素本身的真实内存大小占用计算进来。

请看下面的例子:

from sys import getsizeof

a = 10000
b = []
c = [10000, 100001, 100002]
print(getsizeof(a), getsizeof(b), getsizeof(c))
# -- output --
28 64 88

这里没有拿-5~257之间的小整数来举例,因为Python认为这些数字是常用数字,因此做了特别优化。尽管如此,单个0~255的小整数仍然要占24字节。

从上述例子可以看出,整数a占用了28字节,空的数组占用了64字节,而一个有三个整数的数组,则占用了88字节,即每个整数的引用占了8字节(64位系统)。

如何准确获知Python对象的内存占用?

这个问题其实比想象的复杂,因为getsizeof并不能直接给出对象的真实内存占用。更糟糕的是,它还对第三方库中创建的对象的内存占用无能为力。

首先,我们来看如果获取Python对象所占内存的完整大小(即包含了引用字节):

# https://stackoverflow.com/a/30316760
 import sys
from types import ModuleType, FunctionType
from gc import get_referents

# Custom objects know their class.
# Function objects seem to know way too much, including modules.
# Exclude modules as well.
BLACKLIST = type, ModuleType, FunctionType


def getsize(obj):
    """sum size of object & members."""
    if isinstance(obj, BLACKLIST):
        raise TypeError('getsize() does not take argument of type: '+ str(type(obj)))
    seen_ids = set()
    size = 0
    objects = [obj]
    while objects:
        need_referents = []
        for obj in objects:
            if not isinstance(obj, BLACKLIST) and id(obj) not in seen_ids:
                seen_ids.add(id(obj))
                size += sys.getsizeof(obj)
                need_referents.append(obj)
        objects = get_referents(*need_referents)
    return size

通过这一版的getsize函数,来看看前例中的a,b,c各应占多少个bytes:python c = [20000, 20001, 20002] d = [20000, 20001, 20002, 20000] print(getsize(c), getsize(d)) #--output 172 180 ``` 这里数组c的内存分别是 64(空数组结构) + 8 * 3(三个整数的引用) + 28 * 3(三个整数对象所占的空间),总共172字节。 数组D有四个元素,但只占180字节。原因是它有两个相同的元素,Python这里进行了优化,两个元素都引用到同一个整数,所以最后一个2000并不额外占用28字节。

这里引用上述getsize函数,只是为了演示如何遍历Python对象引用并获取它们的内存大小。在实际应用中,我们可以使用pympler库:

from pympler import asizeof
a = 10000
 b = []
 c = [10000, 10001, 10002]
 print(asizeof.asizeof(a), asizeof.asizeof(b), asizeof.asizeof(c))
--output
32 64 184


看起来asizeof把一个整数(10000)算成了32字节,而不是sys.getsizeof认为的28字节。不过对一般的内存估计影响不大。
上面三个对象总共占内存280字节。

回到开头的问题,如果内存中同时持有5000支股票各250天的日线数据,使用List[Dict]时,应该占用多少内存呢?

首先,我们将日线数据定义为:

{
    "code": str(600000),
    "open": random.random() * 100,
    "close": random.random() * 100,
    "high": random.random() * 100,
    "low": random.random() * 100,
    "vol": random.randint(100, 100000),
    "amount": random.random() * 100000000,
    "frame": datetime.datetime.now()
}

上述数据结构将占用619字节。粗略算下来,5000支股票各一天的分钟数据将占用0.72G的内存空间。这个数字看上去并不算太大,但如果考虑要取多日的分钟线数据和日线数据,以及不同的库之间消费这些数据时可能需要数据转换(从而带来数据副本),这样内存占用就比较大了。在我的一个测试过程中,内存占用最高接近0.2T(通过操作系统的top命令观察)。在这种情况下,我们必须对内存进行优化。选择更优的内存结构

使用slots

如果你操作的数据对象不是python内置对象,而是自定义的类,那么可以使用slots来优化内存。

class A:
    def __init__(self):
        self.a = 10000
        self.b = []
        self.c = [10000, 10001, 10002]

asizeof.asizeof(A())
# --output
 584

class B:
    __slots__ = "a", "b", "c"
    def __init__(self):
        self.a = 10000
        self.b = []
        self.c = [10000, 10001, 10002]

asizeof.asizeof(B())

# --output
312

可以看出,使用了slots之后,内存占用大为下降,但类封装带来了额外的32字节(对比之前的280字节。根据Python 3文档,类声明占1056字节,类实例占56字节。这里pympler的实现可能有问题)。

使用numpy或者其它第三方库

Python是动态类型语言,Python在存储一个对象时,不仅要存储它的值,还要存储其引用计数和数据类型(引用)等信息,因此,Python对象的内存占用就比C/C++语言多得多。

在一个处理大量数据的Python应用中,当内存成为瓶颈时,我们可以使用象numpy这样的c扩展库来存储这些数据(用numpy来存储数据的另一个好处是,numpy还有强大的运算能力,速度也要快很多)。

我们通过一个例子来展示如何在Python中,使用numpy数组来暂存数据,以及numpy数组与Python数组相比,内存使用上可以压缩多少。

def test_memory_list(self):
    import random
    import datetime
    import numpy as np
    from pympler import asizeof


    data = []
    for i in range(10000):
        data.append({
            "code": str(600000 + i),
            "open": random.random() * 100,
            "close": random.random() * 100,
            "high": random.random() * 100,
            "low": random.random() * 100,
            "vol": random.randint(100, 100000),
            "amount": random.random() * 100000000,
            "frame": datetime.datetime.now()
        })

    print(asizeof.asizeof(data[0]), asizeof.asizeof(data) / 1024)

    dtype = [('code', 'S6'), ('open', 'f4'), ('close', 'f4'), ('high', 'f4'), ('low', 'f4'), ('vol', 'f8'),
     ('amount', 'f8'), ('frame', 'O')]
    narray = np.array([(d["code"],
                        d["open"],
                        d["close"],
                        d["high"],
                        d["low"],
                        d["vol"],
                        d["amount"],
                        d["frame"]) for d in data], dtype=dtype)
    print(narray[0].nbytes, narray.nbytes / 1024)
# --output
619 6130.4k
46 449.2k

上面的代码中定义的narray是numpy的一种structured array,它在操作上相当于List[Dict],但具有比List[Dict]多得多的运算功能(如聚合函数)及更强大的运算性能。

对于定义在numpy地址空间中的变量,我们不能用sys.getsizeof来获取它的内存占用情况,只能改用numpy.array自带的nbytes属性来获取内存占用大小。

从测试结果可以看出,同样的对象(10000个K线数据),如果使用List[Dict],将占用6.1M空间,要比使用numpy的structured array(占用4.5k空间)多用10倍以上的内存空间。

除此之外,当然也可以使用pandas的dataframe,不过它的底层数据结构依然是numpy array。

结论

本文探讨了Python中对象占用内存大小的获取方法,介绍了pympler库。即使无须使用到numpy强大的计算功能,在应用程序中需要暂存大量List[Dict]类型数组时,也可以使用numpy的structured array,这样可能将内存使用量压缩一个量级。