238 lines
14 KiB
Python
238 lines
14 KiB
Python
"""VirtualPool:预取调度器 + 双源双缓冲协调器(统一收口)。
|
||
|
||
一个对象承担两职,因为 block.py 用同一个 `self._vpool` 属性:
|
||
|
||
1. ahead 调度(两种模式都用):`ahead_for` / `target_for` 决定「第 L 层应预读哪层」——
|
||
早层用小 ahead 保召回、cutoff 起用大 ahead 抢时序。`_native_fused_prefetch` 靠它选目标层。
|
||
|
||
2. 双源双缓冲协调(仅 ZEROCOPY_DUAL_SOURCE 模式):`begin_forward` / `read_gen` /
|
||
`fill_gen` / `acquire` / `prefetch`。侧区分两「代(gen)」物理行,每前向读上一代、fill 写
|
||
另一代,根除「本前向消费 gather 读的物理行在本前向 eval 期被 fill 覆盖」的竞态
|
||
(见 spec 2026-06-25-qwen-virtual-pool-double-buffer)。对外只暴露「专家→物理行」单次
|
||
gather 接口,消费者每层零 host 同步。
|
||
|
||
构造两种签名并存(互斥使用):
|
||
- 调度器:VirtualPool(num_layers=.., cutoff=.., ahead_lo=.., ahead_hi=..)
|
||
- 协调器:VirtualPool(resident, staging, spec_slots),dual-source 下再补调度参数即可两职合一。
|
||
"""
|
||
import mlx.core as mx
|
||
|
||
from mlx_streaming.core.cache import autopin
|
||
|
||
# 方案B STG_VERIFY 校验累计态(诊断用,默认路径不触及)。
|
||
_stg_verify_state = {"ok": 0, "bad": 0, "printed": 0, "calls": 0}
|
||
|
||
|
||
class VirtualPool:
|
||
def __init__(self, resident=None, staging=None, spec_slots=None, *,
|
||
num_layers=None, cutoff=None, ahead_lo=None, ahead_hi=None, store=None):
|
||
# --- 双源协调(resident/staging 存在时启用)---
|
||
self._rp = resident
|
||
self._stg = staging
|
||
self._store = store # acquire_host 超容量 fetch 回退用(blob 感知);调度器形态可为 None
|
||
self._spec = int(spec_slots) if spec_slots is not None else 0
|
||
self._gen = 0
|
||
self._last_layer = -1 # 前向边界检测:层号回绕(<= 上次) 即新前向
|
||
# --- ahead 调度 ---
|
||
self._num_layers = int(num_layers) if num_layers is not None else 0
|
||
self._cutoff = int(cutoff) if cutoff is not None else 0
|
||
self._a_lo = max(1, int(ahead_lo)) if ahead_lo is not None else 1
|
||
self._a_hi = max(1, int(ahead_hi)) if ahead_hi is not None else 1
|
||
|
||
# ---- ahead 调度 ----
|
||
def ahead_for(self, src_layer: int) -> int:
|
||
# cutoff:早层用 lo(保召回),cutoff 起用 hi(保时序)。
|
||
return self._a_lo if int(src_layer) < self._cutoff else self._a_hi
|
||
|
||
def target_for(self, src_layer: int) -> int:
|
||
# 目标层 = src+ahead。末层无可预读 → 返回 0(跳过)。
|
||
# 不再 clamp 到末层:clamp 会让多个源层同时预读同一末层,对该层每前向 submit 次数 >
|
||
# staging ring,其环形 buffer 在惰性切片被本前向 eval 消费前就被后续 submit 完成回调覆盖成
|
||
# 别的专家字节 → 池槽装错字节。越界即跳过(交给 demand 读真值),保证每层每前向至多 1 次 submit。
|
||
L = int(src_layer)
|
||
if L >= self._num_layers - 1:
|
||
return 0
|
||
tgt = L + self.ahead_for(L)
|
||
if tgt > self._num_layers - 1: # 越界(本会触发 clamp 堆叠)→ 跳过
|
||
return 0
|
||
return tgt
|
||
|
||
# ---- 双源双缓冲协调 ----
|
||
def begin_forward(self, layer_idx: int):
|
||
"""每个 MoE 块 __call__ 开头调;层号回绕(<= 上次) 判为新前向 → 代 +1。
|
||
稳健:不依赖首个 MoE 层是 layer 0、也不要求 MoE 层连续。"""
|
||
if layer_idx <= self._last_layer:
|
||
self._gen += 1
|
||
# AUTOPIN:dual decode 无 Python 计频点,用前向边界驱动周期落盘(AUTOPIN=0 立即返回)。
|
||
autopin.tick()
|
||
# 新前向开头排空上一前向提交的侧区 fill:C++-owned 池 buffer 由后台异步直写,
|
||
# 若消费前 fill 未写完,GPU gather 会读到半写侧区行(DUAL_VERIFY BAD)。
|
||
# drain 阻塞到在途 fill 全部写完 → 本前向要消费的侧区行字节必已就绪。
|
||
if self._spec > 0:
|
||
import mlx_streaming.native_moe_ext as _N
|
||
_N.sideregion_drain()
|
||
self._last_layer = layer_idx
|
||
|
||
def _gens(self) -> int:
|
||
# 代数取自常驻池;单代(=1)时读=填=0(持久 LFU 单区),双代(=2)时交替(%2==&1)。
|
||
g = getattr(self._rp, "spec_gens", 2) if self._rp is not None else 2
|
||
return max(1, int(g))
|
||
|
||
def read_gen(self) -> int:
|
||
return (self._gen - 1) % self._gens() # 读上一前向填好的代;单代恒 0
|
||
|
||
def fill_gen(self) -> int:
|
||
return self._gen % self._gens() # fill 写本代;单代恒 0
|
||
|
||
def acquire(self, layer, inds, num_experts, *, seq_len=None, layer_cap=None):
|
||
"""统一取用入口(GPU-remap 路径):对外呈现「所有专家都在」的视角。
|
||
|
||
返回 (pool_arrays, local, n_experts),计算侧零分支:
|
||
- dual(有侧区 staging 且 spec>0):C++ demand_dual 唯一权威(真实区 ∪ 侧区读代单次 gather);
|
||
n_experts = layer_cap + spec_gens*spec_slots。
|
||
- 非 dual GPU-remap:acquire_gpu;n_experts = layer_cap。
|
||
(host/fetch 路径见 acquire_host,其输入是 host 侧已 .tolist 的 flat,语义不同故分开。)
|
||
"""
|
||
cap = int(layer_cap) if layer_cap is not None else self._rp.cap_for(layer)
|
||
if self._stg is not None and self._spec > 0:
|
||
side_gen = self.read_gen()
|
||
# C++ demand_dual 是双源 decode 真实区的唯一权威(每层 1 次 inds 同步、零主线程落池/记账)。
|
||
# 无 Python 退路:native 未编译 demand_dual → 明确报错(decode 依赖 native 全套能力)。
|
||
if not getattr(self._rp, "_native_demand", False):
|
||
raise RuntimeError(
|
||
"双源 decode 需要 native demand_dual(已成唯一权威),但未检出。请编译 native_moe_ext。")
|
||
pool, local = self._acquire_native(layer, inds, side_gen, cap)
|
||
n_exp = cap + self._rp.spec_gens * self._rp.spec_slots
|
||
return pool, local, n_exp
|
||
pool, local = self._rp.acquire_gpu(layer, inds, num_experts)
|
||
return pool, local, cap
|
||
|
||
def _native_meta(self, layer):
|
||
"""缓存每层 demand_dual 的不变入参(pool_list/seg_nbytes/path),避免逐层重建 host 胶水。"""
|
||
cache = getattr(self, "_nd_meta", None)
|
||
if cache is None:
|
||
cache = self._nd_meta = {}
|
||
m = cache.get(layer)
|
||
if m is None:
|
||
stg = self._stg
|
||
segs = stg.src._segs # (proj, tensor, dt, shape, nb),与池 key 同序
|
||
pool_list = [self._rp._pools[layer][f"{p}.{t}"] for p, t, *_ in segs]
|
||
m = (pool_list, [int(nb) for *_, nb in segs],
|
||
f"{stg.src.dir}/layer{int(layer):02d}.blob", int(stg.stride))
|
||
cache[layer] = m
|
||
return m
|
||
|
||
def _acquire_native(self, layer, inds, side_gen, cap):
|
||
"""方案B 取用:委派 C++ demand_dual(每层 1 次 inds 同步 + 并行 worker pread 落池),
|
||
更新 rp 统计计数(供报告口径一致)。"""
|
||
import mlx_streaming.native_moe_ext as N
|
||
rp = self._rp
|
||
rp._bootstrap_dual_pool(layer) # 首次建池 + real_init(幂等)
|
||
# 方案B 容量前提:miss 只能落真实区的 cap 槽,其中 pinned 永不可驱逐 → 可安放上限 cap−pinned。
|
||
# 超了 C++ 会把专家落 0 号槽(拿别的专家权重算)→ 逐位不正确。调用方(block.py 的分流判据)
|
||
# 负责在超容量前把该前向送去 host/fetch;这里只做一次性告警兜底。
|
||
_placeable = int(cap) - int(N.real_pinned_count(int(layer)))
|
||
if int(inds.size) > _placeable and not getattr(self, "_overcap_warned", False):
|
||
self._overcap_warned = True
|
||
import sys
|
||
print(f"[DEMAND_DUAL] 警告:inds.size={int(inds.size)} > 可安放槽 {_placeable}"
|
||
f"(cap={int(cap)} − pinned={int(cap) - _placeable}),真实区超容量,逐位将不正确。"
|
||
f"请调小 PREFILL_CHUNK,或调大 EXPERT_SLOTS / 调小 AUTOPIN_BUDGET_FRAC。",
|
||
file=sys.stderr, flush=True)
|
||
pool_list, seg_nbytes, path, stride = self._native_meta(layer)
|
||
local = N.demand_dual(inds, pool_list, seg_nbytes, int(layer), int(side_gen), path,
|
||
stride, int(cap),
|
||
rp.eviction_policy == "lfu", int(rp.lfu_decay_interval))
|
||
st = N.demand_last_stats() # [hitpos, misspos, loads, fallback01, unplaced]
|
||
rp.hits += st[0]
|
||
rp.misses += st[2]
|
||
if len(st) > 4:
|
||
rp.unplaced += st[4] # >0 即有前向落了 0 号槽,输出不可信(/api/stats 暴露)
|
||
if st[3] == 0:
|
||
rp.gpu_fastpath += 1
|
||
else:
|
||
rp.gpu_fallback += 1
|
||
from mlx_streaming import config
|
||
if config.stg_verify(): # 诊断:方案B 池字节逐 key 真值校验(默认关)
|
||
self._verify_native_bytes(layer, inds, local)
|
||
return rp._pools[layer], local
|
||
|
||
def _verify_native_bytes(self, layer, inds, local):
|
||
"""诊断(STG_VERIFY,方案B):校验「字节落池不变量」——真实区每个占用槽的池字节 == 该槽当前
|
||
C++ 属主专家(g_real)的 blob 真值。这是 C++ 接管落池的字节等价铁证;发现不一致即池装错字节。
|
||
|
||
注:不以 local→expert 为判据(local 可能因跨调用/多模型共享 g_real 而滞后于 g_real,属路由级
|
||
问题、非落池字节问题;逐位权威信号是 e2e n_mismatch)。
|
||
"""
|
||
st = _stg_verify_state
|
||
st["calls"] += 1
|
||
pool = self._rp._pools.get(layer)
|
||
if pool is None:
|
||
return
|
||
import mlx_streaming.native_moe_ext as N
|
||
stg = self._stg
|
||
path = f"{stg.src.dir}/layer{int(layer):02d}.blob"
|
||
segs = stg.src._segs
|
||
flat = N.real_region_contents(int(layer)) # [expert0,slot0,expert1,slot1,...]
|
||
for j in range(0, len(flat), 2):
|
||
e, slot = flat[j], flat[j + 1]
|
||
raw = N.blob_load(path, mx.array([e], dtype=mx.uint32), int(stg.stride))[0]
|
||
bad, off = None, 0
|
||
for p, t, dt, shape, nb in segs:
|
||
k = f"{p}.{t}"
|
||
pv = pool[k][slot].reshape(-1).view(mx.uint8)
|
||
if not bool(mx.all(pv == raw[off:off + nb])):
|
||
bad = k
|
||
break
|
||
off += nb
|
||
if bad is None:
|
||
st["ok"] += 1
|
||
else:
|
||
st["bad"] += 1
|
||
if st["printed"] < 12:
|
||
st["printed"] += 1
|
||
print(f"[STG_VERIFY-DUAL] BAD 落池字节错 call={st['calls']} layer={layer} "
|
||
f"expert={e} slot={slot} key={bad} (ok={st['ok']} bad={st['bad']})", flush=True)
|
||
|
||
def acquire_host(self, layer, flat, inds_shape, inds_dtype, layer_cap):
|
||
"""host/fetch 路径收口(prefill/大 seq 或关 GPU-remap):flat 为 host 侧路由 id 列表。
|
||
|
||
返回 (pool_arrays, local, n_experts),与 block.py 原 host/fetch 分支逐元素等价:
|
||
- uniq <= cap:acquire(flat),local 为槽位;n_experts = layer_cap。
|
||
- uniq > cap:fetch(uniq_sorted),local 为 remap 到 [0,uniq) 连续索引;n_experts = uniq 数。
|
||
|
||
native demand(dual)下真实区槽状态归 C++ g_real,Python 的 _slot_of/_free 从不填充,
|
||
走 self._rp.acquire 必然在 _choose_victim 抛 "no evictable non-current slot"。
|
||
故 dual 下一律走 fetch 旁路(不进池、正确但慢)——这条路径本就是超容量前向的兜底。
|
||
"""
|
||
import mlx.core as mx
|
||
cap = int(layer_cap)
|
||
uniq_set = set(flat)
|
||
# 入池的充分条件是 uniq ≤ cap − pinned:acquire 把本次全部唯一专家列为不可驱逐(current),
|
||
# 腾槽时只能挑「非 pinned 且非 current」的受害者,少一个都会在 _choose_victim 抛
|
||
# "no evictable non-current slot"。pinned 是那层实际钉死数,不是拍出来的固定余量——
|
||
# 写死余量会在 cap 小于余量时把所有请求推进 fetch 慢路径(每次重读盘)。
|
||
_pinned_n = len(getattr(self._rp, "_pinned", {}).get(layer, ()) or ())
|
||
if (not getattr(self._rp, "_native_demand", False)
|
||
and len(uniq_set) <= max(1, cap - _pinned_n)):
|
||
pool, slots = self._rp.acquire(layer, flat)
|
||
local = mx.array(slots, dtype=inds_dtype).reshape(inds_shape)
|
||
return pool, local, cap
|
||
uniq_sorted = sorted(uniq_set)
|
||
remap = {g: i for i, g in enumerate(uniq_sorted)}
|
||
local = mx.array([remap[i] for i in flat], dtype=inds_dtype).reshape(inds_shape)
|
||
# 超容量(或 dual:真实区槽状态归 C++ g_real,Python _slot_of 从不填充 → acquire 必抛)
|
||
# 走 fetch 旁路:不进池、按 uniq 现堆一份参与计算,正确但慢。这条路径本就是兜底。
|
||
# fetch 由 blob 感知的 FileExpertStore 提供;ResidentExpertPool 本身没有,故优先用注入的 store。
|
||
src = self._store if self._store is not None else self._rp
|
||
if not hasattr(src, "fetch"):
|
||
raise RuntimeError("VirtualPool.acquire_host 超容量回退需要带 fetch 的 store(构造时传入)")
|
||
fetched = src.fetch(layer, uniq_sorted)
|
||
return fetched, local, len(uniq_sorted)
|
||
|
||
def prefetch(self, layer, pred, resident, pool_list):
|
||
"""向 fill 代 submit 预读:base_row = cap_for(layer) + fill_gen*spec。"""
|
||
g = self.fill_gen()
|
||
base = self._rp.cap_for(layer) + g * self._spec
|
||
return self._stg.submit_pool_sideregion(layer, pred, resident, pool_list, base, gen=g)
|