SGLang 单元测试规范与实战:为test/registered/unit编写可注册的 CPU-only 测试
【免费下载链接】sglangSGLang is a high-performance serving framework for large language models and multimodal models.项目地址: https://gitcode.com/GitHub_Trending/sg/sglang
SGLang 仓库将测试按运行环境与开销分层管理,其中test/registered/unit/目录专门承载CPU-only 组件测试:不启动服务器、不加载模型权重、不依赖任何加速器。本文以 test/registered/unit/README.md 为骨架,结合 python/sglang/test/ci/ci_register.py 的 CI 注册解析机制与 python/sglang/test/test_utils.py 的公共工具实现,系统讲解这类测试的边界定义、编写模板、GPU 依赖桩(stub)技巧、CI 注册与本地/覆盖率运行方式,读完即可在仓库中新增或迁移 CPU-only 单元测试。
一、什么是 "Unit Tests":边界与定位
test/registered/unit/下的测试是纯 CPU 组件测试,其硬性边界是:
- 不通过
popen_launch_server()启动推理服务器; - 不构造
Engine(...)拉起在线引擎; - 不加载任何模型权重;
- 不需要 GPU/加速器。
凡是涉及 GPU 算子正确性的测试,一律归属test/registered/kernels/<subsystem>/(原文写作test/registered/kernel/<subsystem>/,仓库实际路径为 test/registered/kernels,其中按attention、kernels、quant等子系统组织)。也就是说:逻辑/协议/状态机层面的正确性放在 unit,算子数值层面的正确性放在 kernels。
从仓库目录看,unit 目录下已经积累了相当丰富的子系统,例如 test/registered/unit/managers、test/registered/unit/mem_cache、test/registered/unit/sampling、test/registered/unit/layers、test/registered/unit/entrypoints、test/registered/unit/scheduler 相关组件 等,覆盖调度器、KV 缓存、采样参数、分布式、多模态、LoRA、约束解码等模块——这为新增测试的归属提供了现成的参照系。
二、快速开始:三步新增一个单元测试
1. 定位被测源码
在python/sglang/srt/下找到被测模块,然后镜像源码树在 unit 目录下创建同名测试文件。README 给出两条典型映射:
srt/mem_cache/radix_cache.py → unit/mem_cache/test_radix_cache.py srt/sampling/sampling_params.py → unit/sampling/test_sampling_params.py实际仓库中 test/registered/unit/sampling/test_sampling_params.py 正是对 python/sglang/srt/sampling/sampling_params.py 的逐类覆盖(TestSamplingParamsInit、TestSamplingParamsValidate等),验证了该镜像约定。
2. 在文件顶部注册 CI
在import 之后、测试类定义之前,调用register_cpu_ci:
from sglang.test.ci.ci_register import register_cpu_ci register_cpu_ci(est_time=5, suite="base-a-test-cpu")register_cpu_ci的签名(见 python/sglang/test/ci/ci_register.py)支持两种调用形态:
- 旧式单字符串 suite:
register_cpu_ci(est_time=5, suite="base-a-test-cpu"),suite保持在第 2 个位置参数以兼容历史调用; - 新式 (stage, runner_config) 组合:
register_cpu_ci(est_time=8, stage="stage-b", runner_config="test-cpu-intel"),stage与runner_config为 kwarg-only,二者必须成对出现且不能与suite混用,注册器会通过effective_suite拼出{stage}-test-{runner_config}。
可选参数还包括nightly(是否仅进夜间流水线)与disabled(禁用原因说明)。除 CPU 外,同一套机制还提供register_cuda_ci、register_amd_ci、register_npu_ci、register_xpu_ci、register_musa_ci、register_mlx_ci,分别对应HWBackend枚举中的各后端。例如 test/registered/unit/sampling/test_sampling_params.py 就同时注册了 CPU 与 XPU 两个后端:
register_cpu_ci(est_time=10, suite="base-a-test-cpu") register_cpu_ci(est_time=8, suite="stage-b-test-cpu-intel") register_xpu_ci(est_time=10, suite="stage-a-test-1-gpu-xpu")est_time(预估秒数)是必须提供的常量,它会被 CI 的auto_partition用作负载均衡的权重。
3. 本地运行与覆盖率
# 运行全部 unit 测试 pytest test/registered/unit/ -v # 只跑某一个模块 pytest test/registered/unit/mem_cache/ -v覆盖率检查分两级:
# 汇总覆盖率 pytest test/registered/unit/ --cov --cov-config=.coveragerc -v # PR 增量检查(变更行覆盖率须 ≥60%) pytest test/registered/unit/ --cov --cov-config=.coveragerc --cov-report=xml diff-cover coverage.xml --compare-branch=origin/main --fail-under=60diff-cover的作用是只对 PR 中“改动行”做覆盖率门槛校验,避免历史代码拖低指标。
三、基本单元测试模板
README 给出的标准模板如下(要点:文件级 docstring 声明“不启动服务器、不加载模型”;注册语句在 import 之后、类之前;继承CustomTestCase):
"""Unit tests for <module> — no server, no model loading.""" from sglang.test.ci.ci_register import register_cpu_ci register_cpu_ci(est_time=5, suite="base-a-test-cpu") import unittest from sglang.srt.<module> import TargetClass from sglang.test.test_utils import CustomTestCase class TestTargetClass(CustomTestCase): def test_basic_behavior(self): obj = TargetClass(...) self.assertEqual(obj.method(), expected) if __name__ == "__main__": unittest.main()这里有两个必须解释的约定:
CustomTestCase而非裸unittest.TestCase:CustomTestCase定义于 python/sglang/test/test_utils.py,它做了两件事——其一,包装setUpClass,使得setUpClass抛异常时仍会执行tearDownClass,避免端口、进程等资源泄漏;其二,重写_callTestMethod接入SGLANG_TEST_MAX_RETRY环境变量(CI 中默认重试 1 次),为偶发 flaky 测试提供自动重试能力。if __name__ == "__main__": unittest.main():这不是可选项。CI 的collect_tests在 python/sglang/test/ci/ci_register.py 中通过 AST 检查每个注册了 CI 的文件必须存在含调用的主入口块,否则直接抛错——因为 CI 以python3 file.py -f方式调用测试文件,缺少该入口会让测试静默跳过、显示假绿。
四、在 CPU 测试中桩掉 GPU-only 依赖
这是 unit 测试编写中最容易踩坑的一环。部分被测模块(README 点名scheduler.py、io_struct.py)会传递导入sgl_kernel这类需要 GPU 才能初始化的包。在纯 CPU CI 上,import 阶段就会ImportError。解决办法是在 import 之前先打桩。
maybe_stub_sgl_kernel()的原理
python/sglang/test/test_utils.py 中的maybe_stub_sgl_kernel()实现了标准做法:
- 先尝试真实
import sgl_kernel:能成功(GPU 机器)则直接返回,no-op; - 失败(CPU 机器)则在
sys.meta_path头部插入一个自定义MetaPathFinder,为sgl_kernel及其所有子模块自动生成空 stub 模块,模块的__getattr__返回MagicMock()。
用法要点是必须在任何会拉入sgl_kernel的 import 之前调用:
from sglang.test.ci.ci_register import register_cpu_ci from sglang.test.test_utils import maybe_stub_sgl_kernel maybe_stub_sgl_kernel() # must precede any import that pulls in sgl_kernel from sglang.srt.managers.io_struct import FlushCacheReqInput from sglang.srt.managers.scheduler import Scheduler register_cpu_ci(est_time=2, suite="base-a-test-cpu")仓库中 test/registered/unit/entrypoints/openai/test_serving_chat.py、test/registered/unit/entrypoints/openai/test_serving_completions.py 等大量入口层测试都采用这一模式。
重要告诫:不要在模块顶层直接改sys.modules
sys.meta_path机制(import 系统级的 finder)与直接篡改sys.modules有本质区别:pytest 会先 import 全部测试文件再逐个执行,任何在模块顶层对sys.modules的原地修改都会污染整个进程、影响其他测试文件。README 明确警告:
- 如需以模块替换方式打桩,必须使用
patch.dict("sys.modules", ...)并在测试结束后清理; sys.meta_path的 finder 模式(maybe_stub_sgl_kernel的做法)可推广到其他 GPU-only 包。
五、CI 注册机制的源码级原理
register_cpu_ci(...)在运行时只是返回None的标记函数,真正的注册工作发生在 CI 侧:collect_tests调用ut_parse_one_file(python/sglang/test/ci/ci_register.py)对每个测试文件做AST 静态解析,而非执行 import。这套设计带来几个可验证的行为:
- 参数校验发生在解析阶段:
RegistryVisitor会拒绝*args/**kwargs、重复参数、未知关键字、非数字的est_time、以及(stage, runner_config, suite)三者混用或缺失的非法组合(python/sglang/test/ci/ci_register.py)。这解释了为什么注册调用必须写在模块顶层且参数必须是常量。 est_time驱动负载均衡:auto_partition(python/sglang/test/ci/ci_register.py)采用 LPT(最长处理时间优先)贪心策略,把所有注册文件的est_time分摊到size个分区,使各分区总耗时大致相等,并按 rank 返回当前 worker 应跑的测试文件集合。- 主入口块是硬性要求:
collect_tests在sanity_check=True时,会对每个含启用注册但缺少if __name__ == "__main__":调用块的测试文件抛出ValueError,错误信息中会给出unittest.main()或sys.exit(pytest.main([__file__, "-v"]))两种修复方式。
也就是说:写测试时在文件顶部放一行注册语句,就是把该文件挂进 CI 调度表的唯一入口——它同时决定了后端归属、预估耗时和所属流水线。
六、测试设计规则与断言质量
README 的 Rules 部分定义了 unit 测试的红线,也是 review 时的检查清单:
- 禁止
popen_launch_server()或Engine(...); - 禁止加载模型权重;
- 统一使用
CustomTestCase(获得 CI 重试与资源清理保障); - Mock 必须有意义:只允许在断言仍会校验“结果、状态迁移、协议输出或错误”的前提下,mock 外部或慢速依赖边界。一个“只证明 mock 被调用了”的测试(仅断言
mock.assert_called_once()之类)是不合格的——它验证的是 mock 本身而非被测逻辑。
CustomTestCase提供的 CI 重试(_callTestMethod中按SGLANG_TEST_MAX_RETRY/CI 环境决定重试次数)也意味着:测试应设计为可安全重试的纯函数式断言,避免依赖全局状态或外部副作用,否则重试机制反而会放大 flaky 面。
七、实战建议
- 优先复用公共工具:桩函数
maybe_stub_sgl_kernel与基类CustomTestCase都集中在 python/sglang/test/test_utils.py,新增 CPU 测试前先扫一眼该文件,避免重复造轮子。 - 归属先看目录:调度器相关组件放
unit/managers/scheduler_components/,KV 缓存策略放unit/mem_cache/,采样与解码参数放unit/sampling/,算子数值正确性则放test/registered/kernels/——保持与python/sglang/srt/的镜像关系。 - 注册参数要真实:
est_time会被 CI 用于分区调度,填写明显偏离实际的数值会影响整个流水线的负载均衡;多后端(如 CPU+XPU)可用多个注册调用叠加,参照 test/registered/unit/sampling/test_sampling_params.py。 - 提交前跑三件套:
pytest test/registered/unit/<模块>/ -v确认通过;python3 <测试文件>.py -f确认主入口可执行(与 CI 调用方式一致);带--cov运行并配合diff-cover --fail-under=60满足 PR 增量覆盖率门槛。
通过上述约定,test/registered/unit/为 SGLang 提供了一层廉价、快速、无硬件依赖的回归防线:绝大多数调度、采样、协议与状态机逻辑可以在几分钟内完成全量验证,而把昂贵的算子与端到端验证留给kernels、e2e等更重的测试层级。
【免费下载链接】sglangSGLang is a high-performance serving framework for large language models and multimodal models.项目地址: https://gitcode.com/GitHub_Trending/sg/sglang
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考