引言:从一次代码审查引发的思考
最近在浏览 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具体是如何实现服务发现的,最可靠的方法是:
- 查阅 SGLang的官方文档,关注其分布式部署的章节。
- 在 SGLang的GitHub仓库 中搜索"service discovery"、"registry"等关键词。
- 直接向 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 应用实例是作为该类的一个成员属性 存在的。这样,该类里的其他方法(比如 run 或 stop)都可以通过 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 的核心工作,就是把原本在
NIXL和Mooncake这两个不同传输后端中重复的"引导(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 分离架构的绝佳样本,它清晰地展示了:
- Bootstrap Server 是一个独立的服务(或功能模块),负责维护集群节点信息和路由决策。
bootstrap_room是请求级别的唯一标识,用于在 Prefill 和 Decode 节点间进行精确的 KV Cache 传输匹配。- 该 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.app 是 web.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_port: 1
2. MooncakeKVManager 初始化
MooncakeKVManager 是 CommonKVManager 的子类,其 __init__ 首先调用 super().__init__(args, disaggregation_mode, server_args, is_mla_backend),之后才进行 mooncake engine 的初始化和 buffer 注册: 2
3. CommonKVManager.__init__ 中触发注册
父类构造函数在完成本 rank 的并行信息(attn_tp_rank、pp_rank、rank_ip、rank_port 等)赋值后,若 disaggregation_mode == DisaggregationMode.PREFILL,会先同步 bootstrap 端口(多机场景),再调用 self.register_to_bootstrap(): 3
4. register_to_bootstrap 发起 HTTP PUT
该方法组装本 rank 的拓扑 payload(attn_tp_rank、pp_rank、rank_ip、rank_port、page_size、kv_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_count: 5
_is_ready() 通过比较 _registered_count 与 dp_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() # 释放资