深入了解Python为什么慢(翻译自Why Python is Slow: Looking Under the Hood)
原文
Why Python is Slow: Looking Under the Hood
概括
Python作为动态的解释语言,语法上很强的灵活性与包容性,但是它底层的实现逻辑比编译型语言(本文以C为例)更复杂,所以执行效率慢了点。但总体来说,因为规避了很多繁琐的问题,Pyhon的编程效率还是很高的。
翻译
前言
我们之前都听说过:Python很慢。
当我教授用于科学计算的Python课程时,我会在课程开始时就很明确地指出这一点,并告诉学生原因:Python是一种动态类型化的解释语言,值并不是存在连续的内存区域里,而是分散的。 然后,我将谈谈如何使用NumPy,SciPy和相关工具对操作进行矢量化,来解决这个问题。
但是我最近才意识到:尽管以上陈述相对准确,但编程小白可能不理解“动态编程、缓冲区、矢量化、编译”这些词汇,专业术语并不能让他们了解python底层的实际情况。
因此,我决定写这篇文章,并深入探讨我通常会掩盖的细节。 在此过程中,我们以CPython(python的一种c语言解释器)的视角来看看Python底层。 因此,无论您是新手还是经验丰富的程序员,我都希望您能从以下探索中学到一些东西。
正文
原因一:python是动态的而不是静态的
意思是说,在程序执行时,python解释器不知道所定义变量的类型。 下图阐释了C变量(我将C语言作为编译语言的代表)与Python变量之间的区别:
写C时,我们要提前定义好变量类型,这样编译器立刻就知道了,而Python中的变量,在程序执行时,解释器所知道的只是它是某种Python对象。
所以,如果你用C语言写如下代码:
/* C code */
int a = 1;
int b = 2;
int c = a + b;
C编译器从一开始就知道a和b是整数:它们根本不能是其他任何东西! 知道了这一点,编译器就可以调用将两个整数相加的函数,并返回一个内存中的简单整数。 事件的粗略顺序如下所示:
-
C addition
- 1.Assign <int> 1 to a
- 2.Assign <int> 2 to b
- 3.call binary_add<int, int>(a, b)
- 4.Assign the result to c
用Python写同样的代码:
# python code
a = 1
b = 2
c = a + b
现在呢,解释器仅知道 1 和 2 是对象,但不知道它们是什么类型的对象(可能是int可能是char之类的)。 因此,解释器必须检查每个变量的PyObject_HEAD以找到类型信息,然后为这两种类型调用相应的的求和函数。 最后,它必须创建并初始化一个新的Python对象以保存返回值。 事件的粗略顺序如下:
-
Python Addition
-
-
1.Assign 1 to a
- 1a. Set a->PyObject_HEAD->typecode to integer
- 1b. Set a->val = 1
-
-
2.Assign 2 to b
- 2a. Set b->PyObject_HEAD->typecode to integer
- 2b. Set b->val = 2
-
-
3.call binary_add(a, b)
- 3a. find typecode in a->PyObject_HEAD
- 3b. a is an integer; value is a->val
- 3c. find typecode in b->PyObject_HEAD
- 3d. b is an integer; value is b->val
- 3e. call binary_add<int, int>(a->val, b->val)
- 3f. result of this is result, and is an integer.
-
-
4.Create a Python object c
- 4a. set c->PyObject_HEAD->typecode to integer
- 4b. set c->val to result
动态类型意味着任何操作都涉及很多步骤。 这是Python在数值数据上执行运算的速度比C慢的主要原因。
原因二:Python是解释型的不是编译型的
一个好的编译器可以优化代码的执行过程,提高速度。 编译器优化是编译器开发者的事,我个人没有资格对此做过多说明,因此我不再多说。 有关此操作的一些示例,您可以查看我之前有关Numba和Cython的文章。
笔者理解:Python有多种语言写成的解释器(C、Java、.NET),解释器的性能与其本身语言的编译器有关。
原因三:Python的对象模型导致内存访问效率低下
上文解释了C整数与Python整数的区别。 现在,假设您创建了许多这样的整数,并且想要对它们进行某种批处理操作。 在Python中,您可能会使用标准的List对象,而在C中,您可能会使用某种基于缓冲区的数组。
最简单形式的NumPy数组是以C数组为基础构建的Python对象。 即,它具有一个指向内存中连续数组的指针,数组里存放的是值。 Python列表也有一个指向内存中连续数组的指针,不过这个数组里存放的也是指针,指向某一个Python对象,该对象又有对其数据的引用(在这种情况下为整数)。 这是两者的示意图:
不难发现,如果您要执行一些按顺序遍历数据的操作,那么在存储成本和访问成本方面,numpy将比Python效率更高。
总结
鉴于Python固有的低效率,我们为什么还要考虑使用Python呢?
因为动态类型让Python比C更灵活宽容(flexible and forgiving),可以节约开发时间.在某些情况下,您可能确实需要 C 或 Fortran 来优化执行速度,Python 也能轻易挂连上其它语言的库。 这就是为什么在许多科学社区中Python的使用不断增长。
综上所述,Python是一门非常高效的语言。
深入了解
作者的话
上面我已经讨论了一些Python的内部结构,但我不想就此停止。 当我汇总以上内容时,我开始研究Python语言的内部原理,发现该过程本身非常有启发性。
在以下各节中,我将通过使用代码来剖析Python本身,以向您证明上文信息是正确的。 请注意,以下所有内容都是使用Python 3.4编写的。 早期版本的Python内部对象结构略有不同,而更高版本可能会对此进行进一步调整。 请确保使用正确的版本! 另外,下面的大多数代码都假定使用64位CPU。 如果您使用的是32位,则必须对以下某些C类型进行调整。
import sys
print("Python version =", sys.version[:5])
output:
Python version = 3.4.0
深入研究Python整数
Python整数很容易创建使用:
x = 42
print(x)
output:
42
但是输入输出的简单性掩盖了底层的复杂。 我们在上面简要讨论了Python整数的内存布局。 在这里,我们将使用Python的内置ctypes模块从Python解释器本身来看看Python的整数类型。 但是首先我们需要确切地了解从C语言的角度来看的Python整数是什么样子。
CPython中实际的x变量存储在CPython源代码中的Include / longintrepr.h中定义的结构中。
struct _longobject {
PyObject_VAR_HEAD
digit ob_digit[1];
};
PyObject_VAR_HEAD是一个宏,在Include/object.h中被定义:
#define PyObject_VAR_HEAD PyVarObject ob_base;
typedef struct {
PyObject ob_base;
Py_ssize_t ob_size; /* Number of items in variable part */
} PyVarObject;
PyObject在Include/object.h中有定义:
typedef struct _object {
_PyObject_HEAD_EXTRA
Py_ssize_t ob_refcnt;
struct _typeobject *ob_type;
} PyObject;
_PyObject_HEAD_EXTRA也是一个宏,不过在Python底层里不常用。
把所有的宏、结构都展开,我们的整数对象如下所示:
struct _longobject {
long ob_refcnt;
PyTypeObject *ob_type;
size_t ob_size;
long ob_digit[1];
};
ob_refcnt变量是对象的引用计数,ob_type变量是指向某结构的指针,该结构包含该对象的所有类型信息和方法定义,而ob_digit保留实际数值。
掌握了这些知识之后,我们将使用ctypes模块开始查看实际的对象结构并提取一些上述信息。
我们首先定义C结构的Python表示:
import ctypes
class IntStruct(ctypes.Structure):
_fields_ = [("ob_refcnt", ctypes.c_long),
("ob_type", ctypes.c_void_p),
("ob_size", ctypes.c_ulong),
("ob_digit", ctypes.c_long)]
def __repr__(self):
return ("IntStruct(ob_digit={self.ob_digit}, "
"refcount={self.ob_refcnt})").format(self=self)
现在让我们看一下数字的内部表示形式,例如42(id函数给出了对象的内存位置):
num = 42
IntStruct.from_address(id(42))
output:
IntStruct(ob_digit=42, refcount=35)
ob_digit的值是42,证明它指向内存中的正确位置!
但是refcount为什么如此之大呢? 我们仅创建了一个值啊!
事实上,Python经常使用小整数。 如果为这些整数中的每个整数创建一个新的PyObject,则将占用大量内存。 因此,Python将常见的整数值实现为单例:也就是说,内存中仅存在这些数字的一个副本。 换句话说,每次创建新的Python整数时,您都只是在引用具有该值的单例:
x = 42
y = 42
id(x) == id(y)
output:
True
这两个变量只是指向相同内存地址的指针。 当您创建更大的整数(在Python 3.4中大于255)时,这不再成立:
x = 1234
y = 1234
id(x) == id(y)
output:
False
Python解释器的启动会创建很多整数对象。 看看每个整数被引用多少次很有趣:
%matplotlib inline
import matplotlib.pyplot as plt
import sys
plt.loglog(range(1000), [sys.getrefcount(i) for i in range(1000)])
plt.xlabel('integer value')
plt.ylabel('reference count')
output:
<matplotlib.text.Text at 0x106866ac8>
我们看到零被引用了数千次,并且正如您所想的那样,引用的频率通常随着整数值的增加而降低。
为了进一步确保我们想的是对的,我们验证一下ob_digit是否有正确的值:
all(i == IntStruct.from_address(id(i)).ob_digit
for i in range(256))
output:
True
如果您想得再深入一点,您可能会注意到这不适用于大于256的数字:事实上,在Objects / longobject.c中执行了一些移位处理,这些处理改变了大整数的在内存中的表现方式。
我不能说我完全理解为什么会这样,但是我认为它与Python对超过溢出限制的long int整数的处理有关,如下所示:
2**100
output:
1267650600228229401496703205376
这数字如果是long的话就溢出了(long的上限是264)
深入研究Python列表
让我们将上述想法应用到更复杂的类型:Python列表。 类似于整数,我们在Include / listobject.h中找到列表对象本身的定义:
typedef struct {
PyObject_VAR_HEAD
PyObject **ob_item;
Py_ssize_t allocated;
} PyListObject;
同样,我们可以展开宏与结构,得到如下的表达式:
typedef struct {
long ob_refcnt;
PyTypeObject *ob_type;
Py_ssize_t ob_size;
PyObject **ob_item;
long allocated;
} PyListObject;
PyObject **ob_item指向list的内容,ob_size表示list的元素个数。
class ListStruct(ctypes.Structure):
_fields_ = [("ob_refcnt", ctypes.c_long),
("ob_type", ctypes.c_void_p),
("ob_size", ctypes.c_ulong),
("ob_item", ctypes.c_long), # PyObject** pointer cast to long
("allocated", ctypes.c_ulong)]
def __repr__(self):
return ("ListStruct(len={self.ob_size}, "
"refcount={self.ob_refcnt})").format(self=self)
试试:
L = [1,2,3,4,5]
ListStruct.from_address(id(L))
output:
ListStruct(len=5, refcount=1)
为了确保我们做得正确,让我们为列表创建一些额外的引用,并查看它如何影响引用计数:
tup = [L, L] # two more references to L
ListStruct.from_address(id(L))
output:
ListStruct(len=5, refcount=3)
现在让我们看一下在列表中查找实际元素的方法。
正如我们在上面看到的,元素是通过PyObject指针的连续数组存储的。 使用ctypes,我们实际上可以创建一个包含IntStruct对象的复合结构:
# get a raw pointer to our list
Lstruct = ListStruct.from_address(id(L))
# create a type which is an array of integer pointers the same length as L
PtrArray = Lstruct.ob_size * ctypes.POINTER(IntStruct)
# instantiate this type using the ob_item pointer
L_values = PtrArray.from_address(Lstruct.ob_item)
现在,让我们看一下每一项中的值:
[ptr[0] for ptr in L_values] # ptr[0] dereferences the pointer
output:
[IntStruct(ob_digit=1, refcount=5296),
IntStruct(ob_digit=2, refcount=2887),
IntStruct(ob_digit=3, refcount=932),
IntStruct(ob_digit=4, refcount=1049),
IntStruct(ob_digit=5, refcount=808)]
我们恢复了列表中的PyObject整数! 您可能需要花些时间回顾上面的“列表”内存布局的示意图,并确保您了解这些ctypes操作如何映射到这些图表中。
深入了解NumPy数组
现在,为了进行比较,让我们看看numpy数组。 我将跳过NumPy用C接口的数组定义的详细演练。 如果要查看它,可以在numpy / core / include / numpy / ndarraytypes.h中找到它
请注意,我在这里使用的是NumPy 1.8版。
import numpy as np
np.__version__
output:
'1.8.1'
我们从创建一个代表numpy数组本身的结构开始。 这应该开始看起来很熟悉…
我们还将添加一些自定义属性来表示数组的形状与步长:
class NumpyStruct(ctypes.Structure):
_fields_ = [("ob_refcnt", ctypes.c_long),
("ob_type", ctypes.c_void_p),
("ob_data", ctypes.c_long), # char* pointer cast to long
("ob_ndim", ctypes.c_int),
("ob_shape", ctypes.c_voidp),
("ob_strides", ctypes.c_voidp)]
@property
def shape(self):
return tuple((self.ob_ndim * ctypes.c_int64).from_address(self.ob_shape))
@property
def strides(self):
return tuple((self.ob_ndim * ctypes.c_int64).from_address(self.ob_strides))
def __repr__(self):
return ("NumpyStruct(shape={self.shape}, "
"refcount={self.ob_refcnt})").format(self=self)
试试:
x = np.random.random((10, 20))
xstruct = NumpyStruct.from_address(id(x))
xstruct
output:
NumpyStruct(shape=(10, 20), refcount=1)
我们看到我们已经提取了正确的形状信息。 我们来验证一下引用计数是否正确:
L = [x,x,x] # add three more references to x
xstruct
output:
NumpyStruct(shape=(10, 20), refcount=4)
现在,我们试试从内存取数。 为简单起见,我们将忽略步长,并假设它是一个C连续数组。
x = np.arange(10)
xstruct = NumpyStruct.from_address(id(x))
size = np.prod(xstruct.shape)
# assume an array of integers
arraytype = size * ctypes.c_long
data = arraytype.from_address(xstruct.ob_data)
[d for d in data]
output:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
可以看到,data变量展示了NumPy数组中的元素! 为了说明这一点,我们将在Numpy数组中更改一个值.
x[4] = 555
[d for d in data]
output:
[0, 1, 2, 3, 555, 5, 6, 7, 8, 9]
data发生变化。 说明x和data都指向相同的连续内存块。
通过比较Python列表和NumPy ndarray的内部结构,明显看出NumPy的数组对于表示相同类型数据的列表要简单得多。 这也能使编译器更有效地进行数据处理。
闲话
原文作者还在最后写了“外挂”:通过强行修改内存中的值,让113与4相等!值得一看,但切勿尝试(会导致解释器崩溃)。