19 KiB
hotpath CPU 热点分析与发包链路优化
概述
本文档记录了使用 hotpath + samply 对 easytier-core 发包链路进行 CPU 热点分析的全过程,包括工具链搭建、热点定位、优化实施、踩坑记录和最终 benchmark 结果。
最终 benchmark 数据
真实性能对比(不带 hotpath,3 runs average)
origin/main baseline 使用 git worktree 从 origin/main 构建,仅添加 bench example +
loopback bind fix(TCP/UDP convergence 需要)。无任何优化代码。
| Tunnel | origin/main baseline | 优化后 | 提升 | 带宽(优化后) |
|---|---|---|---|---|
| Ring | 293K pps / 3.3 Gbps | 1,124K pps | +284% | 12.6 Gbps |
| TCP | 298K pps / 3.3 Gbps | 975K pps | +227% | 10.9 Gbps |
| UDP | 630K pps / 7.1 Gbps | 1,066K pps | +69% | 11.9 Gbps |
UDP baseline 本身较高(630K vs 293K/298K),因为 UDP tunnel 的 forward_from_ring_to_udp 独立 task 提供了天然的 pipeline overlap,部分隐藏了 channel 开销。
带 hotpath profiling(timing 可见,但有 observer effect)
| Tunnel | 原始 pps | 优化后 pps | 提升 | MpscTunnelSender::send |
|---|---|---|---|---|
| Ring | 234K | 478K | +104% | 138ns (原 2.23µs) |
| UDP | N/A | 440K | — | 294ns |
| TCP | N/A | 453K | — | 378ns |
hotpath observer effect
hotpath 测量基础设施引入了 ~54-57% 的性能开销:
| Tunnel | 不带 hotpath | 带 hotpath | hotpath 开销 |
|---|---|---|---|
| Ring | 1,124K pps | 478K pps | -57% |
| TCP | 975K pps | 453K pps | -54% |
含义:
- timing 数据里的
send_msg_by_ip: 2.15µs是膨胀值,真实成本 ~0.9µs - 所有 timing 数据需要按 ~2.3x 校准才能反映真实开销
- hotpath 适用于相对比较(优化前 vs 后),不适用于绝对性能评估
- 生产环境部署不应用 hotpath feature 编译
测试条件:4 threads, 1400B packets, 10s, 宿主机直跑。
工具链搭建
hotpath + samply 安装
# hotpath TUI (console)
cargo install hotpath --version 0.18.0 --bin hotpath --features tui
# hotpath-samply (samply wrapper,autospawn 依赖)
cargo install hotpath --version 0.18.0 --bin hotpath-samply
# samply 本体(hotpath-samply 内部 spawn samply record)
cargo install samply
内核参数
samply 需要 perf_event 开销,需要调整内核参数:
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
echo '65536' | sudo tee /proc/sys/kernel/perf_event_mlock_kb
perf_event_paranoid默认 2(不允许非 root 采样),需降到 1。perf_event_mlock_kb默认 516 KB,32 核机器上 samply 的 mmap buffer 总量超限,需增大到 65536。
hotpath profile 编译
# Cargo.toml
[profile.hotpath]
inherits = "release"
strip = false
debug = "line-tables-only"
samply 需要 debug symbols 且不能 strip。release profile 默认 strip = true,必须用单独的 profile。
Docker 隔离环境(可选,TCP/UDP bench)
修复 loopback bind 地址后(见坑 11),TCP/UDP bench 可以直接在宿主机上跑,不需要 Docker:
# Ring(进程内,无需隔离)
HOTPATH_TUNNEL=ring ./target/hotpath/examples/cpu_hotspot_ring
# TCP/UDP(修复后也支持宿主机直跑)
HOTPATH_TUNNEL=tcp ./target/hotpath/examples/cpu_hotspot_ring
如果仍有 convergence 问题(多网卡环境),用 Docker 提供独立 netns:
docker run --rm \
-v "$(pwd)/target/hotpath/examples/cpu_hotspot_ring:/bench:ro" \
-e HOTPATH_TUNNEL=tcp \
-e HOTPATH_BENCH_SECS=10 \
fedora:latest \
/bench
Docker 镜像需要匹配宿主机的 glibc 版本。Fedora 宿主用 fedora:latest。
踩坑记录
坑 1:samply 报 "failed to spawn samply: No such file or directory"
现象:hotpath CPU report 显示 failed to spawn samply: No such file or directory (os error 2)
原因:hotpath-samply 只是 wrapper,它内部 spawn samply record --pid <pid> 来采集 CPU 样本。samply 本体没装。
解决:
cargo install samply
如果 autospawn 找不到 hotpath-samply 本身,用环境变量指定完整路径:
export HOTPATH_SAMPLY_WRAPPER_BIN=~/.cargo/bin/hotpath-samply
坑 2:samply 报 "Failed to start profiling: mmap failed"
现象:samply 启动后立即报 mmap 失败。
原因:perf_event_mlock_kb 默认只有 516 KB。32 核机器上 samply 为每个 CPU core 创建 mmap buffer,总 mmap 量超过限制。
解决:
echo '65536' | sudo tee /proc/sys/kernel/perf_event_mlock_kb
坑 3:samply 报 "samply exited with status exit status: 1"
现象:samply 被 spawn 了但 exit 1。
原因:同坑 2——perf_event_paranoid = 2 时非 root 用户无法使用 perf_event_open。
解决:
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
坑 4:火焰图全是地址,看不到符号
现象:samply profile 打开后火焰图全是 0x31dd24 之类的地址。
原因:samply profile 里存储的是地址(不内联符号化)。符号化在查看时通过 symbol server 动态完成。如果直接下载 raw JSON 上传到 profiler.firefox.com,符号 server 无法访问本地二进制文件。
解决:必须用 samply load 本地打开(它启动 symbol server 自动做符号化):
samply load /tmp/hotpath/<session>/hp.json.gz
不要下载 JSON 再上传到 profiler.firefox.com。
坑 5:samply 符号化后 _dl_mcount_wrapper 占 18.1%
现象:send_msg_internal inclusive 分析显示 _dl_mcount_wrapper 占 18.1% CPU。
原因:nm 的动态符号表里 _dl_mcount_wrapper(0x1498d0)到下一个符号(0x1b3e9e)之间有 425 KB gap。nm 的 bisect 查找把 gap 内所有地址错误归因到 _dl_mcount_wrapper。gap 里实际是 AVX2 优化的 memmove/memcmp/memset 等函数。
解决:用 addr2line 精确解析(而非 nm bisect)。实际开销是 memmove 1.67% + memcmp 0.20% + memset 0.19% = 2.1%,不是 18%。没有 profiling 钩子。
坑 6:parking_lot::MutexGuard 不是 Send
现象:使用 parking_lot::Mutex 替代 tokio::sync::Mutex 后,编译报 31 个 "future cannot be sent between threads safely"。
原因:parking_lot::MutexGuard 刻意不实现 Send——锁必须在获取它的同一个线程上释放。在 async fn 里 guard 跨 await 点会导致 Future 不是 Send,tokio multi_thread runtime 拒绝 spawn。
解决:自定义 SpinSink(AtomicBool spinlock),SpinGuard 只持有 &SpinSink 引用(SpinSink: Sync via unsafe impl),是 Send。
坑 7:std::sync::MutexGuard 也不是 Send(在某些配置下)
现象:std::sync::Mutex 同样报 "future cannot be sent between threads safely"。
原因:Rust 标准库的 MutexGuard 的 Send 实现依赖于内部类型。Pin<Box<dyn ZCPacketSink>> 包含 trait object,某些配置下 guard 不是 Send。
解决:用自定义 SpinSink 绕过所有标准 Mutex 实现。
坑 8:direct sink path 没有性能提升
现象:去掉 channel 中转(MpscTunnelSender 直接持有 sink),从 3 个 await 点(lock + feed + flush)改为 try_lock + poll_fn 合并。MpscTunnelSender::send 仍然 ~2µs。
原因:瓶颈不在 lock 或 channel,而在 async fn Future 状态机的固有开销。每次 .await 创建一个 Future struct、poll 它、drop 它。即使 poll 立即返回 Ready,整个 async machinery 开销 ~2µs。RingSink 实际操作只有 ~40ns(2%)。
解决:用 noop_waker() 在 async fn 内部同步调用 Sink trait 方法(poll_ready + start_send + poll_flush)。async fn 在第一次 poll 就同步完成返回——绕过所有 async 调度开销。开销从 2µs 降到 ~140ns。
坑 9:sync send 破坏了 TCP/UDP tunnel
现象:把 send 从 async fn 改为 sync fn 后,所有 TCP/UDP 相关测试失败(452 个失败)。
原因:TCP/UDP tunnel 用 channel mode(MpscTunnel::new)。sync send 的 channel path 只做 try_send,channel 满时返回 BufferFull(丢包),而不是 send().await(等待背压)。丢包导致 TCP/UDP 连接握手失败。
解决:保持 send 为 async fn。direct path(ring/UDP/TCP)内部用 noop_waker 同步完成(不 yield)。channel path 仍然走 async send_async().await。async fn wrapper 对 direct path 只有 ~100ns 开销(Future struct 创建 + 单次 poll),因为不 yield。
坑 10:poll_flush Pending 返回 Shutdown 导致连接断开
现象:noop_waker 模式下,TCP tunnel 的 poll_flush 可能返回 Pending(TCP 写缓冲区满)。返回 Err(Shutdown) 导致 PeerConn 认为连接断开。
原因:TCP 的 FramedWriter::poll_flush 做实际 socket write(系统调用)。socket 缓冲区满时返回 Pending。数据已经在 BufList 里,不需要 panic。
解决:poll_flush Pending 时返回 Ok(())。数据已在 buffer(ring buffer 或 BufList),后续操作会消费它。Pending 只意味着 "还没 flush 到网络",不是 "错误"。
坑 11:TCP/UDP bench convergence 失败
现象:TCP/UDP tunnel 的 bench 中,两个实例无法建立连接(routes did not converge within 15s)。
原因:set_bind_addr_for_peer_connector(connector/mod.rs:70-77)收集所有本机 IP 作为 TCP bind 地址,但不包含 127.0.0.1。connector 绑定到 172.17.0.2(Docker eth0)后连接 127.0.0.1 路由不通 → 2 秒超时。
解决:在 bind 地址列表头部加入 127.0.0.1:0。connector 遍历所有 bind 地址,loopback 先被尝试,localhost 连接成功。
坑 12:ShardedCounter (#2385) 在高频路径引入回退
现象:cherry-pick PR #2385(ShardedCounter 替代 UnsafeCell)后,pps 下降 17%(246K → 203K)。
原因:ShardedCounter 的 TLS 分片设计优化多线程 contention,但每包调用 16 次 ShardedCounter::add(TLS load + store),单次 ~14ns,总 224ns/包。比原来的 UnsafeCell(~2ns/次)高 6 倍。每包 16 次的调用频率让 TLS 开销累积。
教训:TLS 分片策略适合 低频高并发 场景,不适合 高频单线程 的发包热路径。
坑 13:ZCPacket pool 不如 glibc tcache
现象:用 crossbeam_queue::ArrayQueue 做 BytesMut 对象池,每包从池取/归还。性能没有提升(甚至 -15%)。
原因:glibc malloc 对 ~1500 bytes 小块分配有 thread-local cache(tcache),单次 alloc ~10-15ns。ArrayQueue 的 pop/push 是 CAS 操作(~20-40ns),比 tcache 更慢。pool 还多了 capacity 检查和 clear 操作。
教训:手动对象池在现代 glibc tcache 面前没有优势。真正需要 pool 的场景是避免 munmap(大块 >128KB 分配),不是小块。
坑 14:Pipeline (FuturesUnordered) 效果微小
现象:用 FuturesUnordered 让多个 send_msg_by_ip 并发(pipeline_depth=4),pps 只提升 1.6%。
原因:try_send fast path 让 MpscTunnelSender::send 立即返回(不 await)。多个 send_msg_by_ip 之间没有自然的时间重叠——它们在 CPU 上是串行的。pipeline 需要利用 await 等待时间,但 fast path 消除了 await。
坑 15:hotpath 测量引入 54% observer effect
现象:同一 binary 带 hotpath feature 和不带 hotpath feature 跑 bench,pps 差距巨大。
数据:
| Tunnel | 不带 hotpath | 带 hotpath | hotpath 开销 |
|---|---|---|---|
| Ring | 1,124K pps | 478K pps | -57% |
| TCP | 975K pps | 453K pps | -54% |
原因:hotpath #[measure] / #[measure_all] 在每个标注的 async fn 上包装 Future struct,每次 poll 记录开始/结束时间(quanta::Instant ~5ns × 2)、更新统计(atomic 操作)。measure_all 覆盖的 impl 块内所有方法都被插桩。当有 ~30 个 measure 点在发包热路径上时,累计开销超过 50%。
教训:
- hotpath timing 数据适用于相对比较(优化前 vs 后),不适用于绝对性能评估
- 生产环境不应用 hotpath feature 编译
- 要获取真实 pps,编译不带
--features hotpath的版本 - timing 数据按 ~2.3x 校准可近似真实开销
优化实施记录
真实提升(不带 hotpath,origin/main baseline 对比)
baseline 构建:git worktree 从 origin/main,仅添加 bench example + loopback bind fix。
| Tunnel | baseline | 优化后 | 提升 |
|---|---|---|---|
| Ring | 293K pps | 1,124K pps | +284% |
| TCP | 298K pps | 975K pps | +227% |
| UDP | 630K pps | 1,066K pps | +69% |
有效优化(按贡献排序)
| 优化 | 带 hotpath pps 变化 | 真实提升来源 | 机制 |
|---|---|---|---|
| noop_waker sync send | +90% | 核心突破 | RingSink/FramedWriter 直接 sync poll,绕过 async machinery |
| try_send fast path | +7% | 次要 | 跳过 tokio mpsc semaphore |
| #2385 ZCPacket safe init | +5% (TCP) | TCP 专属 | copy_nonoverlapping 无 aliasing 检查 |
| metrics batch + sync | +1.6% | 小幅 | batch CounterHandle + sync fast path |
| #2381 advance (零拷贝) | ~0% | 代码质量 | Buf::advance 消除 split_off Arc churn |
| channel 32→1024 | ~0% | 减少 fallback | 更大 buffer |
| 接收侧 try_recv | ~0% (单向) | 双向有价值 | 消除 recv().await async overhead |
| loopback bind fix | — | TCP/UDP convergence 必需 | 127.0.0.1 加入 bind 地址列表 |
验证无效并回退
| 尝试 | 结果 | 原因 |
|---|---|---|
| ShardedCounter (#2385) | -17% pps | TLS 分片高频开销 > UnsafeCell |
| ZCPacket pool | -15% pps | glibc tcache 比 ArrayQueue CAS 更快 |
| Allocator 切换 (jemalloc/mimalloc) | ~0% | 小块分配 tcache 都已足够 |
| Pipeline (FuturesUnordered) | +1.6% | try_send 消除了 await 空隙 |
| dashmap 合并 | ~0% | contains_key 本身 ~50ns |
noop_waker 技术详解
核心原理:async fn send() 内部用 noop_waker() 构造 dummy Context,直接调 Sink trait 的 poll_ready + start_send + poll_flush。RingSink 在 ring buffer 不满时所有操作立即返回 Ready——noop_waker 永远不会被触发。
pub async fn send(&self, item: ZCPacket) -> Result<(), TunnelError> {
if let Some(sink) = &self.direct_sink {
if let Some(mut guard) = sink.try_lock() {
let waker = futures::task::noop_waker();
let mut cx = std::task::Context::from_waker(&waker);
match guard.as_mut().poll_ready(&mut cx) {
Poll::Ready(Ok(())) => {
guard.as_mut().start_send(item)?;
match guard.as_mut().poll_flush(&mut cx) {
Poll::Ready(Err(e)) => return Err(e),
_ => return Ok(()), // Ready(Ok) 或 Pending 都返回 Ok
}
}
// ...
}
}
return Err(TunnelError::BufferFull);
}
// Channel mode: async with backpressure
self.send_async(item).await
}
为什么 Pending 返回 Ok:poll_flush Pending 意味着数据已在 buffer(ring buffer 或 BufList)但还没 flush 到网络。forward task 或下一次 send 会消费它。这是安全的——数据不丢、不乱序。
适用范围:所有 Sink 的 start_send 是同步内存操作的 tunnel:
- Ring tunnel: RingSink → ring buffer(内存)
- UDP tunnel: RingSink → ring buffer → forward_from_ring_to_udp task → socket
- TCP tunnel: FramedWriter → BufList(内存)→ poll_flush 时 write socket
hotpath measure 布点
当前覆盖
send_msg_by_ip ✅ measure
├─ try_compress_and_encrypt ✅ measure
├─ get_msg_dst_peer_ipv4 ✅ measure
├─ run_nic_packet_process_pipeline ✅ measure
├─ send_msg_internal ✅ measure
│ ├─ PeerMap::send_msg_directly ✅ measure_all
│ ├─ PeerMap::get_peer_by_id ✅ measure_all
│ ├─ PeerMap::get_gateway_peer_id ✅ measure_all
│ ├─ PeerMap::has_peer ✅ measure_all
│ ├─ record_tx_fast ❌ (sync fn, 无 measure)
│ └─ Peer::send_msg ✅ measure
│ └─ PeerConn::send_msg ✅ measure
│ └─ MpscTunnelSender::send ✅ measure
├─ MpscTunnel::forward_one_round ✅ measure
│ ├─ RingSink::poll_ready ✅ measure_all
│ ├─ RingSink::start_send ✅ measure_all
│ └─ RingSink::poll_flush ✅ measure_all
└─ CidrSet::* ✅ measure_all
布点排除项(避免与已有 PR 冲突)
| 文件 | 排除原因 |
|---|---|
| stats_manager.rs | PR #2385 重写中 |
| traffic_metrics.rs | 依赖 stats_manager |
| peer_manager.rs (部分) | advisor/001-002 改动 |
| peer_conn.rs (部分) | advisor/001-002 改动 |
| tunnel/mpsc.rs (部分) | perf/001 改动 |
| packet_def.rs | perf/001-003 改动 |
| peer_ospf_route.rs | advisor/003-004 改动 |
运行方式
Ring tunnel bench
cargo build --profile hotpath --features hotpath --example cpu_hotspot_ring
HOTPATH_BENCH_SECS=15 ./target/hotpath/examples/cpu_hotspot_ring
TCP/UDP bench(需要 Docker 隔离)
docker run --rm \
-v "$(pwd)/target/hotpath/examples/cpu_hotspot_ring:/bench:ro" \
-e HOTPATH_TUNNEL=tcp \
-e HOTPATH_BENCH_SECS=10 \
fedora:latest \
/bench
带 samply CPU profiling
export PATH=$HOME/.cargo/bin:$PATH
cargo run --profile hotpath --features hotpath,hotpath-cpu --example cpu_hotspot_ring
# 另一终端查看 CPU top
hotpath console
环境变量
| 变量 | 默认 | 说明 |
|---|---|---|
HOTPATH_BENCH_SECS |
30 | 打流持续秒数 |
HOTPATH_PKT_SIZE |
1400 | 包大小 |
HOTPATH_TUNNEL |
ring | ring / udp / tcp |
HOTPATH_PIPELINE |
1 | pipeline 深度 |
HOTPATH_SAMPLY_WRAPPER_BIN |
— | hotpath-samply 完整路径 |
HOTPATH_SAMPLY_BIN |
— | samply 本体完整路径 |