Prhub

#33026 [rust-server] fix TCP-layer TTFT stalls

原始 PR 作者 sherlockwu 合并时间 2026-08-02 03:13 文件变更 2 提交数 3 评论 2 代码增减 +29 / -11

执行摘要

修复 Rust server TCP 层两类 TTFT 卡顿

PR body 记录了 Rust vs Python TM 对比基准(gpt-oss-120b + MiMo-V2.5-Pro,8×B300,bench_serving c=1, seed 42, --disable-radix-cache)下发现的两个 TCP 层异常:

1) 约 200 ms 大请求体停顿——"A headers+body burst larger than the kernel's initial rcvbuf (tcp_rmem[1] = 87380 B) drops its tail before hyper polls the body; recovery is the client's RTO retransmit (~200 ms, measured tcpi_total_retrans=3; threshold bisected to 75→91 KB)",且它会命中每个新连接的第一个大请求体("Hits the first large-body request of every connection — i.e. every request for fresh-connection clients like bench_serving");
2) keep-alive 连接约 +13 ms——"hyper/axum doesn't set TCP_NODELAY (Python's asyncio does)"。目标是让 Rust server 的 TTFT 与 Python TM 对齐(Python 参考值 166/1080/1986/9669 ms,修复后 Rust 反而全面小幅领先)。

值得精读,尤其适合服务器网络编程与性能调优场景。三个值得关注的设计决策:

1) 诊断先行——先用 raw-socket 探针和内核计数把问题量化到 TCP 层,再动手改代码;
2) 用 tokio 原生 API(TcpSocket)而非手写 libc,降低 unsafe 面且生命周期管理更简单;
3) 启动语义权衡——bind 保持同步(端口冲突是硬错误),listen 移到运行时(失败软降级),配置失败不阻断启动。建议后续补一个 raw-socket 大包探测的自动化测试或 CI 探针,防止这两类 TCP 行为悄悄回归。

讨论亮点

该 PR 没有独立 inline review 评论(reviewer rainj-me 直接 APPROVED),核心讨论通过提交迭代与作者评论展开,可从 commit cab5764 与作者说明重建:

  • socket 配置改走 tokio 原生 API:review 建议停止使用 libc 手写 setsockopt,改用 tokio::net::TcpSocketset_reuseaddr(true) + set_recv_buffer_size(16 MiB)(bind 前设置),listen(1024) 移入 api runtime,libc 依赖移除;作者照做并重新验证了全部基准。
  • 为何不调 SO_SNDBUF:作者明确论证不需要——两个故障分别在接收路径(请求体突发溢出 rcvbuf)与写延迟路径(小响应上的 Nagle),服务器→客户端负载是 KB 级(SSE 增量 / JSON),发送方向不存在类似 rcvbuf 溢出等重传的失败模式。
  • 失败降级策略set_recv_buffer_size 失败仅 eprintln! 警告、set_nodelay 失败仅 tracing::debug!,避免在受限容器中阻断启动;权衡点是「配置失败→服务以未优化行为继续运行」。

实现拆解

  1. 先量化、再定位:作者没有直接改代码,先用三类手段把问题钉死——bench_serving 对比定位两类延迟;raw-socket 探针(headers + 390 KB 在一次 send 中发出,计时到响应头)把大请求体延迟复现成 208 ms;tcpi_total_retrans=3 证明是 TCP 重传而非处理慢;触发阈值二分定位到 75→91 KB。这决定了后续改动全部落在 TCP 层而不是应用层。

  2. runtime.rs:socket 创建与缓冲配置重写Runtime::start 中的监听建立从「std listener + 手动非阻塞」改为「tokio 原生 socket」。按地址族创建 tokio::net::TcpSocket::new_v4() / new_v6(),在 bind 前设置 set_reuseaddr(true)set_recv_buffer_size(16 MiB),再同步 bind——端口冲突仍是硬启动错误;listen(1024) 推迟到 api runtime 执行,失败降级为普通日志。这样 accept 出的连接继承 16 MiB 接收缓冲,覆盖约 1M token 请求体,且不再需要 libc 依赖。

  3. api_server.rs:开启 TCP_NODELAYserve 签名从 std::net::TcpListener 改为 tokio::net::TcpSocket,内部先 socket.listen(1024),再通过 axum::serve::ListenerExt::tap_io 对每个 accepted 连接执行 set_nodelay(true)(失败仅 tracing::debug!),与 Python asyncio 默认行为对齐,消除 keep-alive 连接上 Nagle + delayed-ACK 的约 13 ms 惩罚。tap_io 是钩子式接口,无需改动 axum serve 主流程。

  4. 验证与配套:无新增自动化测试(性能改动以手动基准验证为主)。作者对重做后的 build 重新验证了三类签名:raw 390 KB 上传 0.4 ms、keep-alive in=1 ×128 平均 27.5 ms、bench 16K/64K 159/1047 ms,与改前一致;并书面论证无需调 SO_SNDBUF(两个故障分别在接收路径与写延迟路径,发送方向负载为 KB 级,SO_SNDBUF 对二者都无影响)。

文件 模块 状态 重要度
rust/sglang-server/src/runtime.rs 启动引导 modified 6.46
rust/sglang-server/src/api_server.rs API 入口 modified 6.48

关键符号

Runtime::start serve

关键源码片段

rust/sglang-server/src/runtime.rs core-logic

核心修复 1:socket 创建从 std TcpListener 改为 tokio TcpSocket,在 bind 前设置 16 MiB SO_RCVBUF 与 SO_REUSEADDR,解决大请求体超出内核初始接收缓冲(tcp_rmem[1] = 87380 B)触发 RTO 重传(约 200 ms)的问题;并移除了 libc 依赖。

// Runtime::start 中的 socket 建立段:保持同步绑定,
// 端口被占用(EADDRINUSE)时在 spawn 任何线程之前就以 Err 返回。
let addr = cfg.rust_server_args.http_addr;
let socket = match addr {
    std::net::SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4(),
    std::net::SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6(),
}
.map_err(|e| format!("socket for {addr} failed: {e}"))?;
socket
    .set_reuseaddr(true)
    .map_err(|e| format!("set_reuseaddr failed: {e}"))?;// 16 MiB SO_RCVBUF:内核默认 tcp_rmem[1] 只有 87380 B,而请求头 + body
// 常以一次突发到达,尾部段会在 hyper 轮询 body 前被内核丢弃,只能等
// 客户端 RTO 重传(实测约 200 ms、tcpi_total_retrans=3,阈值在 75→91 KB)。
// 该设置会被 accept 出的 socket 继承,覆盖约 1M token 请求体;内核按需
// 惰性分配,实际生效值受 net.core.rmem_max 钳制。
// 失败不阻断启动(软降级),服务退化为旧行为。
if let Err(e) = socket.set_recv_buffer_size(16 * 1024 * 1024) {
    eprintln!("warning: set_recv_buffer_size on listener failed: {e}");
}socket
    .bind(addr)
    .map_err(|e| format!("bind {addr} failed: {e}"))?;
// socket 随后移交给 api runtime 线程,在 api_server::serve 里执行 listen()
rust/sglang-server/src/api_server.rs entrypoint

核心修复 2:serve 签名从 std TcpListener 改为 tokio TcpSocket,以 socket.listen(1024) 在 api runtime 开始监听,并用 ListenerExt::tap_io 对每个已 accept 连接开启 TCP_NODELAY,消除 keep-alive 连接上约 13 ms 的 Nagle + delayed-ACK 延迟,行为对齐 Python asyncio。

// serve() 现在直接接收 tokio TcpSocket:bind 已在 Runtime::start 中同步完成,
// 端口冲突仍是硬启动错误;listen 与 accept 全部发生在 api runtime 上。
pub async fn serve(
    socket: tokio::net::TcpSocket,
    senders: Senders,
    egress_buf: usize,
    server_args: Arc<ServerArgs>,
    egress_activity: ActivityCounter,
    shutdown: flume::Receiver<()>,
) {
    // (AppState 构造与 Router 合并逻辑省略)    // 在 api runtime 上开始监听:accept 队列上限 1024。
    // 相比改前在 runtime::start 里 std listener + set_nonblocking(true) 再
    // from_std() 采纳,现在少一层跨线程移交,创建 / 配置 / 监听语义更清晰。
    let listener = match socket.listen(1024) {
        Ok(l) => l,
        Err(e) => {
            tracing::error!(error = %e, "listen failed");
            return;
        }
    };    // 对齐 Python asyncio:asyncio 默认开启 TCP_NODELAY,而 hyper/axum 不开。
    // 不开启时 keep-alive 连接上的小响应会陷入 Nagle 攒包与 delayed-ACK
    // 的相互等待,实测每条请求多约 13 ms(keep-alive ×128:40.9 → 26.4 ms)。
    // tap_io 在每个 accept 出的连接上执行闭包;失败仅记 debug 日志,
    // 服务以未优化的 TCP 行为继续运行,不阻断启动。
    use axum::serve::ListenerExt;
    let listener = listener.tap_io(|io| {
        if let Err(e) = io.set_nodelay(true) {
            tracing::debug!(error = %e, "set_nodelay failed");
        }
    });    // with_connect_info 向 access-log 中间件暴露对端地址
    let serve = axum::serve(
        listener,
        app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
    );
    // 下接 tokio::select!:serve 退出或 shutdown 信号到达时停止 accept(略)
}

评论区精华

用 tokio TcpSocket 替代 libc setsockopt 设计

review 建议搁置 libc 手写 setsockopt,改用 tokio::net::TcpSocket 原生配置接口:set_reuseaddr(true) + set_recv_buffer_size(16 MiB) 在 bind 之前设置,listen(1024) 移到 api runtime 执行,libc 依赖随之移除。作者在提交 cab5764 中按此重写,并重新验证了三类性能签名(raw probe 0.4 ms、keep-alive 27.5 ms、bench 159/1047 ms)。

结论:采用 tokio 原生 API;bind 保留在 Runtime::start 中,端口冲突仍是硬启动错误;缓冲配置失败降级为 eprintln! 警告,不再阻断启动。 · 已解决

是否需要同步调大 SO_SNDBUF question

作者回应:不需要。两个已量化的故障都在接收 / 写延迟路径——请求体突发溢出接收缓冲、小响应上的 Nagle;SO_SNDBUF 对二者都无影响。服务器→客户端负载是 KB 级(SSE 增量 / JSON),远低于内核初始 tcp_wmem,且发送方向不存在类似 rcvbuf 溢出等重传的失败模式。

结论:维持默认发送缓冲,避免无谓内存开销;若未来出现发送侧瓶颈再单独评估。 · 已解决

注释精简与失败降级 style

review 要求把注释精简到 2-3 行;同时两处新增的套接字配置失败都设计为不阻断启动(eprintln! 警告与 tracing::debug!),避免在受限容器内直接启动失败。

结论:已按要求精简并验证;失败路径均降级,服务以未优化的 TCP 行为继续运行。 · 已解决

风险与影响

  1. 接收缓冲内存放大:16 MiB SO_RCVBUF 是每连接设置,高并发 keep-alive 连接下服务端内存水位会上升;虽然作者注明内核按需惰性分配,但较大的接收窗口会让对端更快推入更多数据,建议压测观察。
  2. 依赖系统 net.core.rmem_max:请求值会被 rmem_max 钳制。若部署环境未调大(许多发行版默认 212992 B),16 MiB 请求会被钳到约 200 KB,大请求体问题依旧存在,而 PR 没有提供启动时校验实际生效值的逻辑。
  3. listen 失败更隐蔽listen(1024) 从启动阶段移到 api runtime,失败时只打日志返回,api server 静默不可用但进程继续运行,诊断路径变长。
  4. 无自动化回归测试:行为完全依赖手动基准验证,后续 axum 升级、tap_io 语义变化可能悄悄回归,且无 CI 探针覆盖这两类 TCP 行为。
  5. set_reuseaddr(true) 语义变化:允许 TIME_WAIT 状态下快速重绑,与 std TcpListener 默认行为有差异,若同一端口被多个实例或外部进程绑定,需要确认不会掩盖配置错误。

对用户:Rust server 的 TTFT 显著改善——大请求体(长 prompt 首次请求)不再有约 200 ms 停顿,keep-alive 高并发客户端(bench_serving 风格)每条请求省约 13 ms,整体性能对齐甚至反超 Python TM(gpt-oss 64K 输入 1253→1047 ms,MiMo 256K 输入 9716→9513 ms)。对系统:无协议/API 变化、无新增配置项,网络行为对齐 Python asyncio,服务端内存占用在高并发下略升。对团队:确立了一套可复用的 TCP 延迟诊断方法论(raw-socket 单突发探针、tcpi_total_retrans 计数、阈值二分),后续同类网络疑点可直接复用,为 Rust server 生产化提供关键性能证据。

缺少自动化测试覆盖 受 net.core.rmem_max 钳制 高并发下接收缓冲内存放大 listen 移至运行时后失败更隐蔽 硬编码 16 MiB 无配置项

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论