sparkle/mlx_streaming/model_builder.py
fiser_jun 4745f264b2
2026-08-04 14:34:00 +08:00

253 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""共享装配层:把"文件后端流式加载主模型 + 取 hidden + 贪心生成"等跨入口复用的逻辑
集中在这里,而不是藏在某个入口脚本(原 validate_mtp)里。
依赖关系:本模块位于 core(mem/expert_store/streaming_moe)与 mtp 之上,
是把它们粘合成可运行模型的装配层,被 cli/ 各入口与测试复用。
环境变量:
MODEL 主模型路径(MLX 量化)
EXPERT_DIR 拆分/重量化后的 per-expert safetensors 目录
EXPERT_SLOTS 每层常驻池容量
EXPERT_POOL_PROFILE 每层池预算 JSON(无损省内存,可选)
HIDDEN_VARIANT pre_final_norm(默认)| post_final_norm(排错时切换)
"""
import json
import os
import mlx.core as mx
from mlx_lm import load
from mlx_lm.models.base import create_attention_mask, create_ssm_mask
from mlx_streaming import config
from mlx_streaming.core.cache.expert_store import FileExpertStore
from mlx_streaming.core.prefetch.patch import patch_model_filebacked
MODEL = config.model_path()
EXPERT_DIR = config.expert_dir()
EXPERT_SLOTS = config.expert_slots()
# pre_final_norm(默认)| post_final_norm(排错时切换)
HIDDEN_VARIANT = config.hidden_variant()
# 默认 profile 文件名:放在 EXPERT_DIR 下随专家目录一起走,常用跑法自动启用(无损省内存)
DEFAULT_PROFILE_NAME = "pool_profile.json"
def load_pool_profile(expert_dir: str) -> "dict[int, int] | None":
"""解析每层池预算 profile,返回 layer_caps 或 None。
优先级:环境变量 EXPERT_POOL_PROFILE 显式指定路径 > {expert_dir}/pool_profile.json 默认。
EXPERT_POOL_PROFILE=none/0/off 显式关闭(回到 uniform capacity)。
profile 无损:仅按各层真实工作集分配,命中率/输出/吞吐不变(caps 仍被 capacity 上限钳制)。
"""
p = config.expert_pool_profile()
if p.lower() in ("none", "0", "off"):
return None
if not p: # 未显式指定 → 默认找专家目录下的 profile
cand = os.path.join(expert_dir, DEFAULT_PROFILE_NAME)
p = cand if os.path.exists(cand) else ""
if p and os.path.exists(p):
with open(p) as f:
caps = json.load(f).get("layer_caps", {})
return {int(k): int(v) for k, v in caps.items()}
return None
def build_streaming_model():
"""用文件后端流式 patch 加载主模型(32GB 机器装不下 41GB 非流式)。"""
model, tok = load(MODEL, lazy=True)
# 取首个 MoE 维度
dims = None
for layer in model.layers:
mlp = getattr(layer, "mlp", None)
if mlp is not None and hasattr(mlp, "switch_mlp") and hasattr(mlp, "gate"):
gp = mlp.switch_mlp.gate_proj
dims = {"hidden": gp.input_dims, "moe_inter": gp.output_dims,
"group_size": getattr(gp, "group_size", 64),
"bits": getattr(gp, "bits", 4)}
break
bits, group, proj_bits, layer_proj_bits = (
dims["bits"], dims["group_size"], None, None)
meta_path = os.path.join(EXPERT_DIR, "_split_meta.json")
if os.path.exists(meta_path):
with open(meta_path) as f:
meta = json.load(f)
ed = meta.get("dims", {})
bits = ed.get("bits", bits)
group = ed.get("group_size", group)
proj_bits = ed.get("proj_bits")
if "per_layer_proj_bits" in ed:
layer_proj_bits = {int(k): v for k, v in ed["per_layer_proj_bits"].items()}
# 每层池预算 profile(pool_footprint 产出):默认从 {EXPERT_DIR}/pool_profile.json 自动启用,
# 无损省内存(命中率/输出/吞吐不变,仅不再为低占用层预留满 capacity)。
layer_caps = load_pool_profile(EXPERT_DIR)
store = FileExpertStore(EXPERT_DIR, capacity=EXPERT_SLOTS, layer_caps=layer_caps)
if config.zerocopy_dual_source():
# 零拷贝双源双缓冲:常驻池换成侧区模式(预分配 cap+2*spec_slots 行、禁 grow复用原池 loader/cap/profile。
from mlx_streaming.core.cache.resident_pool import ResidentExpertPool
_old = store._resident
# 默认单缓冲(持久 LFU,一份工作集,省一半侧区内存=生产路径);仅显式 legacy(SIDEREGION_LFU=0)用双缓冲。
_spec_gens = 1 if config.sideregion_lfu() else 2
store._resident = ResidentExpertPool(
_old.capacity, loader=_old.loader, layer_caps=_old.layer_caps,
spec_slots=config.pool_spec_slots(),
spec_gens=_spec_gens)
if config.stream_blob_loader():
# blob 接入常驻池 miss-loader复用 GPU-remap 快路径,小 EXPERT_SLOTS 即低内存。
store._blob_loader = _make_blob_source(dims, group, bits)
# 主动预取native-fused-prefetch miss→hitopt-inNATIVE_FUSED_PREFETCH=1
# 经"promote 只写真实路由命中专家"修正后已是净正:易缓存基座上 +15.5% tok/s
# demand 11.86→13.70hit 0.731→0.851,读盘 45%;见 active-prefetch-turnaround-2026-06-17.md
# 默认关只因收益依赖场景(基座可缓存性/是否磁盘受限)且只影响速度不影响质量、多占少量 staging 内存,
# 故作 opt-in 而非默认路径,落地配方见上述报告 §6。
if config.native_fused_prefetch() and getattr(store, "_blob_loader", None) is not None:
try:
import mlx_streaming.native_moe_ext # noqa: F401 确认扩展已编译
from mlx_streaming.core.prefetch.native_staging import NativeStagingManager
_budget = (config.pool_spec_slots() if config.zerocopy_dual_source()
else config.stream_blob_bg_budget(default=16))
store._staging = NativeStagingManager(store._blob_loader, budget=_budget)
except Exception:
store._staging = None # 扩展不可用 → 关闭,不影响主路径
# 零拷贝双源不变量staging 侧区行数必须等于池 spec_slots否则 C++ 会越界写池(静默损坏)。
if config.zerocopy_dual_source() and getattr(store, "_staging", None) is not None:
assert store._staging.budget == store._resident.spec_slots, (
f"零拷贝双源要求 staging.budget({store._staging.budget}) "
f"== 池 spec_slots({store._resident.spec_slots})")
if config.stream_blob_bg():
# 后台预取池预填bg 在独立 stream 物化预测专家promote 写进池槽(需 CROSS_LAYER_PREFETCH=1
from mlx_streaming.core.prefetch.bg_prefetch import BackgroundExpertPrefetcher
src = _make_blob_source(dims, group, bits)
store._blob_loader = src
store._bg = BackgroundExpertPrefetcher(
src, window=config.stream_blob_window())
patch_model_filebacked(model, store, dims["hidden"], dims["moe_inter"],
group, bits, proj_bits=proj_bits,
layer_proj_bits=layer_proj_bits)
# 双源双缓冲:构造一个共享 VirtualPoolgen 跨层全局、每前向 +1挂到每个流式 MoE 块。
if config.zerocopy_dual_source() and getattr(store, "_staging", None) is not None:
from mlx_streaming.core.cache.virtual_pool import VirtualPool
from mlx_streaming.core.moe.block import FileStreamingMoeBlock
# 双源模式仍需 ahead 调度block._native_fused_prefetch 靠 target_for 选目标层,
# 不传调度参数会让 target_for 恒返回 0_num_layers=0→ 预取全跳过、侧区永远空。
_vpool = VirtualPool(store._resident, store._staging, config.pool_spec_slots(),
num_layers=len(model.layers),
cutoff=config.cross_layer_cutoff(),
ahead_lo=config.cross_layer_ahead_lo(),
ahead_hi=config.cross_layer_ahead_hi(),
store=store)
for layer in model.layers:
mlp = getattr(layer, "mlp", None)
if isinstance(mlp, FileStreamingMoeBlock):
mlp._vpool = _vpool
# 主动预取(非 zerocopy挂 per-layer ahead 调度器 vpoolcutoff让晚层预读更早发起。
if (config.native_fused_prefetch() and not config.zerocopy_dual_source()
and getattr(store, "_staging", None) is not None):
from mlx_streaming.core.cache.virtual_pool import VirtualPool
from mlx_streaming.core.moe.block import FileStreamingMoeBlock
_sched = VirtualPool(num_layers=len(model.layers),
cutoff=config.cross_layer_cutoff(),
ahead_lo=config.cross_layer_ahead_lo(),
ahead_hi=config.cross_layer_ahead_hi())
for layer in model.layers:
mlp = getattr(layer, "mlp", None)
if isinstance(mlp, FileStreamingMoeBlock):
mlp._vpool = _sched
if config.stream_blob():
_attach_blob_source(model, dims, group, bits)
# KV 量化(IsoQuant K4/V3 + SO(4) 旋转):仅作用于 12 个全注意力层,128k KV 3.0→~0.68 GiB。
if config.kv_quant():
from mlx_streaming.core.cache.kv_quant_patch import patch_kv_quant
patch_kv_quant(model,
group_size=config.kv_group_size(),
k_bits=config.kv_k_bits(),
v_bits=config.kv_v_bits(),
rotate=config.kv_rotate(),
seed=config.kv_rot_seed())
# AUTOPIN 预热钉死(池 + blob_loader + staging 就绪后、return 前):按历史路由热度把每层
# top-N 热专家预填进常驻池并 pin 住(不参与任何驱逐),消灭冷启动慢热;usage 缺失则跳过
# (会话中计数仍在累计,落盘供下次启动)。AUTOPIN=0 零行为。
if config.autopin():
from mlx_streaming.core.cache import autopin as _autopin
_ap = _autopin.warm_start_pin(store)
if _ap["pinned"]:
print(f"[AUTOPIN] 预热 pin: {_ap['layers']} 层共 {_ap['pinned']} 专家 "
f"(frac={config.autopin_budget_frac()}, 耗时 {_ap['seconds']}s)"
+ (f", 跳过 {_ap['skipped_layers']}" if _ap["skipped_layers"] else ""),
flush=True)
# 把 store 挂到 model 上,供只拿到 model 的调用方(server /api/stats 读专家池健康度)取用。
# 非 array/dict/list/tuple 走 object 属性,不进 nn.Module 参数树。
model._expert_store = store
return model, tok, store
def _make_blob_source(dims, group, bits):
from mlx_streaming.core.cache.blob_loader import BlobExpertSource
blob_dir = config.blob_dir() or os.path.join(EXPERT_DIR, "blobs")
workers = config.stream_blob_workers()
nocache = config.stream_blob_nocache(default="0")
num_experts = 512
idx_path = os.path.join(blob_dir, "blob_index.json")
if os.path.exists(idx_path):
with open(idx_path) as f:
num_experts = int(json.load(f).get("num_experts", num_experts))
return BlobExpertSource(blob_dir, dims["hidden"], dims["moe_inter"], group, bits,
num_experts=num_experts, workers=workers, nocache=nocache)
def _attach_blob_source(model, dims, group, bits):
"""STREAM_BLOB=1给每个流式 MoE 块注入共享 BlobExpertSource全流式低内存路径"""
from mlx_streaming.core.cache.blob_loader import BlobExpertSource
from mlx_streaming.core.moe.block import FileStreamingMoeBlock
blob_dir = config.blob_dir() or os.path.join(EXPERT_DIR, "blobs")
workers = config.stream_blob_workers()
nocache = config.stream_blob_nocache(default="1")
num_experts = 512
idx_path = os.path.join(blob_dir, "blob_index.json")
if os.path.exists(idx_path):
with open(idx_path) as f:
num_experts = int(json.load(f).get("num_experts", num_experts))
src = BlobExpertSource(blob_dir, dims["hidden"], dims["moe_inter"], group, bits,
num_experts=num_experts, workers=workers, nocache=nocache)
for layer in model.layers:
mlp = getattr(layer, "mlp", None)
if isinstance(mlp, FileStreamingMoeBlock):
mlp._blob = src
def capture_prenorm_hidden(model, input_ids: mx.array) -> mx.array:
"""跑主模型层循环但跳过最后的 model.norm,返回 last-layer hidden(norm 前)。
HIDDEN_VARIANT=post_final_norm 时返回 norm 之后(用于消歧排错)。
"""
inner = model.model
h = inner.embed_tokens(input_ids)
layers = inner.layers
if not layers:
return h
cache = model.make_cache()
fa_idx = next((i for i, l in enumerate(layers) if not l.is_linear), 0)
ssm_idx = next((i for i, l in enumerate(layers) if l.is_linear), 0)
fa_mask = create_attention_mask(h, cache[fa_idx])
ssm_mask = create_ssm_mask(h, cache[ssm_idx])
for layer, c in zip(layers, cache):
mask = ssm_mask if layer.is_linear else fa_mask
h = layer(h, mask=mask, cache=c)
if HIDDEN_VARIANT == "post_final_norm":
h = inner.norm(h)
return h
def greedy(model, input_ids: mx.array, n: int) -> mx.array:
"""主模型贪心生成 n 个 token,返回拼接后的完整序列(用作自投机参考)。"""
cache = model.make_cache()
cur = input_ids
out = []
for _ in range(n):
logits = model(cur, cache=cache)
nxt = mx.argmax(logits[:, -1, :], axis=-1, keepdims=True)
out.append(nxt)
cur = nxt
mx.eval(nxt)
return mx.concatenate([input_ids] + out, axis=1)