零、背景
源模型:Intern‑S2‑Preview——上海 AI 实验室的多模态巨兽,文本主干是 Qwen3.5‑MoE,外层包了视觉编码器和一个叫 time_series 的时间序列模块。目标:用 oMLX 的 oQ(Optimal Quantization)把它压到 4bit。
这中间发生了三件事:探查 oMLX 的量化逻辑 → 补丁 oq.py 打通灵敏度测量 → 剥离 511 个孤儿参数让推理跑通。每一件事都踩进了 oMLX 源码的深处。
第一幕:探查——模型类型不支持的背后,是 mlx‑lm 的类型注册表
用户发起量化。oQ 冷冰冰地抛出一行:
intern_s2_preview model type not supported for auto-proxy sensitivity
没错,intern_s2_preview 不在 mlx‑lm 的类型注册表里。但用户一句话点破了要害:「Intern‑S2‑Preview 的架构是基于 Qwen3.5」。
打开 config.json,真相就写在里面:
{
"model_type": "intern_s2_preview",
"text_config": {
"model_type": "qwen3_5_moe_text"
},
"vision_config": { ... }
}
外层的 model_type 是 intern_s2_preview(这是 InternVL 家族的自定义标签),但文本主干就是 qwen3_5_moe。量化只关心文本层的权重分布——灵敏度测量的对象就是那 40 层 Qwen3.5 MoE。
于是问题变成:如何让 oQ 在测量灵敏度时把 intern_s2_preview 当成 qwen3_5_moe?
1.1 深入 oMLX 源码:oQ 的灵敏度测量链路
oMLX 的量化核心在 oq.py。关键函数链:
quantize_oq_streaming()
→ _measure_sensitivity() # 灵敏度测量入口
→ 检测 config 中是否有 vision_config → 判断 is_vlm
→ VLM 路径:mlx_vlm.utils.load_model()
→ 非 VLM 路径:mlx_lm.load() → _get_classes(config) → MODEL_REMAPPING
→ _measure_sensitivity_from_quantized_model() # 备选路径(已量化模型做 proxy)
关键发现:
VLM 检测机制:oQ 通过 config 中的 vision_config 键来判断模型是否 VLM。Intern‑S2 的 config.json 有 vision_config,但 _measure_sensitivity 中实际的判定逻辑走的是非 VLM 分支(因为 is_vlm 设为 False,原因是在更早的配置处理中剥离了视觉部分)。
MODEL_REMAPPING 字典:mlx-lm 的 utils 模块维护了一个 MODEL_REMAPPING 字典,将不认识的模型类型映射到已知类型。oMLX 在 oq.py 第 1591 行硬编码了对 deepseek_v4 的支持——这就是我们的注入点模板。
config.json 磁盘读取问题:mlx_lm.load() 从磁盘读取 config.json,即使你在内存中修改了配置,它仍然读到磁盘上原始的 intern_s2_preview。这意味着 monkey‑patch 必须在 load() 调用之前注入,加载完成后恢复。
双灵敏度函数:代码里存在两个灵敏度测量相关函数——主入口 _measure_sensitivity 和量化后的备选 _measure_sensitivity_from_quantized_model。两者都可能绕过你的补丁,必须同时覆盖。
内置标定数据:oQ 自带 560 条 code_multilingual 文本(704 KB),用于模型前向传播采样。实际只用 2 samples × 128 tokens。这意味着不需要额外准备标定数据集。
灵敏度分层:40 层测下来,L0(第一层)灵敏度 0.0055 最高,L13=0.0005 最低。灵敏度越高的层,量化时保留的精度越高。这个分布本身就印证了 Qwen3.5 MoE 的层间重要性差异。
1.2 web 搜索的辅助发现
过程中检索了 oMLX GitHub issues:
- #1030:
nemotronh_nano_omni_reasoning_v3 同样报 "model type not supported"
- #111:
qwen3_tts 相同问题
- #554:
gemma4 在灵敏度测量中不支持
- v0.3.9 发布笔记提到了 "auto‑build proxy model" 和 "mlx‑lm patched in oQ auto‑built sensitivity proxy"
还有一个 HuggingFace 线索:chanderbalaji/Intern‑S2‑Preview‑FP8‑MLX‑4bit 已经有人做过 MLX 4bit 转换。这说明社区在用笨办法绕过——先转标准格式。
第二幕:补丁——在 oq.py 中植入 MODEL_REMAPPING monkey‑patch
有了上述理解,补丁方案就清晰了:
核心补丁逻辑
在 _measure_sensitivity 的 lm_load 调用前,注入 MODEL_REMAPPING:
_need_monkey = (
config.get("model_type") == "intern_s2_preview"
and not is_vlm
)
if _need_monkey:
# 保存原始映射
_orig_remapping = dict(getattr(_mlx_utils, "MODEL_REMAPPING", {}))
# 注入临时映射
_mlx_utils.MODEL_REMAPPING["intern_s2_preview"] = "qwen3_5_moe"
logger.info("oQ: monkey-patched MODEL_REMAPPING for intern_s2_preview")
# ... lm_load() 调用 ...
if _need_monkey:
# 恢复原始映射,不留副作用
if "intern_s2_preview" in _orig_remapping:
_mlx_utils.MODEL_REMAPPING["intern_s2_preview"] = _orig_remapping["intern_s2_preview"]
else:
_mlx_utils.MODEL_REMAPPING.pop("intern_s2_preview", None)
logger.info("oQ: restored MODEL_REMAPPING after sensitivity load")
踩过的坑
VLM 分支遗漏:最初只给非 VLM 分支加了 strict=False,但 oQ 的实际代码路径走的是 VLM 分支(mlx_vlm.load),导致补丁白打。必须两个分支都改。
strict 模式:mlx_vlm.load_model(..., strict=False) 和 mlx_lm.load(..., lazy=True) 都需要显式传参,否则孤儿参数(time_series 模块)会导致 load 失败。
多次迭代:补丁 → 测试 → 日志显示 monkey‑patch 生效但加载仍失败 → 检查分支 → 修复 → 再测试。最终在第 48 轮得到 SUCCESS after 73s: 40 layers measured。
同步到 App
补丁写好后,同步到 /Applications/oMLX.app/Contents/Resources/omlx/oq.py,重新构建。经过多轮调试(包括僵尸进程、端口冲突等物理世界的混乱),最终 oMLX 的 GUI 量化管线也跑通了。
第三幕:手术——推理时 511 个参数无家可归
量化成功。推理启动。然后:
Received 511 parameters not in model
全部来自 language_model.model.time_series.encoder.*——153 weight + 133 bias + 112 scales + 112 biases + 1 in_proj_bias = 511 个。
根因
oMLX 的 MTP(Multi‑Token Prediction)推理运行时是 qwen35_moe_vlm_runtime.py,仅 446 行。它知道 Qwen3.5 MoE 的每一层——但不认识 time_series。而 mlx_lm.load(strict=True) 不允许孤儿参数。
定策
三条路:
- 补 runtime——给 446 行 runtime 加 time_series 模块 → 代价最高
- 改 loading 为 strict=False —— oMLX 底层可能不支持
- 切赘肉——从 safetensors 中剥离 time_series 参数 → 最快,对推理零影响
选第三条。
手术过程
source: 2230 权重, 2.63GB (model-00001-of-00009.safetensors)
target: 1719 权重, 2.54GB
remove: 511 time_series 参数
耗时: ~1.3s
步骤:
- 备份原始分片和 index
- 遍历 safetensors header,过滤
time_series 键
- 保留非 time_series 的 tensor 数据,重写分片
- 更新 model.safetensors.index.json(2230 → 1719)
- 校验一致性(index 中的每个权重都能在 shard 中找到)
复用脚本
~/.lmstudio/scripts/strip_time_series.py——传入模型目录,自动完成上述五步。dry‑run 模式先预览,确认后正式执行。后续 5bit/6bit 量化产物直接喂给它。
复盘:这个 bug 的本质是架构签名分裂
Intern‑S2 是一个异构架构——同一个 safetensors 文件里混着三种模块的权重:
| 模块 | 类型 | oMLX runtime 是否支持 |
|------|------|----------------------|
| language_model | Qwen3.5 MoE(文本) | ✅ 支持 |
| vision_model | 视觉编码器 | ❌ 但 oQ 剥离了 |
| time_series | 时间序列 | ❌ 完全未知 |
oMLX 的 oQ 管线只处理文本层(language_model),但 quantize 阶段不剥离不认识的权重——它忠实地把整个 safetensors 量化后原样输出。推理时 runtime 按 strict=True 加载,遇到孤儿就崩。
教训:
- 异构模型的量化产物不能直接喂给统一 runtime——量化前要了解 runtime 的模块清单
- mlx‑lm 的 MODEL_REMAPPING 是可插拔的扩展点,不用改 mlx‑lm 自身,在 oq.py 里 monkey‑patch 即可
- safetensors 是自描述的,header 包含所有 tensor 名,可以直接做权重的增删改,不需要理解模型结构
- oQ 内置标定数据(560 条 code_multilingual)对大多数模型够用,不需要额外准备
尾声
现在 ~/.lmstudio/models/Intern‑S2‑Preview‑oQ4‑mtp 已经在安静运行。被切掉的 511 个 time_series 参数,躺在 .bak 文件里,像一段被注释掉的代码——不碍事,也不会丢。
从"模型类型不支持"到"参数无家可归",到最终推理跑通——这个故事的核心不是技术有多难,而是顺着调用链一层层往下扒,直到看见源码里的那一行 if 语句。