PyTorch转Core ML:Laya-CoreML 模型转换全流程与enumerated shapes避坑指南
【免费下载链接】laya-coremlLocal Laya typed decisions on Apple Core ML and Neural Engine. Validated ports, ~5 ms short decisions on M3 Max, reproducible speed and energy benchmarks.项目地址: https://gitcode.com/gh_mirrors/la/laya-coreml
🍎 想在 Apple Silicon 上跑本地决策模型?本文以Laya-CoreML(一个把 Laya 类型化决策模型移植到 Apple Core ML 与 Neural Engine 的开源项目)为例,完整演示PyTorch 转 Core ML的转换流程,并重点拆解enumerated shapes(枚举形状)这个最容易被坑的动态形状方案——帮你在 Mac 上以约 5 ms 的延迟做出稳定的本地推理。
1. 项目是什么:为什么值得学这次转换
Laya-CoreML是一个独立移植项目:它把 Laya(Convai Innovations 的开放权重决策模型)从 PyTorch 权重转换为Core ML ML Program,在 M3 Max 上实现:
- ⚡ 单个短决策P50 约 4.98 ms(ANE FP16),比编译后的 MLX FP16 快 1.39×
- 🔋 每决策系统能耗降低2.78×(W8 调色板变体达 3.19×)
- 📦 推理时无需 PyTorch、Transformers 或 MLX,纯 Core ML 运行时
- ✅ 三个通用 FP16 检查点在 189/189 道验证题上与上游答案一致
它的特色是"类型化决策":模型直接输出choice(多选一)、score(分级分)、noul(布尔)三类答案的概率分布,零生成 token,没有自回归解码,这正是它能被稳定转换成 Core ML 图的原因。
📄 更多背景见 README.md 与 docs/USAGE.md。
2. 转换全流程:从 PyTorch 权重到 Core ML 包
2.1 第一步:安装转换环境
转换需要[convert]扩展(会装 PyTorch),但推理端不需要:
python -m pip install 'laya-coreml[convert]'⚠️ 版本很关键。项目锁定的验证环境是:coremltools==9.0、torch==2.7.0、numpy==2.1.3、Python 3.12。NumPy 必须低于 2.2(2.5 会触发 coremltools 9.0 中废弃的数组转标量路径),PyTorch 则用 2.7.0 而不是 2.7.1。完整清单见 docs/CONVERSION.md。
2.2 第二步:一条命令导出
laya-coreml convert laya-multilingual models/custom-multilingual这条命令背后做了四件事(实现在 laya_coreml/convert.py):
- 加载原始检查点:把 FP16 权重读入 FP32 的 PyTorch 模块,严格校验 state-dict 键;支持本地目录,也支持按 pin 住的 revision 自动下载
- TorchScript 追踪:用
strict=True, check_trace=True追踪一个仅推理的前向图 - 声明输入签名:
input_ids、attention_mask等 5 个输入按枚举形状注册,输出logits/action_logits - 打包产物:保存
model.mlpackage,并复制 tokenizer、编码器配置,生成含权重 SHA256、工具版本、所有文件哈希的coreml_config.json溯源清单
转换入口参数(--max-length、--shape-mode、--attention等)定义在 laya_coreml/cli.py:
| 参数 | 默认值 | 说明 |
|---|---|---|
--shape-mode | enumerated | 本文重点,另一选项range有坑 |
--precision | float16 | FP16 是转换精度选择,FP32 用于诊断 |
--attention | sdpa | 可选explicit手写注意力 |
--fixed | 关 | 固定形状导出,适合已知工作负载 |
2.3 第三步:读懂生成物
导出的目录结构长这样:
models/custom-multilingual/ ├── model.mlpackage/ # Core ML 模型(macOS 15 / iOS 18 部署目标) ├── tokenizer/ # 随包携带的分词器 ├── encoder/config.json ├── rl_agent_config.json └── coreml_config.json # 溯源清单:权重哈希、形状、版本导出拒绝覆盖已有目录,失败时只清理自己新建的输出目录——这是可复现工程里很贴心的细节。
3. 避坑指南:为什么是 enumerated shapes 而不是 RangeDim
这是本项目最有价值的经验部分。动态形状是 PyTorch 转 Core ML 时最容易翻车的地方,项目把踩过的每个坑都留档在 docs/CONVERSION.md 的 "Failures retained for reproducibility" 一节。
3.1 坑 1:RangeDim + GPU 会静默出错
最直觉的做法是用ct.RangeDim(16, max_length, default=...)让模型接受任意长度。项目在 M3 Max 上实测发现:
RangeDim配合CPU_AND_GPU时产生较大数值误差,且相同输入重复执行结果不一致- 原版 SDPA 导出只匹配47/63参考答案;换成显式 matmul/softmax 注意力更是只有 20/63
- 换成 FP32 也没能解决短输入在 GPU 上的失败
⚠️ 教训:"能跑通"不等于"算对了"。GPU 上的动态范围形状可能给出看起来正常、实则错误的输出,必须用参考值逐题比对。
3.2 坑 2:enumerated shapes 的正确写法
最终方案是EnumeratedShapes——枚举一批固定长度[16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024](不超过检查点上下文上限),运行时把请求填充(pad)到最近的长度并用 mask 屏蔽填充 token。GPU 精度与可重复性就此恢复。
写对它有两条规则:
- 多个枚举输入必须形状数量一致、按索引配对。本项目的
input_ids和attention_mask必须用同一组枚举形状,否则签名不对齐 - 超过导出容量的输入会报错而不是静默截断——容量保护是显式的
对应代码在 laya_coreml/convert.py 中:
lengths = sorted( {v for v in (16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024, max_length) if v <= max_length} ) sequence_shape = ct.EnumeratedShapes( [(batch_size, n) for n in lengths], default=(batch_size, length) )📌 如果你只想服务一个已知工作负载,直接用--fixed固定形状更简单——超出的输入直接报错,不存在填充开销。
3.3 坑 3:MPSGraph 编译器对布尔矩阵切片的 SIGTRAP
枚举形状恢复了 GPU 保真度,但一个小的回归测试又暴露了 MPSGraph 编译器的SIGTRAP崩溃:直接切片一个常量布尔局部注意力矩阵会触发编译器缺陷(诊断信息指向FoldStridedSliceOp)。
解法很巧妙:改成先切片整数位置、之后再构造布尔局部 mask。实现在 laya_coreml/torch_model.py 中(attention_mask_construction: integer_positions_v2)。这个修复只消除了编译器陷阱,没有治愈 RangeDim 的 GPU 数值问题——后者匹配 49/63 且不可重复,所以枚举长度仍是默认方案。
3.4 坑 4:符号链接权重会让 Core ML 编译器"找不到文件"
从 Hugging Face 共享缓存加载时,符号链接形式的权重文件会让 Core ML 原生编译器报weight.bin缺失。运行时现在会把符号链接包物化为常规文件,放入内容寻址缓存(~/.cache/laya-coreml/packages/),拷贝前后都校验哈希。详见 docs/CONVERSION.md "Loading Hub snapshots" 一节。
4. 转换之后:用黄金参考验证保真度
转换成功 ≠ 模型正确。项目内置了完整的验证流水线 benchmarks/validate.py:
- 黄金参考(benchmarks/results/reference.json)由未修改的上游 Laya 代码用 FP32 PyTorch MPS 生成,含完整输入 token ID 和未舍入 logits
- 验证时逐 token 精确比对输入,再核对选定答案、校准概率、动作概率与 token 计数
- 跑 100 次重复调用,确认输出有限且稳定
原始的成功/失败报告都保留在 benchmarks/results/——其中"passed": false的报告是反面教材,不能当作已验证配置引用。比如枚举形状 +cpu_gpu的验证结果见 benchmarks/results/validation-laya-enumerated-cpu_gpu.json。
python -m benchmarks.validate models/custom-multilingual \ --name laya-multilingual --compute-units cpu_gpu \ --repeats 100 --output artifacts/validation.json5. 进阶方向:把模型搬上 Neural Engine
普通 SDPA 导出在CPU_AND_NE下,1318 个算子的计算计划全部偏向 CPU——逐个算子"支持 ANE"并不能让整个图落到 ANE 上。项目的 ANE 实验在 experiments/ane_engineering/ 里重写了图结构:
- 激活张量改为B,C,1,L通道优先布局,稠密权重变成 1×1 卷积核
- 注意力拆成逐头的 64 通道计算,QK/AV 用显式 einsum
- 结果:6390 个非常量算子在计算计划中优先分配 ANE,L96 固定形状 59/59 验证题通过
这些研究脚本在 Git 仓库中(推理轮子里没有):
git clone https://gitcode.com/gh_mirrors/la/laya-coreml cd laya-coreml pip install -e '.[convert,dev,research]' python -m experiments.ane_engineering.probe --source laya-multilingual \ --kind body --length 96 --output models/ane96⚠️ 注意区分:CPU_AND_NE表示"允许" CPU 和 ANE 参与,不保证每个算子都跑在 ANE 上;MLComputePlan是预期计划,不是硬件执行 trace。ANE 细节见 docs/ANE_ENGINEERING.md 与 docs/ANE_MATH.md。
6. 总结:转换检查清单
| 检查项 | 推荐做法 |
|---|---|
| 工具版本 | coremltools==9.0+torch==2.7.0+numpy<2.2 |
| 动态形状 | 用EnumeratedShapes,多个输入按索引配对 |
| 避免 RangeDim + GPU | 会静默出错,运行时默认拒绝该组合 |
| 布尔矩阵切片 | 先切整数位置再构造 mask,绕开编译器崩溃 |
| 权重来源 | 符号链接缓存先物化为常规文件并校验哈希 |
| 验证 | 与上游 FP32 黄金参考逐题比对 + 100 次重复调用 |
PyTorch 转 Core ML的核心心得一句话:形状策略决定成败(enumerated 优于 range),精度问题必须用参考值验证而不是靠肉眼,所有失败配置也值得留档。跟着 docs/CONVERSION.md 的完整记录走一遍,你就能在自己的模型上复刻这套可复现的转换流程 🚀
【免费下载链接】laya-coremlLocal Laya typed decisions on Apple Core ML and Neural Engine. Validated ports, ~5 ms short decisions on M3 Max, reproducible speed and energy benchmarks.项目地址: https://gitcode.com/gh_mirrors/la/laya-coreml
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考