深入瞭解Python為什麼慢(翻譯自Why Python is Slow: Looking Under the Hood)

kokoro_ele發表於2020-11-12

原文

Why Python is Slow: Looking Under the Hood

概括

Python作為動態的解釋語言,語法上很強的靈活性與包容性,但是它底層的實現邏輯比編譯型語言(本文以C為例)多繞了個彎。但總理來說,用Pyhon的程式設計效率還是很高的。

翻譯

前言

我們之前都聽說過:Python很慢。

當我教授用於科學計算的Python課程時,我會在課程開始時就很明確地指出這一點,並告訴學生原因:Python是一種動態型別化的解釋語言,值並不是存在連續的記憶體區域裡,而是分散的。 然後,我將談談如何使用NumPy,SciPy和相關工具對操作進行向量化,來解決這個問題。

但是我最近才意識到:儘管以上陳述相對準確,但程式設計小白可能不理解“動態程式設計、緩衝區、向量化、編譯”這些詞彙,專業術語並不能讓他們瞭解python底層的實際情況。

因此,我決定寫這篇文章,並深入探討我通常會掩蓋的細節。 在此過程中,我們以CPython(python的一種c語言直譯器)的視角來看看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的[文章]
(http://jakevdp.github.io/blog/2013/06/15/numba-vs-cython-take-2/)。
筆者理解: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相等!值得一看,但切勿嘗試(會導致直譯器崩潰)。
翻譯了較長時間,如果您從本文中有收穫,請點個贊吧!

相關文章