mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-20 11:22:39 +00:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09cd15725e | ||
|
|
90c45d2964 | ||
|
|
0e665eafc6 | ||
|
|
97526a95a1 | ||
|
|
2794b96028 | ||
|
|
42b6d326a8 | ||
|
|
b37c1539e1 | ||
|
|
8312400913 | ||
|
|
19647f296a | ||
|
|
94d056cb78 | ||
|
|
4c7c3d7672 | ||
|
|
d99efba64f | ||
|
|
6d01908593 | ||
|
|
4b654fc56e | ||
|
|
1fdd4b0abe | ||
|
|
ba4fde40ad | ||
|
|
cdec67ff53 | ||
|
|
7e0cdfc683 | ||
|
|
1d80439c7c | ||
|
|
392a970db1 | ||
|
|
368d140b5b | ||
|
|
57cc9922a4 | ||
|
|
4875393327 | ||
|
|
37f742272b | ||
|
|
d4ef9decd8 | ||
|
|
3464cb801a | ||
|
|
e18387b06b | ||
|
|
31c639f70c | ||
|
|
79035ea972 |
Generated
+1
@@ -2365,6 +2365,7 @@ dependencies = [
|
||||
"quanta",
|
||||
"quinn",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"quote",
|
||||
"rand 0.8.5",
|
||||
"rcgen",
|
||||
|
||||
@@ -27,3 +27,9 @@ lto = true
|
||||
codegen-units = 1
|
||||
opt-level = 3
|
||||
strip = true
|
||||
|
||||
# For hotpath CPU profiling: samply needs debug symbols and unstripped binaries.
|
||||
[profile.hotpath]
|
||||
inherits = "release"
|
||||
strip = false
|
||||
debug = "line-tables-only"
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
# 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 安装
|
||||
|
||||
```bash
|
||||
# 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 开销,需要调整内核参数:
|
||||
|
||||
```bash
|
||||
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 编译
|
||||
|
||||
```toml
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
# Ring(进程内,无需隔离)
|
||||
HOTPATH_TUNNEL=ring ./target/hotpath/examples/cpu_hotspot_ring
|
||||
|
||||
# TCP/UDP(修复后也支持宿主机直跑)
|
||||
HOTPATH_TUNNEL=tcp ./target/hotpath/examples/cpu_hotspot_ring
|
||||
```
|
||||
|
||||
如果仍有 convergence 问题(多网卡环境),用 Docker 提供独立 netns:
|
||||
|
||||
```bash
|
||||
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 本体没装。
|
||||
|
||||
**解决**:
|
||||
|
||||
```bash
|
||||
cargo install samply
|
||||
```
|
||||
|
||||
如果 autospawn 找不到 hotpath-samply 本身,用环境变量指定完整路径:
|
||||
|
||||
```bash
|
||||
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 量超过限制。
|
||||
|
||||
**解决**:
|
||||
|
||||
```bash
|
||||
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。
|
||||
|
||||
**解决**:
|
||||
|
||||
```bash
|
||||
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 自动做符号化):
|
||||
|
||||
```bash
|
||||
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 永远不会被触发。
|
||||
|
||||
```rust
|
||||
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
|
||||
|
||||
```bash
|
||||
cargo build --profile hotpath --features hotpath --example cpu_hotspot_ring
|
||||
HOTPATH_BENCH_SECS=15 ./target/hotpath/examples/cpu_hotspot_ring
|
||||
```
|
||||
|
||||
### TCP/UDP bench(需要 Docker 隔离)
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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 本体完整路径 |
|
||||
@@ -85,6 +85,7 @@ atomic_refcell = "0.1.13"
|
||||
|
||||
quinn = { version = "0.11.8", optional = true, features = ["ring"] }
|
||||
quinn-proto = { version = "0.11.12", optional = true }
|
||||
quinn-udp = { version = "0.5", optional = true }
|
||||
seahash = { version = "4.1.0", optional = true }
|
||||
|
||||
rustls = { version = "0.23.0", features = [
|
||||
@@ -361,6 +362,7 @@ default = [
|
||||
"faketcp",
|
||||
"magic-dns",
|
||||
"zstd",
|
||||
"udp-gso",
|
||||
]
|
||||
full = [
|
||||
"websocket",
|
||||
@@ -378,6 +380,7 @@ full = [
|
||||
]
|
||||
wireguard = ["dep:boringtun", "dep:ring"]
|
||||
quic = ["dep:quinn", "dep:quinn-proto", "dep:seahash", "dep:rustls", "dep:rcgen"]
|
||||
udp-gso = ["dep:quinn-udp"]
|
||||
kcp = ["dep:kcp-sys"]
|
||||
mimalloc = ["dep:mimalloc"]
|
||||
aes-gcm = ["dep:aes-gcm"]
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! CPU hotspot benchmark for hotpath profiling.
|
||||
//!
|
||||
//! Builds two no-tun EasyTier instances connected via an in-process ring
|
||||
//! tunnel, lets routes converge, then floods data-plane packets through
|
||||
//! `send_msg_by_ip` so that `hotpath-cpu` / samply can collect meaningful
|
||||
//! CPU samples.
|
||||
//!
|
||||
//! Build & run:
|
||||
//! cargo run --profile hotpath --features hotpath,hotpath-cpu \
|
||||
//! --example cpu_hotspot_ring
|
||||
//!
|
||||
//! Prerequisites: hotpath-samply + samply must be installed and on PATH.
|
||||
//! See bench/006-hotpath-cpu-top.md for install instructions.
|
||||
//!
|
||||
//! Then in another terminal:
|
||||
//! hotpath console
|
||||
|
||||
#[cfg(feature = "mimalloc")]
|
||||
#[global_allocator]
|
||||
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
#[cfg(feature = "jemalloc")]
|
||||
#[global_allocator]
|
||||
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bytes::BytesMut;
|
||||
|
||||
use easytier::common::config::{ConfigLoader, PeerConfig, TomlConfigLoader};
|
||||
use easytier::instance::instance::Instance;
|
||||
use easytier::tunnel::packet_def::ZCPacket;
|
||||
use easytier::tunnel::ring::RingTunnelConnector;
|
||||
use easytier::tunnel::udp::UdpTunnelConnector;
|
||||
|
||||
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[cfg_attr(feature = "hotpath", hotpath::main)]
|
||||
async fn main() {
|
||||
let duration = std::env::var("HOTPATH_BENCH_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(30u64);
|
||||
|
||||
let pkt_size: usize = std::env::var("HOTPATH_PKT_SIZE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1400);
|
||||
|
||||
let tunnel_type = std::env::var("HOTPATH_TUNNEL")
|
||||
.ok()
|
||||
.unwrap_or_else(|| "ring".to_string());
|
||||
|
||||
let (inst_a_config, inst_b_config) = match tunnel_type.as_str() {
|
||||
"udp" => {
|
||||
let mut a = no_tun_config("hot-a", "10.144.144.1");
|
||||
a.set_listeners(vec!["udp://0.0.0.0:35521".parse().unwrap()]);
|
||||
(a, no_tun_config("hot-b", "10.144.144.2"))
|
||||
}
|
||||
"tcp" => {
|
||||
let mut a = no_tun_config("hot-a", "10.144.144.1");
|
||||
a.set_listeners(vec!["tcp://0.0.0.0:35522".parse().unwrap()]);
|
||||
(a, no_tun_config("hot-b", "10.144.144.2"))
|
||||
}
|
||||
_ => (
|
||||
no_tun_config("hot-a", "10.144.144.1"),
|
||||
no_tun_config("hot-b", "10.144.144.2"),
|
||||
),
|
||||
};
|
||||
|
||||
let mut inst_a = Instance::new(inst_a_config);
|
||||
let mut inst_b = Instance::new(inst_b_config);
|
||||
|
||||
inst_a.run().await.expect("inst_a run");
|
||||
inst_b.run().await.expect("inst_b run");
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
match tunnel_type.as_str() {
|
||||
"ring" => {
|
||||
let ring_url = format!("ring://{}", inst_a.id());
|
||||
inst_b
|
||||
.get_conn_manager()
|
||||
.add_connector(RingTunnelConnector::new(ring_url.parse().unwrap()));
|
||||
}
|
||||
"udp" => {
|
||||
inst_b.get_conn_manager().add_connector(
|
||||
UdpTunnelConnector::new("udp://127.0.0.1:35521".parse().unwrap()),
|
||||
);
|
||||
}
|
||||
"tcp" => {
|
||||
inst_b.get_conn_manager().add_connector(
|
||||
easytier::tunnel::tcp::TcpTunnelConnector::new(
|
||||
"tcp://127.0.0.1:35522".parse().unwrap(),
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let dst: IpAddr = "10.144.144.2".parse().unwrap();
|
||||
let src = "10.144.144.1";
|
||||
|
||||
let converged = tokio::time::timeout(Duration::from_secs(15), async {
|
||||
loop {
|
||||
let a = inst_a.get_peer_manager().list_routes().await;
|
||||
let b = inst_b.get_peer_manager().list_routes().await;
|
||||
if a.len() >= 1 && b.len() >= 1 {
|
||||
return true;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
if !converged {
|
||||
eprintln!("warning: routes did not converge within 15s");
|
||||
}
|
||||
|
||||
println!(
|
||||
"cpu_hotspot_ring: flooding {}s, pkt_size={}, tunnel={} (converged={})",
|
||||
duration, pkt_size, tunnel_type, converged
|
||||
);
|
||||
|
||||
let pm = inst_a.get_peer_manager();
|
||||
let send_pkt = make_data_packet(src, "10.144.144.2", pkt_size);
|
||||
|
||||
let batch_threshold: u32 = std::env::var("HOTPATH_BATCH")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1);
|
||||
|
||||
// After convergence, enable batch flush for writev optimization
|
||||
if converged && batch_threshold > 1 {
|
||||
pm.set_peer_conn_batch_threshold(batch_threshold);
|
||||
println!("cpu_hotspot_ring: batch_threshold={}", batch_threshold);
|
||||
}
|
||||
|
||||
let pipeline_depth: usize = std::env::var("HOTPATH_PIPELINE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1);
|
||||
|
||||
println!(
|
||||
"cpu_hotspot_ring: pipeline_depth={}",
|
||||
pipeline_depth
|
||||
);
|
||||
|
||||
let sender_task = tokio::spawn(async move {
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
|
||||
let mut sent: u64 = 0;
|
||||
let start = Instant::now();
|
||||
let mut in_flight = FuturesUnordered::new();
|
||||
|
||||
loop {
|
||||
while in_flight.len() < pipeline_depth {
|
||||
let pkt = send_pkt.clone();
|
||||
in_flight.push(pm.send_msg_by_ip(pkt, dst, false));
|
||||
}
|
||||
in_flight.next().await;
|
||||
sent += 1;
|
||||
if sent % 10000 == 0 {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
let pps = sent as f64 / elapsed;
|
||||
let mbps = pps * pkt_size as f64 * 8.0 / 1_000_000.0;
|
||||
println!("sent {} pkts ({:.0} pps, {:.0} Mbps)", sent, pps, mbps);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(duration)).await;
|
||||
sender_task.abort();
|
||||
|
||||
println!("cpu_hotspot_ring: done");
|
||||
}
|
||||
|
||||
fn make_data_packet(src: &str, dst: &str, total_size: usize) -> ZCPacket {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
let hdr_len = 28;
|
||||
let payload_len = total_size.saturating_sub(hdr_len);
|
||||
let ip_total_len = (hdr_len + payload_len) as u16;
|
||||
|
||||
let mut buf = BytesMut::with_capacity(total_size);
|
||||
|
||||
buf.extend_from_slice(&[
|
||||
0x45,
|
||||
0x00,
|
||||
(ip_total_len >> 8) as u8,
|
||||
(ip_total_len & 0xff) as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
0x40,
|
||||
0x00,
|
||||
0x40,
|
||||
0x11,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
let src: Ipv4Addr = src.parse().unwrap();
|
||||
buf.extend_from_slice(&src.octets());
|
||||
let dst: Ipv4Addr = dst.parse().unwrap();
|
||||
buf.extend_from_slice(&dst.octets());
|
||||
|
||||
let udp_len = (8 + payload_len) as u16;
|
||||
buf.extend_from_slice(&[
|
||||
0x30,
|
||||
0x39,
|
||||
0xD4,
|
||||
0x31,
|
||||
(udp_len >> 8) as u8,
|
||||
(udp_len & 0xff) as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
buf.resize(total_size, 0xAA);
|
||||
|
||||
ZCPacket::new_with_payload(&buf)
|
||||
}
|
||||
|
||||
fn no_tun_config(name: &str, ipv4: &str) -> TomlConfigLoader {
|
||||
let config = TomlConfigLoader::default();
|
||||
config.set_inst_name(name.to_owned());
|
||||
config.set_ipv4(Some(ipv4.parse().unwrap()));
|
||||
let mut flags = config.get_flags();
|
||||
flags.no_tun = true;
|
||||
config.set_flags(flags);
|
||||
config
|
||||
}
|
||||
@@ -129,6 +129,7 @@ impl Compressor for DefaultCompressor {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "DefaultCompressor"))]
|
||||
async fn decompress(&self, zc_packet: &mut ZCPacket) -> Result<(), Error> {
|
||||
let pm_header = zc_packet.peer_manager_header().unwrap();
|
||||
if !pm_header.is_compressed() {
|
||||
|
||||
@@ -70,6 +70,8 @@ async fn set_bind_addr_for_peer_connector(
|
||||
let ips = global_ctx.get_ip_collector().collect_ip_addrs().await;
|
||||
if is_ipv4 {
|
||||
let mut bind_addrs = vec![];
|
||||
// Always include loopback so localhost connections work
|
||||
bind_addrs.push(std::net::SocketAddr::from(([127, 0, 0, 1], 0)));
|
||||
for ipv4 in ips.interface_ipv4s {
|
||||
let socket_addr = SocketAddrV4::new(ipv4.into(), 0).into();
|
||||
bind_addrs.push(socket_addr);
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(crate) struct CidrSet {
|
||||
mapped_to_real: Arc<DashMap<cidr::Ipv4Cidr, cidr::Ipv4Cidr>>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure_all)]
|
||||
impl CidrSet {
|
||||
pub fn new(global_ctx: ArcGlobalCtx) -> Self {
|
||||
let mut ret = Self {
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::{
|
||||
};
|
||||
|
||||
use byteorder::WriteBytesExt as _;
|
||||
use bytes::{BufMut, BytesMut};
|
||||
use bytes::{Buf, BufMut, BytesMut};
|
||||
use cidr::{Ipv4Inet, Ipv6Inet};
|
||||
use futures::{SinkExt, Stream, StreamExt, lock::BiLock, ready};
|
||||
use pin_project_lite::pin_project;
|
||||
@@ -180,12 +180,13 @@ impl ZCPacketToBytes for TunZCPacketToBytes {
|
||||
assert!(payload_offset >= 4);
|
||||
|
||||
let ret = if self.has_packet_info {
|
||||
let mut inner = inner.split_off(payload_offset - 4);
|
||||
inner.advance(payload_offset - 4);
|
||||
let proto = infer_proto(&inner[4..]);
|
||||
self.fill_packet_info(&mut inner[0..4], proto)?;
|
||||
inner
|
||||
} else {
|
||||
inner.split_off(payload_offset)
|
||||
inner.advance(payload_offset);
|
||||
inner
|
||||
};
|
||||
|
||||
tracing::debug!(?ret, ?payload_offset, "convert zc packet to tun packet");
|
||||
|
||||
@@ -61,13 +61,19 @@ pub type PacketRecvChanReceiver = tokio::sync::mpsc::Receiver<ZCPacket>;
|
||||
pub fn create_packet_recv_chan() -> (PacketRecvChan, PacketRecvChanReceiver) {
|
||||
hotpath::channel!(tokio::sync::mpsc::channel(128))
|
||||
}
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PacketRecvChan"))]
|
||||
pub async fn recv_packet_from_chan(
|
||||
packet_recv_chan_receiver: &mut PacketRecvChanReceiver,
|
||||
) -> Result<ZCPacket, anyhow::Error> {
|
||||
packet_recv_chan_receiver
|
||||
.recv()
|
||||
.await
|
||||
.ok_or(anyhow::anyhow!("recv_packet_from_chan failed"))
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
match packet_recv_chan_receiver.try_recv() {
|
||||
Ok(pkt) => Ok(pkt),
|
||||
Err(TryRecvError::Empty) => packet_recv_chan_receiver
|
||||
.recv()
|
||||
.await
|
||||
.ok_or(anyhow::anyhow!("recv_packet_from_chan failed")),
|
||||
Err(TryRecvError::Disconnected) => Err(anyhow::anyhow!("recv_packet_from_chan failed")),
|
||||
}
|
||||
}
|
||||
|
||||
pub const PUBLIC_SERVER_HOSTNAME_PREFIX: &str = "PublicServer_";
|
||||
|
||||
@@ -268,6 +268,12 @@ impl Peer {
|
||||
self.default_conn_id.load()
|
||||
}
|
||||
|
||||
pub fn set_batch_threshold(&self, n: u32) {
|
||||
for conn in self.conns.iter() {
|
||||
conn.value().set_batch_threshold(n);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_peer_identity_type(&self) -> Option<PeerIdentityType> {
|
||||
self.peer_identity_type.load()
|
||||
}
|
||||
|
||||
@@ -370,7 +370,15 @@ impl PeerConn {
|
||||
let throughput = peer_conn_tunnel_filter.filter_output();
|
||||
let filter_chain = TunnelFilterChain::new(session_filter.clone(), peer_conn_tunnel_filter);
|
||||
let peer_conn_tunnel = TunnelWithFilter::new(tunnel, filter_chain);
|
||||
let mut mpsc_tunnel = MpscTunnel::new(peer_conn_tunnel, Some(Duration::from_secs(7)));
|
||||
let supports_direct = peer_conn_tunnel
|
||||
.info()
|
||||
.map(|i| matches!(i.tunnel_type.as_str(), "ring" | "udp" | "tcp"))
|
||||
.unwrap_or(false);
|
||||
let mut mpsc_tunnel = if supports_direct {
|
||||
MpscTunnel::new_direct(peer_conn_tunnel)
|
||||
} else {
|
||||
MpscTunnel::new(peer_conn_tunnel, Some(Duration::from_secs(7)))
|
||||
};
|
||||
|
||||
let (recv, sink) = (mpsc_tunnel.get_stream(), mpsc_tunnel.get_sink());
|
||||
|
||||
@@ -443,6 +451,10 @@ impl PeerConn {
|
||||
self.conn_id
|
||||
}
|
||||
|
||||
pub fn set_batch_threshold(&self, n: u32) {
|
||||
self.sink.set_batch_threshold(n);
|
||||
}
|
||||
|
||||
pub fn set_is_hole_punched(&mut self, is_hole_punched: bool) {
|
||||
self.is_hole_punched = is_hole_punched;
|
||||
}
|
||||
|
||||
@@ -1015,7 +1015,17 @@ impl PeerManager {
|
||||
|
||||
self.tasks.lock().await.spawn(async move {
|
||||
tracing::trace!("start_peer_recv");
|
||||
while let Ok(ret) = recv_packet_from_chan(&mut recv).await {
|
||||
loop {
|
||||
let ret = match recv.try_recv() {
|
||||
Ok(pkt) => pkt,
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
|
||||
match recv.recv().await {
|
||||
Some(pkt) => pkt,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
|
||||
};
|
||||
let disable_relay_data = global_ctx.flags_arc().disable_relay_data;
|
||||
let Err(mut ret) = Self::try_handle_foreign_network_packet(
|
||||
ret,
|
||||
@@ -1153,9 +1163,11 @@ impl PeerManager {
|
||||
|
||||
self_rx_bytes.add(buf_len as u64);
|
||||
self_rx_packets.inc();
|
||||
traffic_metrics
|
||||
.record_rx(from_peer_id, packet_type, buf_len as u64)
|
||||
.await;
|
||||
if !traffic_metrics.record_rx_fast(from_peer_id, packet_type, buf_len as u64) {
|
||||
traffic_metrics
|
||||
.record_rx(from_peer_id, packet_type, buf_len as u64)
|
||||
.await;
|
||||
}
|
||||
compress_rx_bytes_before.add(buf_len as u64);
|
||||
|
||||
let compressor = DefaultCompressor {};
|
||||
@@ -1556,8 +1568,8 @@ impl PeerManager {
|
||||
&& (peers.has_peer(gateway) || foreign_network_client.has_next_hop(gateway))
|
||||
{
|
||||
relay_peer_map.send_msg(msg, dst_peer_id, policy).await
|
||||
} else if peers.has_peer(dst_peer_id) {
|
||||
peers.send_msg_directly(msg, dst_peer_id).await
|
||||
} else if let Some(peer) = peers.get_peer_by_id(dst_peer_id) {
|
||||
peer.send_msg(msg).await
|
||||
} else if foreign_network_client.has_next_hop(dst_peer_id) {
|
||||
foreign_network_client.send_msg(msg, dst_peer_id).await
|
||||
} else if let Some(gateway) = peers.get_gateway_peer_id(dst_peer_id, policy.clone()).await {
|
||||
@@ -1582,7 +1594,9 @@ impl PeerManager {
|
||||
if send_result.is_ok()
|
||||
&& let Some(metrics) = direct_tx_metrics
|
||||
{
|
||||
metrics.record_tx(dst_peer_id, packet_type, msg_len).await;
|
||||
if !metrics.record_tx_fast(dst_peer_id, packet_type, msg_len) {
|
||||
metrics.record_tx(dst_peer_id, packet_type, msg_len).await;
|
||||
}
|
||||
}
|
||||
|
||||
send_result
|
||||
@@ -1629,6 +1643,7 @@ impl PeerManager {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
|
||||
pub async fn get_msg_dst_peer_ipv4(&self, ipv4_addr: &Ipv4Addr) -> (Vec<PeerId>, bool) {
|
||||
let mut is_exit_node = false;
|
||||
let mut dst_peers = vec![];
|
||||
@@ -1697,6 +1712,7 @@ impl PeerManager {
|
||||
(dst_peers, is_exit_node)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
|
||||
pub async fn try_compress_and_encrypt(
|
||||
compress_algo: CompressorAlgo,
|
||||
encryptor: &Arc<dyn Encryptor + 'static>,
|
||||
@@ -1714,6 +1730,7 @@ impl PeerManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
|
||||
pub async fn send_msg_by_ip(
|
||||
&self,
|
||||
mut msg: ZCPacket,
|
||||
@@ -1977,6 +1994,18 @@ impl PeerManager {
|
||||
self.peers.clone()
|
||||
}
|
||||
|
||||
pub fn set_peer_conn_batch_threshold(&self, n: u32) {
|
||||
let peers = self.peers.clone();
|
||||
tokio::spawn(async move {
|
||||
let peer_ids = peers.list_peers();
|
||||
for peer_id in peer_ids {
|
||||
if let Some(peer) = peers.get_peer_by_id(peer_id) {
|
||||
peer.set_batch_threshold(n);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get_relay_peer_map(&self) -> Arc<RelayPeerMap> {
|
||||
self.relay_peer_map.clone()
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ pub struct PeerMap {
|
||||
alive_client_urls: Arc<Mutex<multimap::MultiMap<url::Url, PeerConnId>>>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure_all)]
|
||||
impl PeerMap {
|
||||
pub fn new(packet_send: PacketRecvChan, global_ctx: ArcGlobalCtx, my_peer_id: PeerId) -> Self {
|
||||
PeerMap {
|
||||
@@ -132,7 +133,6 @@ impl PeerMap {
|
||||
peer_id == self.my_peer_id || self.peer_map.contains_key(&peer_id)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerMap"))]
|
||||
pub async fn send_msg_directly(&self, msg: ZCPacket, dst_peer_id: PeerId) -> Result<(), Error> {
|
||||
if dst_peer_id == self.my_peer_id {
|
||||
let packet_send = self.packet_send.clone();
|
||||
@@ -164,7 +164,6 @@ impl PeerMap {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerMap"))]
|
||||
pub async fn get_gateway_peer_id(
|
||||
&self,
|
||||
dst_peer_id: PeerId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{future::Future, sync::Arc};
|
||||
use std::{future::Future, sync::atomic::{AtomicU64, Ordering}, sync::Arc};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use futures::future::BoxFuture;
|
||||
@@ -18,16 +18,54 @@ pub(crate) enum InstanceLabelKind {
|
||||
From,
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
const TRAFFIC_BATCH_SIZE: u64 = 128;
|
||||
#[cfg(test)]
|
||||
const TRAFFIC_BATCH_SIZE: u64 = 1;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TrafficCounters {
|
||||
bytes: CounterHandle,
|
||||
packets: CounterHandle,
|
||||
batch: Arc<TrafficBatch>,
|
||||
}
|
||||
|
||||
struct TrafficBatch {
|
||||
bytes: AtomicU64,
|
||||
packets: AtomicU64,
|
||||
}
|
||||
|
||||
impl TrafficCounters {
|
||||
fn new(bytes: CounterHandle, packets: CounterHandle) -> Self {
|
||||
Self {
|
||||
bytes,
|
||||
packets,
|
||||
batch: Arc::new(TrafficBatch {
|
||||
bytes: AtomicU64::new(0),
|
||||
packets: AtomicU64::new(0),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_sample(&self, bytes: u64) {
|
||||
self.bytes.add(bytes);
|
||||
self.packets.inc();
|
||||
let prev = self.batch.packets.fetch_add(1, Ordering::Relaxed);
|
||||
self.batch.bytes.fetch_add(bytes, Ordering::Relaxed);
|
||||
if (prev + 1) % TRAFFIC_BATCH_SIZE == 0 {
|
||||
let b = self.batch.bytes.swap(0, Ordering::Relaxed);
|
||||
self.bytes.add(b);
|
||||
self.packets.add(TRAFFIC_BATCH_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
let b = self.batch.bytes.swap(0, Ordering::Relaxed);
|
||||
let p = self.batch.packets.swap(0, Ordering::Relaxed);
|
||||
if b > 0 {
|
||||
self.bytes.add(b);
|
||||
}
|
||||
if p > 0 {
|
||||
self.packets.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,14 +98,14 @@ impl AggregateTrafficMetrics {
|
||||
let label_set =
|
||||
LabelSet::new().with_label_type(LabelType::NetworkName(network_name.clone()));
|
||||
Self {
|
||||
tx: TrafficCounters {
|
||||
bytes: stats_mgr.get_counter(tx_bytes_metric, label_set.clone()),
|
||||
packets: stats_mgr.get_counter(tx_packets_metric, label_set.clone()),
|
||||
},
|
||||
rx: TrafficCounters {
|
||||
bytes: stats_mgr.get_counter(rx_bytes_metric, label_set.clone()),
|
||||
packets: stats_mgr.get_counter(rx_packets_metric, label_set),
|
||||
},
|
||||
tx: TrafficCounters::new(
|
||||
stats_mgr.get_counter(tx_bytes_metric, label_set.clone()),
|
||||
stats_mgr.get_counter(tx_packets_metric, label_set.clone()),
|
||||
),
|
||||
rx: TrafficCounters::new(
|
||||
stats_mgr.get_counter(rx_bytes_metric, label_set.clone()),
|
||||
stats_mgr.get_counter(rx_packets_metric, label_set),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,10 +160,10 @@ impl LogicalTrafficMetrics {
|
||||
let label_set =
|
||||
LabelSet::new().with_label_type(LabelType::NetworkName(network_name.clone()));
|
||||
Self {
|
||||
total: TrafficCounters {
|
||||
bytes: stats_mgr.get_counter(total_bytes_metric, label_set.clone()),
|
||||
packets: stats_mgr.get_counter(total_packets_metric, label_set),
|
||||
},
|
||||
total: TrafficCounters::new(
|
||||
stats_mgr.get_counter(total_bytes_metric, label_set.clone()),
|
||||
stats_mgr.get_counter(total_packets_metric, label_set),
|
||||
),
|
||||
stats_mgr,
|
||||
network_name,
|
||||
instance_bytes_metric,
|
||||
@@ -135,6 +173,22 @@ impl LogicalTrafficMetrics {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_fast(&self, peer_id: PeerId, bytes: u64) -> bool {
|
||||
self.total.add_sample(bytes);
|
||||
if let Some(entry) = self.per_peer.get(&peer_id)
|
||||
&& entry.value().is_resolved()
|
||||
{
|
||||
let counters = match entry.value() {
|
||||
CachedPeerTrafficCounters::Resolved(c)
|
||||
| CachedPeerTrafficCounters::Unknown(c) => c,
|
||||
};
|
||||
counters.add_sample(bytes);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn record_with_resolver<F, Fut>(
|
||||
&self,
|
||||
peer_id: PeerId,
|
||||
@@ -214,14 +268,12 @@ impl LogicalTrafficMetrics {
|
||||
let label_set = LabelSet::new()
|
||||
.with_label_type(LabelType::NetworkName(self.network_name.clone()))
|
||||
.with_label_type(instance_label);
|
||||
TrafficCounters {
|
||||
bytes: self
|
||||
.stats_mgr
|
||||
TrafficCounters::new(
|
||||
self.stats_mgr
|
||||
.get_counter(self.instance_bytes_metric, label_set.clone()),
|
||||
packets: self
|
||||
.stats_mgr
|
||||
self.stats_mgr
|
||||
.get_counter(self.instance_packets_metric, label_set),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +356,15 @@ impl TrafficMetricRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_tx_fast(&self, peer_id: PeerId, packet_type: u8, bytes: u64) -> bool {
|
||||
if peer_id == self.my_peer_id {
|
||||
return true;
|
||||
}
|
||||
self.tx_metrics
|
||||
.select(traffic_kind(packet_type))
|
||||
.record_fast(peer_id, bytes)
|
||||
}
|
||||
|
||||
pub(crate) async fn record_tx(&self, peer_id: PeerId, packet_type: u8, bytes: u64) {
|
||||
if peer_id == self.my_peer_id {
|
||||
return;
|
||||
@@ -314,6 +375,15 @@ impl TrafficMetricRecorder {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) fn record_rx_fast(&self, peer_id: PeerId, packet_type: u8, bytes: u64) -> bool {
|
||||
if peer_id == self.my_peer_id {
|
||||
return true;
|
||||
}
|
||||
self.rx_metrics
|
||||
.select(traffic_kind(packet_type))
|
||||
.record_fast(peer_id, bytes)
|
||||
}
|
||||
|
||||
pub(crate) async fn record_rx(&self, peer_id: PeerId, packet_type: u8, bytes: u64) {
|
||||
if peer_id == self.my_peer_id {
|
||||
return;
|
||||
|
||||
+158
-15
@@ -1,6 +1,13 @@
|
||||
// this mod wrap tunnel to a mpsc tunnel, based on crossbeam_channel
|
||||
|
||||
use std::{pin::Pin, time::Duration};
|
||||
use std::{
|
||||
cell::UnsafeCell,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
sync::atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
task::Poll,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use tokio::time::timeout;
|
||||
@@ -11,39 +18,151 @@ use super::{Tunnel, TunnelError, ZCPacketSink, ZCPacketStream, packet_def::ZCPac
|
||||
|
||||
use tokio::sync::mpsc::{Receiver, Sender, channel, error::TrySendError};
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
// use tachyonix::{channel, Receiver, Sender, TrySendError};
|
||||
|
||||
use futures::SinkExt;
|
||||
|
||||
/// A simple spinlock protecting a sink. The guard is Send because it only
|
||||
/// contains an atomic flag reference (no lifetime-tied borrow like MutexGuard).
|
||||
struct SpinSink {
|
||||
locked: AtomicBool,
|
||||
sink: UnsafeCell<Pin<Box<dyn ZCPacketSink>>>,
|
||||
pending_count: AtomicU32,
|
||||
batch_threshold: AtomicU32,
|
||||
}
|
||||
|
||||
// SAFETY: access is serialized by the spinlock.
|
||||
unsafe impl Send for SpinSink {}
|
||||
unsafe impl Sync for SpinSink {}
|
||||
|
||||
struct SpinGuard<'a> {
|
||||
spin: &'a SpinSink,
|
||||
}
|
||||
|
||||
impl<'a> SpinGuard<'a> {
|
||||
fn as_mut(&mut self) -> Pin<&mut dyn ZCPacketSink> {
|
||||
// SAFETY: we hold the spinlock, so we have exclusive access
|
||||
let sink = unsafe { &mut *self.spin.sink.get() };
|
||||
sink.as_mut()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SpinGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.spin.locked.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
impl SpinSink {
|
||||
fn new(sink: Pin<Box<dyn ZCPacketSink>>) -> Self {
|
||||
Self {
|
||||
locked: AtomicBool::new(false),
|
||||
sink: UnsafeCell::new(sink),
|
||||
pending_count: AtomicU32::new(0),
|
||||
batch_threshold: AtomicU32::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_batch_threshold(&self, n: u32) {
|
||||
self.batch_threshold.store(n, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn try_lock(&self) -> Option<SpinGuard<'_>> {
|
||||
if self
|
||||
.locked
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
Some(SpinGuard { spin: self })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MpscTunnelSender(Sender<ZCPacket>);
|
||||
pub struct MpscTunnelSender {
|
||||
channel_tx: Option<Sender<ZCPacket>>,
|
||||
direct_sink: Option<Arc<SpinSink>>,
|
||||
direct_batch_flush: bool,
|
||||
}
|
||||
|
||||
impl MpscTunnelSender {
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "MpscTunnelSender"))]
|
||||
pub async fn send(&self, item: ZCPacket) -> Result<(), TunnelError> {
|
||||
self.0.send(item).await.with_context(|| "send error")?;
|
||||
Ok(())
|
||||
if let Some(sink) = &self.direct_sink {
|
||||
// Sync fast path: no await needed, returns immediately
|
||||
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)?;
|
||||
let count = sink.pending_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let threshold = sink.batch_threshold.load(Ordering::Relaxed);
|
||||
if count >= threshold {
|
||||
sink.pending_count.store(0, Ordering::Relaxed);
|
||||
// Batch flush: writev all accumulated BufList entries.
|
||||
// RingSink: no-op. FramedWriter: single writev syscall.
|
||||
match guard.as_mut().poll_flush(&mut cx) {
|
||||
Poll::Ready(Err(e)) => return Err(e),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
// Accumulate in BufList, no flush yet
|
||||
return Ok(());
|
||||
}
|
||||
Poll::Ready(Err(e)) => return Err(e),
|
||||
Poll::Pending => return Err(TunnelError::BufferFull),
|
||||
}
|
||||
}
|
||||
return Err(TunnelError::BufferFull);
|
||||
}
|
||||
|
||||
// Channel mode: async with backpressure
|
||||
self.send_async(item).await
|
||||
}
|
||||
|
||||
pub fn try_send(&self, item: ZCPacket) -> Result<(), TunnelError> {
|
||||
self.0.try_send(item).map_err(|e| match e {
|
||||
let tx = self.channel_tx.as_ref().ok_or(TunnelError::Shutdown)?;
|
||||
tx.try_send(item).map_err(|e| match e {
|
||||
TrySendError::Full(_) => TunnelError::BufferFull,
|
||||
TrySendError::Closed(_) => TunnelError::Shutdown,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_batch_threshold(&self, n: u32) {
|
||||
if let Some(sink) = &self.direct_sink {
|
||||
sink.set_batch_threshold(n);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_async(&self, item: ZCPacket) -> Result<(), TunnelError> {
|
||||
let tx = self.channel_tx.as_ref().ok_or(TunnelError::Shutdown)?;
|
||||
match tx.try_send(item) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(TrySendError::Full(item)) => {
|
||||
tx.send(item).await.with_context(|| "send error")?;
|
||||
Ok(())
|
||||
}
|
||||
Err(TrySendError::Closed(_)) => Err(TunnelError::Shutdown),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MpscTunnel<T> {
|
||||
tx: Option<Sender<ZCPacket>>,
|
||||
direct_sink: Option<Arc<SpinSink>>,
|
||||
direct_batch_flush: bool,
|
||||
|
||||
tunnel: T,
|
||||
stream: Option<Pin<Box<dyn ZCPacketStream>>>,
|
||||
|
||||
task: AbortOnDropHandle<()>,
|
||||
task: Option<AbortOnDropHandle<()>>,
|
||||
}
|
||||
|
||||
impl<T: Tunnel> MpscTunnel<T> {
|
||||
pub fn new(tunnel: T, send_timeout: Option<Duration>) -> Self {
|
||||
let (tx, mut rx) = hotpath::channel!(channel(32));
|
||||
let (tx, mut rx) = hotpath::channel!(channel(1024));
|
||||
let (stream, mut sink) = tunnel.split();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
@@ -60,9 +179,28 @@ impl<T: Tunnel> MpscTunnel<T> {
|
||||
|
||||
Self {
|
||||
tx: Some(tx),
|
||||
direct_sink: None,
|
||||
direct_batch_flush: false,
|
||||
tunnel,
|
||||
stream: Some(stream),
|
||||
task: AbortOnDropHandle::new(task),
|
||||
task: Some(AbortOnDropHandle::new(task)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_direct(tunnel: T) -> Self {
|
||||
let (stream, sink) = tunnel.split();
|
||||
let info = tunnel.info();
|
||||
let batch_flush = info
|
||||
.as_ref()
|
||||
.map(|i| matches!(i.tunnel_type.as_str(), "ring" | "udp"))
|
||||
.unwrap_or(false);
|
||||
Self {
|
||||
tx: None,
|
||||
direct_sink: Some(Arc::new(SpinSink::new(sink))),
|
||||
direct_batch_flush: batch_flush,
|
||||
tunnel,
|
||||
stream: Some(stream),
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,12 +265,19 @@ impl<T: Tunnel> MpscTunnel<T> {
|
||||
}
|
||||
|
||||
pub fn get_sink(&self) -> MpscTunnelSender {
|
||||
MpscTunnelSender(self.tx.as_ref().unwrap().clone())
|
||||
MpscTunnelSender {
|
||||
channel_tx: self.tx.as_ref().cloned(),
|
||||
direct_sink: self.direct_sink.clone(),
|
||||
direct_batch_flush: self.direct_batch_flush,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
self.tx.take();
|
||||
self.task.abort();
|
||||
self.direct_sink.take();
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tunnel_info(&self) -> Option<TunnelInfo> {
|
||||
@@ -192,8 +337,7 @@ mod tests {
|
||||
for i in 0..1000000 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
let a = sink1
|
||||
.send(ZCPacket::new_with_payload("hello".as_bytes()))
|
||||
.await;
|
||||
.send_async(ZCPacket::new_with_payload("hello".as_bytes())).await;
|
||||
if a.is_err() {
|
||||
tracing::info!(?a, "t2 exit with err");
|
||||
break;
|
||||
@@ -212,8 +356,7 @@ mod tests {
|
||||
for i in 0..1000000 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
let a = sink2
|
||||
.send(ZCPacket::new_with_payload("hello2".as_bytes()))
|
||||
.await;
|
||||
.send_async(ZCPacket::new_with_payload("hello2".as_bytes())).await;
|
||||
if a.is_err() {
|
||||
tracing::info!(?a, "t3 exit with err");
|
||||
break;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use bytes::Buf;
|
||||
use bytes::Bytes;
|
||||
use bytes::BytesMut;
|
||||
use zerocopy::byteorder::*;
|
||||
use zerocopy::AsBytes;
|
||||
use zerocopy::FromBytes;
|
||||
use zerocopy::FromZeroes;
|
||||
use zerocopy::byteorder::*;
|
||||
|
||||
type DefaultEndian = LittleEndian;
|
||||
|
||||
@@ -483,8 +484,16 @@ impl ZCPacket {
|
||||
let payload_off = ret.packet_type.get_packet_offsets().payload_offset;
|
||||
let total_len = payload_off + payload.len();
|
||||
ret.inner.reserve(total_len);
|
||||
unsafe { ret.inner.set_len(total_len) };
|
||||
ret.mut_payload()[..payload.len()].copy_from_slice(payload);
|
||||
|
||||
// SAFETY: `reserve` guarantees capacity >= total_len.
|
||||
// We zero the header region and copy payload before advancing length,
|
||||
// so every byte in [0..total_len) is initialized before any read.
|
||||
unsafe {
|
||||
let ptr = ret.inner.as_mut_ptr();
|
||||
std::ptr::write_bytes(ptr, 0, payload_off);
|
||||
std::ptr::copy_nonoverlapping(payload.as_ptr(), ptr.add(payload_off), payload.len());
|
||||
ret.inner.set_len(total_len);
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
@@ -492,12 +501,12 @@ impl ZCPacket {
|
||||
let mut ret = Self::new_nic_packet();
|
||||
ret.inner.reserve(cap);
|
||||
let total_len = ret.packet_type.get_packet_offsets().payload_offset - packet_info_len;
|
||||
unsafe { ret.inner.set_len(total_len) };
|
||||
ret.inner.resize(total_len, 0);
|
||||
ret
|
||||
}
|
||||
|
||||
pub fn new_for_foreign_network(
|
||||
network_name: &String,
|
||||
network_name: &str,
|
||||
dst_peer_id: u32,
|
||||
foreign_zc_packet: &ZCPacket,
|
||||
) -> Self {
|
||||
@@ -506,26 +515,71 @@ impl ZCPacket {
|
||||
foreign_network_hdr.get_header_len() + foreign_zc_packet.tunnel_payload().len();
|
||||
|
||||
let mut ret = Self::new_nic_packet();
|
||||
let payload_off = ret.packet_type.get_packet_offsets().payload_offset;
|
||||
ret.inner.reserve(payload_off + total_payload_len);
|
||||
unsafe { ret.inner.set_len(payload_off + total_payload_len) };
|
||||
let offsets = ret.packet_type.get_packet_offsets();
|
||||
let payload_off = offsets.payload_offset;
|
||||
let pm_hdr_off = offsets.peer_manager_header_offset;
|
||||
let total_len = payload_off + total_payload_len;
|
||||
ret.inner.reserve(total_len);
|
||||
|
||||
let fixed_hdr_len = std::mem::size_of::<ForeignNetworkPacketHeader>();
|
||||
ret.mut_payload()[..fixed_hdr_len].copy_from_slice(foreign_network_hdr.as_bytes());
|
||||
|
||||
let name_offset = foreign_network_hdr.network_name_offset.get() as usize;
|
||||
let name_len = foreign_network_hdr.network_name_len.get() as usize;
|
||||
ret.mut_payload()[name_offset..name_offset + name_len]
|
||||
.copy_from_slice(network_name.as_bytes());
|
||||
let foreign_payload = foreign_zc_packet.tunnel_payload();
|
||||
|
||||
ret.mut_payload()[foreign_network_hdr.get_header_len()..]
|
||||
.copy_from_slice(foreign_zc_packet.tunnel_payload());
|
||||
// Construct the PeerManagerHeader on the stack so we can write it
|
||||
// directly into the buffer, avoiding a separate mut_peer_manager_header()
|
||||
// call after set_len.
|
||||
let pm_hdr = PeerManagerHeader {
|
||||
from_peer_id: 0.into(),
|
||||
to_peer_id: 0.into(),
|
||||
packet_type: PacketType::ForeignNetworkPacket as u8,
|
||||
flags: 0,
|
||||
forward_counter: 0,
|
||||
reserved: 0,
|
||||
len: U32::new(total_payload_len as u32),
|
||||
};
|
||||
|
||||
let hdr = ret.mut_peer_manager_header().unwrap();
|
||||
hdr.from_peer_id = 0.into();
|
||||
hdr.to_peer_id = 0.into();
|
||||
hdr.packet_type = PacketType::ForeignNetworkPacket as u8;
|
||||
hdr.len.set(total_payload_len as u32);
|
||||
// SAFETY: `reserve` guarantees capacity >= total_len.
|
||||
// We zero only the tunnel-header reserved space [0..pm_hdr_off], write
|
||||
// the PeerManagerHeader directly at pm_hdr_off, then copy the foreign
|
||||
// network header, network name, and payload. Every byte in [0..total_len)
|
||||
// is initialized before set_len.
|
||||
unsafe {
|
||||
let ptr = ret.inner.as_mut_ptr();
|
||||
|
||||
// Zero the tunnel header reserved space only (not the PM header region)
|
||||
std::ptr::write_bytes(ptr, 0, pm_hdr_off);
|
||||
|
||||
// Write PeerManagerHeader directly
|
||||
std::ptr::copy_nonoverlapping(
|
||||
pm_hdr.as_bytes().as_ptr(),
|
||||
ptr.add(pm_hdr_off),
|
||||
std::mem::size_of::<PeerManagerHeader>(),
|
||||
);
|
||||
|
||||
// Copy foreign network fixed header
|
||||
std::ptr::copy_nonoverlapping(
|
||||
foreign_network_hdr.as_bytes().as_ptr(),
|
||||
ptr.add(payload_off),
|
||||
fixed_hdr_len,
|
||||
);
|
||||
|
||||
// Copy network name
|
||||
std::ptr::copy_nonoverlapping(
|
||||
network_name.as_ptr(),
|
||||
ptr.add(payload_off + name_offset),
|
||||
name_len,
|
||||
);
|
||||
|
||||
// Copy foreign payload
|
||||
std::ptr::copy_nonoverlapping(
|
||||
foreign_payload.as_ptr(),
|
||||
ptr.add(payload_off + foreign_network_hdr.get_header_len()),
|
||||
foreign_payload.len(),
|
||||
);
|
||||
|
||||
ret.inner.set_len(total_len);
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
@@ -585,7 +639,8 @@ impl ZCPacket {
|
||||
}
|
||||
|
||||
pub fn payload_bytes(mut self) -> BytesMut {
|
||||
self.inner.split_off(self.payload_offset())
|
||||
self.inner.advance(self.payload_offset());
|
||||
self.inner
|
||||
}
|
||||
|
||||
pub fn peer_manager_header(&self) -> Option<&PeerManagerHeader> {
|
||||
@@ -650,11 +705,12 @@ impl ZCPacket {
|
||||
}
|
||||
|
||||
pub fn tunnel_payload_bytes(mut self) -> BytesMut {
|
||||
self.inner.split_off(
|
||||
self.inner.advance(
|
||||
self.packet_type
|
||||
.get_packet_offsets()
|
||||
.peer_manager_header_offset,
|
||||
)
|
||||
);
|
||||
self.inner
|
||||
}
|
||||
|
||||
pub fn convert_type(mut self, target_packet_type: ZCPacketType) -> Self {
|
||||
@@ -695,12 +751,13 @@ impl ZCPacket {
|
||||
.get_packet_offsets()
|
||||
.peer_manager_header_offset;
|
||||
let mut buf = BytesMut::with_capacity(new_pm_offset + tunnel_payload.len());
|
||||
unsafe { buf.set_len(new_pm_offset) };
|
||||
buf.resize(new_pm_offset, 0);
|
||||
buf.extend_from_slice(tunnel_payload);
|
||||
return Self::new_from_buf(buf, target_packet_type);
|
||||
}
|
||||
|
||||
Self::new_from_buf(self.inner.split_off(new_offset), target_packet_type)
|
||||
self.inner.advance(new_offset);
|
||||
Self::new_from_buf(self.inner, target_packet_type)
|
||||
}
|
||||
|
||||
pub fn into_bytes(self) -> Bytes {
|
||||
|
||||
@@ -130,6 +130,7 @@ impl RingSink {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure_all)]
|
||||
impl Sink<SinkItem> for RingSink {
|
||||
type Error = SinkError;
|
||||
|
||||
|
||||
+139
-3
@@ -266,14 +266,151 @@ fn get_zcpacket_from_buf(buf: BytesMut, allow_stun: bool) -> Result<ZCPacket, Tu
|
||||
Ok(zc_packet)
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
#[cfg(feature = "udp-gso")]
|
||||
const UDP_BATCH_SIZE: usize = 8;
|
||||
|
||||
#[cfg(feature = "udp-gso")]
|
||||
async fn forward_from_ring_to_udp(
|
||||
mut ring_recv: RingStream,
|
||||
socket: &Arc<UdpSocket>,
|
||||
addr: &SocketAddr,
|
||||
conn_id: u32,
|
||||
) -> Option<TunnelError> {
|
||||
tracing::debug!("udp forward from ring to udp");
|
||||
use quinn_udp::{Transmit, UdpSockRef, UdpSocketState};
|
||||
use std::io;
|
||||
|
||||
tracing::debug!("udp forward from ring to udp (GSO batch)");
|
||||
|
||||
let udp_state = match UdpSocketState::new(UdpSockRef::from(&**socket)) {
|
||||
Ok(state) => state,
|
||||
Err(e) => {
|
||||
tracing::warn!(?e, "failed to init UdpSocketState, fallback to per-packet send");
|
||||
return forward_from_ring_to_udp_fallback(ring_recv, socket, addr, conn_id).await;
|
||||
}
|
||||
};
|
||||
let max_gso = udp_state.max_gso_segments();
|
||||
tracing::info!(max_gso, "udp GSO segments supported");
|
||||
|
||||
loop {
|
||||
// 1. Get first packet (await)
|
||||
let first = match ring_recv.next().await {
|
||||
Some(Ok(pkt)) => convert_to_udp_bytes(pkt, conn_id),
|
||||
Some(Err(e)) => return Some(e),
|
||||
None => return None,
|
||||
};
|
||||
|
||||
// 2. Try to drain more packets (non-blocking via poll_next with noop waker)
|
||||
use std::pin::Pin;
|
||||
use std::task::{Poll, Context};
|
||||
use futures::task::noop_waker;
|
||||
use futures::Stream;
|
||||
let mut batch: Vec<bytes::Bytes> = vec![first];
|
||||
while batch.len() < UDP_BATCH_SIZE.min(max_gso) {
|
||||
let waker = noop_waker();
|
||||
let mut cx = Context::from_waker(&waker);
|
||||
match Pin::new(&mut ring_recv).poll_next(&mut cx) {
|
||||
Poll::Ready(Some(Ok(pkt))) => {
|
||||
batch.push(convert_to_udp_bytes(pkt, conn_id));
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check if all same size (GSO requirement)
|
||||
let seg_size = batch[0].len();
|
||||
let all_same = batch.iter().all(|b| b.len() == seg_size);
|
||||
|
||||
if batch.len() == 1 || !all_same || max_gso == 1 {
|
||||
// Fallback: send individually
|
||||
for buf in &batch {
|
||||
if let Err(e) = send_one(&udp_state, socket, addr, buf).await {
|
||||
return Some(TunnelError::IOError(e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// GSO batch: concatenate + single sendmsg
|
||||
let mut contents = Vec::with_capacity(seg_size * batch.len());
|
||||
for buf in &batch {
|
||||
contents.extend_from_slice(buf);
|
||||
}
|
||||
let transmit = Transmit {
|
||||
destination: *addr,
|
||||
ecn: None,
|
||||
contents: &contents,
|
||||
segment_size: Some(seg_size),
|
||||
src_ip: None,
|
||||
};
|
||||
if let Err(e) = send_one_gso(&udp_state, socket, &transmit).await {
|
||||
return Some(TunnelError::IOError(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "udp-gso")]
|
||||
fn convert_to_udp_bytes(mut packet: ZCPacket, conn_id: u32) -> bytes::Bytes {
|
||||
let mut packet = packet.convert_type(ZCPacketType::UDP);
|
||||
let udp_payload_len = packet.udp_payload().len();
|
||||
let header = packet.mut_udp_tunnel_header().unwrap();
|
||||
header.conn_id.set(conn_id);
|
||||
header.len.set(udp_payload_len as u16);
|
||||
header.msg_type = UdpPacketType::Data as u8;
|
||||
packet.into_bytes()
|
||||
}
|
||||
|
||||
#[cfg(feature = "udp-gso")]
|
||||
async fn send_one(
|
||||
udp_state: &quinn_udp::UdpSocketState,
|
||||
socket: &Arc<UdpSocket>,
|
||||
addr: &SocketAddr,
|
||||
buf: &[u8],
|
||||
) -> Result<(), std::io::Error> {
|
||||
use quinn_udp::{Transmit, UdpSockRef};
|
||||
let transmit = Transmit {
|
||||
destination: *addr,
|
||||
ecn: None,
|
||||
contents: buf,
|
||||
segment_size: None,
|
||||
src_ip: None,
|
||||
};
|
||||
send_one_gso(udp_state, socket, &transmit).await
|
||||
}
|
||||
|
||||
#[cfg(feature = "udp-gso")]
|
||||
async fn send_one_gso(
|
||||
udp_state: &quinn_udp::UdpSocketState,
|
||||
socket: &Arc<UdpSocket>,
|
||||
transmit: &quinn_udp::Transmit<'_>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
use quinn_udp::UdpSockRef;
|
||||
loop {
|
||||
match udp_state.send(UdpSockRef::from(&**socket), transmit) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "udp-gso"))]
|
||||
async fn forward_from_ring_to_udp(
|
||||
mut ring_recv: RingStream,
|
||||
socket: &Arc<UdpSocket>,
|
||||
addr: &SocketAddr,
|
||||
conn_id: u32,
|
||||
) -> Option<TunnelError> {
|
||||
forward_from_ring_to_udp_fallback(ring_recv, socket, addr, conn_id).await
|
||||
}
|
||||
|
||||
async fn forward_from_ring_to_udp_fallback(
|
||||
mut ring_recv: RingStream,
|
||||
socket: &Arc<UdpSocket>,
|
||||
addr: &SocketAddr,
|
||||
conn_id: u32,
|
||||
) -> Option<TunnelError> {
|
||||
tracing::debug!("udp forward from ring to udp (per-packet)");
|
||||
loop {
|
||||
let buf = ring_recv.next().await?;
|
||||
let packet = match buf {
|
||||
@@ -291,7 +428,6 @@ async fn forward_from_ring_to_udp(
|
||||
header.msg_type = UdpPacketType::Data as u8;
|
||||
|
||||
let buf = packet.into_bytes();
|
||||
tracing::trace!(?udp_payload_len, ?buf, "udp forward from ring to udp");
|
||||
let ret = socket.send_to(&buf, &addr).await;
|
||||
if ret.is_err() {
|
||||
return Some(TunnelError::IOError(ret.unwrap_err()));
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# 计划 001:将共享 metrics/throughput 计数改为线程安全实现
|
||||
|
||||
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。
|
||||
>
|
||||
> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/common/stats_manager.rs easytier/src/tunnel/stats.rs easytier/src/tunnel/filter.rs easytier/src/proto/rpc_impl/server.rs easytier/src/tests`
|
||||
> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。
|
||||
|
||||
## 状态
|
||||
|
||||
- **优先级**: P1
|
||||
- **工作量**: M
|
||||
- **风险**: MED
|
||||
- **依赖**: none
|
||||
- **类别**: bug
|
||||
- **计划生成于**: commit `78146d16`, 2026-06-18
|
||||
|
||||
## 为什么重要
|
||||
|
||||
核心 metrics 和 tunnel throughput 计数器当前用 `UnsafeCell<u64>` 保存,并通过 safe methods 在 `Send + Sync` 类型上暴露。VPN 核心运行在多线程 Tokio runtime 上,RPC、tunnel send/receive 和统计快照可能并发访问这些 counters;这会造成 Rust 层面的数据竞争和未定义行为,不只是“统计不准”。完成后应保证所有共享计数使用 atomic 或 lock-backed primitive,且新增并发测试证明 safe API 可多线程调用。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- `easytier/src/common/stats_manager.rs` — 通用 metrics manager;当前 `UnsafeCounter` 和 `MetricData` 手写 `Send + Sync`。
|
||||
- `easytier/src/tunnel/stats.rs` — tunnel throughput 统计;当前单独实现一套 `UnsafeCell` counters。
|
||||
- `easytier/src/tunnel/filter.rs` — `StatsRecorderTunnelFilter` 在 send/receive filter 中更新 `Arc<Throughput>`。
|
||||
- `easytier/src/proto/rpc_impl/server.rs` — RPC server paths 会更新 stats manager counters,可作为并发使用背景参考,不要求修改。
|
||||
|
||||
当前代码摘录:
|
||||
|
||||
```rust
|
||||
// easytier/src/common/stats_manager.rs:406
|
||||
pub unsafe fn add(&self, delta: u64) {
|
||||
let ptr = self.value.get();
|
||||
unsafe {
|
||||
*ptr = (*ptr).saturating_add(delta);
|
||||
}
|
||||
}
|
||||
|
||||
// easytier/src/common/stats_manager.rs:455
|
||||
unsafe impl Send for UnsafeCounter {}
|
||||
unsafe impl Sync for UnsafeCounter {}
|
||||
|
||||
// easytier/src/common/stats_manager.rs:548
|
||||
pub fn add(&self, delta: u64) {
|
||||
unsafe {
|
||||
self.metric_data.counter.add(delta);
|
||||
self.metric_data.touch();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/tunnel/stats.rs:64
|
||||
#[derive(Debug)]
|
||||
pub struct Throughput {
|
||||
tx_bytes: UnsafeCell<u64>,
|
||||
rx_bytes: UnsafeCell<u64>,
|
||||
tx_packets: UnsafeCell<u64>,
|
||||
rx_packets: UnsafeCell<u64>,
|
||||
}
|
||||
|
||||
// easytier/src/tunnel/stats.rs:83
|
||||
unsafe impl Send for Throughput {}
|
||||
unsafe impl Sync for Throughput {}
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/tunnel/filter.rs:265
|
||||
fn before_send(&self, data: SinkItem) -> Option<SinkItem> {
|
||||
self.throughput.record_tx_bytes(data.buf_len() as u64);
|
||||
Some(data)
|
||||
}
|
||||
|
||||
// easytier/src/tunnel/filter.rs:270
|
||||
fn after_received(&self, data: StreamItem) -> Option<StreamItem> {
|
||||
match data {
|
||||
Ok(v) => {
|
||||
self.throughput.record_rx_bytes(v.buf_len() as u64);
|
||||
Some(Ok(v))
|
||||
}
|
||||
Err(e) => Some(Err(e)),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
仓库约定:Rust 代码使用 `anyhow`/`thiserror` 做错误上下文,async tests 使用 `#[tokio::test]`;已有测试集中在 `easytier/src/tests/` 和各模块 `#[cfg(test)]` 中。保持现有 public method names,避免扩大 API 改动。
|
||||
|
||||
## 需要使用的命令
|
||||
|
||||
| Purpose | Command | Expected on success |
|
||||
|---------|---------|---------------------|
|
||||
| Format | `cargo fmt --all -- --check` | exit 0 |
|
||||
| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings |
|
||||
| Feature check | `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` | exit 0 |
|
||||
| Targeted tests | `cargo test --package easytier stats_manager --features full -- --nocapture` | exit 0; new stats tests pass |
|
||||
| Targeted tests | `cargo test --package easytier tunnel::stats --features full -- --nocapture` | exit 0; new throughput tests pass |
|
||||
|
||||
## 临时目录约定
|
||||
|
||||
- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。
|
||||
- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。
|
||||
- 不要把临时产物放进被修改仓库。
|
||||
|
||||
## 范围
|
||||
|
||||
**范围内**(只能修改这些文件):
|
||||
- `easytier/src/common/stats_manager.rs`
|
||||
- `easytier/src/tunnel/stats.rs`
|
||||
- `easytier/src/tunnel/filter.rs`(仅当 type/API 调整需要同步编译)
|
||||
- `easytier/src/tests/mod.rs` 或同文件内 `#[cfg(test)]` 测试(仅用于新增测试入口)
|
||||
|
||||
**范围外**(即使看起来相关也不要触碰):
|
||||
- `easytier/src/peers/*` route 或 RPC 行为;这些由后续计划处理。
|
||||
- `easytier-web/`、`easytier-gui/`、frontend packages。
|
||||
- 任何 public metric names、labels、serialized output shape 的语义变更。
|
||||
|
||||
## Git 工作流
|
||||
|
||||
- Branch: `advisor/001-thread-safe-metrics-throughput`
|
||||
- Commit message style follows existing conventional commits, for example `fix: clarify config parse errors` or `fix(connector): classify manual reconnect timeouts by stage`.
|
||||
- Do NOT push or open a PR unless the operator instructed it.
|
||||
|
||||
## 步骤
|
||||
|
||||
### 步骤 1:替换 `UnsafeCounter` 为 atomic-backed counter
|
||||
|
||||
在 `easytier/src/common/stats_manager.rs` 中将 `UnsafeCounter` 改为持有 `AtomicU64`。保留现有 `new`、`new_with_value`、`add`、`inc`、`get`、`reset`、`set` 方法名,但将它们改成 safe methods,使用 `Ordering::Relaxed` 即可,因为这些 counters 只做统计,不承载同步 happens-before 语义。
|
||||
|
||||
同时移除 `UnsafeCounter` 的 manual `unsafe impl Send/Sync`,让 compiler 从 `AtomicU64` 自动推导。
|
||||
|
||||
**验证**:`cargo test --package easytier stats_manager --features full -- --nocapture` → exit 0;如果此时没有匹配测试,命令应显示 0 failed。
|
||||
|
||||
### 步骤 2:处理 `MetricData::last_updated`
|
||||
|
||||
`MetricData` 当前持有 `UnsafeCell<Instant>`。不要继续共享可变 `Instant`。二选一:
|
||||
|
||||
- 推荐:将 last update 表示为 `AtomicU64`,存储从 `StatsManager` 创建时刻起的 monotonic micros 或 millis;读取时只在内部转换为需要的 age/duration。
|
||||
- 可接受:用 `parking_lot::Mutex<Instant>` 保护 `last_updated`,如果改动最小且性能足够。
|
||||
|
||||
选择方案后,移除 `MetricData` 的 manual `unsafe impl Send/Sync`。保持外部 behavior:counter update 后 last update 被刷新,过期清理逻辑仍能工作。
|
||||
|
||||
**验证**:`cargo clippy --all-targets --features full --all -- -D warnings` → exit 0, no warnings。
|
||||
|
||||
### 步骤 3:替换 `Throughput` 中的 `UnsafeCell` counters
|
||||
|
||||
在 `easytier/src/tunnel/stats.rs` 中将 `tx_bytes`、`rx_bytes`、`tx_packets`、`rx_packets` 改成 `AtomicU64`。`record_tx_bytes` 和 `record_rx_bytes` 使用 `fetch_add(..., Ordering::Relaxed)`;getter 使用 `load(Ordering::Relaxed)`。
|
||||
|
||||
更新 `Clone` 实现为加载旧值后创建新的 atomic counters。移除 `unsafe impl Send for Throughput` 和 `unsafe impl Sync for Throughput`。
|
||||
|
||||
**验证**:`cargo test --package easytier tunnel::stats --features full -- --nocapture` → exit 0;如果没有匹配测试,继续步骤 4 新增测试后重跑。
|
||||
|
||||
### 步骤 4:新增并发回归测试
|
||||
|
||||
为 `stats_manager` 添加一个多线程并发 increment 测试,建议放在 `easytier/src/common/stats_manager.rs` 的 `#[cfg(test)]` 模块中:创建一个 counter handle,启动多个 OS threads 或 `tokio::task::JoinSet`,每个 task 多次 `inc()`,最后断言总数等于预期。
|
||||
|
||||
为 `Throughput` 添加类似测试,创建 `Arc<Throughput>`,并发调用 `record_tx_bytes` 和 `record_rx_bytes`,最后断言 bytes 和 packets 全部精确匹配。
|
||||
|
||||
**验证**:`cargo test --package easytier stats_manager --features full -- --nocapture` 和 `cargo test --package easytier tunnel::stats --features full -- --nocapture` → exit 0;输出中新增测试通过。
|
||||
|
||||
### 步骤 5:运行完整相关门禁
|
||||
|
||||
运行格式、lint 和 feature check。
|
||||
|
||||
**验证**:
|
||||
- `cargo fmt --all -- --check` → exit 0。
|
||||
- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。
|
||||
- `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` → exit 0。
|
||||
|
||||
## 测试计划
|
||||
|
||||
- 新增 `stats_manager` 并发 increment 测试:覆盖多线程 safe API 读写。
|
||||
- 新增 `Throughput` 并发 tx/rx 测试:覆盖 send/receive counters 同时更新。
|
||||
- 现有 tunnel filter 行为不需要改业务测试,只需保证编译和 clippy 通过。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- [ ] `easytier/src/common/stats_manager.rs` 不再包含 `UnsafeCell`-backed counter 或 manual `unsafe impl Send/Sync` for metric data。
|
||||
- [ ] `easytier/src/tunnel/stats.rs` 不再包含 `UnsafeCell<u64>` 或 manual `unsafe impl Send/Sync` for `Throughput`。
|
||||
- [ ] 新增并发测试存在并通过。
|
||||
- [ ] `cargo fmt --all -- --check` exits 0。
|
||||
- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。
|
||||
- [ ] `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` exits 0。
|
||||
- [ ] 没有修改范围外文件(`git status --short` 仅显示本计划范围内文件和 `plans/README.md` 状态更新)。
|
||||
- [ ] 已更新 `plans/README.md` 中本计划的状态行。
|
||||
|
||||
## STOP 条件
|
||||
|
||||
- 当前状态中列出位置的代码与摘录不匹配。
|
||||
- 你发现 `last_updated` 的 public API 依赖真实 `Instant` 值,无法用 atomic duration 或 mutex 在范围内保持行为。
|
||||
- 修复需要改变 metrics output schema、metric names 或 label semantics。
|
||||
- `cargo clippy` 因 atomic ordering 或 dead code 问题连续两次失败且无法在范围内解决。
|
||||
|
||||
## 维护说明
|
||||
|
||||
- 未来新增统计 primitive 时禁止再用 `UnsafeCell` + manual `Send/Sync` 暴露 safe shared mutation;默认使用 atomics 或明确锁。
|
||||
- reviewer 应重点检查 atomic ordering 是否足够、是否移除了所有 unsafe shared counter paths、测试是否真的并发执行。
|
||||
- 本计划不优化 metrics aggregation 性能;只消除 UB 和数据竞争风险。
|
||||
@@ -0,0 +1,160 @@
|
||||
# 计划 002:为 peer RPC/control packet 队列加入背压和过载行为
|
||||
|
||||
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。
|
||||
>
|
||||
> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/peers/peer_manager.rs easytier/src/peers/foreign_network_manager.rs easytier/src/common/stats_manager.rs easytier/src/tests`
|
||||
> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。
|
||||
|
||||
## 状态
|
||||
|
||||
- **优先级**: P1
|
||||
- **工作量**: M
|
||||
- **风险**: MED
|
||||
- **依赖**: plans/001-thread-safe-metrics-throughput.md
|
||||
- **类别**: perf
|
||||
- **计划生成于**: commit `78146d16`, 2026-06-18
|
||||
|
||||
## 为什么重要
|
||||
|
||||
Peer RPC/control packet transport 当前使用 `mpsc::unbounded_channel()`,network-facing packet processor 对每个 RPC packet 直接 `send(...).unwrap()`。如果远端或本地 relay 突发控制面 packet,队列可以无限增长,导致内存膨胀和控制面延迟;如果 receiver 关闭,`unwrap()` 还会 panic。完成后应有明确 bounded capacity、drop/backpressure policy 和可观测 drop 计数。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- `easytier/src/peers/peer_manager.rs` — local peer RPC transport 队列和 packet processor。
|
||||
- `easytier/src/peers/foreign_network_manager.rs` — foreign-network RPC transport 队列和 relay/local packet ingestion。
|
||||
- `easytier/src/common/stats_manager.rs` — 如果 001 已完成,应复用线程安全 metrics 记录 queue drops。
|
||||
|
||||
当前代码摘录:
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_manager.rs:275
|
||||
// TODO: remove these because we have impl pipeline processor.
|
||||
let (peer_rpc_tspt_sender, peer_rpc_tspt_recv) = mpsc::unbounded_channel();
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_manager.rs:1245
|
||||
struct PeerRpcPacketProcessor {
|
||||
peer_rpc_tspt_sender: UnboundedSender<ZCPacket>,
|
||||
}
|
||||
|
||||
// easytier/src/peers/peer_manager.rs:1257
|
||||
self.peer_rpc_tspt_sender.send(packet).unwrap();
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/foreign_network_manager.rs:362
|
||||
let (rpc_transport_sender, peer_rpc_tspt_recv) = mpsc::unbounded_channel();
|
||||
|
||||
// easytier/src/peers/foreign_network_manager.rs:529
|
||||
rpc_sender.send(zc_packet).unwrap();
|
||||
```
|
||||
|
||||
仓库约定:control-plane errors 通常通过 `tracing::{debug,warn,error}` 记录;packet hot path 应避免 blocking await。已有 data-plane queues elsewhere 倾向显式容量和丢弃策略;本计划应保持 hot path 非阻塞。
|
||||
|
||||
## 需要使用的命令
|
||||
|
||||
| Purpose | Command | Expected on success |
|
||||
|---------|---------|---------------------|
|
||||
| Format | `cargo fmt --all -- --check` | exit 0 |
|
||||
| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings |
|
||||
| Feature check | `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` | exit 0 |
|
||||
| Targeted tests | `cargo test --package easytier peer_manager --features full -- --nocapture` | exit 0; new queue tests pass if present |
|
||||
|
||||
## 临时目录约定
|
||||
|
||||
- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。
|
||||
- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。
|
||||
- 不要把临时产物放进被修改仓库。
|
||||
|
||||
## 范围
|
||||
|
||||
**范围内**(只能修改这些文件):
|
||||
- `easytier/src/peers/peer_manager.rs`
|
||||
- `easytier/src/peers/foreign_network_manager.rs`
|
||||
- `easytier/src/common/stats_manager.rs`(仅用于添加/复用 drop metric names;不要重做 001)
|
||||
- `easytier/src/tests/*`(仅新增/调整本计划相关测试)
|
||||
|
||||
**范围外**(即使看起来相关也不要触碰):
|
||||
- RPC protocol message definitions and generated protobuf code。
|
||||
- Routing semantics、credential trust、foreign network topology logic。
|
||||
- Frontend, web server, GUI。
|
||||
|
||||
## Git 工作流
|
||||
|
||||
- Branch: `advisor/002-bound-peer-rpc-queues`
|
||||
- Commit message style follows existing conventional commits, for example `fix: route_update message is not lag`.
|
||||
- Do NOT push or open a PR unless the operator instructed it.
|
||||
|
||||
## 步骤
|
||||
|
||||
### 步骤 1:定义 bounded capacity 和 overload policy
|
||||
|
||||
在两个文件中引入同一个小常量,建议名称为 `PEER_RPC_PACKET_QUEUE_CAPACITY`,初始值建议 `1024` 或 `4096`。如果已有相近 queue capacity 常量,复用仓库风格。
|
||||
|
||||
Policy 必须明确:packet hot path 不等待;当队列满或 receiver closed 时,丢弃当前 RPC/control packet,记录 `tracing::warn!` 或 rate-limited debug,并增加 drop counter。不要 panic。
|
||||
|
||||
**验证**:`cargo fmt --all -- --check` → exit 0。
|
||||
|
||||
### 步骤 2:替换 `peer_manager.rs` 的 unbounded channel
|
||||
|
||||
将 `mpsc::unbounded_channel()` 替换为 `mpsc::channel(PEER_RPC_PACKET_QUEUE_CAPACITY)`。更新 `RpcTransport`、`PeerRpcPacketProcessor` 字段类型,从 `UnboundedSender`/unbounded receiver 改成 bounded `Sender`/`Receiver`。
|
||||
|
||||
在 `try_process_packet_from_peer` 中不要 `.await`,使用 `try_send(packet)`。如果 `Full` 或 `Closed`,记录并返回 `None`,保持原有“这是 RPC packet,不再进入 data-plane pipeline”的行为。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_manager --features full -- --nocapture` → exit 0;如果没有匹配测试,至少必须编译通过。
|
||||
|
||||
### 步骤 3:替换 `foreign_network_manager.rs` 的 unbounded channel
|
||||
|
||||
同样将 foreign-network RPC transport 改为 bounded channel,并在 ingestion path 使用 `try_send(zc_packet)`。不得保留 `unwrap()`。
|
||||
|
||||
如果两个文件都需要相同 helper,优先在各文件内保持小函数,避免为了复用引入新模块。最小正确改动优先。
|
||||
|
||||
**验证**:`cargo test --package easytier foreign_network_manager --features full -- --nocapture` → exit 0;如果没有匹配测试,至少必须编译通过。
|
||||
|
||||
### 步骤 4:添加队列满/receiver closed 的单元测试或小型回归测试
|
||||
|
||||
尽量在模块内新增不依赖真实网络 namespace 的测试:创建 bounded channel 容量为 1,填满后调用封装的 send helper,断言不会 panic 且返回/drop counter 行为正确。如果代码结构不允许直接测试 private helper,可以抽出一个 file-local helper function,例如 `try_enqueue_rpc_packet(...) -> bool`,测试 helper。
|
||||
|
||||
不要为了测试启动完整三节点网络;这属于慢集成测试,不适合验证 queue behavior。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_rpc_queue --features full -- --nocapture` → exit 0;如果测试名不同,使用实际新增测试过滤器,输出中新增测试通过。
|
||||
|
||||
### 步骤 5:运行完整相关门禁
|
||||
|
||||
**验证**:
|
||||
- `cargo fmt --all -- --check` → exit 0。
|
||||
- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。
|
||||
- `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` → exit 0。
|
||||
|
||||
## 测试计划
|
||||
|
||||
- 新增 queue helper tests,覆盖队列未满、队列满、receiver closed 三种情况。
|
||||
- 如果添加 drop metric,测试满队列时 counter 增加。
|
||||
- 不要求新增 full network integration test;bounded queue behavior 应在 unit-level 可验证。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- [ ] `peer_manager.rs` 不再为 peer RPC transport 使用 `mpsc::unbounded_channel()`。
|
||||
- [ ] `foreign_network_manager.rs` 不再为 foreign-network RPC transport 使用 `mpsc::unbounded_channel()`。
|
||||
- [ ] 相关 packet enqueue path 不再调用 `.unwrap()`。
|
||||
- [ ] 满队列和 receiver closed 有明确非 panic 行为。
|
||||
- [ ] 新增或更新测试覆盖 queue overload behavior。
|
||||
- [ ] `cargo fmt --all -- --check` exits 0。
|
||||
- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。
|
||||
- [ ] `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` exits 0。
|
||||
- [ ] 没有修改范围外文件。
|
||||
- [ ] 已更新 `plans/README.md` 中本计划的状态行。
|
||||
|
||||
## STOP 条件
|
||||
|
||||
- 001 尚未完成,而本计划需要新增 metrics/drop counters;此时先执行 001 或报告阻塞。
|
||||
- `PeerRpcManager` 或 transport trait 要求 unbounded receiver 类型且无法在范围内替换。
|
||||
- 正确实现需要改变 RPC protocol semantics 或 routing trust logic。
|
||||
- bounded queue 导致现有 integration tests 稳定失败,且不能通过容量或 policy 微调解决。
|
||||
|
||||
## 维护说明
|
||||
|
||||
- reviewer 应重点审查 drop policy 是否适合 control-plane:丢弃低优先级 sync packet 可以接受,但不能默默破坏必须可靠的 request/response path。
|
||||
- 后续如果出现 reconnect storm 或 route sync loss,应结合 drop metrics 调整 capacity。
|
||||
- 本计划不实现优先级队列;如果未来需要区分 `RpcReq`、`RpcResp`、`TaRpc` 优先级,应另写计划。
|
||||
@@ -0,0 +1,176 @@
|
||||
# 计划 003:避免 OSPF 对 stale/no-op sync payload 重算路由
|
||||
|
||||
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。
|
||||
>
|
||||
> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/peers/peer_ospf_route.rs easytier/src/tests`
|
||||
> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。
|
||||
|
||||
## 状态
|
||||
|
||||
- **优先级**: P2
|
||||
- **工作量**: S
|
||||
- **风险**: MED
|
||||
- **依赖**: none
|
||||
- **类别**: perf
|
||||
- **计划生成于**: commit `78146d16`, 2026-06-18
|
||||
|
||||
## 为什么重要
|
||||
|
||||
OSPF sync handler 当前已经能判断 `peer_infos` 是否实际写入了更新版本,但函数只返回 `Result<(), Error>`,调用方仍对任何非空 payload 设置 `need_update_route_table = true`。在重复、乱序或旧版本 sync 消息较多时,会触发完整 route-table rebuild,造成不必要 CPU 和锁竞争。完成后只有 stored topology state 变化时才重算路由,同时保持 duplicate peer ID 检查和 trust 更新语义。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- `easytier/src/peers/peer_ospf_route.rs` — OSPF route sync、state mutation 和 route-table rebuild 逻辑都在同一文件中。
|
||||
|
||||
当前代码摘录:
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_ospf_route.rs:868
|
||||
fn update_peer_infos(
|
||||
&self,
|
||||
my_peer_id: PeerId,
|
||||
my_peer_route_id: u64,
|
||||
dst_peer_id: PeerId,
|
||||
peer_infos: &[RoutePeerInfo],
|
||||
raw_peer_infos: &[DynamicMessage],
|
||||
) -> Result<(), Error> {
|
||||
let mut need_inc_version = false;
|
||||
// ...
|
||||
if need_inc_version {
|
||||
self.version.inc();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_ospf_route.rs:3623
|
||||
service_impl.synced_route_info.update_peer_infos(
|
||||
my_peer_id,
|
||||
service_impl.my_peer_route_id,
|
||||
from_peer_id,
|
||||
pi,
|
||||
rpi,
|
||||
)?;
|
||||
// ...
|
||||
session.update_dst_saved_peer_info_version(pi, from_peer_id);
|
||||
need_update_route_table = true;
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_ospf_route.rs:3647
|
||||
service_impl.synced_route_info.update_conn_info(conn_info);
|
||||
session.update_dst_saved_conn_info_version(conn_info, from_peer_id);
|
||||
need_update_route_table = true;
|
||||
```
|
||||
|
||||
仓库约定:性能修复必须保守;route correctness 优先于少重算。已有 `foreign_network_changed` 风格 change flag,应匹配这种模式,不要引入复杂 scheduler。
|
||||
|
||||
## 需要使用的命令
|
||||
|
||||
| Purpose | Command | Expected on success |
|
||||
|---------|---------|---------------------|
|
||||
| Format | `cargo fmt --all -- --check` | exit 0 |
|
||||
| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings |
|
||||
| Targeted tests | `cargo test --package easytier peer_ospf_route --features full -- --nocapture` | exit 0; new change-flag tests pass if present |
|
||||
| Integration tests | `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` | exit 0 |
|
||||
|
||||
## 临时目录约定
|
||||
|
||||
- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。
|
||||
- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。
|
||||
- 不要把临时产物放进被修改仓库。
|
||||
|
||||
## 范围
|
||||
|
||||
**范围内**(只能修改这些文件):
|
||||
- `easytier/src/peers/peer_ospf_route.rs`
|
||||
- `easytier/src/tests/*`(仅当需要新增 route sync regression test)
|
||||
|
||||
**范围外**(即使看起来相关也不要触碰):
|
||||
- OSPF graph algorithm、route-table data structures、credential trust policy。
|
||||
- protobuf schema and generated code。
|
||||
- GUI/Web/frontend。
|
||||
|
||||
## Git 工作流
|
||||
|
||||
- Branch: `advisor/003-avoid-noop-ospf-route-rebuilds`
|
||||
- Commit message style follows existing conventional commits, for example `fix: route_update message is not lag`.
|
||||
- Do NOT push or open a PR unless the operator instructed it.
|
||||
|
||||
## 步骤
|
||||
|
||||
### 步骤 1:让 `update_peer_infos` 返回是否改变 state
|
||||
|
||||
将 `update_peer_infos` 返回类型从 `Result<(), Error>` 改为 `Result<bool, Error>`,返回 `need_inc_version`。保持 duplicate peer ID 检查、raw peer info 更新和 version increment 逻辑不变。
|
||||
|
||||
调用方保存为 `let peer_infos_changed = ...?;`。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0 或无匹配测试但编译通过。
|
||||
|
||||
### 步骤 2:确认 `update_conn_info` 是否已有 changed flag
|
||||
|
||||
阅读同文件中 `update_conn_info` 和 `update_conn_info_one_peer`。如果 `update_conn_info_one_peer` 已返回 `bool`,则让 `update_conn_info` 聚合并返回 `bool`。如果当前 `update_conn_info` 已返回 bool,只使用现有返回值,不重复实现。
|
||||
|
||||
不要改变 accept/reject credential conn info 的条件;只改变“是否设置 `need_update_route_table`”的判断。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0。
|
||||
|
||||
### 步骤 3:仅在 actual change 时设置 `need_update_route_table`
|
||||
|
||||
在 sync handler 中改为:
|
||||
|
||||
- `peer_infos_changed` 为 true 时才设置 `need_update_route_table = true`。
|
||||
- `conn_info_changed` 为 true 时才设置 `need_update_route_table = true`。
|
||||
- `session.update_dst_saved_peer_info_version(...)` 和 `session.update_dst_saved_conn_info_version(...)` 是否应在 unchanged payload 时调用,需要按现有 session version semantics 判断;如果它只是记录对端已发送版本,可保留调用,避免重复请求。
|
||||
|
||||
**验证**:`cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。
|
||||
|
||||
### 步骤 4:新增 no-op update regression tests
|
||||
|
||||
优先添加 module-level unit tests,直接构造 `SyncedRouteInfo` 或现有内部结构:
|
||||
|
||||
- 首次插入较新 `RoutePeerInfo` 返回 `true`。
|
||||
- 再次插入相同 version 或旧 version 返回 `false`。
|
||||
- `update_conn_info` 对相同 connected peers 返回 `false`,对变化集合返回 `true`。
|
||||
|
||||
如果内部类型构造太复杂,使用现有 route sync tests 的 helper;不要为了测试暴露 public API,最多使用 `#[cfg(test)]` helper。
|
||||
|
||||
**验证**:使用实际新增测试过滤器运行,例如 `cargo test --package easytier noop_route_update --features full -- --nocapture` → exit 0;输出中新增测试通过。
|
||||
|
||||
### 步骤 5:运行完整相关门禁
|
||||
|
||||
**验证**:
|
||||
- `cargo fmt --all -- --check` → exit 0。
|
||||
- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。
|
||||
- `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` → exit 0。
|
||||
|
||||
## 测试计划
|
||||
|
||||
- 新增 `update_peer_infos` changed flag tests:newer version true,same/older version false。
|
||||
- 新增 `update_conn_info` changed flag tests:changed topology true,identical topology false。
|
||||
- 不要求跑完整 privileged nextest matrix;至少 archive 编译所有 tests。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- [ ] stale/duplicate peer info 不再设置 `need_update_route_table = true`。
|
||||
- [ ] unchanged conn info 不再设置 `need_update_route_table = true`。
|
||||
- [ ] duplicate peer ID check 仍在 stale/no-op 判断前执行。
|
||||
- [ ] 新增 tests 覆盖 true/false change flag。
|
||||
- [ ] `cargo fmt --all -- --check` exits 0。
|
||||
- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。
|
||||
- [ ] `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` exits 0。
|
||||
- [ ] 没有修改范围外文件。
|
||||
- [ ] 已更新 `plans/README.md` 中本计划的状态行。
|
||||
|
||||
## STOP 条件
|
||||
|
||||
- `update_peer_infos` 的返回值已被其他分支重构,当前摘录不匹配。
|
||||
- 判断 no-op 需要改变 route trust、credential 或 duplicate peer semantics。
|
||||
- 无法构造可靠测试,且只能通过完整三节点集成测试验证;停止并报告需要 reviewer 决定测试策略。
|
||||
|
||||
## 维护说明
|
||||
|
||||
- 后续任何 route sync state mutation 都应返回 changed flag,并只在 actual change 时触发 route rebuild。
|
||||
- reviewer 应重点检查 version bookkeeping:不要为了省重算而漏掉必要 route refresh。
|
||||
- 本计划不减少单次 rebuild 的成本;那由 `plans/004-reuse-ospf-route-graph.md` 处理。
|
||||
@@ -0,0 +1,176 @@
|
||||
# 计划 004:复用 OSPF route-table 构图以减少拓扑更新成本
|
||||
|
||||
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。
|
||||
>
|
||||
> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/peers/peer_ospf_route.rs easytier/src/tests`
|
||||
> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。
|
||||
|
||||
## 状态
|
||||
|
||||
- **优先级**: P2
|
||||
- **工作量**: M
|
||||
- **风险**: MED
|
||||
- **依赖**: plans/003-avoid-noop-ospf-route-rebuilds.md
|
||||
- **类别**: perf
|
||||
- **计划生成于**: commit `78146d16`, 2026-06-18
|
||||
|
||||
## 为什么重要
|
||||
|
||||
每次 OSPF 拓扑更新当前会分别为 least-hop 和 least-cost route table 调用 `build_from_synced_info`。每次调用都会从 synced info 重新构建 peer graph,并重新构建 peer/CIDR indexes。对于 peer 数和 proxy CIDR 数较大的 mesh,这把一次拓扑变化放大成两次完整构图和多次 map/trie 重建。完成后应保持 route selection 结果不变,同时复用同一份 graph/materialized synced view,减少 CPU 和分配成本。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- `easytier/src/peers/peer_ospf_route.rs` — `update_route_table`、graph builder、least-hop/least-cost map generation、CIDR trie rebuild 均在此文件。
|
||||
|
||||
当前代码摘录:
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_ospf_route.rs:1628
|
||||
// build next hop map
|
||||
let (graph, start_node) =
|
||||
Self::build_peer_graph_from_synced_info(my_peer_id, synced_info, cost_calc);
|
||||
|
||||
// easytier/src/peers/peer_ospf_route.rs:1649
|
||||
if matches!(policy, NextHopPolicy::LeastHop) {
|
||||
self.gen_next_hop_map_with_least_hop(&graph, &start_node, version);
|
||||
} else {
|
||||
self.gen_next_hop_map_with_least_cost(&graph, &start_node, version);
|
||||
};
|
||||
|
||||
// easytier/src/peers/peer_ospf_route.rs:1655
|
||||
let mut new_cidr_prefix_trie = PrefixMap::new();
|
||||
let mut new_cidr_v6_prefix_trie = PrefixMap::new();
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_ospf_route.rs:2453
|
||||
fn update_route_table(&self) {
|
||||
// ...
|
||||
self.route_table.build_from_synced_info(
|
||||
self.my_peer_id,
|
||||
&self.synced_route_info,
|
||||
NextHopPolicy::LeastHop,
|
||||
calc_locked.as_ref().unwrap(),
|
||||
);
|
||||
|
||||
self.route_table_with_cost.build_from_synced_info(
|
||||
self.my_peer_id,
|
||||
&self.synced_route_info,
|
||||
NextHopPolicy::LeastCost,
|
||||
calc_locked.as_ref().unwrap(),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
仓库约定:core routing behavior must be preserved。先添加 characterization tests,再重构;不要在同一计划里改 route policy。
|
||||
|
||||
## 需要使用的命令
|
||||
|
||||
| Purpose | Command | Expected on success |
|
||||
|---------|---------|---------------------|
|
||||
| Format | `cargo fmt --all -- --check` | exit 0 |
|
||||
| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings |
|
||||
| Targeted tests | `cargo test --package easytier peer_ospf_route --features full -- --nocapture` | exit 0; route characterization tests pass |
|
||||
| Archive tests | `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` | exit 0 |
|
||||
|
||||
## 临时目录约定
|
||||
|
||||
- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。
|
||||
- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。
|
||||
- 不要把临时产物放进被修改仓库。
|
||||
|
||||
## 范围
|
||||
|
||||
**范围内**(只能修改这些文件):
|
||||
- `easytier/src/peers/peer_ospf_route.rs`
|
||||
- `easytier/src/tests/*`(仅新增/调整 route-table characterization tests)
|
||||
|
||||
**范围外**(即使看起来相关也不要触碰):
|
||||
- Route protocol schema and wire format。
|
||||
- Credential/trust semantics。
|
||||
- Peer center、foreign network manager、data-plane tunnels。
|
||||
- Any UI or config surface。
|
||||
|
||||
## Git 工作流
|
||||
|
||||
- Branch: `advisor/004-reuse-ospf-route-graph`
|
||||
- Commit message style follows existing conventional commits, for example `refactor: introduce HedgeExt for task hedging; rewrite NatDstQuicConnector`.
|
||||
- Do NOT push or open a PR unless the operator instructed it.
|
||||
|
||||
## 步骤
|
||||
|
||||
### 步骤 1:添加 route-table characterization tests
|
||||
|
||||
在修改实现前,新增测试覆盖至少一个包含以下元素的小拓扑:
|
||||
|
||||
- 本 peer、两个 reachable peers、一个 unreachable 或 outdated peer。
|
||||
- 至少一个 IPv4 proxy CIDR 和一个 IPv6 proxy CIDR。
|
||||
- least-hop 和 least-cost 结果不同或至少都被断言。
|
||||
|
||||
测试应断言当前 `route_table` 和 `route_table_with_cost` 对 peer next-hop、peer reachability、CIDR lookup 的结果。优先使用现有测试 helper;如果内部 API 不便,添加 `#[cfg(test)]` helper,不改变生产 API。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0;新增 characterization tests 在重构前通过。
|
||||
|
||||
### 步骤 2:抽出一次性 graph build 输入
|
||||
|
||||
在 `peer_ospf_route.rs` 中把 `build_from_synced_info` 内部的 graph construction 拆成私有 helper,例如:
|
||||
|
||||
- `build_peer_graph_from_synced_info(...)` 已存在则复用。
|
||||
- 新增 small struct 持有 `graph`、`start_node`、`version` 和后续 index rebuild 需要的 synced snapshot references。
|
||||
|
||||
不要改变 `gen_next_hop_map_with_least_hop` 或 `gen_next_hop_map_with_least_cost` 的算法。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0。
|
||||
|
||||
### 步骤 3:让 `update_route_table` 对两种 policy 复用 graph
|
||||
|
||||
把 `update_route_table` 改为在持有 `cost_calculator` read lock 时构建一次 graph/materialized input,然后分别对 `self.route_table` 和 `self.route_table_with_cost` 应用 least-hop / least-cost generation。
|
||||
|
||||
如果现有 `RouteTable::build_from_synced_info` 是唯一封装点,可以新增一个 sibling method,例如 `build_from_prebuilt_graph(...)`,保持旧方法用于兼容 tests 或其他调用方。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0;characterization tests 仍通过。
|
||||
|
||||
### 步骤 4:避免重复构建共享 indexes
|
||||
|
||||
检查 `build_from_synced_info` 中 peer info map、IPv4 map、CIDR tries 的生成是否依赖 policy-specific next-hop map。如果只依赖 reachability 或 synced info,可移动到共享 helper;如果依赖每个 `RouteTable` 自己的 `next_hop_map`,不要强行共享,避免改变 semantics。
|
||||
|
||||
允许分阶段收益:只共享 graph build 也可完成本计划;共享 CIDR/index 只有在 characterization tests 能证明 behavior 不变时才做。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0。
|
||||
|
||||
### 步骤 5:运行完整相关门禁
|
||||
|
||||
**验证**:
|
||||
- `cargo fmt --all -- --check` → exit 0。
|
||||
- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。
|
||||
- `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` → exit 0。
|
||||
|
||||
## 测试计划
|
||||
|
||||
- 新增 route-table characterization tests,先在重构前证明现有行为,再在重构后保持通过。
|
||||
- 测试覆盖 least-hop、least-cost、CIDR lookup、unreachable peer exclusion。
|
||||
- 如果可行,加入一个轻量 counter/helper 在 test-only path 确认 graph builder 调用次数从 2 降为 1;如果这需要侵入生产代码,则不要做。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- [ ] `update_route_table` 不再对同一 synced topology 构建两次 peer graph。
|
||||
- [ ] least-hop 和 least-cost route outputs 与 characterization tests 中的旧行为一致。
|
||||
- [ ] 没有改变 routing protocol、credential trust 或 config behavior。
|
||||
- [ ] `cargo fmt --all -- --check` exits 0。
|
||||
- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。
|
||||
- [ ] `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` exits 0。
|
||||
- [ ] 没有修改范围外文件。
|
||||
- [ ] 已更新 `plans/README.md` 中本计划的状态行。
|
||||
|
||||
## STOP 条件
|
||||
|
||||
- `plans/003-avoid-noop-ospf-route-rebuilds.md` 未完成,且当前 route rebuild trigger 仍会对 no-op payload 重算。
|
||||
- 复用 graph 需要改变 least-hop 或 least-cost algorithm。
|
||||
- 现有代码让 `cost_calc` 在两次 build 之间发生有意状态变化;如果确认 `begin_update`/`end_update` 依赖两次独立 build,停止并报告。
|
||||
- Characterization tests 无法稳定构造 route-table expected outputs。
|
||||
|
||||
## 维护说明
|
||||
|
||||
- reviewer 应重点审查是否在锁持有期间引入更长 critical section。
|
||||
- 未来新增 route policy 时应复用本计划抽出的 prebuilt graph input,而不是再调用完整 `build_from_synced_info`。
|
||||
- 本计划不拆分 `peer_ospf_route.rs` 大文件;只做局部性能重构。
|
||||
@@ -0,0 +1,164 @@
|
||||
# 计划 005:补齐 SOCKS5 exit-node 集成测试覆盖
|
||||
|
||||
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。
|
||||
>
|
||||
> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/tests/three_node.rs easytier/src/tests/mod.rs easytier/src/gateway easytier/src/vpn_portal easytier/src/peers`
|
||||
> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。
|
||||
|
||||
## 状态
|
||||
|
||||
- **优先级**: P2
|
||||
- **工作量**: M
|
||||
- **风险**: MED
|
||||
- **依赖**: none
|
||||
- **类别**: tests
|
||||
- **计划生成于**: commit `78146d16`, 2026-06-18
|
||||
|
||||
## 为什么重要
|
||||
|
||||
测试文件顶部明确 TODO 指出需要覆盖 `socks5 + exit node == self || proxy_cidr == 0.0.0.0/0` 的出口节点能力。现有 `socks5_vpn_portal` 测试只覆盖固定 destination 和 `10.1.2.0/24` proxy CIDR,不能证明默认出口路由或 self-exit 场景。完成后,这条核心 VPN routing/use-case 会有 characterization test,后续修改 SOCKS5、proxy CIDR 或 exit-node 行为时不再盲改。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- `easytier/src/tests/three_node.rs` — 三节点集成测试和 SOCKS5 portal 测试所在文件。
|
||||
- `easytier/src/gateway/socks5.rs`、`easytier/src/gateway/socks5/dataplane.rs` — SOCKS5 gateway implementation;仅在测试失败定位时阅读,默认不修改。
|
||||
- `easytier/src/peers/peer_ospf_route.rs` — proxy CIDR 和 route selection 行为;默认不修改。
|
||||
|
||||
当前代码摘录:
|
||||
|
||||
```rust
|
||||
// easytier/src/tests/three_node.rs:16
|
||||
// TODO: 需要加一个单测,确保 socks5 + exit node == self || proxy_cidr == 0.0.0.0/0 时,可以实现出口节点的能力。
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/tests/three_node.rs:1753
|
||||
pub async fn socks5_vpn_portal(
|
||||
#[values("10.144.144.1", "10.144.144.3", "10.1.2.4")] dst_addr: &str,
|
||||
) {
|
||||
// ...
|
||||
let _insts = init_three_node_ex(
|
||||
"tcp",
|
||||
|cfg| {
|
||||
if cfg.get_inst_name() == "inst3" {
|
||||
// 添加子网代理配置
|
||||
cfg.add_proxy_cidr("10.1.2.0/24".parse().unwrap(), None)
|
||||
.unwrap();
|
||||
}
|
||||
cfg
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
```
|
||||
|
||||
仓库约定:这些网络集成测试使用 `#[tokio::test]` 和 `#[serial_test::serial]`,部分测试需要 Linux network namespace/root capabilities。保持测试 isolated and repeatable;不要让新增测试依赖外部网络。
|
||||
|
||||
## 需要使用的命令
|
||||
|
||||
| Purpose | Command | Expected on success |
|
||||
|---------|---------|---------------------|
|
||||
| Format | `cargo fmt --all -- --check` | exit 0 |
|
||||
| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings |
|
||||
| Targeted test | `cargo test --package easytier socks5_vpn_portal --features full -- --nocapture --test-threads 1` | exit 0; existing and new SOCKS5 tests pass |
|
||||
| CI-style archive | `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` | exit 0 |
|
||||
|
||||
如果本地环境缺少 root/network namespace 能力,targeted test 可能失败。此时仍必须确保 compile/archive 通过,并在结果中明确记录环境缺口。
|
||||
|
||||
## 临时目录约定
|
||||
|
||||
- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。
|
||||
- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。
|
||||
- 不要把临时产物放进被修改仓库。
|
||||
|
||||
## 范围
|
||||
|
||||
**范围内**(只能修改这些文件):
|
||||
- `easytier/src/tests/three_node.rs`
|
||||
- `easytier/src/tests/mod.rs`(仅当需要注册 helper/module)
|
||||
|
||||
**范围外**(即使看起来相关也不要触碰):
|
||||
- Production SOCKS5/gateway/routing code。若测试暴露 bug,停止并报告;不要在本计划里修生产逻辑。
|
||||
- Any CI workflow, docs, GUI/Web/frontend。
|
||||
- Existing tests unrelated to SOCKS5 portal or exit-node behavior。
|
||||
|
||||
## Git 工作流
|
||||
|
||||
- Branch: `advisor/005-cover-socks5-exit-node`
|
||||
- Commit message style follows existing conventional commits, for example `test: add tests` from `CONTRIBUTING.md`.
|
||||
- Do NOT push or open a PR unless the operator instructed it.
|
||||
|
||||
## 步骤
|
||||
|
||||
### 步骤 1:阅读现有 `socks5_vpn_portal` helper pattern
|
||||
|
||||
在 `easytier/src/tests/three_node.rs` 中阅读完整 `socks5_vpn_portal` 测试,特别是如何启动三节点、如何启动 TCP listener、如何通过 `tokio_socks::tcp::socks5::Socks5Stream` 访问目标地址、如何 cleanup。
|
||||
|
||||
不要复制大量代码后分叉;优先抽取小 helper,例如 `run_socks5_tcp_echo_case(...)`,让现有测试和新增测试共享。
|
||||
|
||||
**验证**:`cargo fmt --all -- --check` → exit 0(如果尚未修改,仍应通过)。
|
||||
|
||||
### 步骤 2:新增 `0.0.0.0/0` proxy CIDR exit-node case
|
||||
|
||||
新增一个 serial async test,命名建议 `socks5_vpn_portal_default_ipv4_exit_node`。测试应:
|
||||
|
||||
- 使用 `init_three_node_ex` 创建三节点。
|
||||
- 让某个非客户端节点配置 `cfg.add_proxy_cidr("0.0.0.0/0".parse().unwrap(), None).unwrap()`。
|
||||
- 通过 SOCKS5 portal 访问一个由测试内部启动的 TCP echo server 地址。
|
||||
- 断言 payload round-trip 成功。
|
||||
|
||||
测试目标地址必须是本地/测试 namespace 可控地址,不允许依赖公网。
|
||||
|
||||
**验证**:`cargo test --package easytier socks5_vpn_portal_default_ipv4_exit_node --features full -- --nocapture --test-threads 1` → exit 0;若因权限环境失败,错误必须是环境相关,而非编译或断言失败。
|
||||
|
||||
### 步骤 3:新增 self-exit case 或明确不可测原因
|
||||
|
||||
根据 TODO 中的 `exit node == self`,新增第二个测试,命名建议 `socks5_vpn_portal_self_exit_node`。它应覆盖 SOCKS5 入口节点同时也是 exit node 的场景。
|
||||
|
||||
如果现有 config API 没有清晰方式表达 “exit node == self”,不要猜测配置。先搜索现有 tests 中 `exit_nodes`、`add_proxy_cidr`、`vpn_portal` 的用法;如果仍不明确,STOP 并报告需要 maintainer 确认配置语义。
|
||||
|
||||
**验证**:`cargo test --package easytier socks5_vpn_portal_self_exit_node --features full -- --nocapture --test-threads 1` → exit 0;或 STOP 报告不可测配置语义。
|
||||
|
||||
### 步骤 4:移除或更新 TODO
|
||||
|
||||
如果两个场景都已覆盖,将 `three_node.rs:16` 的 TODO 删除或改成剩余未覆盖场景的精确 TODO。不要删除仍未覆盖的提醒。
|
||||
|
||||
**验证**:`cargo test --package easytier socks5_vpn_portal --features full -- --nocapture --test-threads 1` → exit 0;现有和新增 SOCKS5 portal tests 通过。
|
||||
|
||||
### 步骤 5:运行完整相关门禁
|
||||
|
||||
**验证**:
|
||||
- `cargo fmt --all -- --check` → exit 0。
|
||||
- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。
|
||||
- `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` → exit 0。
|
||||
|
||||
## 测试计划
|
||||
|
||||
- 新增 `socks5_vpn_portal_default_ipv4_exit_node`:覆盖 `proxy_cidr == 0.0.0.0/0`。
|
||||
- 新增 `socks5_vpn_portal_self_exit_node`:覆盖 SOCKS5 入口节点作为出口节点。
|
||||
- 复用现有 `socks5_vpn_portal` 的 TCP echo/payload pattern,保持 `#[serial_test::serial]`。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- [ ] TODO 中提到的 `0.0.0.0/0` exit-node 场景有测试覆盖。
|
||||
- [ ] TODO 中提到的 self-exit 场景有测试覆盖,或计划按 STOP 条件阻塞并说明配置语义缺口。
|
||||
- [ ] 新测试不依赖公网服务。
|
||||
- [ ] `cargo fmt --all -- --check` exits 0。
|
||||
- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。
|
||||
- [ ] `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` exits 0。
|
||||
- [ ] 没有修改 production code 或范围外文件。
|
||||
- [ ] 已更新 `plans/README.md` 中本计划的状态行。
|
||||
|
||||
## STOP 条件
|
||||
|
||||
- 新增测试暴露 production bug:不要修生产代码,停止并报告 failing test、命令和错误摘要。
|
||||
- self-exit 的配置语义无法从现有代码/tests 中确认。
|
||||
- 测试只能通过访问公网验证;这不符合仓库测试隔离要求。
|
||||
- 为了让测试通过需要放宽 assertions 或增加 sleeps 超过现有测试风格。
|
||||
|
||||
## 维护说明
|
||||
|
||||
- reviewer 应重点审查测试是否真正走 SOCKS5 portal 和 exit-node route,而不是退化成本地直连。
|
||||
- 后续修改 proxy CIDR、exit-node、SOCKS5 dataplane 时,应运行本计划新增的 targeted tests。
|
||||
- 本计划只建立测试基线;如果发现 bug,应另写修复计划。
|
||||
@@ -0,0 +1,200 @@
|
||||
# 计划 006:send_msg_internal 发包链路 CPU 优化
|
||||
|
||||
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。完成后更新 `plans/README.md` 中本计划的状态行。
|
||||
|
||||
## 状态
|
||||
|
||||
- **优先级**: P1
|
||||
- **工作量**: M
|
||||
- **风险**: LOW
|
||||
- **依赖**: hotpath profiling infra (main branch commit `be2034dd`)
|
||||
- **类别**: performance
|
||||
- **数据来源**: hotpath-cpu samply 423,583,601 samples,4 threads,234K pps,pkt_size=1400
|
||||
|
||||
## 为什么重要
|
||||
|
||||
`send_msg_internal` 是数据面包转发的核心路径,每包耗时 3.26µs(wall time)。在 234K pps 下占 wall time 的 ~70%。samply inclusive CPU 分解显示有多处可通过减少冗余操作来省 µs 级开销。每包省 1µs 即可将吞吐提升 ~30%。
|
||||
|
||||
## 数据基线
|
||||
|
||||
### timing(wall time,含 await)
|
||||
|
||||
| Function | Calls | Avg/包 | 级差 |
|
||||
|---|---|---|---|
|
||||
| `send_msg_internal` | 6.9M | 3.26µs | — |
|
||||
| └─ `send_msg_directly` | 6.9M | 2.83µs | 0.43µs(路由决策) |
|
||||
| └─ `Peer::send_msg` | 6.9M | 2.69µs | 0.14µs(conn 选择) |
|
||||
| └─ `PeerConn::send_msg` | 6.9M | 2.58µs | 0.11µs(session 选择) |
|
||||
|
||||
### samply inclusive CPU(send_msg_internal 子树,11.5M samples)
|
||||
|
||||
| % | Function | 含义 |
|
||||
|---|---|---|
|
||||
| 12.0% | `PeerMap::send_msg_directly` | 发包核心 |
|
||||
| 7.5% | `tokio::mpsc::Sender::send` | mpsc 通道 |
|
||||
| **7.1%** | **`TrafficMetricRecorder::record_tx`** | 每包流量统计 |
|
||||
| **6.1%+4.8%+4.0%** | **`dashmap::get` ×3** | 冗余 dashmap 查询 |
|
||||
| 5.6% | `batch_semaphore::Acquire::poll` | mpsc permit |
|
||||
| **3.9%** | **`quanta::get_now`** | 时间戳获取 |
|
||||
| 3.9% | `malloc` | 内存分配 |
|
||||
| **1.2%** | **`TrafficCounters closure`** | 流量计数器 |
|
||||
| 1.0% | `MpscTunnelSender::send` | tunnel 发送 |
|
||||
|
||||
## 当前代码
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_manager.rs:1533-1588
|
||||
async fn send_msg_internal(
|
||||
peers: &Arc<PeerMap>,
|
||||
foreign_network_client: &Arc<ForeignNetworkClient>,
|
||||
relay_peer_map: &Arc<RelayPeerMap>,
|
||||
direct_tx_metrics: Option<&Arc<TrafficMetricRecorder>>,
|
||||
msg: ZCPacket,
|
||||
dst_peer_id: PeerId,
|
||||
) -> Result<(), Error> {
|
||||
// ...
|
||||
let send_result = if ... {
|
||||
// relay path
|
||||
} else if peers.has_peer(dst_peer_id) { // dashmap get #1 (contains_key)
|
||||
peers.send_msg_directly(msg, dst_peer_id).await // 内部 get_peer_by_id = dashmap get #2
|
||||
} else if foreign_network_client.has_next_hop(dst_peer_id) {
|
||||
// foreign network path
|
||||
} else if let Some(gateway) = peers.get_gateway_peer_id(dst_peer_id, policy.clone()).await {
|
||||
if peers.has_peer(gateway) || ... { // dashmap get #3
|
||||
relay_peer_map.send_msg(msg, dst_peer_id, policy).await
|
||||
}
|
||||
}
|
||||
|
||||
if send_result.is_ok() && let Some(metrics) = direct_tx_metrics {
|
||||
metrics.record_tx(dst_peer_id, packet_type, msg_len).await; // 每包记录
|
||||
}
|
||||
send_result
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_map.rs:136-164
|
||||
pub async fn send_msg_directly(&self, msg: ZCPacket, dst_peer_id: PeerId) -> Result<(), Error> {
|
||||
if dst_peer_id == self.my_peer_id {
|
||||
// self-send path (tokio::spawn)
|
||||
return Ok(());
|
||||
}
|
||||
match self.get_peer_by_id(dst_peer_id) { // dashmap get (重复)
|
||||
Some(peer) => peer.send_msg(msg).await?,
|
||||
None => return Err(Error::RouteError(...)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## 优化项
|
||||
|
||||
### 步骤 1:合并 dashmap 冗余查询(P0,预期省 ~0.1-0.2µs/包)
|
||||
|
||||
**问题**:happy path 上 `has_peer(dst_peer_id)` + `send_msg_directly → get_peer_by_id(dst_peer_id)` 对同一个 key 做了 2 次 dashmap 查询。每次 ~100ns(hash + shard read lock)。
|
||||
|
||||
**方案**:在 `send_msg_internal` 中直接调 `get_peer_by_id`,根据 `Option<Arc<Peer>>` 分支,跳过 `has_peer` 检查。
|
||||
|
||||
```rust
|
||||
// 改前
|
||||
} else if peers.has_peer(dst_peer_id) {
|
||||
peers.send_msg_directly(msg, dst_peer_id).await
|
||||
}
|
||||
|
||||
// 改后
|
||||
} else if let Some(peer) = peers.get_peer_by_id(dst_peer_id) {
|
||||
peer.send_msg(msg).await
|
||||
}
|
||||
```
|
||||
|
||||
注意:`send_msg_directly` 中的 self-send 分支(`dst_peer_id == my_peer_id`)需要在上层处理或保留。当前 bench 场景 `dst_peer_id != my_peer_id`,不触发 self-send。
|
||||
|
||||
**涉及文件**:`easytier/src/peers/peer_manager.rs:1558-1559`
|
||||
**冲突检查**:advisor/001-002 改过此文件(队列背压 + metrics 连带),需 rebase 后确认行号。
|
||||
**验证**:`cargo test -p easytier -- send_msg_internal`
|
||||
|
||||
### 步骤 2:TrafficMetricRecorder 降频记录(P1,预期省 ~0.25µs/包)
|
||||
|
||||
**问题**:`record_tx` 每包都调用,占 inclusive CPU 的 7.1% + TrafficCounters 1.2% = 8.3%。内部做 histogram 记录(`hdrhistogram::record_n_inner`)和时间戳获取(`quanta::get_now`)。
|
||||
|
||||
**方案**:在 `TrafficMetricRecorder` 中引入 per-thread atomic 计数器,每 N 包(如 64)或每 T ms 刷入 histogram。
|
||||
|
||||
```rust
|
||||
// 改前
|
||||
metrics.record_tx(dst_peer_id, packet_type, msg_len).await;
|
||||
|
||||
// 改后
|
||||
metrics.record_tx_fast(dst_peer_id, packet_type, msg_len); // sync, atomic counter
|
||||
// 内部: counter.fetch_add(msg_len); if counter % 64 == 0 { flush_to_histogram() }
|
||||
```
|
||||
|
||||
**涉及文件**:`easytier/src/peers/traffic_metrics.rs`、`easytier/src/peers/peer_manager.rs:1584`
|
||||
**冲突检查**:traffic_metrics.rs 零冲突。peer_manager.rs 同步骤 1。
|
||||
**验证**:`cargo test -p easytier -- traffic_metrics`
|
||||
|
||||
### 步骤 3:缓存时间戳(P2,预期省 ~0.13µs/包)
|
||||
|
||||
**问题**:`quanta::get_now` 占 inclusive CPU 的 3.9%。send_msg_internal 路径上多处获取当前时间(record_tx 内部、traffic counters 等)。
|
||||
|
||||
**方案**:在 `send_msg_internal` 入口取一次时间戳,传入子函数。
|
||||
|
||||
```rust
|
||||
let now = quanta::Instant::now();
|
||||
// ...
|
||||
metrics.record_tx_with_time(dst_peer_id, packet_type, msg_len, now);
|
||||
```
|
||||
|
||||
**涉及文件**:`easytier/src/peers/peer_manager.rs`、`easytier/src/peers/traffic_metrics.rs`
|
||||
**冲突检查**:同步骤 2。
|
||||
**验证**:bench pps 对比。
|
||||
|
||||
### 步骤 4:mpsc batch send(P3,预期省 ~0.46µs/包)
|
||||
|
||||
**问题**:`PeerConn::send_msg` 每包做 1 次 `MpscTunnelSender::send`,触发 mpsc `Sender::send` (7.5%) + `batch_semaphore::Acquire::poll` (5.6%) + `add_permits_locked` (3.82%) = 16.9%。
|
||||
|
||||
**方案**:在 `PeerConn` 或 `Peer` 层引入 batch buffer,攒满 N 个包后一次 `send`(使用 `try_send` 或 unbounded channel)。
|
||||
|
||||
**涉及文件**:`easytier/src/peers/peer_conn.rs`、`easytier/src/tunnel/mpsc.rs`
|
||||
**冲突检查**:peer_conn.rs 被 advisor/001-002 改过。mpsc.rs 被 perf/001 改过。需要协调合并顺序。
|
||||
**验证**:bench pps 对比 + `cargo test -p easytier -- peer_conn`
|
||||
|
||||
### 步骤 5:ZCPacket 池化(P4,预期省 ~0.21µs/包)
|
||||
|
||||
**问题**:每包 malloc 3.9% + free 1.2% + morecore 1.2% = 6.3%。全局 munmap 4.73% 也部分来自此。
|
||||
|
||||
**方案**:对 ZCPacket 引入池化(`crossbeam-queue::ArrayQueue` 或 `tokio::sync::Pool`)。
|
||||
|
||||
**涉及文件**:`easytier/src/tunnel/packet_def.rs`
|
||||
**冲突检查**:packet_def.rs 被 perf/001-003 改过。需要在 perf PR 合并后实施。
|
||||
**验证**:bench pps + `cargo test -p easytier -- packet`
|
||||
|
||||
## 预期总收益
|
||||
|
||||
| 步骤 | 每包省 | 累计 |
|
||||
|---|---|---|
|
||||
| 步骤 1(dashmap 合并) | ~0.15µs | 3.26→3.11µs |
|
||||
| 步骤 2(metrics 降频) | ~0.25µs | 3.11→2.86µs |
|
||||
| 步骤 3(缓存时间戳) | ~0.13µs | 2.86→2.73µs |
|
||||
| 步骤 4(batch send) | ~0.46µs | 2.73→2.27µs |
|
||||
| 步骤 5(packet 池化) | ~0.21µs | 2.27→2.06µs |
|
||||
| **合计** | **~1.2µs** | **3.26→2.06µs(-37%)** |
|
||||
|
||||
在 4 threads 配置下,预期 pps 从 234K 提升到 ~320K-370K(+37%-58%)。
|
||||
|
||||
## 验证方法
|
||||
|
||||
```bash
|
||||
# baseline(当前 main + measure_all)
|
||||
export PATH=$HOME/.cargo/bin:$PATH
|
||||
cargo run --profile hotpath --features hotpath,hotpath-cpu --example cpu_hotspot_ring
|
||||
# 记录 pps 和 timing avg
|
||||
|
||||
# 每个步骤实施后重跑,对比 pps 和 send_msg_internal avg
|
||||
```
|
||||
|
||||
## 风险
|
||||
|
||||
- **步骤 1**:改变路由决策逻辑的边界条件(self-send、foreign network)。需确保不破坏 `send_msg_internal_*` 测试。
|
||||
- **步骤 2**:metrics 精度降低(从每包精确变为每 64 包近似)。需确认 stats 查询端能接受。
|
||||
- **步骤 4**:batch send 引入延迟(攒批期间包等待)。需设置 flush timeout。
|
||||
- **步骤 5**:ZCPacket 池化改变生命周期模型,可能引入 use-after-free。需充分测试。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 实施计划
|
||||
|
||||
由 improve skill 于 2026-06-18 生成,基于 commit `78146d16`。除非依赖关系另有要求,请按以下顺序执行。每个执行者在开始前必须完整阅读计划,遵守 STOP 条件,并在完成后更新自己的状态行。
|
||||
|
||||
## 执行顺序与状态
|
||||
|
||||
| Plan | 标题 | 优先级 | 工作量 | 依赖 | Status |
|
||||
|------|------|--------|--------|------|--------|
|
||||
| 001 | 将共享 metrics/throughput 计数改为线程安全实现 | P1 | M | — | DONE in worktree, not merged |
|
||||
| 002 | 为 peer RPC/control packet 队列加入背压和过载行为 | P1 | M | 001 | DONE in worktree, not merged |
|
||||
| 003 | 避免 OSPF 对 stale/no-op sync payload 重算路由 | P2 | S | — | DONE in worktree, not merged |
|
||||
| 004 | 复用 OSPF route-table 构图以减少拓扑更新成本 | P2 | M | 003 | DONE in worktree, not merged |
|
||||
| 005 | 补齐 SOCKS5 exit-node 集成测试覆盖 | P2 | M | — | DONE in worktree, not merged |
|
||||
|
||||
状态值:TODO | IN PROGRESS | DONE | DONE in worktree, not merged | BLOCKED(附一行原因) | REJECTED(附一行理由,例如 finding 已独立修复或方案放弃)
|
||||
|
||||
## Reconcile 2026-06-18
|
||||
|
||||
- 001: `/home/fanmi/tmp/easytier-exec-001`, branch `advisor/001-thread-safe-metrics-throughput`, commit `7b6e4dfe`; worktree clean; not contained in `main` at `78146d16`.
|
||||
- 002: `/home/fanmi/tmp/easytier-exec-002`, branch `advisor/002-bound-peer-rpc-queues`, commit `34d2193d`; worktree clean; not contained in `main` at `78146d16`.
|
||||
- 003: `/home/fanmi/tmp/easytier-exec-003`, branch `advisor/003-avoid-noop-ospf-route-rebuilds`, commit `1be77b51`; worktree clean; not contained in `main` at `78146d16`.
|
||||
- 004: `/home/fanmi/tmp/easytier-exec-004`, branch `advisor/004-reuse-ospf-route-graph`, commit `325c2e5d`; worktree clean; not contained in `main` at `78146d16`.
|
||||
- 005: `/home/fanmi/tmp/easytier-exec-005`, branch `advisor/005-cover-socks5-exit-node`, commit `2cb51b71`; worktree clean; not contained in `main` at `78146d16`.
|
||||
|
||||
## 依赖说明
|
||||
|
||||
- 002 依赖 001,因为队列背压计划应暴露 queue depth/drop counters;这些 counters 应复用 001 中线程安全后的 metrics primitive,避免在新代码里继续扩散 `UnsafeCell` 模式。
|
||||
- 004 依赖 003,因为先抑制 no-op sync 的无效重算,再做共享构图重构,能让性能测试和行为变化更容易归因。
|
||||
- 005 独立执行,但如果未来要修改 SOCKS5、exit-node 或 `0.0.0.0/0` proxy CIDR 行为,应先落地 005 作为 characterization baseline。
|
||||
|
||||
## 已考虑并拒绝的发现
|
||||
|
||||
- `/api/v1/generate-config` 和 `/api/v1/parse-config` 是否应要求登录:证据显示 route layering 可能使其公开,但可能是产品意图;不属于本次“正确性和性能”范围,且应先补意图测试再判断。
|
||||
- OSPF 7k 行 god module 整体拆分:确认是技术债,但范围过大;应先执行 003、004 并增加 characterization tests 后再规划。
|
||||
- 前端测试/DX、依赖清理、安全 hardening:有价值,但用户本次只要求正确性和性能计划。
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
NS_NAME="et_bench"
|
||||
|
||||
echo "=== Creating netns: $NS_NAME ==="
|
||||
# Clean up old ns
|
||||
sudo ip netns del "$NS_NAME" 2>/dev/null || true
|
||||
|
||||
# Create namespace
|
||||
sudo ip netns add "$NS_NAME"
|
||||
|
||||
# Enable loopback inside namespace
|
||||
sudo ip netns exec "$NS_NAME" ip link set lo up
|
||||
|
||||
echo "=== netns $NS_NAME ready ==="
|
||||
echo "Both instances will share this namespace's loopback."
|
||||
echo "TCP/UDP connections to 127.0.0.1 will work inside it."
|
||||
echo ""
|
||||
echo "Now run the bench with HOTPATH_NETNS=$NS_NAME:"
|
||||
echo " HOTPATH_TUNNEL=tcp HOTPATH_NETNS=$NS_NAME HOTPATH_BENCH_SECS=15 \\"
|
||||
echo " cargo run --profile hotpath --features hotpath --example cpu_hotspot_ring"
|
||||
Reference in New Issue
Block a user