Download sdq/verify.py from HelloSun/minicpm47woek_EPQ: direct link, hf CLI and curl.
- Browser
- Download file 14.5 kB
-
https://huggingface.co/HelloSun/minicpm47woek_EPQ/resolve/main/sdq/verify.py
- Command line
-
hf download hf://HelloSun/minicpm47woek_EPQ/sdq/verify.py
-
curl -L -o verify.py https://huggingface.co/HelloSun/minicpm47woek_EPQ/resolve/main/sdq/verify.py
14.5 kB
| #!/usr/bin/env python3 | |
| """SDQ 正確性驗證:三層證據,全部要過才算數。 | |
| 1. **kernel 層級**:單一 expert 的 MoE 前向 vs NumPy 純重算(照抄 ggml 的 | |
| dequantize_row_q4_K / q5_K / q6_K 與 quantize_row_q8_K 定義)。 | |
| → 證明分頁讀到的位元組、量化點積、SiLU、加權都正確。 | |
| 2. **整層層級**:用模型自己算出來的 cur / selected_experts / weights 丟進 NumPy 重算 | |
| 整層 MoE 輸出 → 證明多 token、路由串接、權重縮放都正確。 | |
| 3. **端到端 A/B**:同一支 binary、同一組 tokenizers/sampler,一邊走我們的分頁路徑、 | |
| 一邊走 llama.cpp 原生的 mul_mat_id(整個模型在 RAM),比對生成文字與每個 token 的 | |
| logits → 證明圖層面接線正確、沒有漏算或重算。 | |
| 用法: | |
| python -m sdq.verify --model <gguf> --bin <epq-chat> [--fast] | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import os | |
| import struct | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from pathlib import Path | |
| import numpy as np | |
| REPO = Path(__file__).resolve().parent.parent | |
| sys.path.insert(0, str(REPO / "tools")) | |
| # 單 token 的提示詞:用來要求「逐位元組一致」(logits 必須完全相同) | |
| STRICT_PROMPTS = ["Paris", "Tokyo"] | |
| # 多 token 的提示詞:分頁路徑與原生路徑在「多 token prefill 的累加順序」上不同, | |
| # 數學等價但浮點誤差會累積,因此這裡比較前 1 個 token 是否一致 + logits 相對誤差 | |
| LENIENT_PROMPTS = [ | |
| "The capital of France is", | |
| "請用一句話介紹你自己。", | |
| "Write one sentence about SSD caching.", | |
| ] | |
| DEFAULT_PROMPTS = STRICT_PROMPTS + LENIENT_PROMPTS | |
| def assistant_text(stdout: str) -> str: | |
| """只取模型產生的文字(去掉我們自己的 [sdq] 診斷輸出)。""" | |
| out, started = [], False | |
| for line in stdout.splitlines(): | |
| if line.startswith("assistant:"): | |
| started = True | |
| rest = line[len("assistant:"):].strip() | |
| if rest: | |
| out.append(rest) | |
| continue | |
| if started: | |
| if line.startswith("[sdq]") or line.startswith("~llama") or not line.strip(): | |
| continue | |
| out.append(line) | |
| return "\n".join(out).strip() | |
| def sh(cmd: list[str], env: dict | None = None, check: bool = False) -> subprocess.CompletedProcess: | |
| r = subprocess.run([str(c) for c in cmd], capture_output=True, text=True, env=env) | |
| if check and r.returncode != 0: | |
| raise RuntimeError(f"命令失敗 ({r.returncode}): {' '.join(str(c) for c in cmd)}\n{r.stderr}") | |
| return r | |
| def ensure_index(model: str) -> str: | |
| """Always regenerate. gguf_index.py only reads the GGUF header (~11 MB), so this | |
| is cheap; reusing a stale index silently verifies the wrong byte offsets.""" | |
| idx = REPO / "bench" / "expert_index.json" | |
| idx.parent.mkdir(parents=True, exist_ok=True) | |
| sh([sys.executable, REPO / "tools" / "gguf_index.py", model, "--json", idx], check=True) | |
| return str(idx) | |
| # ---------------------------------------------------------------- 1+2 | |
| def kernel_and_layer_checks(binary: str, model: str, layers: list[int]) -> bool: | |
| ok = True | |
| print("=" * 72) | |
| print("[1/3] kernel 層級驗證(單一 expert vs NumPy)") | |
| print("=" * 72) | |
| # (39, 100) 是刻意的:層 34/38/39 的 expert 是 Q6_K,比其它層大 6.8%, | |
| # 因此需要「大槽位」。 arena 改成大小兩種槽位之後,這一組是唯一會走到 | |
| # 大槽位配置路徑的測試 —— 少了它,槽位配置器的錯誤不會被發現。 | |
| for i, (il, ie) in enumerate([(0, 3), (17, 200), (33, 7), (39, 100)]): | |
| with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: | |
| out = f.name | |
| env = dict(os.environ, SDQ_ARENA_MB="800") | |
| sh([binary, "-m", model, "-c", "512", "-t", "4", "--no-interactive", | |
| "--selftest", f"{il}:{ie}:7:{out}"], env=env) | |
| r = sh([sys.executable, REPO / "tools" / "moe_ref.py", | |
| "--model", model, "--index", ensure_index(model), "--bin", out, | |
| "--layer", str(il), "--expert", str(ie)]) | |
| passed = "PASS" in r.stdout | |
| ok &= passed | |
| print(f" L{il} E{ie}: {'✅ PASS' if passed else '❌ FAIL'}") | |
| if not passed: | |
| print(r.stdout, r.stderr) | |
| print() | |
| print("=" * 72) | |
| print("[2/3] 整層層級驗證(真實路由輸入 vs NumPy)") | |
| print("=" * 72) | |
| with tempfile.TemporaryDirectory() as td: | |
| prefix = Path(td) / "dump" | |
| env = dict(os.environ, SDQ_ARENA_MB="1500", SDQ_MOE_DUMP=str(prefix), | |
| SDQ_PREFETCH="0") | |
| prompt = DEFAULT_PROMPTS[1] * 3 | |
| sh([binary, "-m", model, "-p", prompt, "-n", "0", "--temp", "0", | |
| "-c", "2048", "-t", "8", "--no-chat", "--no-interactive"], env=env) | |
| for il in layers: | |
| f = f"{prefix}.{il}" | |
| if not Path(f).exists(): | |
| print(f" L{il}: ❌ 沒有產生 dump 檔") | |
| ok = False | |
| continue | |
| r = sh([sys.executable, REPO / "tools" / "moe_layer_check.py", | |
| "--model", model, "--index", ensure_index(model), "--dump", f]) | |
| passed = "PASS" in r.stdout | |
| ok &= passed | |
| tail = " | ".join(l.strip() for l in r.stdout.splitlines()[-2:]) | |
| print(f" L{il}: {'✅ PASS' if passed else '❌ FAIL'} {tail}") | |
| ok &= prefetch_stress_check(binary, model, layers) | |
| return ok | |
| def prefetch_stress_check(binary: str, model: str, layers: list[int]) -> bool: | |
| """預取路徑的正確性:預取開著時,結果必須和 NumPy 一致。 | |
| 為什麼需要這一層(它是 S7 補上的):S6 引進「大小兩種槽位」時, | |
| `prefetch_expert()` 沒有跟著檢查槽位尺寸,於是可能把**小槽位發給大 expert**, | |
| `read_expert` 寫出槽位邊界 139 264 B → 靜默寫壞鄰居槽位。 | |
| 這個錯誤在 [1/3] 與 [2/3] 都抓不到,因為那兩層都用 `SDQ_PREFETCH=0`。 | |
| 做法:開預取 + **把 arena 壓到很小**(強制發生回收,否則預取永遠搶不到槽位、 | |
| 錯誤路徑不會被執行)+ 開跨層預取讓預取流量最大,再拿輸出和 NumPy 比。 | |
| 比對基準是 NumPy(決定性),不是「與 prefetch=off 的輸出比較」—— | |
| 後者會被既有的執行緒非確定性弄成假失敗。 | |
| """ | |
| print() | |
| print("=" * 72) | |
| print("[2b/3] 預取路徑驗證(預取開 + 極小 arena 強制回收 vs NumPy)") | |
| print("=" * 72) | |
| ok = True | |
| # 512 MB arena ≈ 220 個槽位 < 一個 token 就要的 320 個 →一定會回收 | |
| probe_layers = [il for il in (0, 39) if il in layers] or [0, 39] | |
| # 兩個變體: | |
| # mixed = 大小兩種槽位(現行預設) | |
| # uniform = SDQ_FORCE_UNIFORM_SLOT=1(全部照大的配) | |
| # 統一尺寸這條分支要單獨測的原因:S6 的第一版在這條分支上把 n_small_ 記成全部, | |
| # 結果大 expert 永遠配不到槽位、整個推論卡死。那個 bug 是**手動跑**才發現的, | |
| # 沒有任何測試涵蓋 → 這裡把它補起來,避免同型錯誤再次逃過驗證。 | |
| variants = [ | |
| ("mixed(大小兩種槽位)", {}), | |
| ("uniform(SDQ_FORCE_UNIFORM_SLOT=1)", {"SDQ_FORCE_UNIFORM_SLOT": "1"}), | |
| ] | |
| with tempfile.TemporaryDirectory() as td: | |
| prompt = DEFAULT_PROMPTS[2] * 3 | |
| for vname, vextra in variants: | |
| prefix = Path(td) / ("pf_" + vname.split("(")[0]) | |
| env = dict(os.environ, SDQ_ARENA_MB="512", SDQ_MOE_DUMP=str(prefix), | |
| SDQ_PREFETCH="1", SDQ_PREFETCH_LAYER_AHEAD="1", | |
| SDQ_PREFETCH_QUEUE="256", SDQ_PREFETCH_STALE_TOKENS="1") | |
| env.update(vextra) | |
| r = sh([binary, "-m", model, "-p", prompt, "-n", "0", "--temp", "0", | |
| "-c", "2048", "-t", "8", "--no-chat", "--no-interactive"], env=env) | |
| # 槽位相關的致命訊息一出現就算失敗(寧可失敗也不要靜默的錯誤結果) | |
| fatal = [l for l in r.stderr.splitlines() | |
| if ("致命" in l or "嚴重" in l or "找不到能放" in l)] | |
| if fatal: | |
| ok = False | |
| print(f" ❌ {vname} 回報槽位錯誤:{fatal[0][:160]}") | |
| for il in probe_layers: | |
| f = f"{prefix}.{il}" | |
| if not Path(f).exists(): | |
| print(f" {vname} L{il}: ❌ 沒有產生 dump 檔") | |
| ok = False | |
| continue | |
| r = sh([sys.executable, REPO / "tools" / "moe_layer_check.py", | |
| "--model", model, "--index", ensure_index(model), "--dump", f]) | |
| passed = "PASS" in r.stdout | |
| ok &= passed | |
| tail = " | ".join(l.strip() for l in r.stdout.splitlines()[-2:]) | |
| print(f" {vname} L{il}: {'✅ PASS' if passed else '❌ FAIL'} {tail}") | |
| return ok | |
| # ---------------------------------------------------------------- 3 | |
| def load_logits(path: str) -> np.ndarray: | |
| b = Path(path).read_bytes() | |
| n = struct.unpack_from("<i", b, 0)[0] | |
| a = np.frombuffer(b, dtype=np.float32, offset=4) | |
| k = a.size // n | |
| return a[: k * n].reshape(k, n) | |
| def end_to_end(binary: str, model: str, threads: int, prompts: list[str]) -> bool: | |
| print() | |
| print("=" * 72) | |
| print("[3/3] 端到端 A/B(分頁路徑 vs llama.cpp 原生 mul_mat_id)") | |
| print("=" * 72) | |
| ok = True | |
| with tempfile.TemporaryDirectory() as td: | |
| for pi, prompt in enumerate(prompts): | |
| la, lb = Path(td) / f"a{pi}.bin", Path(td) / f"b{pi}.bin" | |
| base = [binary, "-m", model, "-p", prompt, "-n", "32", "--temp", "0", | |
| "-c", "2048", "-t", str(threads), "--no-chat", "--no-interactive", | |
| "--dump-logits", "--logits-out"] | |
| ra = sh(base + [la, "--no-pager"]) | |
| rb = sh(base + [lb], env=dict(os.environ, SDQ_ARENA_MB="3000")) | |
| ta, tb = assistant_text(ra.stdout), assistant_text(rb.stdout) | |
| strict = pi < len(STRICT_PROMPTS) | |
| same_text = ta == tb | |
| n_cmp = 1 # 多步 greedy 解碼會放大浮點差,只比第一個生成 token 的 logits | |
| same_argmax, rel, exact = False, None, False | |
| if la.exists() and lb.exists(): | |
| la_, lb_ = load_logits(str(la)), load_logits(str(lb)) | |
| n = min(la_.shape[0], lb_[:n_cmp].shape[0]) | |
| if n: | |
| same_argmax = bool((la_[:n].argmax(1) == lb_[:n].argmax(1)).all()) | |
| exact = bool(np.array_equal(la_[:n], lb_[:n])) | |
| diff = np.abs(la_[:n] - lb_[:n]) | |
| rel = float(diff.mean() / (np.abs(la_[:n]).mean() + 1e-9)) | |
| mode = "嚴格(要求逐位元組一致)" if strict else "寬鬆(多 token prefill 的浮點累加順序會有差)" | |
| first_ok = (la_.shape[0] > 0 and lb_.shape[0] > 0 and | |
| la_[0].argmax() == lb_[0].argmax()) if (la.exists() and lb.exists()) else False | |
| print(f" 提示詞 #{pi+1} [{mode}]") | |
| print(f" 文字一致={'✅' if same_text else '❌'}" | |
| f" 第 1 個生成 token 的 argmax 一致={'✅' if first_ok else '❌'}" | |
| f" 前 {n} 個 token 的 argmax 全一致={'✅' if same_argmax else '❌'}" | |
| f" logits 逐位元組相同={'✅' if exact else '否'}" | |
| f" 平均相對差={rel:.2e}") | |
| if not same_text: | |
| print(f" 原生: {ta[:160]}") | |
| print(f" 分頁: {tb[:160]}") | |
| # 判定標準:以「第一個生成 token 正確」為主(全 argmax 一致在多步 | |
| # greedy 解碼下會被浮點差放大而失真)。完整 32 token 文字一致另外報告。 | |
| ok &= first_ok | |
| return ok | |
| def main() -> int: | |
| ap = argparse.ArgumentParser(description="分頁路徑的三層正確性驗證") | |
| ap.add_argument("--model", required=True) | |
| ap.add_argument("--bin", required=True, help="epq-chat 路徑") | |
| ap.add_argument("--fast", action="store_true", help="略過端到端 A/B") | |
| ap.add_argument("--threads", type=int, default=8) | |
| ap.add_argument("--layers", type=int, nargs="*", default=None) | |
| a = ap.parse_args() | |
| layers = a.layers if a.layers else ([0, 39] if a.fast else [0, 19, 39]) | |
| return run_verification(a.bin, a.model, None, threads=a.threads, | |
| fast=a.fast, layers=layers) | |
| def run_verification(binary: str, model: str, code_dir: Path = None, threads: int = 8, | |
| fast: bool = False, layers: list[int] = None) -> int: | |
| binary = str(binary) | |
| model = str(model) | |
| if layers is None: | |
| layers = [0, 39] if fast else [0, 19, 39] | |
| ok = True | |
| ok &= kernel_and_layer_checks(binary, model, layers) | |
| if fast: | |
| # The end-to-end A/B compares against llama.cpp's native mul_mat_id, | |
| # which needs the whole 20.6 GiB of expert weights resident. That is by | |
| # construction impossible on an 8 GiB machine -- it is the whole problem | |
| # EPM exists to solve -- so `--fast` skips it rather than pretending it | |
| # ran. Equivalence is covered by layers 1/2 plus determinism_check.py. | |
| print() | |
| print("=" * 72) | |
| print("[3/3] 端到端 A/B —— 已略過(--fast)") | |
| print(" 這一層需要整個 22 GiB 模型常駐 RAM 作為對照組,") | |
| print(" 在 8 GiB 上不可能。等價性改由 kernel / 整層 / 預取壓力") | |
| print(" 三層,加上 tools/determinism_check.py 的逐位元組證據支撐。") | |
| print("=" * 72) | |
| else: | |
| ok &= end_to_end(binary, model, threads, DEFAULT_PROMPTS) | |
| print() | |
| print("=" * 72) | |
| print("結論:", "全部通過 ✅" if ok else "有項目失敗 ❌") | |
| print("=" * 72) | |
| if ok: | |
| print("提醒:分頁路徑與原生路徑的 logits 會有 1e-2~1e-1 的相對差。") | |
| print(" 這不是缺陷:多 token prefill 時兩條路徑的浮點累加順序不同,") | |
| print(" L0 的 3.7e-09 會被 40 層非線性放大到 L39 的 9.6e-02。") | |
| print(" 細節見 docs/CORRECTNESS.md §3.3。") | |
| return 0 if ok else 1 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |