File size: 11,363 Bytes
656477e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | # SPDX-FileCopyrightText: © 2026 Tenstorrent USA, Inc.
#
# SPDX-License-Identifier: Apache-2.0
"""Standalone test for ``models.common.modules.moe.tt_moe_gate.TTMoEGate``.
Modeled on ``test_tt_moe_decode.py``: load each YAML model config, derive the gate
sizes (``num_routed_experts`` / ``select_experts_k`` / ``hidden_size``), build torch
hidden states + a router weight, run ``TTMoEGate`` to produce ``(scores, indices)``,
and verify against the torch golden. ``TTMoEGate`` produces exactly the routing
``(tt_scores, tt_indices)`` that ``test_tt_moe_decode`` currently fakes with random
tensors, so the two compose into the full router→MoE path.
Coverage: every model YAML in ``configs/`` runs. n_group=1 spans the kernel op (k∈{4,6,8}, ≤512 experts:
64/128/160 pad-to-256, 256 single-face, 384/512 2-block combine) and the ttnn fallback (any other k, e.g.
512-experts top-10); n_group=8 is the deepseek grouped op (256 select-8). The only skips are configs
``TTMoEGate`` doesn't wire — n_group∉{1,8}, or n_group=8 not at exactly 256/k8 (mirrors __init__'s guards).
"""
from __future__ import annotations
from pathlib import Path
import pytest
import torch
import yaml
from loguru import logger
import ttnn
from models.common.modules.moe.tt_moe_gate import TTMoEGate
from models.common.modules.moe.tt_moe_gate_config import TTMoEGateConfig
from tests.ttnn.utils_for_testing import comp_pcc
CONFIGS_DIR = Path(__file__).resolve().parents[3] / "modules/moe/configs"
CONFIG_PATHS = sorted(CONFIGS_DIR.glob("*.yaml"))
assert CONFIG_PATHS, f"no YAML configs found in {CONFIGS_DIR}"
def _config_id(path: Path) -> str:
return path.stem
@pytest.mark.parametrize(
# 4×8 = 32-chip mesh (TG/Galaxy). TTMoEGate is a PER-DEVICE gate (no cross-chip comms): its weight +
# op buffers replicate to every chip, so each chip runs the same one-token-per-core routing independently.
# The mesh_device fixture auto-skips when fewer chips are available. (More shapes can be added here in the
# pytest.param form used by test_tt_moe_decode.py.)
"mesh_device",
[pytest.param((4, 8), id="4x8")],
indirect=True,
)
@pytest.mark.parametrize("config_path", CONFIG_PATHS, ids=_config_id)
@pytest.mark.parametrize("seed", [42])
def test_tt_moe_gate(mesh_device, config_path: Path, seed: int):
yaml_text = config_path.read_text()
gate_config = TTMoEGateConfig.from_yaml(yaml_text)
raw = yaml.safe_load(yaml_text) # only for gate_notes (a doc field, not part of the config)
num_experts = gate_config.num_routed_experts
k = gate_config.select_experts_k
hidden = gate_config.hidden_size
n_group = gate_config.n_group
score_func = gate_config.score_func
scaling = gate_config.routed_scaling_factor
# Skip only what TTMoEGate genuinely can't build (mirrors its __init__ guards):
# • n_group ∈ {1, 8} only.
# • n_group=8 = the deepseek grouped op, HARDWIRED to 256 experts select-8 (8 groups × 32 → top-8) — so
# EXACTLY N==256, k==8 (not "≤256": a smaller N has ≠32 experts/group, which the kernel can't do).
# • n_group=1 has NO expert ceiling: k∈{4,6,8} & N≤512 → kernel op; any other k OR N>512 → ttnn fallback.
if n_group not in (1, 8):
pytest.skip(f"only n_group 1 (generalized/ungrouped) / 8 (deepseek grouped) are wired; got n_group={n_group}")
if n_group == 8 and (num_experts != 256 or k != 8):
pytest.skip(f"n_group=8 (deepseek grouped op) is hardwired to 256 experts select-8; got N={num_experts}, k={k}")
batch = gate_config.batch_per_device or 32 # PER-DEVICE batch (one token per core); replicated to every chip
logger.info(
f"[{config_path.stem}] gate: N={num_experts} k={k} hidden={hidden} batch={batch} "
f"score_func={score_func} mesh={tuple(mesh_device.shape)}"
)
# per-model caveats about steps TTMoEGate does NOT cover (e.g. gemma4's RMSNorm + per-dim input scale
# and per-expert output scale) — the caller must apply these around TTMoEGate.
if raw.get("gate_notes"):
logger.warning(f"[{config_path.stem}] gate_notes: {raw['gate_notes']}")
# --- torch inputs ---
torch.manual_seed(seed)
hidden_states = (2 * torch.rand((batch, hidden), dtype=torch.bfloat16)) - 1
gate_weight = ((2 * torch.rand((hidden, num_experts), dtype=torch.bfloat16)) - 1) * 0.1
# score-correction bias (deepseek/noaux_tc): present iff config.score_correction_bias (EXPLICIT per-model).
# Added to scores for SELECTION only (output weights stay unbiased). None → TTMoEGate feeds the op a zeros bias.
gate_bias = (2 * torch.rand((num_experts,)) - 1) if gate_config.score_correction_bias else None
# router LINEAR bias (gpt-oss, config.gate_proj_bias): logits = Wx + b, flows into selection + weights.
proj_bias = (2 * torch.rand((num_experts,)) - 1) if gate_config.gate_proj_bias else None
# --- device module + inputs (config-driven entry point, mirrors TTMoEDecode) ---
gate = TTMoEGate(
mesh_device,
gate_config,
torch_gate_weight=gate_weight,
torch_gate_bias=gate_bias,
torch_gate_proj_bias=proj_bias,
)
# replicate the hidden states to every chip — matches TTMoEGate's replicated weight/buffers, so each chip
# routes the same batch (ReplicateTensorToMesh is a no-op on a 1-chip mesh, so this also works at 1×1).
tt_x = ttnn.from_torch(
hidden_states.reshape(1, 1, batch, hidden),
dtype=ttnn.bfloat16,
layout=ttnn.TILE_LAYOUT,
device=mesh_device,
memory_config=ttnn.DRAM_MEMORY_CONFIG,
mesh_mapper=ttnn.ReplicateTensorToMesh(mesh_device),
)
# --- golden (hidden -> logits -> gate -> (scores[batch,k], indices[batch,k])) ---
gold_scores, gold_idx = TTMoEGate.golden(
hidden_states,
gate_weight,
gate_bias,
select_experts_k=k,
score_func=score_func,
softmax_position=gate_config.softmax_position,
scaling_factor=scaling,
eps=gate_config.eps,
n_group=n_group,
gate_proj_bias=proj_bias,
)
tt_scores, tt_indices = gate.forward(tt_x)
# every chip computed the same routing (replicated inputs); concat over the mesh and keep the first chip.
composer = ttnn.ConcatMeshToTensor(mesh_device, dim=0)
dev_scores = ttnn.to_torch(tt_scores, mesh_composer=composer).reshape(-1, k)[:batch].float()
dev_idx = ttnn.to_torch(tt_indices, mesh_composer=composer).reshape(-1, k)[:batch].to(torch.int64)
# --- verify (torch fp32 golden; the device matmul runs HiFi2 + fp32 accumulation, so its residual
# bf16 noise only nudges rank-boundary experts — the checks below tolerate that) ---
logits = hidden_states.float() @ gate_weight.float()
if proj_bias is not None: # gpt-oss router linear bias: logits = Wx + b (into selection AND weights)
logits = logits + proj_bias.float()
bias = gate_bias.float() if gate_bias is not None else torch.zeros(num_experts)
# the per-expert score the op ranks/weights with, per score_func (softmax ranks by the raw logit):
if score_func == "sigmoid":
score = torch.sigmoid(logits)
elif score_func == "sqrtsoftplus":
score = torch.sqrt(torch.nn.functional.softplus(logits))
else: # softmax
score = logits
gold_idx = gold_idx.to(torch.int64)
logger.info(f"dev_idx=\n{dev_idx}\ngold_idx=\n{gold_idx}")
assert dev_idx.min() >= 0 and dev_idx.max() < num_experts, f"out-of-range expert id:\n{dev_idx}"
# (1) PRIMARY check — score self-consistency: the device's output weights are the correct
# (softmax/linear) normalization of the score at the experts IT selected. Tie-robust (uses
# dev's OWN selection), so it validates the module wiring + normalize regardless of any
# selection ambiguity.
dev_sel = torch.gather(score, -1, dev_idx)
weights = torch.exp(dev_sel) if score_func == "softmax" else dev_sel # softmax→exp-over-selected; else linear
expected = weights / (weights.sum(-1, keepdim=True) + 1e-20) * scaling
# Kernel-op configs hold at the tight 1e-2. The pure-ttnn FALLBACK (n_group=1 with k∉{4,6,8} or N>512 —
# today only qwen35_397b, and the one path with no op-level test behind it) runs matmul→topk→softmax,
# whose bf16 noise the softmax exp-over-logits amplifies, so it alone needs 5e-2. Predicate mirrors
# TTMoEGate.use_fallback (tt_moe_gate.py).
use_fallback = n_group == 1 and (k not in (4, 6, 8) or num_experts > 512)
score_atol = 5e-2 if use_fallback else 1e-2
assert torch.allclose(
dev_scores.sort(-1).values, expected.sort(-1).values, atol=score_atol
), f"gate scores not consistent with the device's own selection.\n dev={dev_scores}\n expected={expected}"
# (2) SELECTION vs golden.
if n_group == 1:
# ungrouped: dev's selected experts form a valid global top-k (ranking-key multiset matches
# golden). key = score + bias for every score_func (softmax has bias=0, so key=logit there;
# sigmoid→sigmoid+bias; sqrtsoftplus→sqrt(softplus)+bias). bf16-at-logit-scale noise only swaps
# rank-(k-1)/k boundary experts; a real mis-selection is off by >>0.05.
key = score + bias
dev_key = torch.gather(key, -1, dev_idx).sort(-1).values
gold_key = torch.gather(key, -1, gold_idx).sort(-1).values
assert torch.allclose(dev_key, gold_key, atol=5e-2), (
f"gate selection not a valid top-{k}.\n dev_idx={dev_idx}\n gold_idx={gold_idx}\n"
f" dev_key={dev_key}\n gold_key={gold_key}"
)
else:
# grouped (deepseek, n_group=8): 8 groups of 32 → top-2-sum per group → top-4 groups → top-8.
# test_moe_gate.py-style: sort weights desc, gather indices to the same order, PCC the sorted
# weights + position-wise index accuracy. The remaining ~1% selection diff (and the lower position
# accuracy) is genuine bf16 ties at the top-8 boundary — the swapped experts have near-equal
# weights, so PCC/overlap stay high while exact index positions shift.
ref_sorted_w, ref_si = torch.sort(gold_scores.float(), dim=-1, descending=True, stable=True)
ref_sorted_i = torch.gather(gold_idx, -1, ref_si)
tt_sorted_w, tt_si = torch.sort(dev_scores, dim=-1, descending=True, stable=True)
tt_sorted_i = torch.gather(dev_idx, -1, tt_si)
pcc_ok, pcc_msg = comp_pcc(ref_sorted_w, tt_sorted_w, 0.99)
accuracy = tt_sorted_i.eq(ref_sorted_i).float().mean().item()
overlap = torch.stack([torch.isin(dev_idx[b], gold_idx[b]).float().mean() for b in range(batch)]).mean().item()
logger.info(f"grouped: weights {pcc_msg} | index accuracy={accuracy:.3f} | mean overlap={overlap:.3f}")
# Regression guards: a wrong grouping/wiring tanks both (the 16×16-vs-8×32 golden bug gave PCC 0.87 /
# overlap 0.71); a correct 8×32 grouped op lands ~0.99 even on random data. Index *position*
# accuracy is left as a log (boundary ties move positions without being a bug).
assert pcc_ok, f"grouped weights PCC below 0.99 — likely a grouping/wiring bug: {pcc_msg}"
assert overlap >= 0.9, f"grouped selection overlaps golden only {overlap:.3f} (< 0.9) — likely a wiring bug"
|