Если вы хотите локально дообучать LLM на Apple Silicon, по сути есть два варианта: PyTorch MPS со стандартным стеком Hugging Face/TRL или Apple MLX — нативный фреймворк Apple. Оба поддерживают локальное дообучение с LoRA. И оба ведут себя совсем не так, как привыкли разработчики, работающие с CUDA.
На обычном компьютере с 16 ГБ памяти обучение на многоходовых трейсах работы агентов для программирования очень быстро упирается в физические ограничения железа. PyTorch MPS обеспечивает высокую производительность в FP16 на небольших моделях, но по мере роста контекста довольно резко упирается в потолок по памяти.
Apple MLX благодаря нативной 4-битной QLoRA позволяет запускать модели на 3 B параметров с контекстом 4 k и почти не увеличивает потребление памяти при его росте. Цена за это — накладные расходы на деквантование при каждом матричном умножении, как на прямом, так и на обратном проходе.
Мы провели 10 контролируемых экспериментов с SFT на моделях Qwen 2.5 размером 0,5 B, 1,5 B и 3 B параметров. Для обучения использовали реальные трейсы работы агентов из badlogicgames/pi‑mono, каждый шаг записывали в общую базу Trackio, а затем сравнили компромиссы двух подходов.
Коротко о результатах:
-
PyTorch MPS в 2,2–5,7 раза быстрее по чистой производительности, но на машине с 16 ГБ памяти предел находится примерно на уровне 1,5 B параметров.
-
Apple MLX позволяет работать с моделями вдвое крупнее, от 3 B параметров, и без проблем масштабируется до контекста 4 k и больше. При увеличении контекста в четыре раза, с 1 k до 4 k токенов, потребление оперативной памяти вырастает всего на 109 МБ.
-
4-битное квантование не ухудшает сходимость на трейсах работы агентов: функция потерь на валидации у обоих бэкендов получается близкой — 0,462 для MPS против 0,478 для MLX на модели 1,5 B с контекстом 2 k.
-
При этом большая часть разницы в скорости вообще не связана с квантованием. В двух из десяти запусков MLX использовался без квантования, и устранение накладных расходов на деквантование сокращает разрыв лишь в 1,07–1,4 раза в зависимости от размера модели. Даже после этого сохраняется примерно 4,1–4,6-кратный разрыв в производительности на уровне фреймворков.
Дальше разберём результаты бенчмарков, проблемы, с которыми мы столкнулись, и особенности аллокатора памяти, из‑за которых macOS может начать задыхаться прямо посреди обучения.
Нагрузка: SFT на многоходовых трейсах работы агентов для программирования
В большинстве гайдов по дообучению используют одноходовые наборы инструкций длиной 256 или 512 токенов. Реальные трейсы работы агентов выглядят совсем иначе.
Мы обучались на badlogicgames/pi‑mono — датасете с полными сессиями автономных агентов для программирования. Каждый пример представляет собой многоходовый диалог, в котором есть:
-
Пользовательские запросы — высокоуровневые описания задачи («Найди утечку памяти в worker.py и напиши регрессионный тест»).
-
Рассуждения ассистента и вызовы инструментов — структурированные вызовы
bash,read_file,edit_file,grepи других инструментов. -
Ответы окружения — необработанный вывод терминала
stdout/stderr, трейсы компилятора и содержимое файлов.
Наш пайплайн конвертации отфильтровал 627 исходных файлов с трейсами, содержащих 33 879 событий сообщений, и получил 6 727 диалоговых ходов с ограничением max_length=4096 токенов. Из них 6 471 пример использовался для обучения, а 256 были отложены для валидации.
Почему здесь важна функция потерь только по токенам ответа
Стандартная целевая функция каузальной языковой модели обучает модель на каждом токене последовательности. Для агента это не тот результат, который нам нужен: модель должна научиться понимать, какой инструмент и когда вызывать, а не запоминать лог сборки на 500 строк, вернувшийся в качестве вывода инструмента.
Поэтому пользовательские запросы и токены вывода инструментов мы маскируем значением -100, а кросс‑энтропийную функцию потерь считаем только по рассуждениям ассистента и вызовам инструментов:
loss = -mean( log P(token_t | tokens_<t) ) for every token t the assistant produced
Все остальные позиции токенов — пользовательские запросы и вывод инструментов — в это среднее вообще не входят. В SFTTrainer из TRL для этого есть параметр completion_only_loss=True. В MLX аналогичного флага нет, поэтому перед обучением мы заранее преобразовали данные в соответствующие маски входных и целевых токенов.
Два фреймворка и объединённая память
В Apple Silicon CPU, Metal GPU и сама macOS с её композитингом используют общий пул объединённой памяти. От того, как каждый фреймворк распоряжается этой памятью, зависит, что в неё в итоге удастся уместить.
1. PyTorch MPS (стек Hugging Face / TRL)
-
Точность: веса и активации в FP16. (4- и 8-битное квантование
bitsandbytesработает только с CUDA и на MPS недоступно.) -
Настройка LoRA: ранг
r=16,alpha=32, адаптация всех семи проекций attention/MLP (q,k,v,o,gate,up,down). -
Плюс: полная совместимость с экосистемой — один и тот же скрипт можно локально запускать через MPS или на NVIDIA GPU через Hugging Face Jobs.
-
Минус: веса FP16 требуют по 2 байта на параметр ещё до того, как вы выделили хотя бы один байт под активации или состояние оптимизатора.
2. Apple MLX (mlx‑lm)
-
Точность: нативная 4-битная QLoRA на предварительно квантованных весах
mlx-communityв 5 из 10 запусков. Ещё в двух запусках MLX (qwen-0.5b-mlx16-2k,qwen-1.5b-mlx16-2k) использовались обычные неквантованные весаQwen/Qwen2.5-*-Instruct— с тем же размером модели и контекста, что и у их 4-битных аналогов. Что именно позволяет отделить такое сравнение, разберём в разделе «Подробные результаты», § 1. Оставшиеся 3 из 10 запусков выполнялись на PyTorch MPS и вообще не использовалиmlx-community. -
Настройка LoRA: стандартные параметры
mlx_lm.lora— ранг 8,scale=20.0,dropout=0, с адаптацией последних 24 слоёв трансформера. Именно такие значения по умолчанию используетrun_experiments.py; при прямом запускеtrain_mlx.pyпо умолчанию адаптируются только 16 слоёв. Вtrain_mlx.pyранг иscaleнигде не переопределяются, поэтому, несмотря на симметрично выглядящие пункты списка, эти настройки не соответствуютrank=16иalpha=32на стороне MPS. То есть здесь различается не только точность вычислений, но и ёмкость самих адаптеров. -
Плюс: 4-битные веса сокращают объём памяти под модель примерно на 75%. Квантованные веса модели на 3 B параметров занимают при загрузке около 1,8 ГБ вместо примерно 6,0 ГБ в FP16.
-
Минус: работает только на Apple. Скрипт на MLX нельзя запустить на CUDA без переписывания под PyTorch.
В обоих бэкендах использовались одинаковые learning rate (1e-4) и эффективный размер батча (4 — микробатч размером 1 и 4 шага накопления градиентов), но оптимизатор и схема изменения learning rate различались.
На MPS использовался AdamW с косинусным затуханием — это настройки train_sft.py по умолчанию. MLX работал со стандартными настройками mlx_lm.lora: обычным Adam и постоянным learning rate. В train_mlx.py нигде не задаются --optimizer или --lr-schedule, так что это не осознанный выбор, а просто поведение библиотеки по умолчанию.
# PyTorch MPS run (FP16 LoRA)
uv run tutorials/01-sft-on-traces/train_sft.py
--model-id Qwen/Qwen2.5-1.5B-Instruct
--device mps --max-length 2048 --max-steps 509 --completion-only-loss
# Apple MLX run (4-bit QLoRA)
uv run tutorials/01-sft-on-traces/train_mlx.py
--model mlx-community/Qwen2.5-1.5B-Instruct-4bit
--iters 509 --batch-size 1 --grad-accumulation-steps 4 --grad-checkpoint
Единая телеметрия с Trackio
Чтобы сравнивать MPS и MLX на равных условиях, мы направили телеметрию обоих бэкендов в Trackio — локальный трекер экспериментов на базе SQLite.
-
Логирование MPS:
train_sft.pyинтегрируется с TRL и через пользовательскийMPSTelemetryCallbackзаписывает функцию потерь на каждом шаге, функцию потерь на валидации, точность по токенам и использование памяти Metal. -
Логирование MLX:
train_mlx.pyоборачиваетmlx_lm.lora, разбирает егоstdout(Train loss,Val loss,It/sec,Peak mem GB) и отправляет полученные значения в ту же базу Trackio.
Почему для запусков MLX нет точности по токенам
В таблице результатов ниже для PyTorch MPS в столбце Token Acc указаны значения от 85,9% до 88,6%, а для MLX стоит -. Это различие связано с архитектурой фреймворков, а не с ошибкой в нашем логировании:
-
SFTTrainerиз TRL во время валидации сравнивает наиболее вероятный токен модели (logits.argmax(-1)) с эталонными токенами ответа и записывает метрикуeval/mean_token_accuracy. -
mlx_lm.loraвычисляет кросс‑энтропийную функцию потерь напрямую по логитам и вообще не выполняет отдельныйargmax— предположительно, чтобы не выделять дополнительный буфер Metal. Оба фреймворка при этом считают функцию потерь на валидации, поэтому именно её мы используем для сравнения между ними.
Локальный дашборд доступен по адресу http://127.0.0.1:7860?project=training‑agents‑sft (uv run trackio show --project training-agents-sft). На нём рядом отображаются кривые функции потерь, производительность по шагам и потребление памяти. Данные также синхронизируются с опубликованным Space — harshitkgupta/training‑agents‑trackio, поэтому графики всех 10 запусков можно посмотреть, ничего не запуская локально.
Результаты бенчмарка
Метрики всех 10 запусков на Mac с Apple Silicon и 16 ГБ памяти:
|
Название запуска |
Модель |
Бэкенд |
Точность |
Контекст |
Шаги |
Функция потерь при обучении (начало → конец) |
Функция потерь на валидации |
Точность по токенам |
Скорость |
Пиковое использование VRAM |
|---|---|---|---|---|---|---|---|---|---|---|
|
Qwen 0.5 B |
PyTorch MPS |
FP16 |
2 048 |
62 |
1.544 → 0.548 |
0.5549 |
85.9% |
5.35 it/s |
1,061 MB |
|
|
Qwen 0.5 B |
Apple MLX |
4-bit |
2 048 |
55 |
2.137 → 0.717 |
0.7560 |
– |
0.94 it/s |
3,523 MB |
|
|
Qwen 0.5 B |
Apple MLX |
16-bit |
2 048 |
55 |
2.119 → 0.478 |
0.6170 |
– |
1.30 it/s |
4,532 MB |
|
|
Qwen 1.5 B |
PyTorch MPS |
FP16 |
2 048 |
62 |
1.235 → 0.487 |
0.4623 |
88.6% |
2.23 it/s |
3,180 MB |
|
|
Qwen 1.5 B |
Apple MLX |
4-bit |
2 048 |
55 |
1.951 → 0.394 |
0.4780 |
– |
0.46 it/s |
4,396 MB |
|
|
Qwen 1.5 B |
Apple MLX |
16-bit |
2 048 |
55 |
1.439 → 0.686 |
0.6280 |
– |
0.49 it/s |
7,117 MB |
|
|
Qwen 1.5 B |
PyTorch MPS |
FP16 |
4 096 |
62 |
1.542 → 0.542 |
0.5659 |
86.4% |
0.99 it/s |
3,180 MB* |
|
|
Qwen 1.5 B |
Apple MLX |
4-bit |
1 024 |
55 |
1.445 → 0.253 |
0.3310 |
– |
0.29 it/s |
4,342 MB |
|
|
Qwen 1.5 B |
Apple MLX |
4-bit |
4 096 |
55 |
1.722 → 0.393 |
0.4640 |
– |
0.46 it/s |
4,451 MB |
|
|
Qwen 3 B |
Apple MLX |
4-bit |
2 048 |
55 |
2.020 → 0.369 |
0.4520 |
– |
0.28 it/s |
5,494 MB |
*В запуске qwen-1.5b-mps-4k значение 3180 МБ — это верхний предел, который показывает собственный аллокатор PyTorch (torch.mps.current_allocated_memory()).
Фактическое потребление памяти драйвером Metal и системной подкачкой было заметно выше: дополнительная память требовалась для графа активаций при контексте 4 k.
Подробные результаты
1. Производительность: FP16-ядра против накладных расходов на деквантование
PyTorch MPS оказался быстрее на моделях всех размеров, которые вообще удавалось на нём запустить:
-
0,5 B при контексте 2 k: MPS — 5,35 it/s, MLX — 0,94 it/s (в 5,7 раза).
-
1,5 B при контексте 2 k: MPS — 2,23 it/s, MLX — 0,46 it/s (в 4,8 раза).
-
1,5 B при контексте 4 k: скорость MPS падает до 0,99 it/s, а MLX остаётся на уровне 0,46 it/s. Разрыв сокращается до 2,2 раза против 4,8 раза при 2 k.
По чистой производительности MLX отстаёт из‑за того, что 4-битная QLoRA хранит базовые веса в виде квантованных целых чисел, а перед каждым матричным умножением на прямом и обратном проходе их приходится деквантовать обратно в FP16.
На Apple Silicon такой подход хорошо экономит пропускную способность памяти, но увеличивает вычислительную нагрузку: на каждом слое и каждом шаге поверх матричного умножения появляется дополнительная операция. PyTorch MPS этого этапа избегает и выполняет матричные умножения в FP16 напрямую через Metal.
Однако на контексте 4 k картина меняется. При переходе с 2 k на 4 k токенов производительность MPS падает более чем на 55% — с 2,23 до 0,99 it/s — по мере роста нагрузки на память под активации. У MLX производительность вообще не меняется: 0,46 it/s и при 2 k, и при 4 k.
Если вы слышали, что MLX — быстрый вариант для Apple Silicon, это не ошибка. Просто речь идёт о другой нагрузке. Репутация MLX во многом связана с генерацией токенов: модель выдаёт по одному токену за раз и для каждого нового токена должна прочитать из памяти все свои веса.
Такая задача ограничена пропускной способностью памяти, поэтому 4-битная модель, которая переносит в четыре раза меньше данных за шаг, почти бесплатно получает ускорение при генерации.
При обучении всё устроено иначе: полные последовательности проходят прямой и обратный проход батчами, поэтому задача уже ограничена вычислительной производительностью.
Здесь деквантование 4-битных весов обратно в FP16 перед каждым матричным умножением превращается в реальную повторяющуюся нагрузку на каждом слое и каждом шаге.
При обычном матричном умножении FP16 на MPS этих накладных расходов нет. Железо то же, квантование то же, а результат противоположный, потому что генерация и обучение нагружают совершенно разные части системы.
Механизм понятен, но само по себе утверждение о «дополнительных накладных расходах» ещё не показывает, какую долю общего разрыва они объясняют.
В двух из десяти запусков (qwen-0.5b-mlx16-2k и qwen-1.5b-mlx16-2k) MLX работал без квантования, с теми же размерами модели и контекста, что и у соответствующих 4-битных вариантов. Благодаря этому разрыв можно разложить на составляющие, а не просто рассуждать о причинах:
|
Модель |
MPS |
MLX 4-бит |
MLX 16-бит |
Вклад деквантования |
Оставшийся разрыв на уровне фреймворков |
|---|---|---|---|---|---|
|
0,5 B |
5,35 it/s |
0,94 it/s |
1,30 it/s |
1,4 раза (0,94→1,30) |
4,1 раза (1,30→5,35) |
|
1,5 B |
2,23 it/s |
0,46 it/s |
0,49 it/s |
1,07 раза (0,46→0,49) |
4,6 раза (0,49→2,23) |
Отказ от квантования сокращает разрыв всего в 1,07–1,4 раза, причём с ростом модели этот эффект быстро уменьшается: на 1,5 B он уже почти незаметен. Даже если полностью исключить деквантование, остаётся примерно 4,1–4,6-кратный разрыв.
Иными словами, основная причина того, что MLX обучает модели медленнее MPS, вовсе не в 4-битных весах.
Путь обучения LoRA в mlx_lm пока менее зрелый и хуже оптимизирован, чем соответствующая реализация в PyTorch. Квантование добавляет к этому заметные, но вторичные накладные расходы, величина которых зависит от размера модели.

2. Память: MLX позволяет уместить модель вдвое крупнее
16 ГБ — жёсткий потолок. Одна только macOS вместе с WindowServer занимает 4–5 ГБ, так что до загрузки модели остаётся примерно 11 ГБ объединённой памяти.
В PyTorch MPS модель на 3 B параметров в FP16 требует:
weights_fp16 ≈ 3,0B параметров × 2 байта/параметр ≈ 6,0 ГБ
Шесть гигабайт под веса всё ещё оставляют несколько гигабайт для состояния оптимизатора и активаций.
Запас небольшой, но на бумаге это не выглядит совсем уж невозможным. На практике всё упирается не в обучение в установившемся режиме, а в загрузку модели: transformers сначала загружает FP16-чекпойнт в тензоры в памяти CPU, а затем model.to("mps") копирует каждый тензор в память MPS.
В объединённой памяти «CPU» и «MPS» используют один и тот же физический пул, поэтому до того момента, пока Python не удалит исходную копию из памяти CPU сборщиком мусора, одновременно хранятся почти два полных набора весов.
Для модели на 3 B это около 12 ГБ плюс ещё 4–5 ГБ, уже занятых ОС. То есть лимит в 16 ГБ превышается ещё до того, как обучение добирается до первого прямого прохода. Поэтому запускать 3 B‑модель на MPS мы даже не пытались.
Apple MLX обходит эту проблему за счёт 4-битного квантования:
weights_4bit ≈ 3,0B параметров × 0,5 байта/параметр ≈ 1,5 ГБ
Упомянутые выше 1,8 ГБ — это реальный размер квантованного чекпойнта с учётом коэффициентов квантования и смещений нулевой точки. Примерно 1,5 ГБ в расчёте выше — только теоретический объём самих 4-битных весов.
В любом случае запуск qwen-3b-mlx-2k достиг пикового потребления всего 5 494 МБ, уверенно уложившись в лимит 16 ГБ.
Потребление памяти MLX почти не менялось и при увеличении контекста:
MLX 1,5B @ контекст 1k: 4342 МБ пик
MLX 1,5B @ контекст 2k: 4396 МБ пик (+54 МБ)
MLX 1,5B @ контекст 4k: 4451 МБ пик (+55 МБ)
Увеличение контекста в четыре раза, с 1 024 до 4 096 токенов, потребовало всего 109 МБ дополнительной памяти.
Но важно точно понимать, за счёт чего получается такая экономия: дело в квантовании, а не в том, что MLX в целом экономнее расходует память.
В тех же двух неквантованных запусках MLX при одинаковых размере модели и контексте потреблял больше памяти, чем MPS в FP16:
|
Модель |
MPS FP16 |
MLX 16-бит |
MLX потребляет |
|---|---|---|---|
|
0,5 B |
1 061 МБ |
4 532 МБ |
в 4,3 раза больше |
|
1,5 B |
3 180 МБ |
7 117 МБ |
в 2,2 раза больше |
Если сравнивать фреймворки при одинаковой точности, MLX оказывается заметно менее эффективным по памяти, чем PyTorch MPS — то есть картина прямо противоположна той, что может сложиться из сказанного выше.
Утверждение «MLX позволяет уместить модель вдвое крупнее в те же 16 ГБ» действительно подтверждается нашими данными, но обеспечивает это именно 4-битное квантование, а не какое‑то общее преимущество MLX по эффективности использования памяти, на которое можно рассчитывать и в других сценариях.

Размеры сегментов с базовыми весами и LoRA‑адаптером рассчитаны по числу параметров, а не измерены отдельно. Непосредственно измерялось только пиковое использование VRAM для столбцов целиком, а также для запуска MLX на модели 3 B. Оставшийся сегмент показан общей суммой: отдельно расход памяти на состояние оптимизатора, активации и KV‑кэш не измерялся.
3. Сходимость: 4-битное квантование не ухудшает качество обучения
Один из частых вопросов к QLoRA — не мешают ли 4-битные веса модели осваивать строгий синтаксис, в нашем случае JSON‑вызовы инструментов.
По значениям функции потерь на валидации такого ухудшения не видно:
-
При 1,5 B / контексте 2 k MPS достиг 0,4623, а MLX — 0,4780.
-
При 3 B / контексте 2 k MLX показал лучший результат на валидации среди запусков с контекстом 2 k — 0,4520. (Минимальная функция потерь на валидации во всей таблице на самом деле у
qwen-1.5b-mlx-1k— 0,3310, но это запуск с контекстом 1 k, где модель предсказывает более короткие ответы, поэтому напрямую сравнивать его с запусками на 2 k/4k некорректно.) -
На MPS точность по токенам в ответах ассистента всё время оставалась высокой: 85,9% на 0,5 B, 88,6% на 1,5 B/2k и 86,4% на 1,5 B/4k.
Здесь важно точно понимать, что именно такое сравнение позволяет утверждать, а что нет. MPS и MLX различаются не только квантованием: у них разный ранг LoRA (16 против 8), оптимизатор (AdamW против Adam) и схема изменения learning rate (косинусная против постоянной) — подробнее об этом было выше, в разделе с настройками. Поэтому это не чистый эксперимент «квантование при прочих равных».
Разбираться в подобных экспериментах проще, когда уверенно понимаешь основы машинного обучения. Если хотите оценить свой уровень и найти пробелы в знаниях, пройдите тест по машинному обучению.
Корректнее сказать так: оба бэкенда со своими настройками по умолчанию приходят к близким значениям функции потерь на валидации.
Практический вывод от этого не меняется: ничто в этих результатах не указывает на то, что 4-битная QLoRA незаметно портит сходимость. Но близость результатов может быть следствием взаимной компенсации нескольких различий, а не доказательством того, что само квантование вообще ни на что не влияет.
Гораздо чище сравнение внутри MLX: ранг, оптимизатор и схема learning rate там одинаковы, отличается только квантование. Но и здесь картина неоднозначная. На модели 0,5 B отказ от квантования, как и ожидалось, улучшает результат: функция потерь на валидации снижается с 0,7560 для 4-битного варианта до 0,6170 для 16-битного.
На 1,5 B всё наоборот: без квантования функция потерь на валидации ухудшается — с 0,4780 для 4-битного варианта до 0,6280 для 16-битного. При этом в 16-битном запуске функция потерь при обучении начиналась с более низкого значения — 1,439 против 1,951, что вполне ожидаемо для исходных весов без потерь от квантования, — но к концу оказалась выше: 0,686 против 0,394.
У нас нет уверенного объяснения, почему на 1,5 B результат оказался обратным. Сначала мы проверили самое очевидное: mlx_lm.lora по умолчанию фиксирует seed для np.random и mx.random равным 0, если явно не задать другое значение.
Ни в одном из этих запусков seed не переопределялся, поэтому порядок батчей и инициализация LoRA были одинаковыми для 4- и 16-битного вариантов. Значит, дело не в случайном перемешивании данных.
Лучшее оставшееся предположение — шум от 4-битного квантования работает как слабая регуляризация в обучении, которое длится заметно меньше одной эпохи.
Но это лишь гипотеза, которую мы не проверяли, а не установленный результат. Поэтому оставим это открытым вопросом, вместо того чтобы подбирать объяснение, которое звучит аккуратнее, чем позволяют данные.
Практические выводы: что сломалось и как мы это починили
По ходу работы всплыло несколько недокументированных особенностей Apple Silicon и macOS.
1. Задавайте оба порога MPS до импорта torch
У аллокатора MPS в PyTorch есть два ограничения на использование объединённой памяти.
Оба задаются как доля от общего объёма объединённой памяти компьютера:
-
PYTORCH_MPS_HIGH_WATERMARK_RATIO— жёсткий верхний порог, по умолчанию1.7, и -
PYTORCH_MPS_LOW_WATERMARK_RATIO— мягкий нижний порог, при достижении которого раньше начинается вытеснение данных из кэша, по умолчанию1.4.
Они нужны, чтобы MPS не забирал столько памяти, что остальная система начинает активно уходить в подкачку.
В обычной ситуации это разумно, но когда вы сознательно работаете почти у самого потолка памяти, пытаясь уместить модель на 3 B параметров, эти ограничения начинают мешать.
Мы отключаем оба:
import os
os.environ["PYTORCH_MPS_HIGH_WATERMARK_RATIO"] = "0.0"
os.environ["PYTORCH_MPS_LOW_WATERMARK_RATIO"] = "0.0"
import torch
Здесь важны два момента.
-
Во‑первых, отключать нужно оба порога. Если обнулить только верхний, а нижний оставить со значением по умолчанию, PyTorch всё равно начнёт вытеснять кэш раньше, чем вам хотелось бы.
-
Во‑вторых, переменные нужно задать до
import torch. Аллокатор MPS считывает эти значения один раз при инициализации, поэтому менять их после импорта уже бесполезно. Вtrain_sft.pyэто сделано черезos.environ.setdefault(...)в самом начале файла, до любого импортаtorch, поэтому настройка сработает, если значение заранее не было экспортировано в shell.
Но есть важная оговорка: если обнулить верхний порог, PyTorch больше не будет сам ограничивать потребление памяти MPS. С этого момента следить за реальной нагрузкой на память придётся вам.
Ниже, в конце статьи, покажем, как контролировать использование подкачки.
2. Порядок callback’ов Trainer ломает пользовательские метрики Trackio
Мы написали callback, который должен был записывать в Trackio использование памяти Metal (torch.mps.current_allocated_memory()) вместе с обычными метриками обучения. Но эта метрика в Trackio так и не появилась.
Причина оказалась в том, как Trainer формирует список callback’ов. Внутри он собирается как default_callbacks + callbacks, причём в default_callbacks уже входит TrackioCallback, если указано report_to="trackio". Callback’и вызываются именно в таком порядке.
В результате сначала срабатывает TrackioCallback.on_log и записывает строку текущего шага в SQLite, а ваш собственный on_log вызывается только после этого и уже не успевает добавить значения в словарь logs.
Исправление простое: после создания trainer нужно переставить callback так, чтобы он выполнялся первым:
trainer = SFTTrainer(...)
if mps_callback is not None:
trainer.callback_handler.callbacks.remove(mps_callback)
trainer.callback_handler.callbacks.insert(0, mps_callback)
3. Никакого закрепления памяти и multiprocessing в DataLoader
Обычная рекомендация для PyTorch — использовать pin_memory=True и num_workers=4. На Apple Silicon оба варианта скорее вредят.
-
pin_memory=Trueвыделяет закреплённую в физической памяти память хоста в стиле CUDA. В системе с объединённой памятью CPU и GPU и так используют одну и ту же физическую DRAM, поэтому закрепление памяти только расходует её впустую и не даёт выигрыша. Используйтеdataloader_pin_memory=False. -
При
num_workers > 0на macOS Python использует метод запуска процессовspawn, а неfork, поэтому каждый worker при старте дублирует таблицы памяти родительского процесса. Лучше оставитьdataloader_num_workers=0.
Когда какой фреймворк использовать
|
Критерий |
PyTorch MPS |
Apple MLX |
|---|---|---|
|
Размер модели |
≤1,5 B параметров |
3 B–7B+ параметров |
|
Длина контекста |
≤2048 токенов |
4096–8192+ токенов |
|
Приоритет |
Чистая производительность (в 2,2–5,7 раза быстрее) |
Максимальный размер модели в пределах 16 ГБ |
|
Где планируется запуск |
Перенос в облако (HF Jobs, CUDA‑кластеры) |
Локальный сервинг через |
|
Инструменты |
Полный стек Hugging Face (TRL, PEFT, Accelerate) |
Нативный для Metal стек Apple |
-
Если модель помещается в FP16 — например, Qwen 0,5 B или 1,5 B с контекстом 2 k, — используйте PyTorch MPS. Он быстрее, сразу предоставляет метрики точности по токенам, а тот же скрипт можно без изменений перенести на NVIDIA GPU.
-
Если на ноутбуке с 16 ГБ памяти нужна модель от 3 B параметров или контекст от 4 k токенов, выбирайте Apple MLX. Нативная 4-битная QLoRA снимает ограничение по памяти, из‑за которого PyTorch на том же железе завершился бы с ошибкой, но ценой более медленных итераций.
Ресурсы
-
Код экспериментов и обвязка для их запуска: tutorials/01-sft‑on‑traces/
-
Датасет:
badlogicgames/pi-mono -
Дашборд телеметрии: опубликованный HF Space (все 10 запусков из таблицы выше, с актуальными данными) или локальный интерфейс Trackio (
uv run trackio show --project training-agents-sft) -
Документация TRL: huggingface.co/docs/trl
-
Apple MLX: github.com/ml‑explore/mlx‑lm
Обученные адаптеры
LoRA‑адаптер каждого из десяти запусков выше опубликован на Hub. Не потому, что мы считаем их готовыми для продакшена, а чтобы приведённые в статье цифры можно было проверить по реальным артефактам, а не принимать на веру:
|
Название запуска |
Модель |
Бэкенд |
Точность |
Контекст |
Адаптер |
|---|---|---|---|---|---|
|
|
Qwen 0.5 B |
PyTorch MPS |
FP16 |
2 048 |
|
|
|
Qwen 0.5 B |
Apple MLX |
4-бит |
2 048 |
|
|
|
Qwen 0.5 B |
Apple MLX |
16-бит |
2 048 |
|
|
|
Qwen 1.5 B |
PyTorch MPS |
FP16 |
2 048 |
|
|
|
Qwen 1.5 B |
Apple MLX |
4-бит |
2 048 |
|
|
|
Qwen 1.5 B |
Apple MLX |
16-бит |
2 048 |
|
|
|
Qwen 1.5 B |
PyTorch MPS |
FP16 |
4 096 |
|
|
|
Qwen 1.5 B |
Apple MLX |
4-бит |
1 024 |
|
|
|
Qwen 1.5 B |
Apple MLX |
4-бит |
4 096 |
|
|
|
Qwen 3 B |
Apple MLX |
4-бит |
2 048 |
Советы: следите за подкачкой и не давайте macOS усыплять процесс
Есть два механизма ОС, которые могут незаметно испортить весь запуск:
-
App Nap: macOS ограничивает фоновые процессы и снижает частоту GPU, когда окно терминала теряет фокус. Для долгих запусков используйте
caffeinate:caffeinate -dimsu uv run python tutorials/01-sft-on-traces/run_experiments.py suite --name all -
Подкачка: держите открытым второй терминал с запущенной командой
sysctl vm.swapusage.-
Если macOS начнёт выгружать буферы объединённой памяти Metal на SSD, производительность резко обвалится: узким местом станет уже пропускная способность SSD, а не GPU.
-
Если во время обучения подкачка активно используется и продолжает расти, не ждите, что ситуация исправится сама: остановите запуск и уменьшите
per_device_train_batch_sizeилиmax_length.
-

После дообучения LLM‑агента важно понимать, как он ведёт себя на реальных задачах, отслеживать его работу и оценивать качество ответов.
Продолжить тему можно на бесплатном вебинаре:
-
27 октября в 18:00 — «Langfuse в AgentOps: наблюдаемость и оценка LLM‑агентов». Записаться
Полный список всех бесплатных вебинаров по машинному обучению, ИИ, разработке и не только уже доступен в календаре мероприятий.
Автор: kmoseenk


