Python 好慢!datetime 處理優化

在開發上,為了方便與好維護,蠻多地方會使用 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 筆資料)

arrowpandaspython 原生
時間(秒)4.09973.17700.7477 (勝)

延伸閱讀:Python 詭譎的 default parameter value ,由踩坑來學習!

封面圖片備註

調程式效能就好像改車一樣,需要細細研究各個部件的問題,才能調出速度!

參考資料

Speeding Through Dates with Pandas
Ten Tricks To Speed Up Your Python Codes

Written by J
雖然大學唸的是生物,但持著興趣與熱情自學,畢業後轉戰硬體工程師,與宅宅工程師們一起過著沒日沒夜的生活,做著台灣最薄的 intel 筆電,要與 macbook air 比拼。 離開後,憑著一股傻勁與朋友創業,再度轉戰軟體工程師,一手扛起前後端、雙平台 app 開發,過程中雖跌跌撞撞,卻也累計不少經驗。 可惜不是那 1% 的成功人士,於是加入其他成功人士的新創公司,專職開發後端。沒想到卻在採前人坑的過程中,拓寬了眼界,得到了深層的領悟。