执行摘要
- 一句话:CFG 并行服务器放行关闭 CFG 的请求,并修复 warning_once 格式参数崩溃
- 推荐动作:值得精读。这个 PR 的亮点不在改动规模,而在于作者对“可观察契约”的严谨处理:
stacklevel=2、logger.warning 方法路由、cache_clear API 表面都是被测试字面断言的外部契约,稍一“简化”就会让整套 GPU CI 快速失败。它对三类读者有参考价值:维护 diffusion serving 的同学(理解 CFG 并行的单分支语义)、想改公共日志工具的同学(去重缓存与内存泄漏的权衡)、以及写分布式测试的同学(TestCfgParallelServesOneBranch 用 patch 在 CPU 上模拟多 rank gather 的思路)。建议重点关注 logging_utils.py 的注释块与 5 个 commit 的演进顺序。
功能与动机
PR body 明确指出:sglang serve --model-path Qwen/Qwen-Image-2512 --num-gpus 2 --model-type diffusion 未传任何并行参数,运行时从模型默认采样参数自动开启 CFG 并行(enable_cfg_parallel: true, cfg_parallel_degree: 2),随后每个 guidance_scale=1.0 请求都被拒绝,报错 Server was launched with --enable-cfg-parallel but this request does not use classifier-free guidance——而用户从未传过该 flag。跨框架 benchmark 中 Qwen-Image-2512、Qwen-Image-Edit-2511 两个 cell 在默认命令下连一个请求都服务不了。作者还考证了守卫的历史来源:该拒绝为 #23198 的 warmup hang(2026-04-23)添加,而 #23736 多分支重构(2026-05-07)已让 dispatcher 支持单分支(dispatch_branches(1, 2) == [[0], []],无分支的 rank 也会运行 branch 0 保证 all-gather 有形状),warmup 路径本身也已有独立修复,遗留的只是一个拒绝“自己能服务流量”的过期守卫。
实现拆解
实现按以下 5 步展开:
-
移除 input_validation.py 中的过期守卫:InputValidationStage.forward(python/sglang/multimodal_gen/runtime/pipelines_core/stages/input_validation.py)中原本对 enable_cfg_parallel and not do_classifier_free_guidance 抛 ValueError 的 catch-all 分支,改为 logger.warning_once(...) 提示“单分支请求会被其余 CFG rank 冗余重算,可用 --cfg-parallel-size 1 关闭”。这一改动的安全性依据是 dispatcher 的单分支语义:无分支 rank 也运行 branch 0 使 all-gather 有形状,reorder 步骤把 owner 的预测分发给所有 rank。
-
扩展日志 once-helpers 支持惰性格式参数:logging_utils.py 中 info_once / warning_once 从只接收 msg 改为接收 msg, *args,并新增 _log_once_guard 统一完成 msg % args 格式化与去重判断。原因是 cfg_parallel_utils 中 logger.warning_once("cfg_parallel_size=%d > n_branches=%d; ...", ws, n, ws - n) 这类调用原本会抛 TypeError,而该路径被旧守卫完全挡住从未执行过;移除守卫后它变成首个单分支请求上的崩溃点。
-
保留三个可观察契约:其一,去重改用 _logged_once: set[tuple[str, int, str]](键为 logger.name + level + 格式化文本),避免 lru_cache 按参数键控对张量持有强引用直至进程结束;其二,stacklevel=2 是 test_diffusion_bcg_padding 字面断言的契约,不能多包一层 helper 改变调用帧数;其三,_print_info_once.cache_clear = _logged_once.clear 与 _print_warning_once.cache_clear = _logged_once.clear 保留旧 lru_cache 的 API 表面,因为测试依赖它重置去重状态。另外回退了一次“改走 logger.log 路由”的尝试——测试会 patch logger.warning,绕道会让 mock 计数为 0。
-
同步 server_args.py 的提示文案:ServerArgs._adjust_parallelism 中自动开启 CFG 并行的日志补充说明“关闭 CFG 的请求仍会运行,但其他 CFG rank 会冗余重算”,引导用户用 --cfg-parallel-size 1、--tp-size 或 --sp-degree / --ulysses-degree 覆盖默认并行策略。
-
测试配套:test_cfg_parallel_warmup.py 中将旧的 TestInputValidationCfgParallelGuard(断言拒绝)改写成 TestInputValidationCfgParallelSingleBranch(断言放行),并新增 TestCfgParallelServesOneBranch,从“不拥有分支的 rank”视角 mock 掉 rank/world_size/设备/all-gather,钉住 run_cfg_parallel 在 n_branches=1, world_size=2 时仍返回 owner 预测这一安全性质;test_logging_utils.py 新增 TestLogOnceTakesFormatArgs,覆盖惰性格式化、去重、弱引用防泄漏(weakref 验证缓存不持有参数)以及“记录文件名为 logging_utils.py”这一历史 wart。所有单测保持 CPU-only,不加载模型、不做分布式初始化。
关键文件:
python/sglang/multimodal_gen/runtime/utils/logging_utils.py(模块 日志工具;类别 source;类型 core-logic;符号 _log_once_guard, _print_info_once, _print_warning_once, info_once): once-helpers 从只收消息改为支持 %-style 惰性格式参数,并新增按格式化文本去重的 set 缓存,修复守卫移除后必然触发的 TypeError;同时保留 stacklevel=2、cache_clear 等被测试字面断言的可观察契约,是本 PR 风险最集中、设计最精巧的文件。
python/sglang/multimodal_gen/runtime/pipelines_core/stages/input_validation.py(模块 校验管线;类别 source;类型 core-logic;符号 InputValidationStage.forward): 请求路径的核心变更:把 CFG 并行模式下对非 CFG 请求的 ValueError 拒绝改为 warning_once 放行。这是本 PR 的行为开关,改动依据是 dispatcher 的单分支安全语义(#23736 多分支重构)。
python/sglang/multimodal_gen/test/unit/test_cfg_parallel_warmup.py(模块 单元测试;类别 test;类型 test-coverage;符号 TestInputValidationCfgParallelSingleBranch, test_input_validation_accepts_cfg_parallel_without_cfg, TestCfgParallelServesOneBranch, _run_on_rank): 测试主战场:旧守卫测试被改写为接受单分支请求,并新增 TestCfgParallelServesOneBranch 从无分支 rank 视角钉住 run_cfg_parallel 在 n_branches=1 / world_size=2 下仍返回 owner 预测的性质——这是移除守卫安全性的直接证据。
python/sglang/multimodal_gen/test/unit/test_logging_utils.py(模块 单元测试;类别 test;类型 test-coverage;符号 TestLogOnceTakesFormatArgs, test_warning_once_formats_lazy_args, test_info_once_formats_lazy_args, test_arguments_are_not_retained): 新增 TestLogOnceTakesFormatArgs 用 assertLogs 与 weakref 分别锁定惰性格式化、按文本去重、不持有参数强引用、记录归属 logging_utils.py 等契约,防止后续“修复”破坏 test_diffusion_bcg_padding。
python/sglang/multimodal_gen/runtime/server_args/server_args.py(模块 服务参数;类别 source;类型 core-logic;符号 ServerArgs._adjust_parallelism): 自动开启 CFG 并行的启动日志补充了“关闭 CFG 的请求仍会运行但有冗余重算”的说明,并给出 --cfg-parallel-size 1 等覆盖手段,是用户体验层面的配套改动。
关键符号:_log_once_guard, _print_info_once, _print_warning_once, info_once, warning_once, InputValidationStage.forward, ServerArgs._adjust_parallelism
关键源码片段
python/sglang/multimodal_gen/runtime/utils/logging_utils.py
once-helpers 从只收消息改为支持 %-style 惰性格式参数,并新增按格式化文本去重的 set 缓存,修复守卫移除后必然触发的 TypeError;同时保留 stacklevel=2、cache_clear 等被测试字面断言的可观察契约,是本 PR 风险最集中、设计最精巧的文件。
# `logger.warning_once(msg, *args)` 通过 MethodType(_print_warning_once, logger)
# 绑定到 logger 实例,因此调用者与 logger.warning 之间恰好只有一帧,
# 而 stacklevel=2 是 test_diffusion_bcg_padding 字面断言的可观察契约。
# 若在中间再插入一层 helper,日志记录的文件名会变成 logging_utils.py,
# 破坏该断言 —— 所以去重不能放在第二个 lru_cache 函数上。
#
# 也不能在本函数上用 lru_cache:以参数为 key 会对每个参数持有强引用
# 直到进程结束,而这里的调用方会传入张量。因此改用“格式化后文本”的
# set 去重,只保存字符串,避免张量泄漏。
#
# 参数本身是新增能力:旧实现只接收消息,调用方若像标准 logger.warning
# 那样惰性格式化,就会抛 TypeError 而不是打印日志 —— 而且总落在
# 极少执行的路径上,从未被发现;移除 CFG 守卫后该路径变成首请求必达。
_logged_once: set[tuple[str, int, str]] = set()
def _log_once_guard(logger: Logger, level: int, msg: str, *args: Any) -> str | None:
"""返回要打印的文本;若该消息已打印过则返回 None。"""
text = msg % args if args else msg
key = (logger.name, level, text)
if key in _logged_once:
return None
_logged_once.add(key)
return text
def _print_info_once(logger: Logger, msg: str, *args: Any) -> None:
text = _log_once_guard(logger, logging.INFO, msg, *args)
# stacklevel=2 是契约而非调参项;init_logger 还会把 warning 包成
# 转发到 logger.log 的函数、多加一帧,所以记录实际上归属本模块。
if text is not None:
logger.info(text, stacklevel=2)
def _print_warning_once(logger: Logger, msg: str, *args: Any) -> None:
text = _log_once_guard(logger, logging.WARNING, msg, *args)
if text is not None:
logger.warning(text, stacklevel=2)
# 旧实现是 lru_cache 对象,`.cache_clear()` 属于其公开表面,
# test_diffusion_bcg_padding 依赖它重置去重状态,故保留同名兼容入口。
_print_info_once.cache_clear = _logged_once.clear
_print_warning_once.cache_clear = _logged_once.clear
python/sglang/multimodal_gen/runtime/pipelines_core/stages/input_validation.py
请求路径的核心变更:把 CFG 并行模式下对非 CFG 请求的 ValueError 拒绝改为 warning_once 放行。这是本 PR 的行为开关,改动依据是 dispatcher 的单分支安全语义(#23736 多分支重构)。
# 关闭 CFG 的请求在 CFG 并行下仍然可服务:dispatcher 把 branch 0 分给
# rank 0,其它 rank 也会运行 branch 0,保证 all-gather 有形状可用;
# reorder 步骤再把 owner 的预测分发给所有 rank,结果就是单分支答案,
# 多余的 rank 只是冗余计算。
#
# 这里以前会抛异常。该守卫是为 warmup hang(#23198)加的,比多分支
# 重构(#23736)早两周;warmup 路径后来也长出了自己的修复 ——
# warmup builder 在启用 CFG 并行时强制打开 CFG。剩下的就是一个拒绝
# “自己能服务流量”的服务器:运行时从模型默认采样参数自动开启
# CFG 并行,于是 `sglang serve --num-gpus 2` 在一个默认启用 CFG 的
# 模型上,会拒绝所有 guidance_scale=1.0 请求,还把责任推给用户
# 从未传过的 --enable-cfg-parallel 参数。
if server_args.enable_cfg_parallel and not batch.do_classifier_free_guidance:
logger.warning_once(
"CFG parallelism is enabled but this request does not use "
"classifier-free guidance (guidance_scale=%s, true_cfg_scale=%s), "
"so it has one branch and the other CFG rank(s) recompute it "
"redundantly. Pass --cfg-parallel-size 1 to spend those GPUs on "
"another parallelism instead.",
batch.guidance_scale,
batch.true_cfg_scale,
)
python/sglang/multimodal_gen/test/unit/test_cfg_parallel_warmup.py
测试主战场:旧守卫测试被改写为接受单分支请求,并新增 TestCfgParallelServesOneBranch 从无分支 rank 视角钉住 run_cfg_parallel 在 n_branches=1 / world_size=2 下仍返回 owner 预测的性质——这是移除守卫安全性的直接证据。
class TestCfgParallelServesOneBranch(unittest.TestCase):
"""“接受单分支请求是安全”这一性质由 dispatcher 保证:
单分支落在 2-rank CFG 组时:rank 0 拥有该分支,其它 rank 也运行它,
从而 all-gather 有形状;reorder 步骤把 owner 的预测交给两个 rank。
这里专门从“不拥有任何分支”的 rank 视角钉住该性质 —— 正是旧注释
说会返回 None、并在 gloo broadcast 上挂半小时的那个 rank。
"""
def _run_on_rank(self, cfg_rank: int, n_branches: int = 1, world_size: int = 2):
from sglang.multimodal_gen.runtime.distributed.cfg_policy import (
CFGBranch,
CFGPolicy,
)
mod = "sglang.multimodal_gen.runtime.distributed.cfg_parallel_utils"
branches = [CFGBranch(f"b{i}", i == 0, {"tag": i}) for i in range(n_branches)]
policy = CFGPolicy(branches=branches)
seen: list[int] = []
def predict_fn(branch):
seen.append(branch.kwargs["tag"])
return torch.full((1, 2), float(branch.kwargs["tag"]))
# 真实 2-rank gather 每个 rank 返回一个张量;这里两个 rank 都
# 运行了同一分支,所以两份贡献值相同。
def fake_all_gather(t, dim=0, separate_tensors=False):
return [t.clone() for _ in range(world_size)]
with (
patch(f"{mod}.get_classifier_free_guidance_rank", return_value=cfg_rank),
patch(
f"{mod}.get_classifier_free_guidance_world_size",
return_value=world_size,
),
patch(f"{mod}.get_local_torch_device", return_value=torch.device("cpu")),
patch(f"{mod}.cfg_model_parallel_all_gather", side_effect=fake_all_gather),
):
from sglang.multimodal_gen.runtime.distributed.cfg_parallel_utils import (
run_cfg_parallel,
)
return run_cfg_parallel(policy, predict_fn), seen
def test_rank_without_a_branch_still_returns_the_owners_prediction(self):
preds, seen = self._run_on_rank(cfg_rank=1)
self.assertEqual(
seen,
[0],
"无分支的 rank 也必须运行一个分支,否则 all-gather 没有形状可用",
)
self.assertEqual(len(preds), 1)
self.assertIsNotNone(preds[0])
self.assertTrue(torch.equal(preds[0], torch.zeros(1, 2)))
评论区精华
该 PR 没有正式 review 评论(review_comments_count: 0),但 5 个 commit 构成了非常完整的“自审闭环”,讨论精华几乎都沉淀在 commit message 与最终 CI 说明中:
- 去重键的选择(commit 0a1eb29):作者明确否定了两种初版方案——
lru_cache 键控参数会对张量持有强引用,二次函数嵌套会破坏 stacklevel 契约;最终以“格式化文本的 set”落地。
logger.warning vs logger.log 路由(commit dbdf2a5):logger.log(WARNING, ...) 看起来是等价简化,但“callers and tests patch logger.warning,test_diffusion_bcg_padding 断言 warning.assert_called_once_with(msg, stacklevel=2)”,绕道后 mock 计数为 0,直接导致 GPU shard 与 coverage 检查快速失败。
- 记录归属与 stacklevel 的悖论(commit 1c56d4b):作者原本断言“记录会命名调用者文件”,实测发现
init_logger 把 warning 替换为转发到 logger.log 的 forwarder,多出一帧,记录始终归属 logging_utils.py——这是先于本 PR 就存在的 wart,文档化它以避免后人“修坏”断言。
-
CI 失败排查(issue 评论):作者逐条核对了 9 个 AMD/NPU 失败,结论是 AMD 侧为 MiniMax-H3 的 ROCm 平台缺口(RuntimeError: requires CPU, CUDA, MPS, XPU, or Ascend NPU)与无关的 LLM weight-transfer 测试,NPU 侧为 self-hosted runner 容器错误与 498 MiB OOM,均与本 diff(只触及一个请求时校验分支和一条日志字符串)无关。
-
warning_once 是否支持 %-style 惰性格式参数 (correctness): helpers 改为接收 msg, *args,先 msg % args 格式化再去重,与标准 logger.warning 契约一致;测试断言结果文本而非异常。
- once 去重缓存应键控于参数还是格式化文本 (performance): 采用
_logged_once: set[tuple[str, int, str]],键为 logger.name + level + 格式化文本;测试用 weakref 验证参数不被缓存持有。
- stacklevel=2 与调用帧数契约 (correctness): 保持 warning_once 与 logger.warning 之间恰好一帧,stacklevel=2 作为契约保留;记录归属 logging_utils.py 是先于本 PR 的 wart,用测试文档化而非修复。
- warning_once 应路由 logger.warning 还是 logger.log (testing): 回退到直接调用
logger.warning / logger.info,保持方法路由可被测试观测。
- lru_cache 的 cache_clear API 表面是否保留 (design): 以
_print_info_once.cache_clear = _logged_once.clear 形式保留同名兼容入口。
- AMD/NPU CI 失败是否由本 diff 引入 (testing): 作者逐条核对后确认均与本 diff(一个请求时校验分支、一条日志字符串、日志 helper)无关,NVIDIA 侧全绿;但 ROCm/NPU 未被本 PR 实测仍是覆盖盲区。
风险与影响
-
风险:具体风险如下:
-
冗余计算开销(input_validation.py):移除守卫后,CFG 并行服务器上的 guidance_scale=1.0 请求会触发所有 CFG rank 冗余执行同一分支。单分支请求越多,浪费的 GPU 算力越大;当前仅以 warning_once 与启动日志提示用户改用 --cfg-parallel-size 1,没有自动降级或调度优化。
- 共享日志工具行为变更(logging_utils.py):
info_once / warning_once 是 sglang/multimodal_gen 包内共享的日志助手,签名从 (msg) 扩为 (msg, *args),去重键也从 lru_cache 的 logger 对象身份改为 (logger.name, level, text)。两个同名不同实例的 logger 现在会互相去重;同一文本但不同 % 参数会各打一次,语义与旧版不完全等价。
- 惰性格式化新增异常面:
text = msg % args 若调用方误传与占位符不匹配的参数会抛 TypeError(与标准 logging 行为一致),此前该异常被 lru_cache 的“只传 msg”签名天然挡住。
- 历史遗留记录归属问题:
warning_once 的日志记录文件名始终是 logging_utils.py 而非调用者,诊断日志来源时可能误导,本 PR 选择文档化而非修复(修复会破坏 test_diffusion_bcg_padding 的字面断言)。
- 覆盖盲区:作者明确说明在 4xB200 验证,未在 ROCm 与 NPU 实测;CI 也确认 AMD/NPU 存在本 diff 之外的既有失败。
-
影响:影响范围集中在 diffusion 多模态生成子系统的请求路径:
-
用户侧:使用默认启用 CFG 的模型(Qwen-Image-2512、Qwen-Image-Edit-2511 等)以 sglang serve --num-gpus 2 默认命令启动时,guidance_scale=1.0 请求从 HTTP 500 变为 HTTP 200,跨框架 benchmark 的 2 个 cell 恢复可用;CFG 请求路径不受影响(实测输出字节数基本一致)。
- 系统侧:CFG 并行服务器将接受单分支请求,代价是非 owner rank 的冗余 denoise 计算;日志系统行为发生变化(once-helpers 支持格式参数、去重键调整),影响包内所有
warning_once / info_once 调用点。
- 团队侧:新增约 80 个单元测试锁定 dispatcher 单分支性质与日志契约,后续改动这些约束会被 CI 拦截;CI 矩阵中 AMD/NPU 的既有失败与本次 diff 无关,但仍是需要单独跟进的基础设施问题。
- 风险标记:过期守卫移除, 共享日志助手行为变更, 冗余计算开销, AMD/NPU 未实测
关联脉络
- PR #23198 (PR 正文引用,标题未提供)warmup hang 修复: 本 PR 移除的守卫正是 2026-04-23 为 #23198 的 --warmup-resolutions hang 添加的;warmup 路径已有独立修复(强制 CFG on),故守卫过期。
- PR #23736 (PR 正文引用,标题未提供)多分支 dispatcher 重构: 2026-05-07 的重构让 dispatcher 支持单分支(dispatch_branches(1, 2) == [[0], []]),是移除守卫安全性的前提;本 PR 的新测试专门钉住该性质。
- PR #39291 [Diffusion] Make the SP sequence gather pass contiguous shards: 同属 diffusion 模块的 bugfix;本 PR 最终 CI 评论将其合并时的 8 个 AMD 失败作为基线,证明本 PR 的 AMD 失败非新增。
- PR #39292 [Diffusion] Don't route an unreadable checkpoint into the native fallback: 同属 diffusion 模块的 bugfix 与测试配套;同样被 CI 评论引用为 AMD 失败基线,与本 PR 同批验证。
参与讨论