Skip to content

Commit abccc06

Browse files
committed
add sigmoid router
1 parent 431d078 commit abccc06

10 files changed

Lines changed: 582 additions & 30 deletions

File tree

d9d/module/block/moe/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from .grouped_experts import GroupedSwiGLU
44
from .grouped_linear import GroupedLinear
55
from .layer import MoELayer
6-
from .router import RoutingResult, TopKRouter
6+
from .router import RoutingResult, SigmoidGroupedTopKRouter, TopKRouter
77
from .shared_expert import SharedExpertParameters, SharedSwiGLU
88

99
__all__ = [
@@ -13,5 +13,6 @@
1313
"RoutingResult",
1414
"SharedExpertParameters",
1515
"SharedSwiGLU",
16+
"SigmoidGroupedTopKRouter",
1617
"TopKRouter",
1718
]

d9d/module/block/moe/layer.py

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import cast
2+
13
import torch
24
from torch import nn
35
from torch.distributed import ProcessGroup
@@ -9,7 +11,7 @@
911
NoCommunicationHandler,
1012
)
1113
from .grouped_experts import GroupedSwiGLU
12-
from .router import RoutingResult, TopKRouter
14+
from .router import RoutingResult
1315
from .shared_expert import SharedExpertParameters, SharedSwiGLU
1416

1517

@@ -29,29 +31,23 @@ def __init__(
2931
hidden_dim: int,
3032
intermediate_dim_grouped: int,
3133
num_grouped_experts: int,
32-
top_k: int,
33-
router_renormalize_probabilities: bool,
34+
router: nn.Module,
3435
shared_expert: SharedExpertParameters | None = None,
3536
):
3637
"""
37-
Constructs the MoELayer.
38+
Constructs the MoELayer.
3839
3940
Args:
4041
hidden_dim: Hidden size.
4142
intermediate_dim_grouped: Intermediate dimension for the Expert FFNs.
4243
num_grouped_experts: Total number of experts.
43-
top_k: Number of experts to route each token to.
44-
router_renormalize_probabilities: Configures router probability normalization behavior.
44+
router: Router module. Must accept ``(num_tokens, hidden_dim)`` and return a
45+
``RoutingResult``. Use ``TopKRouter`` or ``SigmoidGroupedTopKRouter``.
4546
shared_expert: Optional configuration for a shared expert.
4647
"""
4748

4849
super().__init__()
49-
self.router = TopKRouter(
50-
dim=hidden_dim,
51-
num_experts=num_grouped_experts,
52-
top_k=top_k,
53-
renormalize_probabilities=router_renormalize_probabilities,
54-
)
50+
self.router = router
5551
self.grouped_experts = GroupedSwiGLU(
5652
hidden_dim=hidden_dim, intermediate_dim=intermediate_dim_grouped, num_experts=num_grouped_experts
5753
)
@@ -77,11 +73,11 @@ def enable_distributed_communicator(self, group: ProcessGroup):
7773
Args:
7874
group: The PyTorch process group spanning the expert parallel ranks.
7975
"""
80-
# Lazy load the handler to prevent early DeepEP bindings/evaluation
8176
from .communications.deepep import DeepEpCommunicationHandler # noqa: PLC0415
8277

8378
communicator = DeepEpCommunicationHandler(num_experts=self._num_grouped_experts)
84-
communicator.setup(group, self._hidden_dim, self.router.gate.weight.dtype)
79+
gate = cast(nn.Linear, self.router.gate)
80+
communicator.setup(group, self._hidden_dim, gate.weight.dtype)
8581
self._communicator = communicator
8682

8783
@torch.no_grad()
@@ -133,7 +129,7 @@ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
133129

134130
def reset_parameters(self):
135131
"""Resets module parameters."""
136-
self.router.reset_parameters()
132+
cast(ModuleLateInit, self.router).reset_parameters()
137133
self.grouped_experts.reset_parameters()
138134
if self.shared_expert is not None:
139135
self.shared_expert.reset_parameters()

d9d/module/block/moe/router.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import torch
44
import torch.nn.functional as F
55
from torch import nn
6+
from torch.distributed.tensor import DTensor
67

78
from d9d.module.base import ModuleLateInit
89

@@ -105,3 +106,118 @@ def reset_parameters(self) -> None:
105106
nn.init.zeros_(self.expert_bias)
106107

107108
self.gate.reset_parameters()
109+
110+
111+
class SigmoidGroupedTopKRouter(nn.Module, ModuleLateInit):
112+
"""
113+
Selects top-K experts using sigmoid-activated scores with hierarchical group selection.
114+
115+
Used by the DeepSeek-V3 family (DSv3, GLM-4.6, Kimi K2/K2.6, MiniMax-M2, Mistral Large/Small 4).
116+
117+
Selection procedure per token:
118+
1. Gate logits → sigmoid → raw per-expert probabilities (float32).
119+
2. Add ``e_score_correction_bias`` to get selection scores (bias is not reflected in
120+
the final expert weights — it is selection-only for load-balancing purposes).
121+
3. Divide experts into ``n_group`` equal groups; compute each group's score as the sum
122+
of its top-2 expert selection scores.
123+
4. Keep the ``topk_group`` highest-scoring groups; mask out all other experts.
124+
5. From the surviving experts run a final top-``top_k`` selection.
125+
6. Gather weights from the unbiased sigmoid scores (step 1), not the biased scores.
126+
7. Optionally renormalise to sum=1, then multiply by ``routed_scaling_factor``.
127+
"""
128+
129+
def __init__(
130+
self,
131+
dim: int,
132+
num_experts: int,
133+
top_k: int,
134+
n_group: int,
135+
topk_group: int,
136+
routed_scaling_factor: float,
137+
norm_topk_prob: bool,
138+
) -> None:
139+
"""
140+
Constructs the SigmoidGroupedTopKRouter.
141+
142+
Args:
143+
dim: Input feature dimensionality.
144+
num_experts: Total number of routed experts. Must be divisible by ``n_group``.
145+
top_k: Number of experts to select per token.
146+
n_group: Number of expert groups. Experts are partitioned into contiguous groups
147+
of size ``num_experts // n_group``.
148+
topk_group: Number of groups whose experts are candidates for final top-k.
149+
routed_scaling_factor: Scalar multiplied onto the final expert weights.
150+
norm_topk_prob: If True, renormalise selected weights to sum to 1 before scaling.
151+
"""
152+
super().__init__()
153+
if num_experts % n_group != 0:
154+
raise ValueError(f"num_experts ({num_experts}) must be divisible by n_group ({n_group})")
155+
156+
self.gate = nn.Linear(dim, num_experts, bias=False)
157+
self.e_score_correction_bias = nn.Buffer(
158+
torch.zeros(num_experts, dtype=torch.float32),
159+
persistent=True,
160+
)
161+
162+
self._num_experts = num_experts
163+
self._top_k = top_k
164+
self._n_group = n_group
165+
self._topk_group = topk_group
166+
self._experts_per_group = num_experts // n_group
167+
self._routed_scaling_factor = routed_scaling_factor
168+
self._norm_topk_prob = norm_topk_prob
169+
170+
def forward(self, hidden_states: torch.Tensor) -> RoutingResult:
171+
"""
172+
Calculates routing decisions for the input tokens.
173+
174+
Args:
175+
hidden_states: Input tokens. Shape: ``(num_tokens, dim)``.
176+
177+
Returns:
178+
The routing result with selected expert indices and their (possibly scaled) weights.
179+
"""
180+
num_tokens = hidden_states.shape[0]
181+
182+
scores = F.linear(hidden_states.float(), self.gate.weight.float()).sigmoid()
183+
184+
bias = self.e_score_correction_bias
185+
if isinstance(bias, DTensor):
186+
bias = bias.to_local()
187+
scores_for_sel = scores + bias
188+
189+
# Group scoring: sum of top-2 expert scores within each group
190+
grouped = scores_for_sel.view(num_tokens, self._n_group, self._experts_per_group)
191+
group_scores = grouped.topk(min(2, self._experts_per_group), dim=-1)[0].sum(dim=-1)
192+
193+
# Select topk_group groups
194+
group_idx = torch.topk(group_scores, k=self._topk_group, dim=-1, sorted=False)[1]
195+
196+
# Build expert-level boolean mask from selected groups
197+
group_mask = torch.zeros_like(group_scores)
198+
group_mask.scatter_(1, group_idx, 1.0)
199+
expert_mask = (
200+
group_mask.unsqueeze(-1)
201+
.expand(-1, -1, self._experts_per_group)
202+
.reshape(num_tokens, self._num_experts)
203+
.bool()
204+
)
205+
206+
# Final top-k within surviving experts
207+
masked_scores = scores_for_sel.masked_fill(~expert_mask, float("-inf"))
208+
selected_indices = torch.topk(masked_scores, k=self._top_k, dim=-1, sorted=False)[1]
209+
210+
# Weights from unbiased scores (bias must not affect the FFN computation)
211+
selected_weights = scores.gather(dim=-1, index=selected_indices)
212+
213+
if self._norm_topk_prob:
214+
selected_weights = selected_weights / (selected_weights.sum(dim=-1, keepdim=True) + 1e-20)
215+
216+
selected_weights = selected_weights * self._routed_scaling_factor
217+
218+
return RoutingResult(selected_expert_indices=selected_indices, selected_probabilities=selected_weights)
219+
220+
def reset_parameters(self) -> None:
221+
"""Resets module parameters."""
222+
self.gate.reset_parameters()
223+
nn.init.zeros_(self.e_score_correction_bias)

d9d/module/model/qwen3_moe/decoder_layer.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from d9d.module.base import ModuleLateInit
55
from d9d.module.block.attention import GroupedQueryAttention
6-
from d9d.module.block.moe import MoELayer
6+
from d9d.module.block.moe import MoELayer, TopKRouter
77
from d9d.module.block.normalization import RMSNorm
88
from d9d.module.block.positional import RotaryEmbeddingStyle
99

@@ -42,8 +42,12 @@ def __init__(self, params: Qwen3MoELayerParameters):
4242
hidden_dim=params.hidden_size,
4343
num_grouped_experts=params.num_experts,
4444
intermediate_dim_grouped=params.intermediate_size,
45-
top_k=params.experts_top_k,
46-
router_renormalize_probabilities=True,
45+
router=TopKRouter(
46+
dim=params.hidden_size,
47+
num_experts=params.num_experts,
48+
top_k=params.experts_top_k,
49+
renormalize_probabilities=True,
50+
),
4751
)
4852

4953
self.input_layernorm = RMSNorm(params.hidden_size, eps=params.rms_norm_eps)
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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

Comments
 (0)