feat: 完成 issue #59 ⑥ 昇腾 NPU 后端适配(CANN 对接)
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""昇腾 NPU 后端适配(CANN 对接)验收脚本(issue #59,PRD 5.6)。
|
||||
|
||||
验证能力点:
|
||||
1. **配置资产与注册表**:模板配置可解析且含 backend 选择键;npu 已注册;
|
||||
2. **CANN 环境探测**:无 CANN 环境优雅降级(available=False 且不抛异常);
|
||||
注入模拟 ACL 后探测解析正确(设备数/名称/工具链版本);
|
||||
3. **统一接口生命周期**:dry-run(无 endpoint、无 CANN)下
|
||||
load_model / health / unload 状态 ok,health 附带 cann 信息;
|
||||
4. **切换后端仅改配置**:同一业务调用面在 gpu ↔ npu 下等价(backend_name 变化、
|
||||
接口方法可调用、硬件参数落到各自默认值);
|
||||
5. **直连模式 ACL 生命周期**:注入模拟 ACL 后 load_model 触发
|
||||
acl.init + rt.set_device,unload 触发 rt.reset_device + acl.finalize;
|
||||
6. **服务模式(MindIE)载荷**:infer 走 /v1/chat/completions 且携带 model;
|
||||
负例:未注册后端报 ValueError。
|
||||
|
||||
用法(在仓库根目录或 core/inference-backend 目录下均可):
|
||||
python core/inference-backend/scripts/verify_npu_backend.py
|
||||
退出码:0 = 全部通过;1 = 存在未达标项。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest.mock as mock
|
||||
|
||||
# 目录名含连字符(inference-backend),以包名 inference_backend 挂载到 sys.modules
|
||||
_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
if "inference_backend" not in sys.modules:
|
||||
_pkg = types.ModuleType("inference_backend")
|
||||
_pkg.__path__ = [_BACKEND_DIR]
|
||||
sys.modules["inference_backend"] = _pkg
|
||||
|
||||
from inference_backend.base import InferRequest # noqa: E402
|
||||
from inference_backend.cann_probe import probe_cann_environment # noqa: E402
|
||||
from inference_backend.factory import ( # noqa: E402
|
||||
BACKEND_REGISTRY, build_backend, default_config_path, load_backend_config,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 模拟 CANN ACL 模块(用于验证探测/直连解析逻辑,本机无真实 CANN)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_fake_acl(init_ret=0, device_count=2, version="CANN 8.0.RC1",
|
||||
set_device_ret=0):
|
||||
"""构造一个行为可控的 fake ``acl`` 模块(兼容 (ret, value) 元组风格)。"""
|
||||
rt = types.SimpleNamespace(
|
||||
get_device_count=lambda: (0, device_count),
|
||||
get_device_name=lambda i: (0, "ascend-910b-%d" % i),
|
||||
get_version=lambda: (0, version),
|
||||
set_device=lambda d: set_device_ret,
|
||||
reset_device=lambda d: 0,
|
||||
)
|
||||
calls = {"init": [], "set_device": [], "reset_device": [], "finalize": []}
|
||||
acl = types.SimpleNamespace(
|
||||
rt=rt,
|
||||
init=lambda *a, **k: (calls["init"].append(a) or init_ret),
|
||||
finalize=lambda *a, **k: calls["finalize"].append(a) or 0,
|
||||
)
|
||||
rt.set_device = lambda d: (calls["set_device"].append(d) or set_device_ret)
|
||||
rt.reset_device = lambda d: calls["reset_device"].append(d) or 0
|
||||
return acl, calls
|
||||
|
||||
|
||||
@mock.patch.dict("sys.modules", {"acl": None, "torch_npu": None})
|
||||
@mock.patch("inference_backend.cann_probe.shutil.which", return_value=None)
|
||||
def _probe_without_cann(_which):
|
||||
"""强制无 CANN 环境:sys.modules 置 None 使 import 失败,且无 npu-smi。"""
|
||||
return probe_cann_environment()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def scenario_01_config_and_registry() -> bool:
|
||||
print("== 场景 1:配置资产与可插拔注册表 ==")
|
||||
cfg = load_backend_config(default_config_path())
|
||||
ok = "backend" in cfg and cfg["backend"].lower() in ("gpu", "npu")
|
||||
print(f" [A] 模板配置可解析且含 backend 选择键 -> {'PASS' if ok else 'FAIL'}")
|
||||
ok2 = sorted(BACKEND_REGISTRY) == ["gpu", "npu"]
|
||||
print(f" [B] 注册表覆盖 gpu/npu -> {'PASS' if ok2 else 'FAIL'}")
|
||||
print(f" -> 场景 1 {'PASS' if ok and ok2 else 'FAIL'}\n")
|
||||
return ok and ok2
|
||||
|
||||
|
||||
def scenario_02_cann_probe() -> bool:
|
||||
print("== 场景 2:CANN 环境探测 ==")
|
||||
ok = True
|
||||
|
||||
info = _probe_without_cann()
|
||||
degrade_ok = (info["available"] is False and info["runtime"] == "none"
|
||||
and info["device_count"] == 0)
|
||||
print(f" [A] 无 CANN 环境优雅降级(available=False, runtime=none)"
|
||||
f" -> {'PASS' if degrade_ok else 'FAIL'}")
|
||||
ok = ok and degrade_ok
|
||||
|
||||
with mock.patch.dict("sys.modules", {"acl": make_fake_acl()[0]}):
|
||||
info = probe_cann_environment()
|
||||
parse_ok = (info["available"] is True and info["runtime"] == "acl"
|
||||
and info["device_count"] == 2
|
||||
and info["device_names"] == ["ascend-910b-0", "ascend-910b-1"]
|
||||
and info["cann_version"] == "CANN 8.0.RC1")
|
||||
print(f" [B] 模拟 ACL 探测解析(2 设备/名称/版本)"
|
||||
f" -> {'PASS' if parse_ok else 'FAIL'}")
|
||||
ok = ok and parse_ok
|
||||
|
||||
acl_fail, _ = make_fake_acl(init_ret=1)
|
||||
with mock.patch.dict("sys.modules", {"acl": acl_fail}):
|
||||
info = probe_cann_environment()
|
||||
fail_ok = info["available"] is False and "错误码" in info["detail"]
|
||||
print(f" [C] ACL init 失败降级(available=False 且带原因)"
|
||||
f" -> {'PASS' if fail_ok else 'FAIL'}")
|
||||
ok = ok and fail_ok
|
||||
|
||||
print(f" -> 场景 2 {'PASS' if ok else 'FAIL'}\n")
|
||||
return ok
|
||||
|
||||
|
||||
def scenario_03_dry_run_lifecycle() -> bool:
|
||||
print("== 场景 3:统一接口生命周期(dry-run) ==")
|
||||
cfg = load_backend_config(default_config_path())
|
||||
cfg["backend"] = "npu"
|
||||
cfg["inference"]["endpoint"] = ""
|
||||
cfg["inference"].pop("runtime", None)
|
||||
cfg["inference"].pop("device", None)
|
||||
backend = build_backend(cfg)
|
||||
ok = True
|
||||
|
||||
loaded = backend.load_model()
|
||||
ok = ok and loaded["status"] == "ok"
|
||||
health = backend.health()
|
||||
ok = ok and health["status"] == "ok" and health["loaded"] is True
|
||||
ok = ok and "cann" in health and "device_names" in health["cann"]
|
||||
unloaded = backend.unload()
|
||||
ok = ok and unloaded["status"] == "ok"
|
||||
print(f" load/health/unload 状态 ok、health 附 CANN 信息"
|
||||
f" -> {'PASS' if ok else 'FAIL'}")
|
||||
print(f" -> 场景 3 {'PASS' if ok else 'FAIL'}\n")
|
||||
return ok
|
||||
|
||||
|
||||
def scenario_04_switch_backend() -> bool:
|
||||
print("== 场景 4:切换后端仅改配置(gpu ↔ npu 同一调用面) ==")
|
||||
cfg = load_backend_config(default_config_path())
|
||||
ok = True
|
||||
|
||||
gpu = build_backend(cfg)
|
||||
cfg["backend"] = "npu"
|
||||
cfg["inference"].pop("runtime", None)
|
||||
cfg["inference"].pop("device", None)
|
||||
npu = build_backend(cfg)
|
||||
methods = ("load_model", "infer", "health", "unload")
|
||||
surface_ok = (gpu.backend_name == "gpu" and npu.backend_name == "npu"
|
||||
and all(callable(getattr(gpu, m)) and callable(getattr(npu, m))
|
||||
for m in methods))
|
||||
print(f" [A] 同一接口调用面,backend_name 随配置切换 -> "
|
||||
f"{'PASS' if surface_ok else 'FAIL'}")
|
||||
ok = ok and surface_ok
|
||||
|
||||
# 空值不覆盖后端默认:npu 落到 mindie / ascend-910b / CANN 8.0
|
||||
defaults_ok = (npu.runtime == "mindie" and npu.device == "ascend-910b"
|
||||
and npu.cann_version == "8.0")
|
||||
print(f" [B] 硬件参数落到昇腾默认值(mindie/ascend-910b/CANN 8.0) -> "
|
||||
f"{'PASS' if defaults_ok else 'FAIL'}")
|
||||
ok = ok and defaults_ok
|
||||
|
||||
try:
|
||||
build_backend({"backend": "tpu"})
|
||||
neg_ok = False
|
||||
except ValueError:
|
||||
neg_ok = True
|
||||
print(f" [C] 负例:未注册后端报 ValueError -> {'PASS' if neg_ok else 'FAIL'}")
|
||||
ok = ok and neg_ok
|
||||
|
||||
print(f" -> 场景 4 {'PASS' if ok else 'FAIL'}\n")
|
||||
return ok
|
||||
|
||||
|
||||
def scenario_05_direct_mode_acl() -> bool:
|
||||
print("== 场景 5:直连模式 ACL 生命周期 ==")
|
||||
fake, calls = make_fake_acl()
|
||||
with mock.patch.dict("sys.modules", {"acl": fake}):
|
||||
cfg = load_backend_config(default_config_path())
|
||||
cfg["backend"] = "npu"
|
||||
cfg["inference"]["endpoint"] = ""
|
||||
backend = build_backend(cfg)
|
||||
loaded = backend.load_model()
|
||||
load_ok = (loaded["status"] == "ok"
|
||||
and "直连模式" in loaded["reason"]
|
||||
and calls["set_device"] == [0])
|
||||
unloaded = backend.unload()
|
||||
# 探测本身会各调用一次 init/finalize;此处断言直连触发的确定性信号
|
||||
unload_ok = (unloaded["status"] == "ok"
|
||||
and calls["reset_device"] == [0]
|
||||
and len(calls["finalize"]) >= 2)
|
||||
health = backend.health()
|
||||
health_ok = health["cann"]["available"] is True
|
||||
ok = load_ok and unload_ok and health_ok
|
||||
print(f" load 触发 acl.init+set_device、unload 触发 reset+finalize -> "
|
||||
f"{'PASS' if ok else 'FAIL'}")
|
||||
print(f" -> 场景 5 {'PASS' if ok else 'FAIL'}\n")
|
||||
return ok
|
||||
|
||||
|
||||
def scenario_06_service_mode_infer() -> bool:
|
||||
print("== 场景 6:服务模式(MindIE OpenAI 兼容)载荷 ==")
|
||||
cfg = load_backend_config(default_config_path())
|
||||
cfg["backend"] = "npu"
|
||||
cfg["inference"]["endpoint"] = "http://iaop-npu-svc:8000/v1"
|
||||
cfg["inference"]["device"] = "ascend-910b"
|
||||
cfg["inference"]["runtime"] = "mindie"
|
||||
backend = build_backend(cfg)
|
||||
fake_body = {"choices": [{"message": {"content": "炉温偏高,建议降低加料比"}}]}
|
||||
with mock.patch.object(backend, "_post_json",
|
||||
return_value=fake_body) as post:
|
||||
result = backend.infer(InferRequest(prompt="请解释炉温报警"))
|
||||
_, payload = post.call_args[0]
|
||||
ok = (post.call_args[0][0] == "/v1/chat/completions"
|
||||
and payload["model"] == backend.model
|
||||
and result.text == "炉温偏高,建议降低加料比"
|
||||
and result.backend == "npu")
|
||||
print(f" infer 走 /v1/chat/completions 且携带 model、解析结果正确 -> "
|
||||
f"{'PASS' if ok else 'FAIL'}")
|
||||
print(f" -> 场景 6 {'PASS' if ok else 'FAIL'}\n")
|
||||
return ok
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
results = [
|
||||
("配置资产与注册表", scenario_01_config_and_registry()),
|
||||
("CANN 环境探测", scenario_02_cann_probe()),
|
||||
("dry-run 生命周期", scenario_03_dry_run_lifecycle()),
|
||||
("切换后端仅改配置", scenario_04_switch_backend()),
|
||||
("直连模式 ACL 生命周期", scenario_05_direct_mode_acl()),
|
||||
("服务模式 infer 载荷", scenario_06_service_mode_infer()),
|
||||
]
|
||||
print("=" * 48)
|
||||
all_ok = True
|
||||
for name, ok in results:
|
||||
print(f" {name}: {'PASS' if ok else 'FAIL'}")
|
||||
all_ok = all_ok and ok
|
||||
print("=" * 48)
|
||||
print("全部通过" if all_ok else "存在未达标项")
|
||||
return 0 if all_ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user