Почему побайтовое совпадение — плохой способ проверять интеграцию с MLX
Разбор случая из mlx-teacache и та практика проверки корректности, что пришла на смену
сравнению вывода побитово
Казалось, что надёжнее всего тестировать mlx-teacache сравнением вывода побайтово с эталонными
файлами. На деле вышло наоборот. За день до первого релиза библиотеки в мае 2026 года её обёртка при
нулевом пороге разошлась с сохранённым выводом базового (vanilla)
mflux на всех 5 из 5 промптов FLUX.1-dev. На FLUX.1-schnell
тот же тест прошёл — тоже 5 из 5. И это при том, что внутри одного процесса Python обёртка и базовый
прогон совпадали на всех 25 шагах денойзинга.
Такой результат говорил, что с математикой обёртки всё в порядке, а вот проверка никуда не годится. Сохранённый эталон зафиксировал не только сам вывод, но и сценарий, которым его получили. Дальше разберём, что именно сломалось, и разведём четыре разные задачи: обнаружение дрейфа артефакта, проверку того, что обёртка ничего не меняет (no-op), проверку численной эквивалентности и оценку намеренно приближённого вывода. Начиная с v0.1.0 под каждую задачу есть свой тест. С той же проблемой столкнётся любая интеграция, которая вмешивается в численный путь чужого фреймворка на ленивом рантайме с диспетчеризацией на GPU.
Источники и оговорки
- Измерения расхождения за май 2026 года — результаты, о которых сообщает автор, от 14 и 15 мая. Сырые логи не опубликованы, и ничего специально для этой статьи не перезапускалось.
- Утверждения о нынешнем состоянии тестов сверены с публичным репозиторием на версии v0.9.2.
- Инцидент воспроизводился на M1 Max с 32 ГБ единой памяти: mflux 0.17.5, FLUX.1 в 4-битном квантовании, вывод 512×512, 25 шагов, фиксированные промпты и сиды.
- Ссылки на исходники MLX закреплены за коммитом, который изучался в ходе разбора:
046217b.
1. Проверка, которая провалилась
TeaCache (arXiv:2411.19108) пропускает шаги диффузионного
трансформера, когда откалиброванный полином предсказывает, что выход основного блока почти не
изменится. При rel_l1_thresh=0 порог отключён: каждый шаг считается целиком, и обёртка не должна
добавлять ничего, кроме служебного учёта. В документации релиза это называется намеренным эталонным
режимом, полностью эквивалентным базовому. Предрелизный тест взял его за точку отсчёта корректности:
он генерировал итоговые латенты базовым mflux и сохранял их как эталоны .safetensors, а затем CI
сверял их с выводом обёртки при нулевом пороге через mx.array_equal.
Пока эту проверку пытались провести, всплыли три несвязанных бага интеграции. В mflux 0.17 сменилось поле, по которому определяется вариант модели. Реестр колбэков отдавал методы там, где обёртка ждала списки. А слой нормализации получал недопустимый именованный аргумент. Все три починили до релиза, так что на этапе отладки точное сравнение пригодилось.
А вот в роли постоянной проверки оно себя не оправдало. После того как настоящие баги были исправлены, расхождение на варианте dev осталось. По пяти промптам косинусное сходство между итоговым латентом обёртки и сохранённым эталоном лежало в пределах 0.105–0.256, а максимальная абсолютная разница — в пределах 5.31–5.82 на латентах fp32. Порог, достаточно свободный, чтобы принять разницу около 5.8, уже не ловил бы вообще ничего осмысленного.
Эталоны schnell при этом по-прежнему совпадали байт в байт. Из-за этой несогласованности тест становился только опаснее: он мог проходить достаточно долго, чтобы попасть в релиз, а затем упасть после совершенно постороннего изменения.
2. Локализация расхождения: эталон запомнил сценарий
В ходе разбора сравнивались хеши SHA-256 итоговых латентов между конфигурациями, которые отличались ровно одной переменной. Вот ключевые строки, о которых сообщает автор, от 14 мая 2026 года:
| Конфигурация (та же модель, промпт, сид, настройки) | Хеш итогового латента |
|---|---|
| Исходный сценарий генерации эталонов (базовый mflux, один колбэк после цикла) | 45471c34… (эталон) |
| Тот же сценарий, перезапущенный днями позже | 45471c34… (воспроизводимо) |
Обёртка при rel_l1_thresh=0 |
0db096b2… |
| Обёртка: принудительные пропуски против их отсутствия при пороге 0 | оба 0db096b2… |
| Базовый mflux, без обёртки, но класс колбэка дополнительно определяет метод внутри цикла | 0db096b2… |
| Базовый против обёртки, один процесс, пошаговое сравнение, все 25 шагов | байт в байт |
Важнее всего пятая строка. Базовый mflux — вообще без кода TeaCache на модели — выдал байты обёртки, стоило лишь изменить форму класса у зарегистрированного объекта-колбэка. Отсюда видно, что хеш зависел от структуры процесса на уровне Python даже без всякой математики обёртки. При этом непонятно, какая именно подсистема MLX превратила это структурное изменение в другие байты. Сохранённый эталон честно записал одну конфигурацию процесса, а изменённые конфигурации в тестах — другую.
Внутри одного процесса результат менялся. Mflux вычисляет латенты один раз на каждом шаге денойзинга
(mx.eval(latents) в цикле генерации, mflux 0.18.0),
и диагностика показала точное совпадение обёртки с базовым прогоном на каждой такой границе. Здесь
сравнения внутри одного процесса хватило, чтобы взять под контроль достаточную часть окружения и
сделать побайтовое равенство осмысленным.
3. Что MLX документирует, а что нет
В документации MLX нигде не сказано, что MLX недетерминирован, — и эта статья такого не утверждает. Официальные материалы описывают несколько важных для нас механизмов:
- Ленивые, динамически строящиеся графы. «Когда вы выполняете операции в MLX, никаких вычислений
на самом деле не происходит — вместо этого записывается граф вычислений», и выполняется он только
при
eval()(документация по ленивым вычислениям). Графы неиспользуемых выходов всё равно строятся. - Вычисление, идущее от выходов.
eval_implпривязывает синхронизатор к запрошенным выходам и обходит их входы: сначала сбор в глубину, затем выполнение по ленте с ограничением по ширине (mlx/transforms.cppна закреплённом коммите). - Передача буферов (buffer donation), завязанная на счётчик ссылок. Массив MLX «по сути и есть
узел графа»
(
mlx/array.h), аis_donatable()разрешает переиспользовать буфер только тогда, когда и дескриптор, и данные принадлежат единственному владельцу. Поэтому любая другая живая ссылка способна помешать операции переиспользовать входной буфер. - Выбор вычислительного ядра (kernel) в зависимости от формы и окружения. Например, Metal-бэкенд
matmul ветвится по эвристикам формы и по переключателям окружения — таким как включение TF32
(
mlx/backend/metal/matmul.cpp). - Ключи PRNG задают случайные выборки, но не редукции. Документация по генератору случайных чисел описывает детерминированную выборку по ключу, но ничего не обещает о порядке редукции внутри ядра.
Аналога torch.use_deterministic_algorithms()
в MLX нет. В PyTorch такой переключатель есть, но и там документация оговаривает, что воспроизводимость
не гарантируется между релизами, коммитами и платформами, и что одинаковые сиды не дают одинакового
результата на CPU и GPU.
Всё это — правдоподобные пути к различиям на уровне байтов, а не доказательство первопричины конкретного инцидента. Сложение с плавающей точкой не ассоциативно, поэтому другой порядок работы ядра или редукции способен изменить младшие биты. Форма графа и счётчики ссылок влияют на планирование, переиспользование буферов и условия диспетчеризации, не трогая при этом саму математику. Майские эксперименты не выяснили, какой из этих путей (и был ли вообще хоть один) дал сдвиг хеша. Они установили результат поуже: изменение класса колбэка коррелировало с другими итоговыми байтами, тогда как парные пути внутри одного процесса совпадали. А стоит численному различию появиться — каждый следующий шаг денойзинга усиливает его через нелинейный трансформер.
Работа Thinking Machines о батч-инвариантности задокументировала такую же цепочку на серверных GPU: стратегии редукции в ядрах менялись вместе с размером батча, и неконтролируемый размер батча превращал 1000 прогонов при нулевой температуре в 80 разных завершений. Случай с MLX — это лишь аналогия, а не доказательство того же механизма: структура сценария коррелировала с другими байтами, а сохранённый эталон исходил из того, что эта зависимость останется стабильной. Для проверки корректности такое допущение небезопасно.
У этого объяснения два ограничения. Во-первых, стабильность внутри процесса, на которую опирается новый тест, — это эмпирическая закономерность, а не задокументированная гарантия. Нынешний набор тестов подтверждает её заново при каждом запуске ручной проверки на совпадение, но сам MLX её нигде не обещает.
Во-вторых, так и не удалось изолировать конкретный механизм, которым присутствие обёртки давало другие
байты. Главная гипотеза указывала на кэш-тензоры, которые ранняя обёртка при нулевом пороге всё ещё
строила (cached_residual = body_out − body_in). Эти тензоры удерживали живыми промежуточные значения
основного блока и хвоста, а это могло менять право на передачу буфера на их границе. Гипотеза была
достаточно конкретной, чтобы её проверить делом: в проект добавили быстрый путь при нулевом пороге,
который таких тензоров не строит вовсе. Результат её опроверг — как основную причину: расхождение с
сохранёнными эталонами не сократилось ни на йоту. Быстрый путь тем не менее оставили: он убирает лишнюю
работу.
Нынешний _kernel/gate.py
по-прежнему делает короткое замыкание при rel_l1_thresh <= 0, но исходное причинное объяснение свою
проверку не пережило. Передача буферов остаётся реальным, задокументированным механизмом
диспетчеризации; рухнуло лишь частное утверждение, что именно эти тензоры были здесь главным рычагом.
Поэтому в заметке оно и помечено как кандидат, провалившийся в первом же различающем эксперименте, а не
как объяснение.
4. Одна проверка — четыре разных вопроса
Переработка в v0.1.0 началась с того, что четыре вопроса, которые прежде сливались в одной проверке по эталону, развели по отдельности. Каждому нужен свой критерий проверки (в терминах тестирования — свой тестовый оракул). Само изменение зафиксировано в публичном CHANGELOG.
Изменился ли сохранённый артефакт?
Это обнаружение дрейфа, а не проверка корректности. Эталоны никуда не делись, но их понизили в роли —
до отпечатков:
tests/test_fixtures_integrity.py
сверяет SHA-256 файлов на диске, а
tests/test_hf_revisions.py
закрепляет ревизии моделей на Hugging Face, из которых они получены. Провал здесь означает «что-то
сдвинулось», но никогда — «обёртка неверна».
Остаётся ли обёртка математически нейтральной (no-op), когда так настроена?
Именно этот вопрос и пытались задать сравнением байтов, и у протестированного пути FLUX.1 внутри
одного процесса есть точный побайтовый ответ. В поставке за это отвечает парная проверка совпадения в
одном процессе
(tests/test_parity_flux1.py):
снять базовый прогон, применить обёртку при нулевом пороге, затем восстановить состояние и снова снять
базовый прогон. Тест проверяет mx.array_equal(vanilla_before, wrapper),
mx.array_equal(vanilla_before, vanilla_after) и ноль зафиксированных пропусков. Контроль
vanilla_after ловит утечки при восстановлении — оставшийся колбэк, прокси или маркер.
Вариант с обратным порядком (сначала обёртка, потом базовый прогон) проверяет, не «прогревает» ли запуск базового прогона первым процесс в удобное для совпадения состояние. Каждый парный случай занимает примерно 3× одной генерации. Один промпт назначен быстрым случаем проверки, а прогон по пяти промптам помечен как медленный. Настоящие веса требуют доступа по разрешению и учётных данных, поэтому репозиторий гоняет эту проверку вручную — как валидацию релизного качества, а не на каждый pull request.
Численно ли эквивалентен изменённый путь выполнения?
Каждый вариант так или иначе меняет путь выполнения, а останется ли при этом стабильным побайтовое равенство — вопрос эмпирический, поэтому ответ у каждого варианта свой.
FLUX.1 подставляет прокси-трансформер: тот заново обходит прямой проход и вставляет пропуск между
основным блоком и хвостом. Внутри одного процесса этот повторный обход измерялся как побитово точный
относительно базового прогона — значит, для FLUX.1 можно оставить критерий mx.array_equal выше.
FLUX.2 и Z-Image заменяют функцию predict у mflux eager-замыканием, чтобы пропуск можно было вставить между основным блоком и хвостом. На большинстве железа mflux компилирует базовую функцию predict, но на базовых и Pro-чипах M1/M2 компиляцию пропускает. Результат для FLUX.2, о котором сообщает источник, получен на M1 Max, где компиляции нет, — так что различие «компилируемый против eager» это измерение объяснить не может. Общим для обоих вариантов железа остаётся только изменённый обход прямого прохода.
Qwen-Image компиляцию не использует. Mflux вызывает свой трансформер напрямую дважды за шаг, а
интеграция ставит eager-прокси, который заново обходит QwenTransformer.__call__ по стадиям. Его тест
при нулевом пороге уже не побайтовый: отдельная самопроверка на первом шаге меряет косинусное сходство
≥ 0.999 между повторным обходом и незавёрнутым трансформером.
Итоговые критерии отражают эти данные. У FLUX.2 обнаружилось пошаговое расхождение на уровне ULP и
косинусное сходство около 0.99 по всей генерации, поэтому его порог — косинус ≥ 0.97
(tests/test_parity_flux2.py).
У Qwen-Image и Z-Image при нулевом пороге стоит косинус ≥ 0.99. Их опубликованное основание — проверка
достоверности порта на первом шаге ≥ 0.999, а не многопромптовое распределение по всей генерации
(Qwen-Image,
Z-Image).
Контроль восстановления vanilla_before / vanilla_after остаётся побитово точным во всех этих
вариантах: оба базовых прогона идут по одному и тому же нетронутому пути mflux в одном процессе. Так
каждое сравнение получает сильнейший критерий, какой позволяет его граница.
Приемлем ли намеренно приближённый вывод?
При положительном пороге TeaCache обязан менять вывод, поэтому тест проверяет уже не равенство латентов, а перцептивное качество и поведение. Он меряет SSIM на изображениях, декодированных VAE, относительно базовой линии базового прогона в том же процессе. И требует от 5 до 7 пропусков для эталонного прогона FLUX.1-dev с порогом по умолчанию — чтобы бездействующий кэш не мог пройти тест, попросту ничего не делая. Этот режим отказа разобран в сопутствующей заметке о пороге на коротких дистиллированных расписаниях.
5. Как калибровать допуски
Обоснованный допуск нужно измерять, а не выторговывать в бо́льшую сторону после провала. Вот процедура, которую проект стремится применять к каждой интеграции с MLX:
- Проведите парное сравнение и запишите распределение, а не «прошёл/не прошёл». Фиксируйте max-abs, mean-abs, max-relative (со знаменателем с эпсилон) и косинусное сходство по нескольким промптам на целевом железе. Для моделей изображений добавьте SSIM после декодирования.
- Убедитесь, что зазор существует. Допуск становится осмысленной проверкой только тогда, когда измеренный шум и наименьший баг, который стоит ловить, лежат по разные стороны от него. Майский инцидент — как раз отрицательный пример: чтобы пройти сравнение с эталоном, понадобился бы абсолютный допуск выше 5.8. Такой допуск уже не отделял бы численный дрейф от множества значимых сбоев — значит, правильно было менять само сравнение, а не константу.
- Ставьте порог чуть ниже измеренной нижней границы, оставляя запас только под известные источники разброса. Косинусный порог FLUX.2 — 0.97 против измеренных ~0.99 по всей генерации. А Qwen-Image и Z-Image показывают предел нынешних данных: их пороги 0.99 держатся на проверке порта на первом шаге ≥ 0.999, а не на опубликованном многопромптовом распределении по всей генерации. Такие пороги — полезная защита от регрессий, но их стоит считать предварительными и ужесточить или пересмотреть, когда это распределение будет измерено.
- Храните основание рядом с константой. В комментарии к порогу должно быть указано, что измерено, когда и на каком железе, — чтобы следующий человек отличил «откалибровано» от «предварительно».
Альтернатива — ослаблять падающую проверку, пока она не позеленеет. Так каждое вновь принятое различие молча записывается в шум, обычно без всякой проверки того, способен ли тест ещё ловить те сбои, ради которых он вообще писался.
6. Как корректность проверяют другие проекты кэширования
Проект изучил, как другие слои кэширования диффузии подтверждают корректность (обзор мая 2026 года, перепроверенный по публичным репозиториям при подготовке статьи):
- ali-vilab/TeaCache,
эталонная реализация, патчит
FluxTransformer2DModel.forwardна уровне класса. Тестов на совпадение она не поставляет — проверяют глазами, сравнивая картинки бок о бок. - ComfyUI-TeaCache встраивается через узлы-патчи ComfyUI; обзор мая 2026 года не нашёл у неё набора корректностных тестов в стиле pytest.
- Hugging Face Diffusers проверяет строже и автоматически. Его
FirstBlockCacheTesterMixinпрогоняет четырёхшаговый макетный пайплайн на CPU:np.allcloseсо значением по умолчаниюatol=0.1при включённом кэшировании иatol=1e-4после его отключения. Сравнение мягкое, идёт в одном процессе и не завязано на закоммиченный вывод реальной модели (документация по API кэширования).
Ни один из рассмотренных проектов не проверяет корректность побайтовым сравнением с закоммиченным
выводом реальных моделей. Заметки о воспроизводимости PyTorch прямо снимают гарантии побитовой
стабильности между релизами и платформами. Работа над батч-инвариантным выводом относится к побитовой
стабильности как к инженерной цели, ради которой пишут специальные ядра, а не как к свойству по
умолчанию. На MLX mlx-deterministic даёт
батч-инвариантные ядра RMSNorm, matmul, attention и softmax; его README сообщает измеренные накладные
расходы примерно 7% для RMSNorm и 27–32% для matmul. Область у него — инвариантность к размеру
батча для инференса LLM, а не чувствительность к структуре сценария, с которой мы столкнулись здесь. Но
он показывает главное: побитовый детерминизм на Metal достижим, если проект готов за него платить. Для
производительной библиотеки вроде mlx-teacache такую цену трудно оправдать лишь ради того, чтобы
сохранить одну удобную проверку.
7. Утверждения и их статус
| Утверждение | Статус | Источник |
|---|---|---|
| Обёртка при нулевом пороге против сохранённого эталона: 5/5 провалов на dev, косинус 0.105–0.256, max_abs 5.31–5.82; 5/5 успехов на schnell | измерение, о котором сообщает автор, май 2026; сырые логи не опубликованы | метод и ограничения сведены в разделах 1 и 2 |
| Обёртка против базового прогона в одном процессе, пошагово, 25 шагов: байт в байт | диагностика, о которой сообщает автор, май 2026; сырые логи не опубликованы | метод сведён в разделе 2 |
| Нынешний парный тест FLUX.1 проверяет побайтово точные итоговые латенты и ноль пропусков | сверено с публичным тестом на v0.9.2 | tests/test_parity_flux1.py |
| Базовый mflux с изменённой формой колбэка выдаёт хеш обёртки, а не эталона | однопеременный эксперимент, о котором сообщает автор, май 2026; сырые логи не опубликованы | метод сведён в разделе 2 |
| Быстрый путь при нулевом пороге не уменьшил межпроцессное расхождение | измерение, о котором сообщает автор; опровергло гипотезу о передаче буферов как основной причине | результат сведён в разделе 3; быстрый путь публичен в _kernel/gate.py |
| Изменённый прямой путь FLUX.2: расхождение на уровне ULP и косинус около 0.99 по всей генерации при пороге 0 на M1 Max | измерение, о котором сообщает источник; поскольку на этом чипе mflux пропускает компиляцию, это не свидетельство причины «компилируемый против eager» | tests/test_parity_flux2.py |
| Порог Qwen-Image/Z-Image при нуле — косинус ≥ 0.99; отдельная проверка повторного обхода на первом шаге ≥ 0.999 | сверено как задокументированный публичный порог и проверка достоверности порта; не опубликованная многопромптовая граница по всей генерации | страницы вариантов |
| MLX документирует ленивые графы, вычисление от выходов, передачу буферов и эвристики диспетчеризации, но не недетерминизм; API детерминированного режима не найдено | сверено с документацией и закреплённым исходником при подготовке | ссылки в разделе 3 |
| Ни один из рассмотренных проектов кэширования не проверяет корректность по закоммиченным байтам реальных моделей | сверено с публичными репозиториями при подготовке | ссылки в разделе 6 |
| Первопричина межпроцессного различия байтов | не изолирована; гипотеза о передаче буферов провалила свой различающий эксперимент | эта заметка, раздел 3 |
8. Выводы
Если коротко: эталонный файл доказывает корректность лишь тогда, когда тест держит под контролем каждый источник изменения байтов. Здесь же файл зависел от сценария, которым его получили. Schnell совпал случайно, а dev — нет; но даже полностью зелёный результат мог сломаться после изменения колбэка или обновления MLX.
Разные задачи — разными тестами. Хеши ловят дрейф файлов. Парные прогоны проверяют математику обёртки. Если оба прогона идут по одному численному пути — сравнивайте байты. Если путь меняется — берите измеренный допуск. При включённом кэшировании смотрите на качество изображения и убеждайтесь, что пропуски действительно случились.
Заодно история с передачей буферов показывает, как обходиться с неполным объяснением. Оно предсказывало, что удаление части живых ссылок уменьшит различие байтов. Этого не произошло. Быстрый путь остался, потому что убирает лишнюю работу, но первопричина по-прежнему неизвестна — и вывод о тестировании от её поиска не зависит.
Ссылки и заметки об источниках
- Документация MLX и закреплённый исходник (коммит
046217b, осмотрен 2026-05-14, ссылки перепроверены при подготовке): ленивые вычисления, ключи случайных чисел,transforms.cpp(eval_impl),array.h(узел графа,is_donatable),backend/metal/matmul.cpp. - Публичная история
mlx-teacache: CHANGELOG (запись о пирамиде тестов v0.1.0 и последующие ревизии набора проверок совпадения),tests/test_parity_flux1.py,tests/test_parity_flux2.py,tests/test_fixtures_integrity.py,tests/test_hf_revisions.pyи документация по вариантам. - Граница пошагового вычисления в mflux:
flux.pyна v0.18.0. - TeaCache: arXiv:2411.19108; эталонная реализация ali-vilab/TeaCache; ComfyUI-TeaCache.
- Hugging Face Diffusers: API кэширования;
FirstBlockCacheTesterMixin. - Предшествующие работы по воспроизводимости:
заметки о случайности в PyTorch;
Thinking Machines, «Defeating Nondeterminism in LLM Inference» (сентябрь 2025);
mlx-deterministic(батч-инвариантные ядра для Metal).
Подготовлено 2026-07-24. Последнее обновление 2026-07-25. Денис Инешин. Перевод английского оригинала.
