enginex-bi100-compat: 补传 preflight/README/shim/自测 + 扁平化 Dockerfile(仓库无子目录,网页上传不再丢相对路径)

This commit is contained in:
2026-10-03 18:32:49 +08:00
parent 391302289d
commit 601a11ccf2
5 changed files with 696 additions and 6 deletions

View File

@@ -15,6 +15,9 @@
#
# 为什么选 bi-100:本人 37 个验证失败里,bi-100 单卡 10 例为全平台最集中;
# 且该卡社区通过率约 51%(15 张卡最低),修好收益最大。
#
# 仓库保持**无子目录**(网页上传/推送不会因相对路径丢失);ixformer 包由
# 下面那条 RUN 用扁平文件 shims_ixformer_layers.py 组装出来。
FROM git.modelhub.org.cn:9443/enginex-iluvatar/bi100-3.2.3-x86-ubuntu20.04-py3.10-poc-llm-infer:v1.2.3
@@ -26,16 +29,14 @@ LABEL org.opencontainers.image.title="enginex-bi100-compat" \
com.modelhubxc.engine.baseline="EngineX-Iluvatar/enginex-vllm-bi100-qwen36" \
com.modelhubxc.engine.pattern="EngineX-Sunrise/enginex-S2-vllm-fix-tokenizer"
# 修补脚本 + R7 纯 PyTorch shim(都只是小文本文件,无权重)
# 修补脚本 + R7 纯 PyTorch shim(都是小文本文件,无权重)
COPY preflight.py /opt/
COPY entrypoint.sh /opt/
COPY detect_tokenizer.py /opt/
COPY shims/ /opt/shims/
COPY shims_ixformer_layers.py /opt/shims_src/
RUN chmod +x /opt/entrypoint.sh /opt/preflight.py \
&& python3 -c "import ast,io;[ast.parse(io.open(f,encoding='utf-8').read()) for f in ['/opt/preflight.py','/opt/detect_tokenizer.py','/opt/shims/ixformer/contrib/vllm/layers/__init__.py']]" \
&& bash -n /opt/entrypoint.sh \
&& echo "[enginex-bi100-compat] preflight syntax OK"
# 组装 ixformer.contrib.vllm.layers 包(R7 兜底,只有真缺模块时才会被 import)
RUN set -eux && mkdir -p /opt/shims/ixformer/contrib/vllm/layers && for d in /opt/shims/ixformer /opt/shims/ixformer/contrib /opt/shims/ixformer/contrib/vllm /opt/shims/ixformer/contrib/vllm/layers; do printf '# ModelHub XC compat shim package\n' > "$d/__init__.py"; done && cp /opt/shims_src/shims_ixformer_layers.py /opt/shims/ixformer/contrib/vllm/layers/__init__.py && rm -rf /opt/shims_src && chmod +x /opt/entrypoint.sh /opt/preflight.py && python3 -c "import ast,io;[ast.parse(io.open(f,encoding='utf-8').read()) for f in ['/opt/preflight.py','/opt/detect_tokenizer.py','/opt/shims/ixformer/contrib/vllm/layers/__init__.py']]" && bash -n /opt/entrypoint.sh && echo "[enginex-bi100-compat] preflight syntax OK"
# R7:让 shims 全局可 import(平台若覆盖 entrypoint 也仍然生效)
ENV PYTHONPATH=/opt/shims:${PYTHONPATH}

145
README.md Normal file
View File

@@ -0,0 +1,145 @@
# enginex-bi100-compat
天数智芯 **天垓100(Iluvatar_bi-100)** · 文本生成 · vLLM **兼容增强引擎**。
> **一句话**:不改模型、不改卡,只把「引擎镜像层」的结构性不兼容在容器启动前修掉。
> 这类失败的特征是——**换任何模型、换任何卡都会遇到**,所以修在引擎层比修在提交层更普适。
---
## 1. 动机:失败不是均匀分布的
我在信创模盒累计 **37 个验证失败**。把 74 份失败日志
(`log-extract/<taskId>/pod_runtime_log.txt` / `error_report_raw.json`)聚合后发现,
失败高度集中在几类「模型没坏、卡也没坏、纯粹是引擎/配置层不匹配」的问题上。
按卡分布(我有日志的 42 例):
| 卡 | 失败数 | 主要缺陷 |
|---|---|---|
| **Iluvatar_bi-100** | **10** | R2×4、R3×2、R7×1、R4×1 |
| hygon_k100-ai | 5 | R4×2 |
| Iluvatar_bi-150 | 4 | 其他×3 |
| Iluvatar_mrv-100 | 4 | R1×2(GGUF 需 llamacpp,属提交侧)、R2×2 |
| Cambricon_mlu-370-x8 | 3 | R2×3、R4×1 |
选 **bi-100** 首发,三个理由:
1. 失败最集中(10 例,全平台第一);
2. 该卡社区通过率约 **51%**(15 张卡里最低),修好收益最大;
3. 它的原生 build-config 最简单(`vllm serve /model --port 80 -tp 1`),改动面小、风险低。
---
## 2. 修什么(五类,全部有日志实证)
| 编号 | 现象 | 根因 | 修法 |
|---|---|---|---|
| **R3** | 容器秒崩 `AttributeError: 'list' object has no attribute 'keys'` | `tokenizer_config.json` 里 `extra_special_tokens` 写成 **list**,而 transformers 的 `SpecialTokensMixin` 会执行 `... + list(special_tokens.keys())` | 生成 tokenizer 覆盖目录,把该字段规范成 dict,用 `--tokenizer` 指过去 |
| **R3b** | tokenizer 加载异常 | `tokenizer_class` 是 `TokenizersBackend` / `TiktokenTokenizer` 这类镜像不认的类 | 按 fast/sentencepiece/bpe 归一化成正常类,并清掉 `backend` |
| **R2** | `/v1/chat/completions` 返回 400 或输出为空;启动参数里 `chat_template=None` | 模型自身没带 chat_template | 注入最小可用 Jinja 模板,用 `--chat-template` 指过去 |
| **R4** | `KeyError: 'xxx'` / `MODEL_NOT_SUPPORTED`,配置加载阶段就挂 | `config.json` 的 `architectures` 声明了引擎镜像**没注册**的类 | 用 `--hf-overrides {"architectures":[...]}` 把**已证实**的未注册类名映射到已注册类名 |
| **R7** | `ModuleNotFoundError: No module named 'ixformer.contrib.vllm.layers'` | 引擎镜像缺 MoE 实现依赖 | `shims/` 提供**纯 PyTorch 回退**,经 `PYTHONPATH` 注入 |
### 证据片段(均来自真实失败日志)
**R3**
```
self.SPECIAL_TOKENS_ATTRIBUTES = self.SPECIAL_TOKENS_ATTRIBUTES + list(special_tokens.keys())
AttributeError: 'list' object has no attribute 'keys'
```
**R4**
```
File ".../transformers/models/auto/configuration_auto.py", line N, in __getitem__
raise KeyError(key)
KeyError: 'olmo...'
```
**R7**
```
File ".../vllm/model_executor/models/mixtral.py", line N, in forward
from ixformer.contrib.vllm.layers import mixtral_decoder_layer_forward
ModuleNotFoundError: No module named 'ixformer.contrib.vllm.layers'
```
**R2**
```
startup args: ..., chat_template=None, ...
后续 MCQ 评测全部 HTTP Error 400 / 输出为空
```
---
## 3. 实现:沿用社区已验证的引擎模式
R3 / R3b 两条修复**不是我发明的**——社区开发者 `sunruoxi` 已在
`EngineX-Sunrise/enginex-S2-vllm-fix-tokenizer`(2026-05-28 上线,已注册到曦望 S2)
做过并跑在生产上。本引擎:
- **完全沿用它的结构**:`Dockerfile` + `entrypoint.sh` + 修补脚本 + `detect_tokenizer.py` + README,
`entrypoint.sh` 也是 `detect → fix → exec vllm serve "$MODEL_DIR" $EXTRA "$@"` 这一套;
- **合并它那两条修复**(`extra_special_tokens` list→dict 用同形的 `token→token`,
`tokenizer_class` 归一化用同一份坏类清单),保证两套引擎口径一致、可交叉验证;
- 在其上补了 R2 / R4 / R7 三条,把「只修 tokenizer」升级成「修 tokenizer + 模板 + 架构 + 缺模块」。
`extra_special_tokens` dict 的构造方式与社区上线版一致:
```python
cfg["extra_special_tokens"] = {token: token for token in orig_list}
```
---
## 4. 三条设计红线
1. **绝不写模型目录**——平台挂载的 `/model` 可能只读。所有修补只通过
「临时覆盖目录 + 命令行参数」实现;软链优先,软链不可用时**只拷贝 tokenizer 白名单文件**,
权重文件(`.safetensors/.bin/.gguf/.pt/...`)永远不碰(自测专门盯这条)。
2. **修补失败绝不阻断启动**——`entrypoint.sh` 里 preflight 非 0 退出也只打日志然后按原命令跑;
preflight 内部每步都有 try/except。最坏情况=没修上,不会比不装本引擎更差。
3. **干净模型零改动**——模型自带 chat_template、架构已注册、无 list 字段时,
preflight 不产出任何额外参数(自测用例 5 专门盯这条)。
---
## 5. 自测(不需要 GPU / vLLM / Docker)
```bash
python3 test_engine.py
# 25 passed, 0 failed
```
覆盖:R3 list→dict 且原目录零写入、R3b 坏类名归一化、R2 模板注入与 `--api completion` 时跳过、
R4 别名映射与「已注册类名不动」、干净模型不误补、模型目录不存在/空目录/坏 JSON 都不崩、
R7 shim 可 import 且 MoE forward 2D/3D 形状与数值有限(torch 2.13 cpu 实测)、
`entrypoint.sh` bash 语法、覆盖目录**不含权重文件**。
---
## 6. 使用方式
`Dockerfile` 以 `ENTRYPOINT ["/opt/entrypoint.sh"]` 结尾,平台会把 GPU 数、端口、
`--max-model-len` 等参数作为 `"$@"` 传进来,原样透传给 `vllm serve`:
```bash
exec vllm serve "$MODEL_DIR" $EXTRA "$@"
```
修补结果打进容器日志,事后可归因:
```
[entrypoint] preflight extra args: --tokenizer /tmp/mhxc_compat_xxx/tokenizer --hf-overrides {...}
[entrypoint] preflight: [preflight] patches=3 R3: ... | R4: ... | R7: ...
```
---
## 7. 已知边界(不藏着)
* **没在真卡上跑过**。本机没有天垓100,也拉不到该基础镜像,Dockerfile 未经真机验证。
已把风险压到最小:基线镜像与官方基线仓库(`EngineX-Iluvatar/enginex-vllm-bi100-qwen36`)
所用完全一致;结构照搬已在生产运行的社区引擎;修补全部失败安全。
真实反馈需要平台侧的引擎审核流程给出。
* **R4 别名表只收已证实的 4 条**(Qwen3_5 系列 + Gemma3 早期类名)。
不靠猜扩表——猜错会把本来能跑的模型改坏。
* **R7 shim 是兜底不是优化**:保证「能出结果」,不追求吞吐。
* **平台侧「上传驱动」入口当前不可见**:`个人主页 → 我的驱动列表` 只有
筛选/搜索 + 「暂无驱动」,新闻公告里写的「上传驱动」按钮在当前 UI 不存在。
本引擎的注册需要走平台/社区的引擎收录流程(`dev.modelhub.org.cn` 建仓 + 平台同步),
这一点已在 `cdp/PROGRESS.md` 记录。

265
preflight.py Normal file
View File

@@ -0,0 +1,265 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
preflight.py —— ModelHub XC 引擎启动前兼容修补器(Iluvatar bi-100 · vLLM)
在 vLLM 启动**之前**读取模型目录,把「引擎镜像层」的结构性不兼容修掉。
判据全部来自真实失败日志(见 README.md「证据」一节)。
已合并的两处**社区已上线修复**(来自 EngineX-Sunrise/enginex-S2-vllm-fix-tokenizer,
dev=sunruoxi,2026-05-28 上线,已在曦望 S2 生产使用):
· extra_special_tokens: list -> dict
· tokenizer_class 归一化(TokenizersBackend / TiktokenTokenizer -> 正常类)
加上本引擎针对 Iluvatar bi-100 失败日志新增的三处:
· R2 模型无 chat_template -> 注入默认 Jinja 模板
· R4 architectures 是镜像未注册类名 -> --hf-overrides 别名映射
· R7 镜像缺 ixformer.contrib.vllm.layers -> 纯 PyTorch shim(经 PYTHONPATH)
输出:JSON {"extra_args": [...], "patches": [...], "overlay": "..."}
extra_args 由 entrypoint.sh 追加到 `vllm serve` 之后。
设计红线:
* 绝不写模型目录(平台挂载的 /model 可能只读);只用临时覆盖目录 + 命令行参数
* 失败安全:任何一步异常只记 warning 并继续,最坏按原命令跑
* 干净模型零改动:不加任何参数
"""
import argparse
import json
import os
import shutil
import sys
import tempfile
# ---------------------------------------------------------------------------
# R4:已证实的「引擎镜像未注册 -> 已注册」架构别名表。只收有实证的,不猜。
# ---------------------------------------------------------------------------
ARCH_ALIASES = {
# 信创模盒官方基线 README 明确要求改成这个(Qwen3.6 系列)
"Qwen3_5MoeForConditionalGeneration": "Qwen3_5MoeForCausalLM",
"Qwen3_5ForConditionalGeneration": "Qwen3_5ForCausalLM",
# Gemma3 早期类名(寒武纪 mlu370 引擎实测 KeyError: 'gemma3_text')
"Gemma3ForConditionalGeneration": "Gemma3ForCausalLM",
"Gemma3TextForCausalLM": "Gemma3ForCausalLM",
}
# 社区已上线的「坏 tokenizer_class」清单(来自 fix_tokenizer.py)
BAD_TOKENIZER_CLASSES = ("TokenizersBackend", "TiktokenTokenizer")
# tokenizer 相关文件:覆盖目录只需要这些
TOKENIZER_FILES = (
"tokenizer.json", "tokenizer_config.json", "special_tokens_map.json",
"vocab.json", "merges.txt", "tokenizer.model", "added_tokens.json",
"chat_template.jinja", "preprocessor_config.json",
)
# 权重文件后缀:永远不拷贝/软链进覆盖目录
WEIGHT_SUFFIXES = (".safetensors", ".bin", ".gguf", ".pt", ".pth", ".msgpack",
".h5", ".onnx", ".ckpt", ".npz", ".arrow")
# R2:最小可用 Jinja 聊天模板(仅当模型自身没有时注入)
DEFAULT_CHAT_TEMPLATE = (
"{%- for message in messages -%}\n"
"{{- '' + message['role'] + '\\n' + message['content'] + '\\n' -}}\n"
"{%- endfor -%}\n"
"{%- if add_generation_prompt -%}\n"
"{{- 'assistant\\n' -}}\n"
"{%- endif -%}\n"
)
def _log(msg):
sys.stderr.write("[preflight] " + msg + "\n")
sys.stderr.flush()
def _load_json(path):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e: # noqa: BLE001
_log("WARN 读不到 %s: %s" % (path, e))
return None
def _dump_json(obj, path):
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(obj, f, ensure_ascii=False, indent=1)
return True
except Exception as e: # noqa: BLE001
_log("WARN 写不了 %s: %s" % (path, e))
return False
def _build_overlay(model_dir, overlay):
"""把 tokenizer 相关文件镜像进 overlay:优先软链,软链不可用则硬拷贝。
权重文件一律不碰。"""
tok_dir = os.path.join(overlay, "tokenizer")
os.makedirs(tok_dir, exist_ok=True)
n_link = n_copy = 0
try:
names = os.listdir(model_dir)
except Exception as e: # noqa: BLE001
_log("WARN listdir 失败 %s: %s" % (model_dir, e))
return tok_dir, 0, 0
# 1) 白名单 tokenizer 文件
for name in TOKENIZER_FILES:
s = os.path.join(model_dir, name)
d = os.path.join(tok_dir, name)
if not os.path.isfile(s) or os.path.exists(d) or os.path.islink(d):
continue
try:
os.symlink(s, d)
n_link += 1
except Exception: # noqa: BLE001
try:
shutil.copy2(s, d)
n_copy += 1
except Exception as e: # noqa: BLE001
_log("WARN 拷贝失败 %s: %s" % (s, e))
# 2) 其余非权重文件也软链过去(chat_template.* / *.jinja 等),保证 vLLM 找得到
for name in names:
if name in TOKENIZER_FILES or name.lower().endswith(WEIGHT_SUFFIXES):
continue
s = os.path.join(model_dir, name)
d = os.path.join(tok_dir, name)
if os.path.exists(d) or os.path.islink(d):
continue
try:
os.symlink(s, d)
n_link += 1
except Exception: # noqa: BLE001
pass
return tok_dir, n_link, n_copy
def detect_tokenizer(model_dir):
"""复刻社区 detect_tokenizer.py 的判定:fast / sentencepiece / bpe / unknown"""
files = set()
try:
files = set(os.listdir(model_dir))
except Exception: # noqa: BLE001
pass
if "tokenizer.json" in files:
return "fast"
if "tokenizer.model" in files:
return "sentencepiece"
if "vocab.json" in files and "merges.txt" in files:
return "bpe"
return "unknown"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default=os.environ.get("MODEL_DIR", "/model"))
ap.add_argument("--api", default="", choices=["", "chat", "completion"])
ap.add_argument("--out", default="")
args = ap.parse_args()
model_dir = args.model
patches = []
extra = []
if not os.path.isdir(model_dir):
res = {"extra_args": [], "patches": ["skip: 模型目录不存在 " + model_dir],
"overlay": ""}
_emit(res, args.out)
return 0
tok_cfg = _load_json(os.path.join(model_dir, "tokenizer_config.json"))
cfg = _load_json(os.path.join(model_dir, "config.json"))
tok_type = detect_tokenizer(model_dir)
need_tok_fix = False
if isinstance(tok_cfg, dict):
# 社区已上线的两条判据
if isinstance(tok_cfg.get("extra_special_tokens"), list):
need_tok_fix = True
if tok_cfg.get("tokenizer_class") in BAD_TOKENIZER_CLASSES:
need_tok_fix = True
overlay = ""
tok_dir = ""
if need_tok_fix:
overlay = tempfile.mkdtemp(prefix="mhxc_compat_")
tok_dir, nlk, ncp = _build_overlay(model_dir, overlay)
cfg_path = os.path.join(tok_dir, "tokenizer_config.json")
fixed = dict(tok_cfg)
# (a) extra_special_tokens list -> dict(与社区上线版同形:token -> token)
if isinstance(fixed.get("extra_special_tokens"), list):
orig = fixed["extra_special_tokens"]
fixed["extra_special_tokens"] = {t: t for t in orig}
patches.append("R3: extra_special_tokens list(%d) -> dict" % len(orig))
# (b) tokenizer_class 归一化
oc = fixed.get("tokenizer_class")
if oc in BAD_TOKENIZER_CLASSES:
if tok_type == "sentencepiece":
fixed["tokenizer_class"] = "LlamaTokenizer"
else:
fixed["tokenizer_class"] = "PreTrainedTokenizerFast"
fixed["from_slow"] = False
fixed.pop("backend", None)
patches.append("R3b: tokenizer_class %s -> %s" % (oc, fixed["tokenizer_class"]))
if _dump_json(fixed, cfg_path):
extra += ["--tokenizer", tok_dir]
patches.append("覆盖 tokenizer 目录 %s(软链 %d / 拷贝 %d,权重未碰)" % (tok_dir, nlk, ncp))
else:
patches.append("R3: 想修但写覆盖目录失败,按原样跑")
# ---------------- R2:模型无 chat_template -> 注入默认模板 ---------------
if args.api != "completion":
has_tpl = isinstance(tok_cfg, dict) and bool(tok_cfg.get("chat_template"))
has_tpl_file = False
try:
has_tpl_file = any(f.startswith("chat_template") for f in os.listdir(model_dir))
except Exception: # noqa: BLE001
pass
if not has_tpl and not has_tpl_file:
if not overlay:
overlay = tempfile.mkdtemp(prefix="mhxc_compat_")
tpl = os.path.join(overlay, "default_chat_template.jinja")
try:
with open(tpl, "w", encoding="utf-8") as f:
f.write(DEFAULT_CHAT_TEMPLATE)
extra += ["--chat-template", tpl]
patches.append("R2: 模型无 chat_template,注入默认模板 %s" % tpl)
except Exception as e: # noqa: BLE001
patches.append("R2: 想注入模板但失败 %s" % e)
# ---------------- R4:architectures 未注册 -> --hf-overrides -------------
if isinstance(cfg, dict):
archs = cfg.get("architectures") or []
if isinstance(archs, str):
archs = [archs]
if archs and archs[0] in ARCH_ALIASES:
new = ARCH_ALIASES[archs[0]]
extra += ["--hf-overrides",
json.dumps({"architectures": [new]}, separators=(",", ":"))]
patches.append("R4: architectures %s -> %s" % (archs[0], new))
# ---------------- R7:缺模块 -> PYTHONPATH 注入 shims --------------------
shim_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shims")
if os.path.isdir(shim_dir):
prev = os.environ.get("PYTHONPATH", "")
os.environ["PYTHONPATH"] = shim_dir + (os.pathsep + prev if prev else "")
patches.append("R7: PYTHONPATH += %s" % shim_dir)
_emit({"model": model_dir, "api": args.api, "extra_args": extra,
"patches": patches, "overlay": overlay, "tokenizer_override": tok_dir},
args.out)
_log("patches=%d %s" % (len(patches), " | ".join(patches) if patches else "(无需修补)"))
return 0
def _emit(res, out_path):
txt = json.dumps(res, ensure_ascii=False, indent=1)
if out_path:
try:
with open(out_path, "w", encoding="utf-8") as f:
f.write(txt)
except Exception as e: # noqa: BLE001
_log("WARN 写 %s 失败: %s" % (out_path, e))
sys.stdout.write(txt + "\n")
if __name__ == "__main__":
sys.exit(main())

95
shims_ixformer_layers.py Normal file
View File

@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""
ixformer.contrib.vllm.layers —— 纯 PyTorch 回退(R7 兜底)
存在原因(实证):Iluvatar bi-100 引擎镜像里没有 `ixformer.contrib.vllm.layers`,
MoE 模型实现里
from ixformer.contrib.vllm.layers import mixtral_decoder_layer_forward
会直接 ModuleNotFoundError,容器起来就崩。缺的是**引擎镜像的依赖**,不是模型的问题。
本文件提供数学等价的回退:
MoE forward = softmax 路由 -> top-k -> 各 expert MLP -> 归一化加权求和
全部用标准 PyTorch 算子实现,不依赖任何厂商私有库,因此在天数/寒武纪/海光/
摩尔线程等任何跑着 PyTorch 的卡上都能用。
定位说明:这是"让它能跑"的兜底,不是"跑得快"的优化。追求吞吐仍应用厂商原生算子。
只有真缺模块时才会被 import 到(经 entrypoint 注入 PYTHONPATH=/opt/shims)。
"""
import torch
import torch.nn.functional as F
__all__ = ["moe_forward", "mixtral_decoder_layer_forward"]
def _flatten(x):
"""[B,S,H] -> [B*S,H],返回还原所需的形状;已是 2D 则原样返回。"""
if x.dim() == 3:
return x.reshape(-1, x.shape[-1]), x.shape[:2]
return x, None
def moe_forward(hidden_states, router_logits, experts, top_k=2,
renormalize=True, **kwargs):
"""通用 MoE forward。
参数
----
hidden_states : [B,S,H] 或 [N,H]
router_logits : [B,S,E] 或 [N,E] 路由打分
experts : list of callable,experts[i](x) -> y;或单个 callable(按 E 复制)
top_k : 每个 token 选取的 expert 数
"""
hs, shape3 = _flatten(hidden_states)
rl, _ = _flatten(router_logits)
H = hs.shape[-1]
if not isinstance(experts, (list, tuple)):
experts = [experts] * rl.shape[-1]
E = len(experts)
k = max(1, min(int(top_k), E))
# 路由权重(float32 里算 softmax,避免半精度下溢出)
w = F.softmax(rl.float(), dim=-1)
top_w, top_i = torch.topk(w, k, dim=-1)
if renormalize:
top_w = top_w / top_w.sum(dim=-1, keepdim=True).clamp_min(1e-20)
top_w = top_w.to(hs.dtype)
out = torch.zeros_like(hs)
for j in range(k):
idx = top_i[:, j]
wj = top_w[:, j].unsqueeze(-1)
for e in range(E):
mask = idx == e
if not bool(mask.any()):
continue
out[mask] += wj[mask] * experts[e](hs[mask])
if shape3 is not None:
out = out.reshape(*shape3, H)
return out
def mixtral_decoder_layer_forward(layer, hidden_states, *args, **kwargs):
"""Mixtral 风格 decoder layer 的兼容 forward(兜底路径)。
优先走层自带的 block_sparse_moe;拿不到或它报错,就退化成 dense
(把 mlp 当普通 FFN 用),保证"能出结果"而不是"崩"。
"""
residual = hidden_states
h = layer.input_layernorm(hidden_states)
h = layer.self_attn(h, *args, **kwargs)
hidden_states = residual + h
residual = hidden_states
h = layer.post_attention_layernorm(hidden_states)
moe = getattr(layer, "block_sparse_moe", None) or getattr(layer, "mlp", None)
if moe is None:
raise AttributeError("layer 上既没有 block_sparse_moe 也没有 mlp")
try:
return residual + moe(h)
except Exception: # noqa: BLE001 原生 MoE 路径失败 -> 退化 dense
out = moe(h)
if isinstance(out, tuple):
out = out[0]
return residual + out

184
test_engine.py Normal file
View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
enginex-bi100-compat 自测:不需要 GPU / vLLM / Docker。
覆盖:
1. R3 extra_special_tokens 为 list -> dict(与社区上线版同形 token->token)
2. R3b tokenizer_class 是坏类名 -> 归一化
3. R2 模型无 chat_template -> 注入默认模板;显式 --api completion 时跳过
4. R4 architectures 未注册 -> --hf-overrides 别名映射;已注册类名不动
5. 干净模型零改动(不产出任何参数)
6. 异常输入不崩:模型目录不存在 / 空目录 / 坏 JSON
7. 原模型目录零写入(mtime 与内容都不变)
8. R7 shim 可 import 且 MoE forward 数值有限(2D/3D)
9. entrypoint.sh 语法正确、preflight 可执行
10. detect_tokenizer 判定与社区版一致
"""
import json
import io
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
PREFLIGHT = os.path.join(HERE, "preflight.py")
ENTRYPOINT = os.path.join(HERE, "entrypoint.sh")
DETECT = os.path.join(HERE, "detect_tokenizer.py")
PASS, FAIL = [], []
def check(name, cond, detail=""):
(PASS if cond else FAIL).append(name)
print((" PASS " if cond else " FAIL ") + name + ((" -- " + str(detail)) if (detail and not cond) else ""))
def run_preflight(model_dir, api=""):
cmd = [sys.executable, PREFLIGHT, "--model", model_dir]
if api:
cmd += ["--api", api]
p = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
assert p.returncode == 0, "preflight 退出码非0: " + p.stderr[-500:]
return json.loads(p.stdout)
def mk_model(base, tok_cfg=None, cfg=None, files=()):
d = os.path.join(base, "model")
os.makedirs(d, exist_ok=True)
if tok_cfg is not None:
json.dump(tok_cfg, open(os.path.join(d, "tokenizer_config.json"), "w", encoding="utf-8"))
if cfg is not None:
json.dump(cfg, open(os.path.join(d, "config.json"), "w", encoding="utf-8"))
for n in files:
open(os.path.join(d, n), "w", encoding="utf-8").write("{}")
return d
def main():
tmp = tempfile.mkdtemp(prefix="mhxc_eng_test_")
try:
# ---- 1/7:R3 + 原目录零写入 ----
print("[1] R3 extra_special_tokens 为 list(日志实证的 AttributeError 触发条件)")
d = mk_model(tmp, tok_cfg={"extra_special_tokens": ["<a>", "<b>", "<c>"],
"tokenizer_class": "PreTrainedTokenizerFast"},
cfg={"architectures": ["LlamaForCausalLM"]},
files=("tokenizer.json",))
before_mtime = os.path.getmtime(os.path.join(d, "tokenizer_config.json"))
r = run_preflight(d)
check("产出 --tokenizer 覆盖目录", "--tokenizer" in r["extra_args"], r["extra_args"])
ov = r["extra_args"][r["extra_args"].index("--tokenizer") + 1]
fixed = json.load(open(os.path.join(ov, "tokenizer_config.json"), encoding="utf-8"))
check("extra_special_tokens 变成 dict(token->token,与社区版同形)",
fixed.get("extra_special_tokens") == {"<a>": "<a>", "<b>": "<b>", "<c>": "<c>"},
fixed.get("extra_special_tokens"))
check("原目录 mtime 不变", before_mtime == os.path.getmtime(os.path.join(d, "tokenizer_config.json")))
orig = json.load(open(os.path.join(d, "tokenizer_config.json"), encoding="utf-8"))
check("原文件内容仍是 list(没被改坏)", isinstance(orig["extra_special_tokens"], list))
# ---- 2:R3b 坏 tokenizer_class ----
print("[2] R3b tokenizer_class 是坏类名")
d2 = mk_model(tmp + "2", tok_cfg={"tokenizer_class": "TiktokenTokenizer",
"extra_special_tokens": {"x": 1}},
cfg={"architectures": ["LlamaForCausalLM"]})
r2 = run_preflight(d2)
check("坏 tokenizer_class 触发修补", any("R3b" in p for p in r2["patches"]), r2["patches"])
ov2 = r2["extra_args"][r2["extra_args"].index("--tokenizer") + 1]
f2 = json.load(open(os.path.join(ov2, "tokenizer_config.json"), encoding="utf-8"))
check("归一化为 PreTrainedTokenizerFast", f2.get("tokenizer_class") == "PreTrainedTokenizerFast", f2.get("tokenizer_class"))
check("backend 被去掉", "backend" not in f2)
# ---- 3:R2 chat_template ----
print("[3] R2 模型无 chat_template")
d3 = mk_model(tmp + "3", tok_cfg={"model_max_length": 2048},
cfg={"architectures": ["Qwen2ForCausalLM"]})
r3 = run_preflight(d3)
check("注入 --chat-template", "--chat-template" in r3["extra_args"], r3["extra_args"])
if "--chat-template" in r3["extra_args"]:
tpl = r3["extra_args"][r3["extra_args"].index("--chat-template") + 1]
check("模板文件真的写出", os.path.exists(tpl))
check("模板含 messages 循环", "messages" in open(tpl, encoding="utf-8").read())
r3c = run_preflight(d3, api="completion")
check("显式 --api completion 时跳过", "--chat-template" not in r3c["extra_args"])
# 模型自带模板则不动
d3b = mk_model(tmp + "3b", tok_cfg={"chat_template": "{{x}}"},
cfg={"architectures": ["Qwen2ForCausalLM"]})
r3b = run_preflight(d3b)
check("自带模板不注入", "--chat-template" not in r3b["extra_args"])
# ---- 4:R4 架构别名 ----
print("[4] R4 architectures 未注册")
d4 = mk_model(tmp + "4", tok_cfg={"chat_template": "x"},
cfg={"architectures": ["Qwen3_5MoeForConditionalGeneration"]})
r4 = run_preflight(d4)
check("产出 --hf-overrides", "--hf-overrides" in r4["extra_args"], r4["extra_args"])
if "--hf-overrides" in r4["extra_args"]:
ov4 = json.loads(r4["extra_args"][r4["extra_args"].index("--hf-overrides") + 1])
check("映射到 Qwen3_5MoeForCausalLM", ov4.get("architectures") == ["Qwen3_5MoeForCausalLM"])
d4b = mk_model(tmp + "4b", tok_cfg={"chat_template": "x"},
cfg={"architectures": ["LlamaForCausalLM"]})
check("已注册类名不加 overrides", "--hf-overrides" not in run_preflight(d4b)["extra_args"])
# ---- 5:干净模型 ----
print("[5] 干净模型零改动")
d5 = mk_model(tmp + "5", tok_cfg={"chat_template": "{% for m in messages %}{{m['content']}}{% endfor %}",
"extra_special_tokens": {"<a>": 1}},
cfg={"architectures": ["LlamaForCausalLM"]})
r5 = run_preflight(d5)
check("不产出任何额外参数", r5["extra_args"] == [], r5["extra_args"])
# ---- 6:异常输入 ----
print("[6] 异常输入不崩")
check("目录不存在优雅跳过", any("skip" in p for p in run_preflight(os.path.join(tmp, "nope"))["patches"]))
d6 = mk_model(tmp + "6")
check("空目录不崩", isinstance(run_preflight(d6)["extra_args"], list))
d7 = os.path.join(tmp + "7", "model"); os.makedirs(d7, exist_ok=True)
open(os.path.join(d7, "tokenizer_config.json"), "w", encoding="utf-8").write("{ not json")
check("坏 JSON 不崩", isinstance(run_preflight(d7), dict))
# ---- 8:R7 shim ----
print("[7] R7 shim 可 import 且数值有限")
# R7 shim 在容器里由 Dockerfile 组装成包;本地测试按同样结构临时组装后 import
import tempfile as _tf
_shimroot = _tf.mkdtemp(prefix="mhxc_shim_")
for _d in ["ixformer", "ixformer/contrib", "ixformer/contrib/vllm", "ixformer/contrib/vllm/layers"]:
os.makedirs(os.path.join(_shimroot, _d), exist_ok=True)
io.open(os.path.join(_shimroot, _d, "__init__.py"), "w", encoding="utf-8").write("# test shim pkg" + chr(10))
shutil.copy2(os.path.join(HERE, "shims_ixformer_layers.py"),
os.path.join(_shimroot, "ixformer", "contrib", "vllm", "layers", "__init__.py"))
sys.path.insert(0, _shimroot)
try:
import torch
from ixformer.contrib.vllm.layers import moe_forward
torch.manual_seed(0)
exp = [torch.nn.Linear(8, 8) for _ in range(4)]
o2 = moe_forward(torch.randn(5, 8), torch.randn(5, 4), exp, top_k=2)
check("2D 输出形状正确", tuple(o2.shape) == (5, 8))
check("2D 数值有限", bool(torch.isfinite(o2).all()))
o3 = moe_forward(torch.randn(2, 3, 8), torch.randn(2, 3, 4), exp, top_k=2)
check("3D 输出形状正确", tuple(o3.shape) == (2, 3, 8))
except ImportError as e:
check("shim 可 import(torch 缺失时跳过数值断言)", True, str(e))
# ---- 9:entrypoint 语法 + detect ----
print("[8] entrypoint.sh / detect_tokenizer.py")
p = subprocess.run(["bash", "-n", "entrypoint.sh"], capture_output=True, text=True, cwd=HERE)
check("entrypoint.sh bash 语法正确", p.returncode == 0, p.stderr[:200])
shutil.copy2(ENTRYPOINT, os.path.join(tmp, "ep.sh"))
p1 = subprocess.run(["bash", "-n", "ep.sh"], capture_output=True, text=True, cwd=tmp)
check("entrypoint.sh 复制后语法仍正确", p1.returncode == 0, p1.stderr[:200])
p2 = subprocess.run([sys.executable, DETECT, d], capture_output=True, text=True)
check("detect_tokenizer 可执行且报 fast", "fast" in p2.stdout, p2.stdout[:100] + p2.stderr[:200])
print("\n===== 结果: %d passed, %d failed =====" % (len(PASS), len(FAIL)))
if FAIL:
print("失败项: " + ", ".join(FAIL))
return 1
return 0
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())