ARTICLE DETAIL

资讯详情

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

DeepJIT融合内核实战:拆掉TensorRT串行小核墙

DeepJIT融合内核实战:拆掉TensorRT串行小核墙 第一次在 Nsight Systems 里看到自己 TensorRT 引擎的完整时间轴时我整个人是懵的一长串两三微秒的小核排着队顺序执行CUDA kernel 的启动开销占掉整帧耗时将近三分之一。这就是标题里说的串行小核墙。为了拆掉它我试了 DeepJIT 的路子——手写 CUDA 内核在运行期用 NVRTC 现场编译、动态加载把被优化器拆散的串行小核重新焊成大核。这篇文章把我完整踩过的过程记录下来怎么定位问题、为什么 TensorRT 合不上、DeepJIT 最小骨架怎么写、融合内核分几层写以及最后到底提了多少、有哪些绕不过去的坑。适合两类人一类是正在做低延迟推理、被一堆 elementwise 小核卡帧率的人另一类是想搞清楚动态编译 手写 kernel到底能在工程里解决什么问题的同学。我会尽量把原理讲明白也会给出可以直接抄的代码骨架。1. 先定位问题那一堵串行小核墙是怎么量出来的1.1 三微秒的核排两百个队很多同学以为 GPU 上 kernel 就是一次调用一次执行快得很。实际上一次 kernel launch 从 CPU 侧提交到 GPU 真正开始干活中间要经过命令缓冲区、驱动校验、上下文切换通常要 3~10 微秒。这还没算 GPU 侧启动新 kernel 时的流水线排空和重新灌满的时间。如果你的 kernel 本身要跑 50 微秒3 微秒的 launch overhead 无所谓但如果一个 kernel 只要跑 2~3 微秒那 launch overhead 就和计算时间一样长了。我那个检测模型ONNX 导出来有几百个节点TensorRT 优化后主干的卷积基本都被融合掉了但检测头的部分保留了大量 elementwise 小算子sigmoid、exp、乘法、加法、slice、concat、cast……它们被拆成了一百多个小 kernel每个都在 2~4 微秒左右整串跑下来 GPU 真正干活的时间只有零点几毫秒剩下的全耗在排队启动上。更亏的是内存带宽。每个小 kernel 都要把张量从全局内存读一遍、算完再写一遍。一个 4MB 的中间张量被十个串行小核轮一遍就是 80MB 的读写流量——哪怕不启动排队光带宽也浪费掉了。所以拆串行小核墙本质上是同时解决两个问题启动开销和无效内存往返。1.2 用 Nsight Systems 复现GPU 空转现场定位这件事别靠猜直接上 profiler。我的标准操作是nsys profile --tracecuda -o deepjit_trace python run_engine.py nsys stats --reportcuda_gpu_trace deepjit_trace.nsys-rep看 CUDA GPU trace 的时候我一般盯两个东西一是 kernel 时长分布如果出现一大片 1~5 微秒的短条基本可以断定是串行小核墙二是 CPU 侧的 CUDA API 提交时间和 GPU 侧实际执行时间之间的差值如果 CPU 侧的cuLaunchKernel一批一批地排队而 GPU timeline 上 kernel 之间有明显空档那就说明启动开销在支配整个推理。再用--reportgpu_memops看一下全局内存读写总量会发现比理论最小值高好几倍——这就是小核各自读各自写的代价。一句话总结定位方法短 kernel 数量多 GPU timeline 空档多 内存流量虚高三条同时成立DeepJIT 就值得试。2. TensorRT 想帮你又帮不上的地方融合边界的真相2.1 TensorRT 最擅长和最不擅长的两张表TensorRT 确实做了大量图优化但它不是万能的。它最擅长的是把 CNN 里标配三件套焊在一起TensorRT 擅长的融合例子Conv Bias Activation一个 kernel 搞定BatchNorm 折叠推理期 BN 变成 scalebias 直接并进 conv常见 elementwise 短链连续几层 add / mul / activation 有时能合它不擅长的场景恰恰是串行小核墙的来源TensorRT 不擅长的场景结果自定义算子 / Plugin 两侧融合链在这里断开前后各剩一堆小核动态 shape 下的通用策略宁可下发单个小 kernel也不冒险做激进融合layout 转换、transpose、split/concat经常被降级成纯 copy kernel一次一次拷数据依赖的控制流图上有 if/while优化器只能保守处理换句话说TensorRT 的融合是模式匹配式的它只在认识且能证明安全的模式上合。一旦碰到它不认识的边界哪怕只是一个小算子整条融合链也会断掉断口两侧的算子全变成独立小核。2.2 三个最容易拆散小核的元凶我这次项目里罪魁祸首有三个你可以对照自己的模型排查第一自定义算子。检测头里我用了几个 ONNX 里就有的 op但 TensorRT 对这些 op 的支持不完整比如某个自定义的归一化逻辑它干脆不认识。于是以它为分界前面一堆 op、后面一堆 op全被拆成小核。第二动态 shape。我在部署时用了动态 batch。动态环境下 TensorRT 不敢做太激进的融合因为同一个 kernel 可能要服务多种 shape它宁可选一个通用但低效的实现。我后来固定 batch1 重新构建引擎小核数量立刻少了一批。第三slice/concat/transpose 这一类张量搬运算子。这些 op 本身没啥计算量但会被实现成内存拷贝 kernel。如果模型里有 NMS 前处理、多分支拼接这类结构你会看到大量 copy kernel 排在时间轴上。搞清楚这三个元凶之后你的选择就清晰了要么改模型结构绕开它们有时候可行要么在融合链断裂的地方自己上融合 kernel——这正是 DeepJIT 的位置。3. DeepJIT 的核心动作运行期拼源码、NVRTC 现场编译、动态加载3.1 为什么选择运行期生成源码而不是预编译最早我的想法是直接写一个 TensorRT Plugin把融合 kernel 预编译进 .so 里。做了一轮之后发现三个不舒服的地方一是要维护一堆 CRUD 代码Plugin 的 create/configure/serialize/enqueue 全是样板二是每个 shape 变体都得靠运行时参数传编译器没法专门优化三是调试一次要重新编 .so、重新对齐 TRT 版本来回很慢。DeepJIT 的思路完全反过来kernel 的源码在运行期拼出来用 NVRTC 现场编译成 PTX再用 CUDA driver API 加载。因为源码是运行期生成的我可以把张量大小、向量化宽度、是否处理尾块这些信息直接写死进代码里编译器能看到常量就能做循环展开、向量化、用立即数这些都是预编译插件给不了的。还有一层对比是 CUDA Graph。很多人遇到小核多第一反应是上 CUDA Graph 录一波。这个办法只解决了 launch 开销并没有减少 kernel 数量每个小核照样各自读一遍、写一遍全局内存。DeepJIT 是真正把 N 个小核算法合并到一次 kernel 里内存流量按比例降下来。两者不冲突可以在融合之后再用 CUDA Graph 保一层但别指望 Graph 替代融合。3.2 一个最小可跑的 DeepJIT 骨架先给一个最精简的编译加载管线你可以直接抄。#include nvrtc.h #include cuda.h #include cuda_runtime.h #include string #include stdexcept #define NVRTC_CHECK(x) \ do { \ nvrtcResult r (x); \ if (r ! NVRTC_SUCCESS) { \ throw std::runtime_error(std::string(nvrtc: ) \ nvrtcGetErrorString(r)); \ } \ } while (0) #define CU_CHECK(x) \ do { \ CUresult r (x); \ if (r ! CUDA_SUCCESS) { \ const char* msg nullptr; \ cuGetErrorString(r, msg); \ throw std::runtime_error(std::string(cuda: ) msg); \ } \ } while (0) // 一个简单的融合 kerneladd - relu - scale const char* kFusedSource R( extern C __global__ void fused_add_relu_scale( const float* a, const float* b, float* out, float scale, int n) { int i blockIdx.x * blockDim.x threadIdx.x; if (i n) { float t a[i] b[i]; t t 0.f ? t : 0.f; out[i] t * scale; } } ); CUfunction DeepJitLoad(const std::string src, const std::string name, int sm_arch) { nvrtcProgram prog; NVRTC_CHECK(nvrtcCreateProgram(prog, src.c_str(), fused.cu, 0, nullptr, nullptr)); std::string arch --gpu-architecturesm_ std::to_string(sm_arch); const char* opts[] {arch.c_str(), -use_fast_math, --stdc17}; nvrtcResult res nvrtcCompileProgram(prog, 3, opts); if (res ! NVRTC_SUCCESS) { size_t logSize 0; nvrtcGetProgramLogSize(prog, logSize); std::string log(logSize, \0); nvrtcGetProgramLog(prog, log.data()); nvrtcDestroyProgram(prog); throw std::runtime_error(nvrtc compile failed:\n log); } size_t ptxSize 0; NVRTC_CHECK(nvrtcGetPTXSize(prog, ptxSize)); std::string ptx(ptxSize, \0); NVRTC_CHECK(nvrtcGetPTX(prog, ptx.data())); NVRTC_CHECK(nvrtcDestroyProgram(prog)); CUmodule mod; CU_CHECK(cuModuleLoadData(mod, ptx.c_str())); CUfunction fn; CU_CHECK(cuModuleGetFunction(fn, mod, name.c_str())); return fn; }注意一点调用 driver API 之前最好先把 primary context 建好避免和 PyTorch / CUDA runtime 抢上下文。常用姿势是cuInit(0); CUdevice dev; cuDeviceGet(dev, 0); CUcontext ctx; cuDevicePrimaryCtxRetain(ctx, dev); cuCtxSetCurrent(ctx);然后启动 kernelvoid LaunchFused(CUfunction fn, const float* d_a, const float* d_b, float* d_out, float scale, int n, cudaStream_t stream) { int threads 256; int blocks (n threads - 1) / threads; void* args[] {d_a, d_b, d_out, scale, n}; CU_CHECK(cuLaunchKernel(fn, blocks, 1, 1, threads, 1, 1, 0, stream, args, nullptr)); }这套骨架跑通之后你就有了一个运行期生成 CUDA 内核的最小闭环。剩下的问题全在源码生成策略和 kernel 写法上。顺带提醒一句部署环境的事NVRTC 的头文件和库是随 CUDA Toolkit 一起装的在 Ubuntu 上别只装 runtime 版。我遇到过一次nvrtc.h找不到查了半天才发现是装 TensorRT 时顺手装了个运行时没有完整 toolkit后来把/usr/local/cuda/include/nvrtc.h确认存在才跑通编译。3.3 Shape 硬化让编译器替你展开循环NVRTC 编译一次大约要几十到几百毫秒挺贵的所以源码生成时尽量把一切能确定的都变成常量。比如上面的 kerneln是运行期参数编译器没法针对它做太多优化。如果我把n直接宏替换进源码#define N 1048576 extern C __global__ void fused_add_relu_scale_fixed( const float* a, const float* b, float* out, float scale) { int i blockIdx.x * blockDim.x threadIdx.x; if (i N) { float t a[i] b[i]; t t 0.f ? t : 0.f; out[i] t * scale; } }N 变成编译期常量后编译器知道循环边界能把条件判断优化成无分支甚至完全展开。同理#define VEC 1开向量化、#define TAIL 0关尾块处理都会让生成的 SASS 更精简。代价是编译缓存的 key 变多了每个 shape 组合对应一份源码。我的做法是维护一个unordered_mapstring, CUfunctionkey 用hash(source arch nvrtcOptions)生成命中就直接跳过 NVRTC。上线前把常见的 batch 尺寸、分辨率都预编译一遍warmup 时全部塞进缓存线上推理零编译。4. 手写融合内核的三个层次元素级、向量化、归约类4.1 第一层把一条 elementwise 链焊成一个核融合 kernel 的起点是 elementwise 链。比如检测头里常见的(a b) - relu - scale - add c原来是三四个小核焊成一个extern C __global__ void fused_chain(const float* a, const float* b, const float* c, float* out, float scale, int n) { int i blockIdx.x * blockDim.x threadIdx.x; if (i n) { float t fmaxf(a[i] b[i], 0.f) * scale c[i]; out[i] t; } }别看代码不长收益是实打实的原来 (ab) 写一次临时张量、relu 读一次写一次、scale 又读一次写一次、加 c 再读两次写一次同一个数据在全局内存里被来回折腾四五趟融合后只读 a/b/c 一次、写 out 一次内存流量直接砍到三分之一以下。GPU 算力通常不是瓶颈带宽才是所以这种融合在 elementwise 密集的模型上效果最明显。写这种 kernel 我习惯直接用if (i n)而不是 grid-stride loop因为推理场景 kernel 是一次性调度起一个恰好覆盖 n 的 grid 就够了。grid-stride loop 适合常驻 kernel 或需要动态限流的情况这里用不上。4.2 第二层float4 向量化把带宽榨干elementwise kernel 有个天然优势每个线程处理的数据彼此独立可以一次性读四个 float用float4向量化。向量化的本质是让单个线程产生更多的内存级并行减少指令数和访问次数对带宽敏感型 kernel 非常有效。extern C __global__ void fused_chain_vec4(const float4* a, const float4* b, const float4* c, float4* out, float scale, int n4) { int i blockIdx.x * blockDim.x threadIdx.x; if (i n4) { float4 va a[i], vb b[i], vc c[i]; float4 t; t.x fmaxf(va.x vb.x, 0.f) * scale vc.x; t.y fmaxf(va.y vb.y, 0.f) * scale vc.y; t.z fmaxf(va.z vb.z, 0.f) * scale vc.z; t.w fmaxf(va.w vb.w, 0.f) * scale vc.w; out[i] t; } }这里有两个必须注意的坑第一指针要对齐。float4*要求 16 字节对齐。cudaMalloc出来的显存没问题但 TensorRT 引擎里拿到的 binding 指针未必对齐尤其是经过一些 layout 优化之后。我吃过的亏是插件里拿到 TRT 给的指针直接按 float4 读结果非法地址错误排查了半天。正确做法是先检查指针地址和n4的整除性不对齐就退回标量 kernel。第二尾块。n不一定被 4 整除剩下的 1~3 个元素要用标量路径处理。我的习惯是源码生成时把n/4作为常量写进主循环剩下的尾块单独生成一个 if 分支甚至单独一个标量 kernel别在主循环里加最后一块处理一下的判断那样会干扰编译器向量化。4.3 第三层归约类融合处理 LayerNorm / Softmaxelementwise 之外另一个串行小核重灾区是归约类算子。TensorRT 处理 LayerNorm 往往会拆成均值 kernel、方差 kernel、归一化 kernel 三个小核每个都对张量做一次完整读写。融合的思路是一个 block 负责一行先在 shared memory 里把均值和方差算出来再做归一化一次内存遍历解决问题。extern C __global__ void fused_layernorm(const float* x, const float* gamma, const float* beta, float* y, float eps, int cols) { int row blockIdx.x; int tid threadIdx.x; const float* row_in x row * cols; float* row_out y row * cols; __shared__ float s_sum[256], s_sq[256]; __shared__ float s_mean, s_rstd; float sum 0.f, sq 0.f; for (int i tid; i cols; i blockDim.x) { float v row_in[i]; sum v; sq v * v; } s_sum[tid] sum; s_sq[tid] sq; __syncthreads(); if (tid 0) { float ts 0.f, tq 0.f; for (int i 0; i blockDim.x; i) { ts s_sum[i]; tq s_sq[i]; } s_mean ts / cols; float var tq / cols - s_mean * s_mean; s_rstd rsqrtf(var eps); } __syncthreads(); for (int i tid; i cols; i blockDim.x) { row_out[i] (row_in[i] - s_mean) * s_rstd * gamma[i] beta[i]; } }这个版本是一个 block 处理一行的经典结构适合 cols 在几百到一两千的场景。实际部署时我还会做两处升级一是行内归约用 warp shuffle 替代 shared memory 数组二是当一行太长时拆成多个 block 做 split-K 归约再用 atomics 合并。不过对大多数推理模型来说上面的版本已经能跑赢 TensorRT 拆出来的三个小核了。注意__syncthreads()一定不能少否则第二个循环里读s_mean、s_rstd时可能还是旧值。这个 bug 我写过一次症状是输出里偶尔有 NaN时有时无特别阴间。5. 把 DeepJIT 塞进推理链路的三种姿势有了能跑的融合 kernel接下来是想办法让它进到真正的推理链路里。按工程成本从高到低有三种姿势。5.1 姿势一写成 TensorRT Plugin最正统也最重在 TensorRT 里注册自定义算子走IPluginV2DynamicExt或新版的IPluginV3。plugin 的enqueue里调用你缓存的CUfunctionJIT 编译放在引擎加载阶段完成。这个姿势的优点是融合真的发生在 TensorRT 图里引擎序列化后整体一致缺点是样板代码巨多要处理 input/output 的格式协商、维度推断、序列化反序列化还要和具体 TRT 版本绑定。我的建议是如果团队有精力维护这套代码选它否则先别碰。就算走这条路线也别把 NVRTC 编译过程塞进 plugin 的 enqueue。正确做法是plugin 构造函数或 configure 阶段编译好 kernel 并缓存enqueue 只做cuLaunchKernel。推理热路径上任何编译动作都是灾难。5.2 姿势二在引擎外面替换瓶颈段推荐先试我这次实际落地的就是这条路线。检测头的串行小核区域在计算图上是一整块连续区域主干输出 - 一堆 elementwise - 后处理。我把它从 TensorRT 网络里拆掉让引擎只跑主干输出一个中间张量后处理部分用 DeepJIT 生成的几个融合 kernel 在同一条 CUDA stream 上继续跑。这样做的好处是TensorRT 引擎保持标准构建不用碰 plugin API中间张量的 shape 和 layout 是固定的源码生成时能把所有 shape 硬化到极致调试也简单前面引擎跑完后面核函数逐个打日志。代价是你要保证引擎输出和后续 kernel 的输入格式完全一致包括 channel order、dtype、stride。如果 TensorRT 引擎为了内部效率给输出定了 NHWC 或者半精度你的融合 kernel 要么跟着改要么在引擎输出处加一层转换。我的做法是在 ONNX 导出时就把尾部分割出来让主干引擎输出强制为 NCHW FP32后处理 kernel 全部按这个约定写省心很多。5.3 姿势三干脆自己组一小段执行图最后一招是彻底绕开 TensorRT 来管那些病态的小算子。如果你的模型里有那么一段图TensorRT 怎么动都是拆成小核那你可以不把它放进引擎而是在推理代码里直接按顺序 launch 你的 JIT kernel甚至用cudaStreamBeginCapture把这几个 launch 录成 CUDA Graph。我自己试过之后觉得融合 手写执行序的体验很像在用微型的 TVM自由度很高——你可以任意调节 kernel 的 grid/block、shared memory 大小、换用不同的向量化策略——代价是失去了引擎的统一管理shape 一变所有 launch 参数都要重新算。好在有 JIT 编译兜底shape 变了重新生成源码重新编译就行这套组合拳配合好之后其实挺稳的。6. 实测数据与避坑清单6.1 一组可复现的 benchmark 数据交代一下测试环境RTX 3060 12GCUDA 11.8TensorRT 8.5模型是一个带复杂检测头的检测模型batch1FP32。我主要优化的是检测头那一段串行小核区域主干引擎保持不变。指标原始 TensorRT只加 CUDA GraphDeepJIT 融合尾部 region 的 kernel 数1321324kernel 平均执行时长2.3 μs2.3 μs11 μs估算 launch 开销约 370 μs约 10 μs约 10 μs尾部内存往返次数7 次左右7 次左右2 次尾部端到端耗时约 0.68 ms约 0.36 ms约 0.09 ms整帧延迟含主干2.31 ms1.99 ms1.72 ms数字只看比例就好不同模型差异很大。关键结论是三条CUDA Graph 能砍掉 launch 开销但内存往返省不了所以它把 0.68ms 降到 0.36msDeepJIT 融合把计算和内存一起省了尾部直接降到 0.09ms整帧从 2.31ms 降到 1.72ms提升大约 26%对延迟敏感场景是很可观的量级。6.2 第一条坑NVRTC 编译慢忘了做缓存NVRTC 编译一次五十毫秒起步如果线上每次推理都编译你的服务早就超时了。我的做法是三层缓存源码字符串做 key 的unordered_map是最基本的第二层是 PTX 落盘进程重启后直接从磁盘读第三层是上线前 warmup把常见 shape 组合全部编译一遍。千万不要在 enqueue 或者热路径里触发新的编译。6.3 第二条坑-use_fast_math 把精度吃掉了-use_fast_math会把除法、平方根、倒数都变成近似指令对 elementwise 融合通常没问题但对归一化、softmax 这类算子误差可能大到肉眼可见。我的策略是默认不开-use_fast_math只在某一个 kernel 确实需要且验证过误差在可接受范围时才单独开。更细的做法是源码里只对特定表达式用__fdividef、__frsqrt_rn这类 intrinsic而不是全局开。6.4 第三条坑cuModuleUnload 和异步流的恩怨kernel 还在 GPU 上排队执行你这边cuModuleUnload把包含它的模块卸载了轻则随机的非法地址重则直接 CUDA_ERROR_ILLEGAL_ADDRESS而且这错误还不好复现因为它是时序相关的。我的规矩是除非确定所有相关流都同步过否则 module 不主动卸载让它和进程同生共死。一个推理进程里模块数量本来就不多内存又不是问题没必要在这省。6.5 第四、五条坑对齐假设与指针所有权第四坑是内存对齐。前面在 float4 那节提过这里再强调一次从 TRT binding、PyTorch tensor、自建显存池拿到的指针对齐情况各不相同。写融合 kernel 前先写个 assertion 检查指针地址和步长别默认人家帮你对齐了。第五坑是指针所有权。如果载体是 PyTorch你从tensor.data_ptr()拿到的指针只有在 tensor 存活时有效融合 kernel 是异步的kernel 还没跑完tensor 就被释放回显存池你读到的就是脏数据。稳妥做法是在 launch 前tensor.record_stream(stream)或者干脆让张量跨过 kernel 的生命周期再释放。7. 边界感哪些模型不值得上 DeepJIT7.1 大算子为主时收益约等于零DeepJIT 解决的问题非常具体大量短 kernel 串行执行 全局内存反复读写。如果你的模型主力是卷积、GEMM、attention 这类大算子TensorRT 早就把它们融合得很好了你再手写 kernel 纯属给自己找活。判断标准很简单profiler 里短 kernel 占比高不高内存流量是不是远超理论值。两者都不满足就不要折腾。7.2 动态 shape 下的维护成本动态 shape 是 DeepJIT 最头疼的敌人。每一个新的 shape 组合都可能触发一次新的源码生成和编译缓存体积膨胀、warmup 枚举不完、线上偶发编译超时。我的经验是把连续 shape 量化为有限的几个桶batch 只允许 1/2/4/8分辨率只允许预设的几种一旦命中桶就直接 pad 到桶内固定大小用 mask 或尾块处理掉多出来的部分。这样编译次数从无限收敛到几十次完全可控。最后说点个人感受。做完这个项目之后我最大的变化是再也不把推理优化当成换引擎或者调参数了。先看 profiler确认瓶颈是启动开销还是带宽再决定上 CUDA Graph 还是 DeepJIT 融合。前者是消除排队后者是减少干活次数两者解决的问题维度不一样配合起来才是完整的优化思路。希望这篇尝鲜记录也能帮你少走几步弯路。
返回列表