目录

面试讲解稿 · VLA 模型端侧部署 demo

(时间线版 —— 按我真实做下来的顺序讲)

怎么用这份稿子

自检:合上稿子讲一遍第一部分。讲不顺 = 真洞,告诉我,我改。


看不懂里面的词?先去看 docs/术语扫盲.md 那份文档把这里出现的每个专业词都用大白话解释了一遍,还标了三档: A 档必须真懂、B 档知道大意、C 档可以诚实说不知道。先看那份,再回来看这份。

另外那份文档第 0 节回答了一个关键问题:这个模型的代码是从哪来的。 涉及"哪些是你做的、哪些不是",务必先读。


第一部分 · 三十秒结论(先说这个,再走时间线)

(照念)

我做的是把我自己训练的一个 VLA 模型完整部署下去——从 PyTorch 一路走到 TensorRT, 中间过了模型结构分析、ONNX 导出、图优化、手写 CUDA 算子、量化。 先说清楚一件事:这个模型的结构是比赛官方 benchmark 仓库提供的,权重是我训出来的,这次的部署链路是我做的。

最后的结果是:推理延迟从 PyTorch 的 12.8 毫秒降到 TensorRT FP16 的 1.1 毫秒,快了 11.65 倍, 显存从 345 MB 降到 179 MB,engine 体积从 283 MB 降到 128 MB。 我还用真实帧校准做了一个真的 INT8 engine,结论是负面的—— 速度和 FP16 一样(因为这模型不是算力受限),误差却大了一个数量级,只赚到体积减半。

起因很直接:我看了你们 JD,发现我手上正好有一个自己训的 ACT 模型, 但我只训过它、没部署过它。所以我花了一天把这条链路走通。

这个项目我大量用了 AI 辅助写代码,我不藏这个。 它真正能证明的是:我能把每一步为什么这么做讲清楚—— 包括我做错的、被数据推翻的、和我到现在也没做到的。 里面有一个实验我事先就预测它会失败,我照做了,它也真失败了——那一步我觉得最值得讲。

下面我按我实际做下来的顺序讲一遍,中间踩的坑我都留着。


第二部分 · 全程时间线

第 1 步:上手第一件事不是导出,是先把模型拆开看

(照念)

我拿到自己那个 ckpt,第一件事不是急着导 ONNX,是先搞清楚这个模型到底长什么样。 因为后面所有优化打哪,都得从结构推出来,不能凭感觉。

我在 transformer 的入口打了 hook,把真实的中间张量形状抓出来, 又按子模块统计了参数量。为什么要打 hook 而不是读代码算? 因为 240 除以 32 是 7.5,纸上算特征图高度会算成 7,实际是 8——ResNet 下采样有 padding,向上取整。 形状差一格,后面 ONNX 和 TensorRT 的 binding 就全错。

抓出来的结果是这样:

模型 ACT / CVAE + DETR,总参数 83.92M
输入:单目 RGB 320x240 + 本体 qpos 26 维(左臂7+左手6+右臂7+右手6)
输出:(1, 50, 26)  一次预测未来 50 步动作

特征图 (1,512,8,10)  ->  视觉 token 80 个
进 Transformer 的序列 = 80 + latent 1 + proprio 1 = 82

参数分布:
  transformer.decoder(7层)   37.695M   44.9%
  transformer.encoder(4层)   17.333M   20.7%
  CVAE encoder               17.394M   20.7%   <- 推理时完全不执行
  backbone ResNet18          11.167M   13.3%   <- 比直觉小得多

(照念)

这一步有两个发现直接决定了后面所有事。

第一个:这个 CVAE 推理的时候根本不走 encoder。 训练时 encoder 吃的是真实动作序列,把"这条演示是哪种风格"压成 32 维 latent; 推理时你没有答案,latent 直接置零——0 就是标准正态先验的均值,取均值等于取最典型那种风格, 而且是确定性的,同样输入永远同样输出,这对控制回路很重要。 我算了一下,这部分是 17.4M 参数、占 20.7%,推理时全是死的。所以我导 ONNX 只需要导 79.3%。

第二个:序列只有 82。 这个数字后来救了我很多时间—— attention 矩阵才 82 乘 82,flash-attention 那类针对长序列的优化在这个模型上基本没收益, 我一开始就把这个方向排除了。


第 2 步:还没导出就先撞了第一个坑——torchvision 装错版本

(照念)

我要在部署环境里把模型建起来,需要 torchvision(backbone 是 ResNet18)。 我直接 pip install torchvision 装了最新版,一 import 就报:

RuntimeError: operator torchvision::nms does not exist

(照念)

这个报错特别坑,字面看像"某个算子没注册",第一反应会去查 nms 是不是要额外编译。

我的判断是:nms 是 torchvision 最基础的算子之一,它"不存在"几乎不可能是功能缺失, 那多半是整个 C++ 扩展没加载起来。 果然——torchvision 的 _C.so按 torch 的 C++ ABI 编译死的, 我装的 0.28 是给 torch 2.13 编的,我的 torch 是 2.12,so 加载失败, 于是它注册的所有算子全都不存在,nms 只是 import 时第一个被引用到的。 降到对应的 0.27 就好了。

我记这个坑,是因为我当时预计后面装 TensorRT 会撞同一类问题后来确实撞了,但形式跟我想的不一样,这个后面讲到再说。


第 3 步:决定不 import 训练仓的代码,自己拷一份最小模型定义

(照念)

建模型的时候我做了个决定:不直接 import 比赛官方那个 benchmark 仓库,而是把模型定义拷一份到我的部署仓。

(补充一句:那个仓库是主办方发的,不是我写的。我是用它提供的模型结构训练出了权重。)

原因是我读官方那份构建函数的源码,看到三件部署场景下不能接受的事: 它写死了 model.cuda(),我的导出机是纯 CPU; 它调 parse_known_args读进程的 sys.argv,我导出脚本自己带参数会被误读; 它还顺手建了个 AdamW 优化器。 这三个在训练代码里都合理——这不是它写得不好,是训练和部署的前提本来就不一样。部署代码一条都不能假设。

这里我要说清楚一点:这三条我是读源码看到的,不是撞出来的。 我判断没必要花时间去撞一个源码里看得见的问题,直接绕开了。

更重要的理由是产物可复现:如果 import 官方仓库,它改一行我导出的 ONNX 就悄悄变了,我还不知道。

拷完第一次 import 倒是真崩了一次——训练代码里留了个 import IPython 的调试钩子,删掉就好。 这个小事反而印证了我前面的判断:训练代码里到处是"假设开发环境什么都有"的痕迹。

怎么验证拷对了?load_state_dict 看 missing 和 unexpected keys,两个都是 0,参数量 83.92M。 零缺失零多余是最硬的证据——只要差一层、差一个维度,就必然有 key 对不上。


第 4 步:ONNX 导出,中途发现基线本身不可信

(照念)

导出这一步我定了三件事,然后中途插进来一个我没预料到的问题。

第一,我没有直接导原模型,而是写了个只走推理路径的 wrapper。 直接导的话,理论上 trace 在 actions=None 时只记录 else 分支,结果可能是对的。 但那是把正确性寄托在 trace 的副作用上,图里有什么是 trace 说了算不是我说了算,部署里不能接受。

第二,固定 shape,不上 dynamic_axes。 这个模型没有任何一维真需要动态——分辨率写死、batch 恒为 1、chunk 50 是结构决定的。 上动态 shape 反而让 TensorRT 没法基于确定形状做 kernel 选择和显存规划,是纯亏。

第三,我把 ImageNet 归一化收进了图里。 原推理代码里归一化在模型外面,端侧要自己实现——均值写错、RGB/BGR 弄反、除不除 255,都是典型部署 bug。 收进去之后端侧契约就一句话:"喂 [0,1] 的图就行",少一个出错点。

导出之后我验数值,wrapper 和原路径差了 1.25e-6,不是逐位一致。 一般到这儿会说"1e-6 嘛,浮点误差,忽略"。我没忽略,因为这个项目后面全靠数值比对说话,基线不干净后面全废。

(照念,这一段是转折)

我做隔离实验:把归一化单独比,是 0;把同一个输入喂两条路径分别比,也是 0两个都是 0,但端到端就是有差异,对不上。

后来定位到根因:根本不是 wrapper 的问题,是 CPU 多线程本身不确定。 我起了 5 个独立进程跑同一个模型同一个输入, 8 线程下有一个进程的输出 md5 和其他四个不一样;改成单线程,5 个进程完全一致。 原因是 oneDNN 多线程下归约顺序会变,而浮点加法不满足结合律。

所以我定了一条规矩:所有基线一律单线程生成、存盘复用,报误差时同时报基线自己的噪声底,实测是 3e-6。 这条规矩后面救了我一次大的,等会儿讲到 TensorRT 就知道了。

这一步的数字:

act_fp32.onnx  852 节点  图内参数 66.537M  vs  PyTorch 83.922M   <- 差的正好是死掉的 CVAE encoder
PyTorch(单线程) vs onnxruntime(单线程)  最大绝对误差 1.55e-06   余弦 1.000000000000
基线噪声底 3e-06 —— 所以正确说法是"误差在噪声以内、分辨不出",不是"导出很精确"

第 5 步:图优化第一刀——换 opset,白拿 360 个节点

(照念)

导出之后我顺手做了个对照:同一个 wrapper、同一个输入,只改 opset 版本。

opset13  总节点 1212
opset17  总节点  852     差 360

LayerNormalization  0 -> 36
ReduceMean 72->0   Sub 37->1   Pow 36->0   Sqrt 36->0   Div 41->5   Constant 258->186

(照念)

opset 17 才引入原生的 LayerNormalization 算子。 在 13 下面,每个 LayerNorm 要用 11 个基础算子拼出来——求均值、减、平方、再求均值、加 eps、开方、除、乘 weight、加 bias。 17 下面就是 1 个,净省 10 个。这个模型有 36 个 LayerNorm,36 乘 10 正好 360,和总节点差分毫不差。

而且这不只是数字好看:LayerNorm 是纯访存受限的算子, 拆成 11 个意味着中间结果来回读写 11 次,融成 1 个就是一次读、寄存器里算完、一次写。 这是算子融合最典型的形态,而且我这里是选对 opset 白拿的,没手写。


第 6 步:租 GPU 机,然后连不上

(照念)

前面都是 CPU 上能做的。到了 TensorRT 就必须有 GPU 了,我租了一块 3090。

为什么是 3090 不是更好的卡:模型才 83.92M、单目 320x240,大卡的显存算力根本用不满; 而且 3090 是 Ampere、sm_86,CUDA 12 那套生态最成熟。 不过这个理由后来落空了一半,等会儿说。

拿到连接串,连不上,超时。我没有先怀疑密码,而是分层测。

ping 通,0% 丢包         -> IP 活着
nc 测目标端口 23  超时
nc 测同一 IP 端口 22  通,还能拿到 SSH banner
ssh -v  卡在 "Connecting",连版本协商都没开始

(照念)

这里有个关键判据:超时和拒绝是两回事。 拒绝是对端回了 RST、端口没监听;超时是包被中间设备吃了。 而且 ssh -v 卡在 Connecting,说明我的密码根本没机会发出去,所以密码对不对在这一步完全无关。

后来借了一台跳板机,从跳板上测那个端口是通的,问题就定死在我这一侧的出站了。 根因是:23 是 telnet 的标准端口,我那台机器的出站防火墙把 TCP/23 封了, 而 GPU 厂商正好把 ssh 映射在 23。说实话是 nc 的输出把端口显示成 [tcp/telnet] 提醒我的。

解决办法是用跳板做 ProxyCommand。我特意用 ProxyCommand 而不是套两层 ssh—— ProxyCommand 只转发 TCP,认证还是我和目标机端到端做的, 跳板机看不到会话,也不持有目标机的密码;套两层 ssh 的话密码会出现在跳板机的进程列表里。


第 7 步:上机第一件事核环境,结果和预期差很多

(照念)

连上之后我第一件事是把环境全核一遍,不信镜像描述。结果差得有点多:

              预期                 实际
GPU           RTX 3090            RTX 3090 24576MiB  sm_86     相符
CUDA          12.1                13.2(另有13.0,无任何12.x)  不符
镜像          PyTorch 2.x 预装     裸机,torch/pip/g++ 一个都没有  不符
出网          —                   pypi通、清华源通、download.pytorch.org 返回 403

(照念)

我前面说选 3090 的理由落空了一半,就是这个。 我原以为租个 CUDA 12.1 的镜像就能省掉版本对齐的活,结果拿到裸机、CUDA 比我预期的还新, 版本对齐一点没少干,还多了装 pip、g++、换 pip 源。 我的复盘是:版本地狱跟你选哪张卡的关系,没有跟你拿到什么镜像的关系大。这点我当初想错了。 不过结论还是对的,只是原因变了——正因为 3090 是 sm_86,CUDA 12 的轮子对它支持完备, 我才有"无视系统的 13.2、自己对齐"这条退路。

我当时的决定是整条链路对齐到 CUDA 12,还想好了从 pip 装一个 cu12 的 nvcc 来避开系统那个。 结果我一装 torch 就被打脸了:PyPI 上 torch 的默认轮子本来就是 cu130,而且跑得好好的; 系统里还正好有个 CUDA 13.0 的 nvcc,和 torch 精确对齐。我那套方案在解决一个不存在的问题。 所以我改成全线对齐 CUDA 13.0。

教训是:环境这种事不要凭印象提前决策,先装一个最小的东西看它实际给你什么。


第 8 步:先把最高风险的东西验一遍,果然撞坑

(照念)

环境搭好之后,我没有直接去做 TensorRT,而是先花十分钟验了一件事:手写 CUDA 算子这条编译链通不通。

为什么?因为手写 kernel 是这个项目里我最不熟、风险最高的一环。 如果编译链根本不通——版本对不上、缺工具——我宁可第一天发现, 也不要在最后一站发现,那时候就没时间了。

我写了个最小的 add kernel 去编,果然撞了:

RuntimeError: Ninja is required to load C++ extensions

(照念)

装个 ninja 就好。这就是我说的——这种坑第一天撞是十分钟的事,留到最后一站撞就是灾难。 编译通过之后我和 torch 对了一下结果:

nvcc V13.0.48 + torch cu130 -> 编译 67.5s 通过
自写 add_kernel vs torch a+b -> 最大绝对误差 0.0,逐位一致
GPU 真能算:fp32 matmul 4096^3 = 24.28 TFLOPS(3090 峰值 35.6,达 68%,量级合理)

第 9 步:想把 254MB 的 ONNX 传上去,传了 141 分钟的量

(照念)

ONNX 是在 CPU 机上导的,要拿到 GPU 机跑 TensorRT。我直接 scp,传着传着觉得不对劲,测了一下速率:

20 秒内传了 60 万字节 -> 29 KB/s -> 传完 254 MB 还需 141 分钟

(照念)

我没有去查为什么慢,而是问了另一个问题:这个东西我为什么要传? **ONNX 是派生产物,不是源。**源是 ckpt 和代码——ckpt 早就传过去了、md5 校验过,代码只有几十 KB。 所以我改成在 GPU 机上现导,实测 3 秒

但改完我做了一件必须做的事:验证两边导出来的是同一个东西。 节点数都是 852、参数量都是 66.537M、PyTorch 基线的输出总和都是 67.4945602417,精确到小数点后十位。 这也顺带验证了我前面定的"基线单线程生成"是跨机器可复现的。

原则一句话:凡是能从源头廉价重建的,就不要搬运。


第 10 步:TensorRT 第一个坑——FP16 的开关没了

(照念)

开始 build engine。开 FP16,所有教程都写 config.set_flag(trt.BuilderFlag.FP16),我照做,直接:

AttributeError: type object 'BuilderFlag' has no attribute 'FP16'

(照念)

我没去搜这个报错,直接把这个枚举的成员打印出来看。 结果发现 TensorRT 11 里 FP16、INT8、BF16 全删了,精度相关的只剩一个 TF32; 同时 NetworkDefinitionCreationFlag 里多了个 STRONGLY_TYPED

机制就清楚了:TRT 11 把精度控制从 builder 开关,改成了由 ONNX 图里张量的类型决定。 想要 FP16 engine,得先有一个 FP16 的 ONNX。

我的体会是:报错的时候先去看对象里实际有什么,比去搜报错快——搜到的多半是旧版本的答案。


第 11 步:FP32 的 engine 出来了,但误差大得不对劲

(照念)

FP32 engine 先 build 出来了,我拿去和 PyTorch 基线比,差了 9.3e-4

这时候我前面定的那条规矩救了我:我知道基线自己的噪声底是 3e-6, 这个误差高了三百倍,是真差异不是抖动。FP32 对 FP32,不该差这么多。

我怀疑是 TF32——指数位和 FP32 一样但尾数只有 10 位, 而 TensorRT 在 Ampere 上默认就是开 TF32 的。 我做了个只改一个变量的对照,把 TF32 关掉重新 build:

engine                   最大绝对误差    延迟中位数    plan体积
fp32_no_tf32(真FP32)    1.013e-06     2.280 ms    283.16 MB
fp32_default(TF32)      9.321e-04     1.859 ms    283.15 MB

(照念)

**误差从 9.3e-4 掉到 1.0e-6,直接掉到噪声底以下,因果锁死。**代价是慢 24%。

我觉得这件事的意义不在数字,在于这是一个被默认值藏起来的取舍。 如果我没做这个对照,我会去怀疑我的导出、我的 wrapper,查半天; 更糟的是我可能把这个 engine 当成"FP32 基线", 再拿它去衡量 FP16 和 INT8 的损失——那整个精度对比的基准就是歪的。 所以我后面所有精度对比,基准用的是关掉 TF32 那个。


第 12 步:FP16 build 不出来,四种办法全试了都不行

(照念)

按第 10 步的结论,我先把 FP32 的 ONNX 转成 FP16 的再 build。报错:

/transformer/Concat_2: IConcatenationLayer inputs must all be of the same type.
inputs[1] is of type Float but inputs[0] is of type Half

(照念)

我第一反应是 fp16 转换器的参数没设对,试了四种组合—— keep_io_types 开和关、STRONGLY_TYPED 加和不加。 四种全在同一个节点失败。 那就说明不是参数的事,这个方向排除了。 这一步没白费,它让我不再在转换器上耗时间。

然后我去查那个节点的两路输入到底是谁,发现是位置编码那一路。 再往上看 backbone 的代码,Joiner 里有一句 pos.append(self[1](x).to(x.dtype)), 那个 .to 就是类型混乱的来源。

但我真正意识到的是另一件事:sine 位置编码只依赖特征图的形状,不依赖它的值。 我的输入分辨率是固定的,特征图永远是 512 乘 8 乘 10, 所以位置编码是个彻头彻尾的常量,却被留在图里每次推理都重算一遍。

所以我没去给转换器打补丁,而是在 wrapper 初始化时把它算一次、存成 buffer,推理时只走 backbone 主体。

节点 852 -> 756   省 96 个
删掉的正好是:CumSum 2->0  Sin 2->0  Cos 2->0  Slice 10->1  Cast 1->0
数值:烘常量前后 PyTorch 输出**逐位一致**,误差 0.0
FP16 engine 随之 build 成功

(照念)

删掉的算子清单正好就是正弦位置编码的计算链——累加求位置、取 sin/cos、切片交错。 这本身就是"我删对了东西"的证据,不是节点数碰巧少了。 代价是体积大了 0.14 MB,因为常量要存进去,拿存储换计算,端侧一般划算。

这件事我后来发现地平线官方的工具也是这么做的,这个到最后一步再说。


第 13 步:横向对比四个后端,ORT 差点骗了我

(照念)

三个 engine 都出来了,我要横向对比 PyTorch、onnxruntime、TensorRT。 结果 onnxruntime 这里踩了个很阴险的坑

第一次跑,两个 GPU 后端都给了我数字,38 毫秒。 但我脚本里打印了 sess.get_providers(),看到返回的是 CPUExecutionProvider

它 GPU 后端加载失败之后是静默降级到 CPU 的——不报错,照样给你结果。 如果我不看 provider,就会拿 CPU 的 38 毫秒当 GPU 数字报出去。

原因是 onnxruntime-gpu 是按 CUDA 12 编的,我这台机器是 CUDA 13,缺 libcublas.so.12

**说到这我得回头认个错。**前面第 7 步我说"对齐 CUDA 12 是在解决一个不存在的问题", **那句话说早了。**那个问题真的存在,只是我搞错了它会发生在哪个组件上—— TensorRT 有 cu13 版本没事,但 onnxruntime 只有 CUDA 12 版本。 教训是:验证了 A 和 B,不等于验证了 C。

我没有把整个环境降级,因为 torch 和 TensorRT 在 13 上跑得好好的。 我的做法是用 pip 单独把 cu12 的运行时库装进来挂到 LD_LIBRARY_PATH, 让两个 CUDA 版本的运行时库在同一台机器上共存。修好之后 ORT 从 38 毫秒降到 3.677 毫秒。

还有一个我没修的:ORT 的 TensorRT EP 要 libnvinfer.so.10,也就是 TensorRT 10,而我装的是 11。 这个我选择不修——我已经有 TensorRT 原生的完整数字了,为一个冗余对照再塞一个 TensorRT 版本不划算。 这一格我是空的,没有数字,我不编。

最终的完整阶梯:

后端                        延迟中位   vs PyTorch   最大绝对误差   显存峰值
PyTorch GPU FP32           12.816 ms   1.00x       2.505e-04    345.3 MB
PyTorch GPU FP16           12.818 ms   1.00x       2.682e-03    178.7 MB
onnxruntime CUDA EP         3.677 ms   3.49x       5.899e-04      —
TensorRT 真FP32             2.280 ms   5.62x       1.013e-06      —
TensorRT FP32(默认TF32)     1.859 ms   6.89x       9.321e-04      —
TensorRT FP16               1.100 ms  11.65x       2.204e-03      —
onnxruntime TensorRT EP     未取得(需要 TRT10,我装的是 TRT11)

(照念)

显存我用的是 torch.cuda.max_memory_allocated()不是 nvidia-smi。 因为 nvidia-smi 看到的是 CUDA context 加缓存分配器持有的总量,包含大量跟模型无关的开销, 拿它报"模型显存占用"是不诚实的——随便一个 CUDA 程序 context 就吃几百兆。 边界也要说清楚:这个指标只对 PyTorch 后端有意义,ORT 和 TRT 用自己的分配器, 我没有它们的可比显存数字。


第 14 步:写手写 CUDA 算子,选哪个是有理由的

(照念)

编译链第一天就验过了,这时候就是写真的 kernel。 我想先说为什么是这个 kernel,因为这比 kernel 本身重要。

我按第 1 步拆出来的参数量选的:dim_feedforward 是 3200,是 hidden_dim 512 的 6.25 倍, 常规才 4 倍,所以 FFN 参数是自注意力的 3.12 倍,还重复 11 次;而序列只有 82,attention 矩阵才 82 乘 82。 所以我判断火力应该打 FFN。

我否决了三个方案:flash-attention 类的,序列 82 收益接近零; 自己写 GEMM,我写的一定比 cuBLAS 慢几倍; fused LayerNorm,它也是好选择,但涉及两趟归约、要讲 warp shuffle, 复杂度明显更高、讲不透的风险大。我的原则是:做得更炫和我能讲清冲突时,永远选后者。

最后写的是 FFN 后面那个融合尾巴:反量化 + 加 bias + ReLU 三合一。 这三步全是 elementwise,计算量极小,开销 100% 在访存—— 分开做是 3 次 launch、6 次读写全张量;融合后是 1 次 launch、2 次读写。

形状                      PyTorch三步   融合kernel   加速比   有效带宽   峰值达成率
[82,3200] 真实encoder FFN  0.0863 ms    0.0307 ms   2.81x    68.3 GB/s    7.3%
[4096,3200] 放大50倍        0.6624 ms    0.1469 ms   4.51x   713.6 GB/s   76.2%

(照念)

这里最值钱的是带宽达成率那一列。 放大版达到峰值带宽的 76%,说明这个 kernel 本身写得是对的; 但在模型真实的形状上只有 7%——张量太小,还没跑到带宽瓶颈就结束了,时间主要花在 launch 上。 证据是数据量放大 50 倍,耗时只涨 4.8 倍。

数值上我也查到底了。初测误差 3.815e-6,不逐位一致。elementwise 本该逐位一致,所以我去查了。 我怀疑是编译器把乘加融成了 FMA(单条指令只舍入一次,而 PyTorch 分三步舍入三次), 只加一个 -fmad=false 编译选项做对照,误差直接归零、逐位一致。因果锁死。 我选择保留 FMA——它少一次舍入,精度实际上更高

(照念,这句一定要说)

但我必须说清楚:这个 kernel 不跟 cuBLAS 比,也比不过,它做的是 GEMM 之后的尾巴不是 GEMM 本身。 而且到这一步为止,它还没有接进真实推理链路,是独立验证的,所以我不能声称它给端到端带来任何加速。 (这个洞我后来补上了,就是第 19 步——结果是负的,我最后会讲。) 真正的 11.65 倍来自 TensorRT。


第 15 步:做量化,中途被自己的数据打脸一次

(照念)

量化这块我先做了个和原计划不同的决定:我没有把它放在主链路里,而是做成独立的旁支实验。 因为在 PyTorch 里做的假量化,导出 ONNX 之后 TensorRT 会自己重新做精度选择,前面那步根本传不下去。 真实的量化落地要么把量化节点插进 ONNX 图里,要么让 TRT 自己做 INT8 校准。

实验结论有三条。 一,per-channel 比 per-tensor 误差小 1.8 倍,而体积完全一样,因为 scale 只有 N 个 float。这是白拿的。 二,INT4 直接把模型打废,余弦从 0.99998 掉到 0.94。 三,这条是我先猜错了一次的。

我做逐层敏感度,发现 action_head 每 M 参数的误差是 FFN 的 4500 倍,而它只有 0.013M 参数。 我推论说:那把它排除在量化外,应该体积几乎不变、误差明显下降。 我去验证,结果只改善了 8%。

为什么错?因为它每参数敏感度高,但绝对贡献只有 5.4e-3, 总误差其实被 backbone 主导,backbone 是 1.6e-2。 "每参数敏感度"回答的是"这层值不值得省",不回答"排除谁能降误差"。我用错了指标。

改按绝对贡献选之后,最好的一档是只量 FFN:误差只有全量的 19%,体积还能压 1.78 倍。


第 16 步:回头补 profile,两条我一直在说的结论被推翻了

(照念)

到这儿主线已经通了。但我的测试记录里一直挂着两条标注为"未验证的推断",我回头去补了算子级 profile。 结果两条都被推翻了,我觉得这是整个项目最值得讲的部分。

第一条被推翻的:我说"FFN 是耗时大头"。 那是我按参数量推的——FFN 参数是 attention 的 3.12 倍。 我用 profiler 按 GEMM 的输入形状把两者分开(FFN 的矩阵含 3200 维,attention 投影是 512x512):

含 3200 维(FFN) 的矩阵乘合计        0.6770 ms
其余矩阵乘(attention 投影 + bmm)    1.3687 ms
FFN / 其余 = 0.49x        <- 我预测 3.12 倍,实测 0.49 倍,方向完全相反

(照念)

为什么错?因为我用参数量代替了耗时。 这个模型每个 GEMM 都极小,M 只有 50 或 82,这种规模下时间主要花在每次调用的固定开销上,不是浮点运算上。 而 attention 每层有 q、k、v、out 四个投影、decoder 还有 cross-attention 再四个, 一共 72 个投影 GEMM;FFN 每层只有 2 个,一共 22 个参数量大三倍,但调用次数少三倍多——小矩阵下次数比规模更重要。

教训我总结成一句:参数量是静态指标,耗时是动态指标;当算子小到被固定开销主导时,两者会给出相反的结论。

第二条被推翻的:我说"PyTorch 慢是因为 GPU 大部分时间在等 kernel"。 实测 FP32 下 GPU 忙碌 56.5%,是超过一半的。这句话是错的,我收回。

但我补做了一个 FP16 的对照,它给出了更准确的解释:

        墙上时间    GPU忙碌            GPU空闲             kernel数
FP32   12.759 ms   7.211 ms(56.5%)   5.548 ms(43.5%)     1150
FP16   16.077 ms   4.193 ms(26.1%)   11.884 ms(73.9%)    1081

(照念)

FP16 把 GPU 的工作量砍掉了 42%,墙上时间纹丝不动,空出来的全变成了 GPU 空闲。 所以准确的说法是:墙上时间由 CPU 侧的算子分派速率封顶,不由 GPU 计算量决定。 这个模型有 1150 个 kernel,每个平均只干 6.25 微秒的活, CPU 分派一个算子的固定成本,和 GPU 干完它的时间是同一量级。

结论方向没错,但我原来的表述夸大了,给的证据也不成立。 顺带这也让我把"TensorRT 为什么快"说得更准了:不是"PyTorch 的 GPU 在闲着", 而是"PyTorch 的墙上时间被分派速率封顶,TRT 通过融合把 1150 个 kernel 降到几十个,从而解除这个限制"。


第 17 步:把丢失的归一化统计重算出来,然后发现之前的测试都偏乐观

(照念)

这个项目一直有个洞:模型的归一化统计文件 dataset_stats.pkl 丢了。 没有它就没法把真实数据变成模型能吃的输入,所以我前面所有数值实验用的都是随机输入。

后来我发现原始训练数据还在,那个文件本质就是在数据集上算的 mean/std,理论上可以重算。

但第一件事我先做了核对:我在用的 ckpt 是 ind_task_01,而本地数据只有 lab_task_01 和 ind_task_02。对不上。 所以我换成了 ind_task_02 那个 ckpt,它有 208 个 episode 的数据。我没有硬凑。

重算的时候我直接 import 官方仓库里那个算统计的函数,不自己重写—— 任何一处细节不同(std 的 clamp 下界、eps 加在哪)都会得到一份看起来对但其实不同的统计。 读源码还发现一件事:那个函数是把 train 和 val 拼起来一起算的所以怎么切分数据不影响结果,这消除了"我不知道当初怎么切"这个不确定性。

但我必须说清楚:我没法证明重算的和当初训练用的一致,因为原文件丢了、没有比对对象。 所以产物我标的是"重算值,未与训练时原值比对过",不假装等价。

有了统计,我把所有随机输入换成真实观测重跑。结果打脸:

配置                   randn 输入    真实观测      低估倍数
INT8 全量per-channel   1.305e-02   5.269e-02     4.0x
INT8 只量 FFN          2.474e-03   1.172e-02     4.7x
INT4 全量              9.752e-01   2.058e+00     2.1x

(照念)

用随机输入测出来的量化误差,比真实数据小 2 到 4.7 倍。 原因是随机图像不激活真实的视觉特征模式,ReLU 的激活比例、各层的动态范围都不一样,误差传播路径不同。 如果我拿随机输入的数字去判断"能不能接受",会系统性地过于乐观。

更重要的是,有了统计我终于能做一件之前做不了的事:把误差换算成物理量。 模型输出的是归一化动作,乘上 action_std 就是弧度。

配置                  物理max(弧度)   物理max(度)   物理RMS(度)
INT8 全量             1.208e-02      0.6923       0.0601
INT8 只量 FFN         2.472e-03      0.1416       0.0159
INT4 全量             4.791e-01     27.4499       2.4193

(照念)

INT8 全量最大误差 0.69 度,INT4 是 27.45 度。 27 度不需要任何解释,就是彻底不能用。

还有个细节:误差最大的 8 个关节全是手指,不是臂关节。 这有实际含义——灵巧手做精细抓取时手指角度直接决定成败, 而臂关节零点几度在末端可能只是毫米级偏移。 所以"0.69 度大不大"取决于它出现在哪,而它恰好出现在最敏感的地方。


第 18 步:把量化和部署真正接起来——做一个真的 INT8 engine

(照念)

做到这儿我发现项目里有个结构性的断裂: 我的量化那一站是 PyTorch 侧的假量化,部署那一站是 TensorRT,两者是断开的。 我可以说"我做了量化",也可以说"我做了部署",但中间没接上。 所以我最后补了一件事:用真实帧做校准,build 一个真正的 INT8 engine。

这一步撞了四次墙,每次报错指向的东西都不一样

第一次,我按常规做法写 TensorRT 的校准器类,直接:

AttributeError: module 'tensorrt' has no attribute 'IInt8EntropyCalibrator2'

(照念)

我去查 TRT 11 里还剩什么 INT8 相关的符号,只剩 QuantizeLayer、DequantizeLayer, 一个 calibrator 类都没有。 这跟我前面第 10 步发现的是同一件事——TRT 11 把精度控制全部改成由图表达。 所以 INT8 也只能走显式量化:把 QuantizeLinear、DequantizeLinear 节点插进 ONNX 图里, scale 直接写死在图里。我改用 onnxruntime 的静态量化,校准数据用真实帧。

第二次Non-zero zero point is not supported——TensorRT 只接受对称量化, 而 onnxruntime 默认给激活用的是非对称。加两个选项强制对称。

第三次input has type Int32 but must have type FP8, FP4, Int4, or Int8 ——onnxruntime 默认把 bias 量化成 INT32(因为 bias 是加在 INT32 累加器上的,在纯 ORT 推理里这是对的), 但 TRT 的 DequantizeLinear 不收 INT32。关掉 bias 量化,bias 参数量可忽略,留 float 没代价。

第四次,这个最有意思:Could not find any implementation for ... conv1 + Relu + MaxPool。 我试了三种 builder 配置,三种全失败在同一个节点,那就不是配置的事。 定位下去是 ResNet18 的第一个卷积——它是 3 输入通道的, 而 INT8 卷积的硬件指令,DP4A 和 IMMA,要求输入通道数是 4 的倍数,3 通道根本没有 INT8 实现。 把第一层排除在量化之外就通了。这其实也是 INT8 部署的常规做法—— 第一层既对精度敏感、硬件上又尴尬,一般都留 float。

(照念,这段是结论)

然后结果很有意思,是个负面结论:INT8 对这个模型不划算。

engine              延迟中位   plan体积    物理max误差(度)
FP16                1.104 ms  128.32 MB      —
INT8(真实帧校准)     1.123 ms   65.21 MB    8.3869
(对照)PyTorch 假量化 INT8,同 ckpt 同真实帧:物理max 0.6923 度

(照念)

第一,速度基本没赚到:1.123 毫秒对 FP16 的 1.104 毫秒,甚至还慢一点点。 为什么?因为这个模型不是算力受限的。 我前面第 16 步 profile 测过:1150 个 kernel、每个平均只干 6.25 微秒, 墙上时间由 CPU 的分派速率封顶INT8 买的是计算吞吐,而计算吞吐根本不是瓶颈。 这跟"PyTorch 上 FP16 只比 FP32 快 2%"是同一个根因。

第二,精度掉得比我预期的多得多:真 INT8 是 8.39 度,而我之前假量化预测的是 0.69 度,差 12 倍。 这个差距不是意外,是必然的——假量化只量权重、激活还是 float; 真 INT8 权重和激活一起量,多量了激活这一整块。 这正好坐实了我一直标注的那条边界:假量化的数字不能当端到端 INT8 的结论。现在它有了具体倍数。

第三,唯一赚到的是体积:65 MB,是 FP16 的一半、FP32 的四分之一。

所以我的结论是:如果端侧的约束是 Flash 容量,INT8 值得考虑;如果约束是延迟,FP16 是更好的点。 顺带一提,误差最大的 5 个关节又全是手指,和我前面用假量化观察到的模式一致。


第 19 步:把手写 kernel 真接进链路——我预测它会失败,然后它真失败了

(照念)

还有一件事我一直欠着。 我第 14 步写的那个融合 kernel,一直是独立验证的——孤立测能快 2.81 倍, 但它没接进真实模型。所以我在文档里一直标注着一句话: "不能声称它给端到端带来任何加速。"

最后我把这个洞补上了——真接进去测端到端。

但我想先说一件事:我做之前就预测它是负结果,而且这个预测是写在脚本文件头里的,不是我事后补的。 我当时写的原话大意是:T-008 实测过这个 kernel 在真实形状下有效带宽只有峰值的 7.3%, 说明它是 launch 受限不是带宽受限;第 16 步的 profile 又证明整个模型的墙上时间由分派速率封顶。 所以在一个分派受限的模型里,省掉几十个 kernel 的访存,很可能被淹没。

那我为什么还要做? 因为不做,我就只能停在"我推测收益很小"。做了,我才能说"我实测是 0.729 倍,原因是多了 191 个 kernel"。 前者是猜,后者是知道。

(照念,结果)

                延迟中位    kernel 数    GPU 净忙碌
基线 FP32       13.384 ms    1153        7.192 ms
接入 kernel     18.366 ms    1344        7.140 ms
变化            0.729x       +191        -0.052 ms
              (变慢 4.982 毫秒,也就是慢了 37%)

(照念,这段是核心)

端到端变慢了 37%。 但更重要的是,数字把"为什么"说得非常清楚:

第一,我的 kernel 确实起作用了,但作用小得可以忽略:GPU 净忙碌时间从 7.192 降到 7.140,只省了 0.05 毫秒。 它确实省下了访存,可在 7 毫秒的总量里什么都不是。

第二,kernel 数量反而涨了 191 个,从 1153 涨到 1344。 因为替换之后每个 FFN 要多做一串事:求最大值、除以 scale、round、clamp、转成 int8、 做 INT8 的矩阵乘、再跑我的 kernel。原来只有两个算子——一个 Linear 一个 ReLU——现在变成七八个。 11 个 FFN 乘起来就是 191。

第三,而这个模型是分派受限的。 我第 16 步已经实测过这一点。 所以墙上时间约等于 kernel 数量乘以每个的分派开销。多出 191 个 kernel,多出 4.98 毫秒, 平摊下来每个多出的 kernel 花掉 26 微秒。

总结成一句:我在一个"墙上时间由 kernel 数量决定"的模型里, 用增加 kernel 数量的方式,去省 GPU 的计算时间。方向是反的。

(照念,这一段是我最想让面试官听到的)

我觉得这个负结果比我前面任何一个正结果都值钱,因为它是一次成功的自我证伪

我第 16 步说"墙上时间由分派速率封顶",那个结论是从 FP16 对照推出来的—— 把 GPU 工作量砍掉 42%,墙上时间不动。 而这次是一个方向完全相反的独立验证——把 kernel 数量加上去,墙上时间就等比例涨。 同一个结论,两条方向相反的独立证据。这比一条证据硬得多。

那这个 kernel 就没用了吗?在 PyTorch eager 这个环境里确实没用,因为 eager 本身就是分派受限的。 它有意义的前提是执行环境已经把分派开销消掉了——比如 TensorRT、比如 CUDA Graph、 或者整个图被编译成少数几个融合 kernel。那时候瓶颈才轮到访存,融合才有收益。

换句话说:算子级优化必须建立在"分派开销已经被解决"这个前提上,否则不但会被淹没,还可能反噬。 这也正好解释了 TensorRT 为什么能快 11.65 倍——它是先解决了分派问题,算子融合才轮得到发挥作用。

(如果被追问「那怎么改才有收益」)

方向是减少 kernel 数量,不是减少每个 kernel 的工作量。 具体两条:把量化那几步也融进 kernel(求最大值那次归约可以和上一层融在一起); 或者上 CUDA Graph,把一整串 launch 录制成一个图一次提交。 这两个我都没做,但我知道方向。


第 20 步:最后一站,地平线 RDK——这一站我没有板子

(照念,开口第一句必须是这个)

RDK 这一条我必须先说清楚:我没有板子,没跑过天工开物工具链, 没执行过 hb_mapper,没生成过 .bin。这一节是纸面调研,没有一个数字来自真机。

但我做调研的时候找到一件事,我觉得比任何参数表都重要: 地瓜官方已经开源了一个把 ACT 部署到 RDK BPU 的工具,叫 rdk_LeRobot_tools 我去读了它的导出脚本,发现它做的核心几件事,和我自己独立分析这个模型推出来的结论是一样的—— VAE encoder 不导出、latent 直接写成 torch.zeros、位置编码折成图内常量。 我不是照着它抄的,是先做完自己的分析、后找到它的,所以这对我算是一个外部验证。

它还有一件我没想到的做法:把 ACT 切成视觉编码器和 Transformer 两个模型分别导出。 官方说是因为多相机要多次调用,但我觉得还有个更重要的好处—— ResNet 那半边 BPU 支持度接近百分之百,Transformer 那半边才是风险区,切开之后出问题能立刻定位是哪一半。

还有个交叉发现:官方用的是 opset 11,而 opset 11 里没有 LayerNormalization 算子。 我在 TensorRT 这边用 opset 17,实测能少 360 个节点。 这意味着 BPU 那边拿到的图里,每个 LayerNorm 都是拆散的十几个算子—— 这可能就是 Transformer 在 BPU 上不好跑的一个具体原因, 但 BPU 编译器会不会自己融回去,我查不到,这是我上机第一天要验的第一件事。

顺便说,这份调研我是让 AI 助手先做的,然后我逐条回原始来源核对。 核出两个问题:一处把没做的事写成实测了,一处标着"确定"但其实是错的—— 它说官方脚本把 640x480 硬编码了,我去读源码发现尺寸其实是从数据集拿的。 两处都改了,更正记录我留在文档里没删。

我的上岗第一天计划是按"从最安全到最有风险"推:先跑官方 sample 确认工具链本身是好的; 再单独转我的 ResNet18 那半边——纯 CNN、支持度最高,如果这都不通说明是环境问题不是模型问题; 然后逐层加 Transformer 定位问题算子;最后才是精度调优。 我不会一上来把整个模型丢进去看它报什么错,那样出了问题分不清是哪一半的锅。


第三部分 · 收尾

3.1 这个项目的边界(主动讲,不要等问)

(整段照念)

我想主动把边界说清楚,有五条。

**第一,我没有上过任何真实的端侧板子。**没有 RDK,也没有 Jetson。 所有数字都是在一块租来的 3090 上跑的。RDK 那一节是纸面调研,没有一个真机数字。 这是 JD 里明确要求的一条经验,我目前是空的。

第二,我手写的那个 CUDA kernel 接进真实链路之后是负结果——端到端变慢了 37%。 我把它接进去测过了(第 19 步),不是没测。结果是 0.729 倍, 因为它省下的 GPU 时间(0.05 毫秒)远小于它多引入的 191 个 kernel 带来的分派开销。 所以我不能声称它给端到端带来任何加速——事实上是负的。 11.65 倍来自 TensorRT,跟这个 kernel 无关。 我留着这个负结果,是因为它反过来给"这个模型是分派受限"提供了第二条独立证据。

第三,我的 INT8 结论建立在一个只有 32 帧的校准集上。 真的 INT8 engine 我做出来了,也测了——但工业上校准集通常是几百到上千帧,我只有 32 帧。 更多帧能让激活范围估计更稳,误差可能会降。 我判断不会降一个数量级(8.39 度对 0.69 度这 12 倍的差距,主因是"多量了激活"这个结构性因素), 但这是我的判断,我没做校准集大小的消融实验。 另外我没做 QAT——如果 INT8 是硬需求而后训练量化的精度不够,QAT 才是正确的下一步。

第四,我没有闭环的任务成功率。 归一化统计我后来重算出来了,但那只是让我能用真实数据做输入、能把误差换算成弧度; 真要跑闭环还需要仿真环境和 benchmark 的服务端,这些我没有。 所以像"INT8 掉 0.69 度能不能接受"这种问题,我只能给出数字和它落在哪个关节,不能给出结论。

第五,这个项目的代码大量用了 AI 辅助,我不藏。每一个决策是我把关的,而且我把我做错的也记了进去—— 有一条整个方案作废、有一条我判断错了组件、有两条推断被 profile 推翻、 有一次我的预测被自己的数据打脸、还有一次我在没验证的情况下误报了一个事实。

几条较小的边界,被问到再说:


3.2 这条时间线覆盖了 JD 的哪几条

岗位职责

# JD 原文 覆盖程度 时间线上对应哪一步
1 VLA 模型在端侧设备的部署与性能优化 打折 第 4、9~13、18 步。链路完整、11.65x,但跑在 3090 桌面卡上,"端侧"两个字没占住
2 算子优化、图优化及模型量化 完整 第 5 步(opset 省 360 节点)、第 12 步(位置编码省 96 节点)、第 14 步(融合 kernel)、第 15 & 17 步(假量化敏感性)、第 18 步(真 INT8 engine,量化与部署接通)这条最扎实
3 CUDA 编程开发,实现高效的推理加速 部分 第 8、14、19 步。kernel 写了、编译通过、和 torch 逐位对齐、误差溯源到 FMA、并且真接进了链路测端到端结果是负的(0.729x),所以不能声称端到端加速——但我讲得清为什么
4 RDK 及 NVIDIA 端侧开发板部署调试 只到调研 第 20 步。没有板子,零真机数字
5 持续优化资源占用与响应速度 部分 第 13、18 步。延迟 12.816→1.100ms、显存 345.3→178.7MB、engine 283→128→65MB,都有测法说明。但只有一轮

任职要求

# JD 原文 覆盖程度
1 熟悉 VLA 模型结构及其端侧部署流程 完整。第 1 步是结构(实测的参数分布、token 序列、CVAE 推理分支),其余是部署
2 算子优化、图优化、模型量化 完整,三样都有对照数字
3 熟练使用 CUDA 编程 打折一个 kernel 不等于"熟练",我会老实说:我能写、能讲透线程组织和访存,但没写过复杂 kernel
4 有在 RDK 开发板部署的经验 完全没有。这是我唯一的硬缺口,而且它在 JD 里是"任职要求"不是"优先"
5 NVIDIA 端侧开发板经验者优先 没有。但这条是"优先"不是必须

被问到第 4 条硬缺口时怎么答

(照念)

「RDK 这条我确实是空的,我不绕。我能说的是三件事: **第一,我知道自己空在哪。**不是"没听说过"——我把移植路径推演过一遍、 知道哪几个算子是风险点、知道官方工具的限制在哪,也知道哪些问题只有上机才能回答。 **第二,我上手不慢。**这个项目从零到 TensorRT 跑通是一天量级, 中间撞的坑——版本 ABI 不匹配、API 被删、静默降级到 CPU、类型冲突——我都是自己定位掉的。 第三,我已经想好第一天怎么开始:先跑官方 sample 验工具链, 再单独转 ResNet18 那半边探路,然后逐层加 Transformer 定位问题算子。」


3.3 上场前 30 秒过一遍这五个数

问自己 答案
最终快了多少?跟谁比? PyTorch 12.816ms → TensorRT FP16 1.100ms,11.65 倍
模型多大?推理时多少是死的? 83.92M,死掉 17.4M(20.7%),ONNX 图里只有 66.5M
进 Transformer 的序列多长? 82(80 视觉 + 1 latent + 1 proprio)
你的基线噪声底是多少? 3e-6。低于它的差异分辨不出,不能称"精确"
手写 kernel 接进链路的结果? 负的,0.729x。省了 0.05ms GPU 时间,但多了 191 个 kernel。分派受限的模型里,加 kernel = 加时间
INT8 为什么不划算? 速度和 FP16 一样(模型是分派受限不是算力受限),误差是假量化预测的 12 倍,只赚体积
最想让面试官记住哪一条? TensorRT 的 "FP32" 不是真 FP32——Ampere 默认开 TF32,我用只改一个 flag 的对照把因果锁死了

最后三句提醒自己:

  1. 数字后面永远跟一句「怎么测的」——延迟说 warmup 加中位数,显存说 max_memory_allocated 不是 nvidia-smi。
  2. **不确定的就说不确定。**说"这是我推断的、没实测"不丢人,被追问穿了才丢人。
  3. 边界主动说,不要等问。

附:数字出处

内容 出处
模型结构、参数分布、token 序列(第 1 步) T-002;docs/模型结构讲解.md
torchvision ABI(第 2 步) D-002
独立模型定义(第 3 步) D-001;T-001
基线噪声底 3e-6(第 4 步) T-004;D-006
ONNX 导出、opset 对照(第 4、5 步) T-006;D-007
SSH 排障(第 6 步) D-003
环境核查、CUDA 对齐(第 7 步) D-000;D-004(作废);D-005
编译链前置验证(第 8 步) T-003
不传 ONNX 改现导(第 9 步) D-018
TRT11 删 FP16 flag(第 10 步) D-009
TF32 对照(第 11 步) D-010;T-007
位置编码烘常量(第 12 步) D-011;T-007
四后端阶梯、ORT 静默降级(第 13 步) D-014;D-015;T-007
CUDA kernel(第 14 步) D-016;T-008
量化 + 被推翻的预测(第 15 步) D-017;T-009
profile 推翻两条推断(第 16 步) D-023;T-010
重算 stats + 真实观测(第 17 步) D-024;T-011
真 INT8 engine(第 18 步) D-025;T-012
kernel 接入真实链路(第 19 步,负结果) D-026;T-013
RDK 调研(第 20 步) D-008;docs/RDK移植路径调研.md