目录

术语扫盲 · 讲解稿里每个词到底是什么意思

这份文档是给你自己看的,不是给面试官看的。

你说"很多东西第一次看到,不理解啥意思"——这是对的反应,也是必须解决的。 稿子写得再好,讲不出来等于零。

怎么用:

  1. 先看第 0 节(训练仓从哪来)和第 1 节(哪些话不能照念),这两节最要紧。
  2. 然后按讲解稿的顺序往下看词条,每个词都标了它在第几步出现。
  3. 只把 A 档背熟。B 档知道大意,C 档可以直接说不知道。

分档的意思:

含义 怎么对待
A 必须真懂。 直接撑着项目主线,面试官几乎一定会追问 背熟。每个 A 档都给了一句可以直接说出口的话
B 知道大意即可。 被问到能说个八九不离十就行 看懂就行,不用背
C 可以诚实说不知道。 边角实现细节 记住兜底话术,别硬编

一条铁律不确定的就说不确定。 说"这个我没深究"不丢人,编一个听起来专业的答案被追问穿了才丢人。


你问过的问题,直接跳这里

你问另一个 AI 的时候,明确说不懂的是这些。我按你问的顺序列出来:

你问的 看这一节
CVAE Encoder? 第 2 节 ·【第 1 步】CVAE
特征图? 第 2 节 ·【第 1 步】特征图
训练仓?哪来的? 第 0 节(整节都在答这个)
ONNX 不是已经是最终格式了吗? 第 2 节 ·【第 4 步】ONNX(用你说的 GGUF 来解释
engine 是什么?TensorRT 是什么? 第 2 节 ·【第 10 步】TensorRT / engine(再看第 3 节开头
ORT 是什么? 第 4 节
第 12 步在干嘛?(你猜是缓存或查表) 第 3 节(单独讲,你猜得基本对)
四种办法是什么? 第 3 节
第 13 步是啥? 第 4 节
FFN 是什么? 第 2 节 ·【第 1 步】FFN(再看第 5 节 · 第 14 步
CUDA 算子是什么?干嘛的? 第 2 节 ·【第 8 步】kernel(用你说的 x86 指令集来解释
量化可以只量一部分? 第 5 节 · 第 15 步(能,而且实测更好)
profile 是什么? 第 5 节 · 第 16 步

你还说了两句更重要的话,我单独开了两节回应:

你说的 看这一节
「第 14~16 步这一轮基本上都没看懂 第 5 节(把 14~20 步整个重讲一遍,最厚)
「17 和后边基本上全没看懂 第 5 节
「说白了,如果单纯从结果来看是比较失败的 第 6 节(这句话我必须认真回应)

一个提醒:那份 AI 对话里有四处数字是错的

你拿去问的那个 AI,解释得不错,但它有四处把数字讲错了你如果照它的说法讲,面试时会被问穿。以我的实测记录(TEST-LOG.md)为准:

它说的 实际是
TensorRT 把显存从 345MB 省到 178MB 那是 PyTorch 自己从 FP32 换到 FP16 省的,跟 TensorRT 无关。TensorRT 的显存我根本没测(它用自己的内存管理,测不到可比的数)
手写 kernel 达到 76.2% 带宽,很厉害 76.2% 是放大 50 倍的假数据上测的。在你模型真实的尺寸上只有 7.3% —— 而这个 7.3% 才是重点,它说明 kernel 根本没跑到瓶颈
FP16 几乎零精度损失 物理误差我压根没测 FP16,只测了归一化空间的 0.0064。"几乎零损失"这个结论我没有数据支撑,不能说
(它删掉了)模型结构来自官方仓库 这一句绝对不能删,见第 0 节和第 1 节

它的解释方式可以参考,它的数字一个都别用。


第 0 节 · 你问的那个问题:训练仓的源码是从哪来的

这一节必须先搞清楚,因为它关系到你会不会把别人的东西说成自己的。

事实是这样的

那个叫 x-humanoid-vla-simulation-benchmark 的代码仓库, 是比赛官方发的 benchmark(评测基准)仓库,不是你写的。

它是主办方为了让所有参赛队伍在同一套标准下比赛而提供的,里面包含:

你做的事情是:用这个官方仓库提供的模型结构 + 你自己的训练数据,训练出了模型权重(那个 ckpt 文件)。

打个比方:官方给了图纸和厂房,你用它造出了一台机器。 图纸不是你画的,但这台机器是你造出来的,机器里的每一个参数值都是你训练得到的。

「自研 DETR 实现」这个说法——千万别照念

你可能在某些文档里看到过"自研 DETR 实现,不依赖 lerobot"这句话。

这句话的正确意思是:那个官方 benchmark 仓库自己实现了一份 DETR,而不是去调用 lerobot 这个第三方库。 "自研"的主语是官方仓库,不是你。

如果你说成"这个 DETR 是我自研的",这就是硬伤。 面试官只要问一句"那你说说你为什么这么设计",当场就穿。 而且这属于把别人的工作说成自己的,比不会更糟。

正确的说法(照念)

「这个模型的结构来自比赛官方提供的 benchmark 仓库—— 主办方发了一套代码,里面自带一份 ACT 的实现,没有依赖 lerobot 那些第三方库我做的是用这套结构训练出权重,然后这次把训练好的模型部署下去。 所以结构不是我设计的,权重是我训的,部署链路是我做的。

这句话说出来,比含糊带过强一百倍。 面试官听到的是"这个人边界很清楚"。

那"训练仓"这个词在讲解稿里指什么

讲解稿里我一直说"训练仓",指的就是这个官方 benchmark 仓库。 讲的时候建议说全:"比赛官方给的那个 benchmark 仓库",别只说"训练仓", 因为"训练仓"听起来像是你自己的项目。


第 1 节 · 【自查结果】这几句话容易被误说成你自己的成果

我把讲解稿和相关文档过了一遍,有三处需要你注意措辞

风险 1:「我自己训练的一个 VLA 模型」(30 秒结论版、第 1 步)

这句话本身是对的——权重确实是你训的。 但听众可能理解成"模型是他设计的"。

建议改成这样说(照念)

「这是我自己训练的一个 VLA 模型——结构是比赛官方 benchmark 仓库提供的,权重是我训出来的。」

多加半句,风险就没了,而且显得你分得清。

风险 2:「训练仓」这个词(第 3 步、第 17 步)

如前所述,讲的时候说成**"比赛官方给的 benchmark 仓库"**。

风险 3:「我读训练仓那个构建函数的源码,看到三件部署侧不能接受的事」(第 3 步)

这句话没有夸大,但要注意语气——你是在指出官方代码在部署场景下的不适用, 不是在说官方代码写得差。

建议的说法(照念)

「我去读了官方那份代码,发现它有三个地方是为训练场景写的, 放到部署场景就不能用了——这不是它写得不好,是训练和部署的前提本来就不一样。」

另外确认过没问题的


第 2 节 · 词条(按讲解稿出现顺序)


【第 1 步】把模型拆开看

ckpt(读作"check point",检查点) — B 档

大白话:训练完的模型存成的那个文件,里面装的全是数字(权重)。

比方:像一个存档。游戏打到一半存个档,下次读档接着玩。 训练也一样,训到某一步把当时的参数存下来,就是 ckpt。

在这个项目里:就是 agent_best.ckpt 那个 321MB 的文件, 里面存着这个模型 8392 万个数字。你训练得到的成果就是它。

为什么是 B:这个词面试官不会追问,知道"就是模型文件"就够了。


权重 / 参数 — A 档

大白话:模型里那一大堆可以调整的数字。训练就是在调这些数字。

比方:像一台有 8392 万个旋钮的机器。训练的过程就是不断转这些旋钮, 直到机器的输出符合你的期望。训练完,这些旋钮的位置就固定下来了,那就是权重。

在这个项目里:模型总共 83.92M(8392 万)个参数。 部署要做的事,本质上就是让这 8392 万个数字算得更快、占得更少,同时输出别变太多。

为什么是 A:整个项目的一切数字都建立在这上面。参数量、量化、体积,全跟它有关。

照念:「这个模型有 8392 万个参数。所谓部署优化,做的就是让这堆数字算得更快、存得更小,同时保证输出不变太多。」


hook(钩子) — B 档

大白话:在程序运行到某个地方时,插一段自己的代码进去看一眼。

比方:像在流水线中间架一个摄像头。产品照常往下走, 但你能看到经过这一点的时候东西长什么样。

在这个项目里:我在模型内部插了一段代码,把数据流经每一层时的实际形状记录下来。 不这么做的话,你只能靠读代码猜。

为什么是 B:知道"就是插一段代码去偷看中间结果"就够了。


张量 / tensor — B 档

大白话:一堆按格子排好的数字。

比方:一个数字是一个点;一排数字是一条线;一个表格是一个面; 一摞表格就是立体的。不管几维,都叫张量。

在这个项目里:一张图片就是一个张量(高 × 宽 × 3 个颜色通道); 模型输出也是张量(50 步 × 26 个关节)。

为什么是 B:不会被单独追问,但你得知道后面说的"形状 (1,50,26)"是什么东西。


形状 / shape — A 档

大白话:一个张量在每个方向上有多少个数字。

比方:一个 3 行 4 列的表格,形状就是 (3, 4)。

在这个项目里

为什么是 A形状对不上,整条部署链路就崩。 这是部署里最基础也最容易出错的事。 你讲的"纸上算是 7、实际是 8"那个坑,就是形状问题。

照念:「模型输出的形状是 1 乘 50 乘 26,意思是一次预测未来 50 步,每一步 26 个关节的角度。部署里形状差一格,后面全错,所以我是打 hook 实测出来的,不是纸上算的。」


卷积(Convolution) — A 档

大白话一种专门用来看图的计算方式。拿一个小方块(比如 3×3)在整张图上从左到右、 从上到下滑一遍,每滑到一个位置就把方块盖住的那些像素和一组固定的数字相乘再加起来, 得到一个新数字。滑完整张图,就得到一张新的"图"。

比方:像用一个小窗口在照片上一格一格地扫, 每扫一次就问一个问题——"这一小块像不像一条边?""像不像一个角?" 扫完你就得到一张"哪里有边、哪里有角"的图。

在这个项目里:模型处理摄像头画面靠的就是卷积。ResNet18 里面全是卷积。 你讲的"第一个卷积是 3 输入通道所以没有 INT8 实现",说的就是这个。

为什么是 A:这是视觉模型的基础,面试官问"你的模型怎么处理图像"时绕不开。

照念:「卷积就是拿一个小窗口在图上滑一遍,每滑一次算出一个数,用来提取图像里的局部特征,比如边缘、纹理。我这个模型的图像部分是 ResNet18,里面全是卷积。」


通道(channel) — A 档

大白话:同一张图上叠了几层信息。

比方:一张彩色照片其实是三张灰度图叠起来的——红的一张、绿的一张、蓝的一张。 所以它有 3 个通道。 到了模型深处,通道数会变成 512,意思是同一个位置上叠了 512 种不同的"特征", 比如"这里有没有边""这里有没有圆形"……

在这个项目里

为什么是 A:INT8 那个坑你一定会讲到,讲不清通道就讲不清那个坑。

照念:「通道你可以理解成'同一个位置上叠了多少种信息'。彩色图是红绿蓝三个通道。我踩的一个坑是:第一个卷积输入只有 3 个通道,而 INT8 的硬件指令要求通道数是 4 的倍数,所以那一层根本没有 INT8 的实现,只能留着不量化。」


padding(补零) — B 档

大白话:在图的边缘补上一圈 0,让计算能对齐。

比方:像给照片加一圈白边。你要用 3×3 的窗口去扫,扫到最边上的时候窗口会超出去, 加一圈白边就不会超了。

在这个项目里:它导致了一个反直觉的结果——240 除以 32 明明是 7.5, 实际算出来的高度却是 8,因为有 padding,除不尽的时候向上取整。 这就是我为什么坚持打 hook 实测而不是纸上算。

为什么是 B:知道"补一圈 0 导致除不尽时向上取整"就够了。


下采样(downsample)/ stride 32 — B 档

大白话:把图缩小。

比方:一张 320×240 的照片,缩小 32 倍,就变成 10×8 的一张小图。 信息变粗糙了,但每个点代表的范围变大了——一开始一个点是一个像素, 现在一个点代表原图上 32×32 那么大一块。

在这个项目里stride 32 就是说 ResNet18 把图缩小了 32 倍。 320÷32 = 10,240÷32 = 7.5→8,所以出来是 8×10

为什么是 B:能跟着这个算术走一遍就够了。


特征图(feature map) — B 档

你问过:「特征图?」

大白话卷积算完之后得到的那张"新图"。它已经不是照片了。

打个比方: 原始照片上,每个点存的是"这里是什么颜色"。 特征图上,每个点存的是"这里有没有某种东西"—— 比如"这里有没有一条竖边""这里有没有一个圆角""这里像不像金属"。

所以它叫"特征"图:它记录的不是画面本身,是从画面里提取出来的线索

为什么模型要转成这个:模型不关心"这个像素是不是红色", 它关心的是"画面里有没有杯子、杯子在哪"。特征图就是把原始像素翻译成这类线索的中间产物。

在这个项目里(具体的数):

注意这个变化:画面变小了(320→10),但每个点变"厚"了(3→512)。 意思是:位置信息变粗了(一个点现在代表原图上 32×32 那么大一块), 但每个点上记录的线索种类多了很多。

这个 8 × 10 = 80,就是后面说的"80 个视觉 token"的来源。

为什么是 B:知道"卷积的输出,记录的是线索不是画面"就够了。


backbone(主干网络) — A 档

大白话:模型里专门负责看图的那一大块。

比方:像眼睛。眼睛把看到的画面转成大脑能理解的信号, 但眼睛不做决策,决策是大脑做的。backbone 就是眼睛。

在这个项目里:backbone 是 ResNet18,占 11.17M 参数、13.3%。 它把 320×240 的图变成 8×10×512 的特征,然后交给后面的 Transformer 去做决策。

为什么是 A:你会讲到"backbone 只占 13.3%,比直觉小得多",这是个反直觉的点,得能解释。

照念:「backbone 是模型里专门负责看图的那部分,我这个模型用的是 ResNet18。有意思的是它只占 13.3% 的参数,比一般人直觉里小很多——这个模型的重心其实在后面的 Transformer 上。」


ResNet / ResNet18 — A 档

大白话:一种很经典、很常用的看图网络的名字。18 表示它有 18 层。

比方:像"三菱发动机"是个具体型号。ResNet 是型号名,18 是排量。 它 2015 年出来,因为好用又稳定,到现在还是最常见的选择之一。

在这个项目里:就是你模型的 backbone。11.17M 参数。 它的一个好处是:因为太常用了,所有部署工具对它的支持都最好—— 这也是我在 RDK 调研里说"先单独转 ResNet18 那半边探路"的原因: 如果连它都转不过去,那说明是环境问题,不是模型问题。

为什么是 A:面试官问"你的视觉部分用的什么",你得答得出来。

照念:「视觉部分用的是 ResNet18,一个很经典的卷积网络,18 层。选它的好处是它太常用了,所有部署工具链对它的支持都最成熟——所以我做移植计划时,第一步就是单独把它转过去探路,如果连 ResNet18 都不通,那就说明是环境问题不是模型问题。」


Transformer — A 档

大白话一种让信息互相"看一眼"再做决定的计算结构。 现在几乎所有 AI 大模型(包括 ChatGPT)都是基于它。

比方:像开一个圆桌会议。桌上坐着一堆信息(图像的不同区域、机器人当前的姿态……), 每一条信息都可以看一眼其他所有信息,然后更新自己的想法, 开几轮会之后得出结论。 "看一眼其他所有信息"这件事,就是下面要说的 attention

在这个项目里:Transformer 是这个模型的主体,占了参数的 65.6%(encoder 20.7% + decoder 44.9%)。 它负责把"看到的画面"和"机器人现在的姿态"结合起来,决定接下来 50 步该怎么动。

为什么是 A:这是模型的主体,也是你所有优化的战场。

照念:「Transformer 是这个模型的主体,占了六成半的参数。它做的事是让图像信息和机器人当前姿态互相参考,然后决定接下来该怎么动。」


token(词元 / 信息单元) — A 档

大白话送进 Transformer 的一个个"信息块"。

比方:接着上面那个圆桌会议的比方——一个 token 就是桌边坐的一个人。 每个人手里拿着一条信息。会议开始后,每个人都可以看其他所有人手里的信息。

在这个项目里(这是必须记住的一组数):

为什么是 A82 这个数字是你整个项目最关键的数字之一。 它决定了"attention 不是瓶颈"、"不该用 flash-attention"这些判断。

照念:「token 就是送进 Transformer 的一个个信息块。我这个模型里,图像被切成 80 块,加上机器人姿态 1 块、latent 1 块,一共 82 个。这个 82 很关键,因为它太短了,所以那些专门优化长序列的技术在我这儿基本没收益。」


序列长度(sequence length) — A 档

大白话:就是上面说的 token 一共有多少个。

在这个项目里82

对比一下你就知道这有多短:ChatGPT 处理一篇文章可能是几千到几万个 token, 而你这个模型只有 82 个。

为什么是 A:同上,这是关键数字。


attention(注意力) — A 档

大白话让每个信息块去"看"其他所有信息块,然后决定该重点参考谁。

比方:还是圆桌会议。轮到你发言前,你会扫一眼全桌—— "这件事跟老王说的最相关,跟小李说的没关系"—— 于是你多参考老王,少参考小李。这个"扫一眼并决定重点参考谁"的过程就是 attention。

在这个项目里:模型靠 attention 把"画面上这一块"和"机器人的姿态"关联起来。 你实测发现的一个反直觉结论是: attention 的核心计算(softmax)只占 GPU 时间的 1.1%,但它的那些"准备工作"(投影)反而是最大的一块。

为什么是 A:面试官几乎一定会问 Transformer 的原理,attention 是核心。 而且你有一个实测发现跟它直接相关。

照念:「attention 就是让每个信息块去看其他所有信息块,决定该重点参考谁。我这个模型里有个我一开始没想到的实测结果:attention 真正的核心计算只占 GPU 时间的 1.1%,但它前面那些准备工作——把信息投影成三份的那几个矩阵乘——反而是最大的一块耗时。」


encoder / decoder(编码器 / 解码器) — A 档

大白话

比方:encoder 像读题——把题目看懂,理出条理。 decoder 像答题——根据理解的内容,一条一条写出答案。

在这个项目里

注意:这个模型里有两个不同的 encoder,容易混:

  1. Transformer 的 encoder(4 层)—— 推理时要跑
  2. CVAE 的 encoder(也是 4 层)—— 推理时完全不跑,就是你说的"死掉的 20.7%"

为什么是 A:讲"我砍掉了 20.7%"的时候必须分清是哪个 encoder,说混了就穿了。

照念:「这个模型有两个 encoder,得分清楚。一个是 Transformer 的 encoder,负责理解当前画面和姿态,推理时要跑。另一个是 CVAE 的 encoder,它只在训练时用,推理时完全不跑——这部分是 1740 万参数、占 20.7%,我导出的时候把它整个砍掉了。」


FFN(前馈网络,Feed-Forward Network) — A 档

大白话:Transformer 每一层里,跟在 attention 后面的一个"加工车间"。 它不做信息交换,只是把每个信息块自己放大、过滤、再压缩回来

比方:attention 是开会(信息互相交换),FFN 是散会后各自回办公室消化—— 把刚才听到的东西展开想一遍(放大到 3200),去掉没用的(ReLU 过滤),再总结成结论(压回 512)。

在这个项目里(重要数字):

为什么是 A:你选手写 CUDA kernel 就是冲着 FFN 去的,必须能解释为什么。 而且后来 profile 推翻了你的判断,这个反转是你最值钱的素材之一。

照念:「FFN 是 Transformer 每层里跟在 attention 后面的部分,它把每个信息块从 512 个数放大到 3200 个数,过滤一遍再压回 512。我这个模型的放大倍数是 6.25 倍,比常规的 4 倍大不少,所以我一开始判断它是耗时大头——但后来 profile 实测推翻了这个判断,这个我后面细说。」


ReLU(激活函数) — B 档

大白话:一个极其简单的过滤规则——大于 0 的留下,小于 0 的一律变成 0。

比方:像一道及格线。60 分以上按实际分数算,60 分以下一律记 0 分。 (ReLU 的及格线就是 0。)

在这个项目里:FFN 中间那一步的过滤就是 ReLU。 你手写的那个 kernel 里就包含了这一步。

为什么是 B:规则简单到一句话能说完,但不会被深挖。 顺带一提:你可能听过 GELU,那是另一种更平滑的过滤规则, 你这个模型用的是 ReLU 不是 GELU,别说反了。


LayerNorm(层归一化) — A 档

大白话把一组数字调整到"平均值是 0、波动幅度是 1"的标准状态。

比方:像考试成绩换算成标准分。 一个班平均分 80、另一个班平均分 50,直接比没意义; 都换算成"比本班平均高多少个标准差",就能比了。 LayerNorm 就是在模型内部不断做这种换算,防止数字越算越大或者越算越小

在这个项目里:这个模型里有 36 个 LayerNorm。 你最漂亮的一个图优化发现就跟它有关—— 在旧版格式里,一个 LayerNorm 要拆成 11 个小步骤;新版格式里就是 1 步。 36 个 × 省 10 步 = 360,和实测的节点差分毫不差。

为什么是 A:这是你图优化那一段最硬的证据,必须能讲。

照念:「LayerNorm 是把一组数字调整到平均值 0、波动 1 的标准状态,防止数值在深层网络里越算越离谱。我这个模型有 36 个。我做的一个优化就是换了个导出格式版本——旧版里一个 LayerNorm 要拆成 11 个基础步骤,新版里是 1 个,省 10 个。36 乘 10 正好等于 360,跟我实测的节点数差完全对上。」


CVAE(条件变分自编码器) — A 档

大白话:一种**专门用来处理"同一个情况有好几种正确做法"**的模型设计。

比方(这个比方很重要,务必理解): 教一个人开车绕过路障。教练示范了 10 次,5 次从左边绕,5 次从右边绕,两种都对。

如果你让学员简单地"学平均值",他会学出一个从中间走的动作——直接撞上去

CVAE 的解决办法是:训练时额外告诉模型"这一次是左绕风格"还是"右绕风格", 把这个"风格信息"压成一个小小的编码(就是下面说的 latent)。 这样模型就不用在一个输入上硬学两个互相矛盾的答案了。

在这个项目里:训练时 CVAE 的 encoder 偷看了标准答案(真实的动作序列), 把"这一条是什么风格"压成 32 个数。 但推理的时候你没有标准答案——所以这条通道天然就不存在,直接置 0。

为什么是 A:这是"为什么推理时能砍掉 20.7%"的全部理由,面试官一定会追问。

照念:「CVAE 是用来解决动作多样性问题的。同一个画面,示范的人可能从左边绕也可能从右边绕,两种都对,如果直接学平均值就会学出一个从中间撞上去的动作。CVAE 的做法是训练时额外把'这一次是哪种风格'压成一个小编码交给模型。但推理的时候没有标准答案,这条通道天然不存在,所以直接置零——这就是我能砍掉 20.7% 参数的原因。」


latent(潜变量 / 隐编码) — A 档

大白话:上面说的那个"风格编码"——32 个数字,概括了"这一次是哪种做法"。

比方:像给一段舞蹈打的风格标签,只不过不是文字标签,是 32 个数。

在这个项目里

为什么填 0 而不是随便填:0 是"最典型、最中间的那种风格"。 而且填 0 是确定性的——同样的输入永远得到同样的输出。 如果随机填,机器人每次遇到同样场景动作都不一样,对控制来说是灾难

为什么是 A:同上。

照念:「latent 就是那个风格编码,32 个数。训练的时候它是从标准答案里算出来的,推理的时候没有标准答案,所以直接填 0。填 0 的意思是取最典型的那种做法,而且是确定性的——同样输入永远同样输出,这对机器人控制很重要,不能每次动作都不一样。」


KL / kl_weight — B 档

大白话:训练时的一个旋钮,控制"模型有多依赖那个风格编码"。

比方:像给学员的提示强度。

在这个项目里kl_weight = 10,是个偏大的值, 意味着模型对那个风格编码的依赖比较弱—— 这正是"推理时把 latent 填 0 也没事"的前提。

为什么是 B:能讲清"它决定模型多依赖 latent,取大值所以填 0 安全"就够了。 诚实边界:你没有打印过训练时两项 loss 的实际比值,所以"10 算大"是推断。 被追问就说:「'10 算大'这一条我是推断的,我没有打印过训练时两项的实际比值。」


action chunking / chunk_size=50 — A 档

大白话一次预测未来一整段动作,而不是只预测下一步。

比方:下棋的时候,不是只想下一步,而是一次想好接下来 50 步的计划, 然后照着执行。

在这个项目里chunk_size = 50,模型一次吐出未来 50 步。 按 30 Hz 的控制频率算,大约是 1.7 秒的动作

为什么要这样:如果每一步都单独预测,一点小误差会把机器人带到没见过的状态, 在那里错得更多,误差滚雪球。一次预测 50 步,等于把"做决定"的次数降低了一个数量级。

为什么是 A:这是 ACT 这个模型名字的由来(Action Chunking Transformer),必问。

照念:「action chunking 就是一次预测未来一整段动作而不是只预测下一步。我这个模型一次出 50 步,按 30Hz 算大概 1.7 秒。这么做是为了压制误差累积——单步预测的话,一点小误差会把机器人带到训练时没见过的状态,在那里错得更多,误差会滚雪球。」


【第 2 步】torchvision 那个坑

库 / 依赖 — B 档

大白话:别人写好的、你直接拿来用的现成代码包。

比方:像做菜时买的现成调料包。你不用自己熬高汤。

在这个项目里:torchvision 就是一个专门放"看图相关工具"的包, ResNet18 就是从里面拿的。


ABI(应用二进制接口) — C 档

大白话:两块编译好的程序之间"对接口"的规矩。规矩对不上就装不到一起。

比方:像螺丝和螺母的螺纹标准。都是 M6 的螺丝, 但一个是公制螺纹一个是英制螺纹,看起来一样,拧不进去。

在这个项目里:torchvision 是按 PyTorch 的接口规矩编译死的。 我装的那个版本是给 PyTorch 2.13 编的,我的是 2.12,接口对不上,整个包加载失败, 于是它提供的所有功能都"不存在"了,报错报的是第一个被用到的那个功能(nms)。

为什么是 C:底层机制很深,你不需要懂。 兜底话术

「更底层的 ABI 机制我没深挖过。我的处理方式是认版本对应表,加上看报错的是不是这个包最核心的功能——如果最核心的功能都'不存在',那多半是整个包没加载起来,不是缺功能。这套判据在我这次是够用的。」


md5 — C 档

大白话:给文件算一个"指纹"。文件内容变一个字节,指纹就完全不同。

比方:像给一整箱货物贴一个封条编号。 收货的时候对一下编号,就知道路上有没有被换过、少过。

在这个项目里:传输 321MB 的模型文件之后,两边算 md5 比对, 一致才说明传完整了。我靠这个抓到过一次传输截断(只传了 264MB)。

为什么是 C:知道"用来验文件完整"就够了,没人会追问算法。


【第 3 步】自己拷一份模型定义

源码 — B 档

大白话:程序员写的、人能读的那份代码原文。

在这个项目里:指官方 benchmark 仓库里那些 .py 文件。 我"读源码"的意思就是:打开那些文件看它到底是怎么写的, 而不是运行它、看它报什么错。


import(导入) — B 档

大白话:在自己的代码里"引用别人的代码"。

比方:像写文章时引用别人的段落。好处是不用重写, 坏处是原作者改了,你这边跟着变,而你可能不知道。

在这个项目里:这正是我不 import 官方仓库、而是拷一份的理由—— 如果 import,官方仓库改一行,我导出的东西就悄悄变了,我还不知道。


部署 vs 训练 — A 档

大白话

比方:训练像培养一个运动员(几年,条件不计成本); 部署像让他上场比赛(几秒钟内出结果,场地条件还受限)。

在这个项目里:这是整个项目的核心区分。 你讲的"训练代码可以假设有 GPU、有这有那,部署代码一条都不能假设", 就是这个意思。

为什么是 A:这是你整个项目的立足点。

照念:「训练和部署要求完全不一样。训练可以慢、可以吃很多资源、环境是自己搭的;部署要快、要省、而且环境是别人给的、什么都不能假设。我读官方那份代码发现它有三个地方是为训练写的——写死了要用 GPU、会去读命令行参数、还建了个训练才用的优化器——这些在训练里都合理,在部署里一条都不能接受。」


GPU / CPU — A 档

大白话

比方:CPU 像一个博士——什么都会,但一次只能干一件事。 GPU 像一千个小学生——每个只会做加减法,但一千个同时算,算简单的东西就飞快。 AI 模型的计算恰好就是"大量重复的简单运算",所以 GPU 快得多。

在这个项目里:模型最后跑在 GPU(一块 RTX 3090)上。 你项目里一个关键发现就是"GPU 明明不忙,但整体还是慢"—— 因为 CPU 那个"博士"忙着给一千个小学生分发任务,分发不过来。

为什么是 A:你最重要的性能结论就建立在 CPU/GPU 的分工上。

照念:「CPU 擅长按顺序做复杂的事,GPU 擅长同时做几千件简单的事。AI 模型的计算正好是大量重复的简单运算,所以放 GPU 上快。但我这个项目发现了一个反直觉的事:GPU 其实没那么忙,慢在 CPU 给它派任务派不过来。」


【第 4 步】ONNX 导出

算子(operator / op) — A 档

大白话模型里的一个基本计算动作。

比方:像菜谱里的一个步骤——"切"、"炒"、"加盐"。 一道菜是一连串步骤;一个模型就是一连串算子。

在这个项目里(具体例子):

你的模型导出后有 756 个算子(也叫 756 个"节点")。

为什么是 A"算子优化"是 JD 里明确点名的一条职责,你必须能解释这个词。

照念:「算子就是模型里的一个基本计算动作,比如做一次卷积、做一次加法。整个模型就是几百个算子连起来。我这个模型导出之后有 756 个算子。所谓算子优化,就是让单个动作更快,或者把几个动作合并成一个。」


图 / 计算图 — A 档

大白话把所有算子按执行顺序连起来画成的一张流程图。

比方:像一张工艺流程图——原料从哪进,经过哪些工序,成品从哪出。 每个方框是一个算子,箭头是数据的流向。

在这个项目里:ONNX 文件里装的就是这张图 + 那 8392 万个参数。 "图优化"就是改这张流程图——去掉多余的工序、把相邻的几道工序合并成一道。

为什么是 A"图优化"也是 JD 点名的职责。

照念:「计算图就是把模型里所有计算动作按顺序连起来的一张流程图。图优化就是改这张图——删掉多余的步骤,或者把几个相邻的步骤合并成一个。我做了两次图优化,一次省了 360 个节点,一次省了 96 个。」


ONNX — A 档

你问过:「ONNX 是不是类似于 GGUF?」「ONNX 不是已经是最终格式了吗?」 第一个问题:是的,你这个直觉非常准。第二个问题:不是最终的,还差一步。下面细说。

大白话一种通用的模型文件格式,让模型能离开训练它的那套环境,拿去别处跑。

用你熟悉的东西打比方(这个锚点最好用)

ONNX 之于 PyTorch,就像 GGUF 之于 HuggingFace 的原始模型。

你在本地跑大模型的时候应该有这个体验:

ONNX 干的是同一件事:

两者的相同点:都是为了脱离原来的训练框架、拿去别处快速推理而生的。

但有一个关键区别,这个区别正好回答你第二个问题

GGUF ONNX
拿到之后 直接就能跑(llama.cpp 读了就用) 还要再转一道(交给 TensorRT 再编译一次)
里面装的 权重 + 量化信息,结构是约定好的 完整的计算流程图 + 权重
定位 终点 中转站

所以回答你的问题:ONNX 不是最终格式,它是中转站。

想象一下:ONNX 像一份写得很详细的菜谱——每一步怎么做都写清楚了,谁拿到都能照着做。 但每次做菜都要现看菜谱、现找锅、现找料TensorRT 做的事,是把这份菜谱变成一条已经调试好的流水线——那个才是最终成品。

在这个项目里

PyTorch 模型 --导出--> ONNX --TensorRT编译--> engine(最终成品)
                        └──--地平线工具链编译--> .bin(如果上 RDK 的话)

ONNX 卡在中间,是因为它要同时喂给两条不同的下游——一条通 NVIDIA,一条通地平线。

为什么是 A:整条链路的枢纽,而且你已经有 GGUF 的心智模型,讲起来很自然。

照念:「ONNX 是模型的通用中间格式。我自己在本地跑过大模型,所以我理解它的定位——它跟 GGUF 有点像,都是为了脱离原来的训练框架、拿去别处跑。但有个区别:GGUF 拿到就能直接跑,ONNX 还要再交给下游编译一次。所以它不是最终格式,是中转站。 我这个项目里,同一个 ONNX 往下可以交给 TensorRT 生成 NVIDIA 的引擎,也可以交给地平线的工具链生成他们板子上的格式——这就是我为什么要卡在 ONNX 这一层。」

opset(算子集版本) — A 档

大白话ONNX 这个格式的版本号。版本越高,支持的"标准动作"越多。

比方:像 Word 的文档版本。新版 Word 支持"一键生成目录"这个功能; 老版没这个功能,只能手动一行行敲出来——效果一样,但步骤多得多。

在这个项目里(这是你最漂亮的一个对照实验):

为什么是 A:这是你图优化那段的核心证据。

照念:「opset 是 ONNX 格式的版本号,版本越高支持的标准动作越多。我做了个对照实验:用旧版本导出是 1212 个节点,用新版本是 852 个,差 360。原因是新版本才有 LayerNorm 这个标准动作,旧版本得用 11 个基础动作拼出来。我模型里有 36 个 LayerNorm,每个省 10 个,36 乘 10 正好 360,跟实测完全对上。」


节点数 — B 档

大白话:计算图里有多少个方框,也就是有多少个算子。

在这个项目里:756(优化后)。节点越少通常越快, 因为每个节点都要单独"启动"一次,启动本身有开销。


归一化(normalize) — A 档

大白话把数字调整到一个统一的、方便计算的范围。

比方:比较两个人的成绩——一个人考试满分 100 分得了 90, 另一个满分 750 分得了 600。直接比数字没意义,得先换算成百分比。 归一化就是这个换算。

在这个项目里有两处,别混

  1. 图像归一化:把像素值调整到模型习惯的范围。 我把这一步收进了模型内部,这样端侧调用的人不用自己做,少一个出错的地方。
  2. qpos/动作归一化:把关节角度(弧度)换算成"比平均值高几个标准差"。 这一步需要 dataset_stats 文件,而那个文件丢了——这是你项目最大的一个限制。

为什么是 A:dataset_stats 丢失是你必须解释的一个边界。

照念:「归一化就是把数字换算到统一范围,好比把不同满分的考试成绩换算成百分比。这个项目里有两处:图像的归一化我收进了模型内部,让端侧调用的人少一个出错的地方;关节角度的归一化需要一个统计文件,而那个文件丢了——这是我这个项目最大的限制,我后来用原始数据重新算了一份。」


std(标准差) — B 档

大白话一组数字波动有多大。

比方:两个班平均分都是 80。 A 班全是 78~82 分(波动小,std 小), B 班有 40 分也有 100 分(波动大,std 大)。

在这个项目里:每个关节都有自己的 std。 它是"归一化空间的误差"换算成"实际角度误差"的换算系数—— 误差 × std = 真实的弧度误差。


弧度 — A 档

大白话衡量角度的另一种单位,和"度"是换算关系。

换算记住这个1 弧度 ≈ 57.3 度180 度 = 3.14 弧度

在这个项目里:模型输出的关节角度单位是弧度。 你报的所有"多少度"的误差,都是从弧度换算过来的。 比如 INT8 量化的误差 0.0121 弧度 = 0.69 度

为什么是 A你所有关于"精度损失能不能接受"的讨论都建立在这个换算上。

照念:「模型输出的关节角度单位是弧度,1 弧度大概 57.3 度。我把量化误差换算成了度,这样才能判断大小——INT8 是最大 0.69 度,INT4 是 27 度。27 度不用解释,就是完全不能用。」


MSE(均方误差) — B 档

大白话:衡量"两组数字差多少"的一个指标。 把每一处的差值平方,再取平均。

比方:像打靶。每一发离靶心多远,平方之后取平均。 平方的作用是:大的偏差会被放得更大,所以它对"个别错得很离谱"特别敏感。

在这个项目里:用来比较优化前后模型输出差多少。


余弦相似度 — B 档

大白话:衡量两组数字"方向像不像",不看大小,只看方向。1 就是方向完全一致。

比方:两个人指路。 一个说"往东北走 100 米",另一个说"往东北走 200 米"—— 方向一模一样(余弦=1),但距离差一倍。

在这个项目里有个重要发现三个不同精度的引擎,余弦都在 0.9999996 以上,看不出区别, 但最大绝对误差差了三个数量级。 说明对这个任务,余弦不是个好指标—— 机器人执行的是"某个关节转多少度",得看绝对误差。

可以说的一句:「余弦相似度看的是方向像不像,不看大小。我发现它在这个任务上区分度很差——三个引擎余弦都是 0.999999 以上,但最大误差差了一千倍。因为机器人执行的是具体某个关节转多少度,所以得看绝对误差。」


逐位一致(bit-exact) — B 档

大白话:两个结果一个二进制位都不差,完全相同。

比方:两份文件不只是"内容看起来一样",而是连一个标点、一个空格都完全相同

在这个项目里:这是最严格的一致性标准。 你有几处做到了逐位一致(比如位置编码烘成常量前后),那是很硬的证据


【第 4 步】那个"基线不可信"的发现

浮点数 — B 档

大白话:计算机表示小数的方式。它存不下无限位,只能存有限位,所以总有微小的误差。

比方:像用计算器算 1÷3 = 0.3333333, 它只能显示有限位,后面的都截掉了。

在这个项目里:所有的微小误差(1e-6 那种)都源于此。


浮点加法不满足结合律 — A 档

大白话(a+b)+c 和 a+(b+c),在计算机里算出来可能不完全相等。

比方:假设你的计算器只能显示 3 位有效数字。

顺序不同,中间被舍掉的东西不同,结果就可能差一点点

在这个项目里:这是你那个"基线不可信"发现的根本原因—— 多个 CPU 核心同时算的时候,加法的顺序会变,所以结果不完全一样。

为什么是 A:这是你整个数值方法学的基石,讲不清这个,后面所有误差数字都站不住。

照念:「浮点数在计算机里位数有限,所以加法的顺序会影响结果。多线程计算的时候,任务怎么切给不同核心是会变的,加法顺序跟着变,结果就可能差最后几位。我实测过:同一个模型同一个输入,8 线程跑 5 次有一次结果不一样,改成单线程就完全一致。所以我后面所有的基线都用单线程生成。」


噪声底(noise floor) — A 档

大白话你的测量工具自身的误差有多大。比这个小的差异,你根本分辨不出来。

比方你用一把最小刻度 1 毫米的尺子, 就没法说"这两个东西差 0.1 毫米"——那超出你的分辨能力了。

在这个项目里:实测噪声底是 3e-6(也就是 0.000003)。 所以当你测到"误差 1.5e-6"时,正确的说法是"分辨不出来", 而不是"非常精确"——后者是把测不出来说成测出来了。

为什么是 A这是你项目里"诚实"这件事最具体的体现, 面试官听到这个会立刻知道你是认真做测量的。

照念:「噪声底就是测量工具自身的误差。我实测我的基线自己就有 3e-6 的抖动。所以后来我测到某个误差是 1.5e-6 的时候,我的说法是'这个差异在噪声以内、分辨不出来',而不是'导出非常精确'——因为后者是把测不出来的东西说成测出来了。」


【第 5 步】图优化

算子融合(operator fusion) — A 档

大白话把好几个连着做的计算动作,合并成一个动作。

比方(这个比方很重要): 做菜时"切菜 → 装盘 → 端到灶台 → 倒进锅"。 如果切完直接推进锅里,就省掉了装盘和搬运。 计算也一样——每个动作都要把数据从内存里搬出来、算完再搬回去。 合并之后,中间那几趟搬运全省了。

在这个项目里

为什么是 A"算子优化"是 JD 点名的职责,融合是其中最核心的手段。

照念:「算子融合就是把几个连着做的计算合并成一个。为什么有用?因为每一步计算都要把数据从内存搬出来、算完再搬回去,合并之后中间那几趟搬运就省了。我这个项目里做了两次:一次是换 ONNX 版本,让 36 个 LayerNorm 各自从 11 步变 1 步;一次是我自己写的 CUDA kernel,把三步合成一步。」


常量折叠(constant folding) — A 档

大白话如果某个计算的结果永远不变,那就提前算好存起来,别每次都重算。

比方:菜谱上写"取 3 个鸡蛋,打散"。 如果你每天做同一道菜,就该头天晚上打好放冰箱, 而不是每次现打——因为结果每次都一样。

在这个项目里:这是你第 12 步那个发现—— 位置编码这个东西只跟"图有多大"有关,跟图的内容无关。 而你的图尺寸是固定的,所以它永远是同一个结果,却被留在流程里每次重算一遍。 提前算好存起来之后:节点从 852 降到 756,省 96 个,而且输出逐位一致。

为什么是 A:这是你自己发现的一个优化,含金量高。

照念:「常量折叠就是:如果某一步的计算结果永远不变,就提前算好存下来,别每次重算。我发现位置编码这部分只跟图片尺寸有关、跟图片内容无关,而我的尺寸是固定的,所以它永远是同一个结果,却被留在流程里每次重算。我把它提前算好存成常量,节点从 852 降到 756,而且输出逐位一致,一个数都没变。」


位置编码(positional encoding) — B 档

大白话给每个信息块贴一个"你在第几个位置"的标签。

比方:圆桌会议的比方里,attention 本身不知道谁坐在哪—— 在它眼里所有人是一团,没有顺序。 位置编码就是给每个人发一个座位号牌,这样模型才知道 "这块图像在左上角"、"那块在右下角"。

在这个项目里:它有两种,一种是用正弦余弦函数算出来的(图像用), 一种是学出来的。你的优化就是把算出来的那种提前算好、不再每次重算。


【第 6-7 步】租机器、核环境

CUDA — A 档

大白话NVIDIA 显卡的编程平台。 想让程序跑在 N 卡上,就得用它。

比方:像某个品牌电器的专用接口标准。想用这个牌子的设备, 就得按它的插头标准来。

在这个项目里:整条 GPU 链路都建立在 CUDA 上。 你踩的一大类坑都是 CUDA 版本对不上(系统给的是 13.2,某些工具只支持 12)。

为什么是 A"CUDA 编程开发"是 JD 明确点名的一条职责。

照念:「CUDA 是 NVIDIA 显卡的编程平台,想让代码跑在 N 卡上就得用它。我这个项目里踩的一大类坑都是 CUDA 版本对不上——租到的机器是 CUDA 13.2,而 onnxruntime 那个工具只有 CUDA 12 的版本,直接就用不了。」


驱动 — C 档

大白话:让操作系统能跟显卡对话的那层软件。

兜底话术:「驱动那一层我没深入,我只核对了版本号,确认它支持我要用的 CUDA。」


sm_86 / 架构 — B 档

大白话显卡的"代号",表示它是哪一代、支持哪些功能。

比方:像汽车的排放标准——国五、国六。 新标准的车能做的事更多,但老车的配件不一定能装到新车上。

在这个项目里:3090 是 sm_86重要后果是: TensorRT 生成的引擎文件绑定这个架构,换一张不同架构的卡就用不了,必须重新生成。

可以说的一句:「sm_86 是 3090 这一代显卡的架构代号。有个实际后果是 TensorRT 生成的引擎绑架构,换张卡就得重新生成。」


裸机 / 镜像 — B 档

大白话

在这个项目里:我以为租到的是预装好 PyTorch 的镜像, 实际是裸机——连 pip、g++ 都没有。这就是你说的"预期落空"。


【第 8 步】先验编译链

kernel(核函数) / CUDA 算子 — A 档

你问过:「CUDA 算子是什么?干嘛的?」以及「这个算子,有点像 x86 和 AMD?」 你那个类比是对的,而且比一般人想到的还要准。下面用它来讲。

大白话一段专门写给显卡执行的小程序。

用你自己那个类比(这个非常好用)

你说"像 x86 和 AMD"——准确地说,是像指令集。

位置关系对照(帮你把两边对上):

CPU 世界 GPU 世界
硬件架构 x86 / ARM CUDA(NVIDIA)/ ROCm(AMD)
你写的东西 C++ / 汇编 / AVX 指令 CUDA 算子(.cu 文件)
优化手段 用高级复合指令代替多条普通指令 算子融合:一个 kernel 代替多个 kernel

它跟"算子"这个词的关系: 前面讲过算子 = 模型里的一个基本计算动作CUDA kernel 就是"这个动作在显卡上具体怎么执行"的那段代码。 一个算子对应一个(或几个)kernel。

还有一层——为什么 GPU 上要专门写: 显卡里有几千个小计算核心,它们听不懂普通的 C++。 你得用 CUDA 这套语法,告诉它"你们几千个人,每人负责算哪一小块"。

在这个项目里:我手写的那个 kernel 做的是 反量化 + 加偏置 + 过滤(ReLU)三合一——原来三步,现在一步。

为什么是 AJD 里"CUDA 编程开发"是明确点名的一条职责,这是核心概念。

照念:「CUDA 算子就是一段专门写给显卡执行的小程序。我的理解是,它跟 CPU 上的指令集是同一个逻辑——x86 里有 FMA 这种复合指令,一条指令同时做乘法和加法,比分两条快。我手写的这个融合算子也是一样:原本显卡要分三次执行反量化、加偏置、过滤三个动作,我把它们合并成一次执行完。区别只是 CPU 上写的是 x86 汇编,GPU 上写的是 CUDA 代码。


编译 — B 档

大白话:把人写的代码翻译成机器能直接执行的东西。

比方:像把中文说明书翻译成机器人能听懂的指令。

在这个项目里:你写的 CUDA 代码要先编译成 GPU 能跑的东西。 编译要 70 秒,而且缺工具会失败——你第一天就撞了个"缺 ninja"的坑。


grid / block / 线程 — A 档

大白话(从小到大三层):

比方:一个班 = block(256 个学生),全年级 = grid。 每个学生(线程)负责算一道题(一个数据)。

在这个项目里(这三个数字要记住):

为什么是 A面试官问 CUDA 编程,第一个问的就是这个。

照念:「grid、block、线程是三层结构。线程是最小单位,一个线程算一个数据;256 个线程组成一个 block;所有 block 组成 grid。我的 block 取 256,因为 GPU 是 32 个线程一组统一调度的,所以必须取 32 的倍数,否则最后一组会有人闲着。256 是常用的甜点值——太小的话 block 太多、调度开销占比高;太大的话每个计算单元能同时装下的 block 变少,隐藏等待的能力就下降。」


越界判断 if (idx >= n) returnA 档

大白话告诉多出来的那些线程"没你的活,直接下班"。

比方:你有 1000 道题要算,一个班 256 人, 得开 4 个班才够(4×256=1024),但多出来 24 个人。 如果不拦住他们,这 24 个人会去做第 1001 到 1024 题——那些题根本不存在, 他们会去翻别人的桌子(读写不属于自己的内存)。

后果分两种

为什么是 A这是 CUDA 面试的必考题,而且你能答得比"课本答案"更好—— 你能说清后果的两种形态。

照念:「因为 block 的数量是向上取整算的,所以最后一个 block 几乎总会多开出一些线程。比如 1000 个数据、每 block 256 个线程,得开 4 个 block 一共 1024 个线程,多出 24 个。这些线程如果不拦住,就会去读写不属于自己的内存——读越界会拿到垃圾值,结果错但不一定崩;写越界会踩别人的内存,可能崩,也可能在很远的地方引发莫名其妙的错误,后者更危险。所以这一行是正确性的底线,不是优化。」


合并访存(coalesced access) — A 档

大白话让同时干活的那批线程去读内存里挨着的数据,硬件就能一次性搬过来。

比方(务必理解这个比方): 32 个人要去仓库取货。

同样是取 32 件货,后者慢 32 倍。

在这个项目里:你的 kernel 让相邻的线程读相邻的数据, 32 个线程正好读连续的 128 字节,硬件一次搬完。 这就是它在大张量上能达到 76% 峰值带宽的原因。

为什么是 A这是 CUDA 优化的第一原则,必考。

照念:「合并访存的意思是:让同时执行的那 32 个线程去读内存里挨着的数据。因为 GPU 是 32 个线程一组统一调度的,如果它们读的地址是连续的,硬件能把 32 次访问合并成一次内存事务,一次搬 128 字节。如果反过来读的地址是散开的,就得发 32 次独立请求,有效带宽掉到三十二分之一。对我这种受内存带宽限制的 kernel,这是生死差别。」


显存 — A 档

大白话显卡自己的内存。 模型和数据都得先搬进显存,GPU 才能算。

比方:像厨师的操作台。食材得先摆到台面上才能做, 台面太小就摆不下。

在这个项目里

测法很重要:你用的是 PyTorch 自带的统计工具,不是 nvidia-smi。 因为 nvidia-smi 看到的数字里包含几百兆跟模型无关的开销,拿它报"模型占多少显存"是不诚实的。

为什么是 A"优化资源占用"是 JD 点名的职责,而且你的测法本身是个加分项。

照念:「显存是显卡自己的内存,模型得先搬进去才能算。我实测 FP32 是 345 MB,FP16 是 179 MB,几乎正好减半。测的时候我特意用的是 PyTorch 自己的统计接口,不是 nvidia-smi——因为 nvidia-smi 看到的总量里包含几百兆跟模型无关的开销,拿它报模型显存是不诚实的。」


带宽(bandwidth) — A 档

大白话每秒能从显存搬多少数据。

比方:像水管的粗细。水管再粗,水泵抽水速度也有上限。 GPU 算得再快,数据搬不过来也没用。

在这个项目里:3090 的理论上限是 936 GB/s。 你的 kernel 在大张量上达到 713.6 GB/s = 76.2%,说明写得是对的; 但在模型真实的小张量上只有 7.3%,说明根本没跑到带宽上限就结束了

为什么是 A:这是你 kernel 那一段最有信息量的数字。


访存受限 vs 算力受限 — A 档

大白话

比方

在这个项目里:LayerNorm、你写的那个 kernel,都是访存受限的—— 计算量极小,全部时间花在搬数据上。所以融合才有意义(融合省的是搬运)。

为什么是 A:判断一个东西该怎么优化,第一步就是分清它属于哪种。

照念:「优化之前得先分清是算力受限还是访存受限。算力受限是 GPU 拼命算算不过来;访存受限是 GPU 算得飞快但在等数据。LayerNorm 和我写的那个 kernel 都是访存受限的——计算量极小,时间全花在搬数据上。所以对它们来说,融合才有意义,因为融合省的正是搬运次数。」


【第 10-13 步】TensorRT

TensorRT / engine(引擎) — A 档

大白话:NVIDIA 出的推理加速工具。你把模型交给它, 它花几十秒重新规划一遍,生成一个专门为你这块显卡优化过的"成品",这个成品就叫 engine。

比方(这个比方很关键): ONNX 像一份菜谱——写着每一步怎么做,但每次做菜都要现看菜谱、现找锅、现找料。 engine 像一条已经调试好的流水线——工位怎么摆、每步用哪台机器、 半成品放哪里,全都提前定死了。做的时候不用再想,直接跑。

在这个项目里这是你 11.65 倍加速的来源。 代价是:生成要花 15~30 秒,而且engine 绑定具体显卡型号,换张卡得重新生成

为什么是 A:这是整个项目的核心成果。

照念:「TensorRT 是 NVIDIA 的推理加速工具。它拿到我的模型之后会花二三十秒重新规划一遍,生成一个专门为这块显卡优化过的成品,叫 engine。打个比方,ONNX 像一份菜谱,每次做都要现看;engine 像一条已经调试好的流水线,工位怎么摆、用哪台机器全都提前定死了。我的加速从 12.8 毫秒到 1.1 毫秒,11.65 倍,就是这么来的。代价是生成慢,而且 engine 绑显卡型号,换卡得重新生成。」


FP32 / FP16 / INT8 / TF32 — A 档

大白话用多少位来表示一个数字。位数越少,算得越快、占得越少,但越不精确。

比方(务必理解): 像记录一个人的身高。

在这个项目里的实测

精度 延迟 体积 误差
真 FP32 2.280 ms 283 MB 基本没有
TF32 1.859 ms 283 MB 0.00093
FP16 1.104 ms 128 MB 0.0064
INT8 1.123 ms 65 MB 8.39 度

为什么是 A:这是你整个部署工作的核心权衡。

照念:「这几个是数字精度的档位,位数越少算得越快、占得越少,但越不精确。打个比方,像记身高:FP32 是 175.283 厘米,FP16 是 175.3,INT8 就只剩 175 了。我实测下来,FP16 是最好的点——延迟 1.1 毫秒、体积减半,误差还能接受;INT8 体积再减半,但误差大了一个数量级,速度还没赚到。」


TF32 那个坑 — A 档

这是你最想让面试官记住的一条,单独说清楚。

发生了什么:你 build 了一个"FP32"的 engine, 结果它和 PyTorch 的结果差了 0.00093——比你的噪声底大了三百多倍。 FP32 对 FP32,不该差这么多。

原因NVIDIA 从 Ampere 这一代开始,默认偷偷用 TF32 代替 FP32 算矩阵乘这是个默认打开的开关,你不设置它也在生效。

你怎么证明的只改这一个开关,其他全不动。 关掉之后误差从 0.00093 掉到 0.0000010(掉到噪声底以下),代价是慢 24%

照念:「这是我最想讲的一个发现。我 build 了个 FP32 的引擎,结果和 PyTorch 差了 0.00093,比我实测的噪声底大三百多倍。FP32 对 FP32 不该差这么多。我怀疑是 TF32——NVIDIA 从这一代显卡开始,默认就用 TF32 代替 FP32 算矩阵乘,是个默认打开、你不设置它也在生效的开关。我做了个只改这一个开关的对照,关掉之后误差直接掉到噪声底以下,因果就锁死了,代价是慢 24%。这件事的意义不在数字,在于这是一个被默认值藏起来的取舍——如果我没做这个对照,我可能会把这个引擎当成 FP32 基线,再拿它去衡量后面所有的精度损失,那整个基准就是歪的。」


launch 开销(启动开销) — A 档

大白话每次让 GPU 干一件事,CPU 都要先"派任务",这个派任务的动作本身要花时间。

比方(这个比方是你整个性能分析的核心,务必理解): 你有 1000 个小学生(GPU),效率极高。 但只有一个老师(CPU)在发卷子。 老师每发一张卷子要 5 秒,学生做完一张要 6 秒。 发 1150 张卷子,光发卷子就花掉一大半时间——学生大部分时候在等着领卷子。

在这个项目里(关键数字):

你最强的证据FP16 把 GPU 的工作量砍掉了 42%,墙上时间纹丝不动, 空出来的时间全变成了 GPU 空闲。 说明限制根本不在 GPU 算得快不快,在派任务派得快不快。

为什么是 A这是你整个性能分析的最终结论, 也是解释"TensorRT 为什么快"、"手写 kernel 为什么反而变慢"的共同答案。

照念:「launch 开销是指:每次让 GPU 干一件事,CPU 都要先派任务,派任务本身要花时间。我这个模型一次推理要启动 1150 个 kernel,每个 GPU 只干 6 微秒的活,中间还有 5 微秒的空隙。我最强的证据是:我把精度降到 FP16,GPU 的工作量少了 42%,但总时间纹丝不动,空出来的全变成了 GPU 空闲——说明限制不在 GPU 算得快不快,在 CPU 派任务派得快不快。这也解释了 TensorRT 为什么快:它把 1150 个动作融合成几十个,从根上把派任务这件事的次数降下来了。」


warmup(预热) — A 档

大白话正式计时之前先空跑几十次,让系统进入稳定状态。

比方:跑步测成绩之前要热身。冷启动的第一次总是慢的—— 显卡要提频、缓存要装满、程序要加载。

在这个项目里:你所有的延迟测量都是 warmup 50 次之后才开始计时的。

为什么是 A不 warmup 测出来的数字是错的, 面试官问"你怎么测的",这是第一个要说的。

照念:「测延迟之前我都先空跑 50 次预热。因为冷启动的头几次总是慢的——显卡要提频、缓存要装满。不预热直接测,数字是偏大的,不能用。」


中位数 vs 平均数 — A 档

大白话

比方10 个人吃饭,9 个人月薪 1 万,1 个人月薪 1000 万。 平均月薪 100 万——这个数字毫无意义。中位数 1 万,才是真实情况。

在这个项目里:你测延迟连测 300 次,取中位数不取平均, 因为偶尔会有一次被系统调度打断、特别慢,平均数会被它拉偏。

为什么是 A:这是"你怎么测的"的第二个要点。

照念:「我测 300 次取中位数,不取平均数。因为偶尔会有一次被系统调度打断、特别慢,平均数会被这种异常值拉偏,中位数不会。我同时也报 p90,就是排序后第 90% 位置那个值,用来看抖动大不大。」


同步(synchronize) — A 档

大白话CPU 停下来等 GPU 真的干完。

为什么必须有CPU 给 GPU 派完任务就走了,不等结果。 如果你不加同步就计时,你测的是"派任务花了多久",不是"算完花了多久"——数字会小得离谱。

比方:你把衣服丢进洗衣机按下启动,转身就走如果你此时看表说"洗衣服只花了 5 秒"——那是你按按钮的时间,不是洗完的时间。

在这个项目里:每次计时都调用了同步,确保测的是真实耗时。

为什么是 A这是 GPU 计时最经典的错误,你能主动说出来是加分项。

照念:「测 GPU 耗时必须加同步。因为 CPU 给 GPU 派完任务就返回了,不等结果。不加同步的话,你测的是派任务花了多久,不是算完花了多久,数字会小得离谱。就像你把衣服丢进洗衣机按下启动就走,不能说洗衣服只用了 5 秒。」


profile(性能剖析) — A 档

大白话一个测量工具,告诉你时间到底花在哪一步上了。

比方:像给程序做一次体检 + 计时。 它会告诉你"卷积用了 1.4 毫秒、矩阵乘用了 3.8 毫秒", 而不是只告诉你"总共 12.8 毫秒"。

在这个项目里profile 推翻了你两个原本的判断—— 这是你最值钱的素材之一。

为什么是 A:能主动说"我做了 profile 然后被推翻了",含金量极高。

照念:「profile 是性能剖析工具,它告诉你时间具体花在哪一步,而不是只给你一个总数。我用它推翻了自己之前两个判断——我原来按参数量推断 FFN 是耗时大头,实测发现方向是反的。」


FFN vs attention 那个反转 — A 档

这是你最重要的一次"自我证伪",必须能讲清。

你原来的判断:按参数量推——FFN 的参数是 attention 的 3.12 倍,所以 FFN 更耗时。

实测结果FFN 只占 attention 的 0.49 倍。方向完全相反。

为什么错(这段是精华): 因为你用"参数量"代替了"耗时"。 这个模型每次矩阵乘的规模都很小(只有 50 或 82 行), 这么小的计算,时间主要花在"每次开始计算"的固定开销上,不是花在实际算数上。

打个比方:搬 100 箱货。

数字上:attention 有 72 次矩阵乘,FFN 只有 22 次。

照念:「这是我被自己数据打脸的一次。我一开始按参数量推断,FFN 的参数是 attention 的 3.12 倍,所以判断 FFN 是耗时大头。后来我做了 profile,按矩阵形状把两者分开统计,实测 FFN 只占 attention 的 0.49 倍,方向完全相反。为什么错?因为我用参数量代替了耗时。这个模型每次矩阵乘规模都很小,只有 50 或 82 行,这种规模下时间主要花在每次计算的固定开销上,不是花在实际算数上。而 attention 有 72 次矩阵乘,FFN 只有 22 次。我总结成一句:参数量是静态指标,耗时是动态指标,当计算规模小到被固定开销主导时,两者会给出相反的结论。


onnxruntime 静默降级那个坑 — A 档

发生了什么:你让 onnxruntime 用 GPU 跑,它给了你数字,38 毫秒。 但你打印了一行"实际用的是什么",发现它用的是 CPU。

它 GPU 加载失败之后不报错,静默切回 CPU,照样给你结果。

如果你没检查,就会拿 CPU 的 38 毫秒当 GPU 数字报出去。

照念:「这个坑很阴险。我让 onnxruntime 用 GPU 跑,它给了我数字,38 毫秒。但我脚本里打印了一行'你实际用的是哪个后端',发现它返回的是 CPU——它 GPU 加载失败之后是静默降级的,不报错,照样给你结果。如果我没检查,就会把 CPU 的数字当 GPU 数字报出去。修好之后是 3.677 毫秒,差了十倍。」


【第 15、17、18 步】量化

量化(quantization) — A 档

大白话把模型里的数字从"精细"改成"粗糙",换取更小的体积和更快的速度。

比方:像把一张高清照片压缩成 JPEG。 文件小了很多,肉眼看差不多,但细节确实丢了。

在这个项目里

为什么是 A"模型量化"是 JD 点名的职责。

照念:「量化就是把模型里的数字从精细改成粗糙,换体积和速度。好比把高清照片压成 JPEG,文件小很多,看着差不多,但细节确实丢了。我实测下来 INT8 体积能压到四分之一,误差最大 0.69 度还能接受;INT4 压到八分之一,但误差 27 度,模型直接废了。」


per-tensor vs per-channel — A 档

大白话压缩的时候,是"整块用同一个压缩比例",还是"每一部分各用各的"。

比方(这个比方很好用): 一个班要把所有人的身高换算成"相对刻度"。

在这个项目里的实测per-channel 的误差只有 per-tensor 的一半多一点(小 1.8 倍),而体积完全一样。 因为多存的那些"尺子"(scale)只有几千个数,可以忽略。 所以这是白拿的,没有任何理由用 per-tensor。

照念:「per-tensor 是整块共用一个压缩比例,per-channel 是每个通道各用各的。好比全班共用一把尺子,还是每个小组一把——共用的话,为了装下最高的人,刻度得很粗,其他人之间的差别就分不出来了。我实测 per-channel 的误差只有 per-tensor 的一半多,而体积完全一样,因为多存的那些比例值只有几千个数,可以忽略。所以这是白拿的,没理由用 per-tensor。


对称量化 — B 档

大白话:压缩的时候,0 还是对应 0,正负两边范围一样。

比方:像温度计以 0 度为中心,往上往下刻度一样多。 "非对称"就是允许把中心点挪走——比如全是正数的话,把范围整个挪到正半边更划算。

在这个项目里TensorRT 只接受对称量化,这是你踩的一个坑。 而模型权重本来就是围绕 0 分布的,所以对称量化够用。 但激活值经过 ReLU 之后全是非负的,对称量化会浪费掉一半范围—— 这就是"量权重和量激活不是一回事"的具体体现。


fake quant(假量化) vs 真 INT8 — A 档

大白话

比方假量化像试穿——你把衣服套上看看合不合身,但没买。 真 INT8 像真的买回家穿——这时候你才会发现袖子有点紧、洗了会缩水。

在这个项目里(这是重要的诚实点):

为什么差这么多:假量化只压了权重,激活值还是精细的; 真 INT8 连激活值一起压了,多压了一整块。

为什么是 A:这是你项目里一条重要的边界,主动讲出来含金量很高。

照念:「假量化只是模拟——把数字压粗糙再还原,实际计算还是用精细方式,用来估计'如果真压了会掉多少'。真 INT8 是数字真的以 8 位存着、计算也真的用 8 位做。好比假量化是试穿,真 INT8 是买回家真穿。我实测下来两者差了 12 倍——假量化预测 0.69 度,真 INT8 是 8.39 度。因为假量化只压了权重,真 INT8 连中间结果一起压了。所以我一直标注着:假量化的数字不能当作真 INT8 的结论。现在这条边界有了具体倍数。


校准(calibration) — A 档

大白话用一批真实数据跑一遍,观察模型内部每一处的数值范围有多大, 据此决定压缩的时候刻度该怎么定。

比方:还是尺子的比方。你要给一个班配尺子,得先看看这个班最高的人有多高、 最矮的有多矮,才能决定尺子做多长、刻度多细。 校准就是"先看一遍"这个过程。

在这个项目里:你用了 32 帧真实的机器人观测画面做校准。 用随机图像校准是错的——你已经实测过, 随机数据会把量化误差低估 4 倍,因为它激活出来的数值范围和真实数据不一样。

为什么是 A这是 INT8 部署的核心步骤,面试官问 INT8 一定会问校准。

照念:「校准就是拿一批真实数据跑一遍,观察模型内部每处的数值范围,据此决定压缩的刻度怎么定。好比配尺子之前得先看看这个班最高的人多高。我用了 32 帧真实的机器人画面做校准。这里有个我实测的教训:不能用随机数据校准——我对比过,随机输入会把量化误差低估 4 倍,因为它激活出来的数值范围跟真实数据不一样。不过我也得说,32 帧偏少,工业上通常几百到上千帧,这是我这个实验的一个局限。


QDQ / 显式量化 — B 档

大白话把"压缩"和"还原"这两个动作,直接画进模型的流程图里。

比方:原来是"交给工厂,工厂自己看着办压缩"; 现在是"在流程图上明确画出:这一步压缩,那一步还原"。

在这个项目里:TensorRT 11 取消了"你自己看着办"这条路, 只能用 QDQ 这种画进图里的方式。这是你踩的第一个 INT8 坑。


PTQ / QAT — B 档

大白话

比方:PTQ 像衣服做好了再改小;QAT 像一开始就按小尺寸裁。 后者合身,但得重做。

在这个项目里:你做的是 PTQQAT 你没做,因为要重新训练,而这个项目的定位是部署不是训练。

兜底话术:「QAT 我没做,因为它要重新训练一遍,而我这个项目的定位是部署。如果 INT8 是硬需求、而 PTQ 的精度不够,那 QAT 才是正确的下一步。」


FMA(融合乘加) — C 档

大白话把"乘一下再加一下"合并成一个动作,而且中间不做舍入。

比方:算 2.5 × 3.7 + 1.2

结果差一点点,而且合并的那个更准。

在这个项目里:你的 kernel 结果和 PyTorch 差 0.0000038, 查下来 100% 是这个原因——你只加了一个编译选项禁止 FMA,误差直接归零你选择保留 FMA,因为它精度实际上更高。

为什么是 C:机制很细,但你有一个非常干净的对照实验,可以主动讲

可以说的一句:「我的 kernel 和 PyTorch 差了 0.0000038,不是逐位一致。我没有当成'反正很小'放过去,而是去查了。我怀疑是编译器把乘法和加法合并成了一条指令、中间少舍入一次,就加了一个禁止这个合并的编译选项做对照——误差直接归零,因果就锁死了。我最后选择保留这个合并,因为它少舍入一次,精度实际上更高。」


【第 19 步】kernel 接进链路那个负结果

端到端(end-to-end) — A 档

大白话从头到尾整个流程,而不是中间某一段。

比方你把厨房里切菜的环节提速了 3 倍, 但一顿饭从下单到上桌的总时间没变——因为瓶颈在别处。 "切菜快了 3 倍"是局部;"上菜时间没变"是端到端。

在这个项目里:你的 kernel 单独测快 2.81 倍但接进整个模型之后端到端反而慢了 37%。

为什么是 A:这是你最有价值的一次自我证伪。

照念:「端到端就是从头到尾的整个流程。我这个 kernel 单独测是快 2.81 倍的,但接进整个模型之后,端到端反而慢了 37%——因为它虽然省了 GPU 的计算时间,但引入了更多的任务派发次数,而我这个模型的瓶颈恰恰是派发。我等于是在一个'时间由派发次数决定'的系统里,用增加派发次数的方式去省计算时间,方向是反的。


第 3 节 · 你问的「第 12 步在干嘛?四种办法是什么?」

先补一句 engine 和 ONNX 的关系(你问过,这里再说一遍)

PyTorch 模型  →  ONNX(中转站,像 GGUF 但还要再转一道)  →  engine(最终成品)

engine 是 TensorRT 编译出来的最终成品。 它跟 ONNX 的区别,用做菜打比方:

代价:编译要花二三十秒,而且 engine 绑定具体显卡型号,换张卡就得重新编译一次。


第 12 步在干嘛

你猜「是不是可以理解为缓存或者查表」——你猜得基本是对的,方向完全正确。 准确的说法是**"提前算好存起来",行话叫常量折叠**。

事情是这样的

模型里有一个东西叫位置编码

它是干什么的:模型把图片切成 80 小块之后,它本身分不清哪块是左上角、哪块是右下角—— 在它眼里就是 80 块,没有顺序。位置编码就是给每一块贴一个"你在第几号位置"的标签。

关键在于:这个标签只跟"图被切成几行几列"有关,跟图上画的是什么完全无关。

而我的图片尺寸是固定的(永远 320×240),所以切出来永远是 8 行 10 列。

也就是说:这套标签永远是同一套。

但原来的代码每次推理都要重新算一遍。

我做了什么

算一次,存下来,以后直接用。

这就是你说的"缓存"——你的直觉是对的。 只不过它比缓存更彻底一点:缓存是"第一次算完存起来", 这里是"在模型加载的时候就算好,运行时那段计算根本不存在了"。

结果

优化前:756 + 96 = 852 个计算步骤
优化后:756 个
删掉的正好是:算正弦、算余弦、切片、累加 —— 就是算位置标签那一串
而且输出结果一个数都没变(逐位一致)

"删掉的正好是那一串"这件事很重要——它证明我删对了东西, 而不是碰巧少了几个步骤。

顺带:它还修好了一个 bug

这一步其实是被一个 bug 逼出来的。 当时我要做 FP16 版本,一直报错说"某处两路数据类型对不上"。 查下去发现就是位置编码这一串导致的。 我没有去打补丁,而是直接把这一串消掉了——病根没了,病自然就好了。


你问的「四种办法是什么」

那是在第 12 步排查那个 bug 的时候。

报错说"两路数据类型对不上",我第一反应是转换工具的参数没设对。 那个工具有两个开关,两两组合就是四种

开关 A 打开 开关 A 关闭
开关 B 打开 试了,失败 试了,失败
开关 B 关闭 试了,失败 试了,失败

四种全部失败,而且全部失败在同一个位置。

这个结果本身就是信息:既然怎么调参数都失败在同一处, 那就说明问题根本不在参数上——方向排除了。

于是我改去查"那个位置的数据到底从哪来的",才找到真正的原因(位置编码)。

这一段值得讲,因为它体现的是排查方法: 四次尝试全失败不是浪费,它排除了一整个方向。


第 4 节 · 你问的「ORT 是什么?第 13 步是啥?」

ORT 是什么

ORT = ONNX Runtime,是微软做的一个推理引擎

它和 TensorRT 是竞争关系,都是"拿到 ONNX 之后负责把它跑起来"的工具:

谁做的 特点
ONNX Runtime(ORT) 微软 通用,什么硬件都能跑(CPU、N卡、A卡、手机)
TensorRT NVIDIA 专精,只服务 N 卡,但在 N 卡上更快

打个比方: ORT 像一把通用扳手,什么螺丝都能拧,但都不是最顺手。 TensorRT 像一套专用工具,只对付一个牌子的螺丝,但对付那个牌子最快。

在这个项目里:我用 ORT 是为了做对照—— 让面试官(和我自己)知道 TensorRT 到底比通用方案快多少。


第 13 步在干嘛

一句话:把四种跑法排在一起比,看谁快。

因为光说"TensorRT 是 1.1 毫秒"没有意义,得知道它比什么快、快多少。

结果是这样一个阶梯:

怎么跑的 耗时 相对最慢的快几倍
PyTorch 直接跑(最原始) 12.816 毫秒 1 倍
ONNX Runtime 3.677 毫秒 3.5 倍
TensorRT 1.104 毫秒 11.65 倍

两跳的原因不一样,这个要能分开说

TensorRT 具体多做了三件 ORT 不做的事:

  1. 融合更狠——ORT 按固定规则融,TensorRT 是把整张图拿来分析
  2. 实测挑选——TensorRT 编译时会把每一层的多种实现方式各跑一遍计时,挑最快的 (这就是它编译要二三十秒的原因)
  3. 提前定死——内存怎么摆、执行什么顺序,编译时就固定,运行时零决策

第 13 步还踩了一个很阴险的坑

这个坑值得单独讲,因为它是"数字可能骗人"的典型。

我让 ORT 用显卡跑,它给了我一个数字:38 毫秒。

但我在脚本里多打印了一行——问它"你实际用的是显卡还是 CPU"。 它回答:CPU。

也就是说:它加载显卡失败了,但它不报错,默默切回 CPU,照样给你一个数字。

如果我没有多打印那一行,我就会把 CPU 的 38 毫秒当成显卡的成绩报出去。

修好之后是 3.677 毫秒差了十倍。

这件事可以这么讲:「onnxruntime 的 GPU 后端加载失败之后是静默降级到 CPU 的, 不报错,照样给你结果。我是因为脚本里打印了它实际用的是哪个后端才发现的。 如果没检查,我会拿 CPU 的数字当 GPU 数字报出去,差了十倍。」


第 5 节 · 【重点】第 14 步到第 20 步,到底在干什么

你说「第 14~16 步这一轮基本上都没看懂」,「17 和后边基本上全没看懂」。 这一节把这七步从头重讲一遍,不假设你记得前面的任何术语。 这是全文最厚的一节,因为它是你最大的断点。


先给你一张地图:这七步在干什么

前面 1~13 步做完,你已经有了一个能在显卡上飞快跑的模型(1.1 毫秒,快了 11.65 倍)。 主线到这儿其实就结束了。

14~20 步是四条支线,各自回答一个问题:

在回答什么问题 结果
14 我能不能自己手写一段显卡代码? 能,单独测快 2.81 倍
15 把模型数字压小,能压多狠? INT8 能用,INT4 废了
16 我前面那些判断,到底对不对? 两条都错了
17 之前用假数据测的,换真数据会怎样? 误差大了 4 倍
18 真的做一个压缩版的成品,值不值? 不值
19 我手写那段代码接进去,真能加速吗? 不能,反而慢 37%
20 换到地平线的板子上要怎么做? 只做了调研,没有板子

注意 16、18、19 三行——都是负面结果。 你说"从结果看是比较失败的",多半就是看到这三行。第 6 节专门回应这个。


第 14 步:手写一段显卡代码

要解决什么

前面所有加速都是工具(TensorRT)自动做的。 但 JD 里明确写了一条要求:"CUDA 编程开发"——也就是你得会自己写显卡代码。 所以这一步是冲着这条要求去的

具体写了什么

模型里有一个动作重复了 11 次,长这样:

一堆数字  →  【放大】  →  【过滤掉负数】  →  一堆新数字

原本这是三个独立的动作,每个动作都要: 把数据从显存搬出来 → 算一下 → 再搬回显存。 三个动作 = 搬进搬出 6 趟

我写的这段代码把三个动作合并成一个搬出来一次 → 三件事一口气算完 → 搬回去一次从 6 趟变成 2 趟。

(就是你说的那个 x86 复合指令的道理。)

为什么选这个动作,而不是别的

这一点面试官会问,所以要说清楚:不是随便挑的。

我前面拆过模型的参数分布,发现这个叫 FFN 的部分参数最多(是另一个部分的 3.12 倍), 而且重复了 11 次。所以我判断:它应该是最耗时的,优化它收益最大。

注意:这个判断在第 16 步被推翻了。 但当时我是这么想的,讲的时候要按时间顺序讲。

结果

单独测这段代码:快了 2.81 倍

但有个数字更重要,也是我当时就注意到的:

在放大 50 倍的假数据上:跑到了显卡带宽上限的 76.2%   ← 说明代码本身写对了
在模型真实的尺寸上:  只跑到 7.3%                    ← 说明它根本没吃满

7.3% 是什么意思:好比一条能过 100 辆车的路,实际只过了 7 辆。 不是路窄,是车太少——数据量太小,还没跑起来就结束了。

我当时就记下了这个观察,它在第 19 步应验了。


第 15 步:量化 —— 把模型的数字压小

你问过:「量化可以只量化一部分?」答案是可以,而且我实测出这么做更好。

什么是量化

你本地跑大模型的时候肯定见过 Q4、Q5、Q8 这些标记——那就是量化。

原理一样:模型里的每个数字,原本用 32 位存,现在改用 8 位甚至 4 位存。 文件小了,算得快了,但精度掉了。

用你熟悉的说法:Q8 相当于 INT8,Q4 相当于 INT4。

我测了什么

压缩方式 体积 结果
不压(原始) 320 MB
压到 8 位 130 MB 能用
压到 4 位 98 MB 模型直接废了

这跟你跑本地大模型的经验应该对得上——Q4 有时候能用,有时候明显变傻,看模型。 我这个模型属于"4 位直接废掉"那种。

你问的那个问题:能只量一部分吗

能,而且我实测发现"只压一部分"是更好的选择。

我做了个逐层的实验,看哪一层压了之后影响最大。发现:

于是我试了只压 FFN、别的都不动

方案 体积 误差
全压 130 MB 1.0
只压 FFN 179 MB 0.19(只有全压的五分之一)

结论:多花 49 MB,换来误差降到五分之一。这笔账很划算。

这一步我还犯了个错,值得讲

我看到"那个小部分每单位影响最大",就推论说:把它排除掉,误差应该明显下降。

我去验证了,结果只改善了 8%,几乎没动。

为什么错?因为它虽然"每单位影响大",但它总共才占万分之二, 乘起来的总影响其实很小。真正的大头在别的地方。

打个比方:辣椒是"每克影响最大"的调料,但一锅菜里放了 2 克辣椒、500 克盐。 你把辣椒拿掉,这锅菜还是咸的。

教训:'每单位影响'和'总影响'是两个东西,我用错了指标。


第 16 步:回头检查 —— 我前面两个判断都错了

要解决什么

我文档里一直挂着两条**"我这么推测,但没验证"**的判断。 到这一步我去补了验证。结果两条都被推翻了。

什么叫 profile

你问过:「profile 是什么?」

大白话一个测量工具,告诉你时间具体花在哪一步了。

打个比方:你只知道"从家到公司要 1 小时",这没什么用。 profile 告诉你的是:"走到地铁站 10 分钟、等车 15 分钟、坐车 25 分钟、出站走 10 分钟"—— 这样你才知道该优化哪一段。

不做 profile 就优化,等于闭着眼睛猜。

被推翻的第一条

我原来的判断:FFN 那部分参数最多(是另一部分的 3.12 倍),所以它最耗时。 (第 14 步我就是这么选的。)

实测结果它只占另一部分的 0.49 倍。方向完全反了。

为什么错(这个解释很重要):

我拿"东西多"当成了"花时间多"。

打比方:搬 100 箱货。

如果每一趟的准备工作要 5 分钟,那方式 B 慢得多——虽然货总量一样。

我这个模型每次计算的规模都特别小,所以"次数"比"规模"更决定时间。

一句话总结(这句可以直接说)"参数量是静态指标,耗时是动态指标。当计算规模小到被固定开销主导时,这两个指标会给出相反的结论。"

被推翻的第二条

我原来说:"模型慢是因为显卡大部分时间在等着。"

实测:显卡其实忙碌 56.5%,超过一半。这句话是错的,我收回。

但我补做了一个实验,得出了更准确的结论: 我把数字精度降一半(FP16),显卡的工作量少了 42%结果总时间纹丝不动,省下来的全变成了显卡空闲。

这说明什么限制根本不在显卡算得快不快,在给它派任务派得快不快。

打比方(这个比方是理解 17~19 步的钥匙,务必记住):

你有一千个工人(显卡),效率极高。但只有一个工头(CPU)在发任务单。 工头发一张单要 5 秒,工人做完一张要 6 秒。 一次任务要发 1150 张单。

现在你让工人手更快了(降精度),做完一张只要 3 秒。 总时间会缩短吗?不会。因为工头发单的速度没变,工人只是等得更久了。

这就是这个模型的真实情况。


第 17 步:换成真实数据重测 —— 之前的测试都偏乐观

要解决什么

前面所有的数值测试,输入用的都是随机生成的假图片。 因为有个必需的文件丢了(后面解释),没法处理真实数据。

这一步我把那个文件重新算了出来,然后用真实的机器人画面重测了一遍

那个丢了的文件是什么

大白话:一份换算表

模型内部处理的不是"关节转了多少度"这种真实数值, 而是换算过的、方便计算的数值。 就像考试成绩换算成"比平均分高几个标准差"——模型内部用的是换算后的分数

这份换算表丢了,就意味着:

  1. 没法把真实数据喂进模型(不知道怎么换算进去)
  2. 没法把模型的输出翻译回"多少度"(不知道怎么换算出来)

我用原始训练数据重新算了一份(用的是官方仓库里同一份代码,不是自己重写的)。

但这里有个必须说清楚的边界我没法证明重算的和当初训练用的完全一样, 因为原文件已经丢了,没有东西可以对比。所以我在文档里标的是 "重算值,未与原值比对过"。这个诚实标注很重要。

结果一:假数据把误差低估了 4 倍

用随机假图片测:量化误差 1.3
用真实画面测:  量化误差 5.3      ← 大了 4 倍

为什么:随机噪声图和真实画面,在模型内部激活出来的数值范围完全不一样。 用假数据测出来的结论,是系统性偏乐观的。

结果二(这个最有用):终于能把误差翻译成"多少度"了

有了换算表,我能把"误差 5.3"翻译成真实的关节角度误差

压缩方式 最大误差
压到 8 位 0.69 度
压到 4 位 27.45 度

27 度是什么概念:你把手臂抬起来,偏 27 度大概是从"指着杯子"变成"指着旁边的桌子"。 这个数字不需要任何解释,就是完全不能用。

而 0.69 度,肉眼几乎看不出来。

还有个细节值得讲

误差最大的那几个关节,全都是手指,不是手臂。

这有实际意义:手臂偏半度,末端可能只偏几毫米; 但灵巧手的手指偏半度,捏一个小东西的时候可能就直接滑掉了。

所以"0.69 度大不大",取决于它出现在哪个关节上——而它恰好出现在最敏感的地方。


第 18 步:真的做一个压缩版成品

要解决什么

这一步补的是一个结构性的漏洞。

第 15 步做的量化,是模拟的—— 把数字压粗糙再还原回去,实际计算还是用原来的精度。 它只能回答"如果真压了,大概掉多少精度"。

但它跟第 10~13 步做的那个成品(engine)是完全脱节的两件事。 我可以说"我做了量化",也可以说"我做了部署",但中间没接上。

这一步就是把它们真正接起来:做一个真正压缩过的成品

模拟 vs 真做,差在哪

打个比方

实测差距

误差
模拟预测的 0.69 度
真做出来的 8.39 度
差了 12 倍

为什么差这么多:模拟只压了模型的"权重", 真做的时候连中间计算结果也一起压了——多压了一整块。

这个 12 倍是我最想让你记住的数字之一,因为它证明了一件事: 我一直在文档里标注"模拟的数字不能当真实结论"——现在这条边界有了具体的倍数。

这一步撞了四堵墙

(细节看讲解稿,这里只说为什么值得讲)

四次报错每次指向的东西都不一样,最后一次最有意思: 模型的第一层用不了 8 位计算,因为它的输入只有 3 个通道(红绿蓝), 而 8 位计算的硬件指令要求通道数是 4 的倍数。 把第一层排除掉就通了——这也是业界的常规做法。

结果:不划算

速度 体积 误差
不压(FP16) 1.104 毫秒 128 MB
压到 8 位 1.123 毫秒 65 MB 8.39 度

速度没赚到(甚至慢了一点点),误差大了一个数量级,只换来体积减半。

为什么速度没赚到:回到第 16 步那个"工头发任务单"的比方—— 压缩让工人算得更快了,但工头发单的速度没变。瓶颈不在工人。

结论如果你的板子内存紧张,压缩值得考虑;如果你要的是速度,别压。


第 19 步:把第 14 步写的代码真接进去

要解决什么

第 14 步写的那段代码,一直是单独测的,没接进真实模型。 所以我在文档里一直标着一句话:"不能声称它给整体带来了加速。"

这一步就是把这个洞补上

关键:我事先就预测它会失败

这一点非常重要,也是这个实验最值钱的地方。

我在写这个实验的代码时,在文件开头就写下了预测

第 14 步实测过,这段代码在真实尺寸上只跑到带宽的 7.3%,说明它受限于任务派发而不是数据搬运; 第 16 步又证明了整个模型的时间由派发速度决定。 所以在一个"派发受限"的模型里,省掉一点数据搬运,很可能被淹没。

这段话是写在实验之前的,不是事后补的。

那我为什么还要做? 因为不做,我只能说"我猜收益很小"。 做了,我才能说"我实测是 0.729 倍,原因是多了 191 个任务"。

前者是猜,后者是知道。

结果:端到端慢了 37%

原来:13.384 毫秒,1150 个任务,显卡忙 7.192 毫秒
接进去:18.366 毫秒,1344 个任务,显卡忙 7.140 毫秒

三个数字连起来看

  1. 显卡确实少干活了(7.192 → 7.140),但只少了 0.05 毫秒,可以忽略
  2. 任务数量反而多了 191 个(因为压缩本身要额外做求最大值、取整、转换这一串)
  3. 而这个模型的时间由任务数量决定 —— 多 191 个任务,多 4.98 毫秒

一句话

我在一个"时间由任务数量决定"的系统里,用"增加任务数量"的方式去省计算时间。方向是反的。

那这段代码就白写了吗

在 PyTorch 这个环境里确实没用,因为 PyTorch 本身就是"工头发单"这种模式。

它有用的前提是:执行环境已经把"发单"这件事解决掉了。 ——比如 TensorRT,它把 1150 个任务融合成几十个,从根上把发单次数降下来了。

这也正好解释了 TensorRT 为什么能快 11.65 倍。


第 20 步:地平线的板子

这一步很简单:我没有板子。

JD 里要求"在地平线 RDK 开发板上部署",我没有那个硬件,所以只做了纸面调研。

调研里有一个有价值的发现:地平线官方已经开源了一个把这类模型部署到他们板子上的工具。 我读了它的代码,发现它做的几件核心的事,和我自己独立分析出来的结论是一样的—— 比如都是把训练才用的那部分砍掉、都把某个固定的东西提前算好。

我是先做完自己的分析,之后才找到它的,所以这算一个外部印证。

但这一节从头到尾没有一个真机数字,这个必须说在最前面。


第 6 节 · 关于你说的"从结果看是比较失败的"

你说:「说白了,如果单纯从结果来看是比较失败的」。 这句话我得认真回应,因为这个判断会直接影响你面试时的语气, 而语气比内容更容易被感知。

先说结论:这个判断不准确,但你会这么想是正常的

你会这么想,是因为 14 步之后连着三个负面结果, 而这三个恰恰是最难看懂的部分——你自己也说了那几步没看懂。 看不懂的部分连着说"失败",自然会觉得整个项目不行。

但主线其实是成功的,而且成功得很干净:

PyTorch 原始       12.816 毫秒
最终 TensorRT       1.104 毫秒
                   快了 11.65 倍

这个数字是实测的、有完整测法记录的、可复现的。它就是主线结果。

那三个"负面结果"到底是什么性质

我想先说清楚一件事:它们不是"做砸了",是"验证了一个假设,结果假设不成立"。 这两者在工程上完全不是一回事。

分开看:

第 16 步:两个判断被推翻

这不是失败,这是我做了一次检查,发现自己之前想错了。

如果我不做这次检查,那两个错误的判断会一直留在文档里,面试时被问穿。 做了检查,我现在能主动说"我这里想错了,正确的是什么"。

换个角度想:一个项目从头到尾没有任何"我想错了", 要么是这个人只做了很浅的东西,要么是他没检查过。

第 18 步:INT8 不划算

这不是失败,这是一个明确的选型结论。

我现在能回答"这个模型该不该上 INT8"这个问题,而且答案有数据卡内存就上,卡速度就别上。

如果我没做这个实验,这个问题我只能说"应该可以吧"。

第 19 步:手写代码接进去反而变慢

这个是三个里面最有价值的,虽然它看起来最像失败。

原因是:我在做之前就预测它会失败,预测写在代码文件的开头,不是事后补的。 然后它真的失败了,而且失败的原因和我预测的原因完全一致。

这在工程上叫"预测得到了验证"。

打个比方:医生看了片子说"我判断这里有问题",然后做手术打开一看,问题果然在那儿。 手术过程本身可能不顺利,但医生的判断是准的。

而且它还有一个附带的用处: 第 16 步我得出"时间由任务派发决定"这个结论,靠的是"减少显卡工作量,时间不变"这一个证据。 第 19 步是反过来的——"增加任务数量,时间等比例增加"。 同一个结论,两条方向相反的证据。这比只有一条硬得多。

但是——讲的时候语气要克制

这一点很重要,别搞反了。

你不需要(也不应该)把这些说成"这才是最牛的部分"。 那种语气反而会让人怀疑。

正确的讲法是平铺直叙

「我做了这个实验,我事先预测它是负结果,结果确实是负的,原因是……」

就这样说完,不加评价。 让面试官自己去判断这意味着什么。

这份材料最强的地方恰恰是它的克制—— 所有数字都标了怎么测的、所有推断都标了"这是推断没实测"、 所有边界都写在最前面而不是藏在最后。 如果你在讲的时候突然变成"这是我最牛的部分",那个语气跟材料本身是冲突的,反而减分。

一个更准确的自我评价

如果非要给这个项目下一句判断,我建议是这个:

「主线是通的——从 PyTorch 到 TensorRT,11.65 倍,数字都是实测的。 支线上我做了几个验证性的实验,其中有两个结论是负面的:INT8 对这个模型不划算, 我手写的算子接进去反而变慢。这两个负面结论我都留着,因为它们本身就是结论。 另外我有两个判断被自己的 profile 数据推翻了,我也照实记在文档里了。」

这段话说完,面试官会知道三件事:主线成了、你会做验证、你不掩饰。 不需要你自己加一句"所以我很厉害"。

最后一句

这个项目真正的边界不是那三个负面结果,而是这两条

  1. 你没有真实的端侧板子——这是 JD 明确要求的一条经验,你是空的。
  2. 你没有闭环的成功率数据——所以"精度损失能不能接受"这个问题,你只能给数字给不了结论。

这两条才是要主动说的。那三个负面实验,平静地讲完就行。


第 7 节 · A 档速查表(上场前扫一眼)

按讲解稿顺序,这些是必须真懂的

一句话 出现在
权重/参数 模型里 8392 万个可调的数字 第 1 步
形状 张量每个方向有多少数字;差一格全错 第 1 步
卷积 拿小窗口在图上滑一遍提取特征 第 1 步
通道 同一位置叠了几种信息;INT8 那个坑就出在 3 通道 第 1 步
backbone 模型里专门看图的部分,这里是 ResNet18,只占 13.3% 第 1 步
ResNet18 经典卷积网络,18 层,工具链支持最好所以拿它探路 第 1 步
Transformer 让信息互相参考再决策的结构,占 65.6% 第 1 步
token 送进 Transformer 的信息块,这里一共 82 个 第 1 步
attention 每块信息看一眼其他所有信息,决定重点参考谁 第 1 步
encoder/decoder 理解 vs 生成;这模型有两个 encoder 别说混 第 1 步
FFN 每层里的加工车间,放大 6.25 倍再压回来,重复 11 次 第 1 步
LayerNorm 把数字调到标准状态;这模型有 36 个 第 1 步
CVAE 处理"同一情况多种正确做法";砍掉 20.7% 的理由 第 1 步
latent 那个 32 个数的风格编码;推理时填 0 第 1 步
action chunking 一次预测 50 步而不是 1 步 第 1 步
部署 vs 训练 训练可以慢可以假设,部署要快什么都不能假设 第 3 步
GPU/CPU 一千个小学生 vs 一个博士 第 3 步
算子 模型里一个基本计算动作;756 个 第 4 步
算子连起来的流程图;图优化就是改它 第 4 步
ONNX 模型界的 PDF,整条链路的中转站 第 4 步
opset ONNX 的版本号;13 vs 17 差 360 个节点 第 5 步
归一化 换算到统一范围;统计文件丢了是最大限制 第 4 步
弧度 1 弧度 ≈ 57.3 度;所有精度讨论的换算基础 第 17 步
浮点加法不满足结合律 顺序变结果就变;基线必须单线程的原因 第 4 步
噪声底 测量工具自身误差 3e-6;比它小就是分辨不出 第 4 步
算子融合 几步合成一步,省的是搬运 第 5 步
常量折叠 结果永远不变就提前算好;省了 96 个节点 第 12 步
CUDA N 卡编程平台;版本坑的来源 第 6 步
kernel 发给 GPU 的任务卡 第 8 步
grid/block/线程 三层结构;block 取 256 因为要是 32 的倍数 第 14 步
越界判断 多开的线程要拦住;读越界拿垃圾,写越界踩内存 第 14 步
合并访存 相邻线程读相邻数据;不合并带宽掉 32 倍 第 14 步
显存 显卡的内存;用 PyTorch 接口测不用 nvidia-smi 第 13 步
带宽 每秒搬多少数据;大张量 76%,真实形状只有 7.3% 第 14 步
访存受限 vs 算力受限 等数据 vs 算不过来;决定该怎么优化 第 14 步
TensorRT/engine 菜谱 vs 调试好的流水线;11.65 倍的来源 第 10 步
FP32/FP16/INT8/TF32 数字精度档位;FP16 是最优点 第 11 步
TF32 那个坑 默认偷偷降精度;只改一个开关做对照锁死因果 第 11 步
launch 开销 一个老师发 1150 张卷子;整个性能分析的最终结论 第 16 步
warmup 先空跑 50 次;不预热数字是错的 第 13 步
中位数 取中间那个,不被异常值拉偏 第 13 步
同步 CPU 要等 GPU 真干完;不加同步测的是派任务时间 第 13 步
profile 看时间花在哪一步;推翻了我两个判断 第 16 步
FFN vs attention 反转 参数量是静态指标,耗时是动态指标 第 16 步
ORT 静默降级 GPU 失败不报错切 CPU 照样给数字 第 13 步
量化 数字从精细改粗糙换体积和速度 第 15 步
per-channel 每组一把尺子;误差小 1.8 倍而体积不变,白拿 第 15 步
fake quant vs 真 INT8 试穿 vs 买回家穿;实测差 12 倍 第 18 步
校准 用真实数据看数值范围;随机数据会低估 4 倍 第 18 步
端到端 整个流程 vs 局部;局部快 2.81 倍,端到端慢 37% 第 19 步

第 8 节 · 万能兜底话术

背下这三句,任何答不上来的时候用:

  1. 不知道的

「这个我确实没深究过。我能说的是……(说你确定的那部分),更底层的我没有把握,不敢编。」

  1. 是推断不是实测的

「这一条我要说清楚,是我推断的,没有实测。我的依据是……,但我没有做实验去验证它。」

  1. 别人做的不是你做的

「这部分不是我做的——模型结构是比赛官方 benchmark 仓库提供的, 代码我也大量用了 AI 辅助。我做的是训练出权重,以及这次的整条部署链路和所有决策判断。

最后一句提醒面试官不会因为你有不懂的地方扣分,只会因为你把不懂的说成懂的扣分。 这个项目最值钱的部分,恰恰是你能清楚说出"哪些是我做的、哪些不是、哪些我不确定"。