~/niansia / notesEN繁简

研究筆記

把 LumiGrid 搬進瀏覽器:一個只在 WebGPU 出錯的 6 通道卷積

兩個神經網路加上一段自己寫的 WebGL 著色器,逐像素對照 PyTorch 驗證;以及一個讓 WebGPU 版本只剩 28.75 dB 的錯,是怎麼一步步二分找出來的。

2 分鐘閱讀WebGPUONNX Runtime Web除錯

我想讓看網站的人直接用自己的照片試 LumiGrid,而且照片不能離開他們的裝置。所以整個模型要在瀏覽器裡跑。這篇記錄怎麼拆、怎麼驗證,以及一個花了我最多時間的錯。

怎麼拆

LumiGrid 有三個部分,我分別用最適合的方式實作:

部分 瀏覽器裡怎麼跑
網格 CNN(讀 256² 縮圖,輸出 16×16×8 網格) ONNX,WebAssembly
亮度導引 + 三線性切片 + 8 輪曲線 + 色彩矩陣 自己寫的 WebGL2 著色器,fp32
NAFNet 細修 ONNX,WebGPU(沒有就退回 WebAssembly)

中間那段本來是 PyTorch 的 grid_sample,五維的三線性內插在 ONNX 裡支援度不一,而且它本來就是「每個像素做一樣的事」,最適合寫成著色器。每個像素在著色器裡:跑一個 3→16→1 的小網路算亮度座標(GELU 用 erf 近似,誤差約 1e-7),到網格裡取 8 個鄰居內插出 36 個係數,套 8 輪曲線,再乘上 3×4 色彩矩陣。縮圖的面積平均也用 JavaScript 照 OpenCV 的 INTER_AREA 重寫一遍。

怎麼驗證

我先用 PyTorch 在 6 張保留測試圖上產生參考輸出,再用無頭 Chrome 跑網頁版,逐像素比較。

WebAssembly 版:63.87 dB,最大誤差只有 1 個灰階,等於完全一致。每一段中間結果也對過:切片結果和 PyTorch 差距小於 1e-6。

WebGPU 版:28.75 dB。圖看起來大致正常,數字卻明顯不對。

猜錯了三次

我一開始的三個猜測都錯了:

  1. PixelShuffle 匯出成 DepthToSpace(CRD 模式)。 改成明確的 reshape + transpose 重新匯出:還是 28.75 dB。
  2. LayerNorm 裡的 pow(x, 2)。 WebGPU 的 WGSL 裡,pow 對負數底數沒有定義,而算變異數時差值本來就會是負的。改成 d * d:還是 28.75 dB。
  3. ONNX Runtime Web 的版本。 從 1.23.2 換到 1.30.0:還是 28.75 dB。

三次都一模一樣,代表這不是浮點誤差,而是某個運算在 WebGPU 上的行為根本不同。

二分

接著改成有系統地找:

測試 WebGPU 與 WebAssembly 最大差距
只做串接 0
6 個輸入通道的 3×3 卷積 0.69
同一個卷積,補零到 8 個通道 6 × 10⁻⁷

問題就在這裡:在這台 Windows 筆電的 Chrome 上,ONNX Runtime Web 的 WebGPU 後端遇到 6 個輸入通道的卷積會算錯,補成 8 個就正常。

修正

把細修網路的輸入改成 8 個通道:[輸入, 上一步結果, 0, 0],第一層卷積多出來的兩個通道權重設為 0。數學上完全等價(PyTorch 裡比對差距是 0),但避開了那條出錯的路徑。

修完之後兩個後端都是 63.87 dB、最大誤差 1 個灰階。在 1016 × 680 的圖上,我的筆電大約是:網格 CNN 50–250 ms、著色器 35–250 ms、細修 WebGPU 約 1.8–2.1 秒、WebAssembly 約 2.4 秒。

學到的事

自己試試看:在瀏覽器執行的 LumiGrid。

其他筆記

LumiGrid 研究筆記:一個亮度曲線網格,實際上學到了什麼從 16.48 dB 的課堂作法到 24.57 dB:LumiGrid 的設計、消融實驗、兩個失敗案例,以及一個對自己方法不太有利的發現。Taiwan Exam 設計筆記:讓模型出題,讓程式只負責檢查一個讓 AI 出原創學測模擬考的 Agent Skill,為什麼刻意不讓程式替題目品質背書,以及那些檢查、鎖定與套版是怎麼設計的。
研究筆記回到作品集