Reproducing the UE4 Baseline: Matching Official Scores, Part 2
AutoMoT 随仓库自带一棵 vendored(内嵌)的 leaderboard/scenario_runner 树,并在 agent 启动时把它插到 sys.path 最前面:
# agent 启动时的典型操作
sys.path.insert(0, os.path.join(automot_root, "leaderboard"))
如果再从 Bench2Drive 那套树 import,进程里就出现两份”同名不同源”的类:
from leaderboard.autoagents.autonomous_agent import AutonomousAgent # 谁家的?
Python 的模块缓存以模块名为键(sys.modules),先加载者赢。两份树里同名模块只有一个能生效,而类身份(class identity)绑定在具体模块对象上——isinstance 跨树判定直接失败,补丁打在哪棵树上全凭 import 顺序。排障时连”现在跑的是哪份代码”都要先猜,这是不可接受的。
评测全走 AutoMoT 的 vendored 树,只把 SimLingo 侧实战验证过的两个补丁移植过去。可比性由另一层保证:两仓的 220 条路线 XML 做过 diff,逐字节一致——评分语义同源,换树换的只是”裁判的程序实现”,不是”考卷和判分规则”。
这条决策的通用形式:agent 与其评测器是配套的,跨项目复用 agent 时必须连它的评测约定一起端走。leaderboard 系 agent 依赖大量 evaluator 隐式约定(构造参数、路径前缀、启动顺序),脱离原配 evaluator 复用时这些约定全是暗坑。
移植到 AutoMoT vendored 树的两个补丁,都是 SimLingo 侧实战过的同型问题。
pkg_resources 是 setuptools 自带的包元数据接口,缓慢且副作用大(import 时扫描全环境)。setuptools ≥81 已将其删除——新装的 conda 环境里 import pkg_resources 直接 ModuleNotFoundError,而 AutoMoT 的 vendored 树里仍有多处旧式调用。
标准迁移:
# 旧(setuptools>=81 已删)
import pkg_resources
ver = pkg_resources.get_distribution("carla").version
# 新(Python 3.8+ 标准库)
from importlib.metadata import version
ver = version("carla")
同类事故在 SimLingo 侧已经踩过一次(wandb 0.16.3 import 需要 pkg_resources,当时用钉 setuptools==80.9.0 解决)。两种解法对应两种约束:能改代码就迁移接口,不能改代码(第三方包内部)就钉版本。
与 SimLingo 侧完全同构:B2D_EXTERNAL_SERVER=1 时跳过 evaluator 自起 server 的逻辑(它找的是 Linux 路径 CarlaUE4.sh),直连 Windows 宿主上已运行的 2031 端口;官方原逻辑保留在 else 分支一字未删。开关化、单点分支、官方路径可回退——同一补丁模式在两个模型栈上复用,是把它做成环境变量开关而非硬改的直接回报。
直觉判断”5.6B 双专家模型一定比 1B 的 VLM 慢”,被冒烟数据直接推翻:
| 模型 | 规模 | 每帧推理 |
|---|---|---|
| SimLingo(FASTPATH 后) | InternVL2-1B | 模型段 ~543 ms |
| AutoMoT | Qwen3-VL 4B+1.6B 双专家 | 0.32–0.43 s |
3 条路线、2256 帧、22.7 分钟、零重启一次通过。
SimLingo 的慢不在规模,在生成方式。它是逐帧自回归生成驾驶决策文本,官方路径没有 kv-cache,每帧重算全部前缀注意力——profiling 显示生成占帧耗时 93%。1B 模型的长生成比大模型的短前向贵得多。
AutoMoT 的双专家是异步分工,不是串联堆叠。理解专家(4B)低频运行消化场景,动作专家(1.6B)高频输出控制——逐帧摊到的算力接近动作专家那一侧,而不是 5.6B 全量。这属于 MoT(Mixture-of-Transformers,按模态/职能拆专家)架构的典型收益:总参数量换容量,激活参数量定延迟。
所以 T1.1 那条”3 条路线 game 93s → wall 1758s(~19×)“的墙钟教训是 SimLingo 原路径的特产,不可外推到任何”VLM 模型”。墙钟外推必须用目标模型自己的冒烟数据,这是用 63 小时教训换来的规矩。
附带一条资源约束:AutoMoT 单实例峰值显存 19.5 GB(UE4 server ~5.7 GB + 模型与推理 ~13.8 GB),双实例 39 GB 超 32 GB 显卡上限,只能单实例串行,220 全量外推约 30 小时。砍精度换小 ckpt 会改变被测对象——评测研究里这不是可选项。
56 条里挂的两条与一条”看着吓人”的注记,恰好演示了 leaderboard 计分口径的边界。
| 路线 | 终止状态 | DS |
|---|---|---|
| 2091 | TickRuntime(路线预算耗尽) | 24.8 |
| 23930 | AgentBlocked(被判堵死终止) | 29.3 |
两种终止都会截断里程——RC(路线完成度)先掉下来,罚分再乘上去,DS 自然低。这与 SimLingo 侧 13 条失败是同族问题:闭环评测里”没跑完”比”跑得差”代价大得多。
AutoMoT 平均每条约 186 次 MinSpeedTest FAILURE。先回顾 DS 结构:
只覆盖碰撞类(行人 0.50 / 车 0.60 / 静态 0.65)、红灯(0.30)、停车标志(0.20)等。低速违章(min speed)不在罚分乘积里——它只进违章记录,不进 DS。
为什么要声明:186 次/条的数字若不解释,读者会误以为模型在”大量违规”。实际它是模型固有的谨慎驾驶特征(修复前后一致、双锚带内):遇到不确定场景倾向于慢速。口径声明的价值就在于——记录里的每一条 FAILURE 都要先问”它进不进分数”,否则会把诊断信号误读成质量信号。
这也是基准设计的一个细节:低速不进罚分,是为了不让”谨慎”被直接惩罚;但它会间接通过 TickRuntime(开太慢耗尽预算)体现——2091 那条很可能就是这种间接惩罚的实例。
三起事故同一模式:某条路线撞上 CARLA 的 600 秒 tick 超时(同步模式下 server 等不到 client 的 tick 或反之),evaluator 进入清理段(cleanup:销毁 actor、关闭传感器、写结果),然后偶发地永远卡住。
卡死点在 futex_wait。futex(fast userspace mutex,快速用户态互斥锁)是 Linux 线程同步的底层机制:锁无竞争时纯用户态完成,有竞争时才陷入内核等待。进程卡在 futex_wait 意味着某个线程在等一把永远不会被释放的锁——典型成因:
关键性质:进程不退出、不报错、不写日志,就那么挂着。ps 看它活得好好的,CPU 占用归零。
harness 的自愈循环触发条件是”evaluator 进程退出,检查 rc”。死锁进程永远不退出,自愈就永远等下去——深夜无人值守时,一次白停几小时,直到人来发现。
这是监控设计里的经典盲区:用”进程是否存在”推断”任务是否在推进”,对崩溃有效,对挂起无效。覆盖挂起需要活性(liveness)信号:观察任务的可观测输出是否仍在增长(日志写入、结果文件更新),而非进程表里有没有那一行。
guardian 脚本只盯一件事:eval 日志的修改时间(mtime)。
LOG=eval_attempt.log
STALL_SECS=1500 # 25 分钟
while true; do
now=$(date +%s)
mtime=$(stat -c %Y "$LOG")
if (( now - mtime > STALL_SECS )); then
# 判定卡死:杀掉全部匹配的评测进程,
# 进程退出会触发 harness 自愈续跑
kill_eval_tree
fi
sleep 60
done
闭环评测的正常行为是持续产出:evaluator 活着,日志就逐路线逐帧刷新。于是”日志还在写”与”任务在推进”强相关。反过来,死锁在 futex_wait 的进程、卡在无超时 RPC 上的进程,共同症状都是输出停更。
mtime 作为心跳的三个优点:
stat 系统调用,不进文件内容阈值 25 分钟的定法:正常单条路线内日志间隔最远不过分钟级,25 分钟无写入远超任何正常停顿(大地图加载、模型冷启动),又短于一次白停的小时级损失。这是”宁可误杀不可漏判”与”误杀代价是一次重跑”之间的权衡——断点续跑机制让误杀足够便宜,阈值才敢往激进里设。
| 机制 | 覆盖的失败 |
|---|---|
| harness 看退出码 | 崩溃(进程死亡) |
| guardian 看日志心跳 | 挂起(进程活着但停了) |
两类失败模式正交,监控也必须双通道。这就是”进程活着但停了,也要能被发现”的工程实现。
guardian 判定卡死后要杀评测链,“杀”这一步本身踩了两个坑。
head -1 只杀了包装评测进程的启动链是多层的:
run_eval_ue4.sh (外层 bash)
└─ conda run -n mysim-simlingo python ... (conda 包装进程)
└─ leaderboard_evaluator.py (真 evaluator)
pgrep -f leaderboard_evaluator 按命令行匹配,conda run 包装进程的命令行里也含这个字符串,而且排在前面。pgrep ... | head -1 | xargs kill 的结果是:包装死了,真 evaluator 漏网;更糟的是 tee 管道因为一端没关而不退出,harness 卡在管道上,整个自愈链反而被”解药”卡死。
正确做法:杀全部匹配,按从外到内的顺序——外层 bash → conda 包装 → 真 evaluator。漏杀外层 bash 的另一个后果更隐蔽:外层循环发现 evaluator”死了”会立刻重启一个新的,新旧两代 evaluator 抢同一个 server,污染后续所有成绩。
pgrep -f 的模式会匹配发起 pgrep/kill 的那个 shell 自身——如果你的复合命令行里含有同样的关键字(比如 kill $(pgrep -f leaderboard_evaluator) 整条作为某 shell 的参数),它会把自己列入匹配清单,顺手自杀。
纪律因此写成两步:
pgrep -af leaderboard_evaluator # -a 显示完整命令行,人工确认清单
# 确认无误、排除自身后,再逐个 PID 精确杀
kill <pid1> <pid2> ...
自动化脚本里则按精确匹配字段杀(如按 -carla-rpc-port= 区分双实例),而不是模糊关键字。原则一句话:kill 是发射后不管的,匹配清单必须在发射前人工(或显式排除逻辑)审一遍。
事故四的现场:双实例评测,halfB(端口 2041)先收官,进程活着但再没有 client 连接;隔壁 halfA 还在跑,吞吐却从 0.065× 掉到 0.020× 实时——102 到 106 条路线花了 2.7 小时。
CARLA 的同步模式(synchronous mode)语义是:server 每推进一帧,等待 client 的 world.tick()。这个等待只在有 client 连接时生效。没有 client 的 server 不受任何节流派生约束,进入自由渲染循环——满速渲染当前地图,GPU 利用率 98%,什么有价值的事都不做。
双实例共享一张 5090,空转 server 把渲染管线占满,在跑的一侧每帧排队等 GPU,吞吐掉到三分之一。空转几小时,代价直接打在墙钟上。
通常对”闲置进程”的直觉是”占点内存而已”。GPU 服务器不同:渲染型进程的闲置不是静止,是全速空转——它不等待、不休眠,风扇拉满。检测上也有迷惑性:nvidia-smi 里 GPU 98% 利用率看着像”评测跑得很欢”,不分辨进程归属根本看不出一半算力在烧空气。
一侧收官,其 server 立即收编(杀掉或显式停止)——写进坑录,并做成 watchdog 的 kill_side 操作。更一般的形式:共享稀缺加速器上的每个常驻进程,都要有”任务结束即回收”的显式路径,不能依赖”反正它闲着”。
五起事故里损失最大的一起,是救火工具自己放的火。
巡检脚本要收编一台 server,调 watchdog 的 kill_side。链条上的每一环单独看都”合理”:
python3(系统解释器),不是 conda run -n mysim-simlingokill_side 先做探活:server_alive() 内部 import carla——裸环境没装这个包ImportError 被一层 try/except 吞掉,探活函数返回”死”CarlaUE4-Win64-Shipping)损失:约 40 分钟 + 在跑路线重跑。
错误一:环境隐式依赖。脚本能跑依赖”当前环境恰好装了 carla 包”,而这一点没有任何显式保证。修复:conda run -n mysim-simlingo 写进一切 watchdog 调用——环境、工作目录、端口,每个调用显式带。
错误二:吞异常。except ImportError: return False 把”我没法探活”和”探活了,它死了”压成同一个返回值。这两个语义天差地别:前者是测量失败,后者是测量结果。正确设计是让探活失败抛出或返回三态(活/死/未知),兜底分支只在”确认死”时才允许开火。
错误三:兜底逻辑 fail-open。探活不通时的默认动作居然是”杀”——宁可错杀不肯放过。对不可逆操作,默认分支必须是 fail-closed(保守、不动作、报警让人来)。
这条教训的通用形式:探活的”死”必须是确证,不是”没探到活”。监控链条上任何一环的测量失败,都不能被解释成被监控对象的死亡。
三次 600 秒级的 server 停顿,时间点都接近远程桌面(RDP)断连。嫌疑成立但证据不足——立案不定罪。这个案子值得讲,因为它涉及 Windows 图形栈的两个真机制。
RDP 会话建立/断开时,Windows 会切换显示驱动栈(本地 WDDM 驱动 ↔ RDP 间接显示驱动)。正在运行的 Direct3D 应用可能收到 DXGI_ERROR_DEVICE_REMOVED——即 D3D 设备丢失(device lost)。UE 引擎对 device lost 的处理是尝试重建设备,期间渲染线程阻塞;对同步模式下的 CARLA server,外在表现就是 tick 响应停顿几十秒到数百秒,撞上 600 秒 tick 超时就触发清理段。
三次超时(07:31 / 11:20 / 11:16)与 RDP 断连时间接近,但没有设备丢失的直接日志证据(Shipping 版不写 CarlaUE4.log),所以只记为嫌疑。已验证的确定事实有两条:断连后 server 存活(CP0 实测),以及评测期间不保持 RDP 连接成为操作纪律。
另一个常被混淆的点:RDP 里看到的 CARLA 窗口撕裂、花屏,对评测数据零影响。CARLA 的传感器(相机等)是离屏渲染(off-screen rendering)——渲染目标是 GPU 纹理(render target texture),不经窗口合成器(DWM),更不经过 RDP 的显示传输。窗口画面只是渲染结果的另一个拷贝,传感器数据从显存纹理直接读出,两不相干。
所以”远程看着画面破了”和”数据脏了”之间没有因果通道——判断数据有效性要看丢帧计数和 tick 对齐,不是看窗口。