From 7dbb04da17422725a1354a29b586e6f13a4579a3 Mon Sep 17 00:00:00 2001 From: dev-team Date: Fri, 23 Oct 2026 09:47:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(hud):=20B1=20=E5=9F=BA=E7=BA=BF=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E2=80=94=E2=80=94=E6=8A=96=E5=8A=A8/=E5=B8=A7?= =?UTF-8?q?=E9=97=B4=E9=9A=94p95/=E4=B8=A2=E5=8C=85=E7=8E=87/=E8=A7=A3?= =?UTF-8?q?=E7=A0=81=E5=A4=B1=E8=B4=A5=E8=AE=A1=E6=95=B0=20+=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common: Pong 扩展 lost/sent(agent 发送方向 QUIC 计数,经交叉评审项) - agent: Pong 附带 conn.stats().path 丢包统计 - viewer: Stats 加 GapRing(64)/jitter EMA/dec_fail;HUD 新增 JIT/P95/LOSS/DECFAIL 四行,% 字形 - tools/bench.md: 三场景基线测试脚本 --- agent/src/main.rs | 12 +++- common/src/lib.rs | 3 +- tools/bench.md | 37 ++++++++++++ viewer/src/main.rs | 141 +++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 tools/bench.md diff --git a/agent/src/main.rs b/agent/src/main.rs index 72f3961..0e38114 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -93,8 +93,10 @@ async fn main() -> Result<()> { let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::(); let t0 = std::time::Instant::now(); - // 读循环(流 2):输入事件 + Ping + // 读循环(流 2):输入事件 + Ping;Pong 附带视频下行方向的 QUIC 丢包统计 + // (conn.stats().path 统计的是本端发送方向,agent 的发送方向即视频下行) let keyreq_reader = keyreq.clone(); + let conn_stats = conn.clone(); let reader = tokio::spawn(async move { loop { match read_control(&mut irecv).await { @@ -104,7 +106,13 @@ async fn main() -> Result<()> { } Ok(ControlMsg::Ping(t)) => { let agent_ts = t0.elapsed().as_millis() as u64; - let _ = ctl_tx.send(ControlMsg::Pong { t, agent_ts }); + let p = conn_stats.stats().path; + let _ = ctl_tx.send(ControlMsg::Pong { + t, + agent_ts, + lost: p.lost_packets, + sent: p.sent_packets, + }); } Ok(_) => {} Err(_) => break, diff --git a/common/src/lib.rs b/common/src/lib.rs index c72d345..a5a39f9 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -15,7 +15,8 @@ pub enum ControlMsg { Ping(u64), /// t=主控端发送时刻(主控时钟),agent_ts=被控端收到时刻(被控时钟) /// 两者配合可估计时钟偏移,进而用帧头 ts_ms 计算端到端延迟 - Pong { t: u64, agent_ts: u64 }, + /// lost/sent = agent 发送方向(视频下行)的 QUIC 丢包/发包累计计数(conn.stats().path) + Pong { t: u64, agent_ts: u64, lost: u64, sent: u64 }, Input(InputEvent), } diff --git a/tools/bench.md b/tools/bench.md new file mode 100644 index 0000000..97f3b01 --- /dev/null +++ b/tools/bench.md @@ -0,0 +1,37 @@ +# 双机测试基线脚本(B1) + +每个场景测试 60 秒,记录 HUD 读数填入《B侧设计文档.md》末尾的基线数据表。 + +## 准备 + +- 两端都用最新 release:`agent.exe` + `viewer.exe`(同目录带 3 个 mingw DLL) +- 被控机:`agent.exe --port 47800 --code 123456` +- 主控机:`viewer.exe --addr <被控机IP> --port 47800 --code 123456` +- 测试前确认:被控机亮屏有桌面;同一网络(记下是有线还是 WiFi 频段) + +## 场景 + +| # | 场景 | 被控机操作 | 观察点 | +|---|---|---|---| +| 1 | 静止桌面 | 打开一个空白文档,不动 60s | FPS 应低(静止检测),E2E/JIT 应绿 | +| 2 | 视频播放 | 全屏播放任意 1080p 视频 | 码率峰值、FPS、丢包(LOSS 行) | +| 3 | 拖动窗口 | 持续快速拖动一个大窗口 60s | 最恶劣工况:E2E/P95/JIT 是否变红、DECFAIL 是否增长 | + +## 记录方法 + +每个场景进行中截一张 HUD 图(Win+Shift+S 框选右上角),或肉眼逐行抄录: + +``` +FPS= RTT= E2E= JIT= P95= LOSS= DECFAIL= RATE= +``` + +## 附加记录 + +- 网络环境:有线 / WiFi 5GHz / WiFi 2.4GHz,信号强度 +- 被控机 CPU 占用(任务管理器,agent.exe 一行) +- 编码器类型:当前 openh264 软编(A 侧换 NVENC 后需重测一轮对比) + +## 注意 + +- LOSS 显示 `--` 表示第一个 Pong 还没回来或 Δsent=0(静止场景发送少,属正常) +- DECFAIL 在连接建立初期可能有 1-2 次(等第一个关键帧),后续不应增长;持续增长 = 关键帧自愈有问题,报给 A diff --git a/viewer/src/main.rs b/viewer/src/main.rs index a708f21..1ef5d90 100644 --- a/viewer/src/main.rs +++ b/viewer/src/main.rs @@ -81,8 +81,15 @@ fn now_ms() -> u64 { T0.get().unwrap().elapsed().as_millis() as u64 } +/// 帧到达间隔环形缓冲(取 p95 用) +struct GapRing { + last: Option, + ring: [u32; 64], + idx: usize, + filled: usize, +} + /// 会话统计(HUD 数据源,全部原子量,跨任务共享) -#[derive(Default)] struct Stats { /// 最近 RTT ms(0=未知) rtt: AtomicU64, @@ -95,6 +102,80 @@ struct Stats { /// 累计收帧数 / 字节数 frames: AtomicU64, bytes: AtomicU64, + /// 抖动:相邻帧 |ΔE2E| 的 EMA(ms×16 定点存储避免浮点原子) + jitter_x16: AtomicU64, + prev_e2e: AtomicI64, + /// agent 发送侧(视频下行)QUIC 丢包/发包累计(最近一次 Pong) + agent_lost: AtomicU64, + agent_sent: AtomicU64, + /// H.264 解码失败计数(关键帧自愈验证指标) + dec_fail: AtomicU64, + /// 帧到达间隔环形缓冲 + gaps: std::sync::Mutex, +} + +impl Default for Stats { + fn default() -> Self { + Self { + rtt: AtomicU64::new(0), + offset: AtomicI64::new(0), + offset_valid: AtomicBool::new(false), + e2e: AtomicU64::new(0), + e2e_valid: AtomicBool::new(false), + frames: AtomicU64::new(0), + bytes: AtomicU64::new(0), + jitter_x16: AtomicU64::new(0), + prev_e2e: AtomicI64::new(-1), + agent_lost: AtomicU64::new(0), + agent_sent: AtomicU64::new(0), + dec_fail: AtomicU64::new(0), + gaps: std::sync::Mutex::new(GapRing { + last: None, + ring: [0; 64], + idx: 0, + filled: 0, + }), + } + } +} + +impl Stats { + /// 记录一次帧到达:更新间隔环形缓冲与抖动 EMA + fn on_frame_arrival(&self, est: Option) { + let now = Instant::now(); + if let Ok(mut g) = self.gaps.lock() { + if let Some(t) = g.last { + let gap = (now - t).as_millis() as u32; + let i = g.idx; + g.ring[i] = gap; + g.idx = (i + 1) % g.ring.len(); + g.filled = (g.filled + 1).min(g.ring.len()); + } + g.last = Some(now); + } + // 抖动 = |本帧E2E - 上帧E2E| 的 EMA(RFC3550 风格,÷16 衰减) + if let Some(e) = est { + let prev = self.prev_e2e.load(Ordering::Relaxed); + self.prev_e2e.store(e, Ordering::Relaxed); + if prev >= 0 { + let d = (e - prev).unsigned_abs() as u64; + let old = self.jitter_x16.load(Ordering::Relaxed); + let sm = if old == 0 { d * 16 } else { old + d * 16 / 16 - old / 16 }; + self.jitter_x16.store(sm, Ordering::Relaxed); + } + } + } + + /// 帧间隔 p95(ms),样本不足返回 None + fn gap_p95(&self) -> Option { + let g = self.gaps.lock().ok()?; + if g.filled < 8 { + return None; + } + let mut v: Vec = g.ring[..g.filled].to_vec(); + v.sort_unstable(); + v.get(v.len() * 95 / 100).copied() + } } fn main() -> Result<()> { @@ -173,21 +254,24 @@ fn session( stats.bytes.fetch_add(payload.len() as u64, Ordering::Relaxed); stats.frames.fetch_add(1, Ordering::Relaxed); // 端到端延迟 = 接收时刻 - 时钟偏移 - 采集时刻(EMA 平滑) + let mut est = None; if stats.offset_valid.load(Ordering::Relaxed) { - let est = + let e = now_ms() as i64 - stats.offset.load(Ordering::Relaxed) - hdr.ts_ms as i64; - if (0..5000).contains(&est) { + if (0..5000).contains(&e) { let old = stats.e2e.load(Ordering::Relaxed) as i64; let sm = if stats.e2e_valid.load(Ordering::Relaxed) { - (old * 4 + est) / 5 + (old * 4 + e) / 5 } else { - est + e }; stats.e2e.store(sm as u64, Ordering::Relaxed); stats.e2e_valid.store(true, Ordering::Relaxed); + est = Some(sm); } } + stats.on_frame_arrival(est); match decoder.decode(&payload) { Ok(Some(yuv)) => { let (dw, dh) = yuv.dimensions(); @@ -200,6 +284,7 @@ fn session( Ok(None) => {} Err(e) => { // 解码异常:请求被控端强制关键帧 + stats.dec_fail.fetch_add(1, Ordering::Relaxed); eprintln!("[viewer] decode failed: {e}, request keyframe"); let _ = frame_tx.send(Vec::new()); // 空帧占位唤醒渲染循环 } @@ -220,9 +305,11 @@ fn session( tokio::spawn(async move { loop { match read_control(&mut irecv).await { - Ok(ControlMsg::Pong { t, agent_ts }) => { + Ok(ControlMsg::Pong { t, agent_ts, lost, sent }) => { let r = now_ms(); stats.rtt.store(r.saturating_sub(t), Ordering::Relaxed); + stats.agent_lost.store(lost, Ordering::Relaxed); + stats.agent_sent.store(sent, Ordering::Relaxed); // agent_ts ≈ (t+r)/2 时刻的 agent 时钟读数 → 偏移 EMA let est = agent_ts as i64 - ((t + r) / 2) as i64; let old = stats.offset.load(Ordering::Relaxed); @@ -278,6 +365,8 @@ fn session( let mut hud_bytes = 0u64; let mut hud_fps = 0u64; let mut hud_mbps = 0f64; + let mut hud_lost = 0u64; + let mut hud_sent = 0u64; loop { if !window.is_open() || window.is_key_down(Key::Escape) { @@ -368,6 +457,7 @@ fn session( } // 6) HUD:每秒刷新一次指标,画在右上角 + let (hud_loss, hud_jit, hud_p95, hud_dec); if hud_last.elapsed() >= Duration::from_secs(1) { let f = stats.frames.load(Ordering::Relaxed); let b = stats.bytes.load(Ordering::Relaxed); @@ -375,12 +465,46 @@ fn session( hud_mbps = (b.saturating_sub(hud_bytes)) as f64 * 8.0 / 1e6; hud_frames = f; hud_bytes = b; + // 丢包率 = Δlost / Δsent(agent 发送方向,随 Pong 同步) + let l = stats.agent_lost.load(Ordering::Relaxed); + let s = stats.agent_sent.load(Ordering::Relaxed); + hud_loss = if s > hud_sent { + ((l - hud_lost) as f64 / (s - hud_sent) as f64 * 100.0).min(99.9) + } else { + -1.0 + }; + hud_lost = l; + hud_sent = s; + hud_jit = stats.jitter_x16.load(Ordering::Relaxed) / 16; + hud_p95 = stats.gap_p95(); + hud_dec = stats.dec_fail.load(Ordering::Relaxed); hud_last = Instant::now(); + } else { + hud_loss = f64::NAN; + hud_jit = 0; + hud_p95 = None; + hud_dec = 0; } if dirty { let rtt = stats.rtt.load(Ordering::Relaxed); let e2e = stats.e2e.load(Ordering::Relaxed); let e2e_ok = stats.e2e_valid.load(Ordering::Relaxed); + let loss_str = if hud_loss.is_nan() { + "--".to_string() + } else if hud_loss < 0.0 { + "--".to_string() + } else { + format!("{hud_loss:.1}%") + }; + let loss_c = if hud_loss.is_nan() || hud_loss < 0.0 { + C_GRAY + } else { + lat_color(hud_loss < 1.0, hud_loss < 5.0) + }; + let jit_c = lat_color(hud_jit < 10, hud_jit < 30); + let p95_c = hud_p95 + .map(|p| lat_color(p < 60, p < 150)) + .unwrap_or(C_GRAY); draw_hud( &mut buffer, w, @@ -392,6 +516,10 @@ fn session( &if e2e_ok { format!("{e2e}ms") } else { "--".into() }, lat_color(e2e < 100, e2e < 200), ), + ("JIT", &format!("{hud_jit}ms"), jit_c), + ("P95", &hud_p95.map(|p| format!("{p}ms")).unwrap_or("--".into()), p95_c), + ("LOSS", &loss_str, loss_c), + ("DECFAIL", &format!("{hud_dec}"), if hud_dec == 0 { C_GRAY } else { C_RED }), ("RATE", &format!("{:.0}MBP", hud_mbps), C_GRAY), ("RES", &format!("{w}x{h}"), C_GRAY), ], @@ -473,6 +601,7 @@ fn glyph(c: u8) -> [u8; 5] { b':' => [0x00, 0x36, 0x36, 0x00, 0x00], b'.' => [0x00, 0x60, 0x60, 0x00, 0x00], b'-' => [0x08, 0x08, 0x08, 0x08, 0x08], + b'%' => [0x13, 0x18, 0x04, 0x0A, 0x11], b' ' => [0x00; 5], _ => [0x00; 5], }