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

181 lines
8.5 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.

"""路线 B 端到端lazy 加载 + 离线拆分专家 + 文件后端流式 + generate量内存/速度/命中率。
环境变量:
MODEL 模型 repo 或本地路径(默认 models/Qwen3-Next-80B-A3B-Instruct-MLX-8bit
EXPERT_DIR per-expert 拆分输出目录(默认 /tmp/mlx_qwen3_experts
EXPERT_SLOTS 每层 LRU 专家槽数(默认 8=top_kworst-case 常驻≈槽数×MoE层数
PROMPT/MAXTOK 提示与生成 token 数
WIRED_GB 可选set_wired_limit 上限GB
CACHE_GB 可选set_cache_limit 上限GB控制 MLX 缓冲复用上界
"""
import os
import time
import json
import statistics
import mlx.core as mx
from mlx_lm import load, generate
from mlx_streaming import config
from mlx_streaming.core.mem import snapshot, reset_peak, clear_cache
from mlx_streaming.core.cache.expert_store import FileExpertStore
from mlx_streaming.prep.split_experts import split_model
from mlx_streaming.core.prefetch.patch import patch_model_filebacked
from mlx_streaming.model_builder import load_pool_profile
MODEL = os.environ.get("MODEL", "models/Qwen3-Next-80B-A3B-Instruct-MLX-8bit")
EXPERT_DIR = os.environ.get("EXPERT_DIR", "/tmp/mlx_qwen3_experts")
EXPERT_SLOTS = int(os.environ.get("EXPERT_SLOTS", "8")) # 每层槽数
PROMPT = os.environ.get("PROMPT", "用三句话解释什么是混合专家模型。")
MAXTOK = int(os.environ.get("MAXTOK", "128"))
# 稳态测速:warmup 跑满 MAXTOK(把整段会路由到的专家都装进常驻池 + 编译 Metal kernel),
# 再重复 REPEAT 次取中位数,避免冷启动/补池污染绝对 tok/s。WARMUP_TOK=0 关 warmup。
WARMUP_TOK = int(os.environ.get("WARMUP_TOK", str(MAXTOK)))
REPEAT = int(os.environ.get("REPEAT", "3"))
WIRED_GB = os.environ.get("WIRED_GB")
CACHE_GB = os.environ.get("CACHE_GB")
CLEAR_ON_EVICT = os.environ.get("CLEAR_ON_EVICT", "0") == "1"
PIN_HOT = int(os.environ.get("PIN_HOT", "0")) # ③ 每层钉住的热专家数0=关)
CAL_TOK = int(os.environ.get("CAL_TOK", "32")) # 校准生成 token 数
# 专家量化格式覆盖:当 EXPERT_DIR 指向重量化目录(2/3-bit)时,文件后端 QSL 要用对应 bit/group
EXPERT_BITS = os.environ.get("EXPERT_BITS")
EXPERT_GROUP = os.environ.get("EXPERT_GROUP")
def _first_moe_dims(model):
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
return {
"hidden": gp.input_dims, "moe_inter": gp.output_dims,
"group_size": getattr(gp, "group_size", 64),
"bits": getattr(gp, "bits", 4),
}
raise RuntimeError("模型里没有找到 MoE 层")
def main():
if WIRED_GB:
fn = getattr(mx, "set_wired_limit", None)
if fn:
print("set_wired_limit 旧值=", fn(int(float(WIRED_GB) * 1e9)))
if CACHE_GB:
fn = getattr(mx, "set_cache_limit", None)
if fn:
print("set_cache_limit 旧值=", fn(int(float(CACHE_GB) * 1e9)))
# 1. 拆分专家到磁盘(若尚未拆分)
if not os.path.exists(os.path.join(EXPERT_DIR, "_split_meta.json")):
print("拆分专家到", EXPERT_DIR, "...")
t = time.perf_counter()
meta = split_model(MODEL, EXPERT_DIR)
print("拆分完成", round(time.perf_counter() - t, 1), "s; MoE 层=", len(meta["moe_layers"]))
reset_peak()
# 2. lazy 加载(不强制 eval 全部)
t0 = time.perf_counter()
model, tok = load(MODEL, lazy=True)
dims = _first_moe_dims(model)
# 专家目录 meta 是 bit/group/proj_bits 的权威来源(重量化目录都会写);先用它覆盖,
# 再让 EXPERT_BITS/EXPERT_GROUP 环境变量做最高优先级手动覆盖。proj_bits 非空走混合精度。
proj_bits = None
layer_proj_bits = None
meta_path = os.path.join(EXPERT_DIR, "_split_meta.json")
if os.path.exists(meta_path):
with open(meta_path) as f:
ed = json.load(f).get("dims", {})
dims["bits"] = ed.get("bits", dims["bits"])
dims["group_size"] = ed.get("group_size", dims["group_size"])
proj_bits = ed.get("proj_bits")
if "per_layer_proj_bits" in ed: # 逐层混合:键转回 int 层号
layer_proj_bits = {int(k): v for k, v in ed["per_layer_proj_bits"].items()}
if EXPERT_BITS:
dims["bits"] = int(EXPERT_BITS)
if EXPERT_GROUP:
dims["group_size"] = int(EXPERT_GROUP)
# 每层池预算 profile:默认从 {EXPERT_DIR}/pool_profile.json 自动启用(无损省内存)
# EXPERT_POOL_PROFILE 可显式指定路径或设 none 关闭。命中率/输出/吞吐不变。
layer_caps = load_pool_profile(EXPERT_DIR)
# 3. 文件后端 patch丢弃常驻堆叠 switch_mlp
store = FileExpertStore(EXPERT_DIR, capacity=EXPERT_SLOTS, layer_caps=layer_caps,
clear_on_evict=CLEAR_ON_EVICT, record=PIN_HOT > 0)
n = patch_model_filebacked(model, store, dims["hidden"], dims["moe_inter"],
dims["group_size"], dims["bits"],
proj_bits=proj_bits, layer_proj_bits=layer_proj_bits)
t1 = time.perf_counter()
after_patch = snapshot()
# 分块 prefill:把 mlx_lm.generate 内部 prefill 步长压到 config.prefill_chunk()(默认 2),
# 整段 prefill 的激活峰值 ∝prompt 长度 → ∝chunk,使 prefill 与 decode 同稳态。
# PREFILL_CHUNK=0 时不传,回退 mlx_lm 默认 2048。
_ps = config.prefill_chunk()
_gen_kw = {"prefill_step_size": _ps} if _ps > 0 else {}
# 3.5 ③ 热专家常驻:先校准跑一遍统计激活频率,钉住每层最热的 PIN_HOT 个专家
cal_s = 0.0
if PIN_HOT > 0:
tc = time.perf_counter()
generate(model, tok, prompt=PROMPT, max_tokens=CAL_TOK, verbose=False, **_gen_kw)
for li in store.recorded_layers():
store.pin(li, store.hot(li, PIN_HOT))
store.record = False
store.reset_stats()
cal_s = round(time.perf_counter() - tc, 2)
# 3.6 warmup:跑满 MAXTOK 把整段专家装进常驻池并编译 Metal kernel(PIN_HOT 已校准时
# 这里仍补满全长,确保第一次正式测量即稳态)。
if WARMUP_TOK > 0:
generate(model, tok, prompt=PROMPT, max_tokens=WARMUP_TOK, verbose=False, **_gen_kw)
# 4. 生成:重复 REPEAT 次取中位数;最后一次清零专家统计供命中率口径对应稳态。
reset_peak()
text = None
gen_runs = []
for r in range(REPEAT):
if r == REPEAT - 1:
store.reset_stats()
tg = time.perf_counter()
text = generate(model, tok, prompt=PROMPT, max_tokens=MAXTOK, verbose=False, **_gen_kw)
gen_runs.append(round(MAXTOK / (time.perf_counter() - tg), 2))
tok_per_s = statistics.median(gen_runs)
t2 = time.perf_counter()
clear_cache()
after_gen = snapshot()
out = {
"mode": "streaming_filebacked", "model": MODEL,
"expert_dir": EXPERT_DIR,
"expert_bits": dims["bits"], "expert_group": dims["group_size"],
"proj_bits": proj_bits, "layered": layer_proj_bits is not None,
"per_layer_slots": EXPERT_SLOTS, "pool_profile": bool(layer_caps),
"wired_gb": WIRED_GB, "cache_gb": CACHE_GB,
"clear_on_evict": CLEAR_ON_EVICT,
"pin_hot": PIN_HOT, "cal_s": cal_s,
"warmup_tok": WARMUP_TOK, "repeat": REPEAT,
"patched_moe_layers": n,
"resident_experts": store.resident_count(),
"pinned_experts": store.pinned_count(),
"load_patch_s": round(t1 - t0, 2),
"tok_per_s": tok_per_s,
"tok_per_s_runs": gen_runs,
"tok_per_s_minmax": [min(gen_runs), max(gen_runs)],
"rss_gb_after_patch": round(after_patch.rss_bytes / 1e9, 2),
"rss_gb_after_gen": round(after_gen.rss_bytes / 1e9, 2),
"mlx_active_gb_after_gen": round(after_gen.mlx_active_bytes / 1e9, 2),
"mlx_peak_gb": round(after_gen.mlx_peak_bytes / 1e9, 2),
"expert_hit_rate": round(store.hit_rate(), 4),
"expert_hits": store.hits, "expert_misses": store.misses,
# GPU remap 取证:整层全命中走 GPU 快路径 vs 有 miss 回退 host 的层调用占比
"gpu_fastpath": store._resident.gpu_fastpath,
"gpu_fallback": store._resident.gpu_fallback,
"gpu_fastpath_frac": round(
store._resident.gpu_fastpath
/ max(1, store._resident.gpu_fastpath + store._resident.gpu_fallback), 4),
"sample": text[:240],
}
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()