在数据回放或在线推理里同时看到模态错位和动作延迟时,先不要猜原因。把时间基准统一,然后把输入对齐和输出时序当成两个独立检查点分别过一遍:输入错位会让后续所有时序对比失去意义,输出延迟则可能来自推理耗时、调度间隔,或动作与状态的配对方式。建议的判断顺序是先确认每个模态的时间戳来自同一时刻,再确认采集频率与模型期望频率一致,最后才看动作输出相对真实状态落后多少。
适用场景是数据回放和在线推理都出现异常、判断不出该先查输入还是先查输出时。操作顺序是先统一时间基准并给每个模态打时间戳,再核对图像采集频率与模型期望频率,最后在推理出口记录动作预测时间戳和耗时,用滑动窗口与真实状态对齐。验证方式是看同一批次内各模态时间戳极差是否小于一个控制周期、动作相对状态是否稳定滞后。风险边界:日志会拖慢推理循环,建议抽样打印;时钟源不统一时先对齐时钟,再谈延迟。
在数据加载器中打印每个模态的时间戳
数据加载器是少数能同时看到图像、语言、本体状态三个来源的地方,时间戳打印放在 __getitem__ 或 batch 组装函数里最省事。下面是与具体框架无关的骨架,字段名换成自己数据里的即可。
# dataset.py —— 通用骨架,按自己的 Dataset / collate_fn 改写
TS_FIELDS = {'image': 'image_ts', 'lang': 'lang_ts', 'state': 'state_ts'}
def __getitem__(self, idx):
raw = self.records[idx]
ts = {k: raw.get(v) for k, v in TS_FIELDS.items()}
if idx % self.log_every == 0: # 抽样打印,避免拖慢加载
valid = [v for v in ts.values() if v is not None]
span = max(valid) - min(valid)
print(f'[align] idx={idx} ts={ts} span={span:.3f}s')
return {...}
对比方法就是看输出里的 span,即同一帧内最早与最晚时间戳的差。语言模态通常没有逐帧时间戳,样本级标注时间不要硬塞进帧对齐里,把 lang_ts 置空并在统计中排除更稳妥。span 超过一个控制周期(或图像帧间隔的一半),基本可以判定模态错位,先查采集端是否异步写入,再查回放时是按数组下标还是按时间戳拼接——按下标拼接是错位最常见的来源。
检查图像的采集频率与模型期望频率是否一致
采集频率优先从配置或数据集元信息里读,常见字段是 camera.fps、dataset.meta.fps 之类;配置里没有时,用相邻时间戳差分的中位数取倒数来估计,比取均值稳,能少受跳变帧干扰。
import numpy as np
d = np.diff(np.array(sorted(img_ts)))
fps_est = 1.0 / np.median(d)
print('采集频率估计', fps_est, '模型期望', cfg.model.expected_fps)
# 重采样:离线建索引映射,在线只查表
idx_map = np.round(np.arange(0, n_img, fps_est / cfg.model.expected_fps)).astype(int)
idx_map = np.clip(idx_map, 0, n_img - 1)
两者不是整数倍关系时,重采样建议放在离线阶段一次做完,在线只做查表,避免每帧插值带来的不确定耗时。最近邻取帧会引入时间抖动,线性插值则要求同一相机连续帧可用。频率不一致的典型表现是隐性丢帧:回放时画面看着连续,但动作在某些时刻突然跳变,而输入日志里的 span 又正常。
在推理输出端记录动作预测的时间戳与推理耗时
推理端要记录两样东西:状态采集时刻和单步耗时。前者用于算端到端延迟,后者用于区分是推理慢还是排队慢。
import time, logging
log = logging.getLogger('policy')
def infer_step(policy, obs, state):
t_state = state['timestamp'] # 状态采集时刻,需与数据侧同一时钟
t0 = time.perf_counter()
action = policy.act(obs)
t1 = time.perf_counter()
log.info('infer t_state=%.3f t_start=%.3f latency=%.1fms',
t_state, t0, (t1 - t0) * 1e3)
return action, t1
端到端延迟等于动作下发或执行的时刻减去 t_state。注意 perf_counter 是进程内单调时钟,不能直接和采集端时间戳相减;要么让采集端也用同一时钟源打点,要么分别记录「t_start 减 t_state」的发布链路延迟与推理耗时再相加。延迟是否可接受需要结合控制周期判断:延迟接近或超过一个控制周期,动作就总是作用在过期的状态上。
对比动作序列与真实状态序列的时序对齐
把动作和状态按时间戳配对,用滑动窗口逐条对齐,比整体画图更快定位问题。
def pair_by_ts(actions, states, tol=0.02):
# actions/states 均为按时间升序的 [(ts, value), ...]
out, j = [], 0
for ts, a in actions:
while j + 1 < len(states) and abs(states[j+1][0]-ts) <= abs(states[j][0]-ts):
j += 1
if abs(states[j][0] - ts) <= tol:
out.append((ts, a, states[j][1]))
return out
滞后判断看两点:动作曲线相对状态曲线的偏移方向是否稳定、偏移量是否小于一个控制周期。偏移固定且可解释,说明是恒定延迟,可以在下发端做时间补偿;偏移忽大忽小,说明是队列积压或线程调度抖动,补偿没有意义,需要查消费者队列长度和线程优先级。若配对成功率本身就低,先回到第一节查时间戳来源,而不是继续调延迟。
把输入和输出检查项合并成一份运行时自检清单
清单按输入侧到输出侧排列,每次实验前顺序过一遍,不要跳着查,否则容易在错位数据上讨论延迟。
| 检查项 | 预期结果 | 异常处理 |
|---|---|---|
| 各模态时间戳极差 span | 小于一个控制周期 | 查采集端写入顺序与回放拼接逻辑 |
| 采集频率与模型期望频率 | 整数倍或已完成离线重采样 | 重建索引映射,不在线插值 |
| 单步推理耗时 | 明显小于控制周期,留有余量 | 降输入分辨率或降控制频率 |
| 动作下发时刻减状态采集时刻 | 小于一个控制周期 | 查队列长度、线程调度、序列化开销 |
| 动作与状态互相关偏移 | 稳定且能解释 | 抖动查积压,固定做补偿 |
| 日志打印本身的开销 | 抽样打印与全量打印的耗时接近 | 改环形缓冲,事后落盘 |
清单里每一项都能通过配置、日志、命令或页面行为直接验证,不需要额外推断。最后提醒一点:自检清单是排查入口,不是性能优化手段,别为了凑齐绿灯去临时降低打印频率或跳过频率校验,那样只会把问题推到更难观察的位置。