起因#
最近在学 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 designencode_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_model 由 n_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 itemsunittest 的 self.assertEqual 常常就一个干巴巴的 AssertionError。调试长 token ID 列表时,差别很明显。
个人思考#
以前我理解测试就是验证代码对不对——匹配就 pass,不匹配就 fail。CS336 让我觉得这个理解太窄了。
大型工程里的测试,更像一份可执行的设计文档。它不只回答「现在代码是否正确」,还回答:
- 哪些行为是设计保证?——普通测试。
encode_iterable在 1MB 内完成,是保证。 - 哪些限制是暂时存在的?——xfail。
encode超过 1MB,是已知限制,不是 bug。 - 未来哪些地方该被改进?——xfail 同时也是待办。变成 XPASS,说明可能已经修好了。
回到最初的疑问:为什么不直接 assert memory > 1MB?
因为那是在规定行为:「超内存是对的」。而 xfail 说的是:「目前会超内存,但这不是我们追求的最终状态。」
意图不一样。前者把限制写死成规范,后者把限制记成现状,并留出改进空间。
好的测试,本质上是在建立对系统行为的共识:这里是硬约束,那里是已知缺口,这些地方将来可能会变好。测试不只是给 CI 看的,也是给人看的。