505 lines
19 KiB
Python
505 lines
19 KiB
Python
"""CPython native MoE backend 的 Python 包装层。"""
|
||
import importlib
|
||
import json
|
||
import os
|
||
import time
|
||
from collections import OrderedDict
|
||
from functools import lru_cache
|
||
|
||
import mlx.core as mx
|
||
import numpy as np
|
||
|
||
from mlx_streaming import config
|
||
|
||
|
||
_EXPERT_STAGE_CACHE: "OrderedDict[tuple, tuple[mx.array, mx.array, mx.array]]" = OrderedDict()
|
||
_BUNDLE_STAGE_CACHE: "OrderedDict[tuple, tuple[mx.array, ...]]" = OrderedDict()
|
||
_SLOT_POOLS: dict[tuple, "NativeComputeSlotPool"] = {}
|
||
_STAGE_STATS = {
|
||
"expert_hits": 0,
|
||
"expert_misses": 0,
|
||
"bundle_hits": 0,
|
||
"bundle_misses": 0,
|
||
"evictions": 0,
|
||
"can_checks": 0,
|
||
"can_true": 0,
|
||
"can_false": 0,
|
||
"calls": 0,
|
||
"route_sync_s": 0.0,
|
||
"stage_s": 0.0,
|
||
"enqueue_s": 0.0,
|
||
}
|
||
|
||
|
||
class NativeComputeSlotPool:
|
||
"""按层维护 compute-buffer 常驻 slot,hit 时复用 [cap,...] MLX arrays。"""
|
||
|
||
def __init__(self, compute_dir: str, layer: int, hidden: int, inter: int,
|
||
group: int, bits: int, num_experts: int, cap: int):
|
||
self.compute_dir = compute_dir
|
||
self.layer = int(layer)
|
||
self.hidden = int(hidden)
|
||
self.inter = int(inter)
|
||
self.group = int(group)
|
||
self.bits = int(bits)
|
||
self.num_experts = int(num_experts)
|
||
self.cap = int(cap)
|
||
self.slot_of: "OrderedDict[int, int]" = OrderedDict()
|
||
self.expert_of: list[int | None] = [None] * self.cap
|
||
self.slot_arrays = [self._empty_slot() for _ in range(self.cap)]
|
||
self.pool_arrays: tuple[mx.array, ...] | None = None
|
||
self.dirty = True
|
||
self.hits = 0
|
||
self.misses = 0
|
||
self.evictions = 0
|
||
self.rebuilds = 0
|
||
|
||
def _empty_slot(self) -> tuple[mx.array, ...]:
|
||
gu_words = self.hidden * self.bits // 32
|
||
gu_groups = self.hidden // self.group
|
||
down_words = self.inter * self.bits // 32
|
||
down_groups = self.inter // self.group
|
||
return (
|
||
mx.zeros((self.inter, gu_words), dtype=mx.uint32),
|
||
mx.zeros((self.inter, gu_groups), dtype=mx.uint16),
|
||
mx.zeros((self.inter, gu_groups), dtype=mx.uint16),
|
||
mx.zeros((self.inter, gu_words), dtype=mx.uint32),
|
||
mx.zeros((self.inter, gu_groups), dtype=mx.uint16),
|
||
mx.zeros((self.inter, gu_groups), dtype=mx.uint16),
|
||
mx.zeros((self.hidden, down_words), dtype=mx.uint32),
|
||
mx.zeros((self.hidden, down_groups), dtype=mx.uint16),
|
||
mx.zeros((self.hidden, down_groups), dtype=mx.uint16),
|
||
)
|
||
|
||
def _load_slot(self, expert: int) -> tuple[mx.array, ...]:
|
||
gate = _stage_one_projection_uncached(
|
||
self.compute_dir, self.layer, "gate_proj", expert,
|
||
self.inter, self.hidden, self.group, self.bits, self.num_experts)
|
||
up = _stage_one_projection_uncached(
|
||
self.compute_dir, self.layer, "up_proj", expert,
|
||
self.inter, self.hidden, self.group, self.bits, self.num_experts)
|
||
down = _stage_one_projection_uncached(
|
||
self.compute_dir, self.layer, "down_proj", expert,
|
||
self.hidden, self.inter, self.group, self.bits, self.num_experts)
|
||
return (*gate, *up, *down)
|
||
|
||
def _assign_slot(self, expert: int) -> int:
|
||
cached = self.slot_of.get(expert)
|
||
if cached is not None:
|
||
self.slot_of.move_to_end(expert)
|
||
self.hits += 1
|
||
return cached
|
||
self.misses += 1
|
||
if len(self.slot_of) < self.cap:
|
||
slot = next(i for i, e in enumerate(self.expert_of) if e is None)
|
||
else:
|
||
old_expert, slot = self.slot_of.popitem(last=False)
|
||
self.expert_of[slot] = None
|
||
self.evictions += 1
|
||
self.slot_arrays[slot] = self._load_slot(expert)
|
||
self.expert_of[slot] = expert
|
||
self.slot_of[expert] = slot
|
||
self.dirty = True
|
||
return slot
|
||
|
||
def _rebuild_pool_arrays(self) -> None:
|
||
self.pool_arrays = tuple(
|
||
mx.stack([slot[i] for slot in self.slot_arrays], axis=0)
|
||
for i in range(9)
|
||
)
|
||
self.rebuilds += 1
|
||
self.dirty = False
|
||
|
||
def acquire(self, expert_ids: list[int]) -> tuple[list[int], tuple[mx.array, ...]]:
|
||
local = [self._assign_slot(int(e)) for e in expert_ids]
|
||
if self.pool_arrays is None or self.dirty:
|
||
self._rebuild_pool_arrays()
|
||
assert self.pool_arrays is not None
|
||
return local, self.pool_arrays
|
||
|
||
def stats(self) -> dict:
|
||
return {
|
||
"cap": self.cap,
|
||
"entries": len(self.slot_of),
|
||
"hits": self.hits,
|
||
"misses": self.misses,
|
||
"evictions": self.evictions,
|
||
"rebuilds": self.rebuilds,
|
||
}
|
||
|
||
|
||
def _compute_dir() -> str:
|
||
return config.compute_buffer_dir() or os.path.join(
|
||
config.expert_dir(default=""), "compute_buffers")
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def _load_ext():
|
||
try:
|
||
return importlib.import_module("mlx_streaming.native_moe_ext")
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
@lru_cache(maxsize=256)
|
||
def _has_layer_buffers(compute_dir: str, layer: int) -> bool:
|
||
for proj in ("gate_proj", "up_proj", "down_proj"):
|
||
for name in ("weight", "scales", "biases"):
|
||
path = os.path.join(compute_dir, f"layer{layer:02d}.{proj}.{name}.bin")
|
||
if not os.path.exists(path):
|
||
return False
|
||
return True
|
||
|
||
|
||
@lru_cache(maxsize=1024)
|
||
def _buffer_index_matches(compute_dir: str, layer: int, proj: str,
|
||
out_dim: int, in_dim: int, group: int, bits: int,
|
||
num_experts: int) -> bool:
|
||
path = os.path.join(compute_dir, f"layer{layer:02d}.{proj}.index.json")
|
||
if not os.path.exists(path):
|
||
return False
|
||
try:
|
||
with open(path) as f:
|
||
index = json.load(f)
|
||
except Exception:
|
||
return False
|
||
tensors = index.get("tensors", {})
|
||
weight_shape = tensors.get("weight", {}).get("shape_per_expert")
|
||
scale_shape = tensors.get("scales", {}).get("shape_per_expert")
|
||
bias_shape = tensors.get("biases", {}).get("shape_per_expert")
|
||
expected_weight = [out_dim, in_dim * bits // 32]
|
||
expected_grouped = [out_dim, in_dim // group]
|
||
return (
|
||
int(index.get("num_experts", -1)) == int(num_experts)
|
||
and weight_shape == expected_weight
|
||
and scale_shape == expected_grouped
|
||
and bias_shape == expected_grouped
|
||
)
|
||
|
||
|
||
@lru_cache(maxsize=512)
|
||
def _has_matching_compute_buffers(compute_dir: str, layer: int,
|
||
hidden: int, inter: int, group: int,
|
||
bits: int, num_experts: int) -> bool:
|
||
if not _has_layer_buffers(compute_dir, layer):
|
||
return False
|
||
# index.json 是防呆线:bit/group 或目录错配时宁可回退 MLX,不冒险读错 mmap shape。
|
||
return (
|
||
_buffer_index_matches(
|
||
compute_dir, layer, "gate_proj", inter, hidden, group, bits, num_experts)
|
||
and _buffer_index_matches(
|
||
compute_dir, layer, "up_proj", inter, hidden, group, bits, num_experts)
|
||
and _buffer_index_matches(
|
||
compute_dir, layer, "down_proj", hidden, inter, group, bits, num_experts)
|
||
)
|
||
|
||
|
||
@lru_cache(maxsize=256)
|
||
def _projection_memmaps(compute_dir: str, layer: int, proj: str,
|
||
out_dim: int, in_dim: int, group: int, bits: int,
|
||
num_experts: int):
|
||
words = in_dim * bits // 32
|
||
groups = in_dim // group
|
||
base = os.path.join(compute_dir, f"layer{layer:02d}.{proj}")
|
||
weight = np.memmap(
|
||
base + ".weight.bin",
|
||
dtype=np.uint32,
|
||
mode="r",
|
||
shape=(num_experts, out_dim, words),
|
||
)
|
||
scales = np.memmap(
|
||
base + ".scales.bin",
|
||
dtype=np.uint16,
|
||
mode="r",
|
||
shape=(num_experts, out_dim, groups),
|
||
)
|
||
biases = np.memmap(
|
||
base + ".biases.bin",
|
||
dtype=np.uint16,
|
||
mode="r",
|
||
shape=(num_experts, out_dim, groups),
|
||
)
|
||
return weight, scales, biases
|
||
|
||
|
||
def _stage_projection(compute_dir: str, layer: int, proj: str,
|
||
expert_ids: list[int], out_dim: int, in_dim: int,
|
||
group: int, bits: int, num_experts: int):
|
||
if config.native_moe_stage_cache():
|
||
staged = [
|
||
_stage_one_projection(
|
||
compute_dir, layer, proj, int(e), out_dim, in_dim, group, bits, num_experts)
|
||
for e in expert_ids
|
||
]
|
||
return tuple(mx.stack([entry[i] for entry in staged], axis=0) for i in range(3))
|
||
return _stage_projection_uncached(
|
||
compute_dir, layer, proj, expert_ids, out_dim, in_dim, group, bits, num_experts)
|
||
|
||
|
||
def _stage_projection_uncached(compute_dir: str, layer: int, proj: str,
|
||
expert_ids: list[int], out_dim: int, in_dim: int,
|
||
group: int, bits: int, num_experts: int):
|
||
weight, scales, biases = _projection_memmaps(
|
||
compute_dir, layer, proj, out_dim, in_dim, group, bits, num_experts)
|
||
ids = np.asarray(expert_ids, dtype=np.int64)
|
||
# advanced indexing 已经产出 numpy 临时数组;mx.array 再接管成 MLX-managed staging arrays。
|
||
return (
|
||
mx.array(np.asarray(weight[ids])),
|
||
mx.array(np.asarray(scales[ids])),
|
||
mx.array(np.asarray(biases[ids])),
|
||
)
|
||
|
||
|
||
def _stage_one_projection_uncached(compute_dir: str, layer: int, proj: str, expert: int,
|
||
out_dim: int, in_dim: int, group: int, bits: int,
|
||
num_experts: int):
|
||
weight, scales, biases = _projection_memmaps(
|
||
compute_dir, layer, proj, out_dim, in_dim, group, bits, num_experts)
|
||
return (
|
||
mx.array(np.asarray(weight[int(expert)])),
|
||
mx.array(np.asarray(scales[int(expert)])),
|
||
mx.array(np.asarray(biases[int(expert)])),
|
||
)
|
||
|
||
|
||
def _stage_one_projection(compute_dir: str, layer: int, proj: str, expert: int,
|
||
out_dim: int, in_dim: int, group: int, bits: int,
|
||
num_experts: int):
|
||
key = (compute_dir, int(layer), proj, int(expert), out_dim, in_dim, group, bits, num_experts)
|
||
cached = _EXPERT_STAGE_CACHE.get(key)
|
||
if cached is not None:
|
||
_EXPERT_STAGE_CACHE.move_to_end(key)
|
||
_STAGE_STATS["expert_hits"] += 1
|
||
return cached
|
||
_STAGE_STATS["expert_misses"] += 1
|
||
staged = _stage_one_projection_uncached(
|
||
compute_dir, layer, proj, expert, out_dim, in_dim, group, bits, num_experts)
|
||
_EXPERT_STAGE_CACHE[key] = staged
|
||
_EXPERT_STAGE_CACHE.move_to_end(key)
|
||
limit = max(0, config.native_moe_stage_cache_experts())
|
||
while limit and len(_EXPERT_STAGE_CACHE) > limit:
|
||
_EXPERT_STAGE_CACHE.popitem(last=False)
|
||
_STAGE_STATS["evictions"] += 1
|
||
return staged
|
||
|
||
|
||
def _slot_pool(compute_dir: str, layer: int, hidden: int, inter: int, group: int,
|
||
bits: int, num_experts: int) -> NativeComputeSlotPool:
|
||
cap = max(1, config.native_moe_slot_cap())
|
||
key = (compute_dir, int(layer), hidden, inter, group, bits, num_experts, cap)
|
||
pool = _SLOT_POOLS.get(key)
|
||
if pool is None:
|
||
pool = NativeComputeSlotPool(
|
||
compute_dir, int(layer), hidden, inter, group, bits, num_experts, cap)
|
||
_SLOT_POOLS[key] = pool
|
||
return pool
|
||
|
||
|
||
def _stage_bundle(compute_dir: str, layer: int, expert_ids: list[int],
|
||
hidden: int, inter: int, group: int, bits: int,
|
||
num_experts: int):
|
||
key = (
|
||
compute_dir, int(layer), tuple(int(e) for e in expert_ids),
|
||
hidden, inter, group, bits, num_experts,
|
||
)
|
||
if config.native_moe_stage_bundle_cache():
|
||
cached = _BUNDLE_STAGE_CACHE.get(key)
|
||
if cached is not None:
|
||
_BUNDLE_STAGE_CACHE.move_to_end(key)
|
||
_STAGE_STATS["bundle_hits"] += 1
|
||
return cached
|
||
_STAGE_STATS["bundle_misses"] += 1
|
||
gate = _stage_projection(
|
||
compute_dir, int(layer), "gate_proj", expert_ids, inter, hidden, group, bits, num_experts)
|
||
up = _stage_projection(
|
||
compute_dir, int(layer), "up_proj", expert_ids, inter, hidden, group, bits, num_experts)
|
||
down = _stage_projection(
|
||
compute_dir, int(layer), "down_proj", expert_ids, hidden, inter, group, bits, num_experts)
|
||
staged = (*gate, *up, *down)
|
||
if config.native_moe_stage_bundle_cache():
|
||
_BUNDLE_STAGE_CACHE[key] = staged
|
||
_BUNDLE_STAGE_CACHE.move_to_end(key)
|
||
limit = max(0, config.native_moe_stage_cache_bundles())
|
||
while limit and len(_BUNDLE_STAGE_CACHE) > limit:
|
||
_BUNDLE_STAGE_CACHE.popitem(last=False)
|
||
_STAGE_STATS["evictions"] += 1
|
||
return staged
|
||
|
||
|
||
def prefetch_native_moe_stage(layer: int, expert_ids: list[int],
|
||
hidden: int, inter: int, group: int, bits: int,
|
||
num_experts: int) -> bool:
|
||
"""提前把预测专家拷入 MLX-managed staging cache,供后续 fused native op 复用。"""
|
||
if not config.native_moe():
|
||
return False
|
||
if not config.native_moe_stage_prefetch():
|
||
return False
|
||
if config.native_moe_synthetic():
|
||
return False
|
||
compute_dir = _compute_dir()
|
||
if not compute_dir or not _has_matching_compute_buffers(
|
||
compute_dir, layer, hidden, inter, group, bits, num_experts):
|
||
return False
|
||
try:
|
||
uniq = list(dict.fromkeys(int(e) for e in expert_ids))
|
||
if not uniq:
|
||
return False
|
||
for proj, out_dim, in_dim in (
|
||
("gate_proj", inter, hidden),
|
||
("up_proj", inter, hidden),
|
||
("down_proj", hidden, inter),
|
||
):
|
||
for e in uniq:
|
||
_stage_one_projection(
|
||
compute_dir, int(layer), proj, e, out_dim, in_dim, group, bits, num_experts)
|
||
return True
|
||
except Exception:
|
||
if config.native_moe_raise():
|
||
raise
|
||
return False
|
||
|
||
|
||
def stage_cache_stats() -> dict:
|
||
slot_stats = {
|
||
"pools": len(_SLOT_POOLS),
|
||
"entries": sum(len(pool.slot_of) for pool in _SLOT_POOLS.values()),
|
||
"hits": sum(pool.hits for pool in _SLOT_POOLS.values()),
|
||
"misses": sum(pool.misses for pool in _SLOT_POOLS.values()),
|
||
"evictions": sum(pool.evictions for pool in _SLOT_POOLS.values()),
|
||
"rebuilds": sum(pool.rebuilds for pool in _SLOT_POOLS.values()),
|
||
}
|
||
return {
|
||
**_STAGE_STATS,
|
||
"route_sync_s": round(_STAGE_STATS["route_sync_s"], 6),
|
||
"stage_s": round(_STAGE_STATS["stage_s"], 6),
|
||
"enqueue_s": round(_STAGE_STATS["enqueue_s"], 6),
|
||
"expert_entries": len(_EXPERT_STAGE_CACHE),
|
||
"bundle_entries": len(_BUNDLE_STAGE_CACHE),
|
||
"slot_pool": slot_stats,
|
||
}
|
||
|
||
|
||
def note_route_sync(seconds: float) -> None:
|
||
_STAGE_STATS["route_sync_s"] += float(seconds)
|
||
|
||
|
||
def can_native_moe(layer: int, hidden: int, inter: int, group: int, bits: int,
|
||
num_experts: int) -> bool:
|
||
"""只做缓存化的轻量可用性判断;失败时避免上层触发 route host 同步。"""
|
||
_STAGE_STATS["can_checks"] += 1
|
||
if not config.native_moe():
|
||
_STAGE_STATS["can_false"] += 1
|
||
return False
|
||
if not config.native_moe_mlx_op():
|
||
_STAGE_STATS["can_false"] += 1
|
||
return False
|
||
if config.native_moe_synthetic():
|
||
ok = _load_ext() is not None
|
||
_STAGE_STATS["can_true" if ok else "can_false"] += 1
|
||
return ok
|
||
if (hidden * bits) % 32 != 0 or (inter * bits) % 32 != 0:
|
||
_STAGE_STATS["can_false"] += 1
|
||
return False
|
||
compute_dir = _compute_dir()
|
||
if not compute_dir:
|
||
_STAGE_STATS["can_false"] += 1
|
||
return False
|
||
ok = (
|
||
_load_ext() is not None
|
||
and _has_matching_compute_buffers(
|
||
compute_dir, int(layer), hidden, inter, group, bits, num_experts)
|
||
)
|
||
_STAGE_STATS["can_true" if ok else "can_false"] += 1
|
||
return ok
|
||
|
||
|
||
def try_native_moe(layer: int, expert_ids: list[int], x: mx.array, scores: mx.array,
|
||
hidden: int, inter: int, group: int, bits: int,
|
||
num_experts: int) -> "mx.array | None":
|
||
"""尝试 native fused MoE,失败返回 None 让调用方回退 MLX 路径。"""
|
||
if not config.native_moe():
|
||
return None
|
||
if not config.native_moe_mlx_op():
|
||
return None
|
||
if (hidden * bits) % 32 != 0 or (inter * bits) % 32 != 0:
|
||
return None
|
||
if not expert_ids:
|
||
return None
|
||
if not can_native_moe(layer, hidden, inter, group, bits, num_experts):
|
||
return None
|
||
compute_dir = _compute_dir()
|
||
ext = _load_ext()
|
||
if ext is None:
|
||
return None
|
||
synthetic = config.native_moe_synthetic()
|
||
if (not synthetic and not _has_matching_compute_buffers(
|
||
compute_dir, layer, hidden, inter, group, bits, num_experts)):
|
||
return None
|
||
try:
|
||
expert_arr = mx.array([int(e) for e in expert_ids], dtype=mx.uint32)
|
||
if synthetic:
|
||
t_enqueue = time.perf_counter()
|
||
y = ext.fused_moe(
|
||
x.astype(mx.float32),
|
||
expert_arr,
|
||
scores.astype(mx.float32),
|
||
compute_dir,
|
||
int(layer),
|
||
hidden,
|
||
inter,
|
||
group,
|
||
bits,
|
||
num_experts,
|
||
True,
|
||
)
|
||
_STAGE_STATS["enqueue_s"] += time.perf_counter() - t_enqueue
|
||
else:
|
||
t_stage = time.perf_counter()
|
||
if config.native_moe_slot_pool():
|
||
pool = _slot_pool(
|
||
compute_dir, int(layer), hidden, inter, group, bits, num_experts)
|
||
local_slots, pool_arrays = pool.acquire(expert_ids)
|
||
local_arr = mx.array(local_slots, dtype=mx.uint32)
|
||
(
|
||
gate_w, gate_s, gate_b,
|
||
up_w, up_s, up_b,
|
||
down_w, down_s, down_b,
|
||
) = pool_arrays
|
||
else:
|
||
local_arr = None
|
||
(
|
||
gate_w, gate_s, gate_b,
|
||
up_w, up_s, up_b,
|
||
down_w, down_s, down_b,
|
||
) = _stage_bundle(
|
||
compute_dir, int(layer), expert_ids,
|
||
hidden, inter, group, bits, num_experts)
|
||
_STAGE_STATS["stage_s"] += time.perf_counter() - t_stage
|
||
t_enqueue = time.perf_counter()
|
||
if local_arr is not None:
|
||
y = ext.fused_moe_slots(
|
||
x.astype(mx.float32),
|
||
local_arr,
|
||
scores.astype(mx.float32),
|
||
gate_w, gate_s, gate_b,
|
||
up_w, up_s, up_b,
|
||
down_w, down_s, down_b,
|
||
hidden, inter, group, bits,
|
||
)
|
||
else:
|
||
y = ext.fused_moe_staged(
|
||
x.astype(mx.float32),
|
||
scores.astype(mx.float32),
|
||
gate_w, gate_s, gate_b,
|
||
up_w, up_s, up_b,
|
||
down_w, down_s, down_b,
|
||
hidden, inter, group, bits,
|
||
)
|
||
_STAGE_STATS["enqueue_s"] += time.perf_counter() - t_enqueue
|
||
_STAGE_STATS["calls"] += 1
|
||
return y
|
||
except Exception:
|
||
if config.native_moe_raise():
|
||
raise
|
||
return None
|