编译自https://habr.com/en/post/458518/
当一个程序需要处理成千上万的object时,为object选择合适的数据结构减少内存的占用量就成了一个很重要的问题。 毕竟一台服务器的内存终究还是有限的。本文就是要简述在不同的数据结构下,一个单独的object的占用多大的空间,从而得出减少程序内存占用量的方案。
原文作者为了简化分析,选择实现一个三维向量[x, y, z]作为例子。
字典是Python内置的数据结构,也是开发者最常用的数据数据结构。
>>> ob = {'x':1, 'y':2, 'z':3}
>>> x = ob['x']
>>> ob['y'] = y
使用sys.getsizeof(ob)
获得此时ob对应的内存占用量。
>>> print(sys.getsizeof(ob))
240
绘制一张简单的表格比较下拥有大量实例的字典的占用空间。
Number of instances | Size of objects |
---|---|
1 000 000 | 240 Mb |
10 000 000 | 2.40 Gb |
100 000 000 | 24 Gb |
class Point:
#
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
>>> ob = Point(1,2,3)
>>> x = ob.x
>>> ob.y = y
类实例的内存占用很有趣:
Field | Size (bytes) |
---|---|
PyGC_Head | 24 |
PyObject_HEAD | 16 |
__weakref__ | 8 |
__dict__ | 8 |
TOTAL: | 56 |
在这里__weakref__是与这个object有关的软引用的列表,__dict__是这个类实例的字典,包含了这个类实例的所有属性的值。
>>> print(sys.getsizeof(ob), sys.getsizeof(ob.__dict__))
56 112
它占用的空间比字典要少多了。
Number of instances | Size of objects |
---|---|
1 000 000 | 168 Mb |
10 000 000 | 1.68 Gb |
100 000 000 | 16.8 Gb |
因为__dict__的存在,导致类实例依旧使用了大量的空间。
在Python有个小技巧可以减少类实例存储属性的个数,那就是__slots__方法。
class Point:
__slots__ = 'x', 'y', 'z'
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
>>> ob = Point(1,2,3)
>>> print(sys.getsizeof(ob))
64
内存减少的就相当明显了。
Field | Size (bytes) |
---|---|
PyGC_Head | 24 |
PyObject_HEAD | 16 |
x | 8 |
y | 8 |
z | 8 |
TOTAL: | 64 |
因此在使用大量object时,内存占用量如下:
Number of instances | Size of objects |
---|---|
1 000 000 | 64 Mb |
10 000 000 | 640 Mb |
100 000 000 | 6.4 Gb |
这里的内存占用量减少主要是因为类实例内部存储的属性数量减少了。
>>> pprint(Point.__dict__)
mappingproxy(
....................................
'x': <member 'x' of 'Point' objects>,
'y': <member 'y' of 'Point' objects>,
'z': <member 'z' of 'Point' objects>})
这个颇类似于namedlist(https://pypi.org/project/namedlist/)。
>>> Point = namedlist('Point', ('x', 'y', 'z'))
对于不可变的数据结构,Python中可以使用Tuple表示。只不过这时就不能通过指代x/y/z的名称获得相应的数据了。
>>> ob = (1,2,3)
>>> x = ob[0]
>>> ob[1] = y # ERROR
其占用的内存相对于使用__slots__的类实例还是多了 8 byte。
>>> print(sys.getsizeof(ob))
72
因为在Python里的list、tuple等数组类型都会拥有ob_size这个属性存储数组长度
Field | Size (bytes) |
---|---|
PyGC_Head | 24 |
PyObject_HEAD | 16 |
ob_size | 8 |
[0] | 8 |
[1] | 8 |
[2] | 8 |
TOTAL: | 72 |
Namedtuple弥补了tuple没有名称属性的缺点,也就是通过x/y/z调用对应的值。namedtuple在collections包内。
>>> Point = namedtuple('Point', ('x', 'y', 'z'))
其实现类似于下面的类
class Point(tuple):
#
@property
def _get_x(self):
return self[0]
@property
def _get_y(self):
return self[1]
@property
def _get_y(self):
return self[2]
#
def __new__(cls, x, y, z):
return tuple.__new__(cls, (x, y, z))
因此它和tuple拥有一样的内存大小。
Number of instances | Size of objects |
---|---|
1 000 000 | 72 Mb |
10 000 000 | 720 Mb |
100 000 000 | 7.2 Gb |
Recordclass是可变的Namedtuple,具体可以参考(https://stackoverflow.com/questions/29290359/existence-of-mutable-named-tuple-in-python)和(https://pypi.org/project/recordclass/)。Recordclass所有的API都和Namedtuple一样,但是Recordclass支持赋值和修改tuple。
>>> Point = recordclass('Point', ('x', 'y', 'z'))
>>> ob = Point(1, 2, 3)
与tuple相比,Recordclass没有了PyGC_Head这个字段,也就是意味着Recordclass没有参与Python的GC机制。GC需要开发者自己处理。
Field | Size (bytes) |
---|---|
PyObject_HEAD | 16 |
ob_size | 8 |
x | 8 |
y | 8 |
z | 8 |
TOTAL: | 48 |
这带来的内存节省也是明显的。
Number of instances | Size of objects |
---|---|
1 000 000 | 48 Mb |
10 000 000 | 480 Mb |
100 000 000 | 4.8 Gb |
既然Recordclass是可变的Namedtuple,那么ob_size也无存在的必要。因此Recordclass基于__slot__机制,设计了dataclass方法。
>>> Point = make_dataclass('Point', ('x', 'y', 'z'))
或者通过继承recordclass.dataobject。
class Point(dataobject):
x:int
y:int
z:int
同样的内存占用也变得极少。
Field | Size (bytes) |
---|---|
PyObject_HEAD | 16 |
x | 8 |
y | 8 |
z | 8 |
TOTAL: | 40 |
>>> ob = Point(1,2,3)
>>> print(sys.getsizeof(ob))
40
没有了GC和ob_size,recordclass是通过直接访问内存地址获得x,y,z的值。
mappingproxy({'__new__': <staticmethod at 0x7f203c4e6be0>,
.......................................
'x': <recordclass.dataobject.dataslotgetset at 0x7f203c55c690>,
'y': <recordclass.dataobject.dataslotgetset at 0x7f203c55c670>,
'z': <recordclass.dataobject.dataslotgetset at 0x7f203c55c410>})
于是内存下降到一个很可观的程度了。
Number of instances | Size of objects |
---|---|
1 000 000 | 40 Mb |
10 000 000 | 400 Mb |
100 000 000 | 4.0 Gb |
上面的方法依然没有脱离与Python的标准实现CPython,那么使用Cython(https://cython.org)实现类似的类实例。
cdef class Python:
cdef public int x, y, z
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
因为是标准C的实现,那内存自然就少了。
>>> ob = Point(1,2,3)
>>> print(sys.getsizeof(ob))
32
其数据结构也发生改变了。
Field | Size (bytes) |
---|---|
PyObject_HEAD | 16 |
x | 4 |
y | 4 |
z | 4 |
пусто | 4 |
TOTAL: | 32 |
总的内存少了。
Number of instances | Size of objects |
---|---|
1 000 000 | 32 Mb |
10 000 000 | 320 Mb |
100 000 000 | 3.2 Gb |
当然这还不是最有效的,接下来看看Numpy。
不再使用Python灵活的动态特性,而是指定数据存储类型。
>>> Point = numpy.dtype(('x', numpy.int32), ('y', numpy.int32), ('z', numpy.int32)])
然后进行初始化。
>>> points = numpy.zeros(N, dtype=Point)
其内存减少到了最低
Number of instances | Size of objects |
---|---|
1 000 000 | 12 Mb |
10 000 000 | 120 Mb |
100 000 000 | 1.2 Gb |
但是一旦返回Python类型时,便要将int转换成Python Object,内存占用就变多了。
>>> sys.getsizeof(points[0])
68
Python有时候会被称为胶水语言,就是因为与C良好的交互特性。当开发者对性能、内存占用等等有严苛的需求时,就向原文作者做的测试,Python可以使用C扩展极大的避免了Python本身的缺点。