【大模型推理】sglang PD 分离 : 6473 服务发现

引言:从一次代码审查引发的思考

最近在浏览 SGLang 项目的 GitHub 动态时,我注意到了 PR #6473 中的一些变更。这个 PR 涉及对项目代码的修改,而在变更描述或相关讨论中,一个名为 "Bootstrap Server" 的组件被提及。这立刻引起了我的好奇:在一个专注于大语言模型(LLM)高效推理的框架中,一个通常与分布式系统服务发现相关的"引导服务器"会扮演怎样的角色?它是否与 SGLang 核心的 Prefill-Decode(PD)分离 架构有直接关联?

为了回答这个问题,我进行了一番调研。然而,根据目前公开的文档、论文和社区讨论,并没有找到关于"Bootstrap Server"在 SGLang 的 PD 分离架构中有明确应用或官方定义的信息。这似乎暗示,"Bootstrap Server"可能是一个在不同技术语境下有不同含义的通用架构概念,而非 SGLang 中专有的、特指某个组件的术语。

因此,要厘清它是否与 SGLang 相关,我们首先需要跳出具体框架,回归本源,弄清楚:"Bootstrap Server"通常指什么?它在各类分布式系统中究竟解决什么问题? 理解了这些,我们才能更好地判断它在类似 SGLang 这样的分布式推理框架中可能存在的理论关联与实现形态。

什么是Bootstrap Server?

"Bootstrap server"(引导服务器)的核心功能是作为一个初始连接点发现入口,让客户端能够首次连接到某个分布式系统或集群。

它的作用可以概括为以下几点:

  • 提供初始连接信息:在复杂的分布式系统中,客户端不可能预先知道所有服务节点的地址。Bootstrap server 就充当了这个"指路人"的角色,客户端首先连接它,获取其他真正提供服务的节点信息。
  • 获取集群元数据:客户端通过Bootstrap server可以获取整个集群的元数据,比如有哪些节点在线、它们各自的地址和职责等。
  • 实现动态服务发现:获得元数据后,客户端就可以直接与相应的服务节点通信,从而实现服务的动态发现和负载均衡。

在不同技术领域中的应用

"Bootstrap server"这个术语在多个领域都有应用,以下是几个最典型的例子:

  • Apache Kafka :这是"Bootstrap server"最广为人知的应用场景之一。在Kafka中,bootstrap.servers是一个必需的配置项,用于指定一个或多个Kafka Broker的地址。生产者或消费者客户端通过连接这些Broker来获取整个Kafka集群的元数据(如Topic的分区信息),然后才能开始生产和消费消息。
  • 分布式系统与服务发现:在更广泛的分布式系统设计中,Bootstrap server是服务注册与发现机制的一部分。例如,在Spring Cloud体系中,服务实例启动后会向服务注册中心(如Eureka或Consul)注册自己,而客户端则通过这个注册中心(相当于一个Bootstrap server)来发现其他服务的位置。
  • 机器学习与分布式训练:在分布式机器学习框架中,也存在类似"引导"的概念。比如,一个参数服务器(Parameter Server)或协调节点,会负责启动时的工作节点发现和角色分配,其功能就类似于一个Bootstrap server。

与SGLang的可能关联

回到你关心的SGLang和PD分离。在SGLang这类大模型推理框架中,如果采用分布式部署(如将Prefill和Decode阶段分离到不同节点),理论上确实需要一个服务发现机制来让各个节点互相感知。

因此,SGLang的架构中可能存在一个功能上等同于Bootstrap server的组件,只是可能不叫这个名字。它可能用于:

  • 让新的Decode节点加入集群时,能通过它找到Prefill节点。
  • 让客户端(如API网关)能通过它获取所有可用节点的状态和地址。

关于"bootstrap_room"

你之前提到的bootstrap_room很可能是一个特定项目或内部文档中的专有名词,并非行业通用术语。它可能是对上述"引导/发现"功能的一种特定实现或封装。


总结一下Bootstrap server是一个通用的架构概念 ,指代分布式系统中负责初始连接和服务发现的入口节点。它在Kafka等系统中是标准配置,在SGLang这类分布式推理框架中也可能有类似功能的组件。

如果想确认SGLang具体是如何实现服务发现的,最可靠的方法是:

  1. 查阅 SGLang的官方文档,关注其分布式部署的章节。
  2. SGLang的GitHub仓库 中搜索"service discovery"、"registry"等关键词。
  3. 直接向 SGLang的开发社区(如GitHub Issues或Slack)提问。

这行代码 self.app = web.Application() 是在使用 Python 的 aiohttp 库创建一个 Web 应用程序的核心实例。

简单来说,它创建了一个"空壳"的 Web 服务容器,后续你需要往这个容器里添加路由(URL地址)、中间件(拦截器)和静态文件配置等,它才能变成一个真正能接收 HTTP 请求的服务器。

为了让你更清楚,我们可以拆解来看:

1. web 是什么?

绝大多数情况下,这里的 web 指的是 aiohttp 库中的 web 模块(from aiohttp import web)。aiohttp 是 Python 中非常流行的异步 Web 框架。

2. Application() 是什么?

它是 aiohttp 框架中的核心类,负责管理整个 Web 应用的生命周期。它像一个"总控制器",主要做三件事:

  • 存储路由映射 :记录用户访问的网址(比如 /health)应该对应执行哪一段 Python 代码。
  • 管理中间件:可以在这里配置全局的拦截器(比如打印日志、处理跨域 CORS、统一鉴权)。
  • 共享全局数据 :可以把数据库连接池、配置参数等存到 app['config'] 里,让整个应用的各个部分都能访问。

3. 为什么用 self.app

因为这句代码写在了一个 类(Class)__init__ 方法中。self.app 意味着这个 Web 应用实例是作为该类的一个成员属性 存在的。这样,该类里的其他方法(比如 runstop)都可以通过 self.app 来操作这个 Web 服务。


它的完整工作流程通常是这样的:

python 复制代码
from aiohttp import web

class MyServer:
    def __init__(self):
        # 1. 创建空的 app 实例(你问的这行)
        self.app = web.Application()
        
        # 2. 添加路由:告诉 app 当用户访问 "/" 时,调用 handle 方法
        self.app.router.add_get('/', self.handle)
        
        # 3. 添加中间件(可选)
        self.app.middlewares.append(self.logging_middleware)

    async def handle(self, request):
        return web.Response(text="Hello, SGLang!")

    def run(self):
        # 4. 运行服务,监听端口
        web.run_app(self.app, host='0.0.0.0', port=8080)

结合你之前问的 SGLang 和 PD 分离场景:

如果这是在 SGLang 的源码或相关扩展代码中,那么这行代码通常是为了启动一个 HTTP 控制面板或网关接口。比如:

  • 用来接收外部的推理请求(Gateway 入口)。
  • 用来暴露 /metrics 接口给 Prometheus 抓取监控数据。
  • 用来暴露 /health 接口给 K8s 做存活探针。
  • 用来接收 Bootstrap Server 发送过来的"其他节点地址"并更新本地配置。

需要注意的是,这个 web.Application() 负责的是"外部通信接口(HTTP Server)",它本身并不负责 GPU 上的模型推理计算(那是由 SGLang 的 Runtime 和 Kernel 负责的)。它更像是贴在 SGLang 引擎外面的一层"HTTP 皮肤",方便我们用浏览器或 curl 命令去调用它。

这个 Pull Request #6473 直接关联并回答了你之前提出的所有问题。它是 SGLang 项目中实现 PD(Prefill-Decode)分离 功能的一个关键代码合并,核心就是引入了 Bootstrap 机制

下面结合这个 PR 的具体内容,为你串联分析:

1. 关于 bootstrap_room(你最初的问题)

在这个 PR 的代码讨论和日志中,bootstrap_room 被大量使用 ,它确实是 PD 分离中用于请求匹配的核心标识符

  • 作用 :在 PD 分离场景下,一个推理请求会先由 Prefill 节点处理,生成 KV Cache,再传给 Decode 节点。bootstrap_room 就像是这个请求的"房间号"或"配对码",用于确保 Prefill 节点和 Decode 节点能正确匹配到同一个请求的 KV Cache 传输任务
  • 实际 bug 修复证据 :PR 描述中修复了一个"挂起(hang)"问题,日志显示,由于匹配错误,携带大量数据的请求 (byte size 9039) 被发到了 DP0 TP0,而真正处理该请求的 DP2 TP2 只收到了空数据 (byte size 29),导致流程卡住。这个修复正是围绕 bootstrap_room 的路由逻辑展开的。

2. 关于 Bootstrap Server(你上一个问题)

这个 PR 直接引入了 KVBootstrapServer 组件,证实了"Bootstrap Server"在 SGLang PD 分离中的核心地位。

  • 功能定位 :它在这个分布式架构中充当 "服务注册与发现中心"
    • 注册:Prefill 节点启动时,会将自己支持的 DP(数据并行)大小、TP(张量并行)大小等信息注册到 Bootstrap Server。
    • 发现与路由 :Decode 节点在需要获取 KV Cache 时,会向 Bootstrap Server 查询(PR 中提到的 /route 接口),获取应该连接哪个 Prefill 节点的地址和元数据,从而建立传输通道。
  • 代码重构 :这个 PR 的核心工作,就是把原本在 NIXLMooncake 这两个不同传输后端中重复的"引导(bootstrapping)"代码(包括整个 KVBootstrapServer 和节点注册逻辑)抽取出来,放到 common/conn.py统一管理,使其与具体的传输方式解耦。

3. 关于 web.Application()(你之前问的代码)

虽然 PR 没有直接贴出这行代码,但它的功能为 Bootstrap Server 提供了 HTTP 接口能力

  • 日志中明确出现了 "GET /route HTTP/1.1" 200 的请求记录。这个 /route 端点(很可能就是由 web.Application() 创建的路由)正是 Decode 节点向 Bootstrap Server 查询 Prefill 节点信息的接口。
  • 因此,你之前看到的 self.app = web.Application() 这行代码,极大概率就是用于启动这个 Bootstrap Server 的 HTTP 服务 ,用来接收和处理这类 /route 查询请求。

总结这个 PR 的意义

这个 PR 是理解 SGLang PD 分离架构的绝佳样本,它清晰地展示了:

  1. Bootstrap Server 是一个独立的服务(或功能模块),负责维护集群节点信息和路由决策。
  2. bootstrap_room 是请求级别的唯一标识,用于在 Prefill 和 Decode 节点间进行精确的 KV Cache 传输匹配。
  3. 该 PR 通过统一不同后端的引导代码,增强了系统的可维护性,并修复了因路由错误导致的服务挂起问题。

如果你想进一步确认 bootstrap_room 的具体数据结构或 /route 接口的详细定义,可以直接查看 PR 中修改的 common/conn.py 文件和相关的 Prefill/Decode 节点代码。

简短回答

这不是一个特殊/命名的设计模式的直接体现,而是标准的组合(Composition)+ 外观(Facade)模式CommonKVBootstrapServer 把第三方 aiohttp.web.Application 实例作为成员对象持有(self.app),并通过自己的方法(_setup_routes_run_server)封装/委托底层 HTTP 服务器的细节,对外只暴露简单的 run() / close() 接口。


依据

__init__ 中,self.app = web.Application() 创建了一个 aiohttp 的 Web 应用实例作为该类的内部成员,紧接着调用 self._setup_routes() 把路由注册到这个 app 对象上: 1

_setup_routes 把 HTTP 路由(/route, /register_dp_rank, /query_dp_ranks, /health)绑定到类自身的方法(如 self._handle_route),实现了路由分发的委托(Delegation)2

真正启动服务器的逻辑封装在 _run_server 里:创建独立的 asyncio 事件循环,用 web.AppRunner(self.app, ...) 包装 self.app,再绑定 web.TCPSite 监听端口------这一步把 aiohttp 底层繁琐的 Runner/Site 生命周期管理都隐藏在类内部: 3

对外,CommonKVBootstrapServer 只暴露了简单的 run()(启动后台线程)和 close()(停止事件循环并 join 线程)两个方法,调用方(如 TokenizerManager)无需了解 aiohttp 内部细节即可使用: 4 5

具体归类

模式 体现
组合(Composition / has-a) self.appweb.Application() 的实例,作为该类的一个属性持有,而不是继承自 web.Application
外观(Facade) run() / close() 隐藏了 AppRunner + TCPSite + asyncio event loop 的复杂启动/关闭流程
委托(Delegation) 路由处理方法(_handle_route_put/get_handle_register_dp_rank 等)被注册为 self.app.router 的回调,实际的 HTTP dispatch 逻辑委托给 aiohttp 框架完成,业务逻辑再回调到类自身方法上
模板方法(弱) _setup_routes__init__ 中被调用,是初始化阶段固定的一步,符合初始化模板的思路,但整体不算严格的模板方法模式

Notes

  • 该设计并非 sglang 特有的自定义设计模式命名,而是使用 aiohttp 框架时的常见惯用写法(Application + Router + Runner + Site),可以理解为对 aiohttp 服务器生命周期的封装/适配
  • 类似的"持有 web.Application 并在线程中运行事件循环"的写法在同文件的其他地方未见重复,属于 CommonKVBootstrapServer 独有的实现方式。

Wiki pages you might want to explore:

Citations

File: python/sglang/srt/disaggregation/common/conn.py (L1443-1471)

python 复制代码
class CommonKVBootstrapServer(BaseKVBootstrapServer):
    def __init__(self, host: str, port: int):
        self.host = host
        self.port = port
        self.app = web.Application()
        self.store = dict()
        self.lock = asyncio.Lock()
        self._setup_routes()
        self.pp_size = None
        self.attn_tp_size = None
        self.attn_cp_size = None
        self.dp_size = None
        self.page_size = None
        self.kv_cache_dtype: Optional[str] = None
        self.follow_bootstrap_room: Optional[bool] = None
        self.enable_dsa_cache_layer_split: Optional[bool] = None
        self.prefill_http_port: Optional[int] = None
        self.prefill_port_table: Dict[
            int, Dict[int, Dict[int, Dict[int, PrefillRankInfo]]]
        ] = {}
        self.room_to_dp_rank: Dict[int, Dict[str, Union[int, float]]] = {}
        self._registered_count = 0
        self.entry_cleanup_interval = (
            envs.SGLANG_DISAGGREGATION_BOOTSTRAP_ENTRY_CLEANUP_INTERVAL.get()
        )

        # Start bootstrap server
        self.thread = threading.Thread(target=self._run_server, daemon=True)
        self.run()

File: python/sglang/srt/disaggregation/common/conn.py (L1473-1474)

python 复制代码
    def run(self):
        self.thread.start()

File: python/sglang/srt/disaggregation/common/conn.py (L1490-1494)

python 复制代码
    def _setup_routes(self):
        self.app.router.add_route("*", "/route", self._handle_route)
        self.app.router.add_post("/register_dp_rank", self._handle_register_dp_rank)
        self.app.router.add_post("/query_dp_ranks", self._handle_query_dp_ranks)
        self.app.router.add_get("/health", self._handle_health_check)

File: python/sglang/srt/disaggregation/common/conn.py (L1691-1717)

python 复制代码
    def _run_server(self):
        try:
            # Event Loop
            self._loop = asyncio.new_event_loop()
            asyncio.set_event_loop(self._loop)

            self._loop.create_task(self._cleanup_expired_entries())

            access_log = None
            if logging.getLogger(__name__).getEffectiveLevel() <= logging.DEBUG:
                access_log = self.app.logger

            self._runner = web.AppRunner(self.app, access_log=access_log)
            self._loop.run_until_complete(self._runner.setup())

            site = web.TCPSite(self._runner, host=self.host, port=self.port)
            self._loop.run_until_complete(site.start())
            logger.info(
                f"CommonKVBootstrapServer started successfully on {self.host}:{self.port}"
            )
            self._loop.run_forever()
        except Exception as e:
            logger.error(f"Server error: {str(e)}", exc_info=True)
        finally:
            # Cleanup
            self._loop.run_until_complete(self._runner.cleanup())
            self._loop.close()

File: python/sglang/srt/disaggregation/common/conn.py (L1719-1727)

python 复制代码
    def close(self):
        """Shutdown"""
        if self._loop is not None and self._loop.is_running():
            self._loop.call_soon_threadsafe(self._loop.stop)
            logger.info("Stopping server loop...")

        if self.thread.is_alive():
            self.thread.join(timeout=2)
            logger.info("Server thread stopped")

Prefill 4 个 scheduler 子进程启动,每个 MooncakeKVManager 起来后向 bootstrap server 打一个 PUT: 对应代码流程是什么

答案

流程是:Prefill 侧启动 N 个(如 4 个)TP rank 的 Scheduler 子进程 → 每个 Scheduler 初始化时创建 PrefillBootstrapQueue → 该队列内部实例化 MooncakeKVManager(继承自 CommonKVManager)→ 其 __init__ 调用父类 CommonKVManager.__init__,在 disaggregation_mode == PREFILL 分支里调用 register_to_bootstrap(),向 bootstrap server 发送 PUT /route。因此 4 个 rank 各自独立发起一次 PUT 注册,最终 bootstrap server 汇总所有 rank 信息。


详细代码路径

1. 每个 TP rank 一个 Scheduler 子进程

Prefill server 每个 GPU/rank 都会启动一个独立的 Scheduler 进程,在其初始化中根据 disaggregation_mode == PREFILL 创建 PrefillBootstrapQueue,并传入 bootstrap_port=self.server_args.disaggregation_bootstrap_port1

2. MooncakeKVManager 初始化

MooncakeKVManagerCommonKVManager 的子类,其 __init__ 首先调用 super().__init__(args, disaggregation_mode, server_args, is_mla_backend),之后才进行 mooncake engine 的初始化和 buffer 注册: 2

3. CommonKVManager.__init__ 中触发注册

父类构造函数在完成本 rank 的并行信息(attn_tp_rankpp_rankrank_iprank_port 等)赋值后,若 disaggregation_mode == DisaggregationMode.PREFILL,会先同步 bootstrap 端口(多机场景),再调用 self.register_to_bootstrap()3

4. register_to_bootstrap 发起 HTTP PUT

该方法组装本 rank 的拓扑 payload(attn_tp_rankpp_rankrank_iprank_portpage_sizekv_cache_dtype 等),并对 bootstrap_na.to_url()+"/route" 发起 requests.put,带指数退避重试: 4

5. bootstrap server 侧接收并累计计数

Bootstrap server(CommonKVBootstrapServer)的 _handle_route_put 处理该 PUT 请求,把 rank 信息按 dp_group -> cp_rank -> tp_rank -> pp_rank 的嵌套字典存入 prefill_port_table,并递增 _registered_count5

_is_ready() 通过比较 _registered_countdp_size * attn_cp_size * attn_tp_size * pp_size 的期望值,判断是否所有 rank(如 4 个 TP rank)都已完成注册: 6

只有当全部 rank 都注册完毕,Decode 侧的 GET /route 查询才会返回 200(而非 503 "not fully registered"),详见 _handle_route_get 中的 _is_ready() 检查 7

流程图

"CommonKVBootstrapServer" "MooncakeKVManager (per rank)" "Scheduler rank1..3" "Scheduler rank0" "CommonKVBootstrapServer" "MooncakeKVManager (per rank)" "Scheduler rank1..3" "Scheduler rank0" #mermaid-svg-eqiHQPprAoot3FP3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-eqiHQPprAoot3FP3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eqiHQPprAoot3FP3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eqiHQPprAoot3FP3 .error-icon{fill:#552222;}#mermaid-svg-eqiHQPprAoot3FP3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eqiHQPprAoot3FP3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eqiHQPprAoot3FP3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eqiHQPprAoot3FP3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eqiHQPprAoot3FP3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eqiHQPprAoot3FP3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eqiHQPprAoot3FP3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eqiHQPprAoot3FP3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eqiHQPprAoot3FP3 .marker.cross{stroke:#333333;}#mermaid-svg-eqiHQPprAoot3FP3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eqiHQPprAoot3FP3 p{margin:0;}#mermaid-svg-eqiHQPprAoot3FP3 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-eqiHQPprAoot3FP3 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-eqiHQPprAoot3FP3 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-eqiHQPprAoot3FP3 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-eqiHQPprAoot3FP3 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-eqiHQPprAoot3FP3 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-eqiHQPprAoot3FP3 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-eqiHQPprAoot3FP3 .sequenceNumber{fill:white;}#mermaid-svg-eqiHQPprAoot3FP3 #sequencenumber{fill:#333;}#mermaid-svg-eqiHQPprAoot3FP3 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-eqiHQPprAoot3FP3 .messageText{fill:#333;stroke:none;}#mermaid-svg-eqiHQPprAoot3FP3 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-eqiHQPprAoot3FP3 .labelText,#mermaid-svg-eqiHQPprAoot3FP3 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-eqiHQPprAoot3FP3 .loopText,#mermaid-svg-eqiHQPprAoot3FP3 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-eqiHQPprAoot3FP3 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-eqiHQPprAoot3FP3 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-eqiHQPprAoot3FP3 .noteText,#mermaid-svg-eqiHQPprAoot3FP3 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-eqiHQPprAoot3FP3 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-eqiHQPprAoot3FP3 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-eqiHQPprAoot3FP3 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-eqiHQPprAoot3FP3 .actorPopupMenu{position:absolute;}#mermaid-svg-eqiHQPprAoot3FP3 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-eqiHQPprAoot3FP3 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-eqiHQPprAoot3FP3 .actor-man circle,#mermaid-svg-eqiHQPprAoot3FP3 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-eqiHQPprAoot3FP3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} par "4 个 TP rank 并行启动" "当 _registered_count == dp*cp*tp*pp 时 _is_ready() 变 True" "PrefillBootstrapQueue ->> MooncakeKVManager.init" "PrefillBootstrapQueue ->> MooncakeKVManager.init" "CommonKVManager.init: PREFILL branch" "PUT /route (rank_ip, rank_port, tp_rank, pp_rank, ...)" "_handle_route_put: prefill_port_table... = info, _registered_count += 1"

Notes

  • 这 4 个 PUT 请求是并发独立发出的(各自进程各自重试逻辑),bootstrap server 用 async with self.lock 保护共享字典的写入,以保证线程/协程安全 8
  • 单测 test_register_to_bootstrap.py 覆盖了 register_to_bootstrap 的重试/退避逻辑,可作为该流程行为的补充参考 9

Wiki pages you might want to explore:

Citations

File: python/sglang/srt/disaggregation/mooncake/conn.py (L158-177)

python 复制代码
class MooncakeKVManager(CommonKVManager):
    AUX_DATA_HEADER = b"AUX_DATA"

    def __init__(
        self,
        args: KVArgs,
        disaggregation_mode: DisaggregationMode,
        server_args: ServerArgs,
        is_mla_backend: Optional[bool] = False,
    ):
        super().__init__(args, disaggregation_mode, server_args, is_mla_backend)
        self.init_engine()
        self.register_buffer_to_engine()
        self.enable_staging = envs.SGLANG_DISAGG_STAGING_BUFFER.get()
        self.enable_trace = server_args.enable_trace
        if self.disaggregation_mode == DisaggregationMode.PREFILL:
            self.start_prefill_thread()
            self.session_failures = defaultdict(int)
            self.failed_sessions = set()
            self.session_lock = threading.Lock()

File: python/sglang/srt/disaggregation/common/conn.py (L185-200)

python 复制代码
        if self.disaggregation_mode == DisaggregationMode.PREFILL:
            # When SGLANG_DISAGGREGATION_ALL_CP_RANKS_TRANSFER is True, all CP ranks
            # participate in KV transfer; Otherwise only CP rank 0 sends.
            self.is_dummy_cp_rank = (
                not self.enable_all_cp_ranks_for_transfer
                and self.attn_cp_size > 1
                and self.attn_cp_rank != 0
            )
            # Sync the leader's bootstrap port to every rank before
            # registering: in multi-node prefill, registration targets
            # `dist_init_addr` (rank 0) but each rank's local port may
            # differ when the launcher auto-reserves a free port per host.
            self.bootstrap_port = self._sync_bootstrap_port_across_nodes(
                self.bootstrap_port
            )
            self.register_to_bootstrap()

File: python/sglang/srt/disaggregation/common/conn.py (L598-666)

python 复制代码
    def register_to_bootstrap(self):
        """Register prefill server info to bootstrap server via HTTP PUT."""
        if self.dist_init_addr:
            # Multi-node case: bootstrap server's host is dist_init_addr
            host = NetworkAddress.parse(self.dist_init_addr).resolved().host
        else:
            # Single-node case: bootstrap server's host is the same as http server's host
            host = self.bootstrap_host
            # A wildcard bind address (0.0.0.0 / ::) is not a valid HTTP Host
            # and can't be connected to; rewrite it to the same-family loopback,
            # which the wildcard listener also binds.  (self.local_ip is wrong
            # here --- it can resolve to a different family than the listener,
            # e.g. IPv6 while the server is bound to 0.0.0.0.)
            host = {"0.0.0.0": "127.0.0.1", "::": "::1"}.get(host, host)

        bootstrap_na = NetworkAddress(host, self.bootstrap_port)
        url = f"{bootstrap_na.to_url()}/route"
        payload = {
            "attn_tp_size": self.attn_tp_size,
            "attn_tp_rank": self.attn_tp_rank,
            "attn_cp_size": self.attn_cp_size,
            "attn_cp_rank": self.attn_cp_rank,
            "attn_dp_size": self.attn_dp_size,
            "attn_dp_rank": self.attn_dp_rank,
            "pp_size": self.pp_size,
            "pp_rank": self.pp_rank,
            "system_dp_size": self.system_dp_size,
            "system_dp_rank": self.system_dp_rank,
            "rank_ip": self.local_ip,
            "rank_port": self.rank_port,
            "page_size": self.kv_args.page_size,
            "kv_cache_dtype": get_model().kv_cache_dtype,
            "load_balance_method": self.server_args.load_balance_method,
            "enable_dsa_cache_layer_split": getattr(
                self.server_args, "enable_dsa_cache_layer_split", False
            ),
            # Self-register the HTTP API port so the decode can derive the PD
            # retract rebootstrap /generate URL from bootstrap info instead of a
            # router-injected pd_rebootstrap_prefill_url.
            "prefill_http_port": self.server_args.port,
        }

        max_retries, initial_delay, max_delay = 5, 1.0, 30.0
        for attempt in range(max_retries):
            try:
                response = requests.put(url, json=payload, timeout=5)
                if response.status_code == 200:
                    logger.debug("Prefill successfully registered to bootstrap server.")
                    return
                logger.warning(
                    f"Prefill register attempt {attempt + 1}/{max_retries} failed: status {response.status_code}"
                )
            except Exception as e:
                # Walk to root cause to skip misleading urllib3 wrapper messages
                cause = e
                while cause.__cause__ is not None:
                    cause = cause.__cause__
                logger.warning(
                    f"Prefill register attempt {attempt + 1}/{max_retries} failed: {cause}"
                )
            if attempt == max_retries - 1:
                break
            delay = min(initial_delay * (2**attempt), max_delay) * (
                0.75 + 0.25 * (time.monotonic() % 1)
            )
            time.sleep(delay)
        logger.error(
            f"Prefill instance failed to register to bootstrap server after {max_retries} retries"
        )

File: python/sglang/srt/disaggregation/common/conn.py (L1476-1488)

python 复制代码
    def _is_ready(self) -> bool:
        if (
            self.attn_tp_size is None
            or self.attn_cp_size is None
            or self.pp_size is None
            or self.dp_size is None
        ):
            return False
        expected = self.dp_size * self.attn_cp_size * self.attn_tp_size * self.pp_size
        logger.debug(
            f"Expected {expected} prefill servers to be registered, {self._registered_count} registered so far"
        )
        return self._registered_count >= expected

File: python/sglang/srt/disaggregation/common/conn.py (L1560-1584)

python 复制代码
        if system_dp_size == 1:
            dp_group = attn_dp_rank
        else:
            dp_group = system_dp_rank

        # Add lock to make sure thread-safe
        async with self.lock:
            dp_group_table = self.prefill_port_table.setdefault(dp_group, {})
            cp_group_table = dp_group_table.setdefault(attn_cp_rank, {})
            tp_group_table = cp_group_table.setdefault(attn_tp_rank, {})

            tp_group_table[pp_rank] = PrefillRankInfo(
                rank_ip=rank_ip,
                rank_port=rank_port,
            )

            self._registered_count += 1

        expected = self.dp_size * self.attn_cp_size * self.attn_tp_size * self.pp_size
        logger.debug(
            f"Register prefill bootstrap: DP{dp_group} CP{attn_cp_rank} TP{attn_tp_rank} PP{pp_rank} with rank_ip: {rank_ip} and rank_port: {rank_port}"
            f" ({self._registered_count}/{expected} registered)"
        )

        return web.Response(text="OK", status=200)

File: python/sglang/srt/disaggregation/common/conn.py (L1599-1610)

python 复制代码
        if (
            int(prefill_dp_rank) == -1
            and int(prefill_cp_rank) == -1
            and int(target_tp_rank) == -1
            and int(target_pp_rank) == -1
        ):
            if not self._is_ready():
                return web.Response(
                    text=f"Prefill server not fully registered yet"
                    f" ({self._registered_count} workers registered).",
                    status=503,
                )

File: test/registered/unit/disaggregation/test_register_to_bootstrap.py (L46-60)

python 复制代码
    @patch("sglang.srt.disaggregation.common.conn.time")
    @patch("sglang.srt.disaggregation.common.conn.requests.put")
    def test_succeeds_after_retries(self, mock_put, mock_time):
        mock_time.monotonic.return_value = 0.0
        fail_resp = MagicMock()
        fail_resp.status_code = 503
        success_resp = MagicMock()
        success_resp.status_code = 200
        mock_put.side_effect = [fail_resp, fail_resp, success_resp]

        mgr = self._make_manager()
        mgr.register_to_bootstrap()

        self.assertEqual(mock_put.call_count, 3)
        self.assertEqual(mock_time.sleep.call_count, 2)

_run_server 详细解释

完整代码

python 复制代码
def _run_server(self):
    try:
        # Event Loop
        self._loop = asyncio.new_event_loop()
        asyncio.set_event_loop(self._loop)

        self._loop.create_task(self._cleanup_expired_entries())

        access_log = None
        if logging.getLogger(__name__).getEffectiveLevel() <= logging.DEBUG:
            access_log = self.app.logger

        self._runner = web.AppRunner(self.app, access_log=access_log)
        self._loop.run_until_complete(self._runner.setup())

        site = web.TCPSite(self._runner, host=self.host, port=self.port)
        self._loop.run_until_complete(site.start())
        logger.info(
            f"CommonKVBootstrapServer started successfully on {self.host}:{self.port}"
        )
        self._loop.run_forever()
    except Exception as e:
        logger.error(f"Server error: {str(e)}", exc_info=True)
    finally:
        # Cleanup
        self._loop.run_until_complete(self._runner.cleanup())
        self._loop.close()

为什么需要 _run_server

复制代码
主线程                          后台线程
────────────────────────────────────────────────────
CommonKVBootstrapServer.__init__()
    │
    ├── self.app = web.Application()
    ├── self._setup_routes()
    ├── self.thread = Thread(target=self._run_server)
    └── self.thread.start() ──────────────────────────► _run_server()
                                                              │
继续执行其他初始化...                                          │ 独立事件循环
CommonKVManager.__init__()                                    │ 运行HTTP服务
预填充/解码业务逻辑...                                         │
                                                              │ 永久运行

核心原因 :aiohttp 服务器需要一个 asyncio 事件循环持续运行,但主线程有自己的任务,所以把服务器放到独立的守护线程里。


逐行详解

第一部分:创建独立事件循环

python 复制代码
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)

为什么不用默认循环?

复制代码
问题场景:
主线程可能已经有事件循环,或者根本没有
如果用 asyncio.get_event_loop() 可能会:
  1. 拿到主线程的循环 → 冲突
  2. 在Python 3.10+ 抛出 DeprecationWarning
  3. 在非主线程中报错

解决:
asyncio.new_event_loop() → 创建全新的循环,完全隔离
asyncio.set_event_loop()  → 把这个循环设为当前线程的默认循环

内存中的状态:

主线程                    bootstrap服务器线程
┌──────────────────┐     ┌──────────────────────┐
│ loop = None 或   │     │ self._loop =          │
│ 主线程自己的loop  │     │   <新的EventLoop对象>  │
└──────────────────┘     └──────────────────────┘
         互不干扰

第二部分:注册清理任务

python 复制代码
self._loop.create_task(self._cleanup_expired_entries())

时序问题的细节

python 复制代码
# _cleanup_expired_entries 的内容
async def _cleanup_expired_entries(self):
    while True:
        await asyncio.sleep(self.entry_cleanup_interval)  # 比如300秒
        async with self.lock:
            expired_keys = [
                key for key, value in self.room_to_dp_rank.items()
                if current_time - value["timestamp"] > self.entry_cleanup_interval
            ]
            for key in expired_keys:
                del self.room_to_dp_rank[key]
复制代码
为什么在 setup() 之前调用 create_task?

时间线:
t=0  create_task(cleanup)   ← 任务被放入队列,但还没开始执行
t=1  runner.setup()         ← 事件循环还没run
t=2  site.start()           ← 事件循环还没run
t=3  loop.run_forever()     ← 事件循环开始!
                               此时 cleanup 任务才真正开始执行
                               (先sleep 300秒,再做第一次清理)

结论:create_task 只是"预约"任务,不会立即执行

如果在 run_forever 之后调用会怎样?

python 复制代码
# 错误写法(假设的):
self._loop.run_forever()      # ← 阻塞在这里,永远不会到下一行
self._loop.create_task(...)   # ← 永远不会执行到

第三部分:配置访问日志

python 复制代码
access_log = None
if logging.getLogger(__name__).getEffectiveLevel() <= logging.DEBUG:
    access_log = self.app.logger

效果对比

复制代码
DEBUG模式开启时的日志输出:
──────────────────────────────────────────────
INFO  aiohttp.access - 192.168.1.1 GET /route → 200 (0.002s)
INFO  aiohttp.access - 192.168.1.2 PUT /route → 200 (0.001s)
INFO  aiohttp.access - 10.0.0.1   GET /health → 200 (0.000s)

非DEBUG模式:
──────────────────────────────────────────────
(没有任何HTTP访问日志,减少日志噪音)
python 复制代码
# web.AppRunner 的 access_log 参数含义:
web.AppRunner(self.app, access_log=None)        # 关闭访问日志
web.AppRunner(self.app, access_log=self.app.logger)  # 开启访问日志

第四部分:AppRunner 初始化

python 复制代码
self._runner = web.AppRunner(self.app, access_log=access_log)
self._loop.run_until_complete(self._runner.setup())

AppRunner 的作用

复制代码
web.Application (self.app)
    │ 只定义路由和处理函数,不知道如何运行
    │
    ▼
web.AppRunner
    │ 负责:
    │  - 信号处理(SIGTERM等)
    │  - 应用生命周期管理(startup/shutdown钩子)
    │  - 连接管理
    │
    ▼
web.TCPSite
    │ 负责:
    │  - 监听具体的 host:port
    │  - 接受TCP连接
    │  - 将连接交给AppRunner处理

run_until_complete vs create_task

python 复制代码
# run_until_complete:同步等待协程完成后再继续
self._loop.run_until_complete(self._runner.setup())
# setup()做完了,才继续下一行

# create_task:把任务放入队列,不等待
self._loop.create_task(self._cleanup_expired_entries())
# 立刻继续下一行,cleanup任务将来某时执行

第五部分:绑定端口并启动

python 复制代码
site = web.TCPSite(self._runner, host=self.host, port=self.port)
self._loop.run_until_complete(site.start())
logger.info(f"CommonKVBootstrapServer started successfully on {self.host}:{self.port}")

TCPSite 启动过程

复制代码
site.start() 内部做的事:

1. 创建 asyncio 的 TCP 服务器
   loop.create_server(protocol_factory, host, port)

2. 操作系统层面:
   socket() → bind(host:port) → listen()

3. 完成后:
   端口9000 开始接受连接
   ┌─────────────────────────────────┐
   │  OS TCP 缓冲区                  │
   │  等待连接到 0.0.0.0:9000        │
   └─────────────────────────────────┘

logger.info 输出:
"CommonKVBootstrapServer started successfully on 0.0.0.0:9000"

第六部分:永久运行

python 复制代码
self._loop.run_forever()

run_forever 的内部机制

复制代码
run_forever() 的伪代码:

while True:
    # 1. 检查是否有到期的定时器
    scheduled_callbacks = get_ready_callbacks()
    
    # 2. 执行所有就绪的回调
    for callback in scheduled_callbacks:
        callback()
    
    # 3. 等待IO事件(epoll/kqueue)
    events = io_poll(timeout=next_timer_deadline)
    
    # 4. 处理IO事件(新连接、数据到达等)
    for event in events:
        handle_event(event)
    
    # 5. 检查停止标志
    if self._stopping:
        break

实际运行时的事件处理

复制代码
时间线(run_forever运行中):

t=0s    cleanup任务开始(先sleep 300s)

t=1s    预填充节点A发来 PUT /route
        ├── TCP连接建立
        ├── HTTP请求解析
        ├── 调用 _handle_route_put()
        ├── 更新 prefill_port_table
        └── 返回 200 OK

t=2s    预填充节点B发来 PUT /route
        └── (同上)

t=5s    解码节点发来 GET /route?...=-1&...=-1
        ├── 调用 _handle_route_get()
        ├── _is_ready() 返回 True
        └── 返回 PrefillServerInfo JSON

t=10s   解码节点发来 GET /health
        └── 返回 200 OK

t=300s  cleanup任务醒来
        ├── 清理过期的 room_to_dp_rank 条目
        └── 再次 sleep 300s

...     循环往复

第七部分:异常处理和清理

python 复制代码
    except Exception as e:
        logger.error(f"Server error: {str(e)}", exc_info=True)
    finally:
        self._loop.run_until_complete(self._runner.cleanup())
        self._loop.close()

finally 块的执行时机

复制代码
触发场景1:正常关闭
    close() 被调用
        └── loop.call_soon_threadsafe(loop.stop)
                └── run_forever() 退出
                        └── finally 执行

触发场景2:端口占用异常
    site.start() 抛出 OSError: [Errno 98] Address already in use
        └── except 捕获并记录日志
                └── finally 执行

触发场景3:未知错误
    任何其他异常
        └── except 捕获
                └── finally 执行

runner.cleanup() 做什么

python 复制代码
# cleanup() 的效果:
1. 关闭所有活跃的HTTP连接
2. 等待正在处理的请求完成(有超时)
3. 调用 app.on_shutdown 钩子
4. 关闭TCP监听socket
5. 释放相关资源

# 为什么用 run_until_complete 而不是直接 close?
# cleanup() 是协程,需要事件循环来运行
self._loop.run_until_complete(self._runner.cleanup())
# 之后才能安全关闭循环
self._loop.close()

关键问题:asyncio.Lock 跨线程使用

python 复制代码
# __init__ 中创建(主线程)
self.lock = asyncio.Lock()

# _run_server 中使用(bootstrap服务器线程)
async with self.lock:
    ...

这里有一个隐患

复制代码
asyncio.Lock 绑定到创建它时的事件循环

Python 3.10之前:
    主线程创建 asyncio.Lock()
    → Lock 绑定到主线程的事件循环(或无循环)
    → 在 bootstrap 线程的新循环中使用
    → 可能报错或行为异常

Python 3.10之后:
    asyncio.Lock() 不再在创建时绑定循环
    → 在第一次 await 时绑定当前循环
    → 只要始终在 bootstrap 线程的循环中使用,就没问题

实际安全性:
    self.lock 只在 async 方法中使用(_handle_route_put等)
    这些方法只被 aiohttp 调用
    aiohttp 只在 bootstrap 线程的循环中运行
    → 实际是安全的,但依赖Python版本行为

整体执行流程图

复制代码
CommonKVBootstrapServer.__init__()
    │
    ├── app = web.Application()
    ├── _setup_routes()          # 注册URL路由
    ├── thread = Thread(         # 创建守护线程
    │       target=_run_server,
    │       daemon=True          # 主进程退出时自动结束
    │   )
    └── thread.start()
              │
              ▼ (新线程开始执行)
         _run_server()
              │
              ├── new_event_loop()     # 创建独立事件循环
              ├── set_event_loop()     # 设为本线程默认
              ├── create_task(cleanup) # 预约清理任务
              ├── AppRunner(app)       # 创建运行器
              ├── runner.setup()       # 初始化(阻塞直到完成)
              ├── TCPSite(runner,      # 创建TCP监听
              │       host, port)
              ├── site.start()         # 开始监听(阻塞直到完成)
              ├── logger.info(...)     # 打印启动成功
              └── loop.run_forever()   # 永久运行,处理请求
                        │
                        │ (收到停止信号或异常)
                        ▼
                   finally:
                        ├── runner.cleanup()  # 优雅关闭
                        └── loop.close()      # 释放资
相关推荐
久久学姐1 天前
SGLang Custom AllReduce v1 与 v2 实现原理详解
实现原理·sglang·customallreduce·nvlinkp2p·ipc交换
Briwisdom3 天前
LLM 推理引擎三强争霸——vLLM vs SGLang vs TensorRT-LLM
tensorrt·vllm·推理引擎·sglang
布吉岛的石头6 天前
Java 程序员第 45 阶段11:网关统一路由大模型接口,配合 Nacos 配置治理,灰度路由:基于Nacos元数据实现大模型服务灰度发布与权重路由
java·服务发现
老刘说AI7 天前
SGLang 深度优化: Radix 缓存与复杂任务的极致吞吐
人工智能·神经网络·机器学习·缓存·架构·sglang
GPUStack11 天前
Day 0 实测|在 GPUStack 上部署 Inkling-BF16:8 卡 H20-141G 推理性能测试
ai·大模型·llm·gpu·vllm·gpu集群·sglang·gpustack
Jay Kay12 天前
SGLang Model Gateway 特性详解(Cache-Aware 之外)
gateway·sglang
Briwisdom17 天前
LLM 推理引擎架构:vLLM / SGLang 的核心设计
架构·vllm·sglang·pagedattention·radixattention
雪碧聊技术19 天前
Spring Boot项目如何彻底禁用Nacos(服务发现+配置中心),本地启动不再依赖Nacos
spring boot·nacos·服务发现
Token炼金师21 天前
引擎四强:vLLM、SGLang、TensorRT-LLM 与 llama.cpp —— 推理引擎选型对决
人工智能·llm·llama·vllm·tensorrt-llm·sglang