File size: 49,314 Bytes
629b123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
# SPDX-FileCopyrightText: © 2026 Tenstorrent USA, Inc.
#
# SPDX-License-Identifier: Apache-2.0

"""TTMoEGate — the routing front-end that feeds ``TTMoEDecode``.

``TTMoEDecode.forward`` consumes a pre-computed routing decision per token
(``tt_scores``/``tt_indices`` of shape ``[batch, 1, 1, select_experts_k]`` — the
``all_to_all_dispatch_metadata`` op requires the token, index, and score tensors to
share their first three dims, and the token tensor is ``[batch, 1, 1, hidden]``); this
module PRODUCES it from the router logits:

    hidden ──[router matmul: ttnn.matmul(h, W_gate)]──▶ logits[1,1,batch,num_experts]
           ──[reshape→16×16 height-shard, one token/core]──▶ gate-op input
           ──[generalized_moe_gate: (sigmoid|softmax)+top-k+normalize]──▶ (scores,indices)[batch,32,32]
           ──[slice :k + view to the decode layout]──▶ (weights, indices)[batch,1,1,k]

The device flow mirrors ``models/demos/deepseek_v3/tt/moe_gate.py`` (decode path):
the gate op is single-token-per-core, so a variable ``batch`` is padded up to a
multiple of the core count and processed in ``batch_per_iter`` chunks, reusing the
bias / input-indices / output buffers. External tensors are plain L1-interleaved;
the height-sharded layout the op needs is produced inside ``forward``.

Gate-op selection (via ``n_group``):
  - ``n_group=1`` → ungrouped global top-k. With k ∈ {4,6,8} and ≤512 experts it uses the
    ``generalized_moe_gate`` kernel (configurable ``topk`` + sigmoid/softmax; softmax-no-bias models default
    to ``enable_sigmoid=False, output_softmax=True`` since softmax-over-selected ≡ softmax-front + renormalize.
    ``config.softmax_position="pre"`` instead applies a full softmax over all experts up front — mathematically
    equal absent a score-correction bias (the global Z cancels under renorm), a numerically-stable exp — and
    takes the linear-renorm tail like sqrtsoftplus). Any other k, OR >512 experts, takes a pure-ttnn fallback
    (``matmul → topk → softmax``) — so there is NO expert-count ceiling for n_group=1.
  - ``n_group=8`` → ``deepseek_moe_gate`` (grouped; sigmoid + bias). The kernel is HARDWIRED to 256 experts
    as 8 groups × 32, emitting top-8, so this path is EXACTLY 256-experts-select-8 (see ``__init__``).

Expert layout (kernel-op path only): ≤256 runs on one 256-face (fewer experts padded up with phantom
``_PAD_NEG`` experts that rank last); 257-512 uses the op's 2-block combine path (n_group=1 only).
"""

from __future__ import annotations

import torch

import ttnn
from models.common.modules.moe.tt_moe_gate_config import TTMoEGateConfig

_TOKEN_SHAPE = (16, 16)  # 256 experts laid out as a 16×16 face per token
_SHARD_SHAPE = (32, 32)  # one 32×32 tile per core (the op's per-token shard)
_FACE_EXPERTS = _TOKEN_SHAPE[0] * _TOKEN_SHAPE[1]  # 256: the op's fixed face size; <256 models pad up to it
# Phantom (padding) experts get this large-negative value in BOTH the logit and the bias. Selection ranks
# by `score + bias` (score = logit for softmax, sigmoid(logit) for sigmoid); driving the bias to -1e9 makes
# phantom experts rank last in EITHER mode (a -1e9 logit alone isn't enough for sigmoid, since sigmoid(-1e9)=0
# could still sit above a real expert with a negative bias). They never enter the top-k, so they never reach
# the output weights or the softmax exp.
_PAD_NEG = -1e9

# top-k values the generalized_moe_gate KERNEL op supports. Its finalize rank-mask keeps the right ranks
# ONLY for these (the op's C++ validation + unit tests cover exactly {4,6,8}): topk 1-3 leave ranks 0-3
# unmasked (wrong normalize) and 5/7 are untested. Any other k (n_group=1) takes the pure-ttnn fallback,
# which handles any k. Keep in sync with the op's TT_FATAL (generalized_moe_gate_device_operation.cpp).
_KERNEL_TOPK = (4, 6, 8)


class TTMoEGate:
    """Produce ``(weights, indices)`` for ``TTMoEDecode`` from hidden states."""

    def __init__(
        self,
        mesh_device,
        config: TTMoEGateConfig,
        torch_gate_weight: torch.Tensor,  # [hidden_size, num_experts] router projection
        # TWO distinct biases (both [num_experts], both optional):
        #   torch_gate_bias      = score-CORRECTION bias (deepseek/sigmoid): added INSIDE the op to the
        #                          scores for SELECTION ranking only; the output weights stay unbiased.
        #   torch_gate_proj_bias = router LINEAR bias (gpt-oss): added to the LOGITS (Wx + b) BEFORE the op,
        #                          so it flows into BOTH selection AND the softmax/normalized weights.
        torch_gate_bias: torch.Tensor | None = None,
        torch_gate_proj_bias: torch.Tensor | None = None,
    ):
        # Entry point mirrors TTMoEDecode: a config + the torch gate weight/bias(es) in; everything
        # device-side is built here. Unpack the config into the locals the rest of __init__ uses.
        self.config = config

        # The config DECLARES which bias tensors a model has (`score_correction_bias` = the selection-only
        # correction bias `torch_gate_bias`; `gate_proj_bias` = the router-LINEAR/projection bias `torch_gate_proj_bias`).
        # Nothing downstream re-checks: a missing bias is SILENTLY swapped for zeros (correction, line ~211)
        # or a bias-free matmul (router, forward), and a stray bias is used regardless of the flag — both
        # change routing while looking valid. Enforce the contract here so a miswired gate fails at
        # construction, not as a quietly-wrong route. (Validate BOTH directions: required AND forbidden.)
        if config.score_correction_bias and torch_gate_bias is None:
            raise ValueError("config.score_correction_bias=True requires torch_gate_bias, but none was given")
        if not config.score_correction_bias and torch_gate_bias is not None:
            raise ValueError("config.score_correction_bias=False forbids torch_gate_bias, but one was given")
        if config.gate_proj_bias and torch_gate_proj_bias is None:
            raise ValueError("config.gate_proj_bias=True requires torch_gate_proj_bias, but none was given")
        if not config.gate_proj_bias and torch_gate_proj_bias is not None:
            raise ValueError("config.gate_proj_bias=False forbids torch_gate_proj_bias, but one was given")
        # ...and the right SHAPE: both biases are 1-D [num_routed_experts] (the device upload reshapes to
        # [1, N] / F.pads the last dim assuming exactly that). A [1, N] / [N, 1] / wrong-length tensor would
        # mis-pad or mis-broadcast into the wrong experts silently, so reject anything but [num_experts].
        for _name, _tensor in (("torch_gate_bias", torch_gate_bias), ("torch_gate_proj_bias", torch_gate_proj_bias)):
            if _tensor is not None and tuple(_tensor.shape) != (config.num_routed_experts,):
                raise ValueError(
                    f"{_name} must be shape [{config.num_routed_experts}] (num_routed_experts); "
                    f"got {tuple(_tensor.shape)}"
                )

        num_experts = config.num_routed_experts
        select_experts_k = config.select_experts_k
        hidden_size = config.hidden_size
        n_group = config.n_group
        score_func = config.score_func
        scaling_factor = config.routed_scaling_factor
        eps = config.eps

        # `score_func` maps to (a) the score nonlinearity and (b) two op flags -- enable_sigmoid (apply
        # sigmoid inside the op) and output_softmax (softmax over the selected top-k vs linear renorm):
        #   "softmax"      → score=logit,             enable_sigmoid=False, output_softmax=True   (no bias)
        #   "sigmoid"      → score=sigmoid(logit),    enable_sigmoid=True,  output_softmax=False  (+bias)
        #   "sqrtsoftplus" → score=sqrt(softplus(l)), enable_sigmoid=False, output_softmax=False  (+bias)
        # sqrtsoftplus (deepseek-v4) has no in-kernel op, so it is applied EXTERNALLY in forward() via
        # ttnn.sqrt(ttnn.softplus(.)) and fed to the op with enable_sigmoid=False (the op then just adds
        # bias, ranks, and linearly renormalizes -- same tail as the sigmoid path).
        # `softmax_position` (softmax only) moves the softmax exp relative to the top-k. With NO score-
        # correction bias the two placements are mathematically equal (the global Z cancels under renorm),
        # differing only in how the exp is computed; they would DIVERGE with a selection bias, since ranking
        # by softmax(logit)+bias ≠ logit+bias (softmax is nonlinear) picks different experts — but softmax
        # models are bias-free, so this is moot in practice.
        #   "post" (default) → the mapping above (output_softmax=True: exp-over-selected).
        #   "pre"            → softmax over ALL experts is applied EXTERNALLY in forward() (same slot as
        #                      sqrtsoftplus) and fed to the op with enable_sigmoid=False, output_softmax=False
        #                      (LINEAR renorm over the selected) — so the exp is the stable full softmax,
        #                      not the kernel's exp-over-raw-logit. Same op flags/tail as sqrtsoftplus.
        if score_func not in ("softmax", "sigmoid", "sqrtsoftplus"):
            raise ValueError(f"score_func must be 'softmax' | 'sigmoid' | 'sqrtsoftplus'; got {score_func!r}")
        if score_func == "softmax" and config.softmax_position not in ("pre", "post"):
            raise ValueError(f"softmax_position must be 'pre' | 'post'; got {config.softmax_position!r}")
        # n_group picks the backing op: 1 = generalized/ungrouped (kernel op or ttnn fallback),
        # 8 = deepseek_moe_gate (grouped). Only these two are wired.
        if n_group not in (1, 8):
            raise NotImplementedError(
                f"n_group must be 1 (generalized/ungrouped) or 8 (deepseek grouped); got {n_group}"
            )
        if num_experts < 1:
            raise ValueError(f"num_experts must be ≥ 1; got {num_experts}")
        # n_group=8 → the deepseek grouped op. It is HARDWIRED to 256 experts laid out as 8 groups × 32,
        # emitting top-8 (top-2-sum per group → top-4 groups → top-8 of 128). It takes NO group/expert-count/k
        # argument — grouped_golden bakes in the top-2/top-4/top-8 constants, and the op unit test
        # (models/demos/deepseek_v3_b1/tests/unit_tests/test_deepseek_moe_gate.py) only ever feeds (batch,8,32).
        # So n_group=8 is EXACTLY 256-experts-select-8: a <256 grouped model has ≠32 experts/group (padding to
        # 256 would re-group it → wrong top-8) and k≠8 isn't emitted, so neither is supported. (No real grouped
        # model is <256 anyway — deepseek-v3 / ling-1t are both 256.)
        if n_group == 8 and not (num_experts == 256 and select_experts_k == 8):
            raise NotImplementedError(
                f"n_group=8 (deepseek grouped op) is hardwired to 256 experts select-8; got N={num_experts}, k={select_experts_k}"
            )
        # Ungrouped (n_group=1) dispatch — TWO backends, and NO expert-count ceiling (a large N is NOT rejected,
        # it just takes the fallback):
        #   • kernel op (forward):           k ∈ {4,6,8} AND N ≤ 512. The op works in 256-expert blocks (a 16×16
        #     face): ≤256 pads up to ONE block, 257-512 uses the op's 2-block COMBINE path. Phantom experts
        #     (num_experts..num_blocks*256) get a large-negative logit + bias (_PAD_NEG) so they rank last.
        #   • ttnn fallback (_forward_fallback): EVERYTHING else — k ∉ {4,6,8} (the kernel's rank-mask only
        #     covers 4/6/8, drop threshold = 16*(k-4)) OR N > 512 (beyond the 2-block combine). It is pure
        #     ttnn.matmul → topk → softmax, so it handles any k and any N (e.g. qwen3.5: 512-experts top-10).
        use_fallback = (n_group == 1) and (select_experts_k not in _KERNEL_TOPK or num_experts > 2 * _FACE_EXPERTS)
        # Defense-in-depth: the kernel op path (n_group=1 AND not fallback) must only ever see a supported
        # topk. use_fallback already routes every other k to the pure-ttnn path, so this can only fire if
        # that routing is later changed inconsistently — fail loudly here instead of feeding the kernel a
        # topk it silently mis-normalizes (e.g. topk 1-3 → first-4-ranks). The deepseek grouped op
        # (n_group=8) is a separate kernel hardwired to k=8 (guarded above), so it's exempt.
        if n_group == 1 and not use_fallback and select_experts_k not in _KERNEL_TOPK:
            raise ValueError(
                f"generalized_moe_gate kernel op supports topk {_KERNEL_TOPK}; got {select_experts_k} "
                "(this k should have taken the ttnn fallback)"
            )

        self.mesh_device = mesh_device
        self.num_experts = num_experts
        # op-path block count: 1 (≤256) or 2 (257-512). For the n_group=1 fallback (N>512) these are computed
        # but UNUSED — the fallback returns early below and runs ttnn.topk over the full unpadded logits.
        self.num_blocks = (num_experts + _FACE_EXPERTS - 1) // _FACE_EXPERTS
        self._padded_experts = self.num_blocks * _FACE_EXPERTS
        self.k = select_experts_k
        self.hidden_size = hidden_size
        self.n_group = n_group
        self.score_func = score_func
        self.scaling_factor = scaling_factor
        self.eps = eps
        self.softmax_position = config.softmax_position  # "post" (default) | "pre" — softmax only
        self.enable_sigmoid = score_func == "sigmoid"
        # output_softmax = the exp-over-selected tail (in-op / ttnn.softmax(sel)). ONLY softmax-post takes it;
        # softmax-pre, sigmoid, and sqrtsoftplus all use the LINEAR renorm tail.
        self.output_softmax = score_func == "softmax" and self.softmax_position == "post"
        # external score transform applied to the logits before the op (None → the op's own scoring):
        # sqrtsoftplus → sqrt∘softplus; softmax-pre → full softmax over ALL experts (then linear renorm).
        if score_func == "sqrtsoftplus":
            self.score_transform = "sqrtsoftplus"
        elif score_func == "softmax" and self.softmax_position == "pre":
            self.score_transform = "softmax"
        else:
            self.score_transform = None
        self.use_fallback = use_fallback  # ungrouped + k∉{4,6,8} → pure-ttnn path (no kernel op)

        grid = mesh_device.compute_with_storage_grid_size()
        self.num_device_cores = grid.x * grid.y
        self._grid = grid

        self.compute_kernel_config = self._matmul_compute_config(
            config.gate_matmul_compute, config.gate_matmul_high_fidelity
        )
        self.matmul_program_config = self._matmul_program_config(
            config.gate_matmul_program_config, config.gate_matmul_auto_program_config
        )

        # Op path: pad the router weight (+ linear bias) to the block size UP FRONT so the matmul directly
        # emits padded_experts-wide logits — this avoids a per-forward ttnn.pad (its dispatch + memory copy
        # dominated the gate latency). Phantom columns are zero (their logit = 0); the phantom score-correction
        # bias (_PAD_NEG in tt_bias) is what ranks them last, so the zero logit is harmless. (The fallback runs
        # ttnn.topk over the REAL experts, so it keeps the unpadded weight — padding would add phantom experts
        # to the topk.)
        if not self.use_fallback and num_experts < self._padded_experts:
            pad_cols = self._padded_experts - num_experts
            torch_gate_weight = torch.nn.functional.pad(torch_gate_weight, (0, pad_cols))  # [hidden, padded_experts]
            if torch_gate_proj_bias is not None:
                torch_gate_proj_bias = torch.nn.functional.pad(torch_gate_proj_bias, (0, pad_cols))  # [padded_experts]

        # --- router weight (replicated, DRAM) ---
        self.tt_gate_weight = ttnn.from_torch(
            torch_gate_weight,  # [hidden, num_experts] (op path: padded to [hidden, padded_experts])
            dtype=ttnn.bfloat16,
            layout=ttnn.TILE_LAYOUT,
            device=mesh_device,
            memory_config=ttnn.DRAM_MEMORY_CONFIG,
        )

        # --- router LINEAR bias (gpt-oss): logits = Wx + b, added by ttnn.linear in forward (None → matmul) ---
        self.tt_gate_proj_bias = None
        if torch_gate_proj_bias is not None:
            self.tt_gate_proj_bias = ttnn.from_torch(
                torch_gate_proj_bias.reshape(
                    1, -1
                ),  # [1, num_experts] (op path: [1, padded_experts]) — ttnn.linear bias
                dtype=ttnn.bfloat16,
                layout=ttnn.TILE_LAYOUT,
                device=mesh_device,
                memory_config=ttnn.DRAM_MEMORY_CONFIG,
            )

        # --- fallback path: ttnn.topk works on the full logits directly, so none of the op's face/combine/
        #     output buffers are needed. Build only the (optional) score-correction bias for selection
        #     ([1, num_experts] broadcast-add), then return. ---
        if self.use_fallback:
            self.tt_fallback_bias = None
            if torch_gate_bias is not None:
                self.tt_fallback_bias = ttnn.from_torch(
                    torch_gate_bias.reshape(1, num_experts).to(torch.float32),
                    dtype=ttnn.bfloat16,
                    layout=ttnn.TILE_LAYOUT,
                    device=mesh_device,
                    memory_config=ttnn.DRAM_MEMORY_CONFIG,
                )
            return

        # --- per-token buffers, sliced to batch_per_iter in forward. Sized to batch_per_device when the
        #     config provides it (decode: the per-forward slice is then a free FULL-tensor slice, zero cost),
        #     else to the device core count (the slice trims cores→batch_per_iter — the variable-batch
        #     fallback). The slice in forward is kept either way; it's just free in the sized-to-batch case. ---
        buffer_rows = config.batch_per_device if config.batch_per_device is not None else self.num_device_cores
        self._buffer_rows = buffer_rows
        mem = self._sharded_mem_config(buffer_rows)

        # bias + input-indices buffers. Real experts [0:num_experts] get torch_gate_bias (or 0 for no-bias —
        # a constant shift changes neither selection nor output, so zeros is the correct no-bias case);
        # phantom experts [num_experts:num_blocks*256] get _PAD_NEG so they always rank last (see _PAD_NEG).
        real_bias = (torch_gate_bias if torch_gate_bias is not None else torch.zeros(num_experts)).to(torch.float32)
        bias = torch.full((self._padded_experts,), _PAD_NEG, dtype=torch.float32)
        bias[:num_experts] = real_bias
        if self.num_blocks == 1:
            # single 256-face: (1,16,16) → pad to (1,32,32) → transpose → per core. indices = arange(256).
            bias_face = torch.nn.functional.pad(bias.reshape(1, 16, 16), (0, 16, 0, 16, 0, 0), "constant", 0).transpose(
                1, 2
            )  # (1, 32, 32)
            self.tt_bias = ttnn.from_torch(
                bias_face.repeat(buffer_rows, 1, 1),
                dtype=ttnn.bfloat16,
                layout=ttnn.TILE_LAYOUT,
                device=mesh_device,
                memory_config=mem,
            )
            idx = torch.arange(_FACE_EXPERTS, dtype=torch.int32).reshape(1, 16, 16).transpose(1, 2)
            self.tt_input_indices = ttnn.from_torch(
                idx.repeat(buffer_rows, 1, 1),
                dtype=ttnn.uint16,
                layout=ttnn.TILE_LAYOUT,
                device=mesh_device,
                memory_config=mem,
            )
        else:
            # combine path (num_blocks×256): num_blocks 16×16 faces stacked, each block transposed; indices
            # carry GLOBAL expert ids (block b = arange(256)+b*256). Shard (num_blocks*32, 32), tile (32,32).
            # Mirrors test_generalized_moe_gate.py::test_generalized_moe_gate_512_global.
            tile = ttnn.Tile((32, 32))
            mem_blocks = self._sharded_mem_config(buffer_rows, (self.num_blocks * 32, 32))
            bias_blocks = torch.transpose(bias.reshape(self.num_blocks, 16, 16), -2, -1).contiguous()  # (nb,16,16)
            self.tt_bias = ttnn.from_torch(
                bias_blocks.unsqueeze(0).repeat(buffer_rows, 1, 1, 1),  # (buffer_rows, nb, 16, 16)
                dtype=ttnn.bfloat16,
                layout=ttnn.TILE_LAYOUT,
                device=mesh_device,
                memory_config=mem_blocks,
                tile=tile,
            )
            ar = torch.arange(_FACE_EXPERTS, dtype=torch.int32).reshape(1, 16, 16)
            offs = (torch.arange(self.num_blocks, dtype=torch.int32) * _FACE_EXPERTS).reshape(self.num_blocks, 1, 1)
            idx_blocks = torch.transpose(ar + offs, -2, -1).contiguous().to(torch.int32)  # (nb, 16, 16) global ids
            self.tt_input_indices = ttnn.from_torch(
                idx_blocks.unsqueeze(0).repeat(buffer_rows, 1, 1, 1),
                dtype=ttnn.uint16,
                layout=ttnn.TILE_LAYOUT,
                device=mesh_device,
                memory_config=mem_blocks,
                tile=tile,
            )

        # preallocated output buffers (filled in place by the op), per core. ROW_MAJOR (like moe_gate.py)
        # so the final ttnn.view is metadata-only — a TILE buffer routes view through the device
        # reshape_view op, which rejects uint16.
        self.tt_output = ttnn.from_torch(
            torch.zeros((buffer_rows, *_SHARD_SHAPE), dtype=torch.float32),
            dtype=ttnn.bfloat16,
            layout=ttnn.ROW_MAJOR_LAYOUT,
            device=mesh_device,
            memory_config=mem,
        )
        self.tt_output_indices = ttnn.from_torch(
            torch.zeros((buffer_rows, *_SHARD_SHAPE), dtype=torch.int32),
            dtype=ttnn.uint16,
            layout=ttnn.ROW_MAJOR_LAYOUT,
            device=mesh_device,
            memory_config=mem,
        )

    # ------------------------------------------------------------------ helpers
    def _matmul_compute_config(self, cfg, high_fidelity):
        """Build the router-matmul ``compute_kernel_config``.

        The gate matmul is tiny but its reduction is ``hidden_size``-deep; with the ttnn DEFAULT matmul
        (LoFi + bf16 accumulate) the accumulated error flips near-tied experts at the top-k boundary
        (output weights stay ~right, but the selected expert *ids* drift). Whether a given gate actually
        has a near-tie there is data-dependent (not a clean function of ``hidden_size`` / ``k``), so rather
        than gate it per-model we DEFAULT every gate to HiFi2 + fp32 accumulation — the matmul is small, so
        the fidelity cost is negligible and it removes the drift uniformly. Built device-agnostically
        (``init_device_compute_kernel_config`` picks the right config class per arch), unlike a hardcoded
        ``WormholeComputeKernelConfig`` (wrong on Blackhole).

          - ``cfg`` dict → custom: keys ``math_fidelity`` ("HiFi2"|…), ``math_approx_mode``,
                           ``fp32_dest_acc_en``, ``packer_l1_acc`` override the high-fidelity defaults.
                           A dict (or prebuilt config) WINS over ``high_fidelity``.
          - else ``high_fidelity`` True (default) → built-in HiFi2 + fp32 accumulate + packer L1 acc.
          - else (``high_fidelity`` False)        → ttnn's default (low) matmul fidelity.
        """
        if cfg is not None and not isinstance(cfg, dict):
            return cfg  # already a ttnn compute_kernel_config → use as-is
        if cfg is None:
            if not high_fidelity:
                return None  # opt out → ttnn's default matmul fidelity
            cfg = {}  # built-in high-fidelity default (keys below)
        cfg = dict(cfg)
        fidelity = cfg.pop("math_fidelity", "HiFi2")
        if isinstance(fidelity, str):
            fidelity = getattr(ttnn.MathFidelity, fidelity)
        return ttnn.init_device_compute_kernel_config(
            self.mesh_device.arch(),
            math_fidelity=fidelity,
            math_approx_mode=cfg.pop("math_approx_mode", True),
            fp32_dest_acc_en=cfg.pop("fp32_dest_acc_en", True),
            packer_l1_acc=cfg.pop("packer_l1_acc", True),
        )

    def _matmul_program_config(self, cfg, auto):
        """Build the router-matmul ``program_config``.

        The gate matmul is tiny (``batch×hidden @ hidden×experts``) but ttnn's auto-selected program config
        is generic; a hand-tuned 2D-multicast config (kernel grid + block/subblock sizing) materially cuts
        its latency. The tuning is pure shape/grid math (no data dependence), so by DEFAULT we DERIVE it from
        the gate matmul shape + the device grid (see ``_derive_program_config``) rather than hand-listing it
        per model.

          - ``cfg`` dict → full override: key ``type`` names the ttnn program-config class (default
                           "MatmulMultiCoreReuseMultiCastProgramConfig"); the remaining keys are its kwargs
                           (e.g. ``in0_block_w``, ``per_core_M``, ``per_core_N``, ``out_subblock_*``).
                           ``compute_with_storage_grid_size`` is auto-filled when omitted (yaml stays
                           arch-agnostic). A dict (or prebuilt config) WINS over ``auto``.
          - else ``auto`` True (default) → the derived 2D-mcast config.
          - else (``auto`` False)        → ttnn auto-selects the program config.
        """
        if cfg is not None and not isinstance(cfg, dict):
            return cfg  # already a ttnn program config → use as-is
        if cfg is None:
            if not auto:
                return None  # let ttnn auto-select
            cfg = self._derive_program_config()
        cfg = dict(cfg)
        pc_class = getattr(ttnn, cfg.pop("type", "MatmulMultiCoreReuseMultiCastProgramConfig"))
        cfg.setdefault("compute_with_storage_grid_size", self.mesh_device.compute_with_storage_grid_size())
        return pc_class(**cfg)

    def _derive_program_config(self) -> dict:
        """Derive the 2D-mcast blocking for the gate matmul (``batch×hidden @ hidden×padded_experts``) from
        its shape + the device grid. Matches the previously hand-tuned per-model configs, except the
        256-expert gate now uses ``per_core_N=1`` instead of ``2`` (see the ``per_core_N`` bullet).

          ``M_tiles = ceil(batch/32)`` (=1 for the batch-32 decode gate); ``K_tiles = hidden/32``;
          ``N_tiles = padded_experts/32`` (256 experts → 8, the 512-combine path → 16).
            • ``in0_block_w`` = largest divisor of ``K_tiles`` that is ≤ 32 (the widest K-block within the
              32-tile cap; a non-1024-multiple hidden gives e.g. 20 / 22 / 30 instead of 32).
            • ``per_core_N`` = smallest divisor ≥ 1 of ``N_tiles`` that fits the grid
              (``N_tiles / per_core_N ≤ grid.x``) — i.e. spread N across as many cores as the grid holds.
              256 experts (``N_tiles=8``) on an 8-wide grid → ``1`` (8 N-cores); the 512-combine path
              (``N_tiles=16``) → ``2`` (16 tiles can't map to 16 cores, so 8). NOTE: ``per_core_N=1``
              measured faster than ``2`` for the tiny (``M_tiles=1``) 256 gate.
            • ``out_block_{h,w}`` = ``per_core_{M,N}``; ``out_subblock_{h,w}`` = largest factors with
              ``h * w ≤ dst_subblock_cap`` — the DEST subblock budget = HALF of the DEST tile capacity
              (MATH/PACK double-buffer): 8 tiles for bf16 accumulation, but **4 for fp32 accumulation**
              (an fp32 tile takes 2× the DEST space, so half as many fit). This matches ttnn's own
              ``get_subblock_sizes`` rule (``h*w ≤ 4`` iff ``fp32_dest_acc_en``). The gate DEFAULTS to fp32
              accumulation (see ``_matmul_compute_config``), so the cap is normally 4.
        """
        # DEST subblock tile budget: fp32 accumulation halves the usable DEST (8->4 tiles). Read the flag off
        # the already-built compute config (None = ttnn default = bf16 accumulate). Must match the cap ttnn's
        # own auto-derivation enforces, else a hand-built subblock could exceed the fp32 DEST budget.
        fp32_dest_acc_en = bool(getattr(self.compute_kernel_config, "fp32_dest_acc_en", False))
        dst_subblock_cap = 4 if fp32_dest_acc_en else 8
        batch = self.config.batch_per_device or 32
        m_tiles = (batch + 31) // 32
        k_tiles = self.hidden_size // 32
        n_tiles = self._padded_experts // 32
        grid_x = self._grid.x
        in0_block_w = max(d for d in range(1, 33) if k_tiles % d == 0)
        per_core_n = next((d for d in range(1, n_tiles + 1) if n_tiles % d == 0 and n_tiles // d <= grid_x), n_tiles)
        # out_subblock_w capped at dst_subblock_cap too: out_subblock_h can be 1, so w alone can equal the
        # h*w budget — without the cap a wide per_core_n (e.g. 8) would pick w=8,h=1 = 8 tiles, busting fp32's 4.
        out_subblock_w = next((w for w in range(min(per_core_n, dst_subblock_cap), 0, -1) if per_core_n % w == 0), 1)
        out_subblock_h = next(
            (h for h in range(m_tiles, 0, -1) if m_tiles % h == 0 and h * out_subblock_w <= dst_subblock_cap), 1
        )
        return {
            "in0_block_w": in0_block_w,
            "out_subblock_h": out_subblock_h,
            "out_subblock_w": out_subblock_w,
            "out_block_h": m_tiles,
            "out_block_w": per_core_n,
            "per_core_M": m_tiles,
            "per_core_N": per_core_n,
            "transpose_mcast": False,
            "fused_activation": None,
        }

    def _sharded_mem_config(self, num_cores: int, shard_shape: tuple = _SHARD_SHAPE) -> ttnn.MemoryConfig:
        # one token per core; shard_shape is (32,32) for a single 256-block, (num_blocks*32, 32) for the
        # combine path (num_blocks tiles stacked per core).
        core_grid = ttnn.num_cores_to_corerangeset(num_cores, ttnn.CoreCoord(self._grid.x, self._grid.y), row_wise=True)
        return ttnn.MemoryConfig(
            ttnn.TensorMemoryLayout.HEIGHT_SHARDED,
            ttnn.BufferType.L1,
            ttnn.ShardSpec(core_grid, shard_shape, ttnn.ShardOrientation.ROW_MAJOR),
        )

    # ------------------------------------------------------------------ device
    def forward(self, tt_x: ttnn.Tensor) -> tuple[ttnn.Tensor, ttnn.Tensor]:
        """``tt_x``: hidden states ``[1, 1, batch, hidden_size]`` (L1/DRAM interleaved) →
        ``(weights, indices)`` each ``[batch, 1, 1, select_experts_k]`` — the layout
        ``TTMoEDecode``/``all_to_all_dispatch_metadata`` consumes (batch in dim 0, sharing
        the token tensor's first three dims). The batch-in-dim-2 → batch-in-dim-0 move is a
        metadata-only view (ROW_MAJOR, identical memory order)."""
        if self.use_fallback:
            return self._forward_fallback(tt_x)
        # 1) router projection → logits [1, 1, batch, num_experts]. With a router LINEAR bias (gpt-oss) it's
        #    Wx + b via ttnn.linear; otherwise the proven bias-free matmul (unchanged for every other model).
        #    compute_kernel_config + program_config are per-model (see __init__): a deep, tie-sensitive gate
        #    wants HiFi2 + fp32, and a tuned 2D-mcast program_config can cut the matmul latency (else None).
        if self.tt_gate_proj_bias is None:
            logits = ttnn.matmul(
                tt_x,
                self.tt_gate_weight,
                compute_kernel_config=self.compute_kernel_config,
                program_config=self.matmul_program_config,
            )
        else:
            logits = ttnn.linear(
                tt_x,
                self.tt_gate_weight,
                bias=self.tt_gate_proj_bias,
                compute_kernel_config=self.compute_kernel_config,
                program_config=self.matmul_program_config,
            )

        # logits is already padded_experts-wide (the router weight was padded up front, see __init__) — no
        # per-forward ttnn.pad. Phantom columns are zero; the phantom bias (_PAD_NEG in tt_bias) ranks them
        # last regardless of their (bounded) score, so they never enter the top-k.

        # 1a) external score transform fed to the op with enable_sigmoid=False + the LINEAR renorm tail. Both
        #     transforms turn `logits` into the per-expert score; the op then adds bias, ranks, gathers, and
        #     linearly renormalizes over the selected. (Phantom cols are 0 → bounded score → ranked out by
        #     the _PAD_NEG bias.)
        #       • sqrtsoftplus (deepseek-v4): score = sqrt(softplus(logit)).
        #       • softmax-pre (softmax_position="pre"): score = softmax(logit) over ALL (padded) experts. The
        #         linear renorm over the selected cancels softmax's global Z, so this is mathematically equal
        #         to softmax-over-selected (softmax-post) absent a selection bias — but the exp is the stable
        #         full softmax, not the kernel's exp-over-raw-logit. (Phantom cols get a bounded softmax prob;
        #         Z cancels regardless.)
        if self.score_transform == "sqrtsoftplus":
            # standard softplus log(1+e^x): beta=1, threshold=20 (matches torch's defaults / the golden).
            logits = ttnn.sqrt(ttnn.softplus(logits, beta=1.0, threshold=20.0))
        elif self.score_transform == "softmax":
            logits = ttnn.softmax(logits, dim=-1)  # softmax over the full padded width; renorm tail cancels Z

        total_batch = logits.shape[2]

        # 2) the gate op is one-token-per-core: process in equal chunks of ≤ chunk tokens, reusing the
        #    bias/indices/output buffers. chunk = min(cores, buffer_rows): bounded by cores (one token/core)
        #    AND by the buffers' row count, so the per-iter slice always fits. When buffer_rows ==
        #    batch_per_device == this iter's batch (decode), the slice is a free full-tensor slice.
        chunk = min(self.num_device_cores, self._buffer_rows)
        num_iters = (total_batch + chunk - 1) // chunk
        padding = (num_iters - (total_batch % num_iters)) % num_iters
        batch_per_iter = (total_batch + padding) // num_iters
        if padding != 0:
            logits = ttnn.pad(logits, [(0, 0), (0, 0), (0, padding), (0, 0)], 0)

        # input/bias/indices live on (num_blocks*32, 32) shards (1 token/core, num_blocks tiles); the output
        # is always a single (32,32) tile per token (the combine writes the final global top-k into row 0).
        mem_in = self._sharded_mem_config(batch_per_iter, (self.num_blocks * 32, 32))
        mem_out = self._sharded_mem_config(batch_per_iter, _SHARD_SHAPE)
        if self.num_blocks == 1:
            reshaped_shape = (batch_per_iter, *_TOKEN_SHAPE)  # (bpi, 16, 16)
            bias = ttnn.slice(self.tt_bias, [0, 0, 0], [batch_per_iter, 16, 16], memory_config=mem_in)
            in_idx = ttnn.slice(self.tt_input_indices, [0, 0, 0], [batch_per_iter, 16, 16], memory_config=mem_in)
        else:
            reshaped_shape = (batch_per_iter, self.num_blocks, *_TOKEN_SHAPE)  # (bpi, nb, 16, 16)
            bias = ttnn.slice(
                self.tt_bias, [0, 0, 0, 0], [batch_per_iter, self.num_blocks, 16, 16], memory_config=mem_in
            )
            in_idx = ttnn.slice(
                self.tt_input_indices, [0, 0, 0, 0], [batch_per_iter, self.num_blocks, 16, 16], memory_config=mem_in
            )
        out = ttnn.slice(self.tt_output, [0, 0, 0], [batch_per_iter, 32, 32], memory_config=mem_out)
        out_idx = ttnn.slice(self.tt_output_indices, [0, 0, 0], [batch_per_iter, 32, 32], memory_config=mem_out)

        weights_chunks, indices_chunks = [], []
        for start in range(0, total_batch + padding, batch_per_iter):
            cur = logits[:, :, start : start + batch_per_iter, :]
            cur = ttnn.reshape(cur, reshaped_shape)  # (bpi,16,16) or (bpi,num_blocks,16,16) for combine
            cur = ttnn.to_memory_config(cur, memory_config=mem_in)  # height-shard: one token/core

            if self.n_group == 8:
                # deepseek grouped top-8 (sigmoid + bias); no top-k / output-softmax knobs. Call the C++ op
                # directly (ttnn namespace, not a demo package), symmetric with the generalized op below.
                w, idx = ttnn.experimental.deepseek.moe.deepseek_moe_gate(
                    cur,
                    bias_tensor=bias,
                    input_indices_tensor=in_idx,
                    output_tensor=out,
                    output_indices_tensor=out_idx,
                    eps=self.eps,
                    scaling_factor=self.scaling_factor,
                    enable_sigmoid=self.enable_sigmoid,
                )
            else:
                w, idx = ttnn.experimental.deepseek.moe.generalized_moe_gate(
                    cur,
                    bias_tensor=bias,
                    input_indices_tensor=in_idx,
                    output_tensor=out,
                    output_indices_tensor=out_idx,
                    eps=self.eps,
                    scaling_factor=self.scaling_factor,
                    enable_sigmoid=self.enable_sigmoid,
                    topk=self.k,
                    output_softmax=self.output_softmax,
                )
            weights_chunks.append(ttnn.to_memory_config(w, memory_config=ttnn.L1_MEMORY_CONFIG))
            indices_chunks.append(ttnn.to_memory_config(idx, memory_config=ttnn.L1_MEMORY_CONFIG))
            ttnn.deallocate(cur)
        ttnn.deallocate(logits)

        weights = weights_chunks[0] if num_iters == 1 else ttnn.concat(weights_chunks, dim=0)
        indices = indices_chunks[0] if num_iters == 1 else ttnn.concat(indices_chunks, dim=0)

        # 3) take the top-k (ranks 0..k-1 are the first k cols of row 0) → [batch,1,1,k], the layout
        # TTMoEDecode/all_to_all_dispatch_metadata wants (batch in dim 0). ROW_MAJOR output buffers make
        # these views metadata-only (no device reshape_view) — the slice is [total_batch, k] and dim 0 is
        # already the batch, so the view to [total_batch,1,1,k] is the same memory in the same order (the
        # uint16 indices reshape without a dtype round-trip — same as moe_gate.py).
        weights = ttnn.view(weights[:total_batch, 0, : self.k], (total_batch, 1, 1, self.k))
        indices = ttnn.view(indices[:total_batch, 0, : self.k], (total_batch, 1, 1, self.k))
        return weights, indices

    def _forward_fallback(self, tt_x: ttnn.Tensor) -> tuple[ttnn.Tensor, ttnn.Tensor]:
        """Pure-ttnn ungrouped gate for cases the kernel op can't do (k∉{4,6,8}, e.g. qwen3.5 top-10).
        Mirrors the op's logic with stock ttnn ops, no 256-face / combine / padding: matmul → score
        transform → (+ score-correction bias for selection) → topk → gather unbiased scores → normalize
        (softmax-over-selected if output_softmax, else linear renorm) at the END → × scale. Output
        ``(weights, indices)`` each ``[batch, 1, 1, k]`` (the decode/dispatch layout, like the op path),
        indices uint16."""
        # 1) logits = Wx (+ router LINEAR bias). compute_kernel_config per-model (HiFi2 for deep gates).
        if self.tt_gate_proj_bias is None:
            logits = ttnn.matmul(
                tt_x,
                self.tt_gate_weight,
                compute_kernel_config=self.compute_kernel_config,
                program_config=self.matmul_program_config,
            )
        else:
            logits = ttnn.linear(
                tt_x,
                self.tt_gate_weight,
                bias=self.tt_gate_proj_bias,
                compute_kernel_config=self.compute_kernel_config,
                program_config=self.matmul_program_config,
            )

        # 2) score = transform(logits): sqrt(softplus) | full softmax (softmax-pre) | sigmoid |
        #    identity (softmax-post / sigmoid-less, ranks by the raw logit). softmax-pre does the exp here via
        #    the stable full softmax over ALL experts; step 6 then LINEAR-renorms over the selected, which
        #    cancels Z → mathematically equal to softmax-post (absent a selection bias).
        if self.score_transform == "sqrtsoftplus":
            scores = ttnn.sqrt(ttnn.softplus(logits, beta=1.0, threshold=20.0))
        elif self.score_transform == "softmax":
            scores = ttnn.softmax(logits, dim=-1)
        elif self.enable_sigmoid:
            scores = ttnn.sigmoid(logits)
        else:
            scores = logits

        # 3) add the score-CORRECTION bias for SELECTION ranking (None → skip; qwen3.5 is bias-free).
        rank_key = scores if self.tt_fallback_bias is None else ttnn.add(scores, self.tt_fallback_bias)

        # 4) top-k over ALL experts → [1, 1, batch, k]. ttnn.topk handles any k / expert count, ungrouped.
        topk_vals, topk_idx = ttnn.topk(rank_key, self.k, dim=-1)

        # 5) weights = the UNBIASED scores at the selected experts. With a correction bias the topk values are
        #    the BIASED ranking key, so gather the unbiased scores; without one they're already the scores.
        sel = topk_vals if self.tt_fallback_bias is None else ttnn.gather(scores, dim=-1, index=topk_idx)

        # 6) normalize at the END (unified): softmax-over-selected (output_softmax) or linear renorm; × scale.
        if self.output_softmax:
            weights = ttnn.softmax(sel, dim=-1)
        else:
            weights = ttnn.div(sel, ttnn.sum(sel, dim=-1, keepdim=True))
        if self.scaling_factor != 1.0:
            weights = ttnn.multiply(weights, self.scaling_factor)

        indices = ttnn.typecast(topk_idx, ttnn.uint16)
        # reshape [1,1,batch,k] → [batch,1,1,k] to match the op path / the decode dispatch layout. These are
        # TILE-layout topk/softmax outputs, so a view can't move `batch` out of the tiled dims directly; go to
        # ROW_MAJOR first (which all_to_all_dispatch_metadata needs anyway), and then — as on the op path — the
        # view is metadata-only (identical byte order). batch lives in dim 2 here.
        batch = weights.shape[2]
        weights = ttnn.view(ttnn.to_layout(weights, ttnn.ROW_MAJOR_LAYOUT), (batch, 1, 1, self.k))
        indices = ttnn.view(ttnn.to_layout(indices, ttnn.ROW_MAJOR_LAYOUT), (batch, 1, 1, self.k))
        return weights, indices

    # ------------------------------------------------------------------ golden
    @staticmethod
    def golden(
        hidden: torch.Tensor,  # [batch, hidden]
        gate_weight: torch.Tensor,  # [hidden, num_experts]
        gate_bias: torch.Tensor | None,  # [num_experts] score-correction bias (selection-only) or None
        *,
        select_experts_k: int,
        score_func: str = "softmax",
        softmax_position: str = "post",  # softmax only: "post" = exp-over-selected; "pre" = softmax over all + renorm
        scaling_factor: float = 1.0,
        eps: float = 1e-20,
        n_group: int = 1,
        gate_proj_bias: torch.Tensor | None = None,  # [num_experts] router LINEAR bias (gpt-oss): logits = Wx + b
    ) -> tuple[torch.Tensor, torch.Tensor]:
        """PyTorch reference: hidden → logits → gate → (scores[batch,k], indices[batch,k]). n_group==8
        (grouped) delegates to ``TTMoEGate.grouped_golden`` (below); n_group==1 (ungrouped) is inlined below.
        Both are self-contained — no dependency on any demo package."""
        batch = hidden.shape[0]
        logits = hidden.float() @ gate_weight.float()  # [batch, num_experts]
        # router LINEAR bias (gpt-oss) is part of the projection: logits = Wx + b. It flows into BOTH
        # selection and the softmax weights (unlike the score-correction `gate_bias`, which is selection-only).
        if gate_proj_bias is not None:
            logits = logits + gate_proj_bias.float()
        bias = gate_bias.float() if gate_bias is not None else torch.zeros(logits.shape[-1])
        enable_sigmoid = score_func == "sigmoid"

        # external score transform (sqrtsoftplus, deepseek-v4): mirror forward() — apply sqrt∘softplus up
        # front and feed the op golden with enable_sigmoid=False, so it just adds bias / ranks / renorms.
        if score_func == "sqrtsoftplus":
            logits = torch.sqrt(torch.nn.functional.softplus(logits))

        if n_group == 8:
            # deepseek grouped top-8: 8 groups of 32 experts (top-2-sum per group → top-4 groups → top-8).
            # The golden's grouping is driven by shape[-2], so reshape to the LOGICAL (8, 32) — NOT the
            # (16, 16) tile face (that's only the device's physical layout; the kernel still groups the 256
            # experts as 8×32). Bias likewise reshapes to (8, 32) (the device uploads it transposed in the
            # 16×16 tile, but the golden works in logical expert order). See the op unit test:
            # models/demos/deepseek_v3_b1/tests/unit_tests/test_deepseek_moe_gate.py (input_shape=(b,8,32)).
            return TTMoEGate.grouped_golden(
                logits.reshape(batch, 8, 32),
                bias.reshape(8, 32),
                eps=eps,
                scaling_factor=scaling_factor,
                enable_sigmoid=enable_sigmoid,
            )
        # ungrouped global top-k (inlined — the generalized op is no longer a deepseek-v3 dependency):
        # rank by (score + bias), gather the UNBIASED score at the selected experts, normalize, scale.
        # score over ALL experts: sigmoid → sigmoid(logit); softmax-pre → softmax(logit) over all experts
        # (mirrors forward's external transform); softmax-post / sqrtsoftplus → the (already-transformed) logit.
        if enable_sigmoid:
            scores = torch.sigmoid(logits)  # [batch, num_experts]
        elif score_func == "softmax" and softmax_position == "pre":
            scores = torch.softmax(logits, dim=-1)
        else:
            scores = logits
        _, indices = torch.topk(scores + bias, select_experts_k, dim=-1, sorted=True)  # bias broadcasts [num_experts]
        sel = torch.gather(scores, -1, indices)
        # tail: softmax-POST is the only exp-over-selected; softmax-pre / sigmoid / sqrtsoftplus → linear renorm
        # (softmax-pre already did the exp up front, so its renorm cancels the global Z → mathematically equal
        # to softmax-post absent a selection bias).
        weights = torch.exp(sel) if (score_func == "softmax" and softmax_position == "post") else sel
        return weights / (weights.sum(-1, keepdim=True) + eps) * scaling_factor, indices

    @staticmethod
    def grouped_golden(
        input_tensor: torch.Tensor,  # [batch, n_group, group_size] per-expert scores/logits (e.g. [batch, 8, 32])
        bias_tensor: torch.Tensor,  # [n_group, group_size] or [batch, n_group, group_size] selection bias
        eps: float = 1e-20,
        scaling_factor: float = 2.5,
        enable_sigmoid: bool = False,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        """PyTorch reference for the DeepSeek GROUPED gate (n_group=8): 256 experts as 8 groups × 32 — per
        group take the top-2 bias-corrected scores and sum them, pick the top-4 groups by that sum, then the
        top-8 (by bias-corrected score) over those 4 groups (128 experts), gather the UNBIASED score at the
        chosen experts, linearly renormalize and scale. Returns (scores[batch, 8], global_indices[batch, 8]).
        Mirrors the device op ``ttnn.experimental.deepseek.moe.deepseek_moe_gate``; SINGLE source of truth,
        shared by ``TTMoEGate.golden`` (n_group=8) and the op unit test (test_generalized_moe_gate.py)."""
        row_offsets = torch.arange(input_tensor.shape[-2]) * input_tensor.shape[-1]  # group g -> base id g*32
        batch_idx = torch.arange(input_tensor.shape[0]).unsqueeze(-1)
        scores = torch.sigmoid(input_tensor) if enable_sigmoid else input_tensor
        bias_scores = scores + bias_tensor
        sorted_bias, sorted_indices = torch.sort(bias_scores, dim=-1, descending=True)
        sorted_scores = torch.gather(scores, dim=-1, index=sorted_indices)
        sorted_indices = sorted_indices + row_offsets.view(1, -1, 1)  # local -> global expert id
        top2_sum = sorted_bias[:, :, 0] + sorted_bias[:, :, 1]  # per-group top-2-sum
        _, sorted_top2_indices = torch.sort(top2_sum, dim=-1, descending=True)  # rank groups by it
        top4_values = sorted_bias[batch_idx, sorted_top2_indices[:, :4]].flatten(1)  # top-4 groups' bias scores
        top4_scores = sorted_scores[batch_idx, sorted_top2_indices[:, :4]].flatten(1)
        top4_indices = sorted_indices[batch_idx, sorted_top2_indices[:, :4]].flatten(1)
        _, top8_pos = torch.topk(top4_values, 8, dim=-1, sorted=True)  # top-8 of the 128 by bias score
        top8_scores = torch.gather(top4_scores, dim=-1, index=top8_pos)  # UNBIASED scores at the chosen experts
        top8_indices = torch.gather(top4_indices, dim=-1, index=top8_pos)
        return top8_scores / (torch.sum(top8_scores, dim=-1, keepdim=True) + eps) * scaling_factor, top8_indices