Inside CARLA Architecture: Client, Server, and Every Tick, Part 1
CARLA 客户端与服务端之间不是一条连接,而是两种语义完全不同的通信通道,各占独立端口。
远程过程调用(Remote Procedure Call, RPC)是”一问一答”的同步调用:
client = carla.Client(host, port) # 默认走 RPC 端口
world = client.get_world() # 发请求,阻塞等应答,拿回世界对象
调用远端函数像调本地函数一样:参数序列化、发过去、等结果、反序列化返回。CARLA 的 RPC 用 C++ 的 rpclib 实现,Python 包只是绑定层——这一点在排查挂死时很关键。低频、需要返回值的操作全走这里:get_world、spawn_actor、apply_settings、world.tick()。
传感器数据方向相反:服务端主动推,客户端注册回调被动收。相机每仿真出一帧图像,就把字节流推到客户端注册的回调函数,不需要客户端发问。高频、单向、无需应答的数据全走这条路。
WSL2 访问 Windows 宿主走虚拟交换机,宿主侧 IP 每次 WSL 启动可能变。项目里的标准取法(tools/smoke_carla.py、tools/server_watchdog.py 同一段代码):
host = subprocess.check_output(
"ip route show | awk '/default/{print $3}'", shell=True).decode().strip()
# 读默认路由的网关地址 = Windows 宿主在虚拟交换机上的地址
硬编码 localhost 或写死 IP 都会在换网络、重启后莫名连不上。
client.set_timeout(30.0) 设的是”一次 RPC 调用最多等几秒”,超时抛 RuntimeError。它只管正常工作的连接上的慢应答,管不住连接本身已经死透的情况——那是下半集半开连接的坑。冒烟实测两侧握手 RPC 延迟分别为 43ms(UE5)和 30ms(UE4),毫秒级,不是瓶颈。
CARLA 每台 server 占三个连续端口(RPC / 流 / 备用),TrafficManager 另占 8000。双 server 同机的端口约定:
这个约定是被 winNAT 端口排除段打出来的:Windows 的 NAT 服务会动态保留大段 TCP 端口,最初选的 2000–2002/2010–2012 正好落在保留段 1921–2020 里,server bind 时抛 WSAEACCES(权限拒绝),异常未被捕获,进程直接崩溃(退出码 0xe06d7363)。排查命令:
netsh int ipv4 show excludedportrange tcp # 查看当前所有保留段
更麻烦的是保留段会随大重启漂移(实测从 1921–2020 漂到 2756–3355、1937–2136),所以”启动失败先查 netsh 再查显卡”被写成硬规则。两台 server 的 TM 若都绑默认 8000 会互相串台(A 侧的背景车被 B 侧的 TM 指挥),双实例时 UE4 侧 TM 须挪到 8010。
CARLA 传感器数据的收法是发布-订阅:spawn 传感器后调一次 listen,之后服务端每仿真出一帧就主动调你的回调。来自 tools/smoke_carla.py 的实测代码:
frames = {"n": 0, "gaps": 0, "last_frame": None}
def on_img(img):
frames["n"] += 1
if frames["last_frame"] is not None and (img.frame - frames["last_frame"]) > 1:
frames["gaps"] += img.frame - frames["last_frame"] - 1 # 帧号跳变 = 丢帧
frames["last_frame"] = img.frame
cam.listen(on_img) # 挂上回调,之后数据自己进来
frame 序号。收到第 帧后下一帧应是 ,若跳到 ,说明丢了 帧。冒烟脚本用 gaps 计数,判据是”gaps = 0”(实测 UE5 五分钟 12317 帧、UE4 五分钟 33251 帧,均为零丢帧)。world.tick() 产出的所有传感器数据带同一个帧号。前视相机和 BEV 相机要融合,必须按帧号配对,绝不能按到达顺序——两路流的回调在各自的传输队列里,到达先后没有保证。回调在客户端的后台工作线程里触发。如果在回调里做解码、推理这类重活,处理速度跟不上推送速度,队列会堆积,最终表现为延迟越来越大甚至丢帧。正确姿势是回调里只做”入队”(把 img.frame 和原始数据指针塞进队列),重活交给主循环。另外 img.raw_data 的生命周期只在回调内有效,需要异步使用就得先拷贝。
CARLA 的世界设置里有一个开关决定”时间归谁管”:synchronous_mode。
服务端自由运行:渲染完一帧立刻进入下一帧,仿真时间步长 等于这一帧实际渲染耗时:
GPU 快则步长短,场景复杂则步长长, 逐帧漂移。看演示、拍视频用这个模式没问题,画面流畅。
客户端调一次 world.tick(),世界才精确推进一个固定步长 fixed_delta_seconds(本项目取 0.05 s,即 20 Hz)。客户端不调,世界原地冻结——哪怕冻结十分钟。
关键区别不是”快慢”,是因果顺序:同步模式下客户端对第 帧的感知做决策、下发控制之后,世界才进入第 帧。控制作用的状态和感知看到的状态是同一个,闭环才成立。
CARLA 官方文档也明确建议数据采集与评测使用同步模式加固定步长。
来自 tools/smoke_carla.py 的真实片段:
settings = world.get_settings() # 先取回当前设置对象
settings.synchronous_mode = True # ① 世界改为听 tick 驱动
settings.fixed_delta_seconds = 0.05 # ② 每 tick 固定推进 0.05 s(20 Hz)
world.apply_settings(settings) # ③ 提交,立即生效
三个要点:
get_settings 再改:WorldSettings 是值对象,改副本再整体 apply_settings 提交,不能逐项热改。fixed_delta_seconds 只在同步模式下生效:异步模式下这个值被忽略(异步用变步长);反过来同步模式下若不设它,行为是未定义步长,必须显式给。设完之后,整个世界——渲染、物理、传感器产出——全部冻结,只有 world.tick() 的 RPC 到达才推进一帧。这也是为什么同步模式下必须另起一个循环持续踩 tick,否则看起来就是”仿真卡死了”,其实是世界在听话地等你。
注意 CARLA 0.10 有个坑(坑录 M3 §3-3):客户端断开时 0.10 会把同步设置回滚成异步,所以每条路线开始前都要重新 apply_settings 一次。
tools/smoke_carla.py 里的同步跟随率测试:
t_sync = time.time() # 墙钟起点
for _ in range(200):
world.tick() # 每调一次,世界精确走 0.05 s
sync_elapsed = time.time() - t_sync
report["sync"] = {
"ticks": 200,
"elapsed_s": round(sync_elapsed, 2),
"achieved_fps": round(200 / sync_elapsed, 1), # 实测步频
"target_fps": round(1.0 / 0.05, 1), # 目标步频 20 Hz
}
同步模式下 world.tick() 是阻塞 RPC:服务端做完物理、交通、渲染一整帧才返回。所以连踩 200 次的墙钟总耗时除以 200,就是服务端单步的真实耗时上限——渲染最贵,这个数基本是渲染管线的速度。
它回答”同步仿真的吞吐够不够”:目标 20 步/秒,实测若只有 5 步/秒,说明连实时都追不上,挂模型后预算要重估。它也是一切墙钟预算的地基:
其中 是模型单帧推理耗时(纯 server 冒烟时 )。200 步取均值是为了抹平首帧加载、单帧场景复杂度波动——用单次 tick 计时噪声太大。
一个小细节:这个循环里客户端除了踩 tick 什么都不干,所以测的是 server 单边能力。挂上模型后每 tick 还要加推理时间,实测步频会大幅下降。
同一台 RTX 5090、同地图 Town10HD_Opt、同 Epic 画质 1280×720、同 RGB 相机,200 tick 冒烟实测(logs/smoke-ue5.json / logs/smoke-ue4.json):
| 侧 | 200 tick 耗时 | 同步步频 | RGB 流(5 分钟) | 丢帧 |
|---|---|---|---|---|
| CARLA 0.10(UE5) | 4.73 s | 42.3 FPS | 12317 帧(~40.4 FPS) | 0 |
| CARLA 0.9.15(UE4) | 2.05 s | 97.6 FPS | 33251 帧(~110 FPS) | 0 |
差距来自渲染管线,不是故障。CARLA 0.10 迁移到 Unreal Engine 5 后启用了两套新系统:
这两套系统正是本项目要研究的”渲染保真度”本体——画面更接近真实,代价就是每帧渲染耗时翻倍。所以这个 42.3 vs 97.6 的差异本身就是实验的自变量之一,被登记为对比实验的 FPS 基线(坑录 T0.6)。
RGB 流的帧号 gap 计数两侧均为 0,说明 720p 下传感器流吞吐在两代引擎上都够用,丢帧不会成为对比实验的混淆变量。如果某一侧丢帧,“UE5 模型表现差”就可能是数据管线问题而非渲染问题——先排除这一层,后面的归因才干净。
TrafficManager(交通管理器,TM)是 CARLA 服务端里管背景交通的子系统:所有 autopilot 车辆的跟车、变道、红绿灯响应都由它逐帧计算。它是独立于世界设置的第二个时钟源——世界同步了,TM 没同步,背景车照样按自己的节拍走。
世界冻结等 tick(),TM 却按墙钟自由下指令:背景车在”世界静止”期间被反复施加控制,恢复推进时一步跨出老远,甚至瞬移、互撞。从评测数据看就是背景车行为完全错乱,而英雄车(你的模型)一切正常——极具迷惑性。
tools/smoke_carla.py 对 TM 端口做候选探测,拿到就开同步:
for cand in [8000, args.port + 2, args.port + 8000]: # TM 端口没有硬性约定,逐个试
try:
tm = client.get_trafficmanager(cand)
tm.set_synchronous_mode(True) # 关键一行:TM 也听 tick
break
except Exception:
continue
候选列表来自踩坑史:0.9 官方默认 8000;多实例约定是 RPC 端口 +2;port + 8000 是早期误算,留作兜底。这种”探测而非假设”的写法是这台双仿真器环境的常态。
同步是一份三方合同:世界、你的主循环、TM,缺一不可。另外 TM 有独立端口(默认 8000),两台 server 同机时若都占 8000,A 侧车辆会收到 B 侧 TM 的指令(“串台”),双实例部署必须把一侧 TM 挪开(项目里 UE4 侧挪到 8010)。
SimLingo 是视觉语言模型(VLM)逐帧生成,实测单帧推理 s(坑录 T1.1)。异步模式下服务端自由狂奔,UE5 侧 42.3 FPS、UE4 侧 97.6 FPS。一次推理期间世界跑过的帧数:
每帧 0.05 s 游戏时间,相当于模型基于 秒前的画面做决策,然后把方向盘指令发给一个早已时过境迁的状态——闭眼开了一秒半的车,再睁眼重新看。60 km/h 下 1.5 秒是 25 米,足够从”前车刹车”开到追尾。
同步模式下世界在第 帧冻结,等模型看完、想完、控制下发完毕,才进入第 帧。于是:
这正是”渲染保真度对模型的影响”这个研究命题对变量控制的要求:推理速度是项目内部实现细节,绝不能漏进实验结果。
模型推理越快,同步与异步的差异越小;推理趋近零时两者收敛。所以”必须用同步”的强度与模型延迟成正比——autopilot 冒烟可以用异步凑合,VLM 评测绝对不行。
同步模式把仿真时间和真实时间解耦成两个独立的钟:
表示仿真比现实快, 表示比现实慢。本项目实测三档:
| 配置 | 实测 | 来源 |
|---|---|---|
| UE5 server 空转(42.3 FPS / 20 Hz) | 2.1× | smoke-ue5.json |
| UE4 server 空转(97.6 FPS / 20 Hz) | 4.9× | smoke-ue4.json |
| + SimLingo 闭环推理 | 0.04–0.065× | 坑录 T1.1 |
第三档的构成:每 tick 墙钟 ≈ server 一步(1/42.3 ≈ 24 ms)+ VLM 推理(0.7–1 s),推理占绝对主导,所以两侧 RTF 几乎一样——挂上慢模型后,UE4/UE5 的 server 速度差被摊平到看不见。
反解定义式:。冒烟实测三路线游戏时间 93 s,墙钟 1758 s,比值 1758/93 ≈ 19×(RTF ≈ 0.053,落在 0.04–0.065 区间内)。
这个倍率是全部长跑评测的预算单位:规划 220 条路线连跑时,直接把”路线游戏时长 × 19”外推墙钟,实测验证了这个口径(坑录 T1.1 明确指出早期文档按乐观假设低估了 8–18h 的量级)。预算估错的代价不是晚点出结果,而是排队、看门狗、断点续跑整套机制都要按错误的时长设计。