操作有延迟或画面跟不上时,先不要把采集、推理、画面返回当成一个整体去重装或换配置。可行的顺序是:在输入采集处打一个时间戳,在画面返回处打一个时间戳,两者相减得到端到端延迟,再按多次采样的差值分布判断慢的是哪一段。定位之后只改被判定为慢的那一段,用同一条命令复测,比整体重试更容易收敛。
判断顺序建议是先埋点再动手。适用场景是单机或小规模实时闭环、能改自己的代码或至少能加日志;操作动作是在输入读取完成处、画面写回处各打一个单调时钟时间戳,用 trace_id 与 seq 配对;验证方式是采一段固定时长的样本,看中位数与高分位而不是单次最大值;风险边界是埋点本身有开销、日志落盘也会扰动时序,采样率和日志切割要在开启前定好,且这些数字只代表本机当时的场景。
在输入采集处打第一个时间戳
打点位置要贴住输入第一次进入系统的那一刻:设备回调、socket 收包或读取函数返回之后,归一化、滤波、格式转换、入队之前。放在归一化之后会把这部分处理时间藏进“上游延迟”,后面就分不清是设备慢还是自己慢。
时钟一律用单调时钟,不要用墙上时间,否则一次校时就能让差值出现负值或跳变。字段命名建议固定下来,后续脚本和看板都靠它对齐:trace_id(一次交互的唯一标识)、seq(单调递增的输入序号)、src(输入来源)、t_cap_ns(采集时间,纳秒)。
import time, uuid
def on_input_event(raw, src='device'):
rec = {
'trace_id': uuid.uuid4().hex[:12],
'seq': next_seq(),
'src': src,
't_cap_ns': time.monotonic_ns(),
}
payload = normalize(raw)
enqueue(payload, rec)
return rec
这段骨架放在输入入口函数里,rec 要随着数据一路传到下游,不能只写进局部日志,否则第二处打点拿不到起点时间。如果输入频率很高,只保留最近若干条 rec 的环形缓冲即可,不必全量留存。
在画面返回处打第二个时间戳
第二处打点放在输出离开系统的地方:渲染或编码完成、写回显示缓冲或返回给调用方之前的最后一跳。如果系统里既有预览流又有最终画面,两处要分别记,不要合并成一个“输出时间”。
def on_frame_out(frame, rec):
t_out_ns = time.monotonic_ns()
lat_ms = (t_out_ns - rec['t_cap_ns']) / 1e6
log_line({
'trace_id': rec['trace_id'],
'seq': rec['seq'],
'pair': 'seq',
't_cap_ns': rec['t_cap_ns'],
't_out_ns': t_out_ns,
'lat_ms': round(lat_ms, 2),
})
return frame
配对方式有两种,要在日志里写清楚用的是哪一种。输出帧能对应到具体输入时,用 seq 精确配对,pair 记 seq;输入频率明显高于输出、一帧画面会吃掉多次输入时,取输出时刻之前最新的一条 rec,pair 记 latest。两种方式算出的延迟含义不同,混在一起统计会得出错误结论。
算出差值与抖动,区分稳定延迟和偶发卡顿
差值就是 t_out_ns - t_cap_ns,单位换成毫秒便于阅读。单次差值的参考价值很低,一次 GC、一次磁盘写入或一次窗口切换都会造成尖峰。判断时看三项:中位数说明常态水平,高分位说明尾部情况,最大值只用来找异常样本,不要拿它当结论。
抖动可以用相邻样本差值的绝对值来粗看,也可以按时间窗口分组,比较同一窗口内的最大值与中位数。记录格式建议一行一条 JSON 或 CSV,字段固定,便于后续脚本直接读:
ts_wall:写日志时的墙上时间,只用于排序和对照,不参与差值计算trace_id/seq/pair:配对依据t_cap_ns/t_out_ns/lat_ms:两段时间戳与差值scene/load:当时的场景与负载标签,前后对比必须同标签
延迟分解表模板可以按下面的列来填,先把两段填满,中间段视系统结构补充,不要求一次填全:
| 分段 | 时间戳字段 | 起止位置 | 常见原因 | 中位数 | 高分位 |
|---|---|---|---|---|---|
| 输入采集 | t_cap_ns | 读取返回 → 归一化前 | 设备缓冲、读取阻塞 | ||
| 排队与处理 | t_inf_ns | 入队 → 结果可用 | 队列积压、单帧处理过重 | ||
| 画面返回 | t_out_ns | 渲染完成 → 写回显示 | 编码耗时、刷新节流 | ||
| 端到端 | lat_ms | t_cap_ns → t_out_ns | 上面各项叠加 |
按结果决定改哪一层,可以先按这几条规则走:中位数偏高且抖动小,说明是稳定延迟,问题多半在常驻环节,例如读取方式、编码档位、刷新频率;中位数正常但高分位明显偏离,说明是偶发卡顿,优先查队列积压、单帧处理超时和日志落盘;只有端到端偏高而两段各自都不高,问题通常出在中间排队。
只改慢的那一段再复测
改动的原则是一次只动一个变量,并且动的是被判定为慢的那一段。采集侧可以试的方向:降低不必要的输入采样、把逐条读取改为批量读取、把归一化移出关键路径。返回侧可以试的方向:调整编码或合成档位、限制输出队列长度、减少重复绘制。推理或中间环节慢时,改的是批大小与超时设置,不要顺手把采集和返回一起改,否则复测结果无法归因。
HELIX_TRACE=1 HELIX_TRACE_SAMPLE=1 \
./helixworld `--scene` default `--duration` 60 \
> /tmp/trace_before.jsonl 2>&1
python3 latency_stats.py /tmp/trace_before.jsonl `--by` pair
复测用同一条命令、同一场景、相近时长,最好在同一台机器上跑。统计脚本只做三件事:按 pair 分组、排序后取中位数与高分位、输出样本数。样本太少时不要下判断,先延长采集时间。
import json, sys
rows = [json.loads(l) for l in open(sys.argv[1])]
lat = sorted(r['lat_ms'] for r in rows)
n = len(lat)
def q(p):
return lat[min(n - 1, int(p * n))]
print('n=', n, 'p50=', q(0.5), 'p90=', q(0.9), 'max=', lat[-1])
前后对照时,把 before 与 after 的中位数、高分位和样本数并排看。如果只改了返回侧而采集侧的高分位没有变化,说明定位基本成立;如果两段同步变化,多半是系统整体负载在波动,这次改动不能算数,需要回到同一负载下再测一轮。
把埋点保留成常驻开关
埋点不要用完就删。把它做成配置开关,默认关闭,需要时打开,后续每次调优都有可比的数据。开关建议放在配置文件或环境变量里,两个参数就够:是否开启与采样比例。
# helixworld.conf
[latency]
trace_enabled = false
trace_sample_every = 20 # 每 20 帧记 1 条
log_path = /var/log/helixworld/latency.jsonl
log_max_bytes = 33554432 # 单文件上限,超出后滚动
开启后要清楚日志的影响:全量记录会在高频输入下产生可观的写盘量,日志本身的 IO 也会进入时序,从而抬高测到的延迟;滚动与上限不设好,磁盘占满会带来更难排查的问题。因此建议常态用较低采样,只在复现问题时临时调成全量,并同时记录当时的场景与负载标签。开关、采样率、日志路径这三项写进配置说明,团队里其他人复现时就不用再猜参数。