SGLang 单元测试规范与实战:为 `test/registered/unit` 编写可注册的 CPU-only 测试
2026/9/10 13:55:38 网站建设 项目流程

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,其中按attentionkernelsquant等子系统组织)。也就是说:逻辑/协议/状态机层面的正确性放在 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 的逐类覆盖(TestSamplingParamsInitTestSamplingParamsValidate等),验证了该镜像约定。

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)支持两种调用形态:

  • 旧式单字符串 suiteregister_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")stagerunner_config为 kwarg-only,二者必须成对出现且不能与suite混用,注册器会通过effective_suite拼出{stage}-test-{runner_config}

可选参数还包括nightly(是否仅进夜间流水线)与disabled(禁用原因说明)。除 CPU 外,同一套机制还提供register_cuda_ciregister_amd_ciregister_npu_ciregister_xpu_ciregister_musa_ciregister_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=60

diff-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.TestCaseCustomTestCase定义于 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.pyio_struct.py)会传递导入sgl_kernel这类需要 GPU 才能初始化的包。在纯 CPU CI 上,import 阶段就会ImportError。解决办法是在 import 之前先打桩。

maybe_stub_sgl_kernel()的原理

python/sglang/test/test_utils.py 中的maybe_stub_sgl_kernel()实现了标准做法:

  1. 先尝试真实import sgl_kernel:能成功(GPU 机器)则直接返回,no-op
  2. 失败(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_testssanity_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 面。

七、实战建议

  1. 优先复用公共工具:桩函数maybe_stub_sgl_kernel与基类CustomTestCase都集中在 python/sglang/test/test_utils.py,新增 CPU 测试前先扫一眼该文件,避免重复造轮子。
  2. 归属先看目录:调度器相关组件放unit/managers/scheduler_components/,KV 缓存策略放unit/mem_cache/,采样与解码参数放unit/sampling/,算子数值正确性则放test/registered/kernels/——保持与python/sglang/srt/的镜像关系。
  3. 注册参数要真实est_time会被 CI 用于分区调度,填写明显偏离实际的数值会影响整个流水线的负载均衡;多后端(如 CPU+XPU)可用多个注册调用叠加,参照 test/registered/unit/sampling/test_sampling_params.py。
  4. 提交前跑三件套pytest test/registered/unit/<模块>/ -v确认通过;python3 <测试文件>.py -f确认主入口可执行(与 CI 调用方式一致);带--cov运行并配合diff-cover --fail-under=60满足 PR 增量覆盖率门槛。

通过上述约定,test/registered/unit/为 SGLang 提供了一层廉价、快速、无硬件依赖的回归防线:绝大多数调度、采样、协议与状态机逻辑可以在几分钟内完成全量验证,而把昂贵的算子与端到端验证留给kernelse2e等更重的测试层级。

【免费下载链接】sglangSGLang is a high-performance serving framework for large language models and multimodal models.项目地址: https://gitcode.com/GitHub_Trending/sg/sglang

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询