Soup 在 8 GB 统一内存的 M1 上实测 MLX SFT:天花板究竟在哪里

Soup 在 8 GB 统一内存的 M1 上实测 MLX SFT:天花板究竟在哪里 Soup 在 8 GB 统一内存的 M1 上实测 MLX SFT天花板究竟在哪里【免费下载链接】SoupFine-tune LLMs from one YAML. Layer streaming trains an 8B model on a 4 GB laptop GPU.项目地址: https://gitcode.com/GitHub_Trending/soup12/Soup本文是 Soup 开源仓库中 benchmarks/run-m1-8gb-mlx-sft.md 这份实测记录的完整技术解读。它回答了一个此前从未被验证过的问题Soup 的backend: mlx训练路径能否在低内存 Apple Silicon8 GB 统一内存的 M1上端到端跑通峰值内存与吞吐量到底落在什么量级。读完本文你将掌握 MLX 后端 SFT 的实测配置、指标口径陷阱、内存分页机制、失败的模型 fixture以及如何在本地完整复现这份测量。这是一次测量而不是门禁先明确它证明了什么这份记录的开头就划清了边界Status: FIVE RUNS COMPLETED, ONE FIXTURE FAILED. This is not a gate.它对应的仓库议题是 #23该议题从 v0.25.0 起一直悬而未决——因为 CI 没有 Apple Silicon 硬件维护者也没有 Mac。因此MLXSFTTrainerWrapper的训练循环从未在真实 MLX 运行时上执行过。这份记录做的是两件事端到端跑通证明MLXSFTTrainerWrapper的训练循环能对着真实 MLX 运行时从头跑到尾测量两个量低内存 Apple Silicon 端的峰值内存与吞吐量。它不建立适配器正确性——唯一能说的只是适配器能加载、loss 在下降详见下文这次测量不能证明什么一节该节篇幅甚至比结果还长这是有意为之。为什么是这台机器仓库里已有 Apple Silicon 记录benchmarks/gate-qwen4-ple-m4-max.md 是 PyTorch/MPS 路径、覆盖 Qwen4 decoder 映射并且明确声明不得被当作吞吐量、峰值内存或生产可训练性声明。因此本次记录并非首条非 CUDA 记录它的增量是更窄的三点首次测量MLX 后端的训练循环而非 transformers/MPS 路径首批来自 Apple Silicon 的吞吐量与峰值内存数据内存规模是8 GB对照对象是该记录的 128 GiB M4 Max——内存足迹问题只会在这样的机器上暴露或者根本不会暴露。度量单位约定GB 以 MLX 报告的为准mx.get_peak_memory()基于 GiB主机侧数据来自memory_pressure与sysctl vm.swapusage。测试配置机器Apple M18 GB 统一内存8 核系统 / PythonmacOS 26.6.2arm64Python 3.12.14MLXmlx0.32.2、mlx-lm0.31.3Metal 可用配置backend: mlx、task: sft、LoRA r8 α16、batch_size: 1、gradient_accumulation_steps: 1、max_length: 512、lr: 1e-4数据48 条合成 chatml 行1 epoch → 48 次迭代48 行 / batch 1 / accum 1测试脚本benchmarks/harness/mlx_sft_smoke.py派发是断言出来的不是假定出来的每次运行都会在计时器启动之前检查resolve_trainer(cfg)返回的是不是MLXSFTTrainerWrapper否则直接中止。这不是仪式感议题 #363 就是backend: mlx从未真正到达 MLX trainer——一个看起来合理的数字完全可能是 transformers 路径的伪装。该断言写进 harness 正是为了杜绝发布这种数字。从源码看这个断言对应 src/soup_cli/trainer/mlx_routing.py 中的resolve_trainer当cfg.backend mlx时返回get_mlx_trainer(cfg.task)SFT 惰性导入MLXSFTTrainerWrapper否则返回None让调用方落到 transformers 任务链。harness 中对返回类的检查MLX not in cls.__name__即判定失败。结果总览五轮完整运行以下按运行顺序列出全部五轮完成结果。peak是 MLX 自身的get_peak_memory()mlx-lm 逐迭代打印的Peak mem在数值不同处单独列出。模型4-bitloadload 后峰值训练 48 迭代训练峰值mlx-lm 峰值主机空闲 前→后swap 总量 →训练 tokentok/sloss适配器重载Qwen2.5-0.5B-Instruct31.7 s0.262 GB19.7 s0.497 GB0.49754% → 39%2048 M2,130108.13.639 → 0.1072,122 KB0.9 sLlama-3.2-3B-Instruct123.3 s1.683 GB29.5 s2.083 GB2.23654% → 32%2048 → 3072 M2,31678.54.527 → 0.3308,972 KB2.1 sQwen2.5-7B-Instruct268.3 s3.993 GB67.6 s4.584 GB4.92269% → 14%3072 → 9216 M2,13031.54.514 → 0.1279,868 KB15.8 sLlama-3.1-8B-Instruct317.8 s4.207 GB71.0 s4.800 GB5.15469% → 14%3072 → 9216 M2,31632.63.454 → 0.14213,326 KB23.8 stok/s 的推导口径为什么这个表早期版本算错过表中每个 tok/s 都是训练 token 数 ÷ 训练秒数——一个全程平均值whole-run average由 harness 计算并以throughput行打印。它不是mlx-lm 打印的Tokens/sec那是每个报告点的瞬时值在 0.5B 单次运行中波动范围是19.192 到 254.313。该表首个发布版本把第一行引用了后期某个瞬时读数~240而同一行的其他列全是全程值导致该行内部自相矛盾 2.2 倍——详见下文测量过程中做出的修正。表格通过一个内部校验自洽第 1、3 行共用Qwen2tokenizer 与完全相同的输入因此训练 token 总数必须相等2,130 2,130第 2、4 行共用Llama32,316 2,316。这把每迭代约 44-48 token 钉在了整个网格上修正后的 tok/s 列与之吻合。哪个峰值才是头条加粗的peak in train列是 harness 在train()返回后采样的mx.get_peak_memory()mlx-lm peak列是 mlx-lm 自己记录的逐迭代高水位严格不小于前者。引用上限时应引用 mlx-lm 的数值——8B 对应5.154 GB——因为那才是进程真正到达的最大值harness 读数可能漏掉步内的瞬时尖峰。峰值与主机空闲统一内存才是承重列不是 tok/s在与操作系统共享 8 GB 的内存上天花板才是关键变量。7B 与 8B 都结束于14% 主机空闲且这两档之间 swap 文件增长超过三倍——机器是吸收它们而不是拒绝机制见主机行为一节。另外注意load列包含首次从 Hub 下载的时间因此是网络数据而非模型加载数据。头条结论llama3.1-8b-sft-mlx——随项目发布的配方——能在 8 GB M1 上训练。峰值 5.154 GB48 次迭代耗时 71 s适配器写出且可重载。该配方定义在 src/soup_cli/recipes/catalog.pybase: mlx-community/Llama-3.1-8B-Instruct-4bit、backend: mlx、max_length: 2048、LoRA r16 α32、quantization: 4bit。作者在议题上事先预测装不下——结果装下了预测错了而这行数据正是这条记录存在的原因。两个无法解释、也无意粉饰的现象7B 比 8B 慢。Qwen2.5-7B跑出31.5 tok/s低于Llama-3.1-8B的32.6且每个报告迭代的It/sec都更低。该异常在 tok/s 修正后依然存在原始数据有、全程均值也有因此不是测量口径混用造成的假象。作者列出的候选解释均未验证Qwen2.5 更大的词表151,936 vs 128,256在这么短的序列下让输出投影成为瓶颈两档运行之间的主机内存压力不同。记录中明确表述为观察而非机制。适配器重载时间随规模超线性恶化。0.9 s → 2.1 s → 15.8 s → 23.8 s而适配器大小只跨 6 倍。重载要重新读取完整 base 模型因此主导项是 base 权重而非适配器——但 7B 的 15.8 s 对 8B 的 23.8 s 仍然陡于参数比作者未将其隔离出来。主机行为内存太大不会失败而是分页真正有意思的机制是对 RAM 而言过大的模型在这里并不失败——它分页。macOS 在运行期间持续扩大 swap 文件2048 → 3072 → 4096 → 8192 → 9216 MB空闲内存长时间停在 5-6%而ls /依然 16 ms 返回。MLX 对权重做内存映射memory-map因此模型大部分是文件背书、可被逐出的低空闲内存是这类负载的正常形态不是故障信号。这对任何为该后端编写 pre-flight 的人都有直接后果分配失败检查不会触发。这与 #649 中 WDDM 溢出的陷阱同源——step 没有抛异常不等于step 装得下——只不过这次是从相反平台抵达的同一结论。失败样本#23 推荐的 fixture 根本加载不了议题 #23 点名mlx-community/TinyLlama-1.1B-Chat-v1.0-4bit作为小模型测试 fixture但它无法使用FileNotFoundError: No safetensors found in ~/.cache/huggingface/hub/models--mlx-community--TinyLlama-1.1B-Chat-v1.0-4bit/snapshots/01a7088...仓库存在且可解析其文件列表最后修改于2024-01-05为config.json special_tokens_map.json tokenizer.json tokenizer_config.json weights.00.safetensorsmlx_lm0.31.3 按allow_patterns[model*.safetensors, ...]拉取utils.py:237-239load_model则 globmodel*.safetensorsutils.py:316。旧的weights.NN.safetensors命名两种模式都不匹配于是权重从未被下载加载在只有 tokenizer 的 snapshot 上失败。仓库本身没有错错在命名约定早于 mlx-lm 现在要求的规范。任何要写 #23 CI 任务的人都不该使用那个模型 id。本次验证可用的最小 fixture 是mlx-community/Qwen2.5-0.5B-Instruct-4bit282 MB48 迭代 19.7 s——这正是 benchmarks/harness/mlx_sft_smoke.py 中DEFAULT_MODEL的值其文档字符串也把 TinyLlama 标记为已知坏样本Known-bad。这次测量不能证明什么作者在记录中逐条列出边界这里完整保留无正确性声明。Loss 在下降、适配器能重载。但没有任何东西把 MLX 输出与 transformers 参考实现对比、检查梯度精确性、或评估微调后的模型。能训练是声明正确地训练不是。数据是合成的且极其平凡——48 行短问答故意重复。在 48 条重复行上 loss 从 3.6 掉到 0.1 是记忆化不是学习的证据。它只是一个冒烟信号。target_modules: auto在这里只意味着 Q/V。每次运行都打印auto cannot be resolved for all architectures; defaulting to [self_attn.q_proj, self_attn.v_proj]。这是故意的——见 #392 中MLX_DEFAULT_TARGET_KEYS [self_attn.q_proj, self_attn.v_proj]resolve_mlx_target_keys在raw in ([auto], auto)时回落到该列表。一台机器、每档一次运行无重复因此没有离散度——而且后来的重跑显示离散度很大。在更安静机器上、从热缓存重跑Qwen2.5-0.5B同样 48 行得到373.1 tok/s2,130 tokens / 5.7 s对照表中 108.1——同一模型、同一配置、同一全程指标相差 3.5 倍。差异来自主机条件与热 Hub 缓存而非方法。表中的每个 tok/s 都应视为数量级而非基准值。load被下载时间污染。首次 Hub 拉取占主导热缓存加载未被单独测量。各档之间的主机压力不同且未受控swap 在整个会话中单调增长。DPO 与 GRPO 未被触及。_validate_mlx_task在配置加载时就拒绝它们——见 src/soup_cli/config/schema.py 的_validate_mlx_task_support只放行task sft其余抛 ValueError。本次只有 SFT 被验证。测量过程中做出的修正两条都是作者自己的错都在变成结论之前被抓住也正是这两条修正让上面的数字值得信。裸 HTTP 401 什么都证明不了。作者最初把 401 读成模型不存在据此断言SmolLM2-135M-Instruct-4bit缺失。但一个杜撰的 repo id 返回同样的 401。正确工具是huggingface_hub.model_info它能区分RepositoryNotFoundError与存在但被 gated。这超出了 fixture 选择本身——它正是把 #661 建立在证据而非状态码之上的原因。target_modules: auto降级为 Q/V 不是缺陷。作者曾把它记为缺陷随后发现 #392 与解释其意图的 docstring。它应作为解读 loss 曲线的上下文写进记录而不是 bug。tok/s 列混用了两种测量。第 1 行引用了 ~240——mlx-lm stdout 中一个偏后期的瞬时Tokens/sec——而该行训练时间及其余各行都是全程值。在评审中被发现第 1、3 行共用 tokenizer 且输入相同却暗含每迭代 98.5 对 46.5 个 token相差 2.12 倍不可能同时成立。由作者从自己的姊妹 PR 中抓取的 mlx-lm dict 推导出真实 ~45 token/迭代第 1 行的全程均值是108.1 tok/s不是 240。harness 现在自己计算该值使该列无法再偏离方法。另外TinyLlama 那一档的输出被作者自己的tail -120阶梯调用截断因此上面的失败是从头重新测量的而非从截断输出中重构。如何在本地复现当前版本的 harness 还顺带验证了 #665 加入的 Rich 显示与实验追踪器桥接它把experiments.db存在临时工件旁边不碰用户日常实验数据库并且在没有显示更新或 tracker 指标到达时判定失败。桥接照常发出进程内 SSE 事件不启动独立 UI 服务器。注意上述测量早于该桥接接线未在桥接开启状态下重跑新运行会把显示/追踪开销计入训练墙钟时间。pip install -e .[mlx] python benchmarks/harness/mlx_sft_smoke.py mlx-community/Qwen2.5-0.5B-Instruct-4bit 48 1harness 固定batch_size: 1与gradient_accumulation_steps: 1因此那 48 行就是48 次优化器更新。这两个值连同模型 id、行数一起被写死原因相同下面的吞吐量是每步工作量复现者需要步骤数同样可复现而不是从当时的 schema 默认值继承。harness 从加载后的配置解析两个计数若任一不是rows × epochs则在训练开始前以非零码退出并点名迭代数、优化器更新数与舍入会丢弃的行数。对应的解析逻辑与MLXSFTTrainerWrapper中iters的计算镜像见 benchmarks/harness/mlx_sft_smoke.py 的resolve_step_counts。它会打印表中 tok/s 列所来源的吞吐行train : 19.7s mlx peak during train: 0.463 GB throughput : 108.1 tok/s (2130 trained tokens / 19.7s, whole-run average)需要 Apple Silicon。harness 在测量前断言 MLX 派发并在前后打印主机内存因此在其他机器上的运行可直接与上表对照。底层调用链wrapper 到底做了什么从 src/soup_cli/trainer/mlx_sft.py 的源码结构可以完整还原这次冒烟测试驱动的代码路径setup()→_require_mlx()_check_unsupported()拒绝 8bit 量化、GaLore、Ring Attention、FlashAttention、Liger、NEFT、MoD、moe_lora、QAT、FSDP2-compile、training.seed/data_seed等 MLX 无对应实现的配置然后经 src/soup_cli/utils/mlx.py 的load_mlx_modelmlx_lm.load的薄封装quantization参数仅为告知性——MLX 模型通常构建时已量化。train()→_apply_lora先model.freeze()再用linear_to_lora_layers因为mlx_lm.load不会冻结不冻结的话保存的适配器实际是全量微调构建TrainingArgs把iters在grad_accumulation_steps 1时向下取整到整组通过mlx_lm.tuner.callbacks.TrainingCallback的on_train_loss_report/on_val_loss_report两个钩子把 loss、lr、迭代速度、峰值内存桥接到 Soup 的显示与实验追踪器。训练结束后除 mlx-lm 保存的adapters.safetensors外wrapper 还会写出adapter_config.json其中lora_parameters.keys用的是解析后的模块列表resolve_mlx_target_keys杜绝 #392 的训练了 Q/V 却发布[auto]问题。测试侧可参考 tests/test_mlx_backend.py含对llama3.1-8b-sft-mlx配方的断言与 tests/test_issue716_harness_step_guard.py步骤计数守卫。结语这份记录的读法把这份记录放回 benchmarks/README.md 的语境中它属于Measurement records测量记录而非 gate——是在 CI 没有的硬件上验证 MLX 训练循环端到端可运行的证据并给出了低内存端的峰值与吞吐量。它的价值不在于数字本身作者自己都强调单次运行的 tok/s 只能当数量级而在于三点方法论遗产测量前断言派发路径杜绝伪装数字、全程均值与瞬时值严格区分杜绝口径混用、以及**没抛异常 ≠ 装得下的清醒**分页机制下分配失败检查不会触发。想要在 8 GB 内存的 Mac 上复现或继续验证benchmarks/harness/mlx_sft_smoke.py 就是现成的起点。【免费下载链接】SoupFine-tune LLMs from one YAML. Layer streaming trains an 8B model on a 4 GB laptop GPU.项目地址: https://gitcode.com/GitHub_Trending/soup12/Soup创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考