四小時的批次任務死在 98%:我們學到的斷點續跑三件事

上個月我們在本地推論機器上執行一個批次任務:對兩萬四千筆去識別化後的客服訊息進行分類,每筆都要經過一次本地模型,一趟跑滿四小時。

專屬插圖
四小時的批次任務死在 98%:我們學到的斷點續跑三件事

四小時的批次任務死在 98%:我們學到的斷點續跑三件事

上個月我們在本地推論機器上執行一個批次任務:對兩萬四千筆去識別化後的客服訊息進行分類,每筆都要經過一次本地模型,一趟跑滿四小時。

八月中旬出了狀況。第三趟跑到第 23,500 筆時,機房電壓波動,機器直接重開機。剩下 500 筆,照理說十分鐘就能搞定。實際情況呢?我們盯著空白的結果檔案看了很久——總計八千兆位元組的中間產物一個位元組都沒寫入磁碟,因為腳本是「跑完再一次寫入」。四小時,從頭再來。

那次之後,我們將斷點續跑拆解成三件事,每一件都是繳過學費才學會的。

**第一件事:結果要小批量寫入磁碟,別信「最後再儲存」。**

目前的寫法是每 100 筆追加一次 jsonl,並且明確執行 flush。100 筆是測試出來的平衡點:單一 checkpoint 的開銷可以忽略不計,最壞情況下的重跑量約為 20 分鐘。這裡刻意不追求最小的 checkpoint:雖然每 10 筆寫入一次看起來更安心,但經過九百多次 fsync 後,整個任務的牆鐘時間被拉長了大約百分之七,最終還是得為每一毫秒買單。起初我們用 SQLite 儲存進度,結果有一趟跑完後磁碟空間被日誌吃光,資料庫寫入了一堆半截交易,得不償失。採用追加寫入的串流檔案反而最簡單——壞了就壞,能用就行。

順帶一提寫入磁碟的位置。結果目錄和日誌目錄應該掛載在不同的磁碟上。當我們統一掛載在同一塊資料碟時曾出過問題:一次日誌刷寫耗盡了所在分割區一半的 inode,導致批次任務莫名開始報 EIO 錯誤,最後排查了兩小時才發現是 inode 耗盡,而非資料本身有問題。

**第二件事:恢復進度只看結果檔案,不看記憶體裡的計數器,也不看日誌裡的百分比。**

第一次的教訓就是進度數字不可信:日誌已經寫著「19,200/24,000」,但資料檔案只 flush 到 19,100。現在的恢復邏輯是:讀取結果檔案,將已完成的 ID 收集成一個集合,待辦事項 = 全部 − 已完成。任務跑了多久、死在哪裡都無所謂,由集合說了算。原則只有一句——能計算出來的狀態,絕不儲存兩份。記憶體計數器、進度條、heartbeat 裡的百分比,全是衍生值,丟了就丟了。

這裡還有一個容易踩的坑:jsonl 追加寫入的最後一行可能是半行。斷電可能恰好將一行寫了一半,恢復時必須校驗最後一行的完整性,若損毀則砍掉並重跑該筆資料。我們為此寫了一個 lint 腳本:獨立解析每一行,失敗則回報行號。這五分鐘的工作,幫我們省下了兩次更耗時的排查。

**第三件事:冪等性。同一個輸入,無論重跑多少遍都只應產生一筆結果。**

一筆記錄的唯一鍵並非資料庫的自動遞增 ID,而是「輸入內容 + 模型版本 + prompt 版本」的雜湊值。這樣做的好處是,當模型升級或 prompt 改版後,舊的 checkpoint 目錄會自動作廢,新目錄從零開始計算,新舊結果永遠不會混在一起。我們曾吃過「prompt 悄悄改版,舊結果混在新批次中」的虧,產生的報告兩個版本各佔一半,比直接重跑還難解釋。

上線之前,我們還為恢復邏輯加了一個「對帳時刻」:隨機抽取 200 筆 ID,分別確認能在結果檔案中查到,且內容可被正確解析。200 筆雖然是憑直覺決定的,但若沒有這個樣本級的抽檢,我們對於「集合等於真實進度」這件事就只有信仰,沒有證據。

來看一下數字對比:修復後的第二趟任務又在 74% 處中斷过一次,恢復僅花了 11 分鐘補完剩餘部分,其中 3 分鐘耗在重新掃描已完成集合上。這三個月來,這套機制伴隨任務執行了二十多趟,中斷兩次,兩次恢復都在十分鐘以內,沒有任何一次需要重新計算。

批次任務其實和長跑是一回事:你不需要記住每一公里,只需要在每個路口留下一枚記號。

留言區

歡迎分享你的想法!

發表留言

0/500

載入留言中…