Files
ktransformers/kt-kernel/python/experts_base.py

941 lines
38 KiB
Python

# Base classes for MoE CPU inference operations
# SPDX-License-Identifier: Apache-2.0
"""
Base infrastructure for CPU-based MoE inference.
This module contains base classes and utilities shared across all backend implementations.
"""
from __future__ import annotations
import torch
from typing import Dict, List, Optional, Tuple
from abc import ABC, abstractmethod
import ctypes
import logging
import os
from kt_kernel import kt_kernel_ext
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# NPU stream-callback bypass.
#
# On Ascend NPU, `CPUInfer::submit_with_cuda_stream` calls `aclrtLaunchCallback`,
# which inserts the function into a per-stream **callback report queue**. ACL
# requires a dedicated subscriber thread (registered via `aclrtSubscribeReport`
# and continuously running `aclrtProcessReport`) to dispatch those callbacks.
#
# Without such a subscriber, queued callbacks **silently never fire**, so the
# CPU forward task never runs and `output_cpu` stays all-zero.
# `sync_with_cuda_stream` likewise schedules its sync_ via the same callback
# queue and silently completes without actually syncing anything.
#
# kt-kernel therefore starts the subscriber itself (see
# `cpu_backend/ascend_callback_worker.cpp`, entered via
# ``init_ascend_callback_worker``); ``subscribe_ascend_stream`` registers each
# stream with it, after which ``submit_with_cuda_stream`` callbacks are
# dispatched and CPU/NPU overlap works. Set ``KT_FORCE_SYNC_SUBMIT=1`` to fall
# back to the synchronous submit/sync path for debugging.
# -----------------------------------------------------------------------------
# Memoized result of the C++ worker probe. None = not probed yet.
_ascend_worker_degraded: Optional[bool] = None
def _ascend_callback_worker_running() -> Optional[bool]:
"""Probe the C++ callback worker; None when the build predates the probe."""
probe = getattr(kt_kernel_ext, "is_ascend_callback_worker_running", None)
if probe is None:
return None
try:
return bool(probe())
except Exception:
return None
def _ascend_callback_worker_degraded() -> bool:
"""True iff the ACL callback worker is known NOT to be dispatching reports.
Probed once and memoized: the worker's state only changes at start-up and at
interpreter shutdown, and this sits on the per-layer forward path. Builds
without the probe are treated as healthy (backwards compatible).
"""
global _ascend_worker_degraded
if _ascend_worker_degraded is None:
_ascend_worker_degraded = _ascend_callback_worker_running() is False
return _ascend_worker_degraded
def _should_bypass_stream_callback(device: torch.device) -> bool:
"""Return True iff we must use the synchronous submit/sync path."""
if os.environ.get("KT_FORCE_SYNC_SUBMIT", "") == "1":
return True
if device.type == "npu" and not _uses_external_npu_report_subscriber():
# Without a dispatcher thread, aclrtLaunchCallback tasks never fire and
# submit_with_cuda_stream would hang forever. Degrade to sync instead.
if _ascend_callback_worker_degraded():
return True
return False
def _uses_external_npu_report_subscriber() -> bool:
return os.environ.get("KT_EXTERNAL_NPU_REPORT_SUBSCRIBER", "") == "1"
def _ensure_ascend_callback_worker() -> None:
"""Start kt-kernel ACL callback worker (idempotent)."""
if _uses_external_npu_report_subscriber():
return
if not hasattr(kt_kernel_ext, "init_ascend_callback_worker"):
return
if getattr(_ensure_ascend_callback_worker, "_done", False):
return
kt_kernel_ext.init_ascend_callback_worker()
_ensure_ascend_callback_worker._done = True # type: ignore[attr-defined]
global _ascend_worker_degraded
_ascend_worker_degraded = _ascend_callback_worker_running() is False
if _ascend_worker_degraded:
logger.warning(
"kt-kernel ACL callback worker failed to start (no ACL context?); "
"falling back to the synchronous CPU-MoE submit/sync path."
)
if hasattr(kt_kernel_ext, "shutdown_ascend_callback_worker"):
import atexit
atexit.register(kt_kernel_ext.shutdown_ascend_callback_worker)
def _sglang_is_capture_mode() -> bool:
"""True when sglang is inside ``model_capture_mode()`` (graph capture).
kt-kernel must remain importable standalone, so the sglang dependency is
optional and any failure is treated as "not capturing".
"""
try:
from sglang.srt.model_executor.runner import get_is_capture_mode
return bool(get_is_capture_mode())
except Exception:
return False
def _wait_device(device: torch.device) -> None:
"""Block until pending async copies on `device`'s current stream finish.
NOTE on graph capture: torch.{cuda,npu}.synchronize() raises during cuda /
NPU graph capture (NPU returns ERR 107027 "stream is captured"). Skip sync
while capturing; graph MoE uses ``_launch_host_func`` + pinned buffers
(see ``kt_ep_wrapper``).
"""
if device.type == "npu":
try:
if torch.npu.is_current_stream_capturing():
return
except Exception:
pass
# Defensive fallback: torch.npu.is_current_stream_capturing() reliability
# during torch_npu graph capture is unconfirmed; if it returns False (or
# raises) while capturing, the synchronize() below would attempt a
# stream sync on a captured stream and crash (107027/107030). Mirror the
# capture detection used by kt_ep_wrapper._npu_use_graph_host_callback by
# also consulting sglang's global capture flag, which model_capture_mode()
# sets reliably around the whole capture loop.
if _sglang_is_capture_mode():
return
torch.npu.synchronize(device)
elif device.type == "cuda":
try:
if torch.cuda.is_current_stream_capturing():
return
except Exception:
pass
if _sglang_is_capture_mode():
return
torch.cuda.synchronize(device)
def generate_gpu_experts_masks(
activation_freq: torch.Tensor,
num_gpu_experts: int,
) -> torch.Tensor:
"""
Generate GPU experts masks based on activation frequency.
Selects the top `num_gpu_experts` experts with highest activation frequency
across all layers to be placed on GPU.
Args:
activation_freq: Activation frequency table of shape (num_layers, num_experts).
Higher values indicate more frequently activated experts.
num_gpu_experts: Total number of experts to place on GPU across all layers.
Returns:
gpu_experts_masks: Boolean mask of shape (num_layers, num_experts) on CPU.
True means the expert should be on GPU.
Example:
>>> activation_freq = torch.tensor([
... [0.1, 0.5, 0.3, 0.8], # layer 0
... [0.2, 0.4, 0.9, 0.1], # layer 1
... ])
>>> masks = generate_gpu_experts_masks(activation_freq, num_gpu_experts=3)
>>> # Top 3: layer0-expert3 (0.8), layer1-expert2 (0.9), layer0-expert1 (0.5)
>>> masks
tensor([[False, True, False, True],
[False, False, True, False]])
"""
num_layers, num_experts_per_layer = activation_freq.shape
total_experts = num_layers * num_experts_per_layer
# Clamp num_gpu_experts to valid range
num_gpu_experts = min(num_gpu_experts, total_experts)
num_gpu_experts = max(num_gpu_experts, 0)
if num_gpu_experts == 0:
return torch.zeros(num_layers, num_experts_per_layer, dtype=torch.bool, device="cpu")
# Flatten and find top-k indices
flat_freq = activation_freq.view(-1).to(device="cpu")
_, top_indices = torch.topk(flat_freq, k=num_gpu_experts, largest=True, sorted=False)
# Create mask
gpu_experts_masks = torch.zeros(total_experts, dtype=torch.bool, device="cpu")
gpu_experts_masks[top_indices] = True
# Reshape to (num_layers, num_experts)
gpu_experts_masks = gpu_experts_masks.view(num_layers, num_experts_per_layer)
return gpu_experts_masks
class KExpertsCPUBuffer:
"""
CPU buffer management for expert computation.
Manages pinned memory buffers for efficient GPU-CPU data transfer.
"""
capture_bs: List = list()
capture_buffers: Dict = dict()
temp_bs: int = 0
temp_buffer: tuple = tuple()
buffer_depth: int = 2
@classmethod
def get_buffer(cls, hidden_states: torch.Tensor, num_experts_per_tok):
hidden_size = hidden_states.shape[-1]
batch_size = hidden_states.shape[0]
pin_memory = True
if batch_size in cls.capture_buffers:
return cls.capture_buffers[batch_size]
if batch_size == cls.temp_bs:
return cls.temp_buffer
input_tensor_cpu = [
torch.zeros((batch_size, hidden_size), device="cpu", pin_memory=pin_memory, dtype=torch.bfloat16)
for _ in range(cls.buffer_depth)
]
immediate_experts_ids_cpu = [
torch.zeros((batch_size, num_experts_per_tok), device="cpu", dtype=torch.long, pin_memory=pin_memory)
for _ in range(cls.buffer_depth)
]
deferred_experts_ids_cpu = [
torch.full((batch_size, num_experts_per_tok), -1, device="cpu", dtype=torch.long, pin_memory=pin_memory)
for _ in range(cls.buffer_depth)
]
weights_cpu = [
torch.zeros((batch_size, num_experts_per_tok), device="cpu", dtype=torch.float32, pin_memory=pin_memory)
for _ in range(cls.buffer_depth)
]
output_cpu = [
torch.zeros((batch_size, hidden_size), device="cpu", pin_memory=pin_memory, dtype=torch.bfloat16)
for _ in range(cls.buffer_depth)
]
bsz_tensor_cpu = [
torch.full((1,), batch_size, device="cpu", dtype=torch.int32, pin_memory=pin_memory)
for _ in range(cls.buffer_depth)
]
output_gpu = [
torch.zeros((batch_size, hidden_size), device=hidden_states.device, dtype=hidden_states.dtype)
for _ in range(cls.buffer_depth)
]
cur_buffer = (
input_tensor_cpu,
immediate_experts_ids_cpu,
deferred_experts_ids_cpu,
weights_cpu,
output_cpu,
bsz_tensor_cpu,
output_gpu,
)
if batch_size in cls.capture_bs:
cls.capture_buffers[batch_size] = cur_buffer
cls.temp_bs = batch_size
cls.temp_buffer = cur_buffer
return cur_buffer
class _MoEBase:
"""
Shared base class for inference and SFT MoE wrappers.
Provides:
- CPUInfer singleton management
- Basic configuration validation
This class is shared between BaseMoEWrapper (inference) and BaseSFTMoEWrapper (SFT).
"""
_cpu_infer_instance = None
@classmethod
def _get_cpu_infer(
cls,
cpuinfer_threads: int,
threadpool_count: int,
numa_nodes=None,
):
"""
Get or create the CPUInfer singleton instance.
Args:
cpuinfer_threads: Total number of CPU inference threads
threadpool_count: Number of NUMA subpools (TP count)
numa_nodes: Explicit list of NUMA node IDs. If None, defaults to sequential.
Returns:
CPUInfer singleton instance
"""
if cls._cpu_infer_instance is None:
try:
if torch.npu.is_available(): # type: ignore[attr-defined]
_ensure_ascend_callback_worker()
except Exception:
pass
worker_config = kt_kernel_ext.WorkerPoolConfig()
if numa_nodes is not None:
if len(numa_nodes) != threadpool_count:
raise ValueError(
f"numa_nodes length ({len(numa_nodes)}) must match " f"threadpool_count ({threadpool_count})"
)
subpool_numa_map = list(numa_nodes)
else:
subpool_numa_map = list(range(threadpool_count))
subpool_thread_count = [
cpuinfer_threads // threadpool_count + (1 if i < cpuinfer_threads % threadpool_count else 0)
for i in range(threadpool_count)
]
worker_config.subpool_count = threadpool_count
worker_config.subpool_numa_map = subpool_numa_map
worker_config.subpool_thread_count = subpool_thread_count
cls._cpu_infer_instance = kt_kernel_ext.CPUInfer(worker_config)
return cls._cpu_infer_instance
@staticmethod
def _validate_base_config(
num_experts: int,
hidden_size: int,
moe_intermediate_size: int,
num_experts_per_tok: int,
) -> None:
"""
Validate basic configuration parameters.
Raises:
ValueError: If parameters are invalid
"""
if num_experts <= 0:
raise ValueError(f"num_experts must be positive, got {num_experts}")
if hidden_size <= 0:
raise ValueError(f"hidden_size must be positive, got {hidden_size}")
if moe_intermediate_size <= 0:
raise ValueError(f"moe_intermediate_size must be positive, got {moe_intermediate_size}")
if num_experts_per_tok <= 0:
raise ValueError(f"num_experts_per_tok must be positive, got {num_experts_per_tok}")
if num_experts_per_tok > num_experts:
raise ValueError(
f"num_experts_per_tok ({num_experts_per_tok}) cannot exceed " f"num_experts ({num_experts})"
)
class BaseMoEWrapper(_MoEBase, ABC):
"""
Base class for MoE CPU inference operations.
Provides common functionality for all backend implementations.
"""
_layer_has_pending_deferred: Dict[int, bool] = {}
def __init__(
self,
layer_idx: int,
num_experts: int,
num_experts_per_tok: int,
hidden_size: int,
moe_intermediate_size: int,
gpu_experts_mask: Optional[torch.Tensor],
cpuinfer_threads: int,
threadpool_count: int,
weight_path: str,
chunked_prefill_size: int,
cpu_save: bool = False,
max_deferred_experts_per_token: Optional[int] = None,
method: str = "AMXINT4",
numa_nodes: Optional[List[int]] = None,
swiglu_limit: float = 0.0,
):
"""
Initialize base MoE Wrapper.
Args:
layer_idx: Layer index
num_experts: Total number of experts
num_experts_per_tok: Number of experts per token (top-k)
hidden_size: Hidden dimension size
moe_intermediate_size: MoE intermediate size
gpu_experts_mask: Boolean mask indicating which experts are on GPU.
Shape: [num_experts], dtype: torch.bool.
mask[i] = True means expert i is on GPU.
If None, all experts are on CPU.
cpuinfer_threads: Number of CPU inference threads
threadpool_count: Number of NUMA subpools
weight_path: Path to weights
chunked_prefill_size: Maximum prefill chunk size
cpu_save: Whether to save weights to CPU memory
max_deferred_experts_per_token: Number of experts per token to defer on this layer. Defaults to 0 (no defer).
method: Backend method string
numa_nodes: Explicit list of NUMA node IDs for subpool mapping.
If None, defaults to [0, 1, ..., threadpool_count-1].
"""
self.layer_idx = layer_idx
self.num_experts = num_experts
self.num_experts_per_tok = num_experts_per_tok
self.hidden_size = hidden_size
self.moe_intermediate_size = moe_intermediate_size
# Process gpu_experts_mask: convert to bool tensor on CPU, pinned memory for async copy
# This mask is shared between C and Python (C uses uint8_t*), both can read/write it
if gpu_experts_mask is None:
# No GPU experts - all experts on CPU
self.gpu_experts_mask = torch.zeros(num_experts, dtype=torch.bool, device="cpu", pin_memory=True)
else:
# Create a new pinned tensor and copy data into it
self.gpu_experts_mask = torch.empty(num_experts, dtype=torch.bool, device="cpu", pin_memory=True)
self.gpu_experts_mask.copy_(gpu_experts_mask)
self.num_gpu_experts = int(self.gpu_experts_mask.sum().item())
# GPU copy for mask operations in forward pass (e.g., mask_cpu_expert_ids)
# This will be lazily initialized when needed
self._gpu_experts_mask_gpu: Optional[torch.Tensor] = None
self.weight_path = weight_path
self.chunked_prefill_size = chunked_prefill_size
self.cpu_save = cpu_save
self.max_deferred_experts_per_token = (
int(max_deferred_experts_per_token) if max_deferred_experts_per_token is not None else 0
)
BaseMoEWrapper._layer_has_pending_deferred[self.layer_idx] = False
self.method = method
# V4-Flash 2604B SwiGLU clamp limit; 0.0 = disabled. NativeMoEWrapper
# (MXFP4 path) reads this in load_weights() and writes it into
# MOEConfig.swiglu_limit. Other backends ignore it (C++ act_fn skips
# the clamp branch when limit==0). Origin: kt-sglang 耦合.
self.swiglu_limit = float(swiglu_limit)
# Initialize CPU inference engine (singleton via shared base class)
self.cpu_infer = self._get_cpu_infer(cpuinfer_threads, threadpool_count, numa_nodes=numa_nodes)
# Backend-specific initialization happens in subclasses
self.moe = None
@abstractmethod
def load_weights_from_tensors(
self,
gate_proj: torch.Tensor,
up_proj: torch.Tensor,
down_proj: torch.Tensor,
physical_to_logical_map_cpu: torch.Tensor,
):
"""
Load and quantize weights from BF16/FP16 tensors (online quantization).
Args:
gate_proj: Gate projection weights [num_experts, intermediate_size, hidden_size]
up_proj: Up projection weights [num_experts, intermediate_size, hidden_size]
down_proj: Down projection weights [num_experts, hidden_size, intermediate_size]
physical_to_logical_map_cpu: Mapping from physical to logical expert IDs
"""
pass
@abstractmethod
def load_weights(self, physical_to_logical_map_cpu: torch.Tensor):
"""
Load weights for this layer and initialize the MoE module.
Args:
physical_to_logical_map_cpu: Mapping from physical to logical expert IDs
"""
pass
def select_deferred_experts(
self,
expert_ids: torch.Tensor,
expert_scores: torch.Tensor,
protected_k: int,
) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
batch, topk = expert_ids.shape
device = expert_ids.device
protected_k = max(0, min(int(protected_k), topk))
if protected_k == 0:
deferred_ids = expert_ids.clone()
immediate_ids = torch.full_like(expert_ids, -1)
return immediate_ids, deferred_ids
topk_result = torch.topk(expert_scores, k=protected_k, dim=-1, largest=True, sorted=False)
protected_indices = topk_result.indices
protected_ids = torch.gather(expert_ids, -1, protected_indices)
protected_flag = torch.zeros((self.num_experts,), dtype=torch.int32, device=device)
protected_flag.scatter_(0, protected_ids.reshape(-1), 1)
protected_mask_flat = torch.gather(protected_flag, 0, expert_ids.reshape(-1)).ne(0)
protected_mask = protected_mask_flat.view(batch, topk)
immediate_ids = expert_ids.clone().masked_fill(~protected_mask, -1)
deferred_ids = expert_ids.clone().masked_fill(protected_mask, -1)
return immediate_ids, deferred_ids
def _check_qlen_fits_cpp_buffers(self, hidden_states: torch.Tensor) -> None:
"""Fail loudly when qlen would overrun the C++ MoE output buffer.
``moe-tp.hpp`` sizes ``local_output_numa[i]`` by ``max_possible_qlen()`` =
``max(max_len, group_max_len)``, and both are set to ``chunked_prefill_size``
(``utils/llamafile.py``). ``TP::forward`` then hands the *full* qlen to
``MOE::forward``, whose recursion only splits the internal scratch — the
caller-supplied output pointer still advances across ``qlen * hidden_size``.
So ``qlen > chunked_prefill_size`` writes past the allocation and corrupts the
heap, surfacing later as an unrelated ``malloc(): unaligned tcache chunk``
abort. Raise here instead, mirroring the SFT path (``sft/base.py``).
"""
qlen = hidden_states.numel() // hidden_states.shape[-1]
if qlen > self.chunked_prefill_size:
raise ValueError(
f"qlen ({qlen}) exceeds chunked_prefill_size ({self.chunked_prefill_size}); "
"the C++ MoE output buffer is sized by chunked_prefill_size and would be "
"overrun. Raise --chunked-prefill-size or reduce the prefill chunk."
)
def _prepare_forward_cpu_buffers(
self,
hidden_states: torch.Tensor,
topk_ids: torch.Tensor,
topk_weights: torch.Tensor,
) -> Tuple[torch.Tensor, Optional[torch.Tensor], tuple, int, int]:
"""D2H copy into pinned CPU buffers; return deferred ids and buffer handles."""
self._check_qlen_fits_cpp_buffers(hidden_states)
flat_hidden_states = hidden_states.view(-1, hidden_states.shape[-1])
(
input_tensor_cpu,
immediate_experts_ids_cpu,
deferred_experts_ids_cpu,
weights_cpu,
output_cpu,
bsz_tensor_cpu,
_output_gpu,
) = KExpertsCPUBuffer.get_buffer(flat_hidden_states, self.num_experts_per_tok)
current_slot = self.layer_idx % KExpertsCPUBuffer.buffer_depth
next_slot = (current_slot + 1) % KExpertsCPUBuffer.buffer_depth
bsz_slot_tensor = bsz_tensor_cpu[current_slot]
topk_ids_long = topk_ids.to(torch.long)
if self.max_deferred_experts_per_token > 0:
protected_k = self.num_experts_per_tok - self.max_deferred_experts_per_token
immediate_ids, deferred_ids = self.select_deferred_experts(topk_ids_long, topk_weights, protected_k)
else:
immediate_ids = topk_ids_long
deferred_ids = None
input_tensor_cpu[current_slot].copy_(flat_hidden_states, non_blocking=True)
weights_cpu[current_slot].copy_(topk_weights, non_blocking=True)
immediate_experts_ids_cpu[current_slot].copy_(immediate_ids, non_blocking=True)
if deferred_ids is not None:
deferred_experts_ids_cpu[current_slot].copy_(deferred_ids, non_blocking=True)
buffers = (
input_tensor_cpu,
immediate_experts_ids_cpu,
deferred_experts_ids_cpu,
weights_cpu,
output_cpu,
bsz_tensor_cpu,
_output_gpu,
)
return immediate_ids, deferred_ids, buffers, current_slot, next_slot
def copy_inputs_to_cpu_buffers(
self,
hidden_states: torch.Tensor,
topk_ids: torch.Tensor,
topk_weights: torch.Tensor,
) -> None:
"""Copy MoE inputs to pinned CPU buffers (for NPU graph host callbacks)."""
self._prepare_forward_cpu_buffers(hidden_states, topk_ids, topk_weights)
def forward_on_pinned_buffers(
self,
hidden_states: torch.Tensor,
cuda_stream,
) -> None:
"""Run CPU MoE on buffers already filled (sync or stream callback)."""
self._check_qlen_fits_cpp_buffers(hidden_states)
flat_hidden_states = hidden_states.view(-1, hidden_states.shape[-1])
(
input_tensor_cpu,
immediate_experts_ids_cpu,
deferred_experts_ids_cpu,
weights_cpu,
output_cpu,
bsz_tensor_cpu,
_output_gpu,
) = KExpertsCPUBuffer.get_buffer(flat_hidden_states, self.num_experts_per_tok)
current_slot = self.layer_idx % KExpertsCPUBuffer.buffer_depth
next_slot = (current_slot + 1) % KExpertsCPUBuffer.buffer_depth
bsz_slot_tensor = bsz_tensor_cpu[current_slot]
bypass = _should_bypass_stream_callback(hidden_states.device)
incremental = BaseMoEWrapper._layer_has_pending_deferred.get(self.layer_idx - 1, False)
immediate_task = self.moe.forward_task(
bsz_slot_tensor.data_ptr(),
immediate_experts_ids_cpu[current_slot].size(-1),
immediate_experts_ids_cpu[current_slot].data_ptr(),
weights_cpu[current_slot].data_ptr(),
input_tensor_cpu[current_slot].data_ptr(),
output_cpu[current_slot].data_ptr(),
incremental,
)
if bypass:
self.cpu_infer.submit(immediate_task)
else:
# Correct + fast async path. ``submit_with_cuda_stream`` enqueues the
# CPU-MoE via an ACL host callback whose firing is not host-observable
# (NO_BLOCK) -> a later host-side drain can race ahead of it (empty
# queue -> stale output_cpu read by the H2D; nondeterministic on heavy
# prefill). Enqueue synchronously on this host thread instead (work is
# guaranteed submitted; it still runs on the WorkerPool and overlaps the
# GPU experts queued after). Keep subscribe_ascend_stream — that stream
# registration is what keeps decode's host-callback dispatch fast; the
# async submit itself is not required for it.
if (
hidden_states.device.type == "npu"
and not _uses_external_npu_report_subscriber()
and hasattr(kt_kernel_ext, "subscribe_ascend_stream")
):
kt_kernel_ext.subscribe_ascend_stream(int(cuda_stream))
self.cpu_infer.submit(immediate_task)
BaseMoEWrapper._layer_has_pending_deferred[self.layer_idx] = False
has_deferred = (
self.max_deferred_experts_per_token > 0 and (deferred_experts_ids_cpu[current_slot] >= 0).any().item()
)
if has_deferred:
deferred_task = self.moe.forward_task(
bsz_slot_tensor.data_ptr(),
deferred_experts_ids_cpu[current_slot].size(-1),
deferred_experts_ids_cpu[current_slot].data_ptr(),
weights_cpu[current_slot].data_ptr(),
input_tensor_cpu[current_slot].data_ptr(),
output_cpu[next_slot].data_ptr(),
False,
)
if bypass:
self.cpu_infer.submit(deferred_task)
else:
self.cpu_infer.submit_with_cuda_stream(cuda_stream, deferred_task)
BaseMoEWrapper._layer_has_pending_deferred[self.layer_idx] = True
def run_pinned_forward_sync(
self,
hidden_states: torch.Tensor,
cuda_stream,
) -> None:
"""Submit + sync CPU MoE on pre-filled buffers (NPU graph host callback).
Called from ``aclrtLaunchCallback`` / ``_launch_host_func``; must not enqueue
nested stream callbacks.
"""
del cuda_stream # unused — sync path only
self._check_qlen_fits_cpp_buffers(hidden_states)
flat_hidden_states = hidden_states.view(-1, hidden_states.shape[-1])
(
input_tensor_cpu,
immediate_experts_ids_cpu,
deferred_experts_ids_cpu,
weights_cpu,
output_cpu,
bsz_tensor_cpu,
_output_gpu,
) = KExpertsCPUBuffer.get_buffer(flat_hidden_states, self.num_experts_per_tok)
current_slot = self.layer_idx % KExpertsCPUBuffer.buffer_depth
next_slot = (current_slot + 1) % KExpertsCPUBuffer.buffer_depth
bsz_slot_tensor = bsz_tensor_cpu[current_slot]
incremental = BaseMoEWrapper._layer_has_pending_deferred.get(self.layer_idx - 1, False)
immediate_task = self.moe.forward_task(
bsz_slot_tensor.data_ptr(),
immediate_experts_ids_cpu[current_slot].size(-1),
immediate_experts_ids_cpu[current_slot].data_ptr(),
weights_cpu[current_slot].data_ptr(),
input_tensor_cpu[current_slot].data_ptr(),
output_cpu[current_slot].data_ptr(),
incremental,
)
self.cpu_infer.submit(immediate_task)
BaseMoEWrapper._layer_has_pending_deferred[self.layer_idx] = False
has_deferred = (
self.max_deferred_experts_per_token > 0 and (deferred_experts_ids_cpu[current_slot] >= 0).any().item()
)
if has_deferred:
deferred_task = self.moe.forward_task(
bsz_slot_tensor.data_ptr(),
deferred_experts_ids_cpu[current_slot].size(-1),
deferred_experts_ids_cpu[current_slot].data_ptr(),
weights_cpu[current_slot].data_ptr(),
input_tensor_cpu[current_slot].data_ptr(),
output_cpu[next_slot].data_ptr(),
False,
)
self.cpu_infer.submit(deferred_task)
BaseMoEWrapper._layer_has_pending_deferred[self.layer_idx] = True
allow_pending = 1 if BaseMoEWrapper._layer_has_pending_deferred.get(self.layer_idx, False) else 0
self.cpu_infer.sync(allow_pending)
def submit_forward(
self,
hidden_states: torch.Tensor,
topk_ids: torch.Tensor,
topk_weights: torch.Tensor,
cuda_stream,
):
"""
Submit forward inference task to CPU (non-blocking).
Args:
hidden_states: Input hidden states [batch_size, hidden_size]
topk_ids: Top-k expert IDs [batch_size, num_experts_per_tok]
topk_weights: Top-k expert weights [batch_size, num_experts_per_tok]
cuda_stream: CUDA stream for synchronization
"""
_immediate_ids, deferred_ids, _buffers, current_slot, next_slot = self._prepare_forward_cpu_buffers(
hidden_states, topk_ids, topk_weights
)
(
input_tensor_cpu,
immediate_experts_ids_cpu,
deferred_experts_ids_cpu,
weights_cpu,
output_cpu,
bsz_tensor_cpu,
_output_gpu,
) = _buffers
bsz_slot_tensor = bsz_tensor_cpu[current_slot]
bypass = _should_bypass_stream_callback(hidden_states.device)
# NPU only: submit the CPU-MoE synchronously on this host thread.
# ``submit_with_cuda_stream`` enqueues it via an ACL host callback whose
# firing is not host-observable (NO_BLOCK) -> a later host-side drain can
# race ahead of it (empty queue -> stale output_cpu read by the H2D;
# nondeterministic on heavy prefill). The synchronous submit still runs on
# the WorkerPool and overlaps the device experts queued after it.
# CUDA keeps the upstream async submit_with_cuda_stream path unchanged.
sync_submit = bypass or hidden_states.device.type == "npu"
if sync_submit:
# The synchronous submit reads input_tensor_cpu immediately -> the input
# D2H queued async on the stream by _prepare_forward_cpu_buffers MUST be
# finished first, else the CPU MoE reads a half-copied input.
_wait_device(hidden_states.device)
incremental = BaseMoEWrapper._layer_has_pending_deferred.get(self.layer_idx - 1, False)
immediate_task = self.moe.forward_task(
bsz_slot_tensor.data_ptr(),
immediate_experts_ids_cpu[current_slot].size(-1),
immediate_experts_ids_cpu[current_slot].data_ptr(),
weights_cpu[current_slot].data_ptr(),
input_tensor_cpu[current_slot].data_ptr(),
output_cpu[current_slot].data_ptr(),
incremental,
)
if sync_submit:
# Keep subscribe_ascend_stream — that stream registration is what keeps
# decode's host-callback dispatch (sync_forward) fast; the async submit
# itself is not required for it.
if (
not bypass
and hidden_states.device.type == "npu"
and not _uses_external_npu_report_subscriber()
and hasattr(kt_kernel_ext, "subscribe_ascend_stream")
):
kt_kernel_ext.subscribe_ascend_stream(int(cuda_stream))
self.cpu_infer.submit(immediate_task)
else:
self.cpu_infer.submit_with_cuda_stream(cuda_stream, immediate_task)
BaseMoEWrapper._layer_has_pending_deferred[self.layer_idx] = False
if deferred_ids is not None:
if sync_submit:
_wait_device(hidden_states.device)
deferred_task = self.moe.forward_task(
bsz_slot_tensor.data_ptr(),
deferred_experts_ids_cpu[current_slot].size(-1),
deferred_experts_ids_cpu[current_slot].data_ptr(),
weights_cpu[current_slot].data_ptr(),
input_tensor_cpu[current_slot].data_ptr(),
output_cpu[next_slot].data_ptr(),
False,
)
if sync_submit:
self.cpu_infer.submit(deferred_task)
else:
self.cpu_infer.submit_with_cuda_stream(cuda_stream, deferred_task)
BaseMoEWrapper._layer_has_pending_deferred[self.layer_idx] = True
def copy_forward_output_to_device(self, hidden_states: torch.Tensor) -> torch.Tensor:
"""Copy pinned CPU output to the device tensor (CPU work already finished)."""
flat_hidden_states = hidden_states.view(-1, hidden_states.shape[-1])
(
_input_tensor_cpu,
_immediate_experts_ids_cpu,
_deferred_experts_ids_cpu,
_weights_cpu,
output_cpu,
_bsz_tensor_cpu,
output_gpu,
) = KExpertsCPUBuffer.get_buffer(flat_hidden_states, self.num_experts_per_tok)
current_slot = self.layer_idx % KExpertsCPUBuffer.buffer_depth
output_gpu[current_slot].copy_(output_cpu[current_slot], non_blocking=True)
return output_gpu[current_slot]
def sync_forward(self, hidden_states: torch.Tensor, cuda_stream) -> torch.Tensor:
"""
Synchronize and retrieve forward inference results.
Args:
hidden_states: Original input hidden states (for getting buffer)
cuda_stream: CUDA stream for synchronization
Returns:
output_gpu: Output tensor on GPU
"""
flat_hidden_states = hidden_states.view(-1, hidden_states.shape[-1])
(
_input_tensor_cpu,
_immediate_experts_ids_cpu,
_deferred_experts_ids_cpu,
_weights_cpu,
output_cpu,
_bsz_tensor_cpu,
output_gpu,
) = KExpertsCPUBuffer.get_buffer(flat_hidden_states, self.num_experts_per_tok)
current_slot = self.layer_idx % KExpertsCPUBuffer.buffer_depth
allow_pending = 1 if BaseMoEWrapper._layer_has_pending_deferred.get(self.layer_idx, False) else 0
bypass = _should_bypass_stream_callback(hidden_states.device)
if bypass:
self.cpu_infer.sync(allow_pending)
else:
if (
hidden_states.device.type == "npu"
and not _uses_external_npu_report_subscriber()
and hasattr(kt_kernel_ext, "subscribe_ascend_stream")
):
kt_kernel_ext.subscribe_ascend_stream(int(cuda_stream))
if hidden_states.device.type == "npu":
# ACL host callbacks do not order the following H2D copy behind
# the WorkerPool drain. Wait for the accelerator work that
# overlapped CPU MoE, then drain on the host before copying the
# completed pinned output back to the NPU.
_wait_device(hidden_states.device)
self.cpu_infer.sync(allow_pending)
else:
self.cpu_infer.sync_with_cuda_stream(cuda_stream, allow_pending)
return self.copy_forward_output_to_device(hidden_states)
def forward(
self,
hidden_states: torch.Tensor,
topk_ids: torch.Tensor,
topk_weights: torch.Tensor,
cuda_stream,
) -> torch.Tensor:
"""
Execute forward inference synchronously (submit + sync).
Args:
hidden_states: Input hidden states [batch_size, hidden_size]
topk_ids: Top-k expert IDs [batch_size, num_experts_per_tok]
topk_weights: Top-k expert weights [batch_size, num_experts_per_tok]
cuda_stream: CUDA stream for synchronization
Returns:
Output tensor on GPU
"""
self.submit_forward(hidden_states, topk_ids, topk_weights, cuda_stream)
return self.sync_forward(hidden_states, cuda_stream)
@staticmethod
def set_capture_batch_sizes(capture_bs: List[int]):
"""
Set batch sizes to capture and cache buffers for.
This allows pre-allocation of CPU buffers for specific batch sizes,
improving performance by avoiding buffer re-allocation during inference.
Args:
capture_bs: List of batch sizes to capture (e.g., [1, 2, 4, 8, 16])
Example:
>>> BaseMoEWrapper.set_capture_batch_sizes([1, 2, 4, 8, 16])
"""
KExpertsCPUBuffer.capture_bs = capture_bs
@staticmethod
def get_capture_batch_sizes() -> List[int]:
"""
Get currently configured capture batch sizes.
Returns:
List of batch sizes that are being captured
"""
return KExpertsCPUBuffer.capture_bs
@staticmethod
def clear_buffer_cache():
"""
Clear all cached buffers.
This frees up memory by clearing the buffer cache. Useful when you want
to reset the buffer state or free memory.
"""
KExpertsCPUBuffer.capture_buffers.clear()
KExpertsCPUBuffer.temp_bs = 0
KExpertsCPUBuffer.temp_buffer = tuple()