Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ See [models.md](models.md#moe-strategies) for what each strategy does.
| `--moe-cpu-threads` | physical cores | CPU worker threads for the cpu/hybrid executor |
| `--moe-cpu-layers` | all on GPU | With `offload`: which MoE layers decode on CPU (`3,7,11`, a count, a fraction, or `auto`). `auto` is for Windows/WSL only, where CUDA pinned memory is capped; every value needs an expert format the CPU executor serves (bf16, fp8_block, nvfp4, mxfp4) |
| `--moe-hybrid-max-fetch` | auto | With `hybrid`: max experts fetched over PCIe per layer per step; rest computed on CPU |
| `--moe-prefill-demand-tokens` | 0 | Prefill: a chunk of at most this many tokens loads only the experts it routes to, through the expert cache as decode does (GPU decode only); 0 streams whole layers |
| `--moe-prefill-hit-d2d` | off | Prefill: copy cache-hit experts device-side, stream only misses (CUDA >= 13) |
| `--disable-moe-prefill-overlap` | overlap on | Disable the two-buffer prefill copy overlap |

Expand Down
1 change: 1 addition & 0 deletions python/freetoken/engine/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class EngineConfig:
moe_cache_auto: bool = False
kv_reserve_tokens: int = 8192 # KV floor for --moe-cache-auto; small by design (MoE-priority)
moe_cache_policy: str = "lru"
moe_prefill_demand_tokens: int = 0
moe_prefill_overlap: bool = True
# Prefill hit/miss split: serve cache-resident experts D2D during prefill
# prefetch instead of re-streaming the full layer over PCIe. Needs CUDA >= 12.8
Expand Down
2 changes: 2 additions & 0 deletions python/freetoken/engine/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,7 @@ def _init_offload_moe_cache(self, config: EngineConfig) -> OffloadMoeCache:
cache_size=config.moe_cache_size,
device=self.device,
cache_policy=config.moe_cache_policy,
prefill_demand_tokens=config.moe_prefill_demand_tokens,
prefill_overlap=config.moe_prefill_overlap,
prefill_hit_d2d=config.moe_prefill_hit_d2d,
quant_format=banks.quant_format,
Expand Down Expand Up @@ -1459,6 +1460,7 @@ def _auto_cpu_layers(config: EngineConfig, num_moe_layers: int, *, reserved: int
"moe_cpu_layers": None,
"moe_cpu_threads": 0,
"moe_hybrid_max_fetch": -1,
"moe_prefill_demand_tokens": 0,
"moe_prefill_overlap": True,
"moe_prefill_hit_d2d": False,
"expert_load": "auto",
Expand Down
26 changes: 20 additions & 6 deletions python/freetoken/layers/moe.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,9 @@ def prefill_forward(
# ------------------------------------------------------------------
# Data movement -- one decision tree for every quant format (the banks
# registry makes the cache machinery bank-count agnostic). Decode loads
# on demand; prefill streams whole layers, double-buffered when overlap
# is enabled. The kernels only ever see bank views plus row indices;
# on demand, and so does a short enough prefill chunk; a longer one
# streams whole layers, double-buffered when overlap is enabled. The
# kernels only ever see bank views plus row indices;
# which kernel runs is decided afterwards, in ``_expert_gemm``.
# ------------------------------------------------------------------

Expand Down Expand Up @@ -355,12 +356,25 @@ def _prefill_routed(
topk_weights: torch.Tensor,
topk_ids: torch.Tensor,
) -> torch.Tensor:
"""Prefill movement: stream whole layers -- double-buffered behind the
previous layer's GEMMs when ``prefill_overlap`` is on, else a synchronous
``materialize_layer``. In both, position == expert id, so the routing ids
pass through unmapped."""
"""Prefill movement. A chunk within ``prefill_demand_tokens`` loads its routed experts
into the slot cache as decode does and runs the same grouped GEMM over the slots. A
longer one streams whole layers -- double-buffered behind the previous layer's GEMMs
when ``prefill_overlap`` is on, else a synchronous ``materialize_layer`` -- where
position == expert id, so the routing ids pass through unmapped."""
cache = self.offload_cache
assert cache is not None
if cache.loads_prefill_on_demand(hidden_states.shape[0]):
slots = cache.load_prefill_experts(self.layer_id, topk_ids)
return self._expert_gemm(
cache,
hidden_states,
topk_weights,
slots,
views=cache.bank_views(),
n=cache.cache_size,
alphas=cache.alphas_for_slots(self.layer_id),
is_prefill=True,
)
if cache.prefill_overlap:
views = self._wait_prefill_overlap(cache)
out = self._expert_gemm(
Expand Down
7 changes: 6 additions & 1 deletion python/freetoken/moe/fused.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ def fused_topk(
return fused_topk_softmax(gating_output, topk, renormalize, num_token_non_padded)


# sgl_kernel's moe_align_block_size counts experts in one 1024-thread block and reads out of
# bounds past it; a prefill over the offload slot cache aligns against the slot count.
_SGL_ALIGN_MAX_EXPERTS = 1024


def moe_align_block_size(
topk_ids: torch.Tensor, block_size: int, num_experts: int
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
Expand Down Expand Up @@ -86,7 +91,7 @@ def moe_align_block_size(
"""
from freetoken.kernel.backend import is_sgl_kernel_installed

if not is_sgl_kernel_installed():
if not is_sgl_kernel_installed() or num_experts + 1 > _SGL_ALIGN_MAX_EXPERTS:
from freetoken.kernel.triton.moe_align import (
moe_align_block_size as triton_moe_align_block_size,
)
Expand Down
33 changes: 33 additions & 0 deletions python/freetoken/moe/offload_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ class OffloadMoeCache:
cache_size: int
device: torch.device
cache_policy: str = "lru"
# A prefill chunk of at most this many tokens admits the experts it routes to into the slot
# cache, as decode does, instead of streaming every expert of each layer; 0 streams them all.
prefill_demand_tokens: int = 0
prefill_overlap: bool = False
# Prefill hit/miss split: experts already resident in the slot cache (slots
# >= 2 * num_experts) are gathered device-side into the double buffer instead
Expand Down Expand Up @@ -150,6 +153,10 @@ def __post_init__(self) -> None:
policy_ids = {"lru": 0}
assert self.cache_policy in policy_ids
assert self.decode_target in ("gpu", "cpu", "hybrid"), self.decode_target
if self.prefill_demand_tokens and self.decode_target != "gpu":
raise ValueError(
f"--moe-prefill-demand-tokens needs GPU decode; this cache decodes on {self.decode_target!r}"
)
if self.layout is None:
assert self.quant_format in _BANK_SCHEMAS, f"unknown quant_format {self.quant_format!r}"
# Attached by the engine for decode_target == "cpu" (CpuMoeExecutor); None
Expand Down Expand Up @@ -843,6 +850,32 @@ def release_prefill_layer(self, layer_id: int) -> None:
self._prefill_buffer_has_release_event[buffer_id] = True
self._prefill_buffer_released[buffer_id] = True

def loads_prefill_on_demand(self, num_tokens: int) -> bool:
return 0 < num_tokens <= self.prefill_demand_tokens

def load_prefill_experts(self, layer_id: int, topk_ids: torch.Tensor) -> torch.Tensor:
"""Make a demand prefill chunk's routed experts resident, copying in the missing ones,
and return ``topk_ids`` as slot ids. The chunk's distinct experts go to the slot cache
``ENSURE_QUERY_WIDTH`` at a time, each query's misses copied before the next query
evicts: LRU evicts an earlier query's slots last, and a layer never routes to more
experts than the cache holds. The admissions stay out of the decode statistics."""
from freetoken.moe.offload_kernels import ENSURE_QUERY_WIDTH, ensure_experts

experts = torch.arange(self.num_experts, dtype=torch.int32, device=self.device)
routed = torch.zeros(self.num_experts, dtype=torch.bool, device=self.device)
routed.index_fill_(0, topk_ids.reshape(-1).long(), True)
# the routed experts in ascending order, then the first of them again, which the admission
# dedups; a chunk's routes reach at most as many experts as there are routes
ordered = torch.where(routed, experts, experts + self.num_experts).sort().values
ordered = ordered[: min(self.num_experts, topk_ids.numel())]
query = torch.where(ordered < self.num_experts, ordered, ordered[0])
self._pending_src_layer = layer_id
self._pending_whole_layer = False
for piece in query.split(ENSURE_QUERY_WIDTH):
ensure_experts(self, layer_id, piece, record_stats=False)
self.copy_missing()
return self.slot_for_id[layer_id][topk_ids.long()]

def ensure_experts(self, layer_id: int, expert_ids: torch.Tensor) -> None:
from freetoken.moe.offload_kernels import ensure_experts

Expand Down
8 changes: 6 additions & 2 deletions python/freetoken/moe/offload_kernels.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@
os.getenv("FREETOKEN_HYBRID_FETCH", "recency").strip().lower() != "lowest_id"
)

# lru_ensure dedups its query in a [K, K] register block: flat in cost up to 128 ids, spilling past
# that and failing to compile past 1024, so a wider admission goes to it in queries this wide.
ENSURE_QUERY_WIDTH = 128

def ensure_experts(cache, layer_id: int, expert_ids: torch.Tensor) -> None:

def ensure_experts(cache, layer_id: int, expert_ids: torch.Tensor, *, record_stats: bool = True) -> None:
"""Make this layer's routed experts resident; rewrite ``expert_ids`` to slot ids.

Delegates to flashlib's slot cache. ``id_base`` maps this layer's expert ids into the
Expand All @@ -35,7 +39,7 @@ def ensure_experts(cache, layer_id: int, expert_ids: torch.Tensor) -> None:
cache.src_indices,
cache.evict_slots,
cache.num_indices,
stats=cache.lru_stats[layer_id] if cache.collect_stats else None,
stats=cache.lru_stats[layer_id] if cache.collect_stats and record_stats else None,
id_base=layer_id * cache.num_experts,
)

Expand Down
11 changes: 11 additions & 0 deletions python/freetoken/server/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,17 @@ def _infer_reasoning_parser(model_path: str) -> str | None:
),
)

parser.add_argument(
"--moe-prefill-demand-tokens",
type=int,
default=ServerArgs.moe_prefill_demand_tokens,
help=(
"A prefill chunk of at most this many tokens loads only the experts it routes to, "
"through the expert cache as decode does, instead of every expert of each layer. "
"0 streams whole layers for every chunk."
),
)

parser.add_argument(
"--enable-special-token-ckpt",
action="store_true",
Expand Down
44 changes: 44 additions & 0 deletions tests/moe/test_fused_moe.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,47 @@ def test_fused_topk_softmax_is_cuda_graph_capturable():
torch.cuda.synchronize()
assert (ids[:3] != -1).all()
assert (ids[3:] == -1).all()


def test_moe_align_leaves_sgl_kernel_past_its_one_block_of_experts(monkeypatch):
"""sgl_kernel's align counts experts in one 1024-thread block, and every call hands it a
sentinel expert past the real ones: 1023 experts fit, 1024 align on Triton."""
import sys
import types

from freetoken.kernel import backend
from freetoken.kernel.triton import moe_align
from freetoken.moe.fused import moe_align_block_size

calls = []
sgl_kernel = types.ModuleType("sgl_kernel")
sgl_kernel.moe_align_block_size = lambda topk_ids, num_experts, *_: calls.append(("sgl_kernel", num_experts))
monkeypatch.setitem(sys.modules, "sgl_kernel", sgl_kernel)
monkeypatch.setattr(backend, "is_sgl_kernel_installed", lambda: True)
monkeypatch.setattr(
moe_align, "moe_align_block_size",
lambda topk_ids, block_size, num_experts: calls.append(("triton", num_experts)),
)

for num_experts in (1023, 1024, 2100):
moe_align_block_size(torch.zeros((4, 2), dtype=torch.int32), 16, num_experts)

assert calls == [("sgl_kernel", 1024), ("triton", 1024), ("triton", 2100)]


@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required")
@pytest.mark.parametrize("num_experts", [1023, 2100])
def test_moe_align_sorts_every_route_into_its_expert_blocks_past_1024_experts(num_experts):
"""A prefill over the offload slot cache aligns against its slot count."""
from freetoken.moe.fused import moe_align_block_size

torch.manual_seed(0)
ids = torch.randint(0, num_experts, (300, 10), device="cuda", dtype=torch.int32)
sorted_ids, expert_ids, padded = moe_align_block_size(ids, 16, num_experts)
n = int(padded)
flat = ids.flatten()
rows = sorted_ids[:n] < flat.numel()
real = sorted_ids[:n][rows]
assert sorted(real.tolist()) == list(range(flat.numel()))
block_of = torch.arange(n, device="cuda") // 16
assert torch.equal(flat[real.long()], expert_ids[block_of[rows]])
44 changes: 44 additions & 0 deletions tests/moe/test_nvfp4_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,50 @@ def test_triton_overlap_prefill_matches_dequant_reference():
cache.release_prefill_layer(layer_id)


@cuda
@pytest.mark.parametrize(("cache_size", "query_width"), [(1100, None), (E, 3)])
def test_triton_demand_prefill_matches_the_streamed_layer_bit_for_bit(monkeypatch, cache_size, query_width):
"""A short prefill chunk loads only its routed experts, through the slot cache, and runs the
same grouped GEMM over the slots: its output must equal the whole-layer stream's exactly, and
its admissions must not count as decode misses. The chunk routes more often than one
admission query compiles for, and the wider cache aligns past 1024 experts; the cache of one
layer's experts, filled three at a time, must keep every query's experts until the GEMM."""
from freetoken.moe import offload_kernels
from freetoken.moe.fused_nvfp4 import fused_experts_nvfp4

if query_width is not None:
monkeypatch.setattr(offload_kernels, "ENSURE_QUERY_WIDTH", query_width)
device = torch.device("cuda")
torch.manual_seed(15)
M = 600
hidden = torch.randn(M, H, dtype=torch.bfloat16, device=device) / 4
topk_ids = torch.rand(M, E, device=device).argsort(dim=1)[:, :TOPK].to(torch.int32).contiguous()
topk_weights = torch.rand(M, TOPK, dtype=torch.float32, device=device)

streamed_cache, _ = _triton_cache(device)
streamed_cache.materialize_layer(1)
streamed_cache.copy_missing()
streamed = fused_experts_nvfp4(
hidden, *streamed_cache.bank_views(E), topk_weights, topk_ids.clone(), E, "silu", False
)

demand_cache, _ = _triton_cache(device, cache_size=cache_size)
demand_cache.collect_stats = True
# decode left the other layer's experts and one of this layer's resident, so slots are not expert ids
for layer_id, resident in ((0, [7, 6]), (1, [2])):
demand_cache.ensure_experts(layer_id, torch.tensor([resident], dtype=torch.int32, device=device))
demand_cache.copy_missing()
decode_stats = demand_cache.lru_stats.clone()
slots = demand_cache.load_prefill_experts(1, topk_ids)
demanded = fused_experts_nvfp4(
hidden, *demand_cache.bank_views(), topk_weights, slots, demand_cache.cache_size, "silu", False
)

assert not torch.equal(slots, topk_ids), "the admission must have mapped expert ids to slots"
assert torch.equal(demanded, streamed)
assert torch.equal(demand_cache.lru_stats, decode_stats)


@cuda
def test_triton_swigluoai_matches_dequant_reference():
"""MiniMax-M3's swigluoai routed experts through the Triton prefill grouped GEMM
Expand Down
Loading
Loading