kneron_model_converter/tests/workers/test_onnx_softmax_removal.py
jim800121chen 1c53253e6e feat(worker): onnx 階段自動移除尾端 Softmax + pre-check regex 收窄
問題:同一顆含 Softmax 的 tflite,正式站能編出 KL520 真 .nef,
本專案 worker 在 nef 階段撞 UnimplementedFeature [Softmax] exit 6。
根因:runtime ktc 的 eliminate_tail 是 no-op(閹割版、只印警告),
尾端 Softmax 未被移除;正式站產物實測為 logits 輸出(Softmax 已砍)。

修法(staging 實測驗證、與正式站產物權重段逐 byte 相同):
- onnx/core.py:新增 remove_tail_softmax(),onnx2onnx_flow 後對所有
  platform 移除 terminal Softmax(cut_nodes、不用 cut_types 避免誤砍
  中間層下游)、多 output rewire 逐項驗證 fail-loud、onnx.checker、
  removed_tail_softmax metadata
- precheck.py:R3 誤擋收窄——刪 hw_not_support_col 欄位名誤命中、
  op capture 改必須、抽不到具體 op 名改放行+warning(真 fail-open)、
  刪自由文字猜 op fallback、加噪音 token 過濾
- tests:+16(29 passed;terminal/no-op/多 output/fail-open 全覆蓋)
- docs:TDD §12、design-doc ADR-012、PRD 輸出語意變更(機率→logits)

行為變更:所有 platform NEF 輸出改 logits(與正式站一致)、
分類後處理需呼叫端自行補 softmax。

Review:兩輪(0C/3M→0C/0M);W3 docker integration 6/6 PASS
(520+Softmax e2e 產真 .nef 799,844 bytes、720 回歸、precheck 放行)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 05:54:13 +08:00

271 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 1、probs -> 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