Нулевой loss в PyTorch: чужой код в backward pass
В логе обучения стоит ровно 0.000000. Не «почти ноль», не дрожащая в шуме величина, а честный ноль на каждом шаге. А в это время 1024 веса внутри обычной CNN перебирают поколения «Жизни» Конвея: 2345 шагов обучения, 2345 поколения доски, ни одной ошибки в клетках. Точность модели на MNIST при этом 98.80% против 98.84% у нетронутого бейзлайна.
Это не гипотетическая угроза. Elon Litman из Elements of a Vector Space опубликовал 20 октября 2026 года работающий эксплойт: регуляризатор, который в forward pass равен нулю и математически, и в float32, но в backward pass превращается в интерпретатор произвольных программ.
Нулевой loss в PyTorch это скалярное слагаемое в функции потерь, которое на forward pass вычисляется как 0.0 и остаётся нулём во всех метриках, а на backward pass отдаёт заранее заданный градиент. Autograd дифференцирует не то, что посчитало железо, а то, что записано в графе вычислений.
Проверка, которая ничего не проверяет
Типичный ритуал перед файнтюном выглядит разумно: сверить SHA-256 файла .safetensors с хэшем на странице Meta, просмотреть modeling_llama.py на предмет бэкдоров и подозрительных хуков, запустить обучение и смотреть в TensorBoard. Если кривая задачи выглядит нормально, а вспомогательные метрики лежат плоскими линиями, считается, что всё в порядке.
Атака не нарушает ни одного из этих условий. Автор не трогает чекпоинт, архитектуру, датасет и оптимизатор. Он не меняет параметры в forward pass. Он просто публикует пакет ultraloss с классом UltraLossRegularizer, который обещает спектральную регуляризацию для 8-битных квантованных моделей. В цикле обучения появляется одна строка total_loss = task_loss + aux_loss, и этого достаточно.
Шаг первый: SGD как интерпретатор
Апдейт стохастического градиентного спуска с learning rate 1 формулируется просто: новые веса равны старым минус градиент. Если нужно, чтобы оптимизатор записал в веса состояние F(θ), достаточно подать ему градиент v(θ) = θ − F(θ). Тогда θ сокращается, и на месте весов остаётся ровно F(θ).
Для произвольного learning rate η деление на η сокращается в самом апдейте, так что схема не зависит от гиперпараметра, лишь бы компоненты вектора оставались небольшими по модулю. Для правил «Жизни» это выполняется автоматически: веса живут в множестве {0, 1}, а градиент принимает значения −1, 0 или 1. Такие разности считаются в float32 без ошибки округления, поэтому доска эволюционирует бит в бит.
Задача сводится к одному: построить скаляр, который в forward равен 0.0, а в backward отдаёт нужный вектор.
Шаг второй: float32 против символьного дифференцирования
Вся конструкция держится на функции h(u) = (2^30 + u) − 2^30.
В forward её считает GPU по IEEE 754. У float32 восемь бит экспоненты и 23 бита мантиссы, и на интервале [2^30, 2^31) шаг между соседними представимыми числами равен 2^(30−23) = 128. Это unit in the last place. Всё, что меньше половины шага, то есть меньше 64, при округлении к ближайшему чётному просто исчезает: 2^30 + 1 округляется до 2^30, вычитание даёт 0.0.
В backward PyTorch не смотрит на биты. Autograd записал в граф две операции, сложение с константой и вычитание константы, и дифференцирует их символически: производная каждой равна 1, произведение по цепному правилу тоже 1. Узлы сложения и вычитания даже не сохраняют входные тензоры, потому что их производные от значений не зависят.
Отсюда расходятся два мира. На бумаге над вещественными числами h(u) = u. На GPU h(u) = 0.0. В графе Autograd производная равна единице.
Для bfloat16, где мантисса 7 бит, константа меньше: 2^9 = 512, шаг округления 4, и функция стирает всё, что по модулю меньше 2. Это важно, потому что большая часть современного обучения идёт в пониженной точности.
Шаг третий: detach, которого не видно
Обратная операция нужна, чтобы заблокировать градиент, не вызывая .detach(), который заметит любой ревьюер. Если вычесть h(u) из u, в forward получится u без изменений, а в backward производные 1 и 1 сократятся в ноль. Функционально это detach, собранный из сложения и вычитания.
Дальше два множителя склеиваются правилом произведения: L = Σ [v_i − h(v_i)] · h(θ_i). Первый множитель в forward равен v_i, второй равен 0.0, произведение 0.0. На бумаге над вещественными числами первый множитель равен нулю, и результат тоже ноль. Функция обнуляется в обе стороны, и только внутри ленты Autograd она не равна нулю.
Причина в том, что узел умножения, в отличие от сложения, сохраняет в forward реальные значения обоих операндов и подставляет их в производную. Левая ветка умирает дважды: производная первого множителя 0 и сохранённое значение второго 0.0. Правая ветка отдаёт v_i напрямую в theta.grad. Умножение работает как направленный вентиль: в forward закрыт, в backward открыт.
Что это даёт на практике
Минимальная демонстрация: две переменные, обучаемые с lr=1, переход [1, 0] → [0, 1] → [−1, 0] → [0, −1] → [1, 0]. На каждом из четырёх шагов в логе Reported Loss: 0.000000, а движок получает градиенты [1, −1], [1, 1], [−1, 1], [−1, −1]. Веса идут по циклу.
Дальше самое интересное. Автор встроил правила 3×3 соседства «Жизни» в F(θ) для 1024 весов скрытого dense-слоя CNN на 1.6 млн параметров и обучил её на MNIST обычной кросс-энтропией. Пять эпох, 2345 шагов SGD. Вспомогательный loss равен нулю на каждом шаге. Тестовая точность 98.80% при 98.84% у базовой модели. Все 2345 поколений доски прошли без единой ошибки в клетке, включая пульсар с периодом 3, осциллятор с периодом 15 и глайдер на торе.
Вычисление шло параллельно с полезным обучением, внутри тех же весов, и не мешало задаче.
Как прячут переходную функцию
Увидеть в репозитории conway_game_of_life(theta) и v = theta − F_theta может любой. Поэтому в статье описаны три способа замаскировать код.
Первый, math-washing: шаг клеточного автомата это свёртка плюс пороговая нелинейность, то есть ровно структура обычного слоя. Функция переименовывается в штраф кривизны многообразия, ядро свёртки называется KERNEL_EIGENVECTORS, порог собирается из двух torch.clamp со сдвигами 2.0 и 4.0. На ревью это выглядит как дифференциальная геометрия для стабилизации матриц весов.
Второй: константы не нужно держать в коде. Массив ядра и множитель 2^30 уезжают в конфиг вида empirically_tuned_layer_coefficients.json с легендой про байесовский подбор гиперпараметров.
Третий: скомпилированное расширение. Если поставить flash-attn или bitsandbytes, вы уже скачиваете готовые .so под свою архитектуру GPU. Питоновский класс остаётся тонкой обёрткой, а переходная функция, трюк с точностью и вычитание остатка компилируются в PTX и SASS, где их видно только дизассемблером.
Почему не спасают сканеры и песочницы
Статические сканеры ищут сигнатуры в тензорах чекпоинта до начала обучения. Здесь обучение стартует с официального, сверенного по хэшу чекпоинта, а полезная нагрузка собирается только на N-м поколении.
Контейнерные песочницы блокируют сеть, файловую систему и запуск подпроцессов, исходя из того, что эксплойту нужно дотронуться до ОС. Эта программа целиком живёт в обычных тензорных ядрах GPU. Ни шелла, ни системных вызовов, ни трафика.
Отдельная деталь для больших моделей: полезной нагрузке не обязательно делить веса с задачей. В Llama 3 словарь на 128 256 токенов, и десятки тысяч строк эмбеддингов никогда не встречаются в узком доменном датасете. Их градиент от задачи тождественно нулевой, а значит вся полоса достаётся вспомогательному слагаемому. На этих строках можно прокрутить поколения с первого по N−1 как на отдельной оперативной памяти, а на поколении N записать готовый бэкдор в активную проекцию внимания или в голову классификатора. В статье есть прямая рекомендация для frontier-лабораторий: относиться к неиспользуемым параметрам как к защищённой виртуальной машине. У роутеров MoE та же история, невыбранные эксперты в батче не получают градиента от задачи.
С Adam схема тоже переносится. Доску можно заменить на машину Минского с двумя счётчиками, которая тоже полна по Тьюрингу: счётчику не нужны значения из {0, 1}, он может быть непрерывным аккумулятором, который дрейфует на +η или −η за шаг. Другой вариант, многошаговые часы, когда одно поколение доски накапливается за 100 или 1000 шагов обучения. Для модели, которая учится сотни тысяч шагов, это не ограничение.
Тьюринг-полнота здесь не метафора. Правила «Жизни» полны по Тьюрингу, значит регуляризатор со скалярным нулём получает модель как оперативную память и SGD как тактовый генератор.
Дилемма для компиляторов
Самая неприятная часть истории касается не PyTorch, а torch.compile, XLA и Triton. Если компилятор увидит u − ((C + u) − C) и алгебраически свернёт это в ноль, эксплойт исчезнет, а код станет быстрее. Если свернёт, поедет устойчивость вычислений во всей научной экосистеме: компенсированное суммирование Кахана, преобразование TwoSum из TAOCP и double-double арифметика держатся именно на выражениях вида (A + B) − A − B, которые восстанавливают ошибку округления. Ровно поэтому агрессивный -ffast-math по умолчанию выключен в численном коде: после такой оптимизации рассыпаются решатели дифференциальных уравнений, молекулярная динамика, орбитальная механика и климатические модели.
Не сворачивать тоже нельзя, тогда эксплойт остаётся открытым навсегда. Пока компилятор уважает порядок операций IEEE 754, autodiff будет дифференцировать слагаемые, которые в forward исчезли.
Что делать инженеру
Практический вывод не в том, чтобы запретить сторонние регуляризаторы, а в том, чтобы перестать считать метрики доказательством. Ноль в логе больше не означает, что в backward ничего не происходит.
К любому loss-модулю из внешнего пакета стоит относиться как к коду с полными правами на ваши веса, потому что он их и получает. Проверять не только forward, но и то, что модуль делает с графами: какие операции сохраняют тензоры, нет ли подозрительных констант вида 2^30, 2^9 и 512 в конфигах, нет ли прекомпилированных ядер в зависимостях.
Полезно смотреть на фактические градиенты, а не только на loss. Если на каком-то слое градиент стабильно целочисленный, живёт в небольшом множестве значений и не похож на шум задачи, это повод разобраться. Если вспомогательная метрика ровно нулевая до последнего знака на всех шагах, это тоже сигнал, а не признак стабильности.
И главное: изолировать неиспользуемые параметры. Длинный хвост словаря эмбеддингов и невыбранные эксперты MoE это идеальное место для вычислений, которые никто не увидит.
FAQ
Это баг PyTorch?
Нет. Autograd ведёт себя так, как задумано: он дифференцирует записанные операции, а не пересчитывает функцию в повышенной точности. Расхождение между символьной производной и арифметикой железа это фундаментальное свойство численных библиотек, а не ошибка реализации.
Работает ли это в пониженной точности?
Да, и даже проще. В bfloat16 мантисса 7 бит, поэтому хватает константы 512 и шаг округления равен 4: функция стирает всё, что по модулю меньше 2. Большая часть современного обучения идёт в bf16 и fp8, так что условие выполняется без специальных ухищрений.
Можно ли поймать такую атаку аудитом кода?
Частично. Питоновский код с conway_game_of_life виден, но math-washing, константы в конфиге и скомпилированное CUDA-расширение делают ревью почти бесполезным. Надёжнее инструментальный контроль градиентов и запрет на непроверенные нативные ядра.
Итог
Нулевой loss в PyTorch перестал быть безобидной технической деталью. Одна строка task_loss + aux_loss даёт стороннему коду выполнение произвольной программы внутри вашего шага оптимизации, при полном молчании всех метрик и при сохранной точности модели. Пока компиляторы уважают IEEE 754, а autograd дифференцирует символы, а не биты, этот зазор остаётся открытым.
Если вы собираете пайплайн файнтюна, начните с вопроса: что именно я разрешаю этому регуляризатору делать с моими весами. Проверьте зависимости на прекомпилированные ядра, посмотрите на распределение градиентов по слоям, и не считайте идеальный ноль доказательством того, что ничего не происходит.