diff --git a/docs/autoflow/02-prd/PRD.md b/docs/autoflow/02-prd/PRD.md index 975f1ba..d9eff29 100644 --- a/docs/autoflow/02-prd/PRD.md +++ b/docs/autoflow/02-prd/PRD.md @@ -503,6 +503,7 @@ - 流程固定:ONNX → BIE → NEF,不支援跳過或只跑部分(後端限制) - 任何階段失敗即整體 FAILED,不自動重試 +- **ONNX 階段輸出語意(2026-07-06 M 級變更)**:ONNX 優化後、存檔前對所有 platform 移除尾端 Softmax,故 NEF 最終輸出為 logits 而非機率。詳見 §11 註與 `../04-architecture/TDD.md` §12。 ### 5.5 對外 API 端點預告(新增,2026-04-25,待 Architect 產出詳細規格) @@ -802,12 +803,18 @@ RICE = (Reach x Impact x Confidence) / Effort | 平台代號 | 平台名稱 | 備註 | |---------|---------|------| -| 520 | KDP520 | - | +| 520 | KDP520 | 含尾端 Softmax 的模型自 2026-07-06 起可轉(見下方註)| | 720 | KDP720 | - | | 530 | KDP530 | - | | 630 | KDP630 | - | | 730 | KDP730 | - | +> **輸出語意變更(2026-07-06,M 級)**:轉檔管線自 2026-07-06 起,對**所有 platform** 一律移除模型尾端的 Softmax 節點、NEF 輸出改為 **logits(未經 softmax 的原始分數)**,而非先前的機率輸出。 +> - **為什麼**:含尾端 Softmax 的分類模型轉 KL520 時 batch_compile 會因不支援 Softmax CPU op 而失敗(exit 6);移除 Softmax 後可正常編譯,且與正式站(`converter.innovedus.com`)行為一致(正式站本就是這樣做)。 +> - **對呼叫端的影響**:取分類結果(argmax)通常不受影響;若需要「機率值」,呼叫端(如 visionA)須在 host 端自行補 softmax。 +> - **需跨團隊通知**:visionA 團隊。 +> - 技術細節見 `../04-architecture/TDD.md` §12、`../04-architecture/design-doc.md` ADR-012。 + --- ## 12. 風險與緩解 diff --git a/docs/autoflow/04-architecture/TDD.md b/docs/autoflow/04-architecture/TDD.md index 0b904a0..fc8a470 100644 --- a/docs/autoflow/04-architecture/TDD.md +++ b/docs/autoflow/04-architecture/TDD.md @@ -17,6 +17,7 @@ | 2026-04-25 | 初版 Draft 1.0(OAuth resource server + promote) | Architect Agent | | 2026-04-25 | Multipart 上傳路徑改 visionA → converter 直傳;移除 FAA GET/HEAD | Architect Agent | | 2026-05-16 | **Phase 0.8b 重寫**:visionA → converter 改 API key;新增 `/result` endpoint;OAuth resource server 章節砍除;模組化拆分為索引 + 子檔案 | Architect Agent | +| 2026-07-06 | **Worker 行為變更(M 級)**:onnx worker 對**所有 platform** 一律移除尾端 Softmax、輸出 logits(與正式站對齊、修 520 batch_compile exit 6);pre-check regex 收窄。新增 §12 | Architect Agent | --- @@ -37,6 +38,7 @@ | `observability.md` | Log 格式 + 敏感資料保護 + 告警 | Backend | | `security.md` | Trust boundary + Input validation + Auth security | 全部 | | `design-doc.md` | 架構決策 + ADR | 全部 | +| 本檔 §12 | **Worker 轉檔管線行為變更**(尾端 Softmax 移除 + pre-check regex 收窄,2026-07-06 M 級)| Backend、Reviewer、Testing | --- @@ -291,4 +293,94 @@ apps/task-scheduler/ --- -**附註**:本 TDD 從 1390 行單檔重組為 ~180 行索引 + 8 個子檔案。每個子檔案 < 500 行(單一職責),可獨立給 Backend / Reviewer / Testing 不同角色讀對應檔案、減少 context 負擔。 +## 12. Worker 轉檔管線行為變更(2026-07-06,M 級) + +> **範圍界定**:本 TDD 前 11 章聚焦 Task Scheduler 對外 API / auth 層(Phase 0.8b)。本章新增,記錄 **Python Worker 轉檔管線** 的一個行為變更 —— 與 API / auth 無關,但同屬本 repo 的架構決策、故收在同一份 TDD 索引,方便 Backend / Reviewer / Testing 讀。詳細實作插入點見 §12.2。 + +### 12.1 尾端 Softmax 自動移除(輸出 logits) + +#### 為什麼(背景) + +- **問題**:含尾端 Softmax 的分類模型(如 MobileNet classifier)轉 **KL520** 時,`batch_compile -T 520` 撞 `UnimplementedFeature: undefined CPU op [Softmax]`、**exit 6** 失敗。 +- **根因**:runtime image(`/app/ktc/onnx_optimizer.py`,kneronnxopt 版)的 `eliminate_tail` 是**閹割版 no-op**(container 內印 `WRANING: eliminate_tail is not available in current conda environment`),所以即使 `onnx2onnx_flow(eliminate_tail=True)` 也砍不掉尾端 Softmax、Softmax 一路帶進 batch_compile 撞 520 不支援的 CPU op。 +- **對齊正式站**:正式站 `converter.innovedus.com`(另一套 code base)對**同一顆** fixture 能編出真 520 .nef。staging 實測比對正式站 .nef:output = `(1,3,1,1)` 定點量化 logits、**無 Softmax**,且權重段(`wt` 0xc0530)與我們砍掉 Softmax 後產的 .nef **逐 byte 相同**、compiler 版本字串完全相同(`v0.9.1(6d7a863)`)→ 證明正式站就是「模型階段移除 Softmax、logits 落 host 後處理」,這是 Kneron 對 520 的標準做法。 + +#### 決策(拍板) + +**onnx 階段(`onnx2onnx_flow` 之後、`onnx.save` 之前)對所有 platform 一律移除尾端 Softmax、輸出 logits,與正式站行為對齊。** + +- **不限 520**:雖然只有 520 會因 Softmax exit 6,但為了「所有 platform 輸出一致 = logits」、與正式站對齊、避免 platform 分歧造成呼叫端困惑,決定**所有 platform 統一移除**。(若日後有「某 platform 需保留 Softmax」的需求,再走 platform 分支,屆時另開 ADR。) +- **觸發條件(實作對齊,2026-07-06 backend 實測修訂)**:**只移除 terminal(graph 尾端、無下游節點)的 Softmax;中間層的 Softmax 不移除**。理由見下方「怎麼做」的 `cut_nodes` 說明——`cut_types` 是 cut-from-node 語意,遇到中間層 Softmax 會把整段下游靜默切掉,故改用「terminal-only + `cut_nodes`」精準移除。移除後 logits 自動接成新 graph output。 + +#### 怎麼做(實作方式,2026-07-06 backend 實測修訂) + +- **插入點**:`services/workers/onnx/core.py:36-37` 之間(`onnx2onnx_flow(...)` 之後、`onnx.save(model, output_path)` 之前)。 +- **呼叫方式(backend 實作 + staging 實測可行)**: + ```python + # 注意:runtime 的 ktc.onnx_optimizer 沒有 editor API(remove_nodes_with_types 等) + # 必須改呼叫底層 tools.other.remove_nodes + import sys + sys.path.insert(0, "libs/ONNX_Convertor/optimizer_scripts") # 依 container 掛載點調整 + from tools import other + + # 只挑「terminal(無下游節點)且 op_type == Softmax」的節點名,用 cut_nodes 精準移除 + terminal_softmax = [ + n.name for n in model.graph.node + if n.op_type == "Softmax" and _is_terminal(n, model.graph) + ] + if terminal_softmax: + other.remove_nodes(model.graph, cut_nodes=terminal_softmax) + ``` + (`_is_terminal` 判斷該節點的 output 不是任何其他節點的 input,即 graph 尾端;實際 helper 命名以 backend 實作為準。) +- **不要用** `cut_types=["Softmax"]`(原 sketch 寫法,已棄用):`cut_types` 是 cut-from-node 語意,遇到**中間層** Softmax 會把其整段下游節點**靜默切掉**(危險)。改用 `cut_nodes`(明確指定要移除的節點名)+ 只挑 terminal Softmax。對已驗證情境(fixture、尾端 Softmax)兩者行為**逐 byte 等價**,但 `cut_nodes` + terminal-only 在有中間層 Softmax 的模型上才安全。 +- **不要用**:`ktc.onnx_optimizer.remove_nodes_with_types(...)` —— runtime image 的 `ktc.onnx_optimizer`(kneronnxopt 版)**沒有任何 editor API**;`ktc/onnx_optimizer_1_7.py` 雖存在但不可 import(hardcode `/workspace` 路徑 + 依賴 container 沒有的 `onnx.optimizer`)。 +- **不要依賴** `eliminate_tail=True` 做尾端清理(runtime 是 no-op)。 +- job metadata 應標注「Softmax 已移除、需 host 端後處理」提醒呼叫端。 + +#### 行為影響(重要,必須傳達給呼叫端) + +- **所有 platform 的 NEF 輸出從「含 Softmax(機率)」變「logits」**。 +- 呼叫端(visionA-backend)若對轉檔結果做分類後處理(取 argmax 通常不受影響、但取「機率值」會受影響),**需自行在 host 端補 softmax**。 +- **⚠️ 需通知 visionA 團隊**:這是對外可觀察的輸出語意變更,即使 argmax 分類結果不變、機率數值會變。列為交付前跨團隊溝通項。 + +#### 證據連結(個人層,per-branch) + +- 實驗規劃(bug 反推、只讀 code):`.autoflow/05-implementation/tflite-520-experiment-plan-2026-07-06.md` +- staging 實測驗證(砍 Softmax → 520 產真 .nef、與正式站逐 byte 比對):`.autoflow/06-testing/reports/tflite-520-softmax-removal-verify-2026-07-06.md`(H1/H2/H3 全綠、含正式站 .nef 比對閉環、backend 實作注意事項) + +### 12.2 pre-check regex 收窄(設計修訂) + +#### 原設計 → 修訂 + +`services/backends/precheck.py` 的「不支援 op 早期失敗」pre-check,原設計是「掃到 marker 字樣(regex 命中)就擋」,實測發現會**誤擋**——`hw_not_support_col` 是**欄位名**、被 `hw_not_support` regex 誤命中,導致能編出真 .nef 的乾淨模型也可能被擋。 + +**修訂為「訊號 + 具體 op 名稱才擋,抽不到 op 名稱時放行 + warning(fail-open)」:** + +| 項目 | 原設計 | 修訂後 | +|------|--------|--------| +| `hw_not_support_col`(欄位名誤命中) | 會命中、造成誤擋 | **刪除 / 大幅收窄**該 regex | +| `hw_not_support` 的 op capture group | optional(抽不到 op 名也擋) | **改必須**(capture group 必抽到具體 op 名才算命中) | +| 抽不到具體 op 名稱時 | 擋(fail-closed) | **放行 + warning**(真 fail-open) | + +#### 為什麼 fail-open + +pre-check 的定位是「早期快速失敗、省 batch_compile 時間」的**優化**,不是**授權邊界**。誤擋(把能轉的擋掉)比漏擋(放行後 batch_compile 自己 fail)代價高。抽不到具體 op 名稱 = 訊號不明確 = 寧可放行讓後段真正的 batch_compile 判定。 + +#### 證據連結 + +- rootcause 報告 §4a(pre-check 誤擋分析):見 `.autoflow/06-testing/reports/tflite-520-softmax-removal-verify-2026-07-06.md` §5(R3 誤擋風險實測數據點)+ 舊 rootcause 報告 §4a +- **同 PR 收窄**:本 regex 修訂與 §12.1 的 Softmax 移除**同一個 PR** 進,避免「Softmax 砍了但 pre-check 還誤擋」的半套狀態。 + +### 12.3 實作任務(給 Backend) + +| # | 任務 | 檔案 | 驗收標準 | +|---|------|------|---------| +| W1 | onnx worker 加「移除尾端 Softmax」步驟 | `services/workers/onnx/core.py:36-37` | 用 `tools.other.remove_nodes(graph, cut_nodes=)`(**只移除 terminal Softmax、中間層不動**;不可用 `cut_types=["Softmax"]`,會誤砍中間層下游);砍後模型過 `onnx.checker`;520 e2e 不再 exit 6、產真 .nef;job metadata 標注 Softmax 已移除 | +| W2 | pre-check regex 收窄 | `services/backends/precheck.py` | 刪 / 收窄 `hw_not_support_col`;`hw_not_support` op capture group 改必須;抽不到 op 名改放行 + warning;既有測試不 broken | +| W3 | 回歸驗證 | — | 720/530/630/730 既有可轉模型仍能轉(輸出改 logits 但 .nef 有效);含 Softmax 的 520 模型能轉成 | + +> W1 + W2 **同 PR**。W3 交 Testing 做回歸(Prove-It:先寫「520+Softmax 應轉成」的 failing test → W1 修 → 轉綠)。 + +--- + +**附註**:本 TDD 從 1390 行單檔重組為 ~180 行索引 + 8 個子檔案。每個子檔案 < 500 行(單一職責),可獨立給 Backend / Reviewer / Testing 不同角色讀對應檔案、減少 context 負擔。§12(2026-07-06 新增)為 worker 轉檔管線行為變更,與 API/auth 層獨立。 diff --git a/docs/autoflow/04-architecture/design-doc.md b/docs/autoflow/04-architecture/design-doc.md index c4fbb42..dc47213 100644 --- a/docs/autoflow/04-architecture/design-doc.md +++ b/docs/autoflow/04-architecture/design-doc.md @@ -17,6 +17,7 @@ | 2026-04-25 | 初版 Draft;OAuth resource server + promote 設計 | Architect Agent | | 2026-04-25 | 原始模型上傳路徑改 multipart 直傳;移除 FAA GET/HEAD 相關 | Architect Agent | | 2026-05-16 | **Phase 0.8b 重寫**:visionA → converter 改 API key;新增 `GET /api/v1/jobs/:id/result` streaming endpoint;保留 converter → FAA OAuth client(promote 用) | Architect Agent | +| 2026-07-06 | **M 級 worker 行為變更**:onnx worker 對所有 platform 移除尾端 Softmax、輸出 logits(新增 ADR-012);pre-check regex 收窄。影響「輸出語意」(機率 → logits),需通知 visionA | Architect Agent | --- @@ -252,7 +253,7 @@ visionA-backend Converter Scheduler MinIO | Task Scheduler — routes | **小改**:`POST /jobs` / `GET /jobs` / `GET /jobs/:id` / `POST /jobs/:id/promote` 改掛 `requireApiKey()`;新增 `/jobs/:id/result` 路由 + handler | | Task Scheduler — config | 移除 `MEMBER_CENTER_ISSUER` / `MEMBER_CENTER_JWKS_URL` / `KNERON_CONVERTER_AUDIENCE` / `JWKS_*`;保留 `MEMBER_CENTER_TOKEN_URL` / `KNERON_CONVERTER_CLIENT_ID` / `KNERON_CONVERTER_CLIENT_SECRET` / `FILE_ACCESS_AGENT_*`(promote 用);新增 `CONVERTER_API_KEY` | | Redis 資料模型 | **不動** | -| Workers | **不動** | +| Workers | Phase 0.8b **不動**;**2026-07-06 M 級變更**:onnx worker 加「移除尾端 Softmax、輸出 logits」步驟(所有 platform),詳見 ADR-012 + `TDD.md` §12 | | MinIO(Converter Bucket) | **不動** | | FAA / MC | **不動**(converter → FAA 仍走 OAuth client_credentials) | @@ -525,6 +526,37 @@ Web UI 仍走 `/jobs/*` 路徑、無 auth。Phase 0.8b 不動。 --- +### ADR-012:onnx worker 對所有 platform 移除尾端 Softmax、輸出 logits(**2026-07-06 新增,M 級**) + +**狀態**:Accepted(2026-07-06 使用者拍板;staging 實測 + 正式站 .nef 逐 byte 比對佐證) + +**背景**: +1. 含尾端 Softmax 的分類模型轉 **KL520** 時 `batch_compile -T 520` 撞 `undefined CPU op [Softmax]`、**exit 6** 失敗。 +2. runtime image 的 `eliminate_tail` 是閹割版 no-op(`WRANING: eliminate_tail is not available in current conda environment`),砍不掉尾端 Softmax。 +3. 正式站(另一套 code base)對**同一顆** fixture 能編出真 520 .nef —— staging 實測比對正式站 .nef:output 是 `(1,3,1,1)` 定點量化 logits、**無 Softmax**,權重段與我們砍掉 Softmax 後產的 .nef **逐 byte 相同**、compiler 版本字串完全相同 → 正式站就是「模型階段砍 Softmax、logits 落 host」。 + +**決定**:onnx 階段(`onnx2onnx_flow` 之後、`onnx.save` 之前,`services/workers/onnx/core.py:36-37`)對**所有 platform** 一律移除尾端 Softmax、輸出 logits。實作用 `libs/ONNX_Convertor/optimizer_scripts/tools/other.remove_nodes(graph, cut_types=["Softmax"])`(runtime `ktc.onnx_optimizer` 無 editor API、不可用 `remove_nodes_with_types`)。同 PR 收窄 `services/backends/precheck.py` 的不支援 op regex(`hw_not_support_col` 欄位名誤命中 → 刪 / 收窄;op capture group 改必須;抽不到 op 名改放行 + warning = fail-open)。 + +**理由**: +1. 修 520 exit 6(使用者目標「讓 520+Softmax 轉成」)。 +2. 與正式站行為對齊(正式站就是這樣做,Kneron 對 520 的標準做法)。 +3. 所有 platform 統一輸出 logits,避免 platform 分歧造成呼叫端困惑。 + +**代價 / 行為影響**: +- **所有 platform 的 NEF 輸出從「含 Softmax(機率)」變「logits」** —— 對外可觀察的輸出語意變更。 +- 呼叫端(visionA-backend)若取「機率值」需自行在 host 端補 softmax(argmax 分類結果通常不受影響)。 +- **需通知 visionA 團隊**(交付前跨團隊溝通項)。 + +**替代方案**: +- A. 只對 520 移除 Softmax(其他 platform 保留)— 排除:造成 platform 輸出分歧、與正式站不一致、呼叫端要判斷 platform。 +- B. 換 toolchain 版本讓 520 容忍 Softmax(H3 假設)— 排除:正式站 compiler 版本與我們相同,證明不是版本問題;且換 toolchain 成本高。 +- C. 調 `eliminate_tail` 讓它砍 Softmax — 排除:runtime 是 no-op,改不動;且 editor API 不可用。 +- D. onnx 階段用 `tools.other.remove_nodes` 明確移除(**選擇**)。 + +**證據**:`.autoflow/06-testing/reports/tflite-520-softmax-removal-verify-2026-07-06.md`(H1/H2/H3 全綠 + 正式站比對閉環)、`.autoflow/05-implementation/tflite-520-experiment-plan-2026-07-06.md`。實作細節見 `TDD.md` §12。 + +--- + ## 9. 風險與待確認事項 | # | 風險 / 議題 | 影響 | 行動 | diff --git a/services/backends/precheck.py b/services/backends/precheck.py index ff6a48c..a8154da 100644 --- a/services/backends/precheck.py +++ b/services/backends/precheck.py @@ -11,8 +11,10 @@ op_type:`` 等訊號,toolchain 會把這些寫進 ``model_fx_report``。因此 (不需要跑完 quantization + batch_compile)就偵測到不支援的 op,fail-fast 並回一個 使用者看得懂的錯誤,而不是讓 job 跑到 nef 階段撞 C++ backtrace(exit 6)。 -設計原則(避免誤擋): -- 只有在 evaluator 報告中出現「明確的」不支援訊號時才擋。 +設計原則(真 fail-open、避免誤擋): +- 「明確訊號」= 不支援 marker **加上一個具體的 operator 名稱**。只出現 marker 字樣 + (例如報告表頭的 "HW not support" 欄位名)但抽不出 op 名稱時 → 放行 + warning, + 不擋。誤擋能轉的模型(false positive)的代價高於晚一點在 nef 階段失敗。 - evaluator 分析失敗(例如環境問題、transient error)而沒有明確不支援訊號時 → 放行, 讓後續流程照舊跑,不因為 pre-check 有疑慮就擋掉本來能跑的模型。 """ @@ -37,53 +39,74 @@ class UnsupportedOperatorError(Exception): # 對照來源:vendor/sys_flow*/test_case.py::check_compiler_HardwareNotSupport 與 # check_knerex_error 寫入 model_fx_report 的字串。 # -# 每個 pattern 盡量帶一個 capture group 用來抓「op 名稱」(抓不到也沒關係,會退回泛用訊息)。 +# 每個 pattern 都帶一個「必須命中」的 capture group 抓 op 名稱片段。 +# marker 後面抽不出具體 op 名稱 → 視為弱訊號、放行(見 find_unsupported_operators)。 +# +# 注意:這裡刻意**沒有**「HW not support」欄位名的泛用 pattern —— 那是 +# model_fx_report 的欄位分類名稱(vendor/sys_flow/test_case.py::model_fx_report), +# 會出現在正常報告的表頭,光是出現不代表模型有不支援 op(R3 誤擋根因, +# 見 .autoflow/05-implementation/tflite-520-rootcause-2026-07-06.md §3a/§4a)。 +# 「HW not support: 」帶具體 op 的強訊號仍由 hw_not_support pattern +# 涵蓋(IGNORECASE + [_ ] 分隔)。 _UNSUPPORTED_MARKERS: tuple[tuple[str, str], ...] = ( # "creating an EmptyNode instance for op_type: Softmax" ("empty_node", r"creating an EmptyNode instance for op_type:\s*([A-Za-z0-9_]+)"), # "HW_NOT_SUPPORT: Softmax" / "HardwareNotSupport: Softmax" / "Hardware not support: Softmax" - ("hw_not_support", r"(?:HW[_ ]NOT[_ ]SUPPORT|Hardware\s*not\s*support|HardwareNotSupport)\s*[:\-]?\s*([A-Za-z0-9_]+)?"), + # op capture group 為必須:marker 後面一定要跟具體 op 名稱才算訊號。 + # marker 帶 (?:ED)? 字尾容忍 + \b 詞界:報告若寫 "Hardware not supported: ...", + # 字尾 "ed" 必須被 marker 吃掉、不能漏進 capture group 被當成 op 名(誤擋)。 + ("hw_not_support", r"(?:HW[_ ]NOT[_ ]SUPPORT(?:ED)?|Hardware\s*not\s*support(?:ed)?|HardwareNotSupport)\b\s*[:\-]?\s*([A-Za-z0-9_]+)"), # "UNIMPLEMENTED_FEATURE: ..." / "UnimplementedFeature: undefined CPU op [Softmax]" ("unimplemented", r"(?:UNIMPLEMENTED[_ ]FEATURE|UnimplementedFeature)\s*[:\-]?\s*(.*)"), - # 泛用「HW not support」報告欄位(toolchain 報告字串) - ("hw_not_support_col", r"HW\s+not\s+support\b"), ) -# 從一段訊息中撈出被中括號 / 引號包住的 op 名稱,例如 "undefined CPU op [Softmax]"。 +# 從一段訊息中撈出被中括號 / 括號包住的 op 名稱,例如 "undefined CPU op [Softmax]"。 _OP_IN_BRACKETS = re.compile(r"[\[\(]\s*([A-Za-z][A-Za-z0-9_]*)\s*[\]\)]") -# 常見 ONNX op 名稱(PascalCase / 首字母大寫),用來在自由文字裡挑一個像 op 的字。 -_OP_TOKEN = re.compile(r"\b([A-Z][A-Za-z0-9]{2,})\b") +# 報告表格常見的「非 op」值:欄位名(如 "HW not support")後面接的可能是這些字, +# 不能把它們當成 operator 名稱(否則又變回誤擋)。 +_NON_OP_TOKENS = frozenset({"none", "null", "nan", "na", "n", "true", "false", "yes", "no"}) def _extract_op_name(fragment: Optional[str]) -> Optional[str]: - """盡力從訊息片段中抽出 operator 名稱。抓不到就回 None。""" + """從訊息片段中抽出 operator 名稱。抓不到(或抓到的不像 op)就回 None。 + + 只接受兩種**明確**形式: + 1. 中括號 / 括號包住的 token(例如 "undefined CPU op [Softmax]"); + 2. fragment 本身就是單一 token(例如 hw_not_support capture 到的 "Softmax")。 + + 刻意**不**在自由文字裡猜「首字母大寫的字」——自由文字(如 + "UnimplementedFeature: Not supported in this mode")猜出來的字 + ("Not"、"CPU")不是 op 或不是對的 op,會造成誤擋 / 錯誤訊息; + 抓不到就交給呼叫端 fail-open。 + """ if not fragment: return None fragment = fragment.strip() if not fragment: return None + candidate: Optional[str] = None + m = _OP_IN_BRACKETS.search(fragment) if m: - return m.group(1) + candidate = m.group(1) + elif re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", fragment): + # 直接就是一個 op token(例如 "Softmax") + candidate = fragment - # 直接就是一個 op token(例如 "Softmax") - if re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", fragment): - return fragment - - m = _OP_TOKEN.search(fragment) - if m: - return m.group(1) - - return None + if candidate and candidate.lower() in _NON_OP_TOKENS: + return None + return candidate def find_unsupported_operators(report: Optional[str]) -> List[str]: """掃描 evaluator 報告字串,回傳偵測到的「不支援 operator 名稱」清單。 - 沒有偵測到任何明確不支援訊號 → 回空 list(呼叫端應放行)。 - - 偵測到訊號但抽不出 op 名稱 → 回含一個占位符 ``""`` 的 list, - 讓呼叫端仍能 fail-fast(但訊息較泛用)。 + - 偵測到 marker 字樣但抽不出具體 op 名稱 → **放行**(回空 list)+ warning log。 + marker 字樣可能只是報告表頭的欄位名(例如 "HW not support" 欄), + 光是出現不足以斷定模型有不支援 op;擋錯的代價(誤擋能轉的模型) + 比晚一點在 nef 階段失敗更高(真 fail-open)。 """ if not report: return [] @@ -102,14 +125,17 @@ def find_unsupported_operators(report: Optional[str]) -> List[str]: if found: return found if saw_marker: - # 有明確不支援訊號、但抽不到 op 名稱:仍回報(用占位符)。 - return [""] + logger.warning( + "Pre-check saw an unsupported-op marker but could not extract a concrete " + "operator name; treating as inconclusive and allowing the job to proceed " + "(fail-open)." + ) return [] def build_error_message(platform: str, ops: Iterable[str]) -> str: """產生使用者看得懂的錯誤訊息。""" - op_list = [o for o in ops if o and o != ""] + op_list = [o for o in ops if o] if op_list: ops_text = ", ".join(op_list) op_clause = f"operator {ops_text}" diff --git a/services/workers/onnx/core.py b/services/workers/onnx/core.py index 71ff509..c053512 100644 --- a/services/workers/onnx/core.py +++ b/services/workers/onnx/core.py @@ -1,8 +1,165 @@ +import logging import os -from typing import Dict, Any +import sys +from typing import Any, Dict, List import onnx +logger = logging.getLogger(__name__) + +# libs/ONNX_Convertor/optimizer_scripts 的路徑(repo 與 container /app 的 layout 相同, +# 都是「repo root 底下的 libs/...」,所以用 core.py 的相對位置回推即可兩邊通用)。 +# +# 為什麼直接用 libs 底層的 tools.other、而不是 ktc.onnx_optimizer 的 editor API: +# runtime image 的 ktc.onnx_optimizer 是 kneronnxopt 版、沒有任何 editor API +# (remove_nodes_with_types / cut_graph_from_nodes 都不存在);ktc/onnx_optimizer_1_7.py +# 則因 hardcode /workspace 路徑 + onnx>=1.9 移除 onnx.optimizer 而無法 import。 +# 實測驗證:.autoflow/06-testing/reports/tflite-520-softmax-removal-verify-2026-07-06.md §4 +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +_OPTIMIZER_SCRIPTS_DIR = os.path.join(_REPO_ROOT, "libs", "ONNX_Convertor", "optimizer_scripts") + + +def _ensure_optimizer_scripts_path() -> None: + """把 optimizer_scripts 加進 sys.path(只加一次、避免重複 append)。""" + if _OPTIMIZER_SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _OPTIMIZER_SCRIPTS_DIR) + + +def _find_terminal_softmax_nodes(graph) -> List[Any]: + """回傳 graph 中所有「尾端(terminal)」的 Softmax 節點。 + + terminal 定義:該節點的所有 output 都沒有被任何其他節點當 input 消費 + (沒有 children;典型情境是 Softmax output 直接綁 graph output)。 + + 已知限制:只掃描頂層 graph 節點的 input,不掃 If/Loop 等 control-flow + 節點的子圖(subgraph)內的引用;若 Softmax output 只被子圖消費會被誤判為 + terminal。目前轉檔管線(tflite2onnx / onnx2onnx_flow 產物)不會產生 + control-flow 節點,暫不處理。 + """ + consumed_names = set() + for node in graph.node: + consumed_names.update(node.input) + return [ + node + for node in graph.node + if node.op_type == "Softmax" + and not any(out in consumed_names for out in node.output) + ] + + +def remove_tail_softmax(model: onnx.ModelProto) -> List[str]: + """移除 graph 尾端的 Softmax 節點,讓模型輸出 logits(Softmax 落 host 端後處理)。 + + 背景:KL520 的 batch_compile 對 Softmax 是硬 reject(exit 6、無 CPU fallback), + 而 runtime 的 onnx2onnx_flow(eliminate_tail=True) 是閹割版 no-op(log 會印 + "eliminate_tail is not available"),不會清掉尾端 Softmax。正式站的行為是在 + 模型階段移除尾端 Softmax、NEF 輸出 logits——這裡對所有 platform 一律對齊該行為。 + + 只移除 terminal 的 Softmax:底層 tools.other.remove_nodes 是「cut-from-node」語意 + (砍掉指定節點與其後所有下游節點),對中間層的 Softmax 使用會把後半個模型一起 + 切掉,因此非 terminal 的 Softmax 不動(留給 pre-check / batch_compile 決定)。 + + Args: + model: 已經過 onnx2onnx_flow 的 ONNX model。會被就地修改。 + + Returns: + 被移除的 Softmax 節點名稱清單(無尾端 Softmax 時回空 list、模型不變)。 + """ + terminal_nodes = _find_terminal_softmax_nodes(model.graph) + total_softmax = sum(1 for n in model.graph.node if n.op_type == "Softmax") + + if not terminal_nodes: + if total_softmax: + logger.warning( + "Graph contains %d non-terminal Softmax node(s); not removed " + "(cut-from-node removal would truncate downstream nodes).", + total_softmax, + ) + return [] + + # remove_nodes 的 cut_nodes 用 node.name 比對;防禦性處理沒有名字的節點, + # 並確保 fallback 名稱不與既有節點撞名(撞名會誤砍其他節點)。 + existing_names = {n.name for n in model.graph.node if n.name} + fallback_index = 0 + for node in terminal_nodes: + if not node.name: + while f"tail_softmax_{fallback_index}" in existing_names: + fallback_index += 1 + node.name = f"tail_softmax_{fallback_index}" + existing_names.add(node.name) + + _ensure_optimizer_scripts_path() + from tools import other # libs/ONNX_Convertor/optimizer_scripts/tools/other.py + + # 防呆(silent import shadow):sys.path.insert(0) 讓通用名 "tools" 進程級 + # 最優先;若進程先前已 import 過別的 "tools" package,這裡會拿到快取的錯誤 + # 模組。明確驗證來源路徑、不對就 fail loud。 + other_file = os.path.abspath(getattr(other, "__file__", "") or "") + if not other_file.startswith(_OPTIMIZER_SCRIPTS_DIR + os.sep): + raise RuntimeError( + "Imported 'tools.other' does not come from the expected optimizer_scripts " + f"directory (got: {other_file!r}, expected under: {_OPTIMIZER_SCRIPTS_DIR!r}); " + "another 'tools' package is shadowing it." + ) + + # 移除前:記錄每個被移除節點「餵進 graph output 的 output 名」與其 input 名, + # 供移除後驗證 output 重接沒有靜默遺失。remove_nodes 的重接依賴 value_info + # 內有 logits 條目(onnx2onnx_flow 的 shape inference 會補);缺失時該 output + # 會被直接 Abandon(other.py 的 output reorder 邏輯)——多 output 模型只掉 + # 一個 output 時 graph.output 仍非空、checker 也照過,必須逐一驗證。 + graph_output_names = {o.name for o in model.graph.output} + expected_rewires = [ + (node.name, out_name, list(node.input)) + for node in terminal_nodes + for out_name in node.output + if out_name in graph_output_names + ] + + removed_names = [n.name for n in terminal_nodes] + # 防呆(同名節點):cut_nodes 靠名字比對;若 graph 內有其他節點與被移除的 + # Softmax 同名,remove_nodes 會把它(與其下游)一併砍掉。逐名驗證唯一性。 + for name in removed_names: + matches = sum(1 for n in model.graph.node if n.name == name) + if matches != 1: + raise RuntimeError( + f"Cannot remove tail Softmax: node name {name!r} matches " + f"{matches} nodes in the graph (names must be unique for " + "cut_nodes-based removal); aborting to avoid cutting unrelated nodes." + ) + # remove_nodes 會把被砍節點的輸入(logits)自動接成新的 graph output + #(find_first_sequential_output + output_mapping 邏輯,staging 已實測)。 + other.remove_nodes(model.graph, cut_nodes=list(removed_names)) + + if not model.graph.output: + # 極端情況:所有 output 都被 Abandon。模型已壞,直接 fail loud。 + raise RuntimeError( + "Tail Softmax removal produced a graph with no outputs " + f"(removed nodes: {removed_names}); aborting instead of saving a broken model." + ) + # 逐一驗證:原本由被移除 Softmax 餵的 graph output,其 input(logits)必須 + # 出現在新的 graph output,否則該 output 被靜默丟掉了 → fail loud。 + new_output_names = {o.name for o in model.graph.output} + for node_name, original_output, input_names in expected_rewires: + if not any(name in new_output_names for name in input_names): + raise RuntimeError( + f"Tail Softmax removal silently dropped graph output " + f"'{original_output}' (removed node: {node_name}; expected one of its " + f"inputs {input_names} to become a graph output). This usually means " + "the logits tensor is missing from value_info; aborting instead of " + "saving a broken model." + ) + + # 砍後模型必須仍是合法 ONNX(TDD §12.3 W1 驗收標準)。不合法就直接失敗, + # 不要把壞模型往 bie/nef 送。只在真的有移除時檢查(no-op 路徑零成本)。 + try: + onnx.checker.check_model(model) + except Exception as exc: + raise RuntimeError( + "Model failed ONNX validation after tail-Softmax removal " + f"(removed: {removed_names}): {exc}" + ) from exc + return removed_names + def process_onnx_core( input_paths: Dict[str, str], @@ -34,18 +191,33 @@ def process_onnx_core( raise ValueError("Unsupported file type. Only ONNX and TFLite files are supported.") model = ktc.onnx_optimizer.onnx2onnx_flow(model, eliminate_tail=True, opt_matmul=True) + + # 尾端 Softmax 移除(所有 platform 一律;與正式站行為對齊、輸出 logits)。 + # 詳見 remove_tail_softmax docstring 與 + # .autoflow/06-testing/reports/tflite-520-softmax-removal-verify-2026-07-06.md + removed_softmax = remove_tail_softmax(model) + if removed_softmax: + logger.info( + "Removed %d tail Softmax node(s) %s; model outputs are now logits " + "(apply Softmax as host-side post-processing).", + len(removed_softmax), + removed_softmax, + ) + else: + logger.info("Tail Softmax removal: no-op (no terminal Softmax in graph).") + onnx.save(model, output_path) # Platform / operator 相容性 pre-check(fail-fast)。 - # 在送 bie/nef 之前,用 IP Evaluator 偵測目標 platform 不支援的 op(如 Softmax@520), + # 在送 bie/nef 之前,用 IP Evaluator 偵測目標 platform 不支援的 op, # 避免白跑完 bie + nef 最後才在 batch_compile 撞 C++ backtrace(exit 6)。 # 預設開啟;偵測到「明確不支援」才擋,偵測不到則放行(不誤擋本來能跑的模型)。 # # pre-check 內部會跑一次 evaluate()(compiler frontend 分析、成本高)。若下方 # enable_evaluate 也開,直接重用這份報告,避免對同一模型跑兩次 evaluate()。 - precheck_ran = parameters.get("enable_precheck", True) + precheck_enabled = parameters.get("enable_precheck", True) precheck_report = None - if precheck_ran: + if precheck_enabled: from services.backends.precheck import run_precheck precheck_report = run_precheck( @@ -57,7 +229,7 @@ def process_onnx_core( eval_result = "" if parameters.get("enable_evaluate", False): - if precheck_ran: + if precheck_enabled: # 重用 pre-check 已經跑過的 evaluate() 報告,不再重跑。 # precheck_report 為 None 代表 evaluate() 當時 raise 但無不支援訊號 # (已在 pre-check 放行)→ 沒有可用報告,維持空字串。 @@ -78,6 +250,9 @@ def process_onnx_core( "file_path": output_path, "file_size": os.path.getsize(output_path), "eval_report": eval_result, + # 尾端 Softmax 被移除時,這裡列出節點名稱,提示呼叫端「模型輸出是 logits、 + # Softmax 需在 host 端後處理」。沒移除時為空 list。 + "removed_tail_softmax": removed_softmax, "model_info": { "model_id": parameters["model_id"], "version": parameters["version"], diff --git a/tests/workers/test_onnx_softmax_removal.py b/tests/workers/test_onnx_softmax_removal.py new file mode 100644 index 0000000..9be9b6a --- /dev/null +++ b/tests/workers/test_onnx_softmax_removal.py @@ -0,0 +1,270 @@ +"""Unit tests for tail-Softmax removal in the ONNX worker. + +這些測試需要 ``onnx``(建小型手工 graph)但**不需要 ktc / toolchain**: +`remove_tail_softmax` 走的是 repo 內 libs/ONNX_Convertor/optimizer_scripts 的 +``tools.other.remove_nodes``(純 python + onnx),可以在本機直接驗。 + +模型結構模擬 staging 實測的情境(.autoflow/06-testing/reports/ +tflite-520-softmax-removal-verify-2026-07-06.md):Gemm(logits) → Softmax(terminal)。 +注意:remove_nodes 的 output 重接依賴 value_info 內有 logits 條目(production +flow 由 onnx2onnx_flow 的 shape inference 補齊),因此手工 graph 要自帶 value_info。 +""" + +from pathlib import Path +import sys + +import pytest + +onnx = pytest.importorskip("onnx") + +from onnx import TensorProto, helper # noqa: E402 (after importorskip) + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from services.workers.onnx.core import remove_tail_softmax # noqa: E402 + + +def _make_model_with_tail_softmax(softmax_name="tail_softmax"): + """input -> Gemm(logits) -> Softmax -> output,Softmax 為 terminal。""" + gemm = helper.make_node( + "Gemm", inputs=["x", "w"], outputs=["logits"], name="gemm0" + ) + softmax = helper.make_node( + "Softmax", inputs=["logits"], outputs=["probs"], name=softmax_name + ) + graph = helper.make_graph( + nodes=[gemm, softmax], + name="tail_softmax_graph", + inputs=[ + helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 4]), + helper.make_tensor_value_info("w", TensorProto.FLOAT, [4, 3]), + ], + outputs=[helper.make_tensor_value_info("probs", TensorProto.FLOAT, [1, 3])], + value_info=[ + # logits 的 value_info:remove_nodes 重接 output 時需要(production + # 由 shape inference 補、這裡手工補)。 + helper.make_tensor_value_info("logits", TensorProto.FLOAT, [1, 3]), + ], + ) + return helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + + +def _make_model_without_softmax(): + """input -> Gemm -> Relu -> output,完全沒有 Softmax。""" + gemm = helper.make_node("Gemm", inputs=["x", "w"], outputs=["logits"], name="gemm0") + relu = helper.make_node("Relu", inputs=["logits"], outputs=["y"], name="relu0") + graph = helper.make_graph( + nodes=[gemm, relu], + name="no_softmax_graph", + inputs=[ + helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 4]), + helper.make_tensor_value_info("w", TensorProto.FLOAT, [4, 3]), + ], + outputs=[helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 3])], + value_info=[ + helper.make_tensor_value_info("logits", TensorProto.FLOAT, [1, 3]), + ], + ) + return helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + + +def _make_model_with_tail_softmax_missing_logits_value_info(): + """同 _make_model_with_tail_softmax、但刻意不給 logits 的 value_info。 + + remove_nodes 的 output 重接依賴 value_info;缺失時該 output 會被 Abandon + → 單一 output 模型變成 0 個 output → guard 必須 raise。 + """ + gemm = helper.make_node("Gemm", inputs=["x", "w"], outputs=["logits"], name="gemm0") + softmax = helper.make_node( + "Softmax", inputs=["logits"], outputs=["probs"], name="tail_softmax" + ) + graph = helper.make_graph( + nodes=[gemm, softmax], + name="missing_value_info_graph", + inputs=[ + helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 4]), + helper.make_tensor_value_info("w", TensorProto.FLOAT, [4, 3]), + ], + outputs=[helper.make_tensor_value_info("probs", TensorProto.FLOAT, [1, 3])], + # 刻意不給 value_info + ) + return helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + + +def _make_multi_output_model_with_tail_softmax(with_logits_value_info=True): + """雙 output 模型: + branch 1: x -> Gemm(logits) -> Softmax -> probs(graph output 1) + branch 2: x2 -> Relu -> y(graph output 2、與 Softmax 無關) + """ + gemm = helper.make_node("Gemm", inputs=["x", "w"], outputs=["logits"], name="gemm0") + softmax = helper.make_node( + "Softmax", inputs=["logits"], outputs=["probs"], name="tail_softmax" + ) + relu = helper.make_node("Relu", inputs=["x2"], outputs=["y"], name="relu0") + value_info = ( + [helper.make_tensor_value_info("logits", TensorProto.FLOAT, [1, 3])] + if with_logits_value_info + else [] + ) + graph = helper.make_graph( + nodes=[gemm, softmax, relu], + name="multi_output_graph", + inputs=[ + helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 4]), + helper.make_tensor_value_info("w", TensorProto.FLOAT, [4, 3]), + helper.make_tensor_value_info("x2", TensorProto.FLOAT, [1, 3]), + ], + outputs=[ + helper.make_tensor_value_info("probs", TensorProto.FLOAT, [1, 3]), + helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 3]), + ], + value_info=value_info, + ) + return helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + + +def _make_model_with_softmax_output_also_consumed(): + """Softmax 的 output 同時是 graph output、又被另一個節點消費: + x -> Softmax -> probs(graph output 1)、probs -> Relu -> y(graph output 2)。 + 有 children → 非 terminal → 不可移除。 + """ + softmax = helper.make_node("Softmax", inputs=["x"], outputs=["probs"], name="softmax0") + relu = helper.make_node("Relu", inputs=["probs"], outputs=["y"], name="relu0") + graph = helper.make_graph( + nodes=[softmax, relu], + name="output_also_consumed_graph", + inputs=[helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 3])], + outputs=[ + helper.make_tensor_value_info("probs", TensorProto.FLOAT, [1, 3]), + helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 3]), + ], + value_info=[ + helper.make_tensor_value_info("probs", TensorProto.FLOAT, [1, 3]), + ], + ) + return helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + + +def _make_model_with_mid_graph_softmax(): + """input -> Softmax -> Relu -> output,Softmax 不是 terminal(有 children)。""" + softmax = helper.make_node( + "Softmax", inputs=["x"], outputs=["probs"], name="mid_softmax" + ) + relu = helper.make_node("Relu", inputs=["probs"], outputs=["y"], name="relu0") + graph = helper.make_graph( + nodes=[softmax, relu], + name="mid_softmax_graph", + inputs=[helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 3])], + outputs=[helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 3])], + value_info=[ + helper.make_tensor_value_info("probs", TensorProto.FLOAT, [1, 3]), + ], + ) + return helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + + +def test_remove_tail_softmax_removes_terminal_softmax_and_rewires_logits(): + model = _make_model_with_tail_softmax() + + removed = remove_tail_softmax(model) + + assert removed == ["tail_softmax"] + # Softmax 已不在 graph + assert all(n.op_type != "Softmax" for n in model.graph.node) + # graph output 自動重接成 logits(Softmax 的輸入) + assert [o.name for o in model.graph.output] == ["logits"] + # 砍完的模型仍是合法 ONNX + onnx.checker.check_model(model) + + +def test_remove_tail_softmax_is_noop_when_no_softmax(): + model = _make_model_without_softmax() + nodes_before = [(n.name, n.op_type) for n in model.graph.node] + outputs_before = [o.name for o in model.graph.output] + + removed = remove_tail_softmax(model) + + assert removed == [] + # graph 完全不變(絕不能壞既有 onnx/bie 輸入的 flow) + assert [(n.name, n.op_type) for n in model.graph.node] == nodes_before + assert [o.name for o in model.graph.output] == outputs_before + + +def test_remove_tail_softmax_keeps_mid_graph_softmax(): + # 非 terminal 的 Softmax 不移除:remove_nodes 是 cut-from-node 語意, + # 對中間層 Softmax 使用會把下游一併切掉。 + model = _make_model_with_mid_graph_softmax() + nodes_before = [(n.name, n.op_type) for n in model.graph.node] + + removed = remove_tail_softmax(model) + + assert removed == [] + assert [(n.name, n.op_type) for n in model.graph.node] == nodes_before + assert any(n.op_type == "Softmax" for n in model.graph.node) + + +def test_remove_tail_softmax_handles_unnamed_softmax_node(): + # 節點沒有名字時要有 fallback 命名(cut_nodes 靠 node.name 比對)。 + model = _make_model_with_tail_softmax(softmax_name="") + + removed = remove_tail_softmax(model) + + assert len(removed) == 1 + assert removed[0] # 有給 fallback 名稱 + assert all(n.op_type != "Softmax" for n in model.graph.node) + assert [o.name for o in model.graph.output] == ["logits"] + + +def test_remove_tail_softmax_raises_when_all_outputs_dropped(): + # logits 缺 value_info → remove_nodes 把唯一的 output Abandon + # → graph 剩 0 個 output → 必須 raise、不能存壞模型。 + model = _make_model_with_tail_softmax_missing_logits_value_info() + + with pytest.raises(RuntimeError, match="no outputs"): + remove_tail_softmax(model) + + +def test_remove_tail_softmax_multi_output_rewires_and_keeps_unrelated_output(): + # 多 output 模型:Softmax 餵的 output 重接成 logits、無關的 output 保留。 + model = _make_multi_output_model_with_tail_softmax(with_logits_value_info=True) + + removed = remove_tail_softmax(model) + + assert removed == ["tail_softmax"] + assert all(n.op_type != "Softmax" for n in model.graph.node) + assert {o.name for o in model.graph.output} == {"logits", "y"} + onnx.checker.check_model(model) + + +def test_remove_tail_softmax_multi_output_raises_on_silently_dropped_output(): + # 多 output 模型 + logits 缺 value_info:Softmax 餵的 output 被 Abandon、 + # 但另一個 output 還在(graph.output 非空、checker 也會過)—— + # 逐 output 驗證的 guard 必須抓到並 raise(Major-3)。 + model = _make_multi_output_model_with_tail_softmax(with_logits_value_info=False) + + with pytest.raises(RuntimeError, match="silently dropped"): + remove_tail_softmax(model) + + +def test_remove_tail_softmax_raises_on_duplicate_node_name(): + # cut_nodes 靠名字比對:病態的同名節點(Softmax 與其他節點撞名) + # 會導致 remove_nodes 誤砍無關節點 → 必須 raise 而非靜默砍。 + model = _make_model_with_tail_softmax(softmax_name="dup") + model.graph.node[0].name = "dup" # gemm0 改成與 Softmax 同名 + + with pytest.raises(RuntimeError, match="matches 2 nodes"): + remove_tail_softmax(model) + + +def test_remove_tail_softmax_keeps_softmax_whose_output_is_also_consumed(): + # Softmax output 同時是 graph output 又被其他節點消費 → 非 terminal、不移除。 + model = _make_model_with_softmax_output_also_consumed() + nodes_before = [(n.name, n.op_type) for n in model.graph.node] + outputs_before = [o.name for o in model.graph.output] + + removed = remove_tail_softmax(model) + + assert removed == [] + assert [(n.name, n.op_type) for n in model.graph.node] == nodes_before + assert [o.name for o in model.graph.output] == outputs_before diff --git a/tests/workers/test_precheck.py b/tests/workers/test_precheck.py index a4888e4..ce95ed3 100644 --- a/tests/workers/test_precheck.py +++ b/tests/workers/test_precheck.py @@ -64,10 +64,39 @@ def test_find_unsupported_detects_hw_not_support_with_op(): assert find_unsupported_operators(report) == ["Softmax"] -def test_find_unsupported_signal_without_op_returns_placeholder(): - # 有明確訊號但抽不到 op 名稱 → 回占位符,讓呼叫端仍能 fail-fast。 - report = "kdp520/ERROR, HW not support" - assert find_unsupported_operators(report) == [""] +def test_find_unsupported_hw_not_supported_suffix_variants(): + # marker 的 "supported" 字尾要被 marker 本身吃掉: + # 後面跟真 op → 照擋;後面跟表格值 → 放行("ed" 不可被誤捕成 op 名)。 + assert find_unsupported_operators("HW_NOT_SUPPORTED: Softmax") == ["Softmax"] + assert find_unsupported_operators("Hardware not supported: None") == [] + assert find_unsupported_operators("hardware not supported") == [] + + +def test_find_unsupported_free_text_fragment_passes_fail_open(): + # unimplemented 的自由文字片段不做「首字母大寫字」猜測: + # "Not" 是真 ONNX op、"CPU" 不是對的 op —— 猜錯(誤擋 / 錯訊息)比放行更糟。 + assert ( + find_unsupported_operators("UnimplementedFeature: Not supported in this mode") + == [] + ) + # 無中括號的自由文字(抓不到明確 op)→ 放行,而不是抓錯成 "CPU"。 + assert ( + find_unsupported_operators("UnimplementedFeature: undefined CPU op Softmax") + == [] + ) + + +def test_find_unsupported_marker_without_op_passes_fail_open(): + # marker 字樣但抽不到具體 op 名稱 → 放行(真 fail-open)。 + # "HW not support" 很可能只是 model_fx_report 的欄位分類名(表頭), + # 光是出現不代表模型有不支援 op(R3 誤擋根因)。 + assert find_unsupported_operators("kdp520/ERROR, HW not support") == [] + # 欄位名後面接表格值(非 op)也不能擋。 + assert find_unsupported_operators("HW not support: 0") == [] + assert find_unsupported_operators("Hardware not support: None") == [] + assert find_unsupported_operators("kdp520/ip_eval/HW not support: N/A") == [] + # 光禿禿的 "UnimplementedFeature" 欄位名(無後續內容)也放行。 + assert find_unsupported_operators("UnimplementedFeature") == [] def test_find_unsupported_does_not_false_positive_on_supported_wording(): @@ -133,8 +162,8 @@ def test_build_error_message_with_op_names(): assert "720" in msg or "730" in msg -def test_build_error_message_with_unknown_op(): - msg = build_error_message("520", [""]) +def test_build_error_message_without_op_names_falls_back_to_generic(): + msg = build_error_message("520", []) assert "520" in msg assert "operator" in msg @@ -189,6 +218,22 @@ def test_run_precheck_raises_when_evaluate_raises_with_signal(): assert "Softmax" in str(exc.value) +def test_run_precheck_passes_on_marker_only_report(): + # 報告帶「HW not support」欄位名但無具體 op → 放行不 raise(真 fail-open)。 + evaluator = _FakeEvaluator( + report="kdp520/ip_eval/HW not support: N/A, kdp520/FPS: 1200" + ) + # 不應 raise + run_precheck( + "/tmp/out.onnx", + model_id=6, + version="v1", + platform="520", + evaluator=evaluator, + ) + assert len(evaluator.calls) == 1 + + def test_run_precheck_passes_when_evaluate_raises_without_signal(): # evaluate() raise,但訊息不含不支援訊號(transient / 環境問題)→ 放行不誤擋。 evaluator = _FakeEvaluator(