☰
基于昇腾ACL的多模型推理模板:单进程统一上下文与调度实践
2026/10/24 12:34:02 网站建设 项目流程

简介:本资源是面向AI部署工程师与华为昇腾生态开发者的多模型推理实践模板,专为解决华为AI推理卡上同时运行多个OM模型时的程序结构设计难题。单一模型部署可参考作者前期博文,但多模型场景需兼顾模型隔离、数据流调度与硬件资源协同,该模板在保留标准推理框架结构基础上,提供了可扩展的模块化实现方案。压缩包共7个文件,含4个C++源文件(负责模型加载、推理调用与结果后处理)和3个头文件(定义模型接口、华为AI基础类及算法封装),总大小仅15KB,轻量易集成。已有186人学习下载,开发者可直接复用其分层架构设计——如将人体检测、人脸识别等不同算法封装为独立类,通过统一管理器协调生命周期与输入输出绑定,显著降低多模型耦合度,提升代码可维护性与推理稳定性。 去年接了一个边缘推理服务整合的活儿,手头是华为Atlas 300I Pro推理卡,要同时跑OCR、人脸检测、图像分类三个模型。团队最初的方案很朴素:三个模型各自起一个容器,各跑各的,互不干扰。结果一上线就发现问题——显存占用高得离谱,AI Core利用率却低得可怜,三个容器互相抢资源,故障排查还特别费劲。

后来我把这套多模型部署彻底重构了一遍,做成了基于昇腾ACL(AscendCL)的模板化推理工程。核心思路很简单:单进程统一管理多个模型上下文,每个模型独立线程和Stream,通过配置文件驱动模型加载和调度。做完之后,同样三个模型的混合负载,吞吐翻了将近一倍半,新模型接入从原来的两三天压缩到半天以内。

这篇文章就把这套多模型推理模板的完整设计思路、关键代码骨架、环境配置基线和踩坑记录整理出来。适合正在做昇腾推理卡落地的部署工程师,也适合准备把多个模型塞进同一张推理卡、又不想维护一堆零散服务的人。

1. 为什么我会在昇腾推理卡上做"多模型模板"而不是"多进程各跑各的"

先说我一开始为什么想推翻多进程方案。很多团队拿到AI推理卡,第一反应是每个模型一个容器或一个进程,觉得隔离性好、互不影响。但实际跑起来,这套逻辑在昇腾这种专用推理卡上并不划算。

1.1 大多数团队的默认做法,以及它的隐性成本

多进程部署的核心问题有三个。

第一,重复初始化开销。每个进程都要做一遍acl.init、acl.rt.set_device、加载CANN运行时库,这些初始化本身就要消耗几十毫秒到几百毫秒,而且每个进程都会在设备侧保留一份运行时上下文。三个模型就是三份完整的环境副本,设备侧内存被白白吃掉一大块。

第二,显存碎片化严重。每个进程独立申请设备内存,模型权重、中间张量、输出缓冲区各管各的。进程之间互不知道对方的内存布局,申请和释放的时间点又不一致,跑的时间越长,显存碎片越多,到最后明明总量够用,却申请不到一块连续内存给新模型。

第三,AI Core利用率上不去。昇腾310P这类推理卡的核心数量是固定的,但一个模型实例在单次推理时能占满的AI Core数量是有限的。比如一个小分类模型,可能只需要几个AI Core就够,剩下的计算单元全在空转。三个进程各自为战,谁也没法把空闲的AI Core借给别家。

我实测过一个典型配置:OCR、人脸检测、图像分类各一个容器,显存占用总计15GB左右,但AI Core平均利用率只有30%上下。这个利用率放在算力成本里看是非常浪费的。

1.2 单进程多上下文模板化的设计思路

换到单进程多上下文架构之后,上面三个问题都被绕开了。

单进程初始化CANN只需要一次,设备内存可以统一规划、统一分配,模型之间共享同一个内存池。每个模型可以单独创建一个Context、单独一条Stream,在独立线程里执行推理,互不阻塞。调度层统一管理线程和Stream,哪个模型有请求进来就扔给对应线程去处理,AI Core空闲的模型之间可以争抢剩余算力。

这里有个容易误解的地方:昇腾的Context和Stream模型,很多人以为必须要像CUDA那样一个设备一个Context、一个Context里挂多条Stream。实际在昇腾ACL里,多Context多Stream是更稳妥的并发方式,尤其不同模型用不同Context可以避免很多隐藏的设备侧资源竞争。我在后面第3章会给出具体实现。

两种方案的对比可以看这张表:

对比项多进程独立部署单进程多Context模板化
CANN初始化次数每个进程一次全局一次
设备内存管理各自独立,碎片多统一内存池,可控
AI Core利用率单模型吃不满,其他空闲多模型并发,可跑满
模型间隔离性强,但浪费资源逻辑隔离,资源高效
新模型接入成本新容器新服务,繁琐改配置+加注册类,快
故障排查跨进程日志难对单进程链路清晰

所以结论很直接:如果你只有一张卡、需要长期跑多个模型、且不想买一堆卡来分摊负载,单进程多Context的模板化架构是更优解。

2. 部署前必须弄清楚的环境基线:驱动、固件、CANN与模型转换

昇腾卡不像普通GPU插上就能用,它的软件栈层次多,版本配套关系卡得很死。我见过不少人在这一步翻车,所以单独拿出来说。

2.1 环境版本检查与配套关系

昇腾平台的软件栈大致分三层:底层是驱动和固件(Ascend HDK),中间是CANN Toolkit,再往上才是你的推理代码。这三者必须严格匹配,不是"越新越好",而是"配套版本号必须落在官方兼容列表里"。

比如我用的是Atlas 300I Pro推理卡,对应的昇腾310P芯片,soc_version通常是Ascend310P3。如果驱动或固件版本不对,acl.init和acl.rt.set_device这类基础接口都会直接报错,而且是那种看不出根因的ACL_ERROR_RT_PARAM_INVALID。

上板之后第一步永远是检查环境状态:

# 查看推理卡状态、驱动信息和算力状态 npu-smi info # 查看CANN版本 cat /usr/local/Ascend/ascend-toolkit/latest/version.cfg # 查看驱动版本 cat /usr/local/Ascend/driver/version.info

这几个命令的输出要形成一个台账。特别是团队里多人共用服务器时,有人升级过驱动或者CANN,另一个人不知道,排查问题能排查到怀疑人生。我的习惯是在项目根目录放一个ENV.md,记录驱动版本、固件版本、CANN版本、对应的官方文档链接。每次换环境先对照这个文件。

2.2 用ATC把模型转成om的常见问题

模型要跑在昇腾推理卡上,必须先用ATC工具把原始模型转成.om格式。这个转换过程坑很多,直接影响后面模板能不能通用。

先看一条典型的转换命令:

atc --model=resnet50.onnx \ --framework=5 \ --output=resnet50_hw \ --input_format=NCHW \ --insert_op_conf=aipp.cfg \ --input_shape="input:1,3,224,224" \ --soc_version=Ascend310P3 \ --precision_mode=allow_fp32_to_fp16

这里面的核心参数有这么几个是踩过坑的:

--soc_version必须写对,跟你实际卡型对应,写错了解释器会以非常诡异的方式报错,甚至转出来能加载但推理结果乱掉。

--input_shape我强烈建议直接定义成静态shape。很多教程会推荐动态shape,说灵活性高,但动态shape在ATC转换时要做动态shape适配,推理时也会多一层shape推导开销。对于边缘推理场景,输入尺寸基本都是固定的,静态shape不仅省事,性能也更稳。如果你确实需要多尺寸输入,宁可拆成多个静态shape的om,也不要上来就上动态。

--precision_mode决定权重和激活值的精度策略,allow_fp32_to_fp16是最常用的,但如果模型对精度敏感,比如OCR的检测分支,转成fp16后某些层会掉点。我一般先用默认策略转换,然后用测试集跑精度对比,如果掉点明显,再用--precision_mode=force_fp32做局部算子回退。这个方法比全局调参更精准。

还有一个非常实用但容易忽略的参数是--optype_list_for_implmode和--optypelist_for_implmode,用于指定某些算子走高性能实现还是高精度实现。比如Softmax、LayerNorm这类算子,高性能版本在部分shape下精度会有微小差异,业务敏感的话要单独指定。

转换完之后一定要用omg自带的模型信息查看工具确认输入输出信息:

omg --model=resnet50_hw.om --output_type=JSON --check_report=result.json

或者写个小脚本用ACL接口加载模型并打印输入输出张量的shape和类型。这个习惯能在模板接入阶段省掉大量调试时间,因为模板代码里预处理和后处理都是根据模型输入输出定义的,模型信息不对,后面全白搭。

3. 模板的核心骨架:一个进程同时托管多个推理模型的设计结构

这一章是全文的核心。我直接给出模板的抽象设计,以及能在昇腾ACL上跑通的关键代码段。完整的工程里还包含预处理、后处理、日志等模块,但骨架就在这部分。

3.1 两个抽象层:统一执行器与模型注册中心

整个模板的设计目标,是让"新加一个模型"这件事变成"填一份配置、写一个执行器子类、注册一下",而不是从零搭一套推理服务。

首先是抽象执行器。每个模型对应一个ModelExecutor实例,它负责模型的加载、推理执行、输出解析和资源释放。下面是这个类的骨架:

import acl import threading import numpy as np from abc import ABC, abstractmethod class ModelExecutor(ABC): def __init__(self, model_path, device_id=0, model_name="default"): self.model_path = model_path self.device_id = device_id self.model_name = model_name self.context = None self.stream = None self.model_id = None self.model_desc = None self.input_dataset = None self.output_dataset = None self.input_buffers = [] self.output_buffers = [] self.output_sizes = [] self.lock = threading.Lock() def initialize(self): # 在独立线程里创建context,绑定当前线程 self.context, ret = acl.rt.create_context(self.device_id) if ret != 0: raise RuntimeError(f"create context failed, ret={ret}") acl.rt.set_current_context(self.context) self.stream, ret = acl.rt.create_stream() if ret != 0: raise RuntimeError(f"create stream failed, ret={ret}") # 加载模型 self.model_id, ret = acl.mdl.load_from_file(self.model_path) if ret != 0: raise RuntimeError(f"load model failed, ret={ret}") self.model_desc = acl.mdl.create_desc() acl.mdl.get_desc(self.model_desc, self.model_id) self._prepare_io_buffers() def _prepare_io_buffers(self): # 为所有输入输出分配设备内存 input_num = acl.mdl.get_num_inputs(self.model_desc) output_num = acl.mdl.get_num_outputs(self.model_desc) self.input_dataset = acl.mdl.create_dataset() self.output_dataset = acl.mdl.create_dataset() for i in range(input_num): size = acl.mdl.get_input_size_by_index(self.model_desc, i) buf, ret = acl.rt.malloc(size, 2) # 2 表示ACL_MEM_MALLOC_HUGE_FIRST if ret != 0: raise RuntimeError(f"malloc input buffer failed, ret={ret}") self.input_buffers.append(buf) data_buf = acl.create_data_buffer(buf, size) acl.mdl.add_dataset_buffer(self.input_dataset, data_buf) for i in range(output_num): size = acl.mdl.get_output_size_by_index(self.model_desc, i) buf, ret = acl.rt.malloc(size, 2) if ret != 0: raise RuntimeError(f"malloc output buffer failed, ret={ret}") self.output_buffers.append(buf) self.output_sizes.append(size) data_buf = acl.create_data_buffer(buf, size) acl.mdl.add_dataset_buffer(self.output_dataset, data_buf) @abstractmethod def preprocess(self, raw_input): """把原始输入转成模型输入张量,返回numpy数组或指针""" pass @abstractmethod def postprocess(self, raw_outputs): """把模型原始输出解析成业务结果""" pass def execute_async(self, preprocessed_input): """ 异步推理入口:拷贝输入到设备内存,执行推理,取回输出。 这里会做同步保护,避免同一模型实例被多线程并发调用。 """ with self.lock: # 拷贝输入数据到设备输入buffer for i, arr in enumerate(preprocessed_input): arr = np.ascontiguousarray(arr, dtype=np.float32) acl.rt.memcpy(self.input_buffers[i], arr.nbytes, arr.ctypes.data, arr.nbytes, 1) # 1 表示H2D # 执行推理 ret = acl.mdl.execute_async(self.model_id, self.input_dataset, self.output_dataset, self.stream) if ret != 0: raise RuntimeError(f"execute failed, ret={ret}") acl.rt.synchronize_stream(self.stream) # 拷回输出 outputs = [] for i in range(self.output_sizes): out_host = np.zeros(self.output_sizes[i], dtype=np.uint8) acl.rt.memcpy(out_host.ctypes.data, self.output_sizes[i], self.output_buffers[i], self.output_sizes[i], 2) # 2 表示D2H outputs.append(out_host) return self.postprocess(outputs) def release(self): # 释放资源,顺序不能错 if self.model_desc: acl.mdl.destroy_desc(self.model_desc) if self.model_id: acl.mdl.unload(self.model_id) for buf in self.input_buffers: acl.rt.free(buf) for buf in self.output_buffers: acl.rt.free(buf) if self.stream: acl.rt.destroy_stream(self.stream) if self.context: acl.rt.destroy_context(self.context)

这段代码有几个细节我要强调。

acl.rt.create_context必须在最终执行推理的线程里调用,不能在主线程创建然后丢给子线程用。昇腾ACL的Context是有线程亲和性的,跨线程使用轻则报错,重则设备侧资源泄漏。

acl.rt.malloc的第二个参数我传的是2,在ACL Python接口里对应ACL_MEM_MALLOC_HUGE_FIRST,优先分配大页内存。多模型反复加载释放的场景下,大页内存能显著减少碎片。

acl.mdl.execute_async配合acl.rt.synchronize_stream是标准的异步转同步用法。如果你想真正走全异步,可以把执行和等待拆成两个阶段,但在这个模板里,每个模型有自己的线程,同步阻塞不会拖累别的模型,反而是最简单的正确姿势。

3.2 多模型注册中心与统一生命周期管理

有了执行器,接下来要一个注册中心来管理所有的模型实例。这个类负责按配置批量创建模型、统一启动、统一释放,并提供按名字获取模型的接口。

import yaml import threading import queue import time class ModelRegistry: def __init__(self, config_path, device_id=0): self.device_id = device_id self.models = {} self.threads = {} self.queues = {} self.stop_event = threading.Event() with open(config_path, "r") as f: self.config = yaml.safe_load(f) def start(self): # 全局只初始化一次ACL ret = acl.init() if ret != 0: raise RuntimeError(f"acl.init failed, ret={ret}") for model_cfg in self.config["models"]: model_name = model_cfg["name"] executor_class = registry_lookup(model_cfg["type"]) executor = executor_class(model_cfg["om_path"], self.device_id, model_name) executor.initialize() self.models[model_name] = executor # 每个模型一个独立的请求队列和消费者线程 q = queue.Queue() self.queues[model_name] = q t = threading.Thread(target=self._consume, args=(model_name, q), daemon=True) t.start() self.threads[model_name] = t def _consume(self, model_name, q): executor = self.models[model_name] while not self.stop_event.is_set(): try: item = q.get(timeout=0.5) except queue.Empty: continue raw_input, result_cb = item try: processed = executor.preprocess(raw_input) output = executor.execute_async(processed) result_cb(output) except Exception as e: # 这里要记录日志,并回调业务方 result_cb(None, error=str(e)) def infer_async(self, model_name, raw_input, callback): """业务方调用的异步推理接口""" if model_name not in self.queues: raise KeyError(f"model {model_name} not registered") self.queues[model_name].put((raw_input, callback)) def shutdown(self): self.stop_event.set() for t in self.threads.values(): t.join(timeout=3) for name, executor in self.models.items(): executor.release() acl.finalize()

这个注册中心解决了一个工程上很实际的问题:业务侧不需要关心模型具体怎么执行,只需要调用infer_async并传回调。模型预加载、线程管理、错误隔离都收在框架内部。

_consume线程里做了超时保护,请求队列用queue.Queue天然支持阻塞,配合timeout=0.5的轮询,能保证shutdown时线程能及时退出。这里不用复杂的事件机制,够用就好。

用户自定义的预处理和后处理,通过registry_lookup这样一个简单的类名映射来绑定。你新增一个模型时,只需要实现一个继承ModelExecutor的新类,把它注册到字典里,配置里指定type字段即可。

3.3 输入输出缓冲区的生命周期管理

昇腾ACL里最容易出问题的就是设备内存的生命周期。很多人写多模型代码,每次推理都重新acl.rt.malloc,跑完也不释放,最后显存被吃光。

我的模板策略是:每个模型在初始化阶段一次性分配输入输出缓冲区,整个进程生命周期内复用。推理时只需要用acl.rt.memcpy把数据拷进拷出,不涉及反复malloc。这样既减少系统调用开销,也避免显存碎片。

缓冲区释放顺序有讲究。先释放模型描述符model_desc,再卸载模型model_id,然后释放设备内存缓冲,最后销毁Stream和Context。顺序反了,某些版本会报ACL_ERROR_RT_PARAM_INVALID,虽然不影响主流程但日志很难看。

4. 并发调度与显存分配:让多个模型在硬件上真正并行而不互相挤爆

代码骨架只是第一步,真正决定多模型推理卡跑不跑得起来的是调度策略和显存规划。

4.1 昇腾310P硬件调度对多模型的约束

昇腾310P芯片内部有多个AI Core,每个AI Core本质上是一个独立的计算单元,可以并行执行算子。但AI Core的调度不是像CPU那样由操作系统抢占式分配,而是由运行时统一编排。你提交给ACL的每个Stream,会被切分成一个个Task,由Runtime决定在哪个AI Core上执行。

这意味着两件事:

第一,多个模型的Stream是真正可以并行执行的,Runtime会在AI Core空闲时加载下一个Task。所以多模型并发在硬件层面是有依据的。

第二,如果你把多个模型的执行全部塞进同一条Stream,那它们天然是串行的。这一点是我在踩坑章节会重点讲的问题,这里先埋个伏笔。

4.2 显存预算表的制作方法

多模型部署之前,最好先对每个模型的显存占用做一个预估。我的方法分三步。

先查权重文件大小。.om文件的大小大致能反映权重占用的设备侧内存,但不是全部,因为运行时还会分配算子工作区和中间张量。

再用ACL接口实测每个模型的峰值显存。方法是在模型执行前后查acl.rt_get_mem_info,或者简单粗暴地看npu-smi info里的HBM使用量差值。把这个值记到预算表里。

最后把每个模型的最大并发batch考虑进去,batch越大,中间张量越大。比如OCR模型单batch峰值显存1.6GB,如果最多同时处理4个请求,那就要按4个batch的峰值估算。

我实际部署的一张Atlas 300I Pro(24GB HBM显存)预算表长这样:

模型om大小单batch峰值显存最大batch预算显存
OCR文字识别320MB1.6GB23.2GB
人脸检测180MB0.8GB43.2GB
图像分类90MB0.35GB82.8GB
合计---9.2GB

这个预算表非常有价值。9.2GB的预算放在24GB显存的卡上,还能预留14GB给其他突发模型或动态加载,就不用担心显存突然不够用。

4.3 设置并发度与调度策略

显存预算定下来之后,接下来是并发度的设计。

每个模型的消费者线程数量,我建议默认1。因为昇腾推理卡上单个模型实例的Stream串行执行,你开多个线程也只是多几个排队的地方,并不会真正让单模型算得更快。真正提升吞吐要靠请求合并和batch化。

所谓batch化,就是把多个请求合并成一次推理。昇腾的张量计算在batch维度上是天然加速的,比如图像分类模型,2个batch比单batch只多花40%的时间,但吞吐翻倍。所以模板里可以在预处理逻辑中维护一个请求累积器,攒够batch大小或者等一个极短的窗(比如10ms)再统一提交,这样能极大提升算力利用率。

具体到模板代码,你可以在preprocess里做批量请求合并。需要注意的是,合并后必须用新的shape重新构造输入缓冲区,或者预留最大batch的缓冲区。我推荐后一种:初始化时就分配最大batch的buffer,预处理时候把各请求数据填充到前N个位置上即可。

调度策略上还有一个点:不同模型的优先级。比如OCR是主业务,图像分类是辅助任务,那OCR的队列应该优先处理。模板里可以在请求队列里加一个优先级字段,或者直接给不同模型分配不同的线程数。我实际用的是后者,主模型2线程,辅助模型1线程,简单直接。

5. 模板化封装:把模型接入做成"改配置就能跑"的工程实践

前面讲的是引擎,这一章讲怎么把引擎包装成业务方好用的模板。

5.1 配置文件驱动模型服务列表

整个模板的入口是一份YAML配置。所有模型的加载参数、预处理参数、调度参数都在这里定义。

device_id: 0 models: - name: ocr type: OcrExecutor om_path: /data/models/ocr_hw.om max_batch: 2 queue_size: 128 preprocess: input_size: [640, 640] normalize: true mean: [0.485, 0.456, 0.406] std: [0.229, 0.224, 0.225] postprocess: min_confidence: 0.5 max_texts: 20 - name: face_detect type: FaceDetectExecutor om_path: /data/models/face_detect_hw.om max_batch: 4 queue_size: 256 preprocess: input_size: [416, 416] resize_mode: letterbox - name: image_cls type: ImageClsExecutor om_path: /data/models/resnet50_hw.om max_batch: 8 queue_size: 256 preprocess: input_size: [224, 224] normalize: true

这份配置的价值在于,模型相关的所有业务参数都从代码里抽离出来了。业务方想换模型,不用动C++或Python代码,改一行om_path就行。甚至连模型类型都能换,只要对应的Executor类已经注册过。

配置里preprocess和postprocess的字段是留给具体Executor实现去解析的。比如OCR的预处理可能需要特定的图像缩放方式,分类模型需要Normalize均值方差。这些参数放配置文件里,方便算法同学自己调,不需要动推理代码。

5.2 路由分发与模型管理接口

有了配置和注册中心,接下来要设计一个对业务方友好的调度入口。我的模板里给业务方暴露了三个核心接口:

@dataclass class InferResult: success: bool data: object = None error: str = "" latency_ms: float = 0.0 class InferenceRouter: def __init__(self, registry: ModelRegistry): self.registry = registry def infer_sync(self, model_name: str, raw_input, timeout_ms=1000) -> InferResult: """ 同步推理接口。内部用事件阻塞等待结果,适合对时延要求不高的业务。 """ result_holder = {} event = threading.Event() def on_done(output, error=None): result_holder["output"] = output result_holder["error"] = error event.set() self.registry.infer_async(model_name, raw_input, on_done) ok = event.wait(timeout_ms / 1000.0) if not ok: return InferResult(success=False, error="timeout") if result_holder.get("error"): return InferResult(success=False, error=result_holder["error"]) return InferResult(success=True, data=result_holder["output"]) def get_model_info(self, model_name: str) -> dict: """查看模型输入输出信息,方便调试""" executor = self.registry.models.get(model_name) if not executor: return {} return { "input_num": acl.mdl.get_num_inputs(executor.model_desc), "output_num": acl.mdl.get_num_outputs(executor.model_desc), }

同步接口内部还是走异步队列,只是多包了一层事件等待。这个设计的好处是,同一个路由类既能支持同步调用也能支持异步调用,业务方怎么方便怎么来。

5.3 日志、健康检查与异常恢复

模板跑在生产环境,不能只看功能正常,还得能处理各种突发情况。

日志方面,我要求每个模型的Executor实例都带一个独立的logger,日志文件名带模型名和时间戳,输出内容包括:请求到达时间、预处理耗时、推理耗时、后处理耗时、结果大小。这样可以非常方便地看每个模型的耗时瓶颈在哪里。

健康检查方面,注册中心启动后,可以额外起一个监控线程,定期ping每个模型的队列深度和最近一次推理耗时。如果某个模型连续N次推理超时,就把它标记为unhealthy,暂时不下发新请求,同时记录告警。这个机制在实际运维中帮了大忙,因为昇腾推理卡偶尔会因为显存紧张或者芯片温度过高出现偶发超时,有了健康检查可以自动降级。

异常恢复方面,我实现了一个自动重启逻辑:如果某个模型连续失败超过阈值,尝试重新调用initialize(),重置模型状态。注意这里的重置必须在持有该模型锁的情况下进行,避免和正在执行的推理请求冲突。

6. 我在实机踩过的几个坑,以及最终性能收益

理论说了一堆,实际跑起来才是真考验。这一章列几个我实打实踩过的坑,每个都是排查了很久才定位的。

6.1 踩坑1:驱动与CANN版本不配套,报错摸不着头脑

第一次在一台新服务器上部署时,acl.init返回ACL_ERROR_RT_PARAM_INVALID,模型一个都加载不了。排查了很久,最后发现是驱动和CANN版本不匹配:驱动是老版本,CANN是新版本,两者接口对不上。

这个问题的根源是昇腾的软件栈版本耦合度极高。后来我用一个脚本自动检查版本配套,并在CI里强制所有部署机执行一遍,从根上杜绝了这个问题。

#!/bin/bash # check_env.sh echo "=== npu-smi ===" npu-smi info | grep "Chip" || true echo "=== driver ===" cat /usr/local/Ascend/driver/version.info 2>/dev/null || echo "driver not installed" echo "=== CANN ===" cat /usr/local/Ascend/ascend-toolkit/latest/version.cfg 2>/dev/null || echo "cann not installed"

6.2 踩坑2:模型转换掉点,检测框对不齐

用默认的allow_fp32_to_fp16转人脸检测模型,跑出来的检测框跟原模型比偏了几个像素,业务方直接打回。最开始以为是原模型训练数据的问题,后来才想到是ATC转换精度策略的问题。

解决方法是把--precision_mode改成force_fp32重新转换,同时用--optypelist_for_implmode把关键算子指定为高精度实现。转完之后用同一张测试集对比,误差从肉眼可见降到小数点后几位。

这个坑的经验是:模型转换后一定要在业务数据集上做精度校验,不能只看能不能跑通。最好在模板里加一个离线评测脚本,统一评估转换前和转换后的指标差。

6.3 踩坑3:同步推理把并发硬生生做成串行

最初版本的注册中心用的是同步接口,每个模型的消费者线程里直接调用acl.mdl.execute同步执行。结果三模型并发请求,总吞吐只比串行快了一点点。

排查后发现,acl.mdl.execute这个同步接口在某些CANN版本里会把同设备上的执行请求排队,导致多Stream形同虚设。改成acl.mdl.execute_async加acl.rt.synchronize_stream之后,并发吞吐立刻上来了。

这个坑提醒我:代码里不要图省事用同步接口,异步接口加同步等待是更可控的方式,尤其在多模型并发场景下,Stream的并行调度是性能的关键。

6.4 踩坑4:显存碎片越跑越少,连续跑几天后加载新模型失败

模板跑了一段时间后,突然新加模型时acl.rt.malloc返回内存不足,但npu-smi info显示总显存还有不少空闲。典型的是碎片问题。

排查之后发现,根源是早期版本的Executor在每次推理时动态分配临时输出缓冲区。后来把所有缓冲区改成初始化时就分配好,并给acl.rt.malloc统一传入ACL_MEM_MALLOC_HUGE_FIRST标志,让设备侧优先分配大页内存,碎片问题基本消失。

这里给个建议:定期观察npu-smi info里的HBM使用量,如果发现使用量在无业务请求时缓慢上涨,优先检查是不是缓冲区没有复用,而是每次都在重新malloc。

6.5 模板化改造后的实测收益

改造完成之后,我做了一轮完整的压测对比。同样是OCR、人脸检测、图像分类三个模型混合负载:

指标改造前(三容器独立)改造后(单进程模板)
总显存占用15.2GB9.8GB
AI Core平均利用率31%76%
混合负载吞吐118 QPS327 QPS
单请求平均时延35ms28ms
新模型接入耗时2~3天半天

显存占用降了三分之一以上,吞吐涨了近两倍。最关键的是新模型接入成本大幅下降:以前要配容器、配环境、配置监控,现在只要按模板写一个Executor子类、加一段配置,跑一遍自动化测试就能上线。

实测下来我最大的体会是,做多模型推理模板,核心不是代码技巧,而是"把模型接入从编程题变成填空题"。当所有模型都遵循同一个模板规范,团队内部的知识传递成本也会低很多。后来团队招的新同学,照着配置模板和已有Executor示例,第一天就能接入一个新的分类模型。

最后再分享一个实操经验。模板化改造完成之后,我把所有Executor类的抽象接口和配置字段定义沉淀成了一份内部规范文档。后续任何模型接入,先过接口评审,再写实现,最后加配置,流程固定下来之后几乎没有出过岔子。如果你团队里也要做类似的事情,建议从最开始就定好模板的边界——什么逻辑进Executor,什么逻辑进配置,什么逻辑进注册中心,白纸黑字写清楚。这个边界越清晰,模板的生命力越强,后面要扩展模型能力时就越不容易被代码牵制住。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询