首页 / 资讯中心 / 文章详情

AIGC推理后处理瓶颈:CANN上自研SiP库加速信号处理

AIGC推理后处理瓶颈:CANN上自研SiP库加速信号处理 ★ FEATURED ARTICLE
做AIGC推理优化有一段时间了给我留下印象最深的问题反而不是模型本身而是模型前后的信号处理代码。语音合成跑完一个500ms的片段只要十几毫秒但接在后面的重采样、去爆音、滤波却能在CPU上吃掉三十多毫秒整条管线一下子被拖成蜗牛。图像生成也有同样的问题生成器的输出要降噪、锐化、色彩校正这些传统DSP操作在AI框架的算子清单里根本找不到硬用卷积拼经常精度不对速度反而更慢。后来我在CANN生态里做了一个专用的信号处理算子库内部代号SiPSignal Processing Kernel Library把这些高频且计算密集的DSP操作全部下沉到AI加速设备的计算单元上执行跑完一轮管线优化后后处理耗时直接砍掉了大半。这篇文章把SiP库的设计思路、算子实现、性能优化和接入管线的过程完整复盘一遍希望能给同样被AIGC后处理卡住的人一个参考。1. 为什么在CANN生态里做信号处理库1.1 AIGC信号处理成了被忽视的算力断层AIGC应用真正上线以后你会发现算力问题从来不是单点而是整条链路上的短板效应。模型推理这部分有成熟的分布式框架和推理引擎兜底该上多卡上多卡该做量化做量化但模型输入之前的信号预处理、模型输出之后的信号后处理却长期停留在Python脚本和CPU多线程的层面。问题在在线推理场景下尤其突出。举个我遇到的真实情况一个音频超分服务模型推理处理一段音频大概需要12ms但后续要做的重采样、去爆音、FIR滤波用多线程CPU跑一遍需要18ms。你没看错后处理比模型推理还慢。算下来端到端吞吐被砍了差不多一半但排查性能火焰图的时候这块又很难优化因为代码全是Python加NumPy改C要重写一堆逻辑改CUDA又和现有推理栈不统一。AIGC信号处理这个需求实际上非常杂不是简单的矩阵乘就能覆盖。语音合成要处理采样率转换、增益、削波音乐生成要跑均衡器、压缩器、噪声门图像生成要降噪、锐化、边缘检测、色彩空间转换。这些算法在传统DSP领域都有成熟实现但AI加速设备上大多数没有现成的优化算子。你既不能指望模型框架自带这些功能也没法把整个推理服务迁回纯CPU这就需要一层专门承接信号处理负载的算子库。1.2 SiP库的定位与设计边界SiP库的目标很简单把CANN生态里缺失的、但AIGC服务高频使用的信号处理算子补齐并且以高性能的方式跑在AI加速设备上。这里的CANN生态指的是面向AI加速处理器的异构计算架构包含运行时、算子开发接口和编译调优工具链。我选择在CANN这一层做是因为AIGC推理服务本身已经跑在这套栈上信号处理算子下沉之后可以与模型共用同一个设备上下文、内存池和任务流不需要再造一套独立的后端。但我也很清楚SiP库不能什么都做设计边界必须划清楚。凡是计算规律性强、数据量大、可以向量化的算子比如滤波、FFT、重采样、色彩矩阵全部下沉到设备端凡是逻辑分支复杂、依赖字符串解析或文件格式的比如MP3解封装、WAV头解析留在CPU端做处理完头部信息再把裸数据交给SiP库计算。这样既能把热点全部覆盖又不会让算子开发陷入没有意义的复杂性里。在技术选型上我对比过几条路一是纯CPU多线程DSP胜在实现快但和AI设备之间存在频繁的数据同步管线被打断二是在AI框架里用现有算子拼凑能让模型和后处理跑在同一个设备上但FFT、滤波这些非矩阵运算拼出来性能很差而且维护成本高三就是自研SiP库开发周期最长但性能和可控性最好。我的判断是只要CANN上的AIGC推理服务量大这个库的前期投入就值得。2. 算子域划分与kernel设计思路2.1 按信号类型划分算子域动手写kernel之前先把算子域划清楚。SiP库不是简单地把一堆DSP函数堆在一起而是按信号类型和处理方式拆成五个域时域算子、频域算子、滤波器算子、图像算子、通用数学算子。每个域内部的算子尽量共用同一套数据切分和内存搬运模板这样开发效率高后期调优也容易聚焦。算子域典型算子对应AIGC场景时域重采样、归一化、去爆音、PCM类型转换语音合成、音频后处理频域FFT/IFFT、STFT/ISTFT、频谱滤波、功率谱音频音色转换、图像频谱处理滤波器FIR/IIR、双二阶滤波、均衡器、降噪语音增强、音乐后期图像高斯模糊、锐化、边缘检测、色彩空间转换图像修复、视频生成通用数学向量加减乘、clip、abs、阈值化所有场景这个划分的逻辑在于频域算子几乎都要复用FFT内核如果FFT做一次分块优化整个频域域的所有算子都受益图像算子则在大量使用二维数据切分和DMA块搬运可以共用一套二维分块策略。滤波器算子虽然算法各不相同但都涉及系数缓冲区和数据缓冲区的重叠计算适合共用一套双缓冲框架。通用数学算子最简单它们更多是作为其他算子内部的底料也单独暴露给上层做数据预处理。分完域之后还要定义清晰的接口规范。我把SiP库的对外API设计成了类似传统DSP库的风格输入是一块连续内存加长度参数输出是一块连续内存不引入复杂的对象模型。这样上层Python代码很容易绑定原来的NumPy处理逻辑替换起来基本是机械操作。接口统一还有个好处算子内部的优化哪怕大改上层的调用方式都不动。2.2 统一执行模型搬运、计算、搬运AI加速处理器上的计算单元和我们熟悉的CPU不一样它有专门的标量单元、向量单元和矩阵单元数据要先从主存搬运到靠近计算单元的缓冲区算完再搬回去。SiP库的kernel设计事实上可以抽象为一个统一执行模型从全局内存往片上缓冲区搬运数据计算单元做运算然后把结果搬运回全局内存。这个“两搬一算”模型贯穿了所有算子的实现。最开始我尝试过直接使用底层硬件指令编写每一个算子这样性能上限最高但开发效率和可维护性都很差。后来切到CANN生态的Ascend C开发接口阶段它能在保留底层控制能力的同时用类似C的方式描述数据搬运和向量计算并且提供了流水队列来处理搬运和计算之间的并行开发效率高了一大截。Ascend C适合SiP库这类场景的另一个原因是它能细致控制多核切分和缓冲区分配这正好是信号处理算子优化最关键的地方。在统一执行模型下每个kernel的代码结构都是一样的先根据当前核的编号计算要处理的数据区间再把输入数据按块搬运到UB缓冲区接着在缓冲区上执行向量运算最后把结果搬回全局内存。数据块大小是一个需要反复调参的变量太大缓冲区装不下太小搬运开销占比过高。我一般是从UB容量的一半起步去试结合跟随后续性能分析数据做调整。3. 实战一个音频后处理算子从编写到上板3.1 选FIR滤波器作为第一个算子SiP库第一个正式实现的算子我选的是FIR滤波器。原因很直接FIR在AIGC音频后处理里出现频率极高语音增强要滤波音乐音色转换要滤波去爆音和限制带宽都要滤波同时FIR计算模式极其规整每个输出点就是一组乘加运算非常适合向量化。接口设计上我参考了大家很熟悉的一阶DSP接口风格输入参数音频帧指针采样点数量N滤波器参数抽头系数指针抽头数T输出参数滤波结果指针附加参数可选的分块大小和启用的核数FIR的数学定义是y[n] sum(x[n-k] * h[k], k0..T-1)对于一段48kHz采样率、1秒钟的音频N等于48000抽头数T等于64总乘加次数就是48000乘64算下来大约307万次乘加。这个运算量在CPU上跑标量循环至少需要几毫秒但在AI加速设备的向量单元上可以一次处理多个采样点差距非常明显。为什么选FIR而不是更复杂的IIR或者自适应滤波器还有一个原因FIR天然无反馈每个输出点只依赖有限的输入点分块的时候边界处理简单IIR因为有反馈项块与块之间必须传递状态做成多核并行的复杂度会成倍上升。作为第一个跑通全流程的算子FIR能帮我验证工具链、内存模型和多核调度整套流程后面再往上加复杂算子就有底子了。3.2 算子主体结构与关键实现片段用Ascend C风格写FIR算子的结构大致如下。这里展示的是示意结构真实工程里还要包含头文件、算子注册、shape推导这些内容但核心逻辑就是这一段// 示意结构FIR滤波算子内核 class KernelFIR { public: __aicore__ inline KernelFIR(...) { // 初始化全局内存指针、缓冲区指针、参数 } __aicore__ inline void Process() { // 1. 多核切分当前核负责 [start, start len) 这段输出 // 2. 边界处理额外搬运前 T-1 个历史采样点作为重叠区 // 3. 循环分块每次搬运和计算一个 blockSize // - 从 Global Memory 搬入输入块到 UB Buffer // - 在 UB 上执行向量乘累加抽头系数通过广播参与运算 // - 把结果从 UB 搬回 Global Memory // 4. 多核同步 } };有几个实现细节值得展开说明一下。第一抽头系数是固定不变的我把它们预加载到UB缓冲区里而不是每次计算时从全局内存读取这样能大幅减少搬运量。第二向量乘加采用多拍流水的方式把64个抽头拆成多组通过循环展开提升指令级并行。第三多核切分时每个核负责的输出区间不要按简单平均数硬切而是要处理前面提到的边界重叠区否则最后一个输出块会丢掉前T-1个采样点的影响。还有一个关键点数据块搬运要考虑地址对齐。全局内存的DMA搬运动作对地址和长度都有对齐要求非对齐访问轻则性能骤降重则直接报错。FIR算子里输入指针不一定天然对齐我一般会先把起始几个采样点单独处理让主循环里的指针满足对齐要求最后再处理尾部残段。3.3 编译、调用与性能验证算子写完不是直接就能用要经过CANN生态的算子工程构建流程。大致步骤是用算子工程生成工具创建工程骨架填写算子实现文件和host侧调用逻辑然后用CMake做交叉编译最后把编译产物打包成算子包部署到设备环境。编译完成之后在host侧通过ACL接口加载算子把输入数据放到全局内存创建执行流并提交算子任务。调用方式上我做了两层封装。底层是C接口直接用ACL API完成内存申请、任务下发和结果回收上层用pybind11做Python绑定把音频处理函数暴露到Python层。这样在AIGC服务里调用就非常简单传进来一个NumPy数组返回一个处理后的数组和以前用CPU DSP库的体验几乎一样。性能验证我用了一段30秒的语音片段处理内容包含去爆音、48kHz到44.1kHz重采样、64抽头FIR低通滤波。按不同方案跑了几组数据处理方式耗时CPU多线程DSP实现21.6msAI Core单核初版未优化8.9msSiP库优化版多核双缓冲算子融合2.4ms初版单核就能跑到8.9ms已经比CPU好不少后面叠加多核切分和双缓冲流水把DMA等待时间藏起来再把这几个串行小算子融合成一个kernel直接降到2.4ms。这个数值符合预期因为融合之后中间数据不再反复在全局内存和缓冲区之间往返这是收益最大的一个改动。4. 性能优化的五个关键点4.1 内存层级决定策略AI处理器的内存层级和CPU类似但更鲜明全局内存容量大但延迟高片上缓冲区容量小但计算单元访问极快。SiP库所有算子的性能优化本质上都是围绕如何减少全局内存访问次数来展开的。以FIR为例一个输入采样点会被多个输出点重复使用如果每个输出点都从全局内存重新读输入搬运量会爆炸。优化的思路是把一块输入数据搬进UB缓冲区在缓冲区内复用多次算完一批再搬下一批。这个思路同样适用于图像算子一个像素可能在3x3窗口里被使用9次分块搬入UB后复用搬运量直接降到原来的九分之一。内存策略的另一部分是缓冲区大小分配。UB容量是有限的既要放输入块、系数又要放中间结果和输出块分配不当会导致内存溢出或者可用块太小。我的经验是优先保证中间结果的驻留因为中间结果一旦写回全局内存再读回来性能损失是双份的。放不下的数据宁可切小输入块也不要让中间结果外溢。4.2 双缓冲与多级流水数据搬运和计算是串行还是并行对最终性能影响非常大。如果严格按照“搬一块、算一块、再搬一块”的流程DMA搬运期间计算单元完全空闲那效率就浪费了一半。解决办法就是双缓冲。我在UB里分配两块缓冲区计算单元处理A缓冲区的时候DMA同时往B缓冲区搬运下一批数据两块缓冲区交替使用。只要计算时间比搬运时间长DMA的时间就可以被完全隐藏。实现双缓冲有一点必须注意计算单元必须确认当前缓冲区数据已经全部搬入才能开始计算同样DMA要覆盖这个缓冲区之前也要确认计算已经完成。这个同步用事件或栅栏机制处理处理不好会出现计算读了半个块或者覆盖了未算完数据的问题。我用实际踩坑换来的经验是调试阶段先把双缓冲关掉保证单缓冲逻辑完全正确之后再打开双缓冲去调同步否则问题混在一起非常难定位。4.3 多核切分与负载均衡CANN生态管理的AI加速设备有多个AI Core让单个核跑满和不放数据给其他核跑效果天差地别。多核切分的做法是把输出数据均匀分到若干核上每个核处理独立的一段互不干扰。但这个“均匀”很有讲究。如果数据量恰好能被核数整除那一切好说但真实音频帧长度往往不整除简单向下取整会导致最后一部分数据没有任何核处理或者最后一个核承担了过多的尾块拖慢整体时间。我在SiP库里采用按余数均分的方式先把总长度除以核数得到基础块大小再把余数分摊到前面几个核上让每个核处理的长度最多只差一个采样点。多核不是开得越多越好。核数开多了每个核处理的数据块变小搬运和同步的相对开销变大甚至会出现调度开销盖过计算收益的情况。我的经验是让每个核处理的数据量在几十KB以上比较合适低于这个阈值就要考虑是不是少开几个核或者干脆把这个算子和其他算子融合起来增大块粒度。4.4 算子融合与中间结果消除AIGC后处理有一个很常见的行为几个算子串行处理同一段音频比如先去爆音、再重采样、再滤波、再增益。如果每个算子都是独立kernel那么上一轮的输出要写回全局内存下一轮再从全局内存读出来一轮轮下来搬运开销会成倍增长。算子融合就是把这类串行算子合并成一个kernel中间结果只在UB缓冲区里流转不落全局内存。在我的例子里去爆音、重采样、FIR和增益融合后的效果非常明显因为重采样和FIR本身都是计算密集的中间数据流量大每次往返全局内存都是一笔不小的开销。识别融合机会有一个很简单的方法做性能分析看总耗时里面实际计算占比和搬运占比。如果某个算子搬运时间超过计算时间它大概率就是融合的候选。不过融合也有代价kernel的复杂度会上升调试难度变大所以我的原则是先融合两个最热的算子验证收益后再决定要不要继续融。4.5 精度取舍FP16 vs FP32AI Core的向量单元在FP16数据上吞吐通常比FP32高不少看起来把SiP库全部算子做成FP16是件划算的事。但信号处理算法对精度的敏感度远高于深度神经网络不能一概而论。以FIR滤波为例系数和输入都用FP16在信号幅度适中的时候表现尚可但输入信号很小或者抽头数很多时乘加过程中的舍入误差会被放大。FFT的蝴蝶运算更敏感多次迭代累积误差会让频谱出现可见的底噪抬升。我在SiP库里执行的策略是通用数学算子和滤波算子默认支持FP16FFT、重采样和任何需要多级累加的算子在初期全部用FP32等精度验证手段成熟后再考虑混合精度。精度验证的做法很简单用同样的输入分别跑CPU浮点参考实现和SiP库算子逐样本计算误差统计信噪比。我的经验阈值是信噪比不低于45dB低于这个值就说明算子的数值稳定性有问题不要为了性能牺牲品质尤其是音频场景人耳对噪声非常敏感。5. 我把SiP库接入AIGC推理管线的过程5.1 用在一个文生音频服务上为了验证SiP库的真实价值我把一块核心能力接到了一个模拟的文生音频服务里。这个服务的输入是文本输出是一段44.1kHz标准WAV文件流程大概是文本经过语音生成模型合成语音然后接一段后处理管线包含去爆音、48kHz到44.1kHz重采样、FIR低通滤波、音量增益最后做PCM编码。这个后处理管线以前全部跑在CPU上用的是通用DSP库加Python胶水代码。问题在低并发下还勉强能用一旦服务接收的请求量上来CPU后处理就变成了瓶颈。我接入SiP库的思路是保留原有的管线编排逻辑只把最耗时的四个算法步骤替换成SiP库调用其他格式解析、音量元数据计算等留在CPU。接入过程比想象中顺利因为SiP库的接口风格和原DSP库很接近Python绑定层也做得薄大部分改动只是把函数名换掉把数据从内存拷贝改成设备内存引用。真正的挑战不在写代码而在脚本的编排细节上比如设备内存的生命周期管理、音频帧的边界对齐、异常状态向上传递这些单独调都没事串在一起才会暴露问题。5.2 端到端收益和性能数据接入完成后我用一段10秒的真实语音片段做了对比。这里不包含格式解析之类的CPU杂活只统计模型推理和核心后处理的耗时处理阶段耗时模型推理45msCPU后处理原方案32msSiP后处理新方案7.5ms用SiP库之后单段音频的后处理耗时降到了原来的四分之一不到端到端时延从77ms降到52.5ms。在部署层面由于后处理阶段不再占用大量CPU资源同一台机器可以开更多推理并发而没有CPU成为瓶颈。实测下来服务的响应吞吐提升了大约70%P99延迟也显著下降。这个提升的主要来源是算力卸载加算子融合重采样和FIR的计算被放到了AI Core上原本单独跑四个kernel造成的中间数据往返也被一次融合计算取代。单纯说某个算子变快多少其实意义不大真正有价值的是整条管线被拉平了没有哪个环节再成为明显短板。5.3 集成经验绑定、接口协议和异常传递集成过程中踩到的几个经验值得单独写出来。第一Python绑定层要保持薄和纯。我用pybind11做了很薄的绑定只做参数类型转换和内存指针传递所有业务逻辑都在C或设备端完成。一开始有人建议把DSP逻辑和绑定逻辑写在一起我没同意因为一旦绑定层代码膨胀出问题极难定位而且绑定层异线程调用会导致Python解释器崩溃。第二设备内存池必须复用。刚开始我的实现是每次处理后处理都重新申请设备内存、创建执行流结果在并发测试时性能不升反降。原因是内存申请和执行流创建的开销非常大尤其在高频调用下。改成常驻会话后设备内存池预先分配好每次只是取用和归还调用开销一下就降下来了。第三异常传递不能指望默认行为。设备端kernel执行出错时如果在析构函数或者回调里处理不当整个服务进程可能直接崩溃。我在封装层把所有设备调用包进了统一的异常处理入口把错误码映射成对应的自定义异常再通过Python层抛出这样业务侧能够捕获和重试。音频服务最重要的是稳定性一个请求处理失败可以重试但服务进程不能无故挂掉。6. 常见问题与排查技巧实录现象可能原因处理方式程序崩溃 / 设备侧错误全局内存越界、缓冲区地址未对齐开启tensor dump检查每个块的地址和长度计算结果全为零DMA搬运失败或缓冲区数据未就绪核查事件同步确认计算前缓冲区已完整写入单个算子快串联多个算子后很慢中间结果反复在全局内存往返对热区做算子融合减少中间数据落盘多核并行耗时反而增加切分块太小调度开销大于收益减少核数增大单核处理粒度FP16下输出噪声明显FFT或长累加链路误差放大改用FP32或混合精度分段计算6.1 最经典的坑卷积边界丢掉尾部点调试FIR算子的第三天出现了非常诡异的现象输出数据的最后4个采样点永远是0前面对的部分和CPU参考实现完全一致。排查了很久最后定位到原因多核切分后每个核只处理自己负责的那一段输出但没有把相邻的前一段输入末尾数据搬过来。FIR的每个输出依赖前面的T-1个输入点块与块之间的边界信息缺失最后一小段就算不出来。解决办法是在切分时每个核除了加载自己负责区间的输入数据还要额外加载前面T-1个采样点作为重叠区。看起来是简单的边界处理但很容易被忽略因为单核跑的时候根本不会暴露这个问题只有多核切分后才会出现。这也让我养成了习惯涉及卷积、滤波或任何有“邻域依赖”的算子先画清楚数据依赖图再写切分逻辑。6.2 最隐蔽的问题设备内存复用带来的脏数据接入管线后服务跑了一段时间出现了偶发的爆音而且不是每次都能复现。这类间歇性问题最难查。我先是怀疑是精度问题后来把FP32换成双精度问题依旧又怀疑是线程竞争加锁也没改善。最后通过tensor逐帧对比发现输出数据的某些采样点和预期不一致但偏差值非常小。真正的根源是设备内存池复用上一帧数据处理完内存没有清零就归还池子下一帧在极端情况下搬入的数据长度小于缓冲区容量DMA没有完全覆盖的区域残留了旧数据计算时被当成了有效输入。定位到原因后在内存归还前增加一个细微的清空或长度标记机制问题就消失了。现在我的所有算子入口处都强制校验实际数据长度必须与DMA搬运长度一致避免类似脏数据问题。6.3 性能问题排查的流程和方法遇到性能不达标第一件事不是改代码而是用性能分析工具把算子内部耗时拆开看。搬运时间、计算时间、同步等待时间分别占多少一拆就清楚了。如果搬运占比高方向是数据复用和双缓冲如果计算占比高方向是向量化、多核切分和算法优化如果同步等待占比高那就是流水没排好。有一个坑是只看算子整体加速比不看端到端。单个算子优化很好看但如果它在整个管线里本来就是边缘角色总体收益有限。所以我在SiP库整个开发过程中反复强调一个原则优化对象是整条AIGC后处理管线不是单个算子。每个算子优化的优先级都由它在端到端耗时里的占比来决定。7. 实践后的经验与建议整个SiP库从设计到落地我最深的感受是不要一上来就铺开做全量算子库。先找管线里的性能热点把最痛的一两个算子做透验证整套技术路线跑得通收益看得到再逐步扩充算子覆盖范围。我一开始也走过弯路试图把常见的DSP算法全部实现一遍结果前两周都在写代码却没有任何一个算子真正跑到应用里后面改成“热点驱动”模式效率立刻不一样了。算子接口的设计要尽量贴近大家已经熟悉的DSP库风格让迁移成本降到最低。很多团队不愿意改造后处理不是因为技术做不到而是担心逻辑被改坏。接口一稳底层实现怎么优化都不影响上层自然就敢用。最后给一个小建议性能评测一定要从端到端看而且要把真实业务流量引进来测。单元测速再漂亮也不代表整条管线快突发流量场景下的稳定性更不是跑几次benchmark能看出来的。SiP库后续还要扩展STFT/ISTFT这类更复杂的频域算子以及可配置的DSP处理图争取让后处理管线像流水线一样灵活编排。方向很清楚路得一步一步走。
阅读完成 · 觉得有帮助?
咨询建站