了解最新公司動(dòng)態(tài)及行業(yè)資訊
花下貓語:性能優(yōu)化是每個(gè)程序員的必修課,但你是否想過,除了更換算法,還有哪些“大招”?這篇文章堪稱典范,它將一個(gè)普通的函數(shù),通過四套組合拳,硬生生把性能提升了 330 倍!作者不僅展示了“術(shù)”,更傳授了“道”。讓我們一起跟隨作者的思路,體驗(yàn)一次酣暢淋漓的優(yōu)化之旅。
PS.本文選自最新一期Python 潮流周刊,如果你對(duì)優(yōu)質(zhì)文章感興趣,誠心推薦你訂閱我們的專欄。
作者:Itamar Turner-Trauring
譯者:豌豆花下貓@Python貓
英文:330× faster: Four different ways to speed up your code
聲明:本翻譯是出于交流學(xué)習(xí)的目的,為便于閱讀,部分內(nèi)容略有改動(dòng)。轉(zhuǎn)載請(qǐng)保留作者信息。
溫馨提示: 本文原始版本與當(dāng)前略有不同,比如曾經(jīng)提到過500倍加速;本文已根據(jù)實(shí)際情況重新梳理,使論證更清晰。
當(dāng)你的 Python 代碼慢如蝸牛,而你渴望它快如閃電時(shí),其實(shí)有很多種提速方式,從并行化到編譯擴(kuò)展應(yīng)有盡有。如果只盯著一種方法,往往會(huì)錯(cuò)失良機(jī),最終的代碼也難以達(dá)到極致性能。
為了不錯(cuò)過任何潛在的提速機(jī)會(huì),我們可以從“實(shí)踐”的角度來思考。每種實(shí)踐:
以獨(dú)特方式加速你的代碼涉及不同的技能和知識(shí)可以單獨(dú)應(yīng)用也可以組合應(yīng)用,獲得更大提升為了讓這一點(diǎn)更具體,本文將通過一個(gè)案例演示多種實(shí)踐的應(yīng)用,具體包括:
效率(Efficiency): 消除浪費(fèi)或重復(fù)的計(jì)算。編譯(Compilation): 利用編譯型語言,并巧妙繞開編譯器限制。并行化(Parallelism): 充分發(fā)揮多核CPU的威力。流程(Process): 采用能產(chǎn)出更快代碼的開發(fā)流程。我們將看到:
僅用效率實(shí)踐,就能帶來近 2倍 提速。僅用編譯實(shí)踐,可實(shí)現(xiàn) 10倍 提速。兩者結(jié)合,速度更上一層樓。最后加上并行化實(shí)踐,最終實(shí)現(xiàn) 330倍 驚人加速。我們有一本英文書,簡·奧斯汀的《諾桑覺寺》:
with open("northanger_abbey.txt") as f: TEXT = f.read()我們的目標(biāo)是分析書中字母的相對(duì)頻率。元音比輔音更常見嗎?哪個(gè)元音最常見?
下面是最初的實(shí)現(xiàn):
from collections import defaultdict def frequency_1(text): # 一個(gè)當(dāng)鍵不存在時(shí)默認(rèn)值為0的字典 counts = defaultdict(lambda: 0) for character in text: if character.isalpha(): counts[character.lower()] += 1 return counts運(yùn)行結(jié)果如下:
sorted( (count, letter) for (letter, count) in frequency_1(TEXT).items() ) [(1, à), (2, é), (3, ê), (111, z), (419, q), (471, j), (561, x), (2016, k), (3530, v), (5297, b), (5404, p), (6606, g), (7639, w), (7746, f), (7806, y), (8106, c), (8628, m), (9690, u), (13431, l), (14164, d), (20675, s), (21107, r), (21474, h), (22862, i), (24670, n), (26385, a), (26412, o), (30003, t), (44251, e)]毫無意外,出現(xiàn)頻率最高的字母是 "e"。
那我們?nèi)绾巫屵@個(gè)函數(shù)更快?
軟件開發(fā)不僅依賴于源代碼、庫、解釋器、編譯器這些“產(chǎn)物”,更離不開你的工作“流程”——也就是你做事的方法。性能優(yōu)化同樣如此。本文將介紹兩種在優(yōu)化過程中必不可少的流程實(shí)踐:
通過基準(zhǔn)測試和性能分析來測量代碼速度。測試優(yōu)化后的代碼,確保其行為與原始版本一致。我們可以先用 line_profiler 工具分析函數(shù),找出最耗時(shí)的代碼行:
Line # Hits % Time Line Contents ======================================== 3 def frequency_1(text): 4 # 一個(gè)當(dāng)鍵不存在時(shí)默認(rèn)值為0的字典 5 # available: 6 1 0.0 counts = defaultdict(lambda: 0) 7 433070 30.4 for character in text: 8 433069 27.3 if character.isalpha(): 9 339470 42.2 counts[character.lower()] += 1 10 1 0.0 return counts效率實(shí)踐的核心,是用更少的工作量獲得同樣的結(jié)果。這類優(yōu)化通常在較高的抽象層面進(jìn)行,無需關(guān)心底層CPU細(xì)節(jié),因此適用于大多數(shù)編程語言。其本質(zhì)是通過改變計(jì)算邏輯來減少浪費(fèi)。
從上面的性能分析可以看出,函數(shù)大部分時(shí)間都花在 counts[character.lower()] += 1 這行。顯然,對(duì)每個(gè)字母都調(diào)用 character.lower() 是種浪費(fèi)。我們一遍遍地把 "I" 轉(zhuǎn)成 "i",甚至還把 "i" 轉(zhuǎn)成 "i"。
優(yōu)化思路:我們可以先分別統(tǒng)計(jì)大寫和小寫字母的數(shù)量,最后再合并,而不是每次都做小寫轉(zhuǎn)換。
def frequency_2(text): split_counts = defaultdict(lambda: 0) for character in text: if character.isalpha(): split_counts[character] += 1 counts = defaultdict(lambda: 0) for character, num in split_counts.items(): counts[character.lower()] += num return counts # 確保新函數(shù)結(jié)果與舊函數(shù)完全一致 assert frequency_1(TEXT) == frequency_2(TEXT)說明:這里的 assert 就是流程實(shí)踐的一部分。一個(gè)更快但結(jié)果錯(cuò)誤的函數(shù)毫無意義。雖然你在最終文章里看不到這些斷言,但它們在開發(fā)時(shí)幫我抓出了不少bug。
基準(zhǔn)測試(也是流程實(shí)踐的一環(huán))顯示,這個(gè)優(yōu)化確實(shí)讓代碼更快了:
| frequency_1(TEXT) | 34,592.5 μs | | frequency_2(TEXT) | 25,798.6 μs |
我們繼續(xù)用效率實(shí)踐,這次針對(duì)具體目標(biāo)和數(shù)據(jù)進(jìn)一步優(yōu)化。來看下最新代碼的性能分析:
Line # Hits % Time Line Contents ======================================== 3 def frequency_2(text): 4 1 0.0 split_counts = defaultdict(lambda: 0) 5 433070 33.6 for character in text: 6 433069 32.7 if character.isalpha(): 7 339470 33.7 split_counts[character] += 1 8 9 1 0.0 counts = defaultdict(lambda: 0) 10 53 0.0 for character, num in split_counts.items(): 11 52 0.0 counts[character.lower()] += num 12 1 0.0 return counts可以看到,split_counts[character] += 1 依然是耗時(shí)大戶。怎么加速?答案是用 list 替換 defaultdict(本質(zhì)上是 dict)。list 的索引速度遠(yuǎn)快于 dict:
list 存儲(chǔ)條目只需一次數(shù)組索引dict 需要計(jì)算哈希、可能多次比較,還要內(nèi)部數(shù)組索引但 list 的索引必須是整數(shù),不能像 dict 那樣用字符串,所以我們要把字符轉(zhuǎn)成數(shù)字。幸運(yùn)的是,每個(gè)字符都能用 ord() 查到數(shù)值:
ord(a), ord(z), ord(A), ord(Z) # (97, 122, 65, 90)用 chr() 還能把數(shù)值轉(zhuǎn)回字符:
chr(97), chr(122) # (a, z)所以可以用 my_list[ord(character)] += 1 計(jì)數(shù)。但前提是我們得提前知道 list 的大小。如果處理任意字母字符,list 可能會(huì)很大:
ideograph = ord(ideograph), ideograph.isalpha() # (178057, True)再回顧下我們的目標(biāo):
處理對(duì)象是英文文本,這是題目要求。輸出結(jié)果里確實(shí)有少量非標(biāo)準(zhǔn)英文字母(如 à),但極其罕見。(嚴(yán)格說 à 應(yīng)該歸為 a,但這里偷懶沒做……)我們只關(guān)心相對(duì)頻率,不是絕對(duì)精確計(jì)數(shù)。基于這些,我決定簡化問題:只統(tǒng)計(jì) A 到 Z,其他字符都忽略,包括帶重音的。對(duì)英文文本來說,這幾乎不影響字母相對(duì)頻率。
這樣問題就簡單了:字符集有限且已知,可以放心用 list 替代 dict!
優(yōu)化后實(shí)現(xiàn)如下:
def frequency_3(text): # 創(chuàng)建長度為128的零列表;ord(z)是122,128足夠了 split_counts = [0] * 128 for character in text: index = ord(character) if index < 128: split_counts[index] += 1 counts = {} for letter in abcdefghijklmnopqrstuvwxyz: counts[letter] = ( split_counts[ord(letter)] + split_counts[ord(letter.upper())] ) return counts由于輸出只包含A到Z,正確性檢查也要稍作調(diào)整:
def assert_matches(counts1, counts2): """確保A到Z的計(jì)數(shù)匹配""" for character in abcdefghijklmnopqrstuvwxyz: assert counts1[character] == counts2[character] assert_matches( frequency_1(TEXT), frequency_3(TEXT) )新實(shí)現(xiàn)更快了:
| frequency_2(TEXT) | 25,965.5 μs | | frequency_3(TEXT) | 19,443.5 μs |
接下來我們切換到編譯型語言——Rust。
其實(shí)可以直接把 frequency_1() 移植到 Rust,編譯器會(huì)自動(dòng)做一些在 Python 里需要手動(dòng)優(yōu)化的事。
但大多數(shù)時(shí)候,無論用什么語言,效率實(shí)踐都得靠你自己。這也是為什么“效率”和“編譯”是兩種不同的實(shí)踐:它們帶來的性能提升來源不同。我們在 frequency_2() 和 frequency_3() 里做的優(yōu)化,同樣能讓 Rust 代碼更快。
為證明這一點(diǎn),我把上面三個(gè) Python 函數(shù)都移植到了 Rust(前兩個(gè)源碼可點(diǎn)擊展開查看):
前兩個(gè)版本在 Rust 中的實(shí)現(xiàn)
#[pyfunction] fn frequency_1_rust( text: &str, ) -> PyResult<HashMap<char, u32>> { let mut counts = HashMap::new(); for character in text.chars() { if character.is_alphabetic() { *counts .entry( character .to_lowercase() .next() .unwrap_or(character), ) .or_default() += 1; } } Ok(counts) } #[pyfunction] fn frequency_2_rust( text: &str, ) -> PyResult<HashMap<char, u32>> { let mut split_counts: HashMap<char, u32> = HashMap::new(); for character in text.chars() { if character.is_alphabetic() { *split_counts.entry(character).or_default() += 1; } } let mut counts = HashMap::new(); for (character, num) in split_counts.drain() { *counts .entry( character .to_lowercase() .next() .unwrap_or(character), ) .or_default() += num; } Ok(counts) }第三個(gè)版本在 Rust 里的樣子:
fn ascii_arr_to_letter_map( split_counts: [u32; 128], ) -> HashMap<char, u32> { let mut counts: HashMap<char, u32> = HashMap::new(); for index in (a as usize)..=(z as usize) { let character = char::from_u32(index as u32).unwrap(); let upper_index = character.to_ascii_uppercase() as usize; counts.insert( character, split_counts[index] + split_counts[upper_index], ); } counts } #[pyfunction] fn frequency_3_rust(text: &str) -> HashMap<char, u32> { let mut split_counts = [0u32; 128]; for character in text.chars() { let character = character as usize; if character < 128 { split_counts[character] += 1; } } ascii_arr_to_letter_map(split_counts) }所有三個(gè) Rust 版本的結(jié)果都和 Python 版本一致:
assert_matches(frequency_1(TEXT), frequency_1_rust(TEXT)) assert_matches(frequency_1(TEXT), frequency_2_rust(TEXT)) assert_matches(frequency_1(TEXT), frequency_3_rust(TEXT))對(duì)所有6個(gè)版本做基準(zhǔn)測試,清楚地說明了效率實(shí)踐和編譯實(shí)踐的性能優(yōu)勢是不同且互補(bǔ)的。能加速 Python 代碼的效率優(yōu)化,同樣也能加速 Rust 代碼。
函數(shù) 運(yùn)行時(shí)間 (μs) frequency_1(TEXT) 33,741.5 frequency_2(TEXT) 25,797.4 frequency_3(TEXT) 19,432.0 frequency_1_rust(TEXT) 3,704.3 frequency_2_rust(TEXT) 3,504.8 frequency_3_rust(TEXT) 204.9
一句話:效率和編譯是兩種不同的速度來源。
到目前為止,代碼都只跑在單核CPU上。但現(xiàn)在的電腦大多有多核,利用并行計(jì)算又是另一種速度來源,所以它也是獨(dú)立的實(shí)踐。
下面是用 Rayon 庫 實(shí)現(xiàn)的 Rust 并行版本:
fn sum(mut a: [u32; 128], b: [u32; 128]) -> [u32; 128] { for i in 0..128 { a[i] += b[i]; } a } #[pyfunction] fn frequency_parallel_rust( py: Python<_>, text: &str, ) -> HashMap<char, u32> { use rayon::prelude::*; // 確保釋放全局解釋器鎖(GIL) let split_counts = py.allow_threads(|| { // 一個(gè)榨取 Rayon 更多性能的技巧: // 我們關(guān)心的 ASCII 字符總是由單個(gè)字節(jié)明確表示。 // 所以直接處理字節(jié)是安全的,這能讓我們強(qiáng)制 Rayon 使用數(shù)據(jù)塊。 text.as_bytes() // 并行迭代數(shù)據(jù)塊 .par_chunks(8192) .fold_with( [0u32; 128], |mut split_counts, characters| { for character in characters { if *character < 128 { split_counts [*character as usize] += 1; }; } split_counts }, ) // 合并所有數(shù)據(jù)塊的結(jié)果 .reduce(|| [0u32; 128], sum) }); ascii_arr_to_letter_map(split_counts) }結(jié)果依然正確:
assert_matches(frequency_1(TEXT), frequency_parallel_rust(TEXT))加速效果如下:
| frequency_3_rust(TEXT) | 234.5 μs | | frequency_parallel_rust(TEXT) | 105.3 μs |
最終函數(shù)快了330倍……真的嗎?
我們是通過多次調(diào)用函數(shù)取平均運(yùn)行時(shí)間來測量性能的。但我恰好知道一些背景知識(shí):
Rust 字符串是 UTF-8,Python 用的是自己的內(nèi)部格式,不是 UTF-8。所以調(diào)用 Rust 函數(shù)時(shí),Python 需要把字符串轉(zhuǎn)成 UTF-8。Python 用特定 API 轉(zhuǎn) UTF-8 時(shí)會(huì)緩存轉(zhuǎn)換結(jié)果。這意味著,我們很可能沒測到 UTF-8 轉(zhuǎn)換的成本,因?yàn)榉磸?fù)對(duì)同一個(gè) TEXT 字符串基準(zhǔn)測試,第一次后 UTF-8 版本就被緩存了。真實(shí)場景下,未必總有緩存。
我們可以測下單次調(diào)用新字符串的耗時(shí)。我用非并行版本,因?yàn)樗俣雀€(wěn)定:
from time import time def timeit(f, *args): start = time() f(*args) print("Elapsed:", int((time() - start) * 1_000_000), "μs") print("Original text") timeit(frequency_3_rust, TEXT) timeit(frequency_3_rust, TEXT) print() for i in range(3): # 新字符串 s = TEXT + str(i) print("New text", i + 1) timeit(frequency_3_rust, s) timeit(frequency_3_rust, s) print() Original text Elapsed: 212 μs Elapsed: 206 μs New text 1 Elapsed: 769 μs Elapsed: 207 μs New text 2 Elapsed: 599 μs Elapsed: 202 μs New text 3 Elapsed: 625 μs Elapsed: 200 μs對(duì)于新字符串,第一次運(yùn)行比第二次慢了大約 400μs,這很可能就是轉(zhuǎn)換為 UTF-8 的成本。
當(dāng)然,我們加載的書本身就是 UTF-8 格式。所以,我們可以改變 API,直接將 UTF-8 編碼的 bytes 傳遞給 Rust 代碼,而不是先加載到 Python(轉(zhuǎn)換為 Python 字符串),再傳遞給 Rust(轉(zhuǎn)換回 UTF-8),這樣就能避免轉(zhuǎn)換開銷。
我實(shí)現(xiàn)了一個(gè)新函數(shù) frequency_3_rust_bytes(),它接受 UTF-8 編碼的字節(jié)(源碼略,與 frequency_3_rust() 基本一樣)。然后測了下單個(gè)字節(jié)串第一次和第二次的時(shí)間:
with open("northanger_abbey.txt", "rb") as f: TEXT_BYTES = f.read() assert_matches( frequency_1(TEXT), frequency_3_rust_bytes(TEXT_BYTES) ) print("新文本不再有~400μs的轉(zhuǎn)換開銷:") new_text = TEXT_BYTES + b"!" timeit(frequency_3_rust_bytes, new_text) timeit(frequency_3_rust_bytes, new_text) 新文本不再有~400μs的轉(zhuǎn)換開銷: Elapsed: 186 μs Elapsed: 182 μs如果我們測量持續(xù)的平均時(shí)間,可以看到它與之前的版本大致相當(dāng):
| frequency_3_rust(TEXT) | 227.2 μs | | frequency_3_rust_bytes(TEXT_BYTES) | 183.8 μs |
可見傳入 bytes 確實(shí)能繞過 UTF-8 轉(zhuǎn)換成本。你可能還想實(shí)現(xiàn)
frequency_parallel_rust_bytes(),這樣并行也能無轉(zhuǎn)換開銷。你可能會(huì)問,Python 標(biāo)準(zhǔn)庫里不是有現(xiàn)成的 collections.Counter 嗎?它是專門計(jì)數(shù)的 dict 子類。
# 來自 Python 3.13 的 collections/__init__.py def _count_elements(mapping, iterable): Tally elements from the iterable. mapping_get = mapping.get for elem in iterable: mapping[elem] = mapping_get(elem, 0) + 1 try: # 如果可用,加載 C 語言實(shí)現(xiàn)的輔助函數(shù) from _collections import _count_elements except ImportError: pass class Counter(dict): # ...我們可以這樣使用它:
from collections import Counter def frequency_counter(text): return Counter(c.lower() for c in text if c.isalpha()) # 注意:這里的實(shí)現(xiàn)與原文略有不同,是為了與 frequency_1 保持完全一致的行為 # 原文的 Counter(text.lower()) 會(huì)統(tǒng)計(jì)非字母字符,導(dǎo)致結(jié)果不一致 assert_matches(frequency_1(TEXT), frequency_counter(TEXT))這個(gè)實(shí)現(xiàn)比我們的第一個(gè)版本更簡潔,但性能如何?
| frequency_1(TEXT) | 34,592.5 μs | | frequency_counter(TEXT) | 約 30,000 μs |
Counter 確實(shí)比我們的初始實(shí)現(xiàn)快點(diǎn),但遠(yuǎn)不如最終優(yōu)化版。這說明:即使標(biāo)準(zhǔn)庫的優(yōu)化實(shí)現(xiàn),也可能比不上針對(duì)場景深度優(yōu)化的代碼。
當(dāng)然,Counter 勝在簡潔和可讀性。很多對(duì)性能沒極致要求的場景,這種權(quán)衡完全值得。
全文其實(shí)一直在用“流程”實(shí)踐:測試新版本正確性、做性能分析和測量?;鶞?zhǔn)測試還幫我排除了不少無效優(yōu)化,這里就不贅述了。
“效率”實(shí)踐幫我們消除無用功,“編譯”讓代碼更快,“并行化”則讓多核CPU火力全開。每種實(shí)踐都是獨(dú)特的、能帶來乘數(shù)效應(yīng)的速度來源。
一句話:如果你想讓代碼更快,別只盯著一種實(shí)踐,多管齊下,速度才會(huì)飛起來!
Python貓注:如果你喜歡這篇文章,那我要向你推薦一下 Python 潮流周刊!創(chuàng)刊僅兩年,我們已堅(jiān)持分享了超過 1300+ 篇優(yōu)質(zhì)文章,以及 1200+ 個(gè)開源項(xiàng)目或工具資源,每周精選,助力你打破信息差,告別信息過載,成為更優(yōu)秀的人!
24小時(shí)免費(fèi)咨詢
請(qǐng)輸入您的聯(lián)系電話,座機(jī)請(qǐng)加區(qū)號(hào)