Python 模式 - 一個最佳化軼事
警告
此頁面出於歷史原因保留在此處,可能包含過時或不正確的資訊。
我想到的第一個版本非常簡單
def f1(list): string = "" for item in list: string = string + chr(item) return string
我朋友說,這不可能是最快的方法。試試這個怎麼樣
def f2(list): return reduce(lambda string, item: string + chr(item), list, "")
此版本執行與第一個版本完全相同的一組字串操作,但擺脫了 for 迴圈的開銷,轉而使用 reduce() 函式的更快、隱式迴圈。
當然,我回答說,但這樣做是以每個列表項的函式呼叫(lambda 函式)為代價的。我敢打賭它會更慢,因為 Python 中的函式呼叫開銷比 for 迴圈開銷更大。
(好吧,我已經做過比較了。f2() 比 f1() 花費了 60% 的時間。就這麼回事 :-)
嗯,我朋友說。我需要更快。好吧,我說,這個版本怎麼樣
def f3(list): string = "" for character in map(chr, list): string = string + character return string
令我們驚訝的是,f3() 的速度是 f1() 的兩倍!令我們驚訝的原因有兩方面:首先,它使用了更多的儲存空間(map(chr, list) 的結果是另一個相同長度的列表);其次,它包含兩個迴圈而不是一個迴圈(map() 函式隱含的迴圈,以及 for 迴圈)。
當然,空間與時間是眾所周知的權衡,所以第一個不應該讓我們感到驚訝。但是,為什麼兩個迴圈比一個迴圈快?有兩個原因。
首先,在 f1() 中,內建函式 chr() 在每次迭代中都會被查詢,而在 f3() 中,它只查詢一次(作為 map() 的引數)。我告訴我的朋友,這個查詢相對昂貴,因為 Python 的動態作用域規則意味著它首先在當前模組的全域性字典中(不成功地)查詢,然後在內建函式字典中查詢(在那裡找到)。更糟糕的是,由於雜湊連結的工作方式,不成功的字典查詢(平均而言)比成功的查詢要慢一些。
f3() 比 f1() 快的原因之二是,由位元組碼直譯器執行的 chr(item) 呼叫可能比 map() 函式執行的要慢一些 - 位元組碼直譯器必須為每個呼叫執行三個位元組碼指令(載入“chr”,載入“item”,呼叫),而 map() 函式則全部在 C 中完成。
這使我們考慮一種折衷方案,這種方案不會浪費額外的空間,但會加快對 chr() 函式的查詢
def f4(list): string = "" lchr = chr for item in list: string = string + lchr(item) return string
正如預期的那樣,f4() 比 f3() 慢,但只慢了 25%;它仍然比 f1() 快 40% 左右。這是因為區域性變數查詢比全域性或內建變數查詢快得多:Python“編譯器”優化了大多數函式體,因此對於區域性變數,不需要字典查詢,簡單的陣列索引操作就足夠了。f4() 相對於 f1() 和 f3() 的相對速度表明,f3() 更快的原因都有貢獻,但第一個原因(更少的查詢)更重要一些。(要獲得更精確的資料,我們必須對直譯器進行檢測。)
儘管如此,我們最好的版本 f3() 也只比最直接的版本 f1() 快兩倍。我們能做得更好嗎?
我擔心該演算法的二次行為正在扼殺我們。到目前為止,我們一直在使用 256 個整數的列表作為測試資料,因為這是我的朋友需要該函式的原因。但是,如果它應用於一個包含兩千個字元的列表呢?我們將一次一個字元地連線越來越長的字串。很容易看出,除了開銷外,要以這種方式建立長度為 N 的列表,總共需要複製 1 + 2 + 3 + ... + (N-1) 個字元,即 N*(N-1)/2,或 0.5*N**2 - 0.5*N。除此之外,還有 N 個字串分配操作,但對於足夠大的 N,包含 N**2 的項將佔據主導地位。事實上,對於長度是 8 倍(2048 個項)的列表,這些函式花費的時間都遠遠超過 8 倍;事實上,接近 16 倍。我不敢嘗試長度是 64 倍的列表。
有一種通用的技術可以避免此類演算法中的二次行為。我為精確的 256 項的字串編碼如下
def f5(list): string = "" for i in range(0, 256, 16): # 0, 16, 32, 48, 64, ... s = "" for character in map(chr, list[i:i+16]): s = s + character string = string + s return string
不幸的是,對於包含 256 項的列表,此版本的執行速度比 f3() 稍慢(儘管在 20% 以內)。由於編寫通用版本只會使其速度更慢,因此我們沒有費心進一步探索這條路(除了我們還將其與一個不使用 map() 的變體進行了比較,當然,該變體再次變慢了)。
最後,我嘗試了一種完全不同的方法:僅使用隱式迴圈。請注意,整個操作可以描述如下:將 chr() 應用於每個列表項;然後連線生成的字元。我們已經為第一部分使用了隱式迴圈:map()。幸運的是,字串模組中有一些用 C 實現的字串連線函式。特別是,string.joinfields(list_of_strings, delimiter) 連線一個字串列表,在每兩個字串之間放置一個選擇的分隔符。沒有任何東西阻止我們使用空字串作為分隔符來連線字元列表(在 Python 中只是長度為 1 的字串)。瞧
import string def f6(list): return string.joinfields(map(chr, list), "")
此函式的執行速度比我們最快的競爭對手 f3() 快了四到五倍。此外,它沒有其他版本的二次行為。
獲勝者是...
第二天,我想起了 Python 的一個奇怪角落:array 模組。它恰好具有一個操作,可以從 Python 整數列表建立一個 1 位元組寬的整數陣列,並且每個陣列都可以作為二進位制資料結構寫入檔案或轉換為字串。這是使用這些操作實現的函式
import array def f7(list): return array.array('B', list).tostring()
這比 f6() 快大約三倍,或者比 f3() 快 12 到 15 倍!它還使用更少的中間儲存 - 它只分配 N 位元組的 2 個物件(加上固定的開銷),而 f6() 首先分配一個包含 N 項的列表,通常花費 4N 位元組(在 64 位計算機上為 8N 位元組) - 假設字元物件與程式中其他地方的類似物件共享(如小整數,在大多數情況下,Python 會快取長度為 1 的字串)。
停!我的朋友說,在你進入負時間之前 - 這對我的程式來說已經足夠快了。我同意了,儘管我想嘗試另一種方法:用 C 編寫整個函式。這可以最大限度地減少儲存需求(它會立即分配一個長度為 N 的字串),並在我知道 array 模組中存在的 C 程式碼中節省一些指令,因為它具有通用性(它支援 1、2 和 4 位元組的整數寬度)。但是,它無法避免必須一次從列表中提取項,並從中提取 C 整數,這在 Python-C API 中都是相當昂貴的操作,因此我預計與 f7() 相比,速度最多隻能適度提高。考慮到編寫和測試擴充套件的麻煩(與編寫那些 Python 單行程式碼相比),以及對非標準 Python 擴充套件的依賴,我決定不繼續使用此選項...
結論
如果您需要速度,請使用內建函式 - 您無法擊敗用 C 編寫的迴圈。請檢視庫手冊中是否有執行您想要操作的內建函式。如果沒有,以下是一些迴圈最佳化指南
- 第一條規則:僅在有經過驗證的速度瓶頸時才進行最佳化。僅最佳化最內層的迴圈。(此規則與 Python 無關,但重複一下不會有壞處,因為它可以節省大量工作。:-)
- 小即是美。考慮到 Python 位元組碼指令和變數查詢的開銷很大,為了節省少量工作而新增額外的測試通常是不值得的。
- 使用內建操作。map() 中的隱式迴圈比顯式的 for 迴圈更快;而帶有顯式迴圈計數器的 while 迴圈則更慢。
- 避免在內部迴圈中呼叫用 Python 編寫的函式。這包括 lambda 函式。將內部迴圈內聯可以節省大量時間。
- 區域性變數比全域性變數快;如果在迴圈中使用全域性常量,請在迴圈之前將其複製到區域性變數中。在 Python 中,函式名(全域性或內建)也是全域性常量!
- 儘量使用 map()、filter() 或 reduce() 來替換顯式的 for 迴圈,但前提是你可以使用內建函式:使用內建函式的 map 比 for 迴圈快,但是帶有內聯程式碼的 for 迴圈比帶有 lambda 函式的 map 快!
- 檢查你的演算法是否存在二次行為。但請注意,更復雜的演算法只有在 N 很大時才值得。對於小的 N,其複雜性並不划算。在我們的例子中,256 被認為足夠小,以至於更簡單的版本仍然稍微快一點。你的結果可能會有所不同,這值得研究。
- 最後但並非最不重要的是:收集資料。Python 優秀的 profile 模組可以快速顯示程式碼中的瓶頸。如果你正在考慮演算法的不同版本,請使用 time.clock() 函式在一個緊密迴圈中測試它。
順便說一句,這是我使用的計時函式。它使用引數 a 呼叫函式 f n*10 次,並列印函式名稱以及所花費的時間(四捨五入到毫秒)。進行 10 次重複呼叫是為了儘量減少計時函式本身的迴圈開銷。你可以更進一步進行 100 次呼叫... 還要注意,表示式 range(n) 是在計時括號之外計算的——這是另一個最小化計時函式引起的開銷的技巧。如果你擔心這種開銷,可以透過使用一個什麼都不做的函式呼叫計時函式來進行校準。
import time def timing(f, n, a): print f.__name__, r = range(n) t1 = time.clock() for i in r: f(a); f(a); f(a); f(a); f(a); f(a); f(a); f(a); f(a); f(a) t2 = time.clock() print round(t2-t1, 3)
尾聲
幾天後,我的朋友又來問我:如何進行反向操作?即,從字串建立一個整數 ASCII 值的列表。哦不,又要開始了,我腦海裡閃過這個念頭...
但這一次,相對來說沒那麼痛苦。這裡有兩個選擇,顯而易見的
def g1(string): return map(ord, string)
以及稍微不太明顯的
import array def g2(string): return array.array('b', string).tolist()
對它們進行計時後發現,g2() 比 g1() 快大約五倍。但這裡有一個問題:g2() 返回 -128..127 範圍內的整數,而 g1() 返回 0..255 範圍內的整數。如果你需要正整數,那麼 g1() 將比你對 g2() 的結果進行任何後處理都要快。(注意:自從本文撰寫以來,array 模組中添加了“B”型別程式碼,該程式碼儲存無符號位元組,因此不再有理由優先選擇 g1() 了。)