原地址
讲的非常简单易懂
我们之前一定听有人说过,python的执行速度比其他语言慢。
我们通常的解释是:python是一个动态的解释型语言;python中的值不是存储在缓存区而是分散的存储在对象中。通过使用Numpy和Scipy等相关可以进行矢量化操作的工具并调用编译后的代码来绕过这个问题来避开这个问题。
然而,“动态类型 - 解释 - 缓冲 - 矢量化编译”这些词的解释太过笼统。这些解释不能解释python运行过慢的底层性的深层次原因。最佳无意间在网上看到这样一边帖子《Why Python is Slow: Looking Under the Hood》便翻译一下,贴出来供大家参考。
1.1. python是动态性语言不是静态性语言
这是说在python程序执行的时候,编译器不知道变量的类型。图1.展示了C语言中的变量与python中变量的区别。在C中编译器知道变量在定义时的类型,而python中执行的时候只知道它是一个对象。
图1
因此,如果您在C中编写以下内容:
/ * C代码* /
int a = 1 ;
int b = 2 ;
int c = a + b ;
C编译器从一开始就知道a并且b是整数:它们根本不可能是其他任何东西!有了这些知识,它可以调用添加两个整数的例程,返回另一个整数,它在内存中只是一个简单的值。在C中执行的流程大概如下:
###C加法:
1. 分配
2. 分配
3. 调用二进制加法binary_add(a, b)
4. 将结构分配给c变量
python中等效的代码如下:
# python code
a = 1
b = 2
c = a + b
这里解释器只知道1和2是对象,但不知道它们是什么类型的对象。 因此解释器必须检查每个变量的PyObject_HEAD以找到类型信息,然后为这两种类型调用适当的求和例程。 最后,它必须创建并初始化一个新的Python对象来保存返回值。 执行流程大致如下:
###Python 加法
1. 分配1给a
(1)设置a->PyObject_HEAD->typecode为整数
(2)设置Set a->val = 1
2. 分配2给b
(1)设置 b->PyObject_HEAD->typecode 为整数
(2)设置 b->val = 2
3. 调用二进制加法binary_add(a, b)
(1)找到类型代码 a->PyObject_HEAD
(2)a是整数,值为a->val
(3)找到类型代码 b->PyObject_HEAD
(4)b是整数,值为b->val
(5)调用二进制加法 binary_add(a->val, b->val)
(6)结果是result,是一个整数。
4. 创建一个新的对象c
(1)设置 c->PyObject_HEAD->typecode 为整数
(2)将 c->val 分配给结果
动态类型意味着任何操作都需要更多的步骤。这是Python在数值数据操作方面比C慢的主要原因。
1.2. python是解释性语言而不是编译性语言
解释型语言与编译型语言它们本身的区别也会造成程序在执行的时候的速度差异。一个智能化的编译器可以预测并针对重复和不需要的操作进行优化。这也会提升程序执行的速度。
在上面的例子中,相对于C语言,在python中对整数进行操作会有一个额外的类型信息层。当有很多的整数并且希望进行某种批操作时,在python中往往会使用一个list,而在C中会使用某个基于缓存区的数组。
在Numpy数组的最简单的形式是一个围绕着C中的数组建的一个python对象。也就是说Numpy有一个指针指向连续缓存区数据的值,而在python中,python列表有一个只想缓存区的指针,每个指针都指向一个python缓存对象,而且每个对象都绑定一个数据(本例中是整数)。这两种情况的原理图如图2:
图2
从图2中可以很明显的看出,当对数据进行操作时(例如排序、计算、查找等),无论是在存续成本还是访问成本上,Numpy都比python更加的高效。
既然用pytho处理数据那么低效,那么为什么我们还要使用python呢?主要是因为,python是动态的语言,它比C更加的容易上手使用,而且用法更加的灵活和兼容,这可以极大的节省开发时间。而且,python是开源的,跨平台,具有很强的移植性。在那些真正需要运用C或Fortran进行优化的场合中,python都有强大的API或库进行支持。这就是为什么Python在许多科学社区中的使用一直在不断增长。所以,Python最终成为使用代码进行科学研究的总体任务的极其有效的语言。
上面已经谈到了python的一些特有的内部结构,但文章并不想止步于此,下面对python进行一些黑客攻击,这个过程很具有启发性。
接下来的部分将使用黑客攻击来暴露一些python对象来证明上述信息的正确性。请注意,以下所有内容均使用Python 3.4编写。早期的python版本python对象的内部结构不同,随后的版本其内部的对象结构也会发生调整,所以注明python的版本很重要。请确保使用正确的版本!此外,下面的大多数代码假定为64位CPU。如果您使用的是32位平台,则必须调整下面的某些C类型以解释这种差异。
Python中的整数易于创建和使用:
然而,这个界面的简单性却隐藏了底层的复杂性,上面的叙述中简要讨论了python整数在内存中的布局。在这里,我们将使用Python的内置ctypes模块从Python解释器本身检查Python的整数类型。首先我们需要准确的指导在C的API级别上python的整型是什么样子的。
CPython中的实际x变量存储在CPython源代码(包含Include / longintrepr.h)中定义的结构中。
struct _longobject {
PyObject_VAR_HEAD
digit ob_digit[1];
};
PyObject_VAR_HEAD是一个宏,它使用以下结构启动对象,该结构在Include / object.h中定义:
typedef struct {
PyObject ob_base ;
Py_ssize_t ob_size ; / *变量部分中的项目数* /
} 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构建中使用。
把所有这些放在一起,typedef / macros不进行模糊处理,我们的整数对象就就可以解决如下结构:
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。
现在让我们看看一些数字的内部表示,比如说42。我们将使用在cPython中ID函数给出对象的内存的位置:
ob_digit属性指向内存中的正确位置!
但是refcount怎么办?只创建了一个值,为什么引入比这个值大得多的数?
事实上python中使用了很多的小整数。如果PyObject为这些整数中的每一个创建了一个新的,那将占用大量内存。因此,Python将公共整数值实现为单例:即,内存中只存在这些数字的一个副本。换句话说,每次在此范围内创建新的Python整数时,您只需使用该值创建对单例的引用:
这两个变量都是指向同一内存地址的指针。当你得到更大的整数(在Python 3.4中大于255)时,这不再是True:
只要启动Python解释器,就会创建许多整数对象;看看有多少引用可能是很有趣的:
我们看到零被引用几千次,并且正如您所预期的那样,引用的频率通常随着整数值的增加而减小。为了进一步确保这样做符合我们的预期,让我们确保该ob_digit字段保持正确的值:
如果你更深入一点,你可能会注意到这不适用于大于256的数字:事实证明,一些位位移操作是在Objects / longobject.c中执行的,它们改变了内存中表示大整数的方式。我不能说我完全理解为什么会发生这种情况,但我认为这与Python有效处理超过long int数据类型溢出限制的整数的能力有关,正如我们在这里看到的:
这些数太长了而不能变为long整型,long整型只能储存64位。
让我们将上述想法应用于更复杂的类型: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是指向列表内容的对象,该ob_size值告诉我们列表中有多少项。
我们来试试吧:
为了确保我们已经正确完成了任务,让我们创建一些额外的列表引用,并查看它如何影响引用计数:
现在让我们看看如何找到列表中的实际元素。如上所述,元素通过连续的PyObject指针数组存储。使用CyType,我们实际上可以创建一个复合结构,它由以前的IntStruct对象组成:
现在让我们来看看每个项目中的值:
我们已经恢复了列表中的PyObject整数! 您可能希望花一点时间回顾上面列表内存布局的原理图,并确保您了解这些ctypes操作如何映射到该图表。
现在,为了比较,让我们对numpy数组进行相同的检查。我将跳过NumPy C-API数组定义的详细介绍; 如果你想看看它,你可以在中找到它https://github.com/numpy/numpy/blob/maintenance/1.8.x/numpy/core/include/numpy/ndarraytypes.h#L646请注意,我在这里使用Numpy 1.8版; 这些内部可能在不同版本之间发生了变化。
让我们从创建一个表示Numpy数组本身的结构开始。这应该开始看起来很熟悉…
我们还将添加一些定制属性来访问Python版本的shape和stride:
现在让我们试一试:
我们看到我们已经提取出正确的shape信息。让我们确保引用计数是正确的:
现在,我们可以完成提取数据缓冲区的复杂部分。为了简单起见,我们将忽略大步并假设它是一个C连续数组;这可以用一点工作来概括。
该data变量现在是Numpy数组中定义的连续内存块的视图!为了表明这一点,我们将更改数组中的值...
...并观察数据视图也会发生变化。两者x并data都指向的存储器中的相同的连续块。
比较Python列表和Numpy ndarray的内部结构,对于表示相同类型的数据,很明显Numpy的数组要比列表要简单得多。
受这篇Reddit帖子的启发,我们可以修改整数对象的数值!如果我们使用一个普通的数字,比如0或1,我们很可能会崩溃我们的Python内核。 但是如果我们用不那么重要的数字来做,我们就可以侥幸逃脱,至少暂时的。
请注意,这是一个非常非常糟糕的主意。 特别是,如果你在IPython笔记本中运行它,你可能会破坏IPython内核的运行能力(因为你在运行时搞乱了变量)。 尽管如此,我们还是会用指出:
但是现在请注意,我们不能以简单的方式返回值,因为Python中不再存在真正的113的值!
恢复的一种方法是直接操作字节。所以在运行Python 3.4的小端64位系统上,以下工作可以做:
上面我们对Numpy数组中的值进行了就地修改。这很容易,因为Numpy数组只是一个数据缓冲区。但是我们可以为列表做同样的事情吗?这会变得有点棘手,因为列表存储对值的引用而不是值本身。为了不让Python本身崩溃,你需要非常小心地跟踪这些引用计数。可以用一下方式完成:
就像我说的,你永远都不应该用这个,老实说,我想不出任何你想用的理由。但它让您了解了解释器在修改列表内容时必须执行的操作类型。与上面的Numpy示例相比,您将看到为什么Python列表比Python数组开销更大的一个原因。
使用上述方法,我们可以开始变得更加陌生。所述Structure类ctypes本身是一个Python对象,其中可以看到模块/ _ctypes / ctypes.h。正如我们包装整数和列表一样,我们可以按如下方式自行包装结构:
现在我们将尝试制作一个包裹自己的结构。我们不能直接这样做,因为我们不知道内存中的哪个地址将创建新结构。但我们可以做的是创建第二个包装第一个结构,并使用它来就地修改其内容!
我们首先制作一个临时的元结构并将其包装起来:
现在我们添加第三个结构,并使用它来就地调整第二个内存值:
我们现在有一个自包装的Python结构!再说一次,我想不出你有什么理由想要这样做。并且请记住,在Python中有关于这种类型的自引用的开创性 - 由于它的动态类型,在不直接破解内存的情况下执行这样的操作是非常简单的:
Python很慢。正如我们所看到的那样,其中一个重要原因就是引擎盖下的类型间接,这使得开发人员可以快速,轻松,有趣地使用Python。正如我们所见,Python本身提供的工具可以用来攻击Python对象本身。
我希望通过对各种物体之间的差异的探索以及CPython本身内部的一些自由捣蛋来更清楚地说明这一点。这个练习对我来说非常有启发性,我希望它也适合你...快乐的黑客攻击!
这篇博客文章完全是在IPython Notebook中编写的。完整的笔记本可以在查看下载 ,或在这里静态 查看。