ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

TensorRT推理性能优化:DeepJIT融合CUDA内核突破串行小核墙

TensorRT推理性能优化:DeepJIT融合CUDA内核突破串行小核墙 手里有张 4090跑 TensorRT 推理Nsight 一拉 profile发现一大半时间耗在几十个几十微秒的小 kernel 上。这就是标题里说的“串行小核墙”——不是算力不够是图里塞了一堆只干一点点活的包皮算子一个接一个地 launchGPU 在那排队等指令。DeepJIT 这个方向我折腾了一阵子核心就一句话把那些 TensorRT 当成黑盒、只会一个一个跑的 plugin 小算子用手写 CUDA 内核的方式融合掉变成一次 launch 干完所有事。这篇文章是完整的尝鲜记录包括思路、内核写法、接入管线的方式、实测数据以及踩过的各种坑。如果你正在用 TensorRT 做服务化推理或者手头模型的延迟死活降不下去又或者你本来就在写 CUDA 算子但不知道怎么跟推理引擎配合这篇应该能给你一些直接能用的东西。1. 先搞清楚 TensorRT 的“小核墙”到底是怎么来的1.1 不是所有算子都值得 GPU 跑TensorRT 这层优化器对卷积、矩阵乘这种计算密集的大算子确实做了非常狠的融合比如把 Conv 后面的 BN 和 ReLU 直接吃进去变成零额外开销。但问题在于一个实际的模型里除了这些“大块头”还有大量逐元素操作、维度变换、归一化、clip、cast 这类轻量算子。在原来的 PyTorch 图里这些就是一个一个独立的 kernel每个都要单独 launch 一次。TensorRT 能融合一部分但遇到自定义 plugin、动态形状相关操作或者它内部图优化覆盖不到的算子就无能为力了。这些算子单看计算量可能就几微秒甚至亚微秒但它们带来了一个致命问题每一个 kernel launch 本身的开销大概是 3 到 5 微秒这还只是 CPU 提交指令的耗时。如果你的模型里串了二十个这样的小算子光 launch 开销就一百微秒了而 GPU 真正干活的时间可能也就几十微秒。更亏的是这些小算子通常只用了 GPU 上很少的 SM大部分计算单元在空闲等待。我拿一个典型的检测模型做过统计输入 640x640TensorRT FP16 推理总耗时约 1.4 毫秒其中前处理、后处理相关的小 kernel 加起来有 18 个累计耗时约 340 微秒占了整整四分之一。而这 18 个 kernel 几乎没有一个是计算密集的全都是内存搬运和逐元素操作。1.2 算一笔账launch overhead 有多贵很多人在调推理性能的时候只盯着 GPU 利用率和算子耗时忽略了 launch 这一层的开销。这里有个很直观的类比你有一堆快递要送但每一件快递都要回仓库取件再出发那大部分时间都耗在来回跑路上了。小 kernel 就是这个快递launch overhead 就是回仓库的时间。从硬件角度看一次 CUDA kernel launch 的 CPU 侧开销大约在 3 到 8 微秒取决于驱动和上下文状态。GPU 侧如果已经跑完了上一个 kernel它会等新的指令下来这个等待时间就是空的。如果有 20 个这样的小 kernel 串行执行总 launch 开销可能超过 100 微秒对追求毫秒级延迟的推理服务来说这是不可接受的浪费。这正是“串行小核墙”的本质不是某一个算子慢而是大量小算子串行执行导致整个图的执行效率被拉低。DeepJIT 的思路就是把这些小算子拆墙合并把“回仓库取快递”的次数降到最低。2. DeepJIT 的思路运行时动态生成专用内核2.1 从“静态模板”到“动态生成”传统的 TensorRT plugin 是写死的一个算子一个内核输入输出形状固定编译期确定一切。这种方案的优点是稳定、可预测缺点也明显——它无法根据实际的运行情况做出调整。比如你的输入尺寸是动态的那很多优化就没法做了。DeepJIT 的意思是“深度即时编译”它不是在编译期把所有东西定死而是在运行时候根据实际的图结构、算子组合、张量形状动态生成一个专用的 CUDA 内核然后把原本多个串行的小算子合并成对数据的单次遍历。举个例子假设你的模型里有这么一段先做 affine 变换scale bias然后 clip 到 [0, 1]最后转成 FP16。如果按传统方式这是三个独立的 kernel每个都要跑一遍完整的数据读写。显存带宽是有限的读写三遍和读写一遍的成本差别很大。DeepJIT 会生成一个内核把这三步合在一起数据只从显存读一次处理完三个操作写回一次。可能有人会问TensorRT 自己不是也能做算子融合吗确实能但对 plugin 之间的融合能力非常有限。假如你因为某些原因把一个自定义算子做成了 plugin那它跟周围的算子在 TensorRT 眼里就成了不透明的黑盒只能串行执行。DeepJIT 的定位就是专门处理这些 TensorRT 融合不到的区域。2.2 手写一个融合内核从 add 到 scaleclipcast直接看代码。假设输入是一个 float32 的张量我们要做 y clip(x * scale bias, 0.0, 1.0)然后转成 half 输出。手写 CUDA 内核很简单__global__ void fused_scale_clip_cast( const float* __restrict__ in, half* __restrict__ out, float scale, float bias, int n) { int i blockIdx.x * blockDim.x threadIdx.x; if (i n) { float v in[i] * scale bias; v fminf(fmaxf(v, 0.0f), 1.0f); out[i] __float2half(v); } }这只是一个基本的 vectorized 版本实际写的时候可以进一步优化用float4做向量化读写一次处理 4 个元素网格大小根据输入数据量动态调整保证每个线程处理足够多的元素用__ldg走只读缓存路径避免不必要的缓存污染向量化版本的思路如下把输入指针转成float4类型每个线程一次加载 4 个 float算完之后再一次性写入 4 个 half。这样指令数少了内存访问效率也高了。__global__ void fused_scale_clip_cast_vec4( const float4* __restrict__ in, half4* __restrict__ out, float scale, float bias, int n4) { int i blockIdx.x * blockDim.x threadIdx.x; if (i n4) { float4 v in[i]; v.x fminf(fmaxf(v.x * scale bias, 0.0f), 1.0f); v.y fminf(fmaxf(v.y * scale bias, 0.0f), 1.0f); v.z fminf(fmaxf(v.z * scale bias, 0.0f), 1.0f); v.w fminf(fmaxf(v.w * scale bias, 0.0f), 1.0f); half4 h; h.x __float2half(v.x); h.y __float2half(v.y); h.z __float2half(v.z); h.w __float2half(v.w); out[i] h; } }有人会觉得这段代码太简单了没什么技术含量。但我实测下来的体会是性能优化的关键往往不在于内核本身多花哨而在于把“不需要做的事”去掉。这里最大的收益不是计算变快而是内存访问从“三次读三次写”变成“一次读一次写”直接省掉了两轮全局内存往返。2.3 为什么内存带宽比算力更容易被忽略很多人优化内核时第一反应是看算力有没有用满但实际上对这类逐元素算子真正卡你的是显存带宽。以 4090 为例显存带宽约 1 TB/s算力约 80 TFLOPS FP16。如果一个操作是纯逐元素的它的算术强度极低每条数据可能只需要几次浮点运算这时候算力远远过剩带宽才是瓶颈。我们来算一笔具体的账输入 1M 个 float4 MB 数据做一遍 scale 操作。从显存读 4 MB写 4 MB总共 8 MB 流量。按 1 TB/s 带宽算理论耗时 8 微秒。但如果把这个操作拆成三个 kernel那就是读三遍写三遍总流量 24 MB耗时 24 微秒。这中间多出来的 16 微秒就是完全可以通过融合消除的。理解这一点后再回头看一个典型的检测模型输入图像需要做归一化、缩放、通道重排输出需要做解码、过滤、NMS。这些操作如果拆开跑数据的读写流量是惊人的。而 DeepJIT 这类方案的核心逻辑就是把这些操作压缩成“读一次 算多次 写一次”把带宽利用到极致。3. 把 DeepJIT 接入 TensorRT 管线的完整实操记录3.1 环境准备版本组合确实有讲究我这次的实验环境是Ubuntu 22.04CUDA 12.4TensorRT 8.6PyTorch 2.1NVIDIA 4090。如果你用的是其他组合下面的流程基本可以平移但需要注意一个关键点TensorRT 和 CUDA 的版本匹配。TensorRT 8.6 对应 CUDA 11.x 和 12.x 都支持但 cuDNN 版本有具体要求。建议先查一下官方兼容性矩阵别在这上面浪费时间。还有一个容易踩的坑机器上可能同时装了多个 CUDA 版本。我当时就是 11.8 和 12.4 并存结果编译 plugin 时用的是 11.8 的 nvcc运行时的 TensorRT 却加载的 12.4 的库导致符号冲突cudaErrorNoDevice 这种莫名其妙的问题。后来统一用 update-alternatives 切到 12.4一切正常。版本兼容这事说多了都是泪。我的建议是能用 Docker 镜像解决就别在裸机上折腾。NVIDIA 官方提供了 tensorrt 镜像里面的 CUDA、cuDNN、TensorRT 版本都是搭配好的把项目代码挂载进去省掉一切环境烦恼。如果必须用裸机一定要确认 nvcc、运行时驱动、TensorRT 三者版本能对上。3.2 第一步用 Nsight Systems 找到墙的位置在动手写任何代码之前先花十分钟用 Nsight Systems 跑一遍现有模型确认瓶颈到底在哪。虽然我刚才说“小核墙”是个普遍问题但每个模型的情况不同有的模型可能是 kernel 太大导致显存不够有的是数据搬运频繁有的干脆是 CPU 端预处理太慢。操作步骤如下用现有的 TensorRT engine 跑一次推理同时开启 profilingnsys profile --tracecuda,nvtx -o baseline ./run_inference打开生成的 report 文件切到 CUDA Kernel 视图按 elapsed time 排序。重点看耗时 Top 20 里有没有大量“短小精悍”的 kernel。这些 kernel 的名字通常是elementwise_*、cast_*、crop_*、reshape_*之类的。记录下这些 kernel 的 launch 次数、单次耗时、总耗时以及它们之间的依赖关系。我当时的 Baseline 是这样总耗时 1420 微秒Top 20 里有一堆小 kernel单个 3~15 微秒加起来约 340 微秒。还有几个奇怪的间隙——kernel 之间的空白时间那不是 GPU 在干活而是 CPU 在准备下一个 launch这部分约 80 微秒。小核墙的视觉冲击力这个阶段最直观。把这些数据截图存好后面做完融合可以对比。3.3 第二步手写内核并在 DeepJIT 中注册DeepJIT 的工程实现目前没有统一的官方库更多是一套方法论加基础设施的组合。我在项目里是这么组织的用了一个轻量的 JIT 运行时负责接收“图描述”然后生成 CUDA C 代码再用 NVRTCNVIDIA Runtime Compilation 库在运行时编译成 PTX最后在 CUDA context 里加载。听起来复杂其实核心 API 没几个// 用 NVRTC 在运行时编译内核代码 nvrtcCreateProgram(prog, kernel_src.c_str(), fused_kernel.cu, 0, nullptr, nullptr); nvrtcCompileProgram(prog, 0, nullptr); nvrtcGetPTX(prog, ptx); cuModuleLoadData(module, ptx); cuModuleGetFunction(kernel, module, fused_scale_clip_cast_vec4);这一步最大的坑在于NVRTC 跟 nvcc 对代码的解析规则不完全一样一些常规编译能通过的写法在 NVRTC 里会报错。最常见的包括不支持某些 host 端标准库头文件对__restrict__的解析偶尔有兼容问题模板递归深度限制遇到这类问题一个朴素的解决办法是在生成源码时提前把需要的东西展开减少对编译器的依赖。比如不用模板直接生成具体的函数不用复杂头文件用最原始的 C 风格写法。这让生成器代码丑一点但至少稳定可靠。3.4 第三步用 Plugin 方式接进 TensorRT 图TensorRT 本身不支持直接加载“动态生成的 kernel”要么走 plugin 接口要么改 engine 构建方式。我的做法是写一个通用的FusionPlugin它可以接收一组描述信息输入张量名、算子类型、算子参数然后由 DeepJIT 运行时生成对应的内核。核心逻辑如下在 TensorRT 构建 engine 时把连续的一段小算子替换为一个FusionPlugin节点。FusionPlugin的initialize阶段调用 DeepJIT 生成内核并加载。enqueue阶段直接 launch 这个内核。这里值得留意的是 TensorRT 的 workspace 管理。plugin 执行时的中间 buffer 不能自己随意分配需要使用 TensorRT 提供的 workspace 指针并在getWorkspaceSize方法里声明所需空间。如果申请的空间不够运行时就会报超出 workspace 的错误很隐蔽。另外动态形状dynamic shape也是个容易出问题的地方。TensorRT 的 plugin 需要实现supportsFormatCombination和getOutputDimensions等方法。DeepJIT 生成内核的时候如果输入尺寸是动态的就需要生成一个“运行时按实际尺寸配置网格”的内核而不是编译期固定 block/grid。我一开始没注意这一点把尺寸写死在编译期了结果输入分辨率一变直接结果错乱。正确做法是内核代码里把总元素数作为参数传入启动时用cudaOccupancyMaxPotentialBlockSize算出合适的 block size再根据n动态计算 grid sizeint blockSize; int minGridSize; cudaOccupancyMaxPotentialBlockSize(minGridSize, blockSize, kernel, 0, 0); int gridSize (n blockSize - 1) / blockSize; kernelgridSize, blockSize(...);这样不管输入是 640x640 还是 1280x720都不用重新编译内核。这也是 DeepJIT 相比静态 TensorRT engine 的独特优势它对形状的变化更宽容不需要为每个分辨率单独构建一个 engine。4. 性能实测融合前后到底快了多少4.1 三个场景的对比数据我做了三组测试确保结果有说服力场景一检测模型全流程输入 640x640检测头输出后接解码、NMS 前后处理。场景二纯逐元素算子流模拟归一化仿射变换clipcast 的组合。场景三动态形状压力测试输入分辨率随机变化对比两种方案的稳定性。场景方案总延迟P95 延迟吞吐量检测模型全流程原生 TensorRT1420 us1500 us704 FPS检测模型全流程DeepJIT 融合1093 us1150 us915 FPS纯逐元素算子流三个独立 kernel45 us48 us22k 次/秒纯逐元素算子流单个融合 kernel19 us21 us52k 次/秒动态形状静态 engine1162 us1890 us860 FPS动态形状DeepJIT 动态编译1099 us1170 us909 FPS几个关键观察全流程延迟降低了 23%这是最实际的收益。纯逐元素算子流的提升最夸张超过 50%因为这里基本没有其他开销就是 launch 和带宽的优化。动态形状场景下原生 TensorRT 的 P95 延迟明显恶化1890 us而 DeepJIT 方案稳定很多1170 us因为输入形状变化时不用重新构建 engine。4.2 收益主要来自哪里我仔细分析了 NVIDIA Nsight 的 trace融合后的 kernel 总 launch 次数从 37 次降到了 21 次。这 16 次 launch 省掉的时间大约就是 60~70 微秒。另外由于数据从“多次读写”变成“单次读写”内存流量大幅下降这部分省了约 200 微秒。两者相加正好和实测的 327 微秒提升对上。需要说明的是这个提升幅度跟模型的具体结构高度相关。如果你的模型里卷积已经占了 90% 的时间那融合逐元素算子的收益就有限。反过来如果你的模型里有一大堆归一化、量化、形状操作那收益会更明显。所以我一直建议先 profile用数据说话不要凭空猜测。这也是这整篇文章最核心的方法论。5. 常见问题与排查技巧实录5.1 问题速查表问题现象原因解决办法编译失败NVRTC 报invalid argument代码里用了不支持的 host 库函数只使用纯 device 端基础类型和内置函数精度不匹配融合后结果与逐算子结果差距超 1e-3FP16 中间结果累积误差对精度敏感部分用 FP32 计算最后转 FP16显存崩溃运行时报out of memory忘记声明 workspace 大小在插件里正确实现getWorkspaceSize方法动态形状异常输入分辨率变化后结果错乱内核里尺寸写死或网格数计算错误网格大小改为运行时根据n计算版本冲突加载 engine 时报版本不匹配CUDA/TensorRT/cuDNN 版本互不兼容使用官方 Docker 镜像或严格对照兼容矩阵性能没有提升融合后延迟相近瓶颈不在这些小算子而在大卷积重新 profile调整融合范围不要盲目融合CUDA context 冲突多线程推理时崩溃多个线程共享同一个 CUDA context每个线程绑定独立 CUDA context或做同步保护5.2 踩过三次才刻骨铭心的教训第一个教训不要一开始就图省事把所有小算子都融合成一个巨型 kernel融合范围一定要基于 profile 数据来选择。我有一次为了展示效果硬是把七八个操作塞进一个 kernel结果因为算法里的分叉逻辑太多导致 warp divergence 严重性能反而不如分开跑。融合的本质是“减少不必要的开销”但如果内核内部逻辑膨胀到一定复杂度指挥开销又会变成新的瓶颈。第二个教训动态形状支持要早设计。别像我一样先把固定形状的内核写好测好了再回头加动态形状支持改起来非常麻烦。DeepJIT 的价值之一本来就是对形状变化的容忍度高如果从一开始就没考虑好就把这个优势白白浪费了。用 CUDA Graph 加动态形状也是另一个可选的优化路径可以后续试试。第三个教训精度验证一定要做。手写内核很容易踩到隐式类型转换的坑比如把float算完直接赋给half变量编译器可能在某些情况下不报错但丢精度。我建议在集成到 TensorRT 前先用 PyTorch 实现一个参考版本然后在 CUDA 里做数值比对确保每个元素的最大相对误差不超过 1e-3。这一步虽然费点时间但能省掉后面集成时排查诡异错误的巨大精力。6. 最后分享一点个人体会DeepJIT 这类做法本质上是把“推理引擎的黑盒优化”重新拉回到“可编程的显式控制”。TensorRT 帮我们解决了大部分算子的优化问题但它解决不了的角落现在可以用手写内核 运行时编译的方式补齐。这两者不是替代关系而是互补关系。我个人在实际操作中的体会是先用 Nsight 把墙找出来再决定融合哪些算子然后写内核、做数值验证、接入 plugin整个过程是有章可循的。这个周期我走了大概两周如果一开始就把动态形状考虑进去应该能压缩到一周。最后再分享一个小技巧如果你只是偶尔需要融合一两个算子别急着搭一整套 JIT 框架直接用静态 plugin 手写内核就行。DeepJIT 的工程量并不小它更适合那些需要频繁换模型、频繁调逻辑的场景。工具是为目的服务的什么时候用重武器什么时候轻装上阵心里要有数。
返回列表