Skip to main content
  1. Posts/

从 CS336 的 xfail 看大型工程中的测试设计

Table of Contents
Note: This article is available in Chinese only. 本文暂无英文版本。 View original

起因
#

最近在学 Stanford CS336(Language Modeling from Scratch),做 tokenizer 作业时,第一次在测试代码里见到了 pytest.mark.xfail

具体是这段:

 1@pytest.mark.xfail(
 2    reason="Tokenizer.encode is expected to take more memory than allotted (1MB)."
 3)
 4def test_encode_memory_usage():
 5    """
 6    We expect this test to fail, since Tokenizer.encode is not expected to be memory efficient.
 7    """
 8    tokenizer = get_tokenizer_from_vocab_merges_path(
 9        vocab_path=VOCAB_PATH,
10        merges_path=MERGES_PATH,
11    )
12    with open(FIXTURES_PATH / "tinystories_sample_5M.txt") as f:
13        contents = f.read()
14        _ = _encode(tokenizer, contents)

我当时第一反应是困惑:既然作者明确知道 encode 会超过 1MB,为什么不直接写成 assert memory > 1MB?或者干脆删掉?为什么要提交一个「预期会失败」的测试?

这个疑问牵出了一个我之前没认真想过的问题:测试到底在验证什么?

encode 和 encode_iterable
#

先说作业背景。tokenizer 要实现两个编码接口:

  • encode(text: str) -> list[int]:整段文本一次性编码
  • encode_iterable(iterable: Iterable[str]) -> Iterator[int]:流式编码,lazy yield token ID

encode 很直观——拿完整字符串,pre-tokenize,BPE merge,返回 ID 列表。但如果你要编码一个 5MB 的文件,整段文本和中间产物都得进内存。超过 1MB 限制完全是预期行为。

encode_iterable 的目标不同。它吃一个迭代器(比如文件句柄),边读边 yield,内存应该是常数级的:

 1def encode_iterable(self, iterable: Iterable[str]) -> Iterator[int]:
 2    buffer = ""
 3    for text in iterable:
 4        buffer += text
 5        cut = self._find_safe_cut(buffer)
 6        if cut > 0:
 7            yield from self.encode(buffer[:cut])
 8            buffer = buffer[cut:]
 9    if buffer:
10        yield from self.encode(buffer)

回头看两个内存测试,逻辑就清楚了:

 1# 没有 xfail —— encode_iterable 在 1MB 限制下必须通过
 2def test_encode_iterable_memory_usage():
 3    tokenizer = get_tokenizer_from_vocab_merges_path(...)
 4    with open(FIXTURES_PATH / "tinystories_sample_5M.txt") as f:
 5        ids = []
 6        for _id in _encode_iterable(tokenizer, f):
 7            ids.append(_id)
 8
 9# 有 xfail —— encode 在 1MB 限制下预期失败
10@pytest.mark.xfail(reason="Tokenizer.encode is expected to take more memory than allotted (1MB).")
11def test_encode_memory_usage():
12    tokenizer = get_tokenizer_from_vocab_merges_path(...)
13    with open(FIXTURES_PATH / "tinystories_sample_5M.txt") as f:
14        contents = f.read()
15        _ = _encode(tokenizer, contents)

这两个测试放在一起,表达的不是「encode 有 bug」,而是一个设计声明:

  • encode 不是为大文件设计的,超内存是 by design
  • encode_iterable 才是大文件的正确用法,必须在内存限制下通过

xfail 在这里的作用,是把一条设计边界写成了可执行的代码。

pytest xfail 到底是什么
#

xfail vs 普通 FAILED
#

普通 FAILED 是意外——代码坏了,要马上修。

xfail 是已知状态声明——「这个测试目前应该失败,失败本身是预期行为」。

跑出来显示 XFAIL(expected failure),不计入失败数,CI 依然是绿的。

xfail vs skip
#

skip 是「不跑」。测试压根不执行,你也拿不到任何信息。

xfail 是「跑,但预期失败」。pytest 真的会执行它。于是:

  • 某天实现改进了,测试意外通过,会报 XPASS(unexpected pass)
  • 你能立刻发现:这个已知限制可能已经被修掉了

skip 做不到这一点。skip 了就等于把监控也关掉了。

XFAIL 和 XPASS
#

测试结果含义
XFAIL预期失败,确实失败了。正常
XPASS预期失败,却通过了。需要关注

XPASS 是个信号:「你声称的已知限制可能已经不存在了,去看看能不能去掉 xfail。」

strict=True
#

默认 XPASS 只是 warning,不会让 CI 挂。如果写成:

1@pytest.mark.xfail(strict=True, reason="...")

XPASS 就会变成真正的 FAILED。意思是:「如果这测通过了,一定有意料之外的事,我要去检查。」

CS336 没用 strict=True,这是合理的——要是某个同学写了极其省内存的 encode,在 1MB 内搞定 5MB 文件,XPASS 只是惊喜,不该让 CI 挂掉。

说白了:xfail 不是「忽略失败」,而是对测试状态的声明。「这个问题我知道,目前不修,但我在持续监控。」

xfail 和 TDD
#

TDD 经典流程是 Red → Green → Refactor。写一个失败的测试(Red),实现让它通过(Green),再重构。

大型工程里,Red 和 Green 之间可能隔数周甚至数月。怎么办?

CI 不能一直红——大家会开始无视失败,信号就废了。但也不想删测试或 skip——那样就丢了「这个功能需要被实现」这条信息。

xfail 就是 TDD 里 Red 阶段的持久化。测试可以先合进主干,CI 仍然绿。

几个典型场景:

新 feature。 先写 test_new_feature(),实现还要两个 sprint。标 xfail(reason="Feature X not yet implemented"),合进主干,做完再摘标记。

Bug regression。 先写 repro,标 xfail(reason="Bug #1234")。修好后去掉。以后若复发,从 XFAIL 变成 FAILED,会非常醒目。

Backend capability。 系统有多 backend(CPU / CUDA / ROCm),功能先在 CPU 上有了,CUDA 还没跟。标 xfail(condition=is_cuda, reason="CUDA backend not yet supported")

在 ML Infra / PyTorch 里怎么用
#

如果你觉得 xfail 只是教学项目的玩具,可能还没怎么翻过 PyTorch 的测试。那边 xfail 非常多,场景也比 CS336 复杂得多。

torch.compile / Inductor
#

Inductor 不是一口气支持所有 op 的。很多 op 在 eager 下正常,编译后会 fallback 甚至报错。

常见做法是维护一大份 xfail 列表。每条意思是:「这个 op 在 Inductor 上已知不工作。」不是 regression,是 known gap。

1# 简化示意
2@pytest.mark.xfail(
3    condition=backend == "inductor",
4    reason="Complex op not yet lowered to Inductor"
5)
6def test_complex_scatter():
7    ...

Inductor 团队每支持一个新 op,对应测试从 XFAIL 变 XPASS,就可以从列表里摘掉。这是很干净的进度追踪机制。

CUDA / GPU backend
#

GPU 测试难在硬件和软件版本的排列组合:

  • GPU 架构:Volta、Ampere、Hopper、Ada Lovelace
  • CUDA:11.8、12.1、12.4
  • cuDNN:8.x、9.x
  • Backend:CUDA、ROCm、MPS

某些组合暂时不支持,很正常。全 skip 掉,就失去了「某天修好」的监控。用 xfail,系统会自己告诉你什么时候变 pass。

分布式训练
#

NCCL / DDP / FSDP 的组合测试也是重灾区。比如:

  • FSDP + activation checkpointing 在某些 dtype 下已知有精度问题
  • DDP 在 ROCm 上某些 collective 还没适配

这些往往不是要立刻修的 bug,但也不能假装不存在。xfail 正好表达这种中间态。

数值测试
#

这个我工作里体会最深。

之前公司 toolbox 某版本之后结果发生偏移。排查下来:cupy 和 torch 的 import 顺序不同,训练输出在小数点后六七位开始分叉。

这种差异用 assert a == b 一定会挂。但也不好直接叫 bug——float32 在 GPU 上本来就不保证 bit exact。不同 CUDA kernel、fast math、FP16 / BF16,都会带进微小数值差。

PyTorch 数值测试里,常见做法是 torch.allclose 带容差,或者在已知会炸的精度配置上标 xfail。这不是偷懒,是对底层硬件现实的尊重。

CS336 里还有哪些 pytest 技巧值得看
#

fixture 依赖链
#

conftest.py 里模型参数 fixture 之间有依赖:

 1@pytest.fixture
 2def n_heads():
 3    return 4
 4
 5@pytest.fixture
 6def d_head():
 7    return 16
 8
 9@pytest.fixture
10def d_model(n_heads, d_head):
11    return n_heads * d_head
12
13@pytest.fixture
14def q(batch_size, n_queries, d_model):
15    torch.manual_seed(1)
16    return torch.randn(batch_size, n_queries, d_model)

d_modeln_heads * d_head 算出,q 的 shape 再依赖 d_model。改一个参数,下游自动跟上。比每个测试里手写 d_model = 4 * 16 干净得多。

ML 里配置约束很多(d_model 要被 n_heads 整除、d_ff 常常是 d_model 的 4 倍),fixture 依赖链让约束只声明一次。

snapshot 测试
#

model 测试用了 snapshot:参考实现跑一遍,存输出,之后每次和 snapshot 比。

1def test_linear(numpy_snapshot, ts_state_dict, in_embeddings, d_model, d_ff):
2    w1_weight = ts_state_dict[0]["layers.0.ffn.w1.weight"]
3    output = run_linear(
4        d_in=d_model, d_out=d_ff, weights=w1_weight, in_features=in_embeddings,
5    )
6    numpy_snapshot.assert_match(output)

输出是高维 tensor 时,你不可能手写一份 [4, 12, 128] 的期望值。snapshot 就很合适。

memory_limit 装饰器
#

CS336 自己写了个装饰器,用 Linux resource.setrlimit 限制单个函数的内存:

 1def memory_limit(max_mem):
 2    def decorator(f):
 3        def wrapper(*args, **kwargs):
 4            process = psutil.Process(os.getpid())
 5            prev_limits = resource.getrlimit(resource.RLIMIT_AS)
 6            resource.setrlimit(resource.RLIMIT_AS, (process.memory_info().rss + max_mem, -1))
 7            try:
 8                return f(*args, **kwargs)
 9            finally:
10                resource.setrlimit(resource.RLIMIT_AS, prev_limits)
11        return wrapper
12    return decorator

思路值得记。ML Infra 测试不能只测 correctness,还要盯非功能性指标:内存、延迟、数值稳定性。只测「结果对不对」,内存泄漏也能全绿。

memory_limit + xfail 合在一起,完整表达了设计约束:

  • streaming API(encode_iterable)必须在 1MB 内 → 普通测试,必须 pass
  • 非 streaming(encode)预期超出 1MB → xfail,失败是正常的

parametrize
#

CS336 没用 @pytest.mark.parametrize,每个 case 是独立函数。实际 ML 工程里,parametrize 几乎是必需品。

比如 tokenizer:

1@pytest.mark.parametrize("text", [
2    "Hello, world!",
3    "你好世界",
4    "",
5    "a" * 10000,
6    "<|endoftext|>",
7])
8def test_encode_decode_roundtrip(text):
9    ...

模型侧也是 dtype、batch size 一排过去。不用 parametrize,要么复制一堆函数,要么 for 循环——而 for 循环第一个挂了后面都不跑,报错也不清楚。

assert rewriting
#

这是 pytest 本身的能力。它对 assert 做 AST 重写,失败时直接甩中间值:

1assert ids == reference_ids
2E       AssertionError: assert [27, 91, 437] == [50256]
3E         At index 0: 27 != 50256
4E         Left contains 2 more items

unittestself.assertEqual 常常就一个干巴巴的 AssertionError。调试长 token ID 列表时,差别很明显。

个人思考
#

以前我理解测试就是验证代码对不对——匹配就 pass,不匹配就 fail。CS336 让我觉得这个理解太窄了。

大型工程里的测试,更像一份可执行的设计文档。它不只回答「现在代码是否正确」,还回答:

  • 哪些行为是设计保证?——普通测试。encode_iterable 在 1MB 内完成,是保证。
  • 哪些限制是暂时存在的?——xfail。encode 超过 1MB,是已知限制,不是 bug。
  • 未来哪些地方该被改进?——xfail 同时也是待办。变成 XPASS,说明可能已经修好了。

回到最初的疑问:为什么不直接 assert memory > 1MB

因为那是在规定行为:「超内存是对的」。而 xfail 说的是:「目前会超内存,但这不是我们追求的最终状态。」

意图不一样。前者把限制写死成规范,后者把限制记成现状,并留出改进空间。

好的测试,本质上是在建立对系统行为的共识:这里是硬约束,那里是已知缺口,这些地方将来可能会变好。测试不只是给 CI 看的,也是给人看的。

参考资料
#

Related

pytorch 函数笔记

·2 mins
记录一些常用的…总去查文档也是有点麻烦 * tensor.view 的作用是reshape 比如 a = torch.range(1, 16) 得到一个tensor that has 16 elements from 1 to 16. 在a=a.view(4,4)就得到了一个44的tensor。 需要注意reshape之后元素的个数不能改变(16==44) 参数-1的作用是,我懒得算这一维度应该是多少,(由于元素个数不能改变)所以希望自动被计算。**需要注意的是,只有一个维度可以写-1。 **不过view和reshape有些区别:reshape always copies memory. view never copies memory # * torch.squeeze 将输入张量形状中的1 去除并返回。 如果输入是形如(A×1×B×1×C×1×D),那么输出形状就为: (A×B×C×D)当给定dim时,那么挤压操作只在给定维度上。例如,输入形状为: (A×1×B), squeeze(input, 0) 将会保持张量不变,只有用 squeeze(input, 1),形状会变成 (A×B)。注意: 返回张量与输入张量共享内存,所以改变其中一个的内容会改变另一个。 # * torch.unsqueeze 返回一个新的张量,对输入的制定位置插入维度 1 注意: 返回张量与输入张量共享内存,所以改变其中一个的内容会改变另一个。如果dim为负,则将会被转化dim+input.dim()+1 # 1>>> x = torch.Tensor([1, 2, 3, 4]) 2>>> torch.unsqueeze(x, 0) 3 1 2 3 4 4[torch.FloatTensor of size 1x4] 5>>> torch.unsqueeze(x, 1) 6 1 7 2 8 3 9 4 10[torch.FloatTensor of size 4x1] 11 12 13 14 * tensor.expand(size) 扩展tensor.可以保持维度数目不变,每一维度的size增加(比如AB变到C*D,其中C>=A,D>=B).-1参数表示某一个维度的size不发生改变 . 有可以扩展tensor到更多的维度,新增加的维度会默认放在最前面,并且不能以-1作为参数。 # * tensor.contiguous 将一个tensor变成连续的。(一些ops如expand/expand_as会让tensor 不连续) #