Compare commits

..

No commits in common. "407fe10a8c9a6715449bfc54721aacd080ac43f2" and "25ce7f87a817f85d9df8d161705b08dff59b9622" have entirely different histories.

7 changed files with 39 additions and 686 deletions

View File

@ -503,7 +503,6 @@
- 流程固定ONNX → BIE → NEF不支援跳過或只跑部分後端限制 - 流程固定ONNX → BIE → NEF不支援跳過或只跑部分後端限制
- 任何階段失敗即整體 FAILED不自動重試 - 任何階段失敗即整體 FAILED不自動重試
- **ONNX 階段輸出語意2026-07-06 M 級變更)**ONNX 優化後、存檔前對所有 platform 移除尾端 Softmax故 NEF 最終輸出為 logits 而非機率。詳見 §11 註與 `../04-architecture/TDD.md` §12。
### 5.5 對外 API 端點預告新增2026-04-25待 Architect 產出詳細規格) ### 5.5 對外 API 端點預告新增2026-04-25待 Architect 產出詳細規格)
@ -803,18 +802,12 @@ RICE = (Reach x Impact x Confidence) / Effort
| 平台代號 | 平台名稱 | 備註 | | 平台代號 | 平台名稱 | 備註 |
|---------|---------|------| |---------|---------|------|
| 520 | KDP520 | 含尾端 Softmax 的模型自 2026-07-06 起可轉(見下方註)| | 520 | KDP520 | - |
| 720 | KDP720 | - | | 720 | KDP720 | - |
| 530 | KDP530 | - | | 530 | KDP530 | - |
| 630 | KDP630 | - | | 630 | KDP630 | - |
| 730 | KDP730 | - | | 730 | KDP730 | - |
> **輸出語意變更2026-07-06M 級)**:轉檔管線自 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. 風險與緩解 ## 12. 風險與緩解

View File

@ -17,7 +17,6 @@
| 2026-04-25 | 初版 Draft 1.0OAuth resource server + promote | Architect Agent | | 2026-04-25 | 初版 Draft 1.0OAuth resource server + promote | Architect Agent |
| 2026-04-25 | Multipart 上傳路徑改 visionA → converter 直傳;移除 FAA GET/HEAD | Architect Agent | | 2026-04-25 | Multipart 上傳路徑改 visionA → converter 直傳;移除 FAA GET/HEAD | Architect Agent |
| 2026-05-16 | **Phase 0.8b 重寫**visionA → converter 改 API key新增 `/result` endpointOAuth resource server 章節砍除;模組化拆分為索引 + 子檔案 | Architect Agent | | 2026-05-16 | **Phase 0.8b 重寫**visionA → converter 改 API key新增 `/result` endpointOAuth resource server 章節砍除;模組化拆分為索引 + 子檔案 | Architect Agent |
| 2026-07-06 | **Worker 行為變更M 級)**onnx worker 對**所有 platform** 一律移除尾端 Softmax、輸出 logits與正式站對齊、修 520 batch_compile exit 6pre-check regex 收窄。新增 §12 | Architect Agent |
--- ---
@ -38,7 +37,6 @@
| `observability.md` | Log 格式 + 敏感資料保護 + 告警 | Backend | | `observability.md` | Log 格式 + 敏感資料保護 + 告警 | Backend |
| `security.md` | Trust boundary + Input validation + Auth security | 全部 | | `security.md` | Trust boundary + Input validation + Auth security | 全部 |
| `design-doc.md` | 架構決策 + ADR | 全部 | | `design-doc.md` | 架構決策 + ADR | 全部 |
| 本檔 §12 | **Worker 轉檔管線行為變更**(尾端 Softmax 移除 + pre-check regex 收窄2026-07-06 M 級)| Backend、Reviewer、Testing |
--- ---
@ -293,94 +291,4 @@ apps/task-scheduler/
--- ---
## 12. Worker 轉檔管線行為變更2026-07-06M 級) **附註**:本 TDD 從 1390 行單檔重組為 ~180 行索引 + 8 個子檔案。每個子檔案 < 500 單一職責可獨立給 Backend / Reviewer / Testing 不同角色讀對應檔案減少 context 負擔
> **範圍界定**:本 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 實測比對正式站 .nefoutput = `(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 實測修訂)****只移除 terminalgraph 尾端、無下游節點)的 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 APIremove_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` 雖存在但不可 importhardcode `/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 名稱時放行 + warningfail-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 報告 §4apre-check 誤擋分析):見 `.autoflow/06-testing/reports/tflite-520-softmax-removal-verify-2026-07-06.md` §5R3 誤擋風險實測數據點)+ 舊 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 節點名清單>)`**只移除 terminal Softmax、中間層不動**;不可用 `cut_types=["Softmax"]`,會誤砍中間層下游);砍後模型過 `onnx.checker`520 e2e 不再 exit 6、產真 .nefjob 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 負擔§122026-07-06 新增 worker 轉檔管線行為變更 API/auth 層獨立

View File

@ -17,7 +17,6 @@
| 2026-04-25 | 初版 DraftOAuth resource server + promote 設計 | Architect Agent | | 2026-04-25 | 初版 DraftOAuth resource server + promote 設計 | Architect Agent |
| 2026-04-25 | 原始模型上傳路徑改 multipart 直傳;移除 FAA GET/HEAD 相關 | 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 clientpromote 用) | Architect Agent | | 2026-05-16 | **Phase 0.8b 重寫**visionA → converter 改 API key新增 `GET /api/v1/jobs/:id/result` streaming endpoint保留 converter → FAA OAuth clientpromote 用) | Architect Agent |
| 2026-07-06 | **M 級 worker 行為變更**onnx worker 對所有 platform 移除尾端 Softmax、輸出 logits新增 ADR-012pre-check regex 收窄。影響「輸出語意」(機率 → logits需通知 visionA | Architect Agent |
--- ---
@ -253,7 +252,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 — 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` | | 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 資料模型 | **不動** | | Redis 資料模型 | **不動** |
| Workers | Phase 0.8b **不動****2026-07-06 M 級變更**onnx worker 加「移除尾端 Softmax、輸出 logits」步驟所有 platform詳見 ADR-012 + `TDD.md` §12 | | Workers | **不動** |
| MinIOConverter Bucket | **不動** | | MinIOConverter Bucket | **不動** |
| FAA / MC | **不動**converter → FAA 仍走 OAuth client_credentials | | FAA / MC | **不動**converter → FAA 仍走 OAuth client_credentials |
@ -526,37 +525,6 @@ Web UI 仍走 `/jobs/*` 路徑、無 auth。Phase 0.8b 不動。
--- ---
### ADR-012onnx worker 對所有 platform 移除尾端 Softmax、輸出 logits**2026-07-06 新增M 級**
**狀態**Accepted2026-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 實測比對正式站 .nefoutput 是 `(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 端補 softmaxargmax 分類結果通常不受影響)。
- **需通知 visionA 團隊**(交付前跨團隊溝通項)。
**替代方案**
- A. 只對 520 移除 Softmax其他 platform 保留)— 排除:造成 platform 輸出分歧、與正式站不一致、呼叫端要判斷 platform。
- B. 換 toolchain 版本讓 520 容忍 SoftmaxH3 假設)— 排除:正式站 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. 風險與待確認事項 ## 9. 風險與待確認事項
| # | 風險 / 議題 | 影響 | 行動 | | # | 風險 / 議題 | 影響 | 行動 |

View File

@ -11,10 +11,8 @@ op_type:`` 等訊號toolchain 會把這些寫進 ``model_fx_report``。因此
不需要跑完 quantization + batch_compile就偵測到不支援的 opfail-fast 並回一個 不需要跑完 quantization + batch_compile就偵測到不支援的 opfail-fast 並回一個
使用者看得懂的錯誤而不是讓 job 跑到 nef 階段撞 C++ backtraceexit 6 使用者看得懂的錯誤而不是讓 job 跑到 nef 階段撞 C++ backtraceexit 6
設計原則 fail-open避免誤擋 設計原則避免誤擋
- 明確訊號= 不支援 marker **加上一個具體的 operator 名稱**只出現 marker 字樣 - 只有在 evaluator 報告中出現明確的不支援訊號時才擋
例如報告表頭的 "HW not support" 欄位名但抽不出 op 名稱時 放行 + warning
不擋誤擋能轉的模型false positive的代價高於晚一點在 nef 階段失敗
- evaluator 分析失敗例如環境問題transient error而沒有明確不支援訊號時 放行 - evaluator 分析失敗例如環境問題transient error而沒有明確不支援訊號時 放行
讓後續流程照舊跑不因為 pre-check 有疑慮就擋掉本來能跑的模型 讓後續流程照舊跑不因為 pre-check 有疑慮就擋掉本來能跑的模型
""" """
@ -39,74 +37,53 @@ class UnsupportedOperatorError(Exception):
# 對照來源vendor/sys_flow*/test_case.py::check_compiler_HardwareNotSupport 與 # 對照來源vendor/sys_flow*/test_case.py::check_compiler_HardwareNotSupport 與
# check_knerex_error 寫入 model_fx_report 的字串。 # 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
# 會出現在正常報告的表頭,光是出現不代表模型有不支援 opR3 誤擋根因,
# 見 .autoflow/05-implementation/tflite-520-rootcause-2026-07-06.md §3a/§4a
# 「HW not support: <Op>」帶具體 op 的強訊號仍由 hw_not_support pattern
# 涵蓋IGNORECASE + [_ ] 分隔)。
_UNSUPPORTED_MARKERS: tuple[tuple[str, str], ...] = ( _UNSUPPORTED_MARKERS: tuple[tuple[str, str], ...] = (
# "creating an EmptyNode instance for op_type: Softmax" # "creating an EmptyNode instance for op_type: Softmax"
("empty_node", r"creating an EmptyNode instance for op_type:\s*([A-Za-z0-9_]+)"), ("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: Softmax" / "HardwareNotSupport: Softmax" / "Hardware not support: Softmax"
# op capture group 為必須marker 後面一定要跟具體 op 名稱才算訊號。 ("hw_not_support", r"(?:HW[_ ]NOT[_ ]SUPPORT|Hardware\s*not\s*support|HardwareNotSupport)\s*[:\-]?\s*([A-Za-z0-9_]+)?"),
# 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_FEATURE: ..." / "UnimplementedFeature: undefined CPU op [Softmax]"
("unimplemented", r"(?:UNIMPLEMENTED[_ ]FEATURE|UnimplementedFeature)\s*[:\-]?\s*(.*)"), ("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*[\]\)]") _OP_IN_BRACKETS = re.compile(r"[\[\(]\s*([A-Za-z][A-Za-z0-9_]*)\s*[\]\)]")
# 報告表格常見的「非 op」值欄位名如 "HW not support")後面接的可能是這些字, # 常見 ONNX op 名稱PascalCase / 首字母大寫),用來在自由文字裡挑一個像 op 的字。
# 不能把它們當成 operator 名稱(否則又變回誤擋)。 _OP_TOKEN = re.compile(r"\b([A-Z][A-Za-z0-9]{2,})\b")
_NON_OP_TOKENS = frozenset({"none", "null", "nan", "na", "n", "true", "false", "yes", "no"})
def _extract_op_name(fragment: Optional[str]) -> Optional[str]: def _extract_op_name(fragment: Optional[str]) -> Optional[str]:
"""從訊息片段中抽出 operator 名稱。抓不到(或抓到的不像 op就回 None。 """盡力從訊息片段中抽出 operator 名稱。抓不到就回 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: if not fragment:
return None return None
fragment = fragment.strip() fragment = fragment.strip()
if not fragment: if not fragment:
return None return None
candidate: Optional[str] = None
m = _OP_IN_BRACKETS.search(fragment) m = _OP_IN_BRACKETS.search(fragment)
if m: if m:
candidate = m.group(1) return m.group(1)
elif re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", fragment):
# 直接就是一個 op token例如 "Softmax"
candidate = fragment
if candidate and candidate.lower() in _NON_OP_TOKENS: # 直接就是一個 op token例如 "Softmax"
return None if re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", fragment):
return candidate return fragment
m = _OP_TOKEN.search(fragment)
if m:
return m.group(1)
return None
def find_unsupported_operators(report: Optional[str]) -> List[str]: def find_unsupported_operators(report: Optional[str]) -> List[str]:
"""掃描 evaluator 報告字串,回傳偵測到的「不支援 operator 名稱」清單。 """掃描 evaluator 報告字串,回傳偵測到的「不支援 operator 名稱」清單。
- 沒有偵測到任何明確不支援訊號 回空 list呼叫端應放行 - 沒有偵測到任何明確不支援訊號 回空 list呼叫端應放行
- 偵測到 marker 字樣但抽不出具體 op 名稱 **放行**回空 list+ warning log - 偵測到訊號但抽不出 op 名稱 回含一個占位符 ``"<unknown>"`` list
marker 字樣可能只是報告表頭的欄位名例如 "HW not support" 讓呼叫端仍能 fail-fast但訊息較泛用
光是出現不足以斷定模型有不支援 op擋錯的代價誤擋能轉的模型
比晚一點在 nef 階段失敗更高 fail-open
""" """
if not report: if not report:
return [] return []
@ -125,17 +102,14 @@ def find_unsupported_operators(report: Optional[str]) -> List[str]:
if found: if found:
return found return found
if saw_marker: if saw_marker:
logger.warning( # 有明確不支援訊號、但抽不到 op 名稱:仍回報(用占位符)。
"Pre-check saw an unsupported-op marker but could not extract a concrete " return ["<unknown>"]
"operator name; treating as inconclusive and allowing the job to proceed "
"(fail-open)."
)
return [] return []
def build_error_message(platform: str, ops: Iterable[str]) -> str: def build_error_message(platform: str, ops: Iterable[str]) -> str:
"""產生使用者看得懂的錯誤訊息。""" """產生使用者看得懂的錯誤訊息。"""
op_list = [o for o in ops if o] op_list = [o for o in ops if o and o != "<unknown>"]
if op_list: if op_list:
ops_text = ", ".join(op_list) ops_text = ", ".join(op_list)
op_clause = f"operator {ops_text}" op_clause = f"operator {ops_text}"

View File

@ -1,165 +1,8 @@
import logging
import os import os
import sys from typing import Dict, Any
from typing import Any, Dict, List
import onnx 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 節點,讓模型輸出 logitsSoftmax 落 host 端後處理)。
背景KL520 batch_compile Softmax 是硬 rejectexit 6 CPU fallback
runtime onnx2onnx_flow(eliminate_tail=True) 是閹割版 no-oplog 會印
"eliminate_tail is not available"不會清掉尾端 Softmax正式站的行為是在
模型階段移除尾端 SoftmaxNEF 輸出 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 shadowsys.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
# 會被直接 Abandonother.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其 inputlogits必須
# 出現在新的 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."
)
# 砍後模型必須仍是合法 ONNXTDD §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( def process_onnx_core(
input_paths: Dict[str, str], input_paths: Dict[str, str],
@ -191,33 +34,18 @@ def process_onnx_core(
raise ValueError("Unsupported file type. Only ONNX and TFLite files are supported.") 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) 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) onnx.save(model, output_path)
# Platform / operator 相容性 pre-checkfail-fast # Platform / operator 相容性 pre-checkfail-fast
# 在送 bie/nef 之前,用 IP Evaluator 偵測目標 platform 不支援的 op # 在送 bie/nef 之前,用 IP Evaluator 偵測目標 platform 不支援的 op如 Softmax@520
# 避免白跑完 bie + nef 最後才在 batch_compile 撞 C++ backtraceexit 6 # 避免白跑完 bie + nef 最後才在 batch_compile 撞 C++ backtraceexit 6
# 預設開啟;偵測到「明確不支援」才擋,偵測不到則放行(不誤擋本來能跑的模型)。 # 預設開啟;偵測到「明確不支援」才擋,偵測不到則放行(不誤擋本來能跑的模型)。
# #
# pre-check 內部會跑一次 evaluate()compiler frontend 分析、成本高)。若下方 # pre-check 內部會跑一次 evaluate()compiler frontend 分析、成本高)。若下方
# enable_evaluate 也開,直接重用這份報告,避免對同一模型跑兩次 evaluate()。 # enable_evaluate 也開,直接重用這份報告,避免對同一模型跑兩次 evaluate()。
precheck_enabled = parameters.get("enable_precheck", True) precheck_ran = parameters.get("enable_precheck", True)
precheck_report = None precheck_report = None
if precheck_enabled: if precheck_ran:
from services.backends.precheck import run_precheck from services.backends.precheck import run_precheck
precheck_report = run_precheck( precheck_report = run_precheck(
@ -229,7 +57,7 @@ def process_onnx_core(
eval_result = "" eval_result = ""
if parameters.get("enable_evaluate", False): if parameters.get("enable_evaluate", False):
if precheck_enabled: if precheck_ran:
# 重用 pre-check 已經跑過的 evaluate() 報告,不再重跑。 # 重用 pre-check 已經跑過的 evaluate() 報告,不再重跑。
# precheck_report 為 None 代表 evaluate() 當時 raise 但無不支援訊號 # precheck_report 為 None 代表 evaluate() 當時 raise 但無不支援訊號
# (已在 pre-check 放行)→ 沒有可用報告,維持空字串。 # (已在 pre-check 放行)→ 沒有可用報告,維持空字串。
@ -250,9 +78,6 @@ def process_onnx_core(
"file_path": output_path, "file_path": output_path,
"file_size": os.path.getsize(output_path), "file_size": os.path.getsize(output_path),
"eval_report": eval_result, "eval_report": eval_result,
# 尾端 Softmax 被移除時,這裡列出節點名稱,提示呼叫端「模型輸出是 logits、
# Softmax 需在 host 端後處理」。沒移除時為空 list。
"removed_tail_softmax": removed_softmax,
"model_info": { "model_info": {
"model_id": parameters["model_id"], "model_id": parameters["model_id"],
"version": parameters["version"], "version": parameters["version"],

View File

@ -1,270 +0,0 @@
"""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.mdGemm(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 -> outputSoftmax 為 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_inforemove_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 -> probsgraph output 1
branch 2: x2 -> Relu -> ygraph 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 -> probsgraph output 1probs -> Relu -> ygraph 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 -> outputSoftmax 不是 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 自動重接成 logitsSoftmax 的輸入)
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_infoSoftmax 餵的 output 被 Abandon、
# 但另一個 output 還在graph.output 非空、checker 也會過)——
# 逐 output 驗證的 guard 必須抓到並 raiseMajor-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

View File

@ -64,39 +64,10 @@ def test_find_unsupported_detects_hw_not_support_with_op():
assert find_unsupported_operators(report) == ["Softmax"] assert find_unsupported_operators(report) == ["Softmax"]
def test_find_unsupported_hw_not_supported_suffix_variants(): def test_find_unsupported_signal_without_op_returns_placeholder():
# marker 的 "supported" 字尾要被 marker 本身吃掉: # 有明確訊號但抽不到 op 名稱 → 回占位符,讓呼叫端仍能 fail-fast。
# 後面跟真 op → 照擋;後面跟表格值 → 放行("ed" 不可被誤捕成 op 名)。 report = "kdp520/ERROR, HW not support"
assert find_unsupported_operators("HW_NOT_SUPPORTED: Softmax") == ["Softmax"] assert find_unsupported_operators(report) == ["<unknown>"]
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 的欄位分類名(表頭),
# 光是出現不代表模型有不支援 opR3 誤擋根因)。
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(): def test_find_unsupported_does_not_false_positive_on_supported_wording():
@ -162,8 +133,8 @@ def test_build_error_message_with_op_names():
assert "720" in msg or "730" in msg assert "720" in msg or "730" in msg
def test_build_error_message_without_op_names_falls_back_to_generic(): def test_build_error_message_with_unknown_op():
msg = build_error_message("520", []) msg = build_error_message("520", ["<unknown>"])
assert "520" in msg assert "520" in msg
assert "operator" in msg assert "operator" in msg
@ -218,22 +189,6 @@ def test_run_precheck_raises_when_evaluate_raises_with_signal():
assert "Softmax" in str(exc.value) 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(): def test_run_precheck_passes_when_evaluate_raises_without_signal():
# evaluate() raise但訊息不含不支援訊號transient / 環境問題)→ 放行不誤擋。 # evaluate() raise但訊息不含不支援訊號transient / 環境問題)→ 放行不誤擋。
evaluator = _FakeEvaluator( evaluator = _FakeEvaluator(