时下Python语言非常流行,由于其简单明了易于上手的特性被IT业界和非IT当,广泛使用,尤其是在数据处理和AI方面更是一枝独秀。然而Python的大量的类库和应用却良莠不齐,广为诟病,尤其在性能方面很多程序确实存在很多问题。本文虫虫就给大家介绍下如何通过使用合宜的数据结构来减少其对内存的消耗,从而优化Python应用的性能。
字典
在小程序中,特别是在脚本中,使用内置的dict来表示结构信息非常简单方便,例如:
import sys
>>> ob = {'x':1, 'y':2, 'z':3}
>>> x = ob['x']
>>> ob['y'] = y
Python 3.6后,对dict做了优化,使用一组的更紧凑有序键列的实现,dict更加便捷好用。但是不要光图了使用方便,我们来看看它的内存占用。
print(sys.getsizeof(ob))
乖乖,内存占用可以不小。这还是是三个坐标而已,当程序中涉及大量数据的时候内存占用就更可怕了。我们计算一下,根据实例中对象个数和对象占用:
100w个对象的时候 1000000*240=240Mb
1000w个对象的时候 10000000*240=2.4Gb
类实例
另一个常用的是大量使用类,用来类属性来表示数据结构:
class Coordinate:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
关于类的实例内存占用:
PyGC_Head 24
PyObject_HEAD 16
__weakref__ 8
__dict__ 8
总计:56
这里__weakref__是这个对象的弱引用列表的引用, __dict__是对类实例字典的引用,它包含实例属性的值(注意64位平台下引用占用8个字节)。从Python 3.3开始,共享空间用于在字典中为所有类实例存储键,这种机制减少了RAM中实例跟踪的大小:
因此,大量的类实例在内存中的占用空间小于常规字典(dict):
100w类实例 1000000*56=168 Mb
尽管如此,由于实例字典的占用较大大,类实例内存占用大小仍然很大。
带__slots__的类实例
上面我们说了,类实例中__dict__ 和__weakref__耗费很多空间,如果我们把他们俩个取消就可以显著地减少RAM中类实例的大小。而取消他们需要借助__slots__:
class Coordinate:
__slots__ = 'x', 'y', 'z'
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
ob = Point(1,2,3)
RAM中的对象大小就变的很小:
PyGC_Head 24
PyObject_HEAD 16
x 8
y 8
z 8
总计:64
在类定义中使用__slots__可以让类实例的内存占用显著减少。
100w实例 1000000*64=64 Mb
主要原因是:在对象标题之后的内存中,存储了对象引用,属性值,并且使用类字典中的对特殊描述符来实现对它们的访问。要默认就用用__slots__自动创建类的过程,可以使用模块namedlist和attrs。使用namedlist.namedlist函数创建__slots__类:
Coordinate = namedlist('Point',()'x','y','z'))
元祖
Python还有一个内置类型元组,用于表示不可变数据结构。元组具有固定的结构记录,但没有字段名称。对于字段访问,使用字段索引。在创建元组实例时,元组字段一次性与值对象关联,不能在做修改:
ob2 =(1,2,3)
ob2[1] = 4
上面语句会报类型错误。
元组的实例非常紧凑:
print(sys.getsizeof(ob2))
它们在内存中占用8个字节比使用__slots__的类实例多,因为内存中的对元组跟踪还额外引入了很多字段:
PyGC_Head 24
PyObject_HEAD 16
ob_size 8
[0] 8
[1] 8
[2] 8
总计:72
命名元祖
由于元组被广泛使用,许多人希望元祖也能通过字段名称来访问。这也是可以的不过需要引入一个额外的模块collections.namedtuple,使用namedtuple函数自动生成这种类类:
Coordinate1 = namedtuple('Coordinate',('x','y','z'))
它创建了一个元组的子类,其中定义了用于按名称访问字段的描述符。其行文相当于:
class Coordinate (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))
此类的所有实例都具有与元组相同的内存占用。大量的实例占用很大的内存:
100w 1000000*72 72 Mb
Recordclass
由于元组以及相应的命名元组在ob.x的对象值不再与另一个值对象相关联的意义上生成非残缺对象,因此实践中需要可以赋值的对可变的命名元组变体的需求。由于Python中没有提供支持给元祖赋值的类似内置类型,但是有一些第三方的模块提供类似的功能:
recordclass:与类似元组的对象的大小相比,它可以用于减少RAM中对象的大小。
recordclass引入了类型recordclass.mutabletuple,它几乎与元组相同,但是支持对其赋值。在此基础上,创建的子类几乎与namedtuples完全相同,但支持为字段分配新值(不创建新实例)。recordamed类函数与thenamedtuple函数一样,支持自动创建类:
Coordinate = recordclass(' Coordinate', ('x', 'y', 'z'))
ob = Coordinate (1, 2, 3)
类实例与元组具有相同的结构,但只有一个withoutPyGC_Head:
PyObject_HEAD 16
ob_size 8
x 8
y 8
z 8
总计:48
默认情况下,recordclass函数创建一个不参与循环垃圾收集机制的类。通常,namedtuple和recordclass用于生成表示记录或简单数据结构的类。在Python中正确使用它们不会生成循环引用。由于这个原因,在记录类生成的类实例之后,默认情况下,没有PyGC_Headfragment,对应于创建的类,在标志字段中,默认情况下,没有 flagPy_TPFLAGS_HAVE_GC。
大量实例的内存占用大小比slots__的类的实例还小:
100w实例 1000000*48=48 Mb
dataclass
recordclass提供的另一个解决方案基于如下的机制:在内存中使用与__slots__类相同的存储结构,但不参与循环垃圾收集机制。这些类通过recordclass.make_dataclass函数生成的:
Coordinate = make_dataclass('Coordinate '('x','y','z'))
默认情况下,以这种方式创建的类会创建可变实例。
另一种方法是使用来自recordclass.dataobject的继承的类声明:
class Coordinate(dataobject):
X:INT
Y:INT
Z:INT
以这种方式创建的类将创建不参与循环垃圾收集机制的实例。内存中实例的结构与__slots__的情况相同,但没有PyGC_Head:
字段大小(字节)
PyObject_HEAD 16
X 8
Y 8
Z 8
总计:40
ob = Coordinate(1,2,3)
print(sys.getsizeof(ob))
40
要访问这些字段,需要使用特殊描述符通过对象开头的偏移量来访问字段,这些对象位于类字典中:
这种实例的内存占用的大小是最小的:
实例数量
100w实例 1000000*40=40 Mb
Cython
还有一种是基于Cython的数据结果。Cython的优点是字段可以采用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
在这种情况下,实例的内存大小很小:
ob = Coordinate(1,2,3)
print(sys.getsizeof(ob))
32
只有32字节。在内存中的实例跟踪它具有以下结构:
PyObject_HEAD 16
x 4
y 4
z 4
总计:32
大量实例下空间占用:
100w实例 1000000*32=32 Mb
NumPy
对大量数据使用多维数组或记录数组可以节约内存耗用。但是,为了在纯Python中进行高效处理,我们可以使用numpy包中的函数的处理方法。
Coordinate = numpy.dtype(('x', numpy.int32), ('y', numpy.int32), ('z', numpy.int32)])
也支持使用函数比如zeros对其进行初始化:
po = numpy.zeros(N, dtype= Coordinate)
其内存占用是最小的:
100w实例 1000000*12=12 M
对数组元素和行的正常访问将需要从Python对象转换为C int值,反之亦然。提取单行会导致创建包含单个元素的数组。
sys.getsizeof(po[0])
68
所以,在Python代码中,建议用numpy包中的函数处理数组。
结论
本文中,我们列举了Python常见的数据结构及其内存的占用,基于此希望大家在开发中选择合适的数据结构,减少内存的使用,从而提高应用的性能。
领取专属 10元无门槛券
私享最新 技术干货