After studying this material, you should be able to:
Instead of every token passing through the same neural network, MoE routes each token to only a subset of specialized sub-networks called experts.
Token → Router → [Expert 3, Expert 7] → Weighted Sum → Output
(only 2 of 256 experts activated)
For each token x, the MoE layer computes:
MoE(x) = SharedExpert(x) + Σ(router_weight_i × RoutedExpert_i(x))
i ∈ top-k selected experts
Each routed expert runs a standard Feed-Forward Network (FFN):
Expert_i(x) = Down_i( SwiGLU( Up_i(x), Gate_i(x) ) )
Where:
| Symbol | Meaning | Example (Kimi K2.5) |
|---|---|---|
| d | Model dimension | 7168 |
| d_ff | Expert intermediate dimension | 2048 |
| top-k | Experts selected per token | 8 |
| N_experts | Total routed experts | 256 |
With 256 experts and 64 GPUs, each GPU holds only 4 experts. This is called Expert Parallelism (EP).
GPU 0: Experts 0-3
GPU 1: Experts 4-7
GPU 2: Experts 8-11
...
GPU 63: Experts 252-255
A token on GPU 0 might be assigned to Expert 7 (on GPU 1) and Expert 200 (on GPU 50). This means:
Step 1: DISPATCH → Send token from GPU 0 → GPU 1 and GPU 50
Step 2: COMPUTE → Run FFN on GPU 1 and GPU 50
Step 3: COMBINE → Send results back to GPU 0
Step 4: WEIGHTED SUM → Combine expert outputs
Running dispatch → compute → combine sequentially wastes time:
Timeline (naive):
|--Dispatch--|--Compute--|--Combine--|
↑ ↑
GPU idle GPU idle
waiting waiting
Communication can take as long as computation itself, so sequential execution wastes up to 50% of time.
Instead of waiting for ALL tokens to arrive before computing, process tokens in chunks (minibatches):
Timeline (pipelined):
Comms SMs: |--Dispatch chunk 1--|--Dispatch chunk 2--|--Combine chunk 1--|--Combine chunk 2--|
Comp SMs: |----FFN chunk 1----|----FFN chunk 2----|
This is called inter-SM overlapping: different groups of Streaming Multiprocessors (SMs) handle different tasks simultaneously.
Total SMs on GPU
├── Comms SMs (~1/3 of SMs) → Handle NVLink transfers (dispatch/combine)
└── Comp SMs (~2/3 of SMs) → Handle FFN computation (tensor cores)
Key insight: TMA (Tensor Memory Accelerator) loads can saturate NVLink bandwidth using fewer than 1/3 of SMs, leaving the majority free for computation.
This is one of MoK's most important innovations. There are two ways GPU A can transfer data to GPU B:
GPU A (has data) → actively WRITES → GPU B's memory
GPU B (needs data) → actively READS → from GPU A's memory
You might expect push to be faster (fewer bytes), but:
NVLink has SEPARATE lanes for each direction:
→ direction: 900 GB/s
← direction: 900 GB/s
Total: 1800 GB/s (only if BOTH directions are busy)
Push sends almost everything in one direction → other direction sits idle → wastes half the bandwidth
Pull splits traffic between both directions → both lanes stay busy → up to 29% higher bandwidth utilization under expert imbalance
| Method | Latency | Why |
|---|---|---|
| Push dispatch (cross-GPU signals) | 103 µs | Must wait for signals from up to 71 peers |
| Pull dispatch (local completion) | 18 µs | Load completes = data arrived, no coordination needed |
| Ratio | 5.8x slower | Signals accumulate with EP degree |
MoK cleverly mixes both approaches to get the best of each:
| Operation | Direction | Reason |
|---|---|---|
| Forward dispatch | Pull | Better bandwidth + no cross-GPU signaling |
| Forward combine | Push | Reuses same schedule (src↔dst swap) |
| Backward reverse-combine | Pull | Same benefits as forward dispatch |
| Backward reverse-dispatch | Push | Reuses same schedule |
Bonus: The schedule table is built once and reused for all 4 operations, taking less than 3% of total MoE runtime.
Too fine-grained (tiny chunks):
|D|C|D|C|D|C|D|C| ← Many small barriers, tensor cores never fully saturate
Too coarse-grained (huge chunks):
|----Dispatch all----|----Compute all----|----Combine all----| ← Long idle periods
Just right:
|--D--|--D--|--D--|
|--C--|--C--|--C--|
|--Cb-|--Cb-|--Cb-|
A wave = one round of concurrent execution across ALL SMs on the GPU.
Why 2 waves?
On Blackwell GPUs, each SM works on a 128×128 output tile for optimal tensor core utilization.
For up + gate projections (run in parallel):
tokens_per_minibatch × d_ff ≥ 2 × num_SMs × 128²
For down projection (runs alone):
tokens_per_minibatch × d_model ≥ 2 × num_SMs × 128²
Combined requirement:
tokens_per_minibatch ≥ 2 × num_SMs × 128² / min(d_model, d_ff)
d_model = 7168
d_ff = 2048 ← smaller, so this is the bottleneck
num_SMs = 132 (Blackwell GPU)
tokens_per_minibatch ≥ 2 × 132 × 128² / 2048
≥ 2 × 132 × 16384 / 2048
≥ 2 × 132 × 8
≥ 2112
This matches the benchmark data showing performance peaks around 2048-2560 tokens:
| Minibatch Size | Time (ms) |
|---|---|
| 512 | 5.981 |
| 1024 | 4.669 |
| 2048 | 3.666 |
| 2560 | 3.425 ← optimal |
| 3072 | 3.447 |
| 4096 | 3.473 |
The router decides at runtime how many tokens go to each GPU. You don't know in advance.
Existing solutions and their problems:
| Approach | Problem |
|---|---|
| Token dropping | Hurts training quality |
| CPU-GPU sync | CPU tells GPU buffer sizes → GPU must wait for slow CPU |
GB300 NVL72 architecture:
├── 72 × GPU (very fast)
└── 72 × Grace CPU (integrated, relatively slow)
Problem: GPU streams catch up to CPU work → GPU sits completely idle
waiting for CPU to push the next kernel launch
Instead of one large buffer (mostly empty), use a fixed-size circular ring buffer:
Ring Buffer (few hundred MB):
┌─────┬─────┬─────┬─────┬─────┬─────┐
│ S0 │ S1 │ S2 │ S3 │ S4 │ S5 │ ← Slots
└─────┴─────┴─────┴─────┴─────┴─────┘
↑ ↑
Write Read
head head
Key principle: Drain each slot as early as possible so it can be reused.
Without interleaving (bad):
Macrobatch 1: |--Dispatch all--|--Compute all--|--Combine all--|
Macrobatch 2: |--Dispatch--|...
↑ Long gap between macrobatches!
With interleaving (good):
Macrobatch 1 Dispatch: |--D1--|--D2--|--D3--|
Macrobatch 1 Compute: |--C1--|--C2--|--C3--|
Macrobatch 1 Combine: |--Cb1-|--Cb2-|--Cb3-|
Macrobatch 2 Dispatch: |--D1--|--D2--|--D3--| ← reuses same buffer slots!
Combine for macrobatch 1 and dispatch for macrobatch 2 use the same ring buffer slots, so the buffer is refilled as soon as it's emptied.
During backward, some activations saved in the ring buffer get overwritten and must be replayed (recomputed).
Naive order (ascending macrobatches):
Forward: MB1 → MB2 → MB3 → MB4
Ring: [MB4 overwrites MB1, MB2 data]
Backward: Must replay MB1, MB2 from scratch
Reversed ring (descending macrobatches):
Forward: MB4 → MB3 → MB2 → MB1
Ring: [MB1 is last written, ring is full or contains all tokens]
Backward: Minimal replay needed
Traditional GPU programming launches many separate kernels:
CPU launches: [Dispatch kernel] → [FFN kernel] → [Combine kernel] → ...
↑ overhead ↑ overhead ↑ overhead
A megakernel fuses everything into one persistent kernel:
CPU launches: [Single megakernel — runs entire MoE layer]
SMs internally coordinate via shared counters
During training, inter-rack communication (InfiniBand/RoCE for FSDP all-gather) must overlap with the megakernel.
Without CLC:
|----MoK megakernel----|----IB transfer----| ← serialized, slow
With CLC:
|----MoK megakernel----|
|--IB--| ← megakernel yields to higher-priority stream
CLC is a Blackwell hardware feature that allows the persistent megakernel to yield SMs to higher-priority streams without fully terminating.
MoK supports two precision modes:
| Mode | Speed | Notes |
|---|---|---|
| BF16 | Baseline | Standard |
| MXFP8 | Faster | Used for production training |
MXFP8 overhead: Tensors must be quantized before tensor cores can use them.
MoK minimizes this by:
Exception: Shared expert stays in BF16 for training stability.
MoK fixes the order of floating point operations so:
Same input → Bitwise-identical output
regardless of hardware scheduling
This matters for:
┌─────────────────────────────────────────────────────────────┐
│ MoK Megakernel │
│ │
│ Comms SMs: │
│ 1. Build schedule (pull-based, <3% of runtime) │
│ 2. Pull dispatch chunk 1 from remote GPUs │
│ 3. Signal comp SMs: "chunk 1 ready" │
│ 4. Pull dispatch chunk 2... │
│ 5. (After all dispatches) Push combine chunk 1 │
│ 6. Push combine chunk 2... │
│ │
│ Comp SMs: │
│ 1. Run shared expert FFN (overlaps with first dispatch) │
│ 2. Wait for signal: "chunk 1 ready" │
│ 3. Run FFN (Up+Gate → SwiGLU → Down) on chunk 1 │
│ 4. Signal comms SMs: "chunk 1 ready for combine" │
│ 5. Run FFN on chunk 2... │
│ │
│ Ring Buffer: Slots freed as combine drains them │
│ Macrobatch interleaving: Next macrobatch dispatches │
│ reuse freed slots immediately │
└─────────────────────────────────────────────────────────────┘
| Mode | MoK vs Best Baseline |
|---|---|
| MXFP8 Forward | 2.37x faster |
| MXFP8 Backward | 1.78x faster |
| BF16 Forward | 1.92x faster |
| BF16 Backward | 1.58x faster |
| Metric | DeepEP-based | MoK |
|---|---|---|
| Tokens/second/GPU | 760.9 | 1,070.2 |
| Speedup | — | 1.41x |
MoE Bottleneck
│
├── Problem: Communication as slow as computation
│ └── Solution: Overlap via inter-SM pipelining
│
├── Problem: Push vs Pull tradeoff
│ └── Solution: Hybrid (pull dispatch, push combine)
│ ├── Better bandwidth utilization (29% higher)
│ └── 5.8x lower signaling latency
│
├── Problem: Minibatch size affects GPU utilization
│ └── Solution: Formula-based sizing (≥2 waves per GEMM)
│
├── Problem: Dynamic token counts require CPU sync
│ └── Solution: Ring buffer (macrobatch)
│ └── Dispatch-combine interleaving keeps buffer flowing
│
├── Problem: Multiple kernel launches = overhead
│ └── Solution: Megakernel with CLC for RDMA overlap
│
└── Result: 1.41x end-to-end speedup, 2.37x MoE layer speedup