跳转到正文
403F's Cafe
返回

把IceFormer跑到华为昇腾上

封面图

头图: Xenomorph 2026国庆

来源标注不代表获得使用授权,图片权利归原权利人所有。

最近需要把之前在NVIDIA GPU上训练的IceFormer分割模型部署到华为昇腾上。原本觉得导出一个ONNX,再用ONNX Runtime的CANN后端跑一下就完事了,结果从运行库版本、Python环境一路折腾到算子和图优化,最后才把独立模型的纯NPU推理跑通。本文记录一下中间踩过的坑,免得下次再来一遍。

最终得到的是 opset13 + ORT BASIC图优化 + CANN EP。这时的推理图里没有交给CPU执行的节点,读图、预处理和保存结果还是在CPU上做的。虽然说可以用ORT跑了,但是CANN EP的执行效率极低,查了下社区有说部分情况下会比CPU EP还慢,因此后续如果要生产部署的话,大概率还得是转成OM做好优化后直接OM运行。

先把模型和测试数据打包

模型原来用的是MMSegmentation,同时依赖MMCV和MMEngine。部署时准备走ONNX Runtime,其通过Execution Provider,也就是EP,把计算交给不同的后端;昇腾对应的是CANN EP。

这次模型输入是四波段图像,输出是八个类别的分数,尺寸如下:

输入:FP32 [1, 4, 768, 768]
输出:FP32 [1, 8, 768, 768]

输出的logits还不是最终的分割图,需要经过logits上采样+ArgMax后才是最终结果。

一开始我把ONNX、测试图像、标注、配置和权重来源打成了一个bundle,顺带放进去在NVIDIA CUDA上生成的PTH参考预测。这样搬到服务器上之后,至少能保证用的是同一份输入,不至于环境还没查清楚,先被预处理差异坑一遍。

restored = torch.nn.functional.interpolate(
    logits,
    size=(768, 768),
    mode='bilinear',
    align_corners=False,
)
prediction = restored.argmax(dim=1)

为了让服务器上的后处理也一致,我直接在ONNX后面追加了Resize和ArgMax:

flowchart TD
    logits["FP32 logits<br/>[1, 8, 768, 768]"] --> resize["Resize"]
    resize --> argmax["ArgMax:选择类别"]
    argmax --> labels["INT64 labels<br/>[1, 1, 768, 768]"]
ONNX后处理节点
from onnx import helper

# roi和scales是空的FP32 initializer。
# sizes是INT64 initializer,内容为[1, 8, 768, 768]。
resize = helper.make_node(
    'Resize',
    ['logits', 'roi', 'scales', 'sizes'],
    ['resized_logits'],
    mode='linear',
    coordinate_transformation_mode='half_pixel',
)
argmax = helper.make_node(
    'ArgMax', ['resized_logits'], ['labels'], axis=1, keepdims=1,
)

包里的几个图共用外部权重文件,只是把权重放到了图文件外面,并没有改模型的计算。

结果第一版包在服务器上就没跑起来。最开始是节点被分给CPU,但我禁止了CPU回退导致的问题,允许回退后这里过了;但是这时已经交给CANN的节点计算图还是报错。

CANN版本先踩一坑

ORT最后抛出来的异常往往没什么用,真正的报错在前面的CANN、GE或者ACL日志里,所以先把日志打开:

export ASCEND_GLOBAL_LOG_LEVEL=1
export ASCEND_SLOG_PRINT_TO_STDOUT=1

ORT这边也可以开详细日志:

options = ort.SessionOptions()
options.log_severity_level = 0
options.log_verbosity_level = 1

查了一轮发现服务器装的是CANN 9.1,而我用的是onnxruntime-cann 1.22.1。官方版本表给出的配套关系是:

ORT-CANNCANN
1.22.18.2.0

既然9.1不在这个组合里,那就先处理环境,而不是接着改模型。之后我实际装的是 CANN 8.2.RC2,重新加载环境再跑,依然报错。版本问题要解决,但它并不是后面所有问题的原因。

此处之所以不采用本地编译,是因为服务器是ARM架构的,同时环境里的CMake和GCC都太老了,还不允许升级其版本,因此只能是通过这种办法绕过去。

机器上有多套CANN时,还得确认当前shell到底在用哪一套:

printf '%s\n' "$ASCEND_HOME_PATH"
printf '%s\n' "$LD_LIBRARY_PATH"
command -v atc
npu-smi info

latest软链接和环境变量都有可能还指着旧版本。安装完不等于切换完,需要加载目标版本的set_env.sh,实在混乱就开个新shell再试。

先不管ORT,试试直接转OM

换完版本还是跑不起来,于是先绕过ORT,拿测试ONNX直接用ATC转OM。ATC是昇腾的模型转换工具,OM则是昇腾可以加载执行的模型格式。

这么做主要是想先看看,抛开ORT之后,这个测试模型到底能不能被CANN编译。否则转换、初始化、执行全混在一起,根本不知道该从哪里查。

uv环境找不到python3-config

第一步就被Python环境卡住了。ATC需要用python3-config获取Python开发配置,我一直都使用uv来管理虚拟环境,但是uv不会处理python3-config的链接。试着把python3-config链接进venv也没用,改PATH同样没有解决,因为ATC处理的时候是不认PATH里的python3-config地址的。

我没继续跟ATC的查找方式较劲,先单独建了一个Conda环境做转换测试,由于ATC要求numpy<2,同时ARM服务器又没法编译,因此只能用带whl的Python 3.10。不是说uv一定不能用ATC,只是这次手动链接这条路没走通,应用和后面的独立推理程序还是继续用uv。

排查时可以先看这几个东西是不是来自同一套Python:

python -VV
command -v python
command -v python3-config
python3-config --includes
python3-config --ldflags
python -c 'import sys, sysconfig; print(sys.executable); print(sysconfig.get_config_var("LIBDIR"))'

缺失依赖

另外ATC还得手动补上decorator、attrs、psutil和scipy,不然汇报错:

conda create -n atc-py310 python=3.10 -y
conda activate atc-py310
python -m pip install 'numpy==1.26.4' decorator attrs psutil scipy

# 再加载实际安装的CANN环境。
# source /实际安装路径/set_env.sh

动态库路径也要查

中间还碰到了动态库和glibc相关报错,又回头检查了一轮LD_LIBRARY_PATH。

printf '%s\n' "$LD_LIBRARY_PATH"

# 对日志里实际报错的共享库检查依赖。
# ldd /实际报错的共享库.so

如果加载的是旧CANN或者Conda里的错误库,调整搜索路径可能就够了;如果系统glibc真的比二进制要求的版本低,改路径也不一定能救,还是得换兼容的二进制或运行环境。

这些环境问题处理完之后,测试ONNX终于转成了OM:

SOC_VERSION='<实际设备的SoC型号>'

atc \
  --framework=5 \
  --model=test.onnx \
  --output=test_model \
  --input_format=NCHW \
  --input_shape='input:1,4,768,768' \
  --soc_version="$SOC_VERSION"

输入名称、尺寸和SoC都要换成实际值,转成功至少说明这份测试ONNX能在当前环境下编译。

转完OM继续折腾ORT

如果只部署这一个模型,直接用OM可能就够了。但我后面不一定只部署一个模型,也不想每换一种设备就把上层服务重写一遍。

我更想要的是业务只管输入输出,设备差异尽量留给ORT处理:

flowchart TD
    service["业务服务"] --> model["统一模型接口"]
    model --> ort["ONNX Runtime"]
    ort --> cuda["CUDA EP"]
    ort --> cann["CANN EP"]
    ort --> cpu["CPU EP"]

所以ATC转成功之后,还是回头继续查ORT。这里是希望尽量复用应用实现,部署到不同机器时仍然需要对应的ORT构建、EP库、驱动和硬件运行库。

另外,get_available_providers()里能看到CANN,也不代表当前模型就能跑。算子兼容性、图优化、实际推理EP有没有fallback到CPU,都得单独检查。

应用侧我还是用uv锁依赖,最终只跑ONNX的项目不需要MMSegmentation,也不需要torch_npu。

注意不要同时装CPU版onnxruntime和onnxruntime-cann,它们导入时都叫onnxruntime,装在一起可能互相覆盖。

把模型从训练框架里拆出来

原来整套MMSegmentation项目还包着注册器、预处理和导出适配,排查时不太方便判断问题出在哪里。我干脆把IceFormer抽成了纯PyTorch实现,先看看模型本身在NPU上能不能跑。

抽完之后不再依赖MMSegmentation、MMCV、MMEngine和timm,attention使用原生的nn.MultiheadAttention。权重加载则直接开严格检查:

model.load_state_dict(state_dict, strict=True)
model.eval()

少一个参数就报错,避免参数变成随机初始化的。训练checkpoint里还可能带着框架的日志对象,所以能用验证过的纯state_dict就尽量用它;必须读原checkpoint时,只处理已知的元数据类型,碰到未知框架对象就停下来。这里默认权重来源可信,自定义unpickler并不能让恶意pickle变安全。

之后用torch_npu.contrib里的transfer_to_npu做测试,保留CUDA风格的调用,把执行迁移到NPU:

import torch
import torch_npu
from torch_npu.contrib import transfer_to_npu

if not torch_npu.npu.is_available():
    raise RuntimeError('NPU unavailable; CPU inference fallback is forbidden')

# model是抽出来的模型,inputs使用与原模型一致的预处理。
torch.cuda.set_device(0)
model = model.eval().cuda(0)
inputs = inputs.cuda(0)

with torch.no_grad():
    logits = model(inputs)
torch.cuda.synchronize()

assert logits.device.type == 'npu'
assert torch.isfinite(logits).all().item()

这次跑通了,说明模型至少能走当前的PyTorch NPU执行路径。但PyTorch能跑,不代表导出成ONNX之后也能跑,后者还要经过另一套算子转换和编译。

ONNX最后还是用了opset13

导出时我另外起了一个进程,用CPU做tracing,避免transfer_to_npu对API的修改影响导出,这里用CPU只是为了生成标准ONNX。

需要在导入torch之前关掉后端自动加载,启动导出子进程时也传入这个环境变量:

import os
os.environ['TORCH_DEVICE_BACKEND_AUTOLOAD'] = '0'

import torch

关键导出配置如下,模型已经加载了对应权重,示例输入也按原来的方式预处理:

model = model.cpu().eval()
inputs = inputs.cpu()

with torch.no_grad():
    torch.onnx.export(
        model,
        inputs,
        'model.onnx',
        input_names=['input'],
        output_names=['logits'],
        opset_version=13,
        export_params=True,
        do_constant_folding=True,
        dynamo=False,
    )

导出之后再跑ONNX checker,顺便检查有没有意外带进去的自定义算子domain。

一开始导出用的是opset17,后来把两份图放一起看,发现差别还挺明显:

项目opset17opset13
网络节点数49256535
LayerNormalization1610
常量Mod5252
输入FP32 [1,4,768,768]相同
原始输出FP32 [1,8,768,768]相同

opset17会把LayerNorm导出成一个LayerNormalization节点,而opset13会把它拆成ReduceMean、Sub、Pow、Add、Sqrt、Div等基础算子,再做仿射变换。

问题在于,ORT-CANN 1.22.1的图模式算子名单没有LayerNormalization,但有拆开之后的这些基础算子。所以这里节点更多的opset13反而更合适。降到13之后,那52个Mod仍然在,后面还要继续处理。

torch_npu和ORT别放一起初始化

中间还试过在同一个进程里先跑PyTorch NPU,再跑ORT CANN,结果报了:

ACL_ERROR_REPEAT_INITIALIZE
expr=aclInit(nullptr)

这时torch_npu已经初始化过ACL,CANN EP又尝试初始化,于是重点查同进程重复初始化的问题。

我没有在模型和NPU张量还活着的时候强行调用aclFinalize(),免得把已有资源一起弄失效。最后直接拆成两个进程:

独立ORT程序只保留NumPy、图像IO、ONNX和ORT-CANN相关依赖。环境里装了torch_npu不等于进程已经初始化它,要隔离的是实际导入和初始化。

跑是跑起来了,但怎么还有CPU

换成独立ONNX程序之后,仍然遇到了这个报错:

This session contains graph nodes that are assigned to the default CPU EP,
but fallback to CPU EP has been explicitly disabled by the user.

意思很直接,有节点要放在CPU上执行,但我不让它用CPU。

为了先看到结果,我临时加了--allow-cpu,允许CANN和CPU混着跑:

options.add_session_config_entry('session.disable_cpu_ep_fallback', '0')
providers = [
    ('CANNExecutionProvider', cann_options),
    'CPUExecutionProvider',
]

这次能正常推理了。但目标是纯NPU,接下来就得看看CPU到底在算什么。

session.get_providers()只能告诉我有哪些EP,不能告诉我每个节点实际在哪里执行。于是打开ORT profiling:

options.enable_profiling = True
options.profile_file_prefix = 'cann_profile'

# 完成推理后结束profiling,拿到JSON路径。
profile_path = session.end_profiling()
从profile里查看实际执行的EP
import json
from collections import Counter

with open(profile_path, encoding='utf-8') as stream:
    events = json.load(stream)

provider_events = Counter()
for event in events:
    arguments = event.get('args', {})
    provider = arguments.get('provider')
    if event.get('cat') != 'Node' or not provider:
        continue
    provider_events[provider] += 1
    print(
        event.get('name'),
        arguments.get('op_name'),
        provider,
        event.get('dur'),  # 微秒
    )

推理程序还把这些信息导出成CSV和JSON,按provider、节点索引、名称和算子类型汇总调用次数及耗时。已有profile也可以单独分析,不用每次重新跑模型。

CANN图模式会把多个ONNX节点合成一个子图,ORT profile能看见这个子图交给CANN执行,但看不到OM内部每个算子的硬件耗时。

52个节点都在算2 % 3

看完报告发现,CPU实际执行的全是Mod。再查ONNX,这52个Mod的输入全部是INT64常量[2]和[3],来自attention层,结果给后面的Reshape和Add使用。

也就是说,不管输入什么图,每次都要算52遍:

2 % 3 = 2

这就没必要纠结怎么让NPU支持取模了,直接常量折叠。把图优化改成BASIC:

options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC

改完之后,服务器上禁止CPU回退的CANN推理完整跑通了。之前CPU上只有这些常量Mod,这个结果也符合常量折叠的预期。不过当时没有保存优化后的图,没法再列出具体删掉了多少节点。

最后的严格配置

最后使用的session配置如下:

import onnxruntime as ort

options = ort.SessionOptions()
options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
options.add_session_config_entry('session.disable_cpu_ep_fallback', '1')
options.enable_profiling = True
options.profile_file_prefix = 'cann_profile'

cann_options = {
    'device_id': '0',
    'enable_cann_graph': '1',
    'dump_om_model': '1',
    'precision_mode': 'must_keep_origin_dtype',
    'op_select_impl_mode': 'high_precision',
}

# 针对ORT 1.22.1,在创建native session之前禁止provider重试。
class StrictCANNInferenceSession(ort.InferenceSession):
    def _create_inference_session(self, *args, **kwargs):
        self.disable_fallback()
        return super()._create_inference_session(*args, **kwargs)

session = StrictCANNInferenceSession(
    'model.onnx',
    sess_options=options,
    providers=[('CANNExecutionProvider', cann_options)],
)
session.disable_fallback()

这里还有个坑,只在构造完成之后调用disable_fallback()不够。1.22.1的Python包装层可能在构造失败时就重试其他provider,等session建好了再禁用已经晚了。

上面重写的是私有方法,只针对这个版本,升级ORT之后需要重新检查,别把它当成稳定的公开接口。

调试时还试过enable_cann_subgraph,但在当前版本下报了配置错误。EP选项应该查对应版本的解析源码,不能看在线文档有个选项就直接往旧版里塞。

严格推理另外用了CANN I/O binding,把输入输出都绑定到CANN设备上。下面的例子使用的是同时输出labels和原始logits的后处理图,不是前面只输出logits的原始图:

CANN输入输出绑定
# inputs_np是归一化后的连续FP32 NCHW数组。
value = ort.OrtValue.ortvalue_from_numpy(inputs_np, 'cann', 0)
binding = session.io_binding()
binding.bind_ortvalue_input('input', value)
binding.bind_output('labels', 'cann', 0)
binding.bind_output('logits', 'cann', 0)

session.run_with_iobinding(binding)
binding.synchronize_outputs()
outputs = binding.get_outputs()
assert all(item.device_name().lower() == 'cann' for item in outputs)

# 执行完再传回CPU,做结果比较和保存。
labels_np, logits_np = [item.numpy() for item in outputs]

绑定的名称必须和实际ONNX一致,只有logits输出就只绑定logits。

但输出在CANN设备上,也不能直接证明中间没有CPU计算。最终还是要看profile里的节点:

assert provider_events.get('CANNExecutionProvider', 0) > 0
assert set(provider_events) == {'CANNExecutionProvider'}

既要确实执行过CANN节点,也不能夹着CPU或其他EP的计算。

ORT背后还是在生成OM

后来看了ORT 1.22.1的源码,发现CANN EP在图模式下还是要把接到的图或子图编译成OM,再加载执行,主要调用顺序是:

flowchart TD
    parse["aclgrphParseONNXFromMem<br/>解析ONNX"] --> build["aclgrphBuildModel<br/>编译OM"]
    build --> load["aclmdlLoadFromMem / aclmdlLoadFromFile<br/>加载OM"]
    load --> execute["aclmdlExecuteAsync<br/>执行推理"]

所以用了ORT并不等于绕过OM,只是这部分工作交给了EP。

这也解释了为什么有些图已经分给CANN,仍然跑不起来。ORT这一版分配节点时主要看算子名单等条件,并没有把所有dtype、属性、形状和算子组合都验证一遍。直接用ATC转整张图,和ORT对子图的编译,输入及参数也不一定相同。

总结

最后确认完成的是:

参考资料

排查EP行为时看的是ORT 1.22.1对应源码:


分享文章:

上一篇
ICPC 西安区域赛 2026 办赛总结
下一篇
超像素算法比较