feat: 完成 issue #59 ⑥ 昇腾 NPU 后端适配(CANN 对接)

This commit is contained in:
2026-08-05 04:13:23 +08:00
parent 5d9a76c3ff
commit 95dace5ef5
4 changed files with 584 additions and 23 deletions
@@ -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())