SimLingo Anatomy: When a Language Model Learns to Drive, Part 2
SimLingo 闭环推理实测 0.04–0.065 倍实时(约 0.7–1.25 s/帧),对一个 1B 模型在 RTX 5090 上明显不正常。MySim T1.2b 的做法是离线 profiling:不进 CARLA,用假数据复刻 agent 的模型输入,分段计时。结果(中位数):
| 段 | 耗时 | 占比 |
|---|---|---|
| CPU 图像预处理(jpeg 往返 + 裁剪 + 分块) | 7 ms | ~0.5% |
| 视觉编码(2×448² tiles 过 InternViT) | 17.8 ms | ~1% |
| 贪心文本生成 | 1527 ms | ~93% |
| 驾驶头前向(整序列重算) | ~85 ms | ~5% |
93% 的时间在语言生成。生成慢的直接原因:官方 greedy_sample 没有 kv-cache,每帧中位生成 17 个 token,每个 token 都把 546–660 token 的完整序列重算一遍——每帧 18 次全序列前向。
继续拆解单次前向的 85 ms:实测 CPU 提交算子耗时 82.7 ms,墙钟 82.9 ms——enqueue ≈ wall,意思是 GPU 完成数学的速度和 CPU 提交的速度一样快,GPU 一直在等 CPU。GPU 上的数学本身每次前向只有 ~4 ms。
瓶颈是 Python 侧 dispatch:24 层 × PEFT wrapper × flash-attn varlen unpad,每次前向约 2000+ 次算子调用,逐次过 Python→CUDA 的提交通道。佐证:kv-cache 单 token 步进实测 ~40 ms 且与上下文长度无关(L=8/64/600 都一样)——这 40 ms 是纯固定提交开销。这也解释了为什么”换更大的卡”救不了这个慢:算力从来不是瓶颈,工程路径才是。
profiling 的三条纪律在这段里全体现了:
LoRA(Low-Rank Adaptation,低秩适配)的核心假设:微调一个下游任务所需的权重改动量 是低秩的——它不需要满秩矩阵的自由度。于是冻结预训练权重 ,在旁边学一个小分解:
是秩(SimLingo 取 32), 是缩放系数(取 64), 控制补丁强度。前向变成 ——原权重输出加一条低秩支路。
参数量对比:全量微调一层要更新 个参数,LoRA 只要 。以方阵 、 为例,比例是 ;矩阵越宽省得越多。
约定 初始化为零、 随机高斯初始化——训练起点处 ,模型行为与未微调的底座完全一致,梯度通过 逐epoch把补丁”长”出来。这也是为什么 LoRA 微调不破坏预训练能力:改动从零开始、被限制在秩 的子空间里,底座权重一个数都没动过。
“微调一个任务的改动量远小于重新学语言”这个直觉,正是 SimLingo 单卡可行的依据:冻结 0.9B 底座,只训 17.6M 参数的补丁加新头,batch size 6 时训练步峰值实测 19.6 GiB。但低秩假设也是天花板——如果目标行为需要的改动超出秩 32 子空间(比如底座从没见过 UE5 渲染风格的像素),LoRA 学不到就是学不到,加 epoch 也没用。这是评估”微调能否回收分布偏移”时首先要问的容量问题。
SimLingo 仓库 simlingo_training/config/experiment/simlingo_seed1.yaml 的关键字段:
model:
lr: 3e-5 # 学习率
predict_route_as_wps: True # 20 点路径头
speed_wps_mode: 2d # 10 点速度头
language_model:
variant: 'OpenGVLab/InternVL2-1B'
lora: True
lora_alpha: 64
lora_r: 32
lora_dropout: 0.1
data_module:
batch_size: 6 # 原为 12,单卡适配降到 6
base_dataset:
cut_bottom_quarter: True # 训练图裁底部四分之一
route_as: target_point_command
use_safety_flag: True # 安全标记前缀
LoRA 挂载方式在 llm.py:LoraConfig(target_modules="all-linear")——注意力与 MLP 的全部线性层都挂补丁,只作用于语言塔。
前向里的补丁项带系数 (见 LoRA 原理一篇),这里 。社区经验规律是 取 的两倍,这份配置是教科书式取值。直觉: 与 解耦后,调秩时不用重调学习率——补丁输出的量级被 归一到与 无关。
官方配置按 8 卡设计(gpus: 8),MySim 在单张 RTX 5090(32 GB)上的移植实测三条:
第三条值得单独强调:ckpt 装载日志里的 missing/unexpected 列表永远是第一现场。装载器只打印不报错,缺了键模型照样跑,只是对应层停在随机初始化——MySim T1.3 的 AutoMoT 事故就是先例(ckpt 跨代际改名 transfuser_proj→bev_encoder_proj,64 个 BEV token 全垃圾,18 路线 meanDS 27.5 vs 官方 87.34)。规矩:missing/unexpected 必须当硬错误查,零缺失零多余才算装上。
MySim 微调失败排查(M4)时,训练日志里出现过两个互相矛盾的数字:
后者是真的。17.6M 可以对到账:Qwen2-0.5B 每层 7 个线性层挂秩 32 补丁,约 73 万/层 × 24 层 ≈ 17.6M,与官方 2.72% 完全一致。LoRA 挂载正常。
那 3.27 亿哪来的?PyTorch Lightning 的参数量统计按模块树遍历:PEFT 的 get_peft_model 把原模型包进 PeftModelForCausalLM 包装层,Lightning 的口径把包装模块整组计入”可训练”,底座里被冻结的子树也被算了进去。两种数法差 18 倍,描述的是同一份权重。
这 18 倍的虚惊值一条通用工程课:任何聚合指标,先问它是谁统计的、遍历的是什么集合。参数量、显存、FPS、分数,全都存在两种以上合法口径:
排查时的正确顺序是:找到机制上最接近事实的那个计数器(这里是 PEFT 的 print_trainable_parameters,它直接数 requires_grad 张量),用它当事实源,其余数字先解释口径再采信。把这个顺序反过来——先信聚合面板——就会像本案一样,把一天时间花在排查一个不存在的故障上。
自回归生成的第 步,注意力需要前 个 token 的键(Key)和值(Value)。不带缓存的朴素实现每生成一个 token 都把整个序列重新前向一遍——前面 token 的 K、V 被反复重算,尽管它们的值从不改变。
算总量:序列长 、生成 个 token,无缓存时第 步计算量是 的函数,总计算量
随 平方增长。有缓存时:prefill 阶段一次算出全部 个前缀 token 的 K/V 存进缓存,之后每步只计算新 token 自己的 K/V 追加进缓存,注意力从缓存里读全量——每步计算量与 无关,总量降到 量级,从平方回到线性。
SimLingo 的尺度:序列 ~600 token,每帧中位生成 17 token(长尾帧触顶 100)。无缓存就是每帧 18 次 600 token 全序列前向,这就是 93% 帧耗时的来源。
simlingo_training/models/language_model/llm.py 的 greedy_sample,循环体一眼可见问题:
for i in range(max_new_tokens): # 最多 100 步
features, logits = self.forward(
embeddings=input_embeds, # ← 每次都把整段序列重新前向
attention_mask=attention_mask,
position_ids=position_ids,
)
last_hidden_state = features[:, -1] # 只取最后一个位置
logits = F.linear(last_hidden_state, logit_matrix)
next_token = self.sample_categorical(logits, ...)
x = F.embedding(next_token.unsqueeze(1), input_embed_matrix)
input_embeds = torch.cat([input_embeds, x], dim=1) # 序列变长一位,下一轮全量重算
# ... eos 早停
每轮 forward 对完整 input_embeds 重算,算完只取 [:, -1] 一个位置——前面几百个位置的特征算出来就扔。这不是模型结构问题,纯是推理路径没优化。
理论上有缓存能把生成段提速一个数量级,但 SimLingo 实测每次前向是 Python dispatch-bound(GPU 数学 ~4 ms,提交开销 ~40 ms)。kv-cache 把每步的序列长度降下来,却降不掉每步固定的 ~40 ms 提交开销——所以实测收益是 1.76–1.92 倍而非 25 倍。这解释了为什么 kv-cache 之后还要叠加 LoRA merge(再削每个算子的 wrapper 开销):在 dispatch-bound 的 regime 里,减少每次前向的算子数量和每层的分支数,收益大于减少 FLOPs。
MySim 的 FASTPATH 补丁落在 SimLingo 源码三个文件上,环境变量 SIMLINGO_FASTPATH(默认 1)总控,SIMLINGO_MERGE(默认 1)管第四板斧,两者设 0 即回退官方原路径。
llm.py 的 greedy_sample 重写为 prefill 一次 + 逐 token 缓存解码,返回 (sampled_tokens, input_embeds, past_key_values, pending_embeds)——把缓存显式交还给调用方复用。采样逻辑(temperature/top_k/top_p/eos 早停)与 eos-embedding 追加语义逐行保持不变,原实现原样留作 greedy_sample_legacy,做 A/B 对照组。
这是最巧的一件。官方路径在文本生成完后,把”提示 + 已生成文本 + 30 个 driving query”拼成的新序列整段再前向一遍(~85 ms)。但生成结束时,提示和生成文本的 K/V 已经在缓存里了——driving query 只需作为增量追加进同一份缓存续算。实现细节:pending_embeds(30 个 query 的 embedding)拼在最前,取输出末尾 30 位过驾驶头。这里修掉了 profiling 脚本里的一个 off-by-one 切片错误(把 eos 位置算进了 driving features,导致初报差异虚高一个量级:0.25–0.9 m,修正后实测 0.125 m)。
官方 LLM.forward 走 Qwen2ForCausalLM,每次前向都算全序列 logits: 的词表级矩阵,bf16 下 182 MB/次,每帧 18 次 ≈ 3.3 GB 显存分配抖动。但这些 logits 没有任何消费者——greedy_sample 只取最后位置自己用 F.linear 重算,驾驶头只取 features。补丁改走 backbone(逐层剥 PeftModelForCausalLM→Qwen2ForCausalLM→Qwen2Model 外壳,LoRA 注入在层内不受影响),只取 last_hidden_state。数值验证:features 差 = 0.0,严格 bit 等价。
冻结权重 与补丁 在数学上可预先合并:。评测开始前调用 PEFT 的 merge_and_unload(),LoRA 分支整体从计算图消失,之后每次前向不再付低秩支路的算子开销(实测单次前向 83→~50 ms)。这是纯评测侧优化,训练态不受影响。代价:bf16 权重合并引入舍入差异(实测轨迹最大偏差 1.5 m),不再与官方路径 bit 等价——这正是后面两道 A/B 闸存在的原因。
回退路径就是对照组。没有同权重的官方路径臂,“提速不掉分”这句话永远无法证伪——所有 A/B 结论(离线 2.86×、闭环 ab3、20 路线 DS 闸)都建立在两个开关随时可回退之上。
优化补丁上线前先在离线数值上过一遍(假数据复刻 agent 输入,模型段计时,MySim T1.2 补丁后验证):
| 配置 | 模型段中位耗时 | 相对提速 | 数值偏差 |
|---|---|---|---|
| 官方原路径 | 1555 ms | 1×(基线) | — |
| 只开 kv-cache | 885 ms | 1.76× | 轨迹最大差 0.125 m |
| 只开 LoRA merge | 980 ms | 1.59× | 轨迹最大差 1.5 m |
| 全开 | 543 ms | 2.86× | 轨迹最大差 1.5 m |
文本一致性:8 帧假数据里 6–7 帧生成文本逐 token 一致,个别长生成帧出现数字位翻转(38.3→38.4)。
kv-cache 本身数学上与全量重算等价,但浮点不结合律意味着计算顺序变,结果就变:bf16 只有 8 位尾数,注意力累加的顺序稍有不同(缓存增量 vs 全量重算的 kernel 路径不同),末尾就翻转。LoRA merge 同理: 预先合并成一次矩阵乘,与”两次矩阵乘再相加”在 bf16 下不是同一条舍入路径。
1.5 m 的轨迹偏差看着大,注意两点:一是它出现在随机噪声输入上(假数据的文本分布本就任意,模型输出在垃圾输入上不稳定);二是轨迹经 cumsum 累积,单步差异沿 20 个点放大。闭环里世界会进一步放大或吸收这个偏差——这正是离线闸回答不了的问题。
离线 A/B 的定位是筛掉明显算错,不是证明安全。它回答的是”补丁有没有把模型算坏”(答:没有,偏差在 bf16 噪声量级),回答不了”闭环里行为是否等价”——后者只能进仿真器实测。把离线通过当成上线许可,是性能优化里最常见的自欺。
顺带一个反面教材:profiling 初报曾给出 0.25–0.9 m 的 kv-cache 偏差,排查发现是脚本把 eos 位置错算进 driving features 的 off-by-one——验证脚本本身也是代码,也需要被验证。
离线过了,进 CARLA 闭环。MySim ab3 实验设计:3 条路线(24841 Town01 + 27515 Town03 + 25928 Town11),三条臂——orig(官方原路径)、fast(kv-cache + merge 全开)、kvonly(只开 kv-cache)。判据:每路线 DS 差 ≤2 且无新增违章类型。
| 臂 | 配置 | 总墙钟 | 总游戏时间 | DS |
|---|---|---|---|---|
| orig | 官方原路径 | 2904 s | 132.0 s | 3×100 |
| fast | kv-cache + merge | 2431 s | 281.4 s | 3×100 |
| kvonly | 仅 kv-cache | 3245 s | 179.1 s | 3×100 |
1. 满分没有区分度。 三条臂 DS 全是 100——路线太容易,判据失效。这本身是实验设计课:对照实验的试金石必须足够难,否则”无差异”既可能是真等价,也可能是天花板效应。结论:判据升级,上 20 路线 DS 闸。
2. 行为扰动是真的,方向是混沌的。 fast 臂总游戏时间从 132 s 涨到 281.4 s(翻倍):25928 触发 54 帧蠕动自救(game 18.1→107.0 s),27515 触发 38 帧(91.7→151.6 s)。bf16 级数值差异在闭环里被世界放大成了可观测的行为变化——但方向不定:有的路线变慢,有的反而更快通关。这是混沌扰动,不是系统性变差。
3. kvonly 比 orig 还慢,直接淘汰。 3245 s vs 2904 s。原因指向 dispatch-bound 的本质:kv-cache 削了序列长度,但每个缓存步仍要逐次穿过 PEFT wrapper 的提交开销;merge 恰好削掉这部分(LoRA 分支消失、算子数下降)。两者是互补关系——在提交瓶颈的 regime 里,只降 FLOPs 不降算子数的优化可能净亏。
ab3 满分无区分度之后,终审用 20 条分层抽样路线(12 镇全覆盖,LargeMap 配额偏重,与 220 全量同分布)跑 DS(Driving Score,驾驶分数)对照:
| 臂 | DS | 总墙钟 |
|---|---|---|
| orig(官方原路径) | 92.54 | 22363 s |
| fast(FASTPATH 全开) | 94.39 | 9965 s |
提速 倍,DS 不降反升 1.85 分。闸门放行,220 条全量评测采纳 FASTPATH=1 + MERGE=1。
SimLingo 闭环评测的重跑噪声由少数路线的”卡死↔完成”二元翻转主导(M3 实测 UE4 侧重跑噪声带 12.91 分)——闭环评测的噪声不是高斯的,是行为混沌的。1.85 分的差远在噪声带内,正确读法是”提速不掉分”,而不是”提速能涨分”。用均值差做结论在这个噪声结构下没有统计意义,判断必须落在”差值是否超出重跑噪声带”上。
彩蛋提供了行为混沌的又一实例:25845 号路线 orig 臂车辆物理卡死耗尽 200 s 路线预算(TickRuntime,DS=25.2),fast 臂反而跑完(DS=70.5)——提速在这里救了一条路线。同一补丁,在 ab3 里让两条路线触发蠕动,在 DS 闸里救回一条路线,方向不可预测,均值不变。混沌,不是变差。
采用 FASTPATH 后,任何对外报告必须附带:
这正是控制变量规则的实践:优化的收益按墙钟拿走,优化的代价按口径声明锁住。
算法层优化(FASTPATH,2.24×)到头之后,瓶颈回到系统层:评测墙钟 = 路线数 × 单条墙钟,而单卡单 server 串行时 GPU 大部分时间空闲(agent 推理是 dispatch-bound,server 渲染也没吃满)。MySim T1.2 全量的解法:同一张 RTX 5090 起两台 UE4 CARLA server,220 条路线 town 内轮流发牌对半分——halfA 113 条走 2031 端口实例,halfB 107 条走 2041 端口实例。
效果(220 全量实测):两侧并行净墙钟 ~22.5 小时,路线 system time 合计 34.5 小时;对比单实例串行外推的 55–70 小时,并发叠加 FASTPATH 合计提速 ~2.5–3×。从最初 63 小时的外推到 22.5 小时收官,算法与系统两层各拿了一份。
1. TrafficManager 端口必须错开。 CARLA 的 TM(TrafficManager,背景交通控制器)有独立端口(默认 8000),与 RPC 端口分离。两个实例共用 8000 会串台——一侧的交通指令灌进另一侧的世界。双实例配置:RPC 2031/2041,TM 8000/8010。同机 UE4/UE5 并行时同理。
2. 进程不能按名杀。 两个实例的进程同名(都是 CarlaUE4-Win64-Shipping.exe),watchdog 按进程名杀会团灭两侧(实测误杀过一次,损失 ~40 分钟加在跑路线重跑)。解法是按命令行参数 -carla-rpc-port= 匹配 PID 定点清除。
3. 收官一侧必须立刻杀掉它的 server。 CARLA server 在没有 client 连接时空转满速渲染烧 GPU:实测 halfB 收官后 2041 实例空转把 GPU 吃到 98%,halfA 吞吐从 0.065× 实时掉到 0.020×,最后 4 条路线花了 2.7 小时。空转 server 不是闲置资源,是活跃的拖累。
这段故事的完整顺序值得记住:先 profiling 定位(93% 在生成)→ 算法优化(kv-cache/merge,离线 2.86×)→ 闭环验证(三道闸)→ 系统并发(双实例把路线 system time 合计 34.5 h 压进 22.5 h 净墙钟)。如果反过来先做并发,63 小时只能变 ~40 小时,照样超预算;把 dispatch-bound 的帧耗时压下来之后,GPU 才有余量同时喂饱两台 server。优化的顺序就是依赖链的顺序。