merge: B1 联调基线(HUD 扩展 + bench 脚本)

This commit is contained in:
dev-team 2026-10-23 09:47:10 +08:00
commit 881341be3a
4 changed files with 184 additions and 9 deletions

View File

@ -93,8 +93,10 @@ async fn main() -> Result<()> {
let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<ControlMsg>(); let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<ControlMsg>();
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
// 读循环(流 2):输入事件 + Ping // 读循环(流 2):输入事件 + Ping;Pong 附带视频下行方向的 QUIC 丢包统计
// (conn.stats().path 统计的是本端发送方向,agent 的发送方向即视频下行)
let keyreq_reader = keyreq.clone(); let keyreq_reader = keyreq.clone();
let conn_stats = conn.clone();
let reader = tokio::spawn(async move { let reader = tokio::spawn(async move {
loop { loop {
match read_control(&mut irecv).await { match read_control(&mut irecv).await {
@ -104,7 +106,13 @@ async fn main() -> Result<()> {
} }
Ok(ControlMsg::Ping(t)) => { Ok(ControlMsg::Ping(t)) => {
let agent_ts = t0.elapsed().as_millis() as u64; 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(_) => {} Ok(_) => {}
Err(_) => break, Err(_) => break,

View File

@ -15,7 +15,8 @@ pub enum ControlMsg {
Ping(u64), Ping(u64),
/// t=主控端发送时刻(主控时钟),agent_ts=被控端收到时刻(被控时钟) /// t=主控端发送时刻(主控时钟),agent_ts=被控端收到时刻(被控时钟)
/// 两者配合可估计时钟偏移,进而用帧头 ts_ms 计算端到端延迟 /// 两者配合可估计时钟偏移,进而用帧头 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), Input(InputEvent),
} }

37
tools/bench.md Normal file
View File

@ -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

View File

@ -81,8 +81,15 @@ fn now_ms() -> u64 {
T0.get().unwrap().elapsed().as_millis() as u64 T0.get().unwrap().elapsed().as_millis() as u64
} }
/// 帧到达间隔环形缓冲(取 p95 用)
struct GapRing {
last: Option<Instant>,
ring: [u32; 64],
idx: usize,
filled: usize,
}
/// 会话统计(HUD 数据源,全部原子量,跨任务共享) /// 会话统计(HUD 数据源,全部原子量,跨任务共享)
#[derive(Default)]
struct Stats { struct Stats {
/// 最近 RTT ms(0=未知) /// 最近 RTT ms(0=未知)
rtt: AtomicU64, rtt: AtomicU64,
@ -95,6 +102,80 @@ struct Stats {
/// 累计收帧数 / 字节数 /// 累计收帧数 / 字节数
frames: AtomicU64, frames: AtomicU64,
bytes: 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<GapRing>,
}
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<i64>) {
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<u32> {
let g = self.gaps.lock().ok()?;
if g.filled < 8 {
return None;
}
let mut v: Vec<u32> = g.ring[..g.filled].to_vec();
v.sort_unstable();
v.get(v.len() * 95 / 100).copied()
}
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@ -173,21 +254,24 @@ fn session(
stats.bytes.fetch_add(payload.len() as u64, Ordering::Relaxed); stats.bytes.fetch_add(payload.len() as u64, Ordering::Relaxed);
stats.frames.fetch_add(1, Ordering::Relaxed); stats.frames.fetch_add(1, Ordering::Relaxed);
// 端到端延迟 = 接收时刻 - 时钟偏移 - 采集时刻(EMA 平滑) // 端到端延迟 = 接收时刻 - 时钟偏移 - 采集时刻(EMA 平滑)
let mut est = None;
if stats.offset_valid.load(Ordering::Relaxed) { if stats.offset_valid.load(Ordering::Relaxed) {
let est = let e =
now_ms() as i64 - stats.offset.load(Ordering::Relaxed) now_ms() as i64 - stats.offset.load(Ordering::Relaxed)
- hdr.ts_ms as i64; - 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 old = stats.e2e.load(Ordering::Relaxed) as i64;
let sm = if stats.e2e_valid.load(Ordering::Relaxed) { let sm = if stats.e2e_valid.load(Ordering::Relaxed) {
(old * 4 + est) / 5 (old * 4 + e) / 5
} else { } else {
est e
}; };
stats.e2e.store(sm as u64, Ordering::Relaxed); stats.e2e.store(sm as u64, Ordering::Relaxed);
stats.e2e_valid.store(true, Ordering::Relaxed); stats.e2e_valid.store(true, Ordering::Relaxed);
est = Some(sm);
} }
} }
stats.on_frame_arrival(est);
match decoder.decode(&payload) { match decoder.decode(&payload) {
Ok(Some(yuv)) => { Ok(Some(yuv)) => {
let (dw, dh) = yuv.dimensions(); let (dw, dh) = yuv.dimensions();
@ -200,6 +284,7 @@ fn session(
Ok(None) => {} Ok(None) => {}
Err(e) => { Err(e) => {
// 解码异常:请求被控端强制关键帧 // 解码异常:请求被控端强制关键帧
stats.dec_fail.fetch_add(1, Ordering::Relaxed);
eprintln!("[viewer] decode failed: {e}, request keyframe"); eprintln!("[viewer] decode failed: {e}, request keyframe");
let _ = frame_tx.send(Vec::new()); // 空帧占位唤醒渲染循环 let _ = frame_tx.send(Vec::new()); // 空帧占位唤醒渲染循环
} }
@ -220,9 +305,11 @@ fn session(
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
match read_control(&mut irecv).await { match read_control(&mut irecv).await {
Ok(ControlMsg::Pong { t, agent_ts }) => { Ok(ControlMsg::Pong { t, agent_ts, lost, sent }) => {
let r = now_ms(); let r = now_ms();
stats.rtt.store(r.saturating_sub(t), Ordering::Relaxed); 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 // agent_ts ≈ (t+r)/2 时刻的 agent 时钟读数 → 偏移 EMA
let est = agent_ts as i64 - ((t + r) / 2) as i64; let est = agent_ts as i64 - ((t + r) / 2) as i64;
let old = stats.offset.load(Ordering::Relaxed); let old = stats.offset.load(Ordering::Relaxed);
@ -278,6 +365,8 @@ fn session(
let mut hud_bytes = 0u64; let mut hud_bytes = 0u64;
let mut hud_fps = 0u64; let mut hud_fps = 0u64;
let mut hud_mbps = 0f64; let mut hud_mbps = 0f64;
let mut hud_lost = 0u64;
let mut hud_sent = 0u64;
loop { loop {
if !window.is_open() || window.is_key_down(Key::Escape) { if !window.is_open() || window.is_key_down(Key::Escape) {
@ -368,6 +457,7 @@ fn session(
} }
// 6) HUD:每秒刷新一次指标,画在右上角 // 6) HUD:每秒刷新一次指标,画在右上角
let (hud_loss, hud_jit, hud_p95, hud_dec);
if hud_last.elapsed() >= Duration::from_secs(1) { if hud_last.elapsed() >= Duration::from_secs(1) {
let f = stats.frames.load(Ordering::Relaxed); let f = stats.frames.load(Ordering::Relaxed);
let b = stats.bytes.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_mbps = (b.saturating_sub(hud_bytes)) as f64 * 8.0 / 1e6;
hud_frames = f; hud_frames = f;
hud_bytes = b; 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(); hud_last = Instant::now();
} else {
hud_loss = f64::NAN;
hud_jit = 0;
hud_p95 = None;
hud_dec = 0;
} }
if dirty { if dirty {
let rtt = stats.rtt.load(Ordering::Relaxed); let rtt = stats.rtt.load(Ordering::Relaxed);
let e2e = stats.e2e.load(Ordering::Relaxed); let e2e = stats.e2e.load(Ordering::Relaxed);
let e2e_ok = stats.e2e_valid.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( draw_hud(
&mut buffer, &mut buffer,
w, w,
@ -392,6 +516,10 @@ fn session(
&if e2e_ok { format!("{e2e}ms") } else { "--".into() }, &if e2e_ok { format!("{e2e}ms") } else { "--".into() },
lat_color(e2e < 100, e2e < 200), 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), ("RATE", &format!("{:.0}MBP", hud_mbps), C_GRAY),
("RES", &format!("{w}x{h}"), 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, 0x36, 0x36, 0x00, 0x00],
b'.' => [0x00, 0x60, 0x60, 0x00, 0x00], b'.' => [0x00, 0x60, 0x60, 0x00, 0x00],
b'-' => [0x08, 0x08, 0x08, 0x08, 0x08], b'-' => [0x08, 0x08, 0x08, 0x08, 0x08],
b'%' => [0x13, 0x18, 0x04, 0x0A, 0x11],
b' ' => [0x00; 5], b' ' => [0x00; 5],
_ => [0x00; 5], _ => [0x00; 5],
} }