HelixWorld 的输入采集和画面返回要分开调、先把慢的一方定位出来

文章导读
操作有延迟或画面跟不上时,先不要把采集、推理、画面返回当成一个整体去重装或换配置。可行的顺序是:在输入采集处打一个时间戳,在画面返回处打一个时间戳,两者相减得到端到端延迟,再按多次采样的差值分布判断慢的是哪一段。定位之后只改被判定为慢的那一段,用同一条命令复测,比整体重试更容易收敛。
📋 目录
  1. Ⅰ 在输入采集处打第一个时间戳
  2. Ⅱ 在画面返回处打第二个时间戳
  3. Ⅲ 算出差值与抖动,区分稳定延迟和偶发卡顿
  4. Ⅳ 只改慢的那一段再复测
  5. Ⅴ 把埋点保留成常驻开关
A A

操作有延迟或画面跟不上时,先不要把采集、推理、画面返回当成一个整体去重装或换配置。可行的顺序是:在输入采集处打一个时间戳,在画面返回处打一个时间戳,两者相减得到端到端延迟,再按多次采样的差值分布判断慢的是哪一段。定位之后只改被判定为慢的那一段,用同一条命令复测,比整体重试更容易收敛。

判断顺序建议是先埋点再动手。适用场景是单机或小规模实时闭环、能改自己的代码或至少能加日志;操作动作是在输入读取完成处、画面写回处各打一个单调时钟时间戳,用 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_mst_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 也会进入时序,从而抬高测到的延迟;滚动与上限不设好,磁盘占满会带来更难排查的问题。因此建议常态用较低采样,只在复现问题时临时调成全量,并同时记录当时的场景与负载标签。开关、采样率、日志路径这三项写进配置说明,团队里其他人复现时就不用再猜参数。