深入了解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变量之间的区别:a variable in C&&a variable in 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

现在呢,解释器仅知道 12 是对象,但不知道它们是什么类型的对象(可能是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;

PyObjectInclude/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发生变化。 说明xdata都指向相同的连续内存块。

通过比较Python列表和NumPy ndarray的内部结构,明显看出NumPy的数组对于表示相同类型数据的列表要简单得多。 这也能使编译器更有效地进行数据处理。

闲话

原文作者还在最后写了“外挂”:通过强行修改内存中的值,让113与4相等!值得一看,但切勿尝试(会导致解释器崩溃)。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值