Day 23 已經確認一件很重要的事:把模型從 PyTorch 換成 ONNX Runtime 之後,Agent 的決策沒有跑掉。
接下來自然會想問:
那它到底跑得夠不夠快?
如果要讓 Agent 一邊玩 Breakout、一邊根據畫面做決策,那每次判斷就不能拖太久。
它每拿到一個新的遊戲狀態,大致要做四件事:
所以 Day 24 不再問「模型會不會玩」,而是問另一個很實際的問題:
Agent 每做一次決策,到底要花多久?
很多 AI Benchmark 喜歡一次丟很多資料給 GPU,因為這樣通常比較容易跑出漂亮的效能數字。
但玩 Breakout 的 Agent 不是這樣工作。
它的節奏比較像:
看到現在的畫面
→ 做一次決策
→ 遊戲往前走
→ 再看新的畫面
→ 再做一次決策
所以它大部分時間一次只需要處理一個遊戲狀態。
這就是 batch=1。
如果拿一次處理 32 個狀態的結果,去代表實際玩遊戲時一次只處理 1 個狀態的速度,很容易得到錯誤的印象。
因此 Day 24 的主要結果全部以 batch=1 為主。
一開始我也以為 Benchmark 很簡單:
開始計時
model(input)
停止計時
但真的做到 GPU 才發現,事情沒這麼單純。
模型第一次執行時,常常還要先做一些準備工作。
所以如果只跑一次:
第一次 = 5 ms
不能直接說:
我的模型每次都要 5 ms。
這次正式測量前,我先跑 25 次 warm-up,也就是先讓模型跑幾次進入穩定狀態;這些結果完全不進統計,之後才記錄 100 次正式測量。
GPU 很多工作是非同步的。
簡單來說,Python 把工作交給 GPU 之後,不一定會站在原地等它算完。
如果這時候馬上停止計時,很可能只量到:
「把工作交出去花多久」
而不是:
「GPU 真正把模型算完花多久」
所以這次測 GPU 時,會在必要的位置等它真的完成工作,再讀取時間。
不然很容易得到一個看起來快得不可思議、實際上卻沒有意義的數字。
假設測了 100 次:
大部分:1 ms
偶爾:4 ms
只看平均值,很容易把偶發的慢延遲藏掉。
所以這次主要看兩個數字:
對即時操作來說,P95 很重要。
因為真正讓人感覺「卡」的,往往不是平均速度,而是偶爾突然慢一下。
這次我把速度拆成兩種看法。
先把模型要吃的資料準備好,再開始計時。
這回答的是:
資料都準備好了之後,模型本身算一次需要多久?
而且這次我把資料格式、大小和數值是否合法的檢查移到計時外,避免把額外的 Python 檢查也算進模型速度。
這個才更接近 Agent 實際玩遊戲時會經過的流程。
它從原本的遊戲畫面開始,一路包含:
遊戲畫面
→ 整理成模型輸入
→ 模型計算
→ 拿到四個動作分數
→ 選出下一個動作
所以如果要問:
「Agent 每做一次決策,實際要付出多少時間?」
我會優先看完整決策,而不是只看最漂亮的「模型本身」數字。
正式測試比較了四種方式:
下面這張圖直接來自實際 benchmark 資料,會同時畫出 P50 和 P95。
這次正式 v2 run 的 batch=1 完整決策結果如下:
| 執行方式 | P50 | P95 |
|---|---|---|
| PyTorch CPU | 2.533 ms | 3.298 ms |
| PyTorch CUDA | 1.526 ms | 2.862 ms |
| ONNX Runtime CPU | 0.856 ms | 1.596 ms |
| ONNX Runtime CUDA | 1.342 ms | 2.663 ms |
這裡最值得注意的,不是哪一個名字排第一,而是:四種方式都只有幾毫秒。
另外,這一輪 ONNX Runtime CPU 甚至比 CUDA 更快一些。這不代表 CPU 永遠比較強,而是提醒我們:模型很小、一次只算一個狀態時,GPU 額外要付出的資料搬移和等待成本,也可能變得很明顯。
所以這組結果比較適合拿來回答:
模型算一次到底快不快?
而不是拿來宣布某一種執行方式永遠最快。
只看上面的表格還是不夠。
例如兩種方式都可能是:
P50 = 1 ms
但其中一種非常穩定,另一種可能偶爾突然跳到 5 ms。
所以第二張圖直接把 100 次 batch=1 完整決策的測量畫出來。
看這張圖時可以注意兩件事:
這比拿一次最快結果說「我的模型只要 0.x ms」誠實得多。
這次還有一個很有意思的現象。
這顆 DQN 其實不算大。
在這種小模型、batch=1 的情況下,真正的神經網路計算本來就很短;這時候資料搬到 GPU、等待 GPU 完成、再把結果拿回來,反而可能占掉不小的比例。
所以不能直接套用:
GPU 一定比 CPU 快很多。
如果只看模型本身,batch=1 的 P50 是:
| 執行方式 | 只量模型 P50 |
|---|---|
| PyTorch CPU | 0.989 ms |
| PyTorch CUDA | 0.625 ms |
| ONNX Runtime CPU | 0.221 ms |
| ONNX Runtime CUDA | 0.306 ms |
但把資料整理、模型計算和最後選動作全部算進去後,順序又會改變。
這就是為什麼我會把「模型本身」和「一次完整決策」分開看。
CPU 測試另外比較了不同執行緒數量。
最新結果裡,PyTorch CPU 使用 2 個執行緒時,P50 / P95 是 1.424 / 1.933 ms;使用預設的 12 個執行緒時,反而變成 2.533 / 3.298 ms。
ONNX Runtime CPU 也沒有出現「執行緒越多就越快」的單純規律。
這件事很反直覺,但對小模型並不奇怪。
因為多開執行緒本身也需要協調,工作太小時,那些額外成本反而可能拖慢速度。
所以如果以後看到:
CPU A 比 CPU B 快
最好先問一句:
兩邊用了幾個執行緒?
不然很容易把不同設定下的結果直接拿來比較。
可以先用一個很粗略的尺度來想。
如果遊戲每秒更新大約 60 張畫面,那一張畫面的時間大約是:
1000 / 60 ≈ 16.7 ms
這個 Agent 不是每一張畫面都重新做決策,而是隔幾張畫面才決定一次。以四張畫面當作簡單的比較尺度,大約是:
16.7 × 4 ≈ 66.7 ms
而這次四種完整決策的 P95 都只有:
約 1.6 ~ 3.3 ms
兩邊不是同一件事,所以不能把 66.7 - 3.3 當成真正剩下的「可用時間」。但至少可以看出一件事:
目前模型做一次決策,只占很小的一段時間。
所以現在沒有必要因為「怕模型太慢」就急著把 CNN 改小。
這是 Day 24 真正能得到的結論。
今天量到的是:
模型在這台電腦上做一次決策要多久。
它沒有把整個遊戲從畫面更新、讀取狀態、模型判斷,到最後顯示畫面的所有時間都一起量進去。
所以今天還不能直接說:
整個遊戲一定會很順。
因為完整遊戲還有其他工作要做,例如:
這些都不是 Day 24 今天要量的範圍。
也就是說,Day 24 比較像是在先排除一個可能性:
至少目前沒有看到「模型本身太慢」這個問題。
至於整套程式跑起來之後到底順不順,必須等真的把完整流程接起來再測。
今天我最大的收穫反而不是「哪個方式最快」,而是發現 Benchmark 本身也需要被設計。
要讓速度數字有意義,至少要注意:
目前的結果告訴我:模型本身還有很大的速度餘裕。
那下一個問題就變成:
如果把 FP32 換成 FP16,能不能再快一點?而且會不會因此讓 Agent 的輸出跑掉?
這就是 Day 25 要做的事。