尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

基于昇腾ACL的多模型推理模板:单进程统一上下文与调度实践

基于昇腾ACL的多模型推理模板:单进程统一上下文与调度实践 简介本资源是面向AI部署工程师与华为昇腾生态开发者的多模型推理实践模板专为解决华为AI推理卡上同时运行多个OM模型时的程序结构设计难题。单一模型部署可参考作者前期博文但多模型场景需兼顾模型隔离、数据流调度与硬件资源协同该模板在保留标准推理框架结构基础上提供了可扩展的模块化实现方案。压缩包共7个文件含4个C源文件负责模型加载、推理调用与结果后处理和3个头文件定义模型接口、华为AI基础类及算法封装总大小仅15KB轻量易集成。已有186人学习下载开发者可直接复用其分层架构设计——如将人体检测、人脸识别等不同算法封装为独立类通过统一管理器协调生命周期与输入输出绑定显著降低多模型耦合度提升代码可维护性与推理稳定性。 去年接了一个边缘推理服务整合的活儿手头是华为Atlas 300I Pro推理卡要同时跑OCR、人脸检测、图像分类三个模型。团队最初的方案很朴素三个模型各自起一个容器各跑各的互不干扰。结果一上线就发现问题——显存占用高得离谱AI Core利用率却低得可怜三个容器互相抢资源故障排查还特别费劲。后来我把这套多模型部署彻底重构了一遍做成了基于昇腾ACLAscendCL的模板化推理工程。核心思路很简单单进程统一管理多个模型上下文每个模型独立线程和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 --modelresnet50.onnx \ --framework5 \ --outputresnet50_hw \ --input_formatNCHW \ --insert_op_confaipp.cfg \ --input_shapeinput:1,3,224,224 \ --soc_versionAscend310P3 \ --precision_modeallow_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_modeforce_fp32做局部算子回退。这个方法比全局调参更精准。还有一个非常实用但容易忽略的参数是--optype_list_for_implmode和--optypelist_for_implmode用于指定某些算子走高性能实现还是高精度实现。比如Softmax、LayerNorm这类算子高性能版本在部分shape下精度会有微小差异业务敏感的话要单独指定。转换完之后一定要用omg自带的模型信息查看工具确认输入输出信息omg --modelresnet50_hw.om --output_typeJSON --check_reportresult.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_id0, model_namedefault): 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(fcreate context failed, ret{ret}) acl.rt.set_current_context(self.context) self.stream, ret acl.rt.create_stream() if ret ! 0: raise RuntimeError(fcreate stream failed, ret{ret}) # 加载模型 self.model_id, ret acl.mdl.load_from_file(self.model_path) if ret ! 0: raise RuntimeError(fload 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(fmalloc 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(fmalloc 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, dtypenp.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(fexecute 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], dtypenp.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_id0): 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(facl.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(targetself._consume, args(model_name, q), daemonTrue) 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(timeout0.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, errorstr(e)) def infer_async(self, model_name, raw_input, callback): 业务方调用的异步推理接口 if model_name not in self.queues: raise KeyError(fmodel {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(timeout3) for name, executor in self.models.items(): executor.release() acl.finalize()这个注册中心解决了一个工程上很实际的问题业务侧不需要关心模型具体怎么执行只需要调用infer_async并传回调。模型预加载、线程管理、错误隔离都收在框架内部。_consume线程里做了超时保护请求队列用queue.Queue天然支持阻塞配合timeout0.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 Pro24GB 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_ms1000) - InferResult: 同步推理接口。内部用事件阻塞等待结果适合对时延要求不高的业务。 result_holder {} event threading.Event() def on_done(output, errorNone): 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(successFalse, errortimeout) if result_holder.get(error): return InferResult(successFalse, errorresult_holder[error]) return InferResult(successTrue, dataresult_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 installed6.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.8GBAI Core平均利用率31%76%混合负载吞吐118 QPS327 QPS单请求平均时延35ms28ms新模型接入耗时2~3天半天显存占用降了三分之一以上吞吐涨了近两倍。最关键的是新模型接入成本大幅下降以前要配容器、配环境、配置监控现在只要按模板写一个Executor子类、加一段配置跑一遍自动化测试就能上线。实测下来我最大的体会是做多模型推理模板核心不是代码技巧而是把模型接入从编程题变成填空题。当所有模型都遵循同一个模板规范团队内部的知识传递成本也会低很多。后来团队招的新同学照着配置模板和已有Executor示例第一天就能接入一个新的分类模型。最后再分享一个实操经验。模板化改造完成之后我把所有Executor类的抽象接口和配置字段定义沉淀成了一份内部规范文档。后续任何模型接入先过接口评审再写实现最后加配置流程固定下来之后几乎没有出过岔子。如果你团队里也要做类似的事情建议从最开始就定好模板的边界——什么逻辑进Executor什么逻辑进配置什么逻辑进注册中心白纸黑字写清楚。这个边界越清晰模板的生命力越强后面要扩展模型能力时就越不容易被代码牵制住。本文还有配套的精品资源点击获取
返回列表