Python循環(huán)優(yōu)化指南:從10秒到0.1秒的性能調(diào)優(yōu)
小李是個剛?cè)胄械臄?shù)據(jù)分析師,今天接了個活兒——處理一份三百萬行的用戶行為日志。他的代碼寫得很清爽,一個for循環(huán)套著幾個if判斷,逐行讀取、逐行處理、逐行寫入。邏輯沒問題,結(jié)果也正確,就是跑起來有點慢。
他泡了杯咖啡,代碼開始跑。咖啡喝完了,進度條才走了5%。他算了算,按這個速度,跑完要將近四十分鐘。這還只是一天的數(shù)據(jù),明天還有新的,后天也有。小李盯著屏幕,陷入了沉思——代碼邏輯沒錯,但就是慢,這該怎么辦?
這個故事在程序員圈子里每天都在上演。Python寫起來快,跑起來慢,這是共識。尤其是循環(huán),簡直就是Python的性能黑洞。但很多人不知道的是,一個寫得不講究的循環(huán)和經(jīng)過優(yōu)化的循環(huán),性能差距可以達到幾十倍甚至上百倍。
那個跑了10秒的循環(huán)長什么樣
我們先看看小李最初那段代碼大概是什么樣子的。
假設(shè)他要處理三百萬條數(shù)據(jù),每條數(shù)據(jù)是一串用逗號分隔的字符串,包含用戶ID、時間戳、行為類型、金額等字段。他要做的事情是:篩選出金額大于100的交易,把時間戳轉(zhuǎn)換成可讀的日期格式,然后寫入一個新列表。
import time
import random
# 模擬三百萬條數(shù)據(jù)
data = []
for i in range(3000000):
line = f"{i},2024-01-01 12:00:00,click,{random.randint(1, 500)}"
data.append(line)
start = time.time()
result = []
for line in data:
parts = line.split(',')
user_id = parts[0]
timestamp = parts[1]
action = parts[2]
amount = int(parts[3])
if amount > 100:
# 時間戳轉(zhuǎn)換的模擬操作
formatted_time = timestamp.replace('-', '/')
result.append(f"{user_id},{formatted_time},{action},{amount}")
end = time.time()
print(f"耗時: {end - start:.2f}秒")
這段代碼在普通的機器上跑三百萬條數(shù)據(jù),大概需要10秒左右。看起來不算太慢?但想想看,如果數(shù)據(jù)量是三個億,那就是100秒。如果邏輯更復(fù)雜,可能幾百秒。關(guān)鍵是,這種慢是可以被優(yōu)化掉的。
病根在哪里
要治病,得先找到病根。Python循環(huán)慢,有幾個核心原因。
第一層原因是Python本身就是解釋型語言。每一行代碼在執(zhí)行時,解釋器要做大量的工作——解析語法、查找變量、分配內(nèi)存、調(diào)用函數(shù)。循環(huán)體里每多寫一行代碼,這些操作就要重復(fù)執(zhí)行幾百萬次。累加起來,就是肉眼可見的延遲。
第二層原因是屬性查找。在循環(huán)里寫line.split(','),Python每次都要去line這個對象里找split方法在哪里。三百萬次循環(huán),就是三百萬次屬性查找。同樣的道理,parts[0]、parts[1]這些索引訪問,每次也要做類型檢查。
第三層原因是動態(tài)類型。Python的變量沒有類型聲明,每次運行時都要推斷類型。amount = int(parts[3])這行,Python要先確定parts[3]是個字符串,然后調(diào)用整數(shù)轉(zhuǎn)換函數(shù),再檢查轉(zhuǎn)換結(jié)果是不是整數(shù)。這些動態(tài)檢查在三百萬次循環(huán)里,成本相當可觀。
第一刀:減少循環(huán)體里的操作
優(yōu)化的第一條原則是:循環(huán)體里能少做的事,絕不多做。
看看上面那段代碼,user_id、timestamp、action這三個變量,在篩選之后只用了一次,卻每次都定義出來。完全可以在篩選通過之后再取用。
result = []
for line in data:
parts = line.split(',')
amount = int(parts[3])
if amount > 100:
formatted_time = parts[1].replace('-', '/')
result.append(f"{parts[0]},{formatted_time},{parts[2]},{amount}")
這樣改完,三百萬次循環(huán)少做了幾百萬次變量賦值。能快多少?大概能快個1秒左右。這只是個開始。
第二刀:把屬性查找挪到循環(huán)外面
Python里每次寫line.split,解釋器都要去line對象的類里找split這個屬性。有個小技巧可以解決這個問題——在循環(huán)外面把方法賦值給一個局部變量。
result = []
split_method = str.split # 直接把split方法拿出來
int_convert = int
for line in data:
parts = split_method(line, ',')
amount = int_convert(parts[3])
if amount > 100:
formatted_time = parts[1].replace('-', '/')
result.append(f"{parts[0]},{formatted_time},{parts[2]},{amount}")
這樣做的好處是,循環(huán)體內(nèi)不再需要做屬性查找。Python直接拿著已經(jīng)找到的方法去調(diào)用。三百萬次循環(huán)下來,這個改動能省下1到2秒。
第三刀:用列表推導(dǎo)式替代顯式循環(huán)
Python的列表推導(dǎo)式(list comprehension)是用C語言層面實現(xiàn)的,比Python層面的顯式循環(huán)快得多。當你的循環(huán)只是為了構(gòu)建一個新列表時,列表推導(dǎo)式是最佳選擇。
但這里有個問題——我們的循環(huán)里有篩選條件。好消息是,列表推導(dǎo)式也支持條件判斷。
def process_line(line):
parts = line.split(',')
amount = int(parts[3])
if amount > 100:
formatted_time = parts[1].replace('-', '/')
return f"{parts[0]},{formatted_time},{parts[2]},{amount}"
return None
result = [item for item in (process_line(line) for line in data) if item is not None]
這段代碼用了生成器表達式加列表推導(dǎo)式的組合。生成器表達式逐行處理,列表推導(dǎo)式收集非空的結(jié)果。把處理邏輯包進一個函數(shù)里,雖然多了一次函數(shù)調(diào)用,但整體上因為列表推導(dǎo)式的底層優(yōu)化,速度反而會提升。
這樣改下來,原來的10秒能降到6秒左右。
第四刀:用map和filter組合
map和filter也是用C實現(xiàn)的,比Python循環(huán)快。可以把處理流程寫成函數(shù)鏈。
def parse_line(line):
parts = line.split(',')
return (parts[0], parts[1], parts[2], int(parts[3]))
def filter_by_amount(item):
return item[3] > 100
def format_output(item):
formatted_time = item[1].replace('-', '/')
return f"{item[0]},{formatted_time},{item[2]},{item[3]}"
parsed = map(parse_line, data)
filtered = filter(filter_by_amount, parsed)
result = list(map(format_output, filtered))
這種寫法的好處是每個函數(shù)只做一件事,邏輯清晰,而且map和filter的組合在性能上優(yōu)于顯式循環(huán)。跑下來大概能到4秒左右。
第五刀:避免重復(fù)的類型轉(zhuǎn)換
上面幾輪優(yōu)化下來,代碼已經(jīng)快了不少。但仔細觀察,parts[1].replace('-', '/')這一行,每次都在做字符串替換。如果數(shù)據(jù)量足夠大,字符串操作的成本會變得很明顯。
這里有一個小技巧——如果你知道時間戳的格式是固定的,可以用切片拼接的方式來替換,比調(diào)用replace快得多。
formatted_time = parts[1][:4] + '/' + parts[1][5:7] + '/' + parts[1][8:10]
這行代碼看起來很丑,但性能比replace好。因為它不做模式匹配,只是純粹的內(nèi)存操作。三百萬次調(diào)用下來,這個改動又能省下0.5秒。
同樣的思路,字符串拼接也有講究。用f-string已經(jīng)很快了,但如果需要拼的字段特別多,join方法在某些場景下會更穩(wěn)定。
第六刀:用內(nèi)置模塊分擔壓力
有些數(shù)據(jù)處理任務(wù),根本不應(yīng)該在Python循環(huán)里做。Python的內(nèi)置模塊itertools、collections、operator提供了很多高性能的工具。
比如這個場景,如果用itertools.islice配合map,可以避免一次性把所有數(shù)據(jù)加載到內(nèi)存里。如果數(shù)據(jù)量巨大,這比直接用列表更友好。
更激進的方案是換數(shù)據(jù)結(jié)構(gòu)。如果數(shù)據(jù)是結(jié)構(gòu)化的,可以考慮用pandas來處理。pandas的底層是C和NumPy,處理三百萬行數(shù)據(jù)只是眨眼間的事。
import pandas as pd
# 模擬數(shù)據(jù)
df = pd.DataFrame([line.split(',') for line in data], columns=['user_id', 'timestamp', 'action', 'amount'])
df['amount'] = df['amount'].astype(int)
df_filtered = df[df['amount'] > 100]
df_filtered['timestamp'] = df_filtered['timestamp'].str.replace('-', '/')
result = df_filtered.apply(lambda row: f"{row.user_id},{row.timestamp},{row.action},{row.amount}", axis=1).tolist()
這段代碼用pandas處理,三百萬行數(shù)據(jù)大概0.3到0.5秒就能跑完。為什么這么快?因為pandas把循環(huán)推到了C層面,Python只是負責(zé)調(diào)用。
第七刀:把循環(huán)徹底干掉
最后一刀最狠——如果真的需要極致性能,就別在Python層面循環(huán)。
一種做法是把數(shù)據(jù)處理邏輯寫成SQL,讓數(shù)據(jù)庫去處理。數(shù)據(jù)庫的查詢優(yōu)化器比任何手寫的Python循環(huán)都聰明。
另一種做法是用Python的multiprocessing模塊做并行處理。把三百萬條數(shù)據(jù)切成八份,八個進程同時跑,理想情況下耗時能降到原來的八分之一。但要注意,多進程有額外的開銷,數(shù)據(jù)量不夠大的時候反而更慢。
更進階的做法是用numba或者Cython把關(guān)鍵代碼編譯成機器碼。numba用起來很簡單,加一個裝飾器就能讓循環(huán)飛起來。
from numba import jit
@jit(nopython=True)
def process_data(data):
# 注意:numba對Python對象的支持有限,需要把數(shù)據(jù)轉(zhuǎn)換成NumPy數(shù)組
pass
但這條路有一定門檻,不適合所有場景。
小李的最終方案
小李后來沒選最極端的方案。他覺得代碼的可維護性也很重要,不能為了性能把代碼寫成天書。他最終選的是pandas方案——代碼簡潔,邏輯清晰,三百萬行數(shù)據(jù)從原來的10秒降到了0.4秒。
他算了一筆賬。如果每天跑一次,每次省9.6秒,一年下來省了將近一個小時??此撇欢?,但關(guān)鍵是——他的代碼不再需要中途喝咖啡等了。點一下運行,喝口水的時間,結(jié)果就出來了。
他把優(yōu)化前后的代碼都存了下來,在代碼注釋里寫了一句:“慢的版本留著做對比,提醒自己Python循環(huán)有多貴。”
性能優(yōu)化的心法
這幾刀砍下來,其實能總結(jié)出幾個通用的心法。
循環(huán)體越小越好。循環(huán)體里的每一行代碼,都會被放大幾百萬倍。能挪出去的,堅決挪出去。能不用變量存的,就別存。
能用內(nèi)置的,就用內(nèi)置的。map、filter、列表推導(dǎo)式、itertools,這些都是C寫的,比Python循環(huán)快得多。Python的“內(nèi)置”兩個字,本身就是性能的保證。
數(shù)據(jù)量大的時候,換個工具。pandas、numpy、multiprocessing,這些工具存在的意義,就是幫你把循環(huán)從Python層面推出去。別死磕。
先寫對,再寫快。優(yōu)化之前,先用一小段數(shù)據(jù)驗證邏輯是否正確。在錯誤的代碼上做優(yōu)化,是最大的浪費時間。等邏輯穩(wěn)定了,再針對熱點部分下手。
用數(shù)據(jù)說話。優(yōu)化到什么程度算夠,看業(yè)務(wù)需求。如果10秒已經(jīng)夠用了,沒必要非得優(yōu)化到0.1秒。但如果你知道未來數(shù)據(jù)量會翻十倍,那提前做準備就很有必要。
小李后來成了組里的性能優(yōu)化小能手。每次同事吐槽代碼跑得慢,他就會走過去,看一眼循環(huán),然后說:“你這個循環(huán),我?guī)湍憧硯椎丁?rdquo;
他辦公室里貼著一張紙條,上面寫著: “Python循環(huán),能不寫就不寫,能少寫就少寫。”
以上就是Python循環(huán)優(yōu)化指南:從10秒到0.1秒的性能調(diào)優(yōu)的詳細內(nèi)容,更多關(guān)于Python循環(huán)優(yōu)化的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Python實現(xiàn)用networkx繪制MultiDiGraph
這篇文章主要介紹了Python實現(xiàn)用networkx繪制MultiDiGraph方式,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-02-02
python實現(xiàn)異步回調(diào)機制代碼分享
本文介紹了python實現(xiàn)異步回調(diào)機制的功能,大家參考使用吧2014-01-01
django實現(xiàn)模板中的字符串文字和自動轉(zhuǎn)義
這篇文章主要介紹了django實現(xiàn)模板中的字符串文字和自動轉(zhuǎn)義,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-03-03
使用Python和Tkinter實現(xiàn)html標簽去除工具
本文介紹用Python和Tkinter開發(fā)的HTML標簽去除工具,支持去除HTML標簽、轉(zhuǎn)義實體并輸出純文本,提供圖形界面操作及復(fù)制功能,需要的朋友可以參考下2025-05-05
Python?計算機視覺編程進階之OpenCV?圖像銳化及邊緣檢測
計算機視覺這種技術(shù)可以將靜止圖像或視頻數(shù)據(jù)轉(zhuǎn)換為一種決策或新的表示。所有這樣的轉(zhuǎn)換都是為了完成某種特定的目的而進行的,本篇我們來學(xué)習(xí)下如何對圖像進行銳化處理以及如何進行邊緣檢測2021-11-11

