187 lines
8.7 KiB
Python
187 lines
8.7 KiB
Python
"""MoE 排序分发的正确性测试 / correctness tests for the sorted MoE dispatch.
|
||
|
||
本仓库没有 pytest / CI,所以这里不引入任何测试框架,直接 `python scripts/test_moe_sorted.py`
|
||
运行,失败返回非零。
|
||
|
||
reference_masked_forward 是改动之前逐专家做 mask 的写法,保留在这里作为参考实现:
|
||
它每次运行都会被执行,并断言与当前实现逐位相同,因此既能当作对照读,也不会随代码腐烂。
|
||
"""
|
||
import os
|
||
import sys
|
||
import traceback
|
||
import warnings
|
||
|
||
import torch
|
||
import torch.nn.functional as F
|
||
|
||
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
|
||
from model.model_minimind import MiniMindConfig, MOEFeedForward
|
||
|
||
|
||
class Skip(Exception): pass
|
||
|
||
|
||
# ============================== 参考实现 / reference ==============================
|
||
|
||
def reference_masked_forward(mod, x):
|
||
"""改动前的逐专家掩码写法,逐位对照用。
|
||
|
||
每个专家都要 3 次 device→host 同步:mask.any() 的取值、nonzero() 的输出长度、
|
||
布尔索引的结果长度,全都得先回到主机才知道。
|
||
"""
|
||
batch_size, seq_len, hidden_dim = x.shape
|
||
x_flat = x.view(-1, hidden_dim)
|
||
scores = F.softmax(mod.gate(x_flat), dim=-1)
|
||
topk_weight, topk_idx = torch.topk(scores, k=mod.config.num_experts_per_tok, dim=-1, sorted=False)
|
||
if mod.config.norm_topk_prob: topk_weight = topk_weight / (topk_weight.sum(dim=-1, keepdim=True) + 1e-20)
|
||
y = torch.zeros_like(x_flat)
|
||
for i, expert in enumerate(mod.experts):
|
||
mask = (topk_idx == i)
|
||
if mask.any():
|
||
token_idx = mask.any(dim=-1).nonzero().flatten()
|
||
weight = topk_weight[mask].view(-1, 1)
|
||
y.index_add_(0, token_idx, (expert(x_flat[token_idx]) * weight).to(y.dtype))
|
||
elif mod.training:
|
||
y[0, 0] += 0 * sum(p.sum() for p in expert.parameters())
|
||
return y.view(batch_size, seq_len, hidden_dim)
|
||
|
||
|
||
# ============================== 工具 / helpers ==============================
|
||
|
||
def build(seed=0, **kw):
|
||
base = dict(hidden_size=64, use_moe=True, num_experts=4, num_experts_per_tok=1, moe_intermediate_size=128)
|
||
base.update(kw)
|
||
torch.manual_seed(seed)
|
||
return MOEFeedForward(MiniMindConfig(**base))
|
||
|
||
|
||
def force_routing(mod, assign, hidden_size=64):
|
||
"""构造 x 使得 token t 被路由到 assign[t]:gate 取单位阵,x 在目标维上给大值。"""
|
||
with torch.no_grad():
|
||
mod.gate.weight.zero_()
|
||
for i in range(mod.config.num_experts): mod.gate.weight[i, i] = 1.0
|
||
x = torch.zeros(1, len(assign), hidden_size)
|
||
for t, a in enumerate(assign): x[0, t, a] = 10.0
|
||
return x
|
||
|
||
|
||
# ============================== 用例 / tests ==============================
|
||
|
||
def test_bit_identical_to_masked_reference():
|
||
"""与掩码参考实现对照:前向和参数梯度在所有 k 下逐位相同;输入梯度在 k<=2 下逐位相同。
|
||
|
||
k>=3 时输入梯度只能做到 1 ulp 以内,这不是 bug:每个 token 要把 k 份专家贡献加起来,
|
||
浮点加法不满足结合律,(a+b)+c 与 a+(b+c) 可以差 1 ulp;两种写法的求和顺序不同。
|
||
k<=2 时任何顺序都精确相等,所以那里仍然要求 torch.equal。实测最大相对偏差 ~1.2e-7
|
||
(fp32 eps ≈ 1.2e-7),这里的阈值 1e-6 只是留一点余量。
|
||
"""
|
||
for e, k in ((1, 1), (2, 1), (4, 1), (4, 2), (4, 3), (4, 4), (8, 2), (8, 3), (8, 8)):
|
||
mod = build(seed=3, num_experts=e, num_experts_per_tok=k)
|
||
torch.manual_seed(21)
|
||
x = torch.randn(3, 11, 64)
|
||
for train in (True, False):
|
||
mod.train(train)
|
||
xa, xb = x.clone().requires_grad_(), x.clone().requires_grad_()
|
||
ya = reference_masked_forward(mod, xa)
|
||
ya.sum().backward()
|
||
ga = {n: (p.grad.clone() if p.grad is not None else None) for n, p in mod.named_parameters()}
|
||
mod.zero_grad(set_to_none=True)
|
||
yb = mod(xb)
|
||
yb.sum().backward()
|
||
assert torch.equal(ya, yb), f'E={e} k={k} train={train}: 前向不是逐位相同'
|
||
if k <= 2:
|
||
assert torch.equal(xa.grad, xb.grad), f'E={e} k={k} train={train}: 输入梯度不是逐位相同'
|
||
else: # 求和顺序不同,只能要求 1 ulp 量级
|
||
rel = (xa.grad - xb.grad).abs().max() / xa.grad.abs().max()
|
||
assert rel < 1e-6, f'E={e} k={k} train={train}: 输入梯度相对偏差 {rel:.2e} 超出浮点求和顺序可解释的范围'
|
||
for n, p in mod.named_parameters():
|
||
assert (ga[n] is None) == (p.grad is None), f'{n}: 梯度有无不一致'
|
||
if p.grad is not None:
|
||
assert torch.equal(ga[n], p.grad), f'E={e} k={k} train={train} {n}: 参数梯度不是逐位相同'
|
||
mod.zero_grad(set_to_none=True)
|
||
|
||
|
||
def test_edge_cases():
|
||
"""零token专家 / 全部token到同一专家 / batch 小于专家数。"""
|
||
for name, assign in {'零token专家': [0, 1, 0, 1, 0, 1], '全部到专家0': [0] * 6, 'batch<专家数': [1, 2]}.items():
|
||
mod = build(seed=5); mod.train()
|
||
x = force_routing(mod, assign)
|
||
assert torch.equal(reference_masked_forward(mod, x), mod(x)), f'{name}: 与掩码参考实现不一致'
|
||
|
||
|
||
def test_zero_token_expert_still_gets_grad():
|
||
"""DDP 以 find_unused_parameters=False 构造,空专家也必须出现在自动求导图里。"""
|
||
mod = build(seed=5); mod.train()
|
||
mod(force_routing(mod, [0] * 6)).sum().backward()
|
||
missing = [n for n, p in mod.named_parameters() if p.grad is None]
|
||
assert not missing, f'{missing} 没有梯度 -> DDP(find_unused_parameters=False) 会报错'
|
||
|
||
|
||
def test_aux_loss_and_state_dict_unchanged():
|
||
"""aux_loss 与 state_dict 结构不受本次改动影响。"""
|
||
mod = build(seed=7); mod.train()
|
||
torch.manual_seed(11)
|
||
x = torch.randn(2, 16, 64)
|
||
mod(x); got = mod.aux_loss.clone()
|
||
scores = F.softmax(mod.gate(x.view(-1, 64)), dim=-1)
|
||
_, topk_idx = torch.topk(scores, k=1, dim=-1, sorted=False)
|
||
load = F.one_hot(topk_idx, 4).float().mean(0)
|
||
want = (load * scores.mean(0)).sum() * 4 * mod.config.router_aux_loss_coef
|
||
assert torch.equal(got, want), 'aux_loss 变了'
|
||
expect = {'gate.weight'} | {f'experts.{i}.{p}_proj.weight' for i in range(4) for p in ('gate', 'up', 'down')}
|
||
assert set(mod.state_dict()) == expect, 'state_dict 键名变了,旧 checkpoint 将无法 strict 加载'
|
||
|
||
|
||
def test_host_sync_count():
|
||
"""本次改动的核心:整层同步次数恒为 1,不随专家数增长。"""
|
||
if not torch.cuda.is_available(): raise Skip('需要 CUDA')
|
||
|
||
def syncs(fn, x):
|
||
fn(x); torch.cuda.synchronize() # 预热,避免把首次分配算进去
|
||
torch.cuda.set_sync_debug_mode('warn')
|
||
with warnings.catch_warnings(record=True) as w:
|
||
warnings.simplefilter('always'); fn(x)
|
||
torch.cuda.set_sync_debug_mode('default')
|
||
return sum('sync' in str(r.message).lower() for r in w)
|
||
|
||
for e in (4, 8, 16):
|
||
mod = build(seed=3, num_experts=e).cuda().train()
|
||
x = torch.randn(2, 64, 64, device='cuda')
|
||
ref, now = syncs(lambda t: reference_masked_forward(mod, t), x), syncs(mod, x)
|
||
assert ref >= 3 * e, f'E={e}: 掩码写法应约 3E={3 * e} 次同步,实测 {ref}'
|
||
assert now == 1, f'E={e}: 排序写法应恒为 1 次同步,实测 {now}'
|
||
|
||
|
||
def test_cuda_bit_identical():
|
||
"""CUDA 上 fp32 与 bf16 autocast 同样逐位相同——排序改变了 kernel 的分块方式。"""
|
||
if not torch.cuda.is_available(): raise Skip('需要 CUDA')
|
||
for e, k in ((4, 1), (8, 2)):
|
||
mod = build(seed=3, num_experts=e, num_experts_per_tok=k).cuda().train()
|
||
torch.manual_seed(21)
|
||
x = torch.randn(4, 128, 64, device='cuda')
|
||
assert torch.equal(reference_masked_forward(mod, x), mod(x)), f'E={e} k={k}: CUDA fp32 不一致'
|
||
with torch.autocast('cuda', dtype=torch.bfloat16):
|
||
assert torch.equal(reference_masked_forward(mod, x), mod(x)), f'E={e} k={k}: CUDA bf16 不一致'
|
||
|
||
|
||
TESTS = [test_bit_identical_to_masked_reference, test_edge_cases, test_zero_token_expert_still_gets_grad,
|
||
test_aux_loss_and_state_dict_unchanged, test_host_sync_count, test_cuda_bit_identical]
|
||
|
||
|
||
def main():
|
||
print(f'torch {torch.__version__} cuda={torch.cuda.is_available()}\n')
|
||
fails = skips = 0
|
||
for t in TESTS:
|
||
try:
|
||
t(); print(f' PASS {t.__name__}')
|
||
except Skip as s:
|
||
skips += 1; print(f' SKIP {t.__name__} ({s})')
|
||
except Exception:
|
||
fails += 1; print(f' FAIL {t.__name__}'); traceback.print_exc()
|
||
print(f'\n{len(TESTS) - fails - skips} passed, {fails} failed, {skips} skipped')
|
||
sys.exit(1 if fails else 0)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|