ARTICLE DETAIL

资讯详情

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

模型推理性能优化:五个角度拆解与工程实战

模型推理性能优化:五个角度拆解与工程实战 “模型推理为什么能变快”这个话题其实藏着一个很反直觉的事实很多时候模型推理慢根本原因不是“算法不够先进”而是“计算资源在发呆”。比如同样的BERT模型默认PyTorch部署和经过TensorRT优化后的吞吐量可能相差三四倍做法只差在把每一层计算怎么组织、怎么调度上。这些年我做过不少推理性能优化的实际项目从纯GPU算子到在线服务都有涉及踩坑踩得多了慢慢把优化思路归纳成五个角度算法计算图、推理引擎、硬件资源、模型设计、运行时服务化。这五条线不是互相独立的而是像搭积木一样一层叠一层。下面就用我的实操经验把这五个角度逐一展开。1. 第一个角度算法与计算图层面的底层变革这个层面的优化核心逻辑只有一句话让数据少搬家让计算少重复。GPU算力再强如果数据在显存和寄存器之间来回倒腾实际有效计算时间就会被压得很低。算子融合、低精度量化、稀疏化裁剪其实都是从这个思路出发的。1.1 算子融合减少数据搬运的“懒人哲学”先讲一个最常见也最容易见效的优化——算子融合。这个名字听起来很高端实际思想上就是个懒人策略把多个计算步骤合并成一步减少中间结果的写回和二次读取。拿卷积网络举例Conv卷积后面通常跟着BN批归一化和ReLU激活。在训练阶段BN需要统计batch内的均值和方差所以必须保持独立。但到了推理阶段BN的参数就固定下来了完全可以把BN的缩放和平移直接折叠进卷积的权重和偏置里。更彻底的融合是把ReLU也并进去因为ReLU只是一个逐元素操作读完一个值立刻判断是否小于0不需要额外开一个kernel。这里要说清楚一个关键“为什么”一个kernel的启动和执行需要从显存读数据、计算、写回显存。如果拆成三个kernel同一份特征图要读三次写三次融合成一个kernel只需要读一次写一次。访问显存的速度远低于计算速度所以减少访存频率比减少计算次数往往更能提升性能。我在一个生产项目里实测过仅做ConvBNReLU的三合一融合单层耗时就能降低20%到30%。1.2 低精度量化用更少的比特跑完同样的路量化是另一个耳熟能详的技术。你可能会问模型里存的不都是浮点数吗换成低精度真能快答案是快很多而且快的原因不只是“数据变小了”。先解释一个容易混淆的概念精度减少直接影响的是传输带宽和存储开销。FP32是4字节FP16和BF16是2字节INT8是1字节INT4甚至只有0.5字节。数据量变小显存带宽压力直接减半甚至减到四分之一。更关键的是现代GPU从NVIDIA Volta架构开始引入了Tensor Core它专门为低精度矩阵乘加运算做了硬件加速。同样一份矩阵乘法FP16的Tensor Core峰值算力可以做到FP32 CUDA Core的几倍甚至十几倍INT8算力通常又在FP16的基础上翻一倍。不过量化不是无脑换类型就完事。以INT8量化为例实际部署时要做校准calibration——收集一批有代表性的输入数据比如100到500张图片统计每一层激活值的分布再决定缩放因子和零点。校准时最容易踩的坑是分布里有个别极大值把整个量化区间撑大了导致大多数正常的数值精度损失严重。遇到这种情况我会改用更多的校准样本、采用百分位裁剪比如p99.9把离群点裁掉而不是直接取min/max。1.3 计算裁剪砍掉不那么重要的计算第三个算法层面的手段是稀疏化和结构化剪枝。今天主流的Transformer模型动辄几十亿上百亿参数很多研究表明其中相当一部分参数和注意力头对最终预测结果贡献很小。把这些冗余计算在保留精度的前提下砍掉推理自然就快了。这里要特别注意非结构化稀疏比如随机把某些权重置为0在理论上的FLOPs减少很大但硬件并不买账因为GPU按稠密矩阵的tile进行读取和计算遇到全零区域还得照样搬数据。真正能带来实际加速的是结构化稀疏也就是按行、按列、按通道剪掉一整块。在实际工程里我建议优先考虑卷积的通道剪枝和Transformer的注意力头剪枝因为它们能直接改变计算图的结构。2. 第二个角度推理引擎与框架侧的运行调度模型本身没变计算量也没变但换个推理引擎就能变快这大概是很多刚接触部署的人最惊讶的地方。秘密在于框架与推理引擎对计算图的组织方式完全不同。2.1 静态计算图优化把“随用随算”改成“全盘计划”PyTorch默认采用动态图模式好处是灵活方便调试坏处是每次迭代都要重新解释一遍算子调度而且无法提前知道整体内存布局。推理引擎则倾向于把模型转换成一个静态计算图于是可以做很多“离线计划”的事常量折叠权重和偏置在推理时是不变的提前把可算的常量表达式算完而不是每次请求都重复算。内存复用静态图里所有tensor的形状和生命周期是已知的引擎可以给多个中间tensor分配同一块显存节省内存的同时提高缓存命中率。拓扑排序优化把不依赖彼此的算子重新排列让它们尽量并发执行。TensorRT、ONNX Runtime、OpenVINO这些引擎都内置了上述优化原理大同小异但效果差别很大。我的经验是不要在“哪个框架最厉害”上争论而是把同一份ONNX模型分别导出、实测用数据说话。2.2 内核自动调优选择最合适的那一个卷积算法推理引擎还有一个隐藏很深的加速机制——kernel自动调优auto-tuning。一个卷积操作在GPU上可以用很多种算法实现隐式GEMM、Winograd、FFT、直接卷积等。不同算法在不同shape、不同硬件上表现差异巨大。比如Winograd对3x3卷积能减少乘法次数但需要更多变换和额外的显存开销在特征图大、通道深的情况下可能反而不如隐式GEMM。TensorRT在构建引擎时会做一次benchmark对每个算子尝试多种算法选最快的那个记录下来这个过程叫tactic选择。这也是为什么TensorRT有多种精度、多种tactic的构建配置构建一次之后运行速度才稳定。实际部署中我一般会给同一个模型构建2到3个版本的引擎分别用于不同batch大小然后在服务启动时按需加载。2.3 请求级调度让GPU不再“空转”推理引擎不只优化单次推理还能优化多请求的整体调度。常见的手段有两种动态batchdynamic batching和连续批处理continuous batching。动态batch的原理很直白把多个用户的推理请求攒一攒凑满一个batch再一起送进GPU提高GPU利用率。但攒太久会增加用户等待时间所以需要设置最大等待毫秒数和batch上限。连续批处理则是LLM推理服务中更激进的做法——每个请求的生成进度不同传统方法等整个batch结束再整体切换而连续批处理让完成的请求立即退出、新请求立即插入空位GPU始终处于计算状态。vLLM、TensorRT-LLM这些框架的核心设计思想就在这里。3. 第三个角度硬件资源与并行计算的极限压榨前面两个角度讲的都是“省着用”这个角度讲“狠着用”。硬件层面的性能优化核心是理解GPU的物理特性然后用代码去贴合它。3.1 显存带宽最容易被忽视的瓶颈很多人评估GPU只知道看算力比如TFLOPS多少却忽略了一个更关键的指标——显存带宽。算力决定“每秒能算多少次”带宽决定“每秒能搬多少数据”。对大多数推理任务尤其是Transformer这类模型瓶颈不是算力而是带宽。为什么这么说因为Transformer的decode阶段每一步生成的token都需要读取全部的KV缓存和模型权重。A100的显存带宽大约2TB/sH100大约3.3TB/s看起来很快但权重文件动辄几GB算一下就会发现每秒钟能传输的token数量有限。这就是为什么某些大模型在A100上生成速度也只有几十token每秒——卡在带宽上。判断你的算子究竟是计算瓶颈还是带宽瓶颈可以用计算强度arithmetic intensity来衡量FLOPs除以访存字节数。这个值低于硬件的平衡点就是访存受限高于平衡点就是计算受限。优化策略完全不同前者要减少数据搬运量后者要优化计算方式。3.2 并行策略数据并行、张量并行与流水线并行当单卡放不下模型的时候多卡并行是必然选择但并行策略选错了耗时反而更高。数据并行每张卡存一份完整模型把不同请求分给不同卡。适合高并发在线推理例如负载均衡后每张卡各处理一批请求。张量并行把单个矩阵乘法切分成多个小块分布到多张卡上同时计算适合单卡放不下的超大模型。但因为每层计算都要跨卡通信通信开销不可忽略一般单机内部用NVLink互联效果才好。流水线并行把模型的不同层分配到不同卡上数据像流水线一样依次流过。它适合减少每张卡的内存占用但在单次推理时延上并没有优势反而可能增加通信开销。实际项目里我见过很多人一张卡就能放下模型却非要做张量并行结果只是让GPU利用率更难打满。并行是一个工程决策不是技术炫技一定要先量化模型大小、显存容量、请求并发和网络拓扑再做选择。3.3 计算单元组织理解SIMT与Tensor Core的脾气GPU之所以能并行计算那么多线程靠的是SIMTSingle Instruction Multiple Threads架构——一条指令同时驱动一批线程执行。为了充分利用这种架构CUDA编程里强调“合并访存”也就是让相邻线程访问相邻内存地址这样硬件可以把多次访存合并成一次大的内存事务。如果线程访问地址是随机分散的内存吞吐会掉好几个数量级。Tensor Core则是另一套规则。它本质上是一个小规模的矩阵乘加单元但需要数据以特定tile形状排列比如FP16的m16n8k16、INT8的m16n8k32。所以编写高性能GEMM kernel时绝不是一个线程算一个输出元素而是把输出矩阵切分成多个tile每个tile由一组线程协作完成数据先从全局显存加载到shared memory再从shared memory按对齐规则喂给Tensor Core。这里的代码优化空间非常大也是推理引擎内部最核心的功夫所在。4. 第四个角度模型设计与压缩层面的“源头瘦身”前面几个角度都是在“执行端”想办法还有一个更颠覆性的思路直接从源头把模型做小。模型小权重读取快计算量少推理自然快。4.1 知识蒸馏让老师教出一个更精干的学生知识蒸馏的思想很直观用一个能力更强的大模型teacher去指导一个小模型student训练。学生模型不光学习训练集的正确答案hard label还要模仿教师模型的输出概率分布soft label因为概率分布里藏着比单一答案更多的信息比如“这张图更像猫还是更像狗”。训练时通常用温度系数T把概率分布“软化”再结合真实标签和教师输出两者的损失函数。我实际做过的项目里student模型压缩到teacher的1/3到1/4体积精度只掉了不到1个百分点推理延迟却降低了50%以上。这条路的性价比往往比硬塞量化要高但需要重新训练模型对很多业务场景来说成本是个现实问题。4.2 量化感知训练比训练后量化更稳一档我在前文提到了训练后量化PTQ它胜在快速、不用重新训练。但是当模型层数深、任务复杂时PTQ的误差容易累积尤其是第一个和最后一个量化层。这时我会选择量化感知训练QAT。QAT的原理是在训练阶段插入伪量化算子fake quantization让模型在训练时就模拟量化的误差从而适应低精度的数值表示。训练结束后权重是离散化的精度通常比PTQ高不少。代价是训练时间变长而且要准备额外的trick比如在训练后期冻结batch norm参数、设置足够长的退火过程。一句话总结追求快速上线选PTQ追求精度上限选QAT。4.3 轻量化结构的“基因改造”如果项目从一开始就有性能预算直接选择轻量模型结构是最高明的优化。比如MobileNet系列用深度可分离卷积代替标准卷积计算量能降低一个数量级。Transformer领域也有不少轻量方案比如减少注意力头数、采用稀疏注意力、线性注意力等。结构定制的好处是“基因层面”的决定后续不需要太多额外优化也能跑得飞快。缺点是需要根据业务指标重新验证收敛效果。做这种选择时我的建议是列一个评估矩阵精度指标、时延P99、内存占用、峰值吞吐四项全部达标才能上线每一项少了一环都容易在后期埋雷。5. 第五个角度运行时内存管理与服务化部署的“后勤保障”前面每一个角度讲的都是“让推理本身变快”但到了真实在线服务场景你很快会发现推理之外的事务同样能拖死性能显存申请太慢、内存碎片太多、缓存没命中、请求调度不均衡。5.1 显存池与内存复用别让GPU反复“搬家具”默认情况下PyTorch每执行一个op就通过cudaMalloc申请显存用完再释放。而cudaMalloc是一个很重的操作频繁调用会引入毫秒级延迟和显存碎片。为此PyTorch内部有一个CUDACachingAllocator它会把释放的显存块缓存起来后续新的tensor优先复用旧块而不是真的还给系统。但在推理引擎场景静态图和动态请求并存时显存碎片化仍然常见。尤其是不同长度的序列反复申请释放KV cache会产生大量形状不规则的显存块。LLM推理中的PagedAttention就是为解决这个问题出现的模仿操作系统内存分页机制把KV cache切成固定大小的“页”按需分配。给操作系统的连续内存管理理论找了一份GPU版的应用效果立竿见影。5.2 推理结果缓存把算过的答案存起来在线服务里很多请求是重复的。同一个报表、同一个推荐位、同一段文本的embedding没必要每次重新算一遍。加一层结果缓存命中时直接返回是性能上最便宜的一笔投资。LLM场景还有更高级的缓存前缀缓存prefix caching。一个聊天机器人的系统提示词往往是固定的与其每次请求都重新计算这段前缀的KV cache不如把前缀对应的KV缓存起来新请求直接复用。这样长上下文的首token延迟能大幅下降。这类优化在普通推理引擎里是看不到的只有深入服务层才能做。5.3 并发控制、限流与防止“慢请求”拖垮全局GPU是典型的多租户共享资源如果不对并发做控制来了10个慢的大请求后面100个小请求全部排队。处理这类问题我通常用两套措施按延迟预算分流短请求走一条优先队列长请求走另一条队列避免相互阻塞。设置最大并发数和排队超时GPU并行度有限超过就拒绝或走降级方案而不是无限堆积。还有一点容易被忽略的是动态shape问题。同一个模型如果每次输入长度都不同推理引擎无法提前做内存规划和kernel选择性能会有明显回退。很多部署框架支持“形状范围”约束把所有输入动态范围控制在少数几个档位然后为每个档位构建固定shape的优化引擎性能可以恢复不少。6. 实用建议与避坑指南优化做多了你会发现一个规律绝大多数性能问题不是某一个技术点没做对而是瓶颈没有找对。所以最后这部分我把自己的一套工作方法和我踩过的坑集中整理出来。6.1 先量化再优化动手之前先用性能分析工具建立基线。GPU场景我用Nsight Systems看时间线、识别GPU空闲区间用ncu定位具体kernel瓶颈CPU服务最少也要做一次火焰图分析。没有基线数据所有优化都可能是瞎猜。一个通用的经验是找瓶颈要按这个顺序——先看访存/带宽受限再看kernel启动和内存分配最后才考虑算子本身算得够不够快。6.2 三个常见误区误区一只盯FLOPS忽略显存带宽。矩阵乘法的优化方向是提升算力利用率但这只适用于计算强度高的算子对embedding、激活函数、KV cache这些访存密集的操作提升带宽效率才是正道。误区二盲目上量化导致精度崩了再回滚。量化是有效的工具但不是万能药。小批量业务或长尾数据多的场景很容易让校准集失真量化前一定要评估误差和算法鲁棒性。误区三为了框架换框架忽略了服务本身的问题。有时候延迟高不是算子慢而是请求线程模型没设计好、网络io阻塞、显存反复申请。先把服务层的问题排查干净再把精力留给推理引擎更有针对性。6.3 几条值得长期坚持的实操心得每次优化都做A/B对照实验固定输入数据、固定batch、固定并发延迟取P50和P99两个指标同时看只看均值很容易被骗。推理引擎版本升级后必须重新跑一遍性能基线。TensorRT换一个版本同一个网络的优化结果有时会有明显差异。给优化工作单独建一份文档记录每一次改动对应的指标变化。性能优化是个系统工程没有记录过两个星期连自己都不知道什么改动起了作用。有条件就自动跑benchmark集成到CI里。性能和功能一样也会在不知不觉中退化自动化回归是唯一的防线。最后分享一个我自己的感受性能优化做久了你会逐渐培养出一种“估算任何一件事快不快”的直觉。拿到一个推理任务先估算它的计算强度再估算显存带宽上限然后看它到底卡在哪。这种直觉不是天生的而是踩过足够多的坑、跑过足够多的profile之后自然沉淀出来的。希望这篇拆解能让你在模型推理性能优化这条路上少走一些弯路。
返回列表