Download code/models/common/tests/modules/moe/test_tt_moe_gate.py from tt-hous/clef: direct link, hf CLI and curl.
- Browser
- Download file 11.4 kB
-
https://huggingface.co/tt-hous/clef/resolve/main/code/models/common/tests/modules/moe/test_tt_moe_gate.py
- Command line
-
hf download hf://tt-hous/clef/code/models/common/tests/modules/moe/test_tt_moe_gate.py
-
curl -L -o test_tt_moe_gate.py https://huggingface.co/tt-hous/clef/resolve/main/code/models/common/tests/modules/moe/test_tt_moe_gate.py
11.4 kB
| # 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 | |
| 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" | |