在開發上,為了方便與好維護,蠻多地方會使用 arrow 這個套件來處理日期。但有一次在處理大量數據的時候,發現效能非常差!經過研究後才得知原來 arrow 是罪魁禍首!為何好用的套件卻會發生這種事呢?就讓我們一起來研究一下!
先描述一下程式要做的事情,其實很簡單,我有一串由 dict 資料組成的 list,我需要透過 dict 資料裡的日期來作過濾,去除重複,最後再依照日期排序,原始程式碼長這樣:
def _filter_records(self, cgms: List[Cgm], start_datetime: datetime, end_datetime: datetime) -> List[dict]:
records = []
# 過濾
for cgm in cgms:
# filter records which is in this time range
filtered_records = filter(
lambda r:
arrow.get(r['datetime']).datetime >= start_datetime and
arrow.get(r['datetime']).datetime <= end_datetime,
cgm.records
)
records += list(filtered_records)
# 去除重複
deduplicated_records = []
datetime_set = set()
for record in records:
if record['datetime'] not in datetime_set:
datetime_set.add(record['datetime'])
deduplicated_records.append(record)
# 回傳排序好的陣列
return sorted(deduplicated_records, key=lambda r: arrow.get(r['datetime']).datetime)
資料可能長這樣
[
{
'value': 100,
'datetime': '2020-01-01T01:01:01+0800'
},....
]
用原始程式碼測試 41760 筆資料,這個函數需要花費快 5 秒,其中大部分是耗費在 filter 那一段。
filter 是 python 原生函數,理論上應該不至於慢。仔細觀察,我的寫法是在他判斷前後順序的 lambda 裡使用 arrow.get() 將時間字串轉成 datetime,也就是說只要他每判斷一次,都需要重新建立 arrow instance 並讀取 datetime 欄位。我想到在「重構」這本書中讀到一個程式碼低效能的案例,就是在迴圈中不斷建立物件導致。因此我試著將日期轉換移到最前面並緩存,後面有需要用到只需要透過時間字串為 key 去拿即可:
records = []
datetime_map = {}
# 因為 datetime 轉換很花時間,所以先轉一次緩存起來
for cgm in cgms:
for record in cgm.records:
datetime_map[record['datetime']] = arrow.get(record[‘datetime’]).datetime
for cgm in cgms:
# filter records which is in this time range
filtered_records = filter(
lambda r:
datetime_map.get(r['datetime']) >= start_datetime and
datetime_map.get(r['datetime']) <= end_datetime,
cgm.records
)
records += list(filtered_records)
調整後測試,filter 那一段就變超快,但整體還是需要 4.0997 秒,瓶頸還是出現在最前面轉換並緩存日期的地方。
可能是 arrow 這個套件的問題,於是上網搜尋有沒有更快的做法。
看到這一篇使用 pandas 做日期轉換,於是我修改如下:
# 因為 datetime 轉換很花時間,所以先轉一次緩存起來
for cgm in cgms:
for record in cgm.records:
datetime_map[record['datetime']] = pd.to_datetime(record[‘datetime’])
一樣使用 41760 筆資料測試,速度有稍微快一點點,3.1770 秒,但還是太慢了,無法接受。
再仔細閱讀剛剛的文章,才發現原來他在比較 pandas 和原生 datetime lib 將日期字串轉換的速度。所以意思是原生的速度更快?立刻測試看看!
# 因為 datetime 轉換很花時間,所以先轉一次緩存起來
for cgm in cgms:
for record in cgm.records:
datetime_map[record['datetime']] = datetime.strptime(record['datetime'], '%Y-%m-%dT%H:%M:%S%z')
結果竟然只需要 0.7476 秒!差超級多!原來 arrow 和 pandas 轉換日期的時候太聰明,不需要給定原始日期字串的格式,他自己會判斷再做轉換。也正是因為聰明,所以需要額外花費轉換的時間。為了速度,我們只能捨棄好用的套件,直接使用原生並給定醜醜的日期格式讓他做轉換。同時必須確保資料來源的日期格式是一致的,否則就會 crash。
這讓我想起在「重構」這本書中提到,好維護跟高效能,有時候會是衝突必須權衡的,那時候還沒有特別的感受,現在可真是能完全理解了。
最後 sorted 的部分還可以再榨一點效能出來。這邊改成使用 sort 會比 sorted 更快。因為 sorted 會產生一個新陣列,而 sort 則是直接修改原始陣列。產生新陣列需要額外花費記憶體配置時間,如果原始陣列沒有需要保留,直接使用 sort 會更好!
所以修改如下
deduplicated_records = deduplicated_records.sort(deduplicated_records, key=lambda r: datetime_map.get(r['datetime']))
return deduplicated_records
最後再附上完整修改後的程式碼以利對照。
def _filter_records(self, cgms: List[Cgm], start_datetime: datetime, end_datetime: datetime) -> List[dict]:
records = []
datetime_map = {}
# 因為 datetime 轉換很花時間,所以先轉一次緩存起來
# 且這邊用原生函數轉換,會比 arrow 快很多
for cgm in cgms:
for record in cgm.records:
datetime_map[record['datetime']] = datetime.strptime(record['datetime'], '%Y-%m-%dT%H:%M:%S%z')
for cgm in cgms:
# filter records which is in this time range
filtered_records = filter(
lambda r:
datetime_map.get(r['datetime']) >= start_datetime and
datetime_map.get(r['datetime']) <= end_datetime,
cgm.records
)
records += list(filtered_records)
deduplicated_records = []
datetime_set = set()
for record in records:
if record['datetime'] not in datetime_set:
datetime_set.add(record['datetime'])
deduplicated_records.append(record)
deduplicated_records = deduplicated_records.sort(deduplicated_records, key=lambda r: datetime_map.get(r['datetime']))
return deduplicated_records
速度對照(測試 41760 筆資料)
arrow | pandas | python 原生 | |
時間(秒) | 4.0997 | 3.1770 | 0.7477 (勝) |
延伸閱讀:Python 詭譎的 default parameter value ,由踩坑來學習!
封面圖片備註
調程式效能就好像改車一樣,需要細細研究各個部件的問題,才能調出速度!
參考資料
Speeding Through Dates with Pandas
Ten Tricks To Speed Up Your Python Codes