手里有张 4090,跑 TensorRT 推理,Nsight 一拉 profile,发现一大半时间耗在几十个几十微秒的小 kernel 上。这就是标题里说的“串行小核墙”——不是算力不够,是图里塞了一堆只干一点点活的包皮算子,一个接一个地 launch,GPU 在那排队等指令。
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,大部分计算单元在空闲等待。
我拿一个典型的检测模型做过统计,输入 640x640,TensorRT 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 到 scale+clip+cast
直接看代码。假设输入是一个 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 个 float(4 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.04,CUDA 12.4,TensorRT 8.6,PyTorch 2.1,NVIDIA 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 跑一次推理,同时开启 profiling:
nsys profile --trace=cuda,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++ 代码,再用 NVRTC(NVIDIA 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 size:
int blockSize; int minGridSize; cudaOccupancyMaxPotentialBlockSize(&minGridSize, &blockSize, kernel, 0, 0); int gridSize = (n + blockSize - 1) / blockSize; kernel<<<gridSize, blockSize>>>(...);这样不管输入是 640x640 还是 1280x720,都不用重新编译内核。这也是 DeepJIT 相比静态 TensorRT engine 的独特优势:它对形状的变化更宽容,不需要为每个分辨率单独构建一个 engine。
4. 性能实测:融合前后到底快了多少
4.1 三个场景的对比数据
我做了三组测试,确保结果有说服力:
- 场景一:检测模型全流程,输入 640x640,检测头输出后接解码、NMS 前后处理。
- 场景二:纯逐元素算子流,模拟归一化+仿射变换+clip+cast 的组合。
- 场景三:动态形状压力测试,输入分辨率随机变化,对比两种方案的稳定性。
| 场景 | 方案 | 总延迟 | P95 延迟 | 吞吐量 |
|---|---|---|---|---|
| 检测模型全流程 | 原生 TensorRT | 1420 us | 1500 us | 704 FPS |
| 检测模型全流程 | DeepJIT 融合 | 1093 us | 1150 us | 915 FPS |
| 纯逐元素算子流 | 三个独立 kernel | 45 us | 48 us | 22k 次/秒 |
| 纯逐元素算子流 | 单个融合 kernel | 19 us | 21 us | 52k 次/秒 |
| 动态形状 | 静态 engine | 1162 us | 1890 us | 860 FPS |
| 动态形状 | DeepJIT 动态编译 | 1099 us | 1170 us | 909 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-3 | FP16 中间结果累积误差 | 对精度敏感部分用 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 的工程量并不小,它更适合那些需要频繁换模型、频繁调逻辑的场景。工具是为目的服务的,什么时候用重武器,什么时候轻装上阵,心里要有数。