Mooncake TENT 高性能 TCP 传输(hp_tcp)全解析:架构、静态多轨路由与调优实战
Mooncake TENT 高性能 TCP 传输(hp_tcp)全解析:架构、静态多轨路由与调优实战
hp_tcp 是 Mooncake 项目 TENT(Transfer Engine)体系中的独立传输通道,面向数据中心 TCP 网络上的 CPU DRAM 数据搬运场景,以"每对端持久化 lane + 静态多轨路由 + 版本化协议"取代基于 RPC 的兼容路径。本文基于仓库设计文档 docs/source/design/tent/hp-tcp.md,并结合 mooncake-transfer-engine/tent 的源码、配置模板与测试用例,完整讲解其架构模型、配置参数、运行验证与并发调优方法。读完本文,你将能够为单机双轨乃至四轨场景正确配置 hp_tcp,理解 lane/rail/worker 三者的定量关系,并用 tebench、ss、ip route 等手段验证多轨数据是否真正走通。
一、定位:为什么要单独做一个高性能 TCP 传输
在 TENT 中,标准 tcp 传输保留了基于 RPC 的兼容路径,而 hp_tcp 则是一个独立的、为 CPU DRAM 传输量身定制的高性能 TCP 传输。二者不能同时启用:控制面通知(notification)的所有权是单一的,这一点在配置解析器中以硬约束体现——hp_tcp_transport_config.cpp 会直接校验"tcp 与 hp_tcp 不能同时 enable",否则返回 InvalidArgument。
首版 hp_tcp 刻意划定了能力边界,以下特性不在其内:GPU 内存、TLS、WRITE 条带化(striping)、rail 故障切换、歧义 WRITE 后的透明重放、以及动态 lane 调度。理解这一边界很重要——它是一个"少而精"的传输,复杂度被刻意压到可验证的水平。
在配置模板 transfer-engine.json 中,hp_tcp 默认处于关闭状态("enable": false),并预留了完整参数骨架;启用时需显式把 transports.tcp.enable 置为 false。
二、架构:worker 亲和、持久 lane 与进程级准入
2.1 Worker 与 lane 模型
hp_tcp 的线程模型极其克制:每个 worker 拥有一个 asio::io_context 和一条线程,对应的实现是 hp_tcp_workers.h 中的 HighPerformanceTcpWorkers,每个 WorkerContext 内含 asio::io_context、executor_work_guard 与 std::thread。
每个对端(peer)有一组配置数量的持久 lane。请求分配到哪条 lane 由两层规则决定:
- 每个 peer 独立的 lane 序列轮转:
next_lane_sequence_(见 hp_tcp_transport.h)为每个 peer 维护独立的序列号,操作在 lanes 之间轮转。这样,发往其他 peer 的交错流量不会把某个 peer 钉死在单条 lane 上。 - 序列从该 peer 的第一个请求 ID 起算,以保持其初始 lane 选择;而请求 ID 本身全局唯一(
next_request_id_{1}原子递增)。
lane 的 owner 由 peer 与 lane 的稳定哈希决定(affinityOwner(peer, lane)),socket 状态永不跨 worker 迁移,同一条 lane 上的操作严格 FIFO。这意味着:从 admission 到 socket 读写,一条请求的生命周期始终绑定在一个线程上,既消除了锁竞争,也让超时取消等逻辑可确定性地归位。
ASIO 充当事件队列;进程级任务数与字节数准入上限约束所有被接受的 work——包括排队在事件队列中的回调,即 hp_tcp_workers.h 中 HighPerformanceTcpAdmissionController 的 tryReserve/release 计数。任务在 no-throw 的批量提交段内才真正占用 reservation(见 hp_tcp_task.h 的注释)。
2.2 Nagle 与写合并
两端都会关闭 Nagle 算法:头部与 payload 是分开的两次 write,在持久 socket 上,延迟 ACK(delayed ACK)不能卡住短的 payload。这是吞吐与延迟取舍中的关键细节——分写 + 禁 Nagle 保证小请求的头部能立即发出。
2.3 服务端会话管理
服务端复用同一个 worker 池:accept 到的 socket 按轮转(next_worker_ 原子计数)分配给 worker,存进 worker 专属的 session 集合(hp_tcp_server.h 的 sessions_ 向量,每个集合只被对应 worker 线程触碰)。全局连接数上限(max_connections,默认 4096)约束存活会话;会话关闭即从集合中移除,不会为历史连接保留线程。这避免了经典的"一连接一线程"内存与调度开销。
数据路径可用下面这条链概括:
TENT request -> bounded admission -> owner worker -> persistent lane
-> versioned TCP protocol -> registered remote buffer
三、静态多轨路由(rail_addresses)
3.1 路由规则
rail_addresses 可选地将持久 lane 分配到一组有序的本地与远端 TCP 地址上,每个条目必须是分配给本地主机的数字地址,当前实现仅接受 IPv4。
- 当列表非空时,它取代
advertise_address成为发布端点; - lane
i使用 raili % rail_count,socket 绑定到对应的本地地址,同时保留原有的 peer-and-lane worker 归属; - 两端必须配置相同的非零 rail 数;
- 单一 rail 时,
bind_address必须与唯一的 rail 地址一致;多个本地 rail 时,监听地址必须是通配符(空或0.0.0.0)。
3.2 切片的触发条件
路由是刻意静态的:
- 小传输与所有 WRITE 都停留在单条持久 lane 上;
- 只有当 READ 大到足以每条 rail 至少贡献一个内部 I/O 步时,才按 rail 切成连续切片:当前内部步长为 1 MiB,即双 rail 下 ≥2 MiB 的 READ 才会被切片;更小的 READ 与单 rail READ 不切;
- 切片复用持久 lane,整体作为一个 TENT 任务完成;
- 余数字节分配给最前面的切片,因此长度不均的切片仍能精确覆盖整个请求;
- 多条独立 WRITE 可以用不同 rail,但每条 WRITE 只走一条 lane,等待自己的远端完成 ACK。
hp_tcp 不会在 rail 之间做负载再平衡或故障切换。
3.3 connections_per_peer 的语义陷阱
connections_per_peer 是每对端的 lane 总预算,不是 per-rail 倍数。例如 4 条 lane + 2 个 rail = 每个 rail 2 条 lane。worker 数与 rail 数相互独立:多条 lane 可以共享同一个 owner worker。需要特别注意的是:单 rail 下增加 lane 数不会把单个 READ 拆成多流。
这一语义在源码中对应 HighPerformanceTcpClient::Config::connections_per_peer(hp_tcp_client.h),lane 的归属通过 LaneKey{peer_id, incarnation, host, port, lane_id} 键管理。
四、协议与内存安全
4.1 线协议帧
协议常量定义在 hp_tcp_protocol.h:
- Magic:
0x4d435450(即 ASCII "MCTP"); - 版本:
kHighPerformanceTcpVersion = 1; - 请求帧固定 48 字节,含 opcode(
kRead = 1/kWrite = 2)、request ID、registration ID、远端地址与长度; - 响应帧固定 32 字节,含状态、request ID 与 committed 字节数。
远端状态码包括 kOk、kBadVersion、kBadOpcode、kBadLength、kRangeRejected、kPermissionDenied、kStaleRegistration、kShuttingDown、kInternalError。
完成语义:WRITE 只有在目标端拷贝完完整 payload 并返回 ACK 后才算完成;READ 在完整响应 payload 到达后完成。
4.2 注册 ID、lease 与重启防护
每个注册缓冲区 ID 由每注册器随机命名空间 + 单调序列构成,并附远端权限(HighPerformanceTcpBufferAttr{registration_id, permission})。随机命名空间的引入,防止上一次服务端化身(incarnation)遗留的陈旧 ID 在重启后复活。目标端在访问前校验 ID、范围与权限。
- 操作持有 lease 直到其最终 I/O 回调退役;unregister 把该范围对新增 work 隐藏,并等待现有 lease 结束(对应 hp_tcp_task.h 中任务持有的
local_lease_); - 陈旧的注册元数据触发一次有界的元数据刷新与重试(仍在同一传输上);
- 权限与范围失败是终态,不做重试。
4.3 歧义 WRITE 的"不可重放"纪律
若一个 WRITE 请求可能已到达对端、但未收到有效 ACK,远端结果是未知的。该失败被判定为终态,不通过其他传输重放——否则一个已提交的 WRITE 可能因 ACK 丢失被执行两次。这是保证 at-most-once 语义的关键设计取舍。
五、超时与关闭:有界且 fail-closed
5.1 两级超时
- resolve/connect 使用
connect_timeout_ms; - 头部、payload 与响应的进度使用
progress_timeout_ms,客户端与服务端都适用:- 新接受的连接必须在期限内发出第一个头部字节;
- 每个部分头部或 payload 必须持续取得进展;
- 合法请求完成后,持久连接上的纯空闲不算停滞 I/O——下个头部一开始,deadline 重新起算;
- 超时取消 resolver 或 socket,终态完成只在其对应回调退役后发布。
5.2 失败传播
- 某条 READ 切片失败会取消其兄弟切片;逻辑任务在所有切片回调退役后才结算;
- 陈旧注册结果可触发有界元数据刷新/重试,但不会迁移到另一条 rail;
- 一个 peer 在等待其进度超时期间,其他 peer 可继续工作;
- 同一 peer 的 lane 间 FIFO 共享仍会让小请求排在大请求后面——切片不是优先级或抢占机制。
5.3 客户端空闲连接回收
客户端在 idle_connection_timeout_ms(默认 60000 ms)内、该 lane 无活动或排队 work 时,单独关闭池化 socket:
- 到期前的新 work 会取消该定时器并复用 socket;
- 到期后的 work 触发重连;
- 这样可释放更新版客户端持有的接收端连接槽位;
- 服务端不主动驱逐已建立的空闲 socket——它无法知道客户端是否刚发起另一个 WRITE;
- 无限期保持 socket 打开的老客户端仍需要自己的池清理;
- 接收端连接数占满时尝试的任务仍可能失败:空闲清理不是任务背压,也不是自动重试策略。
5.4 关闭顺序与故障封锁
关闭流程为:关闭准入与 listener → 排空已排队的 dispatch 回调 → 在每个 owner 上取消所有客户端 lane 与服务端 session → 等待操作与 lease → 停止并 join worker 线程。这保证即使对端只发来半个请求,关闭也是有界的。
- 这是生命周期不变量,不是析构的附带细节:客户端与服务端在它们使用的 worker 上下文之前被销毁;debug 构建下,正常 teardown 会断言 admission、客户端操作与服务端会话都在 owner 销毁前排空;
- 异常逃出 ASIO handler 会标记运行时失败并封锁后续准入;owner 事件循环仅继续退役已提交的 work、以相同亲和性处理 teardown 取消;资源排空后 join worker 并上报失败;
- admission-release 下溢是 fail-closed 的:计数器保持不变、新 work 被拒绝、drain 返回错误,而不是把存活 work 当作已完成。
六、配置指南:参数、默认值与双轨示例
6.1 参数总表
hp_tcp 在 transports.hp_tcp 下配置。下表为文档参数与源码默认值(hp_tcp_transport_config.h)的合并视图:
| Field | Meaning | 默认值(源码) |
|---|---|---|
enable |
启用 hp_tcp;须同时把 transports.tcp.enable 置为 false,两者不能共存(控制面通知所有权单一) |
false |
bind_address / advertise_address / port |
监听端点与发布端点 | "" / "" / 0 |
rail_addresses |
静态 lane→rail 路由的有序 IPv4 源地址列表;须唯一、长度不超过 connections_per_peer、两端等长;非通配 bind_address 必须等于唯一 rail 地址,多 rail 要求其为空或 0.0.0.0 |
[] |
worker_count |
ASIO 事件循环线程数 | 16 |
connections_per_peer |
每对端持久 lane 数 | 4 |
max_outstanding_tasks / max_outstanding_bytes |
全局准入(任务/字节)上限 | 4096 / 1 << 32(4 GiB) |
max_transfer_bytes |
单请求最大尺寸;HP TCP 启用时,HP TCP/UNSPEC 请求的合并同时尊重本地与发布的远端上限,单独超限的请求仍被拒绝 | 1 << 30(1 GiB) |
connect_timeout_ms / progress_timeout_ms |
连接与 I/O 进度截止时间 | 2000 / 30000 |
idle_connection_timeout_ms |
客户端空闲池保留时长(正数),默认 60000 ms;有活动或排队请求的 lane 永不被此定时器驱逐;保留越短越早释放接收端槽位,但间歇流量需要更多重连 |
60000 |
源码解析器 hp_tcp_transport_config.cpp 额外给出两类校验:启用状态下所有上限与超时均为严格正数(任一为零即报"zero or inconsistent limits"),且与 transports/tcp/enable 互斥。它刻意不用 Config::get() 的静默类型默认替换(注释明确说明),而是对每个字段做严格类型/范围解析。
6.2 单 rail 配置示例
以下 JSON 写入服务端 MC_TENT_CONF 文件(示例地址须替换为本机实际分配地址):
{
"transports": {
"tcp": {"enable": false},
"rdma": {"enable": false},
"shm": {"enable": false},
"hp_tcp": {
"enable": true,
"bind_address": "",
"rail_addresses": ["10.0.0.2"],
"worker_count": 4,
"connections_per_peer": 4
}
}
}
客户端使用相同配置,仅把本地地址换成 10.0.0.1。注意:MC_TENT_CONF 加载的是完整配置,因此即使使用 tebench 的 --xport_type=hp_tcp,也必须显式包含各传输的 enable 标志。
6.3 双 rail 配置
双 rail 只需改两个列表,条目按索引配对:
| Host | rail_addresses |
|---|---|
| Client | ["10.0.0.1", "10.1.0.1"] |
| Server | ["10.0.0.2", "10.1.0.2"] |
两端需要为这些地址对准备可用的源地址路由;复用的 socket 保持该映射,失败的 socket 在后续请求重连前被关闭。测试代码 hp_tcp_e2e_test.cpp 展示了同构的最小化配置:单 rail 绑定 127.0.0.1,多 rail 把 bind_address 置空、rail_addresses 设为 ["127.0.0.1", "127.0.0.2"],且同时置 rdma/shm/tcp 为禁用。
七、用 tebench 验证传输正确性
先启动目标的 target,再用 initiator 以发布段名连接:
MC_TENT_CONF=client.json tebench --backend=tent --xport_type=hp_tcp \
--tent_transport_hint=hp_tcp --target_seg_name=SERVER_SEGMENT \
--seg_type=DRAM --op_type=mix --check_consistency=true \
--start_block_size=67108864 --max_block_size=67108864 --duration=3
- 上述命令使用 64 MiB 块走切片路径;把两个块大小标志都改为
4096可覆盖未切片路径; - 在
--xport_type=hp_tcp --check_consistency=true下,CPU 检查器使用种子可复现、非常量的数据并做全字节比对,用于检出切片乱序(reordered slices); - 吞吐测试应单独运行以避开检查开销;
- 普通
mix、其他 backend 与write_seed/read_verify仍沿用各自既有数据模式。
八、检查 rail 是否真正被使用
8.1 观测手段
- 测试本地的 socket 中继(relay)会记录对端/本地地址与完成的切片区间;
- 全引擎测试校验 payload、guard、切片阈值与连接复用;双进程 E2E 还覆盖两端传输大小上限不一致的情况(见 hp_tcp_e2e_test.cpp);
- 在两台机器上:
ss -tnp检查连接;ip route get REMOTE from LOCAL检查源地址路由;- 传输前后记录每接口字节计数器:READ payload 从服务端 TX 流向客户端 RX,对比两条 rail 的增量与成功应用层字节数,并扣除协议开销与无关流量;
- 注意:两条打开的连接本身不能证明 payload 走了多轨。
8.2 实验设计注意事项
- loopback 只能证明路由逻辑,不能证明物理 NIC 使用——需检查 PCI 设备与共享的主机/结构上限;
- 对比三组:单 rail/单 lane、单 rail/多 lane、双 rail/同总 lane 数——后两者不仅在 rail 放置上不同,在单 READ 是否切片上也不同;
- 保持 CPU/内存绑定固定,记录各 NIC 的 NUMA 节点;
- 使用每线程 CPU 测量,因为 Store 与 tebench 的调用方也在轮询完成时消耗 CPU。
九、并发 READ 调优:流数上界与实测数据
9.1 理论边界
对单个 peer,若有 C 个并发大 READ(大到足以跨所有 rail 切片)和 R 条 rail,最多可有 min(connections_per_peer, C * R) 条活跃 payload 流:
- 4 个并发 READ + 4 条总 lane,在单/双 rail 下都是同样的四流上限——单加 rail 不提高上界;
- 4 rail + 4 lane = 每 rail 1 连接;8 lane = 每 rail 2 连接;
- 每条 lane 同时只处理一个操作;当 4 条 lane 已有不同 owner 时,只加 worker 无法增加活跃 lane owner 数;
- 应分开比较 lane 数与 worker 数,两端都记录数量、吞吐、尾延迟与 CPU;共享同一批 worker 的多余 lane 未必提升吞吐。
9.2 Xeon 8457C VM 上的对照结果
两台 Xeon 8457C 虚拟机上,4 worker + 4 总 lane,三次交错运行(1 秒 warmup、3 秒测量)的中位数:
| READ workload | Metric | One rail | Two rails |
|---|---|---|---|
| 64 MiB, one concurrent task | Throughput (GB/s) | 3.22 | 6.46 |
| 64 MiB, four concurrent tasks | Throughput (GB/s) | 11.13 | 10.96 |
| 4 KiB, one concurrent task | Mean latency (microseconds) | 72 | 78 |
两个接口都承载了 payload 方向流量,但其底层资源独立性并不保证。在该 4 lane/4 worker 配置下,4 个并发 READ 没有额外收益;同一池中 4 KiB/64 MiB 的闭环混合仍让小任务排在大任务后面:静态切片既无延迟隔离,也无普适的带宽扩展。
9.3 H20 主机上的 Store get_into 对比
另一个 Store get_into 对比实验:两台 H20 主机,未改动的 202ad9c89 Release 构建,8 MiB 主机内存对象,4 个闭环调用方(各 1 个在途 READ),CPU 0-89、新分配绑定 NUMA node 0;双 rail 用 node 0 的 eth1/2,四 rail 再加 node 1 的 eth3/4;每组 30 秒 × 3 次(2 秒 warmup),配置对交错、三对顺序执行:
| Rails | Workers / total lanes | Median GB/s [min, max] | Client CPU (core equivalents) |
|---|---|---|---|
| 2 | 4 / 4 | 10.371 [9.664, 10.747] | 6.84 |
| 2 | 8 / 8 | 8.022 [7.904, 8.159] | 5.40 |
| 4 | 4 / 4 | 7.599 [7.440, 7.877] | 6.86 |
| 4 | 4 / 8 | 8.537 [8.139, 8.624] | 6.93 |
| 4 | 8 / 4 | 7.339 [7.260, 7.790] | 6.83 |
| 4 | 8 / 8 | 11.487 [11.343, 11.551] | 7.76 |
关键读数:
- 四 rail 下同时提高两个计数,吞吐较 4/4 提升 51.2%,客户端 CPU 从 6.86 升至 7.76 core;
- 只加 worker 无效;只加 lane 效果有限;
- 四 rail 8/8 比测得的最佳双 rail(4/4)快 10.8%,代价是 13.5% 更多的客户端 CPU;服务端进程 CPU 采样也从约 2.24 升至 3.35 core;
- 双 rail 在 8/8 下反而回退——这些结果不能作为上调默认值的依据。
18 次计时运行前,全部 32 个对象通过逐字节校验;每次运行都用满配置的 4 或 8 条连接,per-rail payload 字节总数与静态切分一致。实验同时改变两端 worker 数,不能隔离客户端/服务端成本,也无法消除 NUMA 效应;其覆盖范围是 Store 主机内存 READ,不适用于 GPU 传输或模型级性能。
十、源码验证路径速览
若想深入验证上述结论,建议按以下路径阅读:
- 配置:默认值与解析校验见 hp_tcp_transport_config.h 与 hp_tcp_transport_config.cpp,完整 JSON 骨架见 transfer-engine.json;
- 协议帧与状态码:hp_tcp_protocol.h;
- Worker 池与准入控制:hp_tcp_workers.h;
- 客户端 lane 管理、服务端 session 集与任务 shell:hp_tcp_client.h、hp_tcp_server.h、hp_tcp_task.h;
- 测试:单/多 rail 双进程 E2E 见 hp_tcp_e2e_test.cpp,另有协议、socket、buffer registry、worker 与配置解析的专项测试(tent/tests 下
hp_tcp_*_test.cpp系列)。
综上,hp_tcp 的价值在于用"持久 lane + 静态多 rail 切片 + 有界准入 + 严格关闭纪律"在纯 TCP 网络上换取确定性与可验证性。它的调优空间并非无限:流数上界由 min(connections_per_peer, C × R) 决定,worker 数只影响并发 owner 的覆盖,切片只解决带宽叠加、不解决延迟隔离。部署时请以实测数据为准,逐项对照"单 rail/多 lane/多 rail 同总 lane"三组配置,而不是盲目上调默认值。