ONNX 匯出上線:
同一個模型,推論快幾百倍
模型上線之後,那台伺服器裡跑的其實是一個 pickle 出來的 Python 物件—— 它綁 Python、綁 scikit-learn 版本,而且每一筆預測都要穿過一整層 Python 呼叫。 把同一個模型匯出成 ONNX(模型的通用交換格式)之後,用 onnxruntime 執行, 答案一模一樣,時間差幾個數量級。差多少要看你一次送幾列——自己選選看:
長條為 log 刻度(不用 log,快的那根短到看不見)
毫秒數是同一台 CPU 機器上的實測(onnxruntime 1.29、onnx 1.22、100 棵樹深度 8 的 RandomForest), 多次量測會在一個範圍內浮動——看倍數,不要看絕對值。GPU 或深度模型的比例完全不同(常見是 1–3 倍)。
pickle 的三個代價,上線後才會痛
訓練完 pickle.dumps(rf) 存檔,是最順手的做法,也是大多數服務上線時的樣子。 它有三個代價,在筆記本裡完全感覺不到,一上線就全部浮出來:
| 代價 | 症狀 | ONNX 怎麼解 |
|---|---|---|
| 綁 Python | 只有 Python 讀得回來。前端、行動裝置、Java 服務要用,只能再包一層 API | 存的是運算圖不是 Python 物件;C++/Java/C#/JavaScript 都有 onnxruntime |
| 綁版本 | scikit-learn 1.7 存的檔在 1.4 的機器上可能載不起來,或更糟——不報錯但算錯 | 圖裡只有算子與 opset 版本,執行端不需要 scikit-learn |
| 慢 | 每筆預測穿過一長串 Python 函式;單筆延遲以毫秒計 | 算子是編譯好的 C++,實測單筆 約 6–7 ms → 0.008 ms |
第三點最容易被低估:批次算 500 列的時候均攤下來很便宜, 但線上 API 是一次來一筆——那時候你付的是「單筆」的價錢,不是「每列」的價錢。 實測同一個 RandomForest,500 列一次要 8.7–10.4 ms,一列也要 5.9–7.1 ms: 幾乎一樣貴,因為時間幾乎全花在跟資料量無關的固定成本上。
到 notebook 的 1️⃣ 節:pickle 的三個代價to_onnx 只有一行,但那一行裡有三個要點
① 已經 fit 過。拿沒訓練的 estimator 去轉,會得到 RuntimeError: No known ways to retrieve the number of classes…。
② 範例輸入就是對外合約。轉換器不猜你的資料,它從這一筆讀出「12 個欄位、float32」, 把形狀寫死進圖裡。所以範例的 dtype 就是上線時必須用的 dtype。 忘了給範例會直接說:NotImplementedError: Initial types must be specified.
③ zipmap=False 幾乎每次都要加。sklearn 分類器的預設會把機率包成 「每列一個 {類別: 機率} 字典」(那個算子叫 ZipMap),拿到手是 Python 的 list of dict, 不能切欄、也不好給別的語言用。關掉之後 probabilities 就是乾淨的 (n, 2) 陣列。
轉出來以後先看圖的合約與檔案大小(實測輸出):
輸入第一維是動態的(幾列都行),第二維 12 寫死。輸出兩個:label 與 probabilities。 最值得注意的是整座 100 棵樹的森林在圖裡只有一個節點—— 樹的分支條件與葉值全放在那個 TreeEnsembleClassifier 算子的屬性裡, 所以檔案比 pickle 小一半,執行時也不必在 Python 層走訪 100 次。
到 notebook 的 2️⃣ 節:三個要點與 graph 合約換格式,不可以換答案
轉換是「翻譯」,翻譯可能翻錯:不支援的參數被靜靜忽略、機率欄序顛倒、float32 捨入。 這種故障沒有任何錯誤訊息,只是答案悄悄不一樣了。所以每轉一次就要對一次答案,而且要寫成函式進 CI:
500 列 test set 的實測結果:
1e-07 這個量級的差不是 bug,是 float32 的捨入——ONNX 圖用 32 位元算, sklearn 內部用 64 位元。對「機率大於 0.5 就判正」完全無關痛癢,但你要知道它存在: 閾值卡在 0.500000 附近、或下游要拿機率做精算的話,容許值要自己定。 真正該警覺的是類別不一致,或誤差跳到 1e-2 等級——那就是翻譯出錯了,別上線。
到 notebook 的 3️⃣ 節:InferenceSession 與 assert_same()批次快 10 倍上下,單筆快好幾百倍
| 情境 | scikit-learn | onnxruntime | 倍數 |
|---|---|---|---|
| RandomForest · 500 列一次 | 8.7–10.4 ms | 0.6–1.2 ms | 9–14× |
| RandomForest · 單筆 | 5.9–7.1 ms | 0.008–0.012 ms | 500–840× |
| LogisticRegression · 單筆 | 0.10–0.25 ms | 0.006–0.012 ms | 16–20× |
為什麼差這麼多?兩件事加起來。 第一,Python 物件的開銷:rf.predict_proba(一筆) 要建 numpy 陣列、跑輸入檢查、 在 Python 層迴圈走訪 100 棵樹再平均——這些成本跟資料量幾乎無關,所以一列和五百列花的時間差不多, 單筆時它們就是全部的成本。 第二,編譯好的算子:onnxruntime 那邊整座森林是一個 C++ 實作的算子, 圖在建立 session 時就規劃好了記憶體與執行順序,呼叫一次只是把資料丟進去。
所以請記住這個區別:加速主要來自「每次呼叫的固定成本」,批次越大越被均攤掉。 LogisticRegression 的倍數小很多,也是同一個道理——它在 sklearn 裡本來就只是一次矩陣乘法, Python 開銷占比沒那麼誇張。 永遠在付單筆價錢的線上 API,才是換 ONNX 收益最大的地方。
notebook 的 6️⃣ 節有一個拉桿:自己選 1–500 列與模型,按一下當場量、當場畫圖。 把列數從 1 拉到 500,看那個倍數怎麼一路縮小——這是本課最值得自己跑一次的東西。
到 notebook 的 4️⃣ 與 6️⃣ 節:延遲對照與互動量測存進 MLflow、包成函式、守住合約
.onnx 是一個檔案,但別讓它變成「某台機器上的孤兒檔案」—— 跟訓練它的那個 run 綁在一起,三個月後才有人說得出線上跑的是誰:
這個函式短得有點可疑,但那兩行防呆正是把三種最常見的上線錯誤擋在門外。 ONNX 的合約很硬,違反了會當場報錯——這是好消息,因為 pickle 的 sklearn 模型在同樣情況下常常照算不誤, 算出一個沒有意義的機率,沒有人會發現。三種實測錯誤原文:
接上線的三條路,都用同一個檔: 把上一課 FastAPI 的 model.predict 換成 predict_proba_onnx (InferenceSession 在服務啟動時建立一次,跟「模型載一次」同一個原則); 或把檔案交給 C++/Java/C# 的 onnxruntime,模型不再需要一台 Python 伺服器才能存在; 或用 onnxruntime-web 直接在使用者的瀏覽器裡跑這張圖,資料完全不離開裝置。
到 notebook 的 5️⃣ 節:MLflow、推論函式與合約錯誤換你動手
把 logreg 也轉成 ONNX,建 session 後呼叫本課的 assert_same()。它的 .onnx 只有幾百 bytes,RandomForest 是 548 KB——想清楚為什麼,順便解釋它的加速倍數為何小這麼多。
拿到一個陌生的 .onnx:用 onnx.load() 讀進來、onnx.checker.check_model() 驗一次,再列出 graph 裡每種節點型別各有幾個、以及 opset 版本。100 棵樹的森林有幾個節點?
ort.SessionOptions() 的 intra_op_num_threads 控制單一算子用幾條執行緒。建 1/2/預設三個 session,量單筆與 500 列的延遲。先猜:單筆時執行緒開多會變快還是變慢?
卡住了?每一題在 notebook 末節都有折疊解答——先自己做,再打開對照。
情境測驗
離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。
Q1 情境題
兩個服務都吃同一個 RandomForest:A 是即時反詐 API,每次一筆、延遲要求 20 ms;B 是每天半夜把 200 萬列跑完的批次評分,跑 40 分鐘。團隊只有時間改一個。先改哪個、為什麼?
ONNX 省下的主要是「每次呼叫的固定成本」——建陣列、輸入檢查、Python 層走訪 100 棵樹。這些成本跟資料量幾乎無關,所以批次時被均攤到幾乎看不見(實測 500 列只快 9–14 倍),單筆時卻是全部的成本(實測約 6–7 ms → 0.008 ms,500–840 倍)。A 的直覺不對:批次 40 分鐘裡真正花在 predict 的往往只是一小段,讀寫資料才是大頭,而且就算全部是推論,倍數也小一個數量級;B 在時間有限的前提下不是「選一個」的答案,而且 A 每快一毫秒都直接影響使用者;D 是錯的,批次一樣可以用 ONNX,只是收益小得多。
Q2 錯誤診斷
模型轉換與本機測試都過了。接上線上服務後,每一筆請求都回 500,日誌裡是這一段。最直接的修法是?
錯誤把兩邊都講明了:收到的是 double(float64),圖要的是 float(float32)。而圖之所以要 float32,是因為當初 to_onnx 的範例輸入就是 float32——那一筆範例把型別寫死成合約了。pandas 的 .values 預設給 float64,本機測試沒炸多半是因為當時直接餵訓練用的 float32 陣列。修法就是在推論函式的入口統一轉型。A 沒有根據,opset 跟 dtype 是兩回事;C 是好習慣但症狀不符——名稱錯會得到 Required inputs (['X']) are missing from input feed;D 版本不合會在 InferenceSession 建立時就失敗,不會等到 run。
Q3 錯誤診斷
同事照著文件轉了模型,要取正類機率時炸了。他堅持「形狀我印過,就是 (n, 2)」。最可能的原因是?
兩個線索直接指向 ZipMap:輸出名稱是 output_label/output_probability(關掉 zipmap 之後會是 label/probabilities),而 probabilities[0] 印出來是一個 {類別: 機率} 字典。sklearn 分類器轉 ONNX 的預設行為就是加上 ZipMap 算子,把每列的機率包成字典;np.asarray 一個 list of dict 得到的是一維的 object 陣列,所以切第二維才會炸。他印過的 (n, 2) 是 sklearn 那邊的形狀,不是 ONNX 這邊的。A 是誤解,輸出順序就是 graph outputs 的順序、固定不變;B 症狀不符,錯的是機率陣列不是輸入;C 這是二分類,而且輸出只有兩個。
Q4 情境題
重訓的模型要換到線上。CI 只跑得動一件事,你要讓它抓住「ONNX 轉換出錯」這種故障。加哪一步最有效?
轉換出錯最可怕的形態是安靜的:不支援的參數被忽略、機率欄序顛倒、某個算子行為有細微差異——全都不會拋例外,只是答案變了。唯一抓得到的方法就是拿同一批輸入跑兩邊、逐筆比對輸出。實測的通過長相是機率最大差異 1.9e-07(float32 捨入)、類別一致率 100%;一旦類別不一致、或誤差跳到 1e-2 等級,就是翻譯真的出錯了。B 只驗「這張圖結構合法」,一張結構完全合法的圖照樣可以算出錯的答案;C 檔案大小跟正確性沒有因果關係,重訓後樹的深度不同大小本來就會變;D 方向對但太鈍——AUC 是排序指標,機率整體偏移或少數幾筆算錯,AUC 可能完全看不出來,而且它混淆了「模型品質」與「轉換正確性」這兩件事。
實作在 molab 跑(免費)
molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。
- 登入 molab(GitHub / Google)
- 開啟課程 notebook,Fork 成自己的副本即可編輯
- 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU,也不連任何外部服務
不想用 molab?下載 onnx-export_ext.py 後在自己電腦
uvx marimo edit --sandbox onnx-export_ext.py,依賴會自動安裝。
molab 的線上編輯器在手機上體驗有限——動手這一段建議用電腦進行。