我將示範微優化(micro optimization)如何提升python程式碼5%的執行速度。5%!同時也會觸怒任何維護你程式碼的人。
但實際上,這篇文章只是解釋一下你偶爾會在標準庫或者其他人的程式碼中碰到的程式碼。我們先看一個標準庫的例子,collections.OrderedDict
類:
1 2 3 4 5 6 |
def __setitem__(self, key, value, dict_setitem=dict.__setitem__): if key not in self: root = self.__root last = root[0] last[1] = root[0] = self.__map[key] = [last, root, key] return dict_setitem(self, key, value) |
注意最後一個引數:dict_setitem=dict.__setitem__
。如果你仔細想就會感覺有道理。將值關聯到鍵上,你只需要給__setitem__
傳遞三個引數:要設定的鍵,與鍵關聯的值,傳遞給內建dict類的__setitem__
類方法。等會,好吧,也許最後一個引數沒什麼意義。
作用域查詢
為了理解到底發生了什麼,我們看下作用域。從一個簡單問題開始:在一個python函式中,如果遇到了一個名為open
的東西,python如何找出open
的值?
1 2 3 4 5 6 |
# <GLOBAL: bunch of code here> def myfunc(): # <LOCAL: bunch of code here> with open('foo.txt', 'w') as f: pass |
簡單作答:如果不知道GLOBAL和LOCAL的內容,你不可能確定open
的值。概念上,python查詢名稱時會檢查3個名稱空間(簡單起見忽略巢狀作用域):
- 區域性名稱空間
- 全域性名稱空間
- 內建名稱空間
所以在myfunc
函式中,如果嘗試查詢open
的值時,我們首先會檢查本地名稱空間,然後是全域性名稱空間,接著內建名稱空間。如果在這3個名稱空間中都找不到open
的定義,就會引發NameError
異常。
作用域查詢的實現
上面的查詢過程只是概念上的。這個查詢過程的實現給予了我們探索實現的空間。
1 2 3 4 5 6 7 8 9 |
def foo(): a = 1 return a def bar(): return a def baz(a=1): return a |
我們看下每個函式的位元組碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
>>> import dis >>> dis.dis(foo) 2 0 LOAD_CONST 1 (1) 3 STORE_FAST 0 (a) 3 6 LOAD_FAST 0 (a) 9 RETURN_VALUE >>> dis.dis(bar) 2 0 LOAD_GLOBAL 0 (a) 3 RETURN_VALUE >>> dis.dis(baz) 2 0 LOAD_FAST 0 (a) 3 RETURN_VALUE |
注意foo和bar的區別。我們立即就可以看到,在位元組碼層面,python已經判斷了什麼是區域性變數、什麼不是,因為foo
使用LOAD_FAST
,而bar
使用LOAD_GLOBAL
。
我們不會具體闡述python的編譯器如何知道何時生成何種位元組碼(也許那是另一篇文章的範疇了),但足以理解,python在執行函式時已經知道進行何種型別的查詢。
另一個容易混淆的是,LOAD_GLOBAL
既可以用於全域性,也可以用於內建名稱空間的查詢。忽略巢狀作用域的問題,你可以認為這是“非區域性的”。對應的C程式碼大概是[1]:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
case LOAD_GLOBAL: v = PyObject_GetItem(f->f_globals, name); if (v == NULL) { v = PyObject_GetItem(f->f_builtins, name); if (v == NULL) { if (PyErr_ExceptionMatches(PyExc_KeyError)) format_exc_check_arg( PyExc_NameError, NAME_ERROR_MSG, name); goto error; } } PUSH(v); |
即使你從來沒有看過CPython的C程式碼,上面的程式碼已經相當直白了。首先,檢查我們查詢的鍵名是否在f->f_globals
(全域性字典)中,然後檢查名稱是否在f->f_builtins
(內建字典)中,最後,如果上面兩個位置都沒找到,就會丟擲NameError
異常。
將常量繫結到區域性作用域
現在我們再看最開始的程式碼例子,就會理解最後一個引數其實是將一個函式繫結到區域性作用域中的一個函式上。具體是通過將dict.__setitem__
賦值為引數的預設值。這裡還有另一個例子:
1 2 3 4 5 |
def not_list_or_dict(value): return not (isinstance(value, dict) or isinstance(value, list)) def not_list_or_dict(value, _isinstance=isinstance, _dict=dict, _list=list): return not (_isinstance(value, _dict) or _isinstance(value, _list)) |
這裡我們做同樣的事情,把本來將會在內建名稱空間中的物件繫結到區域性作用域中去。因此,python將會使用LOCAL_FAST
而不是LOAD_GLOBAL
(全域性查詢)。那麼這到底有多快呢?我們做個簡單的測試:
1 2 3 4 |
$ python -m timeit -s 'def not_list_or_dict(value): return not (isinstance(value, dict) or isinstance(value, list))' 'not_list_or_dict(50)' 1000000 loops, best of 3: 0.48 usec per loop $ python -m timeit -s 'def not_list_or_dict(value, _isinstance=isinstance, _dict=dict, _list=list): return not (_isinstance(value, _dict) or _isinstance(value, _list))' 'not_list_or_dict(50)' 1000000 loops, best of 3: 0.423 usec per loop |
換句話說,大概有11.9%的提升 [2]。比我在文章開始處承諾的5%還多!
還有更多內涵
可以合理地認為,速度提升在於LOAD_FAST
讀取區域性作用域,而LOAD_GLOBAL
在檢查內建作用域之前會先首先檢查全域性作用域。上面那個示例函式中,isinstance
、dict
、list
都位於內建名稱空間。
但是,還有更多。我們不僅可以使用LOAD_FAST
跳過多餘的查詢,它也是一種不同型別的查詢。
上面C程式碼片段給出了LOAD_GLOBAL
的程式碼,下面是LOAD_FAST
的:
1 2 3 4 5 6 7 8 9 10 11 |
case LOAD_FAST: PyObject *value = fastlocal[oparg]; if (value == NULL) { format_exc_check_arg(PyExc_UnboundLocalError, UNBOUNDLOCAL_ERROR_MSG, PyTuple_GetItem(co->co_varnames, oparg)); goto error; } Py_INCREF(value); PUSH(value); FAST_DISPATCH() |
我們通過索引一個陣列獲取區域性值。雖然沒有直接出現,但是oparg
只是那個陣列的一個索引。
現在聽起來才合理。我們第一個版本的not_list_or_dict
要進行4個查詢,每個名稱都位於內建名稱空間,它們只有在查詢全域性名稱空間之後才會查詢。這就是8個字典鍵的查詢操作了。相比之下,not_list_or_dict
的第二版中,直接索引C陣列4次,底層全部使用LOAD_FAST
。這就是為什麼區域性查詢更快的原因。
總結
現在當下次你在其他人程式碼中看到這種例子,就會明白了。
最後,除非確實需要,請不要在具體應用中進行這類優化。而且大部分時間你都沒必要做。但是如果時候到了,你需要擠出最後一點效能,就需要搞懂這點。
腳註
[1]注意,為了更易讀,上面的程式碼中我去掉了一些效能優化。真正的程式碼稍微有點複雜。
[2]示例函式事實上沒有做什麼有價值的東西,也沒進行IO操作,大部分是受python VM迴圈的限制。