|
| 1 | +--- |
| 2 | +DEP: 0006 |
| 3 | +Title: Sigmoid Grouped MoE Router |
| 4 | +Author: Illarion Iov |
| 5 | +Status: Implemented |
| 6 | +Type: Feature |
| 7 | +Created: 2026-05-18 |
| 8 | +--- |
| 9 | + |
| 10 | +# DEP-0006: Sigmoid Grouped MoE Router |
| 11 | + |
| 12 | +## Abstract |
| 13 | + |
| 14 | +This proposal adds `SigmoidGroupedTopKRouter` to `d9d/module/block/moe/router.py` — a router that |
| 15 | +combines sigmoid-activated expert scores with a two-level hierarchical selection procedure |
| 16 | +(group selection → per-group top-k). It also makes `MoELayer` router-agnostic by accepting any |
| 17 | +router instance via constructor injection instead of constructing `TopKRouter` internally. |
| 18 | + |
| 19 | +The new router is the routing strategy used by DeepSeek-V3, GLM-4.6, Kimi K2 / K2.6, |
| 20 | +MiniMax-M2, and Mistral Large 3 / Small 4 — all of which are planned for d9d integration. |
| 21 | + |
| 22 | +## Motivation |
| 23 | + |
| 24 | +The existing `TopKRouter` uses softmax gating: token scores across all experts are normalised |
| 25 | +jointly before top-k selection. Softmax introduces a coupling between experts that is absent in |
| 26 | +sigmoid gating, and it does not allow per-token load-balancing corrections to be applied without |
| 27 | +re-normalising the whole distribution. |
| 28 | + |
| 29 | +DeepSeek-V3 and its derivative models address this with a three-part change: |
| 30 | + |
| 31 | +1. **Sigmoid instead of softmax.** Each expert is scored independently; there is no |
| 32 | + inter-expert normalisation before selection. |
| 33 | +2. **Hierarchical group selection.** Experts are divided into `n_group` equal groups. A group |
| 34 | + score (sum of the top-2 expert scores within the group) selects `topk_group` candidate groups |
| 35 | + first; the final top-k experts are drawn only from those groups. This constrains which experts |
| 36 | + a token can reach and simplifies load balancing. |
| 37 | +3. **Correction bias.** An `e_score_correction_bias` buffer (one scalar per expert, not updated |
| 38 | + by gradient) shifts expert scores before group and expert selection. It is updated externally |
| 39 | + by a load-balancing controller. The final expert weights fed into the FFN use the unbiased |
| 40 | + sigmoid scores — the bias is selection-only. |
| 41 | + |
| 42 | +Without this router, none of the DeepSeek-V3 family checkpoints can be loaded correctly into d9d. |
| 43 | + |
| 44 | +## Design Proposal |
| 45 | + |
| 46 | +### New class: `SigmoidGroupedTopKRouter` |
| 47 | + |
| 48 | +```python |
| 49 | +class SigmoidGroupedTopKRouter(nn.Module, ModuleLateInit): |
| 50 | + def __init__( |
| 51 | + self, |
| 52 | + dim: int, |
| 53 | + num_experts: int, |
| 54 | + top_k: int, |
| 55 | + n_group: int, |
| 56 | + topk_group: int, |
| 57 | + routed_scaling_factor: float, |
| 58 | + norm_topk_prob: bool, |
| 59 | + ) -> None: ... |
| 60 | + |
| 61 | + def forward(self, hidden_states: torch.Tensor) -> RoutingResult: ... |
| 62 | + def reset_parameters(self) -> None: ... |
| 63 | +``` |
| 64 | + |
| 65 | +**Forward pass** (tokens shape `(T, dim)`): |
| 66 | + |
| 67 | +``` |
| 68 | +scores = sigmoid(gate(hidden_states)) # (T, E), float32 |
| 69 | +scores_sel = scores + e_score_correction_bias # (T, E) — for selection only |
| 70 | +group_score = scores_sel.view(T, G, E/G).topk(2, -1)[0].sum(-1) # (T, G) |
| 71 | +group_idx = topk(group_score, topk_group)[1] # (T, topk_group) |
| 72 | +expert_mask = scatter group_idx → (T, E) boolean mask |
| 73 | +masked = scores_sel.masked_fill(~expert_mask, -inf) |
| 74 | +indices = topk(masked, top_k)[1] # (T, top_k) |
| 75 | +weights = scores.gather(-1, indices) # unbiased weights |
| 76 | +if norm_topk_prob: weights /= weights.sum(-1, keepdim=True) + ε |
| 77 | +weights *= routed_scaling_factor |
| 78 | +return RoutingResult(indices, weights) |
| 79 | +``` |
| 80 | + |
| 81 | +The `e_score_correction_bias` is a `nn.Buffer(persistent=True)`, initialised to zero. It is not |
| 82 | +a gradient parameter; callers update it via an external load-balancing controller. |
| 83 | + |
| 84 | +### Change to `MoELayer` |
| 85 | + |
| 86 | +`MoELayer` is refactored to be fully router-agnostic. `router` becomes a required positional |
| 87 | +argument; `router_renormalize_probabilities` and `top_k` are removed — both were |
| 88 | +`TopKRouter`-specific concerns that had leaked into the wrong class. |
| 89 | + |
| 90 | +```python |
| 91 | +def __init__( |
| 92 | + self, |
| 93 | + hidden_dim: int, |
| 94 | + intermediate_dim_grouped: int, |
| 95 | + num_grouped_experts: int, |
| 96 | + router: nn.Module, |
| 97 | + shared_expert: SharedExpertParameters | None = None, |
| 98 | +): |
| 99 | + self.router = router # plain assignment, no branching |
| 100 | +``` |
| 101 | + |
| 102 | +All existing call sites are updated to construct their router explicitly before passing it. |
| 103 | +`reset_parameters` calls `self.router.reset_parameters()` and requires no changes. |
| 104 | + |
| 105 | +### Exports |
| 106 | + |
| 107 | +`SigmoidGroupedTopKRouter` is added to `d9d/module/block/moe/__init__.py` `__all__`. |
| 108 | + |
| 109 | +## Usage |
| 110 | + |
| 111 | +```python |
| 112 | +from d9d.module.block.moe import MoELayer, SigmoidGroupedTopKRouter, TopKRouter |
| 113 | +from d9d.module.block.moe.shared_expert import SharedExpertParameters |
| 114 | + |
| 115 | +# DeepSeek-V3 style |
| 116 | +moe = MoELayer( |
| 117 | + hidden_dim=7168, |
| 118 | + num_grouped_experts=256, |
| 119 | + intermediate_dim_grouped=2048, |
| 120 | + router=SigmoidGroupedTopKRouter( |
| 121 | + dim=7168, |
| 122 | + num_experts=256, |
| 123 | + top_k=8, |
| 124 | + n_group=8, |
| 125 | + topk_group=4, |
| 126 | + routed_scaling_factor=2.5, |
| 127 | + norm_topk_prob=True, |
| 128 | + ), |
| 129 | + shared_expert=SharedExpertParameters(intermediate_size=2048), |
| 130 | +) |
| 131 | + |
| 132 | +# Qwen3 style (unchanged semantics, explicit construction) |
| 133 | +moe = MoELayer( |
| 134 | + hidden_dim=4096, |
| 135 | + num_grouped_experts=64, |
| 136 | + intermediate_dim_grouped=1024, |
| 137 | + router=TopKRouter(dim=4096, num_experts=64, top_k=4, renormalize_probabilities=True), |
| 138 | +) |
| 139 | +``` |
| 140 | + |
| 141 | +## Backward Compatibility |
| 142 | + |
| 143 | +**Breaking change** to `MoELayer`: `router_renormalize_probabilities` and `top_k` are removed; |
| 144 | +`router` is now required. All in-tree call sites are updated in the same commit. There are no |
| 145 | +external callers in this codebase. |
| 146 | + |
| 147 | +## Alternatives Considered |
| 148 | + |
| 149 | +**Subclassing `TopKRouter`**: the two routers share only the `gate` linear and |
| 150 | +`RoutingResult` return type. A base class would add overhead without benefit. |
| 151 | + |
| 152 | +**`routing_type: Literal["softmax", "sigmoid_grouped"]` enum on `MoELayer`**: |
| 153 | +pushes router hyperparameters into `MoELayer`, coupling it to every future routing variant. |
| 154 | + |
| 155 | +**Optional `router=None` with internal fallback**: keeps the dead |
| 156 | +`router_renormalize_probabilities` / `top_k` params alive and adds a conditional. The required |
| 157 | +argument is cleaner and forces callers to be explicit about what router they are using. |
0 commit comments