深入瞭解Python為什麼慢(翻譯自Why Python is Slow: Looking Under the Hood)
深入瞭解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是動態的而不是靜態的
意思是說,在程式執行時,直譯器不知道所定義變數的型別。 下圖闡釋了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的[文章]
(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;
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相等!值得一看,但切勿嘗試(會導致直譯器崩潰)。
翻譯了較長時間,如果您從本文中有收穫,請點個贊吧!
相關文章
- 為什麼Python這麼慢?Python
- 為什麼 Python 這麼慢?Python
- Under the Hood: NaN of JavaScriptNaNJavaScript
- Python為什麼這麼火?你瞭解多少呢?Python
- Python是什麼?你對Python瞭解嗎?Python
- Why MVC is Better?(翻譯)MVC
- 【Under-the-hood-ReactJS-Part13】原始碼解讀ReactJS原始碼
- 【Under-the-hood-ReactJS-Part9】React原始碼解讀ReactJS原始碼
- 【Under-the-hood-ReactJS-Part6】React原始碼解讀ReactJS原始碼
- [譯] 深入瞭解 FlutterFlutter
- 為什麼你寫的Python執行的那麼慢呢?Python
- 【Under-the-hood-ReactJS-Part10】React原始碼解讀ReactJS原始碼
- 【Under-the-hood-ReactJS-Part11】React原始碼解讀ReactJS原始碼
- 【Under-the-hood-ReactJS-Part14】React原始碼解讀ReactJS原始碼
- 【Under-the-hood-ReactJS-Part13】React原始碼解讀ReactJS原始碼
- 【Under-the-hood-ReactJS-Part2】React原始碼解讀ReactJS原始碼
- 為什麼不能當職業翻譯
- Python 谷歌翻譯Python谷歌
- Python是什麼?為什麼要掌握python?Python
- 深入瞭解 Python 字串物件的實現Python字串物件
- Python3 原始碼閱讀-深入瞭解Python GILPython原始碼
- 什麼是Python?Python為什麼這麼搶手?Python
- Python是什麼?為什麼Python受歡迎?Python
- Why mobile web apps are slowWebAPP
- 為什麼反射慢?反射
- 為什麼使用PythonPython
- Python到底是什麼?為什麼要學Python?Python
- 藉助 zope.interface 深入瞭解 Python 介面Python
- 非同步 PHP:為什麼? ( Asynchronous PHP: Why?)非同步PHP
- Python為什麼這麼火?學習python有什麼用?Python
- 什麼是python?python為何這麼火?Python
- 為什麼 Python 這麼火Python
- Python為什麼叫爬蟲?Python為什麼適合寫爬蟲?Python爬蟲
- 為什麼要學習Python?Python可以做什麼事情?Python
- ? python 介面自動化 (二)--什麼是介面測試、為什麼要做介面測試 (詳解)Python
- 瞭解pythonPython
- python為什麼用類Python
- 為什麼是python (轉)Python