feat(worker): 加 platform/operator pre-check(不支援 op 早期快速失敗)

轉檔時若 op 在目標 platform 不支援(如 Softmax@520),原本會白跑完 onnx+bie
兩階段、最後在 nef 的 batch_compile 拋難懂的 C++ backtrace(undefined CPU op)。
本次在 onnx 階段(拿到 out.onnx 後、進 bie 前)用 Kneron IP Evaluator
(ktc ModelConfig.evaluate / ip_evaluator mode 0 會跑 compiler frontend)
早期偵測不支援 op,快速失敗並回清楚訊息。

- 新增 services/backends/precheck.py:run_precheck 掃 evaluate 的不支援訊號
  (HardwareNotSupport / UnimplementedFeature / undefined CPU op / EmptyNode)
  抽出 op 名、raise UnsupportedOperatorError;錯誤走既有 _push_done(fail) 回報
- services/workers/onnx/core.py:enable_precheck default-on;pre-check 與既有
  evaluate 共用同一次 evaluate() 呼叫(避免跑兩次 compiler frontend)
- error 訊息:「platform 520 不支援 operator Softmax,請改用支援的 platform
  (如 720/730)或將該 operator 移至 host 端後處理。」
- fail-open:只有明確訊號才擋、evaluate 環境問題無訊號則放行(不誤擋能跑的模型)
- 17 tests pass(含多 op / dedup)

follow-up(部署後、需 docker toolchain 環境):integration smoke 真觸發
Softmax@520 確認 onnx 階段就擋;依實測 frontend 耗時評估是否收窄 default-on 粒度。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-03 02:51:07 +08:00
parent 57ffc619f9
commit 5a9c1b0140
7 changed files with 472 additions and 41 deletions

View File

@ -0,0 +1,183 @@
"""Platform / operator compatibility pre-check.
在轉檔流程早期ONNX 階段拿到 out.onnx 之後 bie/nef 之前 IP Evaluator
的分析結果判斷模型是否含有目標 platform 不支援的 operator例如 Softmax@520
背景Kneron toolchain ``ktc.ModelConfig(...).evaluate()``ip_evaluator, mode 0
內部會跑 compiler frontend node placement 分析當某個 op 在目標 platform 無法
落在 NPU且沒有對應的 CPU op 定義時compiler frontend 會在 log 內留下
``HardwareNotSupport`` / ``UnimplementedFeature`` / ``creating an EmptyNode instance for
op_type:`` 等訊號toolchain 會把這些寫進 ``model_fx_report``因此我們可以在編譯前
不需要跑完 quantization + batch_compile就偵測到不支援的 opfail-fast 並回一個
使用者看得懂的錯誤而不是讓 job 跑到 nef 階段撞 C++ backtraceexit 6
設計原則避免誤擋
- 只有在 evaluator 報告中出現明確的不支援訊號時才擋
- evaluator 分析失敗例如環境問題transient error而沒有明確不支援訊號時 放行
讓後續流程照舊跑不因為 pre-check 有疑慮就擋掉本來能跑的模型
"""
from __future__ import annotations
import logging
import re
from typing import Iterable, List, Optional
logger = logging.getLogger(__name__)
class UnsupportedOperatorError(Exception):
"""目標 platform 不支援模型中的某個 operator。
這是一個明確且可預期的失敗訊息會直接透過 job 失敗回報機制傳回給使用者
"""
# Compiler frontend / ip_evaluator 在遇到不支援 op 時會在報告 / log 留下的訊號。
# 對照來源vendor/sys_flow*/test_case.py::check_compiler_HardwareNotSupport 與
# check_knerex_error 寫入 model_fx_report 的字串。
#
# 每個 pattern 盡量帶一個 capture group 用來抓「op 名稱」(抓不到也沒關係,會退回泛用訊息)。
_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_]+)?"),
# "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_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")
def _extract_op_name(fragment: Optional[str]) -> Optional[str]:
"""盡力從訊息片段中抽出 operator 名稱。抓不到就回 None。"""
if not fragment:
return None
fragment = fragment.strip()
if not fragment:
return None
m = _OP_IN_BRACKETS.search(fragment)
if m:
return m.group(1)
# 直接就是一個 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
def find_unsupported_operators(report: Optional[str]) -> List[str]:
"""掃描 evaluator 報告字串,回傳偵測到的「不支援 operator 名稱」清單。
- 沒有偵測到任何明確不支援訊號 回空 list呼叫端應放行
- 偵測到訊號但抽不出 op 名稱 回含一個占位符 ``"<unknown>"`` list
讓呼叫端仍能 fail-fast但訊息較泛用
"""
if not report:
return []
found: List[str] = []
saw_marker = False
for _kind, pattern in _UNSUPPORTED_MARKERS:
for match in re.finditer(pattern, report, flags=re.IGNORECASE):
saw_marker = True
captured = match.group(1) if match.groups() else None
op = _extract_op_name(captured)
if op and op not in found:
found.append(op)
if found:
return found
if saw_marker:
# 有明確不支援訊號、但抽不到 op 名稱:仍回報(用占位符)。
return ["<unknown>"]
return []
def build_error_message(platform: str, ops: Iterable[str]) -> str:
"""產生使用者看得懂的錯誤訊息。"""
op_list = [o for o in ops if o and o != "<unknown>"]
if op_list:
ops_text = ", ".join(op_list)
op_clause = f"operator {ops_text}"
else:
op_clause = "某個 operator"
return (
f"platform {platform} 不支援 {op_clause}"
f"請改用支援的 platform如 720/730或將該 operator 移至 host 端後處理。"
)
def run_precheck(
onnx_path: str,
*,
model_id: int,
version: str,
platform: str,
evaluator=None,
) -> Optional[str]:
"""對 ``onnx_path`` 跑 platform/operator 相容性檢查。
偵測到明確不支援 op raise :class:`UnsupportedOperatorError`
其餘情況分析成功且全支援或分析失敗但無明確訊號 放行
放行時回傳 ``evaluate()`` 的原始報告字串成功分析 ``None``
evaluate() raise 但無明確不支援訊號回傳值讓呼叫端可以**重用**這份
報告避免 ``enable_precheck`` ``enable_evaluate`` 同開時重複跑一次
``evaluate()``compiler frontend 分析成本高~數十秒級
Args:
onnx_path: 已經過 onnx2onnx_flow out.onnx 路徑
model_id / version / platform: 目標編譯設定
evaluator: 可注入的 evaluator backend測試用None 時使用預設 Kneron backend
Returns:
evaluate() 的報告字串放行且分析成功 None放行但分析失敗無訊號
"""
if evaluator is None:
from services.backends.evaluator import get_evaluator_backend
evaluator = get_evaluator_backend()
report: Optional[str] = None
try:
report = evaluator.evaluate(
onnx_path,
model_id=int(model_id),
version=str(version),
platform=str(platform),
)
except Exception as exc: # noqa: BLE001 - 需要檢查例外訊息內容
# evaluate() 在遇到不支援 op 時可能直接 raise例如 AssertionError
# 用例外訊息掃描明確訊號;掃不到就當成「分析失敗但不確定」→ 放行(不誤擋)。
message = str(exc)
ops = find_unsupported_operators(message)
if ops:
raise UnsupportedOperatorError(build_error_message(platform, ops)) from exc
logger.warning(
"Compatibility pre-check inconclusive (evaluator raised, no explicit "
"unsupported-op signal); allowing job to proceed. platform=%s error=%s",
platform,
message,
)
return None
ops = find_unsupported_operators(report)
if ops:
raise UnsupportedOperatorError(build_error_message(platform, ops))
logger.info("Compatibility pre-check passed. platform=%s", platform)
return report

View File

@ -36,17 +36,42 @@ def process_onnx_core(
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)
onnx.save(model, output_path) onnx.save(model, output_path)
eval_result = "" # Platform / operator 相容性 pre-checkfail-fast
if parameters.get("enable_evaluate", False): # 在送 bie/nef 之前,用 IP Evaluator 偵測目標 platform 不支援的 op如 Softmax@520
from services.backends.evaluator import get_evaluator_backend # 避免白跑完 bie + nef 最後才在 batch_compile 撞 C++ backtraceexit 6
# 預設開啟;偵測到「明確不支援」才擋,偵測不到則放行(不誤擋本來能跑的模型)。
#
# pre-check 內部會跑一次 evaluate()compiler frontend 分析、成本高)。若下方
# enable_evaluate 也開,直接重用這份報告,避免對同一模型跑兩次 evaluate()。
precheck_ran = parameters.get("enable_precheck", True)
precheck_report = None
if precheck_ran:
from services.backends.precheck import run_precheck
evaluator = get_evaluator_backend() precheck_report = run_precheck(
evaluate_result = evaluator.evaluate(
output_path, output_path,
model_id=int(parameters["model_id"]), model_id=int(parameters["model_id"]),
version=str(parameters["version"]), version=str(parameters["version"]),
platform=str(parameters["platform"]), platform=str(parameters["platform"]),
) )
eval_result = ""
if parameters.get("enable_evaluate", False):
if precheck_ran:
# 重用 pre-check 已經跑過的 evaluate() 報告,不再重跑。
# precheck_report 為 None 代表 evaluate() 當時 raise 但無不支援訊號
# (已在 pre-check 放行)→ 沒有可用報告,維持空字串。
evaluate_result = precheck_report or ""
else:
from services.backends.evaluator import get_evaluator_backend
evaluator = get_evaluator_backend()
evaluate_result = evaluator.evaluate(
output_path,
model_id=int(parameters["model_id"]),
version=str(parameters["version"]),
platform=str(parameters["platform"]),
)
eval_result = evaluate_result.split(",")[0] eval_result = evaluate_result.split(",")[0]
return { return {

View File

@ -49,6 +49,8 @@ def test_worker_flow_e2e_uses_single_workdir():
"platform": "520", "platform": "520",
"work_dir": str(work_dir), "work_dir": str(work_dir),
"enable_evaluate": False, "enable_evaluate": False,
# 此測試聚焦 workdir 隔離、非相容性檢查;關掉 pre-check 保持原行為與速度。
"enable_precheck": False,
} }
onnx_result = process_onnx_core( onnx_result = process_onnx_core(
{"file_path": str(work_input_file)}, {"file_path": str(work_input_file)},

View File

@ -49,6 +49,8 @@ def test_worker_flow_e2e_tflite_uses_single_workdir():
"platform": "520", "platform": "520",
"work_dir": str(work_dir), "work_dir": str(work_dir),
"enable_evaluate": False, "enable_evaluate": False,
# 此測試聚焦端到端流程、非相容性檢查;關掉 pre-check 保持原行為與速度。
"enable_precheck": False,
} }
onnx_result = process_onnx_core( onnx_result = process_onnx_core(
{"file_path": str(work_input_file)}, {"file_path": str(work_input_file)},

View File

@ -13,7 +13,7 @@ def test_process_onnx_core_creates_output():
assert input_file.exists(), f"Missing input file: {input_file}" assert input_file.exists(), f"Missing input file: {input_file}"
params = {"model_id": 1, "version": "1a2b", "platform": "520"} params = {"model_id": 1, "version": "1a2b", "platform": "520", "enable_precheck": False}
result = process_onnx_core( result = process_onnx_core(
{"file_path": str(input_file)}, {"file_path": str(input_file)},

View File

@ -13,7 +13,7 @@ def test_process_tflite_core_creates_output():
assert input_file.exists(), f"Missing input file: {input_file}" assert input_file.exists(), f"Missing input file: {input_file}"
params = {"model_id": 4, "version": "tflite", "platform": "520"} params = {"model_id": 4, "version": "tflite", "platform": "520", "enable_precheck": False}
result = process_onnx_core( result = process_onnx_core(
{"file_path": str(input_file)}, {"file_path": str(input_file)},

View File

@ -0,0 +1,219 @@
"""Unit tests for platform/operator compatibility pre-check.
這些測試刻意不 import onnx / ktc本機環境沒有 toolchain只透過 mock
evaluator backend 驗證 pre-check 的判斷邏輯
"""
from pathlib import Path
import sys
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from services.backends.precheck import (
UnsupportedOperatorError,
build_error_message,
find_unsupported_operators,
run_precheck,
)
class _FakeEvaluator:
"""可注入的假 evaluator回傳固定字串或在呼叫時 raise。"""
def __init__(self, *, report=None, raises=None):
self._report = report
self._raises = raises
self.calls = []
def evaluate(self, onnx_path, **kwargs):
self.calls.append((onnx_path, kwargs))
if self._raises is not None:
raise self._raises
return self._report
# --------------------------------------------------------------------------- #
# find_unsupported_operators
# --------------------------------------------------------------------------- #
def test_find_unsupported_empty_report_returns_empty():
assert find_unsupported_operators("") == []
assert find_unsupported_operators(None) == []
def test_find_unsupported_clean_report_returns_empty():
report = "kdp520/FPS: 1200, cpu_node: None, docker_version: 1.0"
assert find_unsupported_operators(report) == []
def test_find_unsupported_detects_empty_node_op():
report = "compiler: creating an EmptyNode instance for op_type: Softmax"
assert find_unsupported_operators(report) == ["Softmax"]
def test_find_unsupported_detects_unimplemented_feature_with_bracket_op():
report = "UnimplementedFeature: undefined CPU op [Softmax]"
assert find_unsupported_operators(report) == ["Softmax"]
def test_find_unsupported_detects_hw_not_support_with_op():
report = "HW_NOT_SUPPORT: Softmax"
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) == ["<unknown>"]
def test_find_unsupported_does_not_false_positive_on_supported_wording():
# "int16 is not supported in 520" 這種 bitwidth 說明不應被誤判為 op 不支援。
report = "note: int16 is not supported in 520; datapath int8 ok"
assert find_unsupported_operators(report) == []
def test_find_unsupported_detects_multiple_ops_across_markers():
# 模型含多個不支援 op且分散在不同 marker patternEmptyNode + HW_NOT_SUPPORT
# 應回傳兩者、順序穩定(依報告出現順序)。
report = (
"compiler: creating an EmptyNode instance for op_type: Softmax\n"
"HW_NOT_SUPPORT: LSTM"
)
assert find_unsupported_operators(report) == ["Softmax", "LSTM"]
def test_find_unsupported_dedups_repeated_op():
# 同一個不支援 op 在報告中出現多次(多個節點)→ 只回一次precheck.py 的 dedup
report = (
"creating an EmptyNode instance for op_type: Softmax\n"
"creating an EmptyNode instance for op_type: Softmax\n"
"HW_NOT_SUPPORT: Softmax"
)
assert find_unsupported_operators(report) == ["Softmax"]
def test_run_precheck_raises_lists_multiple_ops_in_message():
# 多個不支援 op → 擋掉時錯誤訊息應同時列出(去重後)。
evaluator = _FakeEvaluator(
report=(
"creating an EmptyNode instance for op_type: Softmax\n"
"HW_NOT_SUPPORT: LSTM\n"
"creating an EmptyNode instance for op_type: Softmax"
)
)
with pytest.raises(UnsupportedOperatorError) as exc:
run_precheck(
"/tmp/out.onnx",
model_id=6,
version="v1",
platform="520",
evaluator=evaluator,
)
msg = str(exc.value)
assert "Softmax" in msg
assert "LSTM" in msg
assert "520" in msg
# 去重Softmax 只出現一次
assert msg.count("Softmax") == 1
# --------------------------------------------------------------------------- #
# build_error_message
# --------------------------------------------------------------------------- #
def test_build_error_message_with_op_names():
msg = build_error_message("520", ["Softmax"])
assert "520" in msg
assert "Softmax" in msg
# 帶有引導使用者的補救建議
assert "720" in msg or "730" in msg
def test_build_error_message_with_unknown_op():
msg = build_error_message("520", ["<unknown>"])
assert "520" in msg
assert "operator" in msg
# --------------------------------------------------------------------------- #
# run_precheck
# --------------------------------------------------------------------------- #
def test_run_precheck_raises_on_unsupported_report():
evaluator = _FakeEvaluator(
report="compiler: creating an EmptyNode instance for op_type: Softmax"
)
with pytest.raises(UnsupportedOperatorError) as exc:
run_precheck(
"/tmp/out.onnx",
model_id=1,
version="v1",
platform="520",
evaluator=evaluator,
)
assert "Softmax" in str(exc.value)
assert "520" in str(exc.value)
assert len(evaluator.calls) == 1
def test_run_precheck_passes_on_clean_report():
evaluator = _FakeEvaluator(report="kdp720/FPS: 900, cpu_node: None")
# 不應 raise
run_precheck(
"/tmp/out.onnx",
model_id=2,
version="v1",
platform="720",
evaluator=evaluator,
)
assert len(evaluator.calls) == 1
def test_run_precheck_raises_when_evaluate_raises_with_signal():
# evaluate() 直接 raise但例外訊息含明確不支援訊號 → 應轉成清楚錯誤。
evaluator = _FakeEvaluator(
raises=AssertionError("UnimplementedFeature: undefined CPU op [Softmax]")
)
with pytest.raises(UnsupportedOperatorError) as exc:
run_precheck(
"/tmp/out.onnx",
model_id=3,
version="v1",
platform="520",
evaluator=evaluator,
)
assert "Softmax" in str(exc.value)
def test_run_precheck_passes_when_evaluate_raises_without_signal():
# evaluate() raise但訊息不含不支援訊號transient / 環境問題)→ 放行不誤擋。
evaluator = _FakeEvaluator(
raises=RuntimeError("Quantization model generation failed. See above message.")
)
# 不應 raise
run_precheck(
"/tmp/out.onnx",
model_id=4,
version="v1",
platform="530",
evaluator=evaluator,
)
def test_run_precheck_passes_target_platform_to_evaluator():
evaluator = _FakeEvaluator(report="")
run_precheck(
"/tmp/out.onnx",
model_id=5,
version="abc",
platform="730",
evaluator=evaluator,
)
_path, kwargs = evaluator.calls[0]
assert kwargs["platform"] == "730"
assert kwargs["model_id"] == 5
assert kwargs["version"] == "abc"