如何降低 Python 的内存消耗量?
👆 “Python猫” ,一个值得加星标的公众号
在执行程序时,如果内存中有大量活动的对象,就可能出现内存问题,尤其是在可用内存总量有限的情况下。在本文中,我们将讨论缩小对象的方法,大幅减少Python所需的内存。
出品 | CSDN(ID:CSDNnews)
以下为译文:
为了简便起见,我们以一个表示点的Python结构为例,它包括x、y、z坐标值,坐标值可以通过名称访问。
Dict
在小型程序中,特别是在脚本中,使用Python自带的dict来表示结构信息非常简单方便:
'x':1, 'y':2, 'z':3}
> x = ob['x']
> ob['y'] = y
> ob = {由于在Python 3.6中dict的实现采用了一组有序键,因此其结构更为紧凑,更深得人心。但是,让我们看看dict在内容中占用的空间大小:
240
> print(sys.getsizeof(ob))如上所示,dict占用了大量内存,尤其是如果突然虚需要创建大量实例时:
实例数 | 对象大小 |
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
类实例的结构很有趣:
字段 | 大小(比特) |
PyGC_Head | 24 |
PyObject_HEAD | 16 |
__weakref__ | 8 |
__dict__ | 8 |
合计: | 56 |
在上表中,__weakref__是该列表的引用,称之为到该对象的弱引用(weak reference);字段__dict__是该类的实例字典的引用,其中包含实例属性的值(注意在64-bit引用平台中占用8字节)。从Python3.3开始,所有类实例的字典的键都存储在共享空间中。这样就减少了内存中实例的大小:
>>> print(sys.getsizeof(ob), sys.getsizeof(ob.__dict__))
56 112
因此,大量类实例在内存中占用的空间少于常规字典(dict):
实例数 | 大小 |
1 000 000 | 168 Mb |
10 000 000 | 1.68 Gb |
100 000 000 | 16.8 Gb |
不难看出,由于实例的字典很大,所以实例依然占用了大量内存。
带有__slots__的类实例
为了大幅降低内存中类实例的大小,我们可以考虑干掉__dict__和__weakref__。为此,我们可以借助 __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
如此一来,内存中的对象就明显变小了:
字段 | 大小(比特) |
PyGC_Head | 24 |
PyObject_HEAD | 16 |
x | 8 |
y | 8 |
z | 8 |
总计: | 64 |
在类的定义中使用了__slots__以后,大量实例占据的内存就明显减少了:
实例数 | 大小 |
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>})
为了自动化使用__slots__创建类的过程,你可以使用库namedlist(https://pypi.org/project/namedlist)。namedlist.namedlist函数可以创建带有__slots__的类:
'Point', ('x', 'y', 'z'))
> Point = namedlist(还有一个包attrs(https://pypi.org/project/attrs),无论使用或不使用__slots__都可以利用这个包自动创建类。
元组
Python还有一个自带的元组(tuple)类型,代表不可修改的数据结构。元组是固定的结构或记录,但它不包含字段名称。你可以利用字段索引访问元组的字段。在创建元组实例时,元组的字段会一次性关联到值对象:
1,2,3)
> x = ob[0]
> ob[1] = y # ERROR
> ob = (元组实例非常紧凑:
72
> print(sys.getsizeof(ob))由于内存中的元组还包含字段数,因此需要占据内存的8个字节,多于带有__slots__的类:
字段 | 大小(字节) |
PyGC_Head | 24 |
PyObject_HEAD | 16 |
ob_size | 8 |
[0] | 8 |
[1] | 8 |
[2] | 8 |
总计: | 72 |
命名元组
由于元组的使用非常广泛,所以终有一天你需要通过名称访问元组。为了满足这种需求,你可以使用模块collections.namedtuple。
namedtuple函数可以自动生成这种类:
'Point', ('x', 'y', 'z'))
> Point = namedtuple(如上代码创建了元组的子类,其中还定义了通过名称访问字段的描述符。对于上述示例,访问方式如下:
class Point(tuple):
#
@property
def _get_x(self):
return self[0]
@property
def _get_y(self):
return self[1]
@property
def _get_z(self):
return self[2]
#
def __new__(cls, x, y, z):
return tuple.__new__(cls, (x, y, z))
这种类所有的实例所占用的内存与元组完全相同。但大量的实例占用的内存也会稍稍多一些:
实例数 | 大小 |
1 000 000 | 72 Mb |
10 000 000 | 720 Mb |
100 000 000 | 7.2 Gb |
记录类:不带循环GC的可变更命名元组
由于元组及其相应的命名元组类能够生成不可修改的对象,因此类似于ob.x的对象值不能再被赋予其他值,所以有时还需要可修改的命名元组。由于Python没有相当于元组且支持赋值的内置类型,因此人们想了许多办法。在这里我们讨论一下记录类(recordclass,https://pypi.org/project/recordclass),它在StackoverFlow上广受好评(https://stackoverflow.com/questions/29290359/existence-of-mutable-named-tuple-in)。
此外,它还可以将对象占用的内存量减少到与元组对象差不多的水平。
recordclass包引入了类型recordclass.mutabletuple,它几乎等价于元组,但它支持赋值。它会创建几乎与namedtuple完全一致的子类,但支持给属性赋新值(而不需要创建新的实例)。recordclass函数与namedtuple函数类似,可以自动创建这些类:
'Point', ('x', 'y', 'z'))
ob = Point(1, 2, 3)
Point = recordclass(类实例的结构也类似于tuple,但没有PyGC_Head:
字段 | 大小(字节) |
PyObject_HEAD | 16 |
ob_size | 8 |
x | 8 |
y | 8 |
z | 8 |
总计: | 48 |
在默认情况下,recordclass函数会创建一个类,该类不参与垃圾回收机制。一般来说,namedtuple和recordclass都可以生成表示记录或简单数据结构(即非递归结构)的类。在Python中正确使用这二者不会造成循环引用。因此,recordclass生成的类实例默认情况下不包含PyGC_Head片段(这个片段是支持循环垃圾回收机制的必需字段,或者更准确地说,在创建类的PyTypeObject结构中,flags字段默认情况下不会设置Py_TPFLAGS_HAVE_GC标志)。
大量实例占用的内存量要小于带有__slots__的类实例:
实例数 | 大小 |
1 000 000 | 48 Mb |
10 000 000 | 480 Mb |
100 000 000 | 4.8 Gb |
dataobject
recordclass库提出的另一个解决方案的基本想法为:内存结构采用与带__slots__的类实例同样的结构,但不参与循环垃圾回收机制。这种类可以通过recordclass.make_dataclass函数生成:
'Point', ('x', 'y', 'z'))
> Point = make_dataclass(这种方式创建的类默认会生成可修改的实例。
另一种方法是从recordclass.dataobject继承:
class Point(dataobject):
x:int
y:int
z:int
这种方法创建的类实例不会参与循环垃圾回收机制。内存中实例的结构与带有__slots__的类相同,但没有PyGC_Head:
字段 | 大小(字节) |
PyObject_HEAD | 16 |
ob_size | 8 |
x | 8 |
y | 8 |
z | 8 |
总计: | 48 |
1,2,3)
> print(sys.getsizeof(ob))
40
> ob = Point(如果想访问字段,则需要使用特殊的描述符来表示从对象开头算起的偏移量,其位置位于类字典内:
mappingproxy({'__new__': <staticmethod at 0x7f203c4e6be0>,
.......................................
'x': <recordclass.dataobject.dataslotgetset at 0x7f203c55c690>,
'y': <recordclass.dataobject.dataslotgetset at 0x7f203c55c670>,
'z': <recordclass.dataobject.dataslotgetset at 0x7f203c55c410>})
大量实例占用的内存量在CPython实现中是最小的:
实例数 | 大小 |
1 000 000 | 40 Mb |
10 000 000 | 400 Mb |
100 000 000 | 4.0 Gb |
Cython
还有一个基于Cython(https://cython.org/)的方案。该方案的优点是字段可以使用C语言的原子类型。访问字段的描述符可以通过纯Python创建。例如:
cdef class Python:
cdef public int x, y, z
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
本例中实例占用的内存更小:
1,2,3)
> print(sys.getsizeof(ob))
32
> ob = Point(内存结构如下:
字段 | 大小(字节) |
PyObject_HEAD | 16 |
x | 4 |
y | 4 |
z | 4 |
nycto | 4 |
总计: | 32 |
大量副本所占用的内存量也很小:
实例数 | 大小 |
1 000 000 | 32 Mb |
10 000 000 | 320 Mb |
100 000 000 | 3.2 Gb |
但是,需要记住在从Python代码访问时,每次访问都会引发int类型和Python对象之间的转换。
Numpy
使用拥有大量数据的多维数组或记录数组会占用大量内存。但是,为了有效地利用纯Python处理数据,你应该使用Numpy包提供的函数。
>>> Point = numpy.dtype(('x', numpy.int32), ('y', numpy.int32), ('z', numpy.int32)])
一个拥有N个元素、初始化成零的数组可以通过下面的函数创建:
points = numpy.zeros(N, dtype=Point)内存占用是最小的:
实例数 | 大小 |
1 000 000 | 12 Mb |
10 000 000 | 120 Mb |
100 000 000 | 1.2 Gb |
一般情况下,访问数组元素和行会引发Python对象与C语言int值之间的转换。如果从生成的数组中获取一行结果,其中包含一个元素,其内存就没那么紧凑了:
>>> sys.getsizeof(points[0])
68
因此,如上所述,在Pytho代码中需要使用numpy包提供的函数来处理数组。
总结
在本文中,我们通过一个简单明了的例子,求证了Python语言(CPython)社区的开发人员和用户可以真正减少对象占用的内存量。
原文:https://habr.com/en/post/458518
本文为 CSDN 翻译,【Python猫】授权转载。
文章分享完了,最后是随机荐书环节。我会根据文章内容,提出一个关键词(这篇是“numpy”)来搜索,随机选择一本技术书推荐给大家,如果你感兴趣的话,可以点击链接进行了解。希望能给大家带来不经意的收获~~
喂,别走!还有几篇文章送你
1
2
3
4