Prhub

#33269 [rust-server] Reland: fix TCP-layer TTFT stalls (#33026)

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

执行摘要

重放 TCP 层 TTFT 修复,socket2 保持同步监听并启用 TCP_NODELAY

PR body 明确给出了两个性能根因:一是「headers+body burst larger than the kernel's initial rcvbuf(tcp_rmem[1] = 87380 B)drops its tail before hyper polls the body」,导致每个连接的第一个大请求体停顿约 200 ms;二是「hyper/axum 不设置 TCP_NODELAY(Python 的 asyncio 会设置),Nagle + delayed-ACK」导致 keep-alive 连接额外约 13 ms。原始修复 #33026 因把 listen() 移入 async runtime 破坏「start() 返回即可接受连接」的测试语义,被 #33257 回滚;本次重放需要在不牺牲同步 bind+listen 的前提下保留 SO_RCVBUF 调优。

值得精读。两个设计点很有参考价值:一是用 socket2 在保持同步 bind + listen 语义的同时拿到 bind 前设置 SO_RCVBUF 的能力,绕开了 tokio reactor 依赖造成的启动竞态,这是一个「用底层 API 换取语义不变」的典型手法;二是基于内核 tcp_rmem[1] 默认值的量化分析来定位 TTFT 根因,性能数据扎实(含 P99 与 Python 对照)。可关注后续是否落实 reviewer 建议的 socket 参数可配置化。

讨论亮点

Reviewer rainj-me 在 runtime.rs 的 socket 构建块上提了两条改进建议(均标记为 nic:非阻塞性意见),最终给出 APPROVED:

  • 将 16 MiB 缓冲与 1024 backlog 参数化为 server argsnic: add a TODO comments to extract 16M to and 1024 backlog to server args。属于可配置性建议,当前硬编码可以接受,但长期看大连接数部署可能需要调整为按需配置。
  • 将 TCP socket 调优逻辑抽取到独立文件nic: refactor the tcp socket tuning logic to different file。当前逻辑直接内联在 Runtime::start 中,后续若继续扩展 socket 参数,抽取独立模块会更利于维护。

两条建议均未在本次 PR 落地,也没有后续回复,属于已通过但留待后续处理的改进项。

实现拆解

实现分为两步,围绕「同步起监听」与「连接级 TCP 参数」展开:

  1. 重启监听构建方式(rust/sglang-server/src/runtime.rs 的 Runtime::start。将原来 std::net::TcpListener::bind 一步替换为 socket2 构建流程:Socket::new 建流式 TCP socket → set_reuse_address(true) 保持快速重启能力 → set_recv_buffer_size(16 * 1024 * 1024) 在 bind 前调大接收缓冲 → bindlisten(1024) → 转回 std::net::TcpListenerset_nonblocking(true)。核心原因:std::net::TcpListener 无法在 bind 前设置 SO_RCVBUF,而上一版用 tokio::net::TcpSocket::listen() 需要 reactor,只能把 listen 挪进 async runtime,导致 start() 返回与端口可接受连接之间出现竞态(CI 中 runtime::testsECONNREFUSED)。socket2 是同步 API,可在 spawn 任何线程之前完成 bind + listen,恢复测试保证。listen backlog 从 std 默认的 128 提高到 1024。

  2. 连接级 TCP_NODELAY(rust/sglang-server/src/api_server.rs 的 serve。在 axum::serve 之前,通过 axum::serve::ListenerExt::tap_io 对每个被 accept 的 socket 调用 set_nodelay(true),失败仅记录 debug 日志,不阻断启动流程。这消除了 keep-alive 连接上 Nagle 算法与 delayed-ACK 的互相等待,与 Python asyncio 的默认行为对齐。

  3. 依赖与锁文件配套(rust/Cargo.toml、rust/sglang-server/Cargo.toml、rust/Cargo.lock)。在 workspace 依赖统一声明 socket2 = "0.6",server crate 以 { workspace = true } 引用,锁文件同步更新。没有新增测试文件;验证依赖现有的 cargo test --workspace(含此前 CI 失败的两个运行时测试)、cargo fmt --checkcargo clippy,PR body 还附带完整的 TTFT benchmark 对照数据。

文件 模块 状态 重要度
rust/sglang-server/src/runtime.rs 启动引导 modified 6.5
rust/sglang-server/src/api_server.rs HTTP 服务 modified 6.22
rust/Cargo.toml 依赖管理 modified 2.93
rust/sglang-server/Cargo.toml 依赖管理 modified 2.93
rust/Cargo.lock 依赖管理 modified 2.0

关键符号

start serve

关键源码片段

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

核心改动:用 socket2 重写监听 socket 构建流程,在 spawn 线程前同步完成 bind + listen,并设置 16 MiB SO_RCVBUF 与 1024 backlog。这是解决大请求体 TTFT 停顿的关键,同时也是避免重蹈 #33026 竞态失败的核心设计。

/// 启动整个前端。线程 spawn 完成后立即返回(非阻塞),
/// 让 Python 调用方立刻拿回 GIL。配置错误(如缺 tokenizer)返回 Err。
pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
    // 绑定 API server 端口必须早于任何线程 spawn:端口占用(EADDRINUSE)
    // 应当成为硬性启动错误。这里用 socket2 而非 std::net::TcpListener,
    // 因为只有 socket2 能在 listen() 之前设置 SO_RCVBUF。
    let addr = cfg.rust_server_args.http_addr;
    let socket = socket2::Socket::new(
        socket2::Domain::for_address(addr),
        socket2::Type::STREAM,
        Some(socket2::Protocol::TCP),
    )
    .map_err(|e| format!("socket for {addr} failed: {e}"))?;    // 允许快速重启复用 TIME_WAIT 端口,保持与旧 std TcpListener 行为一致
    socket
        .set_reuse_address(true)
        .map_err(|e| format!("set_reuseaddr failed: {e}"))?;    // 16 MiB 接收缓冲:内核默认 rcvbuf(tcp_rmem[1] ≈ 87 KB)会在 hyper
    // 轮询请求体之前丢弃大请求的尾部,造成每个连接首个大请求约 200 ms 停顿。
    // 设置在 listener 上会被 accept 出的连接继承;内核按 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}");
    }    // bind + listen 同步完成,确保 start() 返回时端口已可接受连接。
    // 这是 runtime::tests 依赖的语义:上一版 #33026 用 tokio TcpSocket::listen
    // 需要 reactor,被迫把 listen 挪进 async runtime,导致测试在 start() 返回后
    // 立即连接时竞态失败(ECONNREFUSED),PR 被回滚。socket2 让调优与同步语义共存。
    socket
        .bind(&addr.into())
        .map_err(|e| format!("bind {addr} failed: {e}"))?;
    socket
        .listen(1024)
        .map_err(|e| format!("listen on {addr} failed: {e}"))?;    let listener: std::net::TcpListener = socket.into();
    listener
        .set_nonblocking(true)
        .map_err(|e| format!("listener set_nonblocking failed: {e}"))?;
    // ... 后续创建 ring、channel、spawn 线程 ...
}
rust/sglang-server/src/api_server.rs entrypoint

入口层补上 TCP_NODELAY:通过 axum ListenerExt::tap_io 对已 accept socket 设置 set_nodelay(true),消除 keep-alive 连接上约 13 ms 的 Nagle/delayed-ACK 延迟,与 Python asyncio 行为对齐。

    // 与 Python 对齐:asyncio 默认给 TCP 连接设置 TCP_NODELAY,
    // 而 hyper/axum 不会自动设置。不设的话,keep-alive 连接上会
    // 出现 Nagle 算法与 delayed-ACK 互相等待,带来约 13 ms 额外延迟。
    // ListenerExt::tap_io 钩住每个被 accept 的 socket,在它进入
    // tokio 事件循环之前立即完成设置,行为与 Python 一致。
    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>(),
    );

评论区精华

16 MiB 缓冲与 1024 backlog 应参数化到 server args 设计

rainj-me 指出:`nic: add a TODO comments to extract 16M to and 1024 backlog to server args`,建议把硬编码的 16 MiB 与 backlog 1024 提取为可配置项,以便大连接数部署按需调整。

结论:硬编码保留,未在本 PR 落地;reviewer 认可为非阻塞建议后 APPROVED。 · 待处理

将 TCP socket 调优逻辑抽取到独立文件 设计

rainj-me 对同一条评论追加建议:`nic: refactor the tcp socket tuning logic to different file`,认为 socket 调优逻辑内联在 Runtime::start 中不利于后续扩展与测试。

结论:未在本次重放中重构,作为后续维护项保留;整体 APPROVED。 · 待处理

风险与影响

技术风险集中在三处:

  • 内核配置依赖:16 MiB SO_RCVBUF 实际受 net.core.rmem_max 钳制,若部署环境该值小于 16 MiB,缓冲设置会静默降级,大请求体停顿可能未完全消除;反之若 rmem_max 被调大且连接数众多,每个连接按需分配的大缓冲可能带来额外内存压力(PR body 说明是惰性分配,风险可控)。
  • listen backlog 提高:从 std 默认 128 提到 1024,在连接突发超过 net.core.somaxconn 时行为取决于内核,对绝大多数部署无影响,但值得留意高并发接入场景的配置联动。
  • 无新增测试文件:本 PR 依赖现有 runtime::tests 回归保障同步 listen 语义,但没有为 SO_RCVBUF / TCP_NODELAY 新增专门测试;性能修复的长期防回归完全依赖 benchmark。另外启动失败路径中 set_recv_buffer_sizeeprintln! 告警,库代码中更合适的做法是 tracing,属于小瑕疵。

影响范围:所有通过 PyO3 嵌入使用 Rust server 的部署(python/sglang/srt/server/_core),覆盖大请求体(长上下文 / 大提示)与 keep-alive 长连接两类常见流量。实测收益:gpt-oss 16K in 的 TTFT 从 192 ms 降至 157 ms(P99 369 → 161),64K in 从 1253 ms 降至 1047 ms;MiMo 256K in 从 9716 ms 降至 9513 ms;keep-alive 128 连发从 40.9 ms 降至 26.4 ms,与 Python TM 参考值(26.4 ms)完全对齐;裸 socket 探针从 208 ms 降至 0.4 ms。对团队而言,这是 Rust server 与 Python TM 性能对齐路径上的关键一步,且修复了上一版引入的启动竞态回归,test_cargo_workspace CI 恢复绿色。

核心路径变更 内核配置依赖 无新增测试覆盖 参数硬编码

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论