Выборка выключена или seed зафиксирован, значит на тот же промпт придёт тот же ответ. Так я к этому относился весь год и ни разу не проверял. Когда локальная генерация не совпадала с тем, что я сохранил раньше, я говорил себе “выборка” и запускал ещё раз, не спрашивая, не поменялось ли окружение модели. А окружение у меня меняется раз в несколько недель, потому что я делаю pip install -U каждый раз, когда в новом релизе transformers есть нужная мне правка. Одного конкретного сломанного вывода, который заставил бы меня полезть внутрь, не было. Просто кончался год и мне хотелось понять, что сделал с выводом год таких обновлений.
Январские выводы я не сохранил, поэтому собрал январский venv заново из того, что было актуально тогда. Это torch 1.10.1 от 15 декабря 2021 года и transformers 4.15.0 от 22 декабря 2021 года. Декабрьский venv собран на transformers 4.25.1 и torch 1.13.1, вышедшем в прошлый четверг. Оба работают на Python 3.9, только на CPU, во float32, на 4 vCPU виртуальной машины с AMD EPYC 7713. Модель GPT-2 small на 124 млн параметров, веса во всех прогонах грузятся из одного и того же файла pytorch_model.bin.
Текст не сдвинулся
Я взял 8 промптов, от “The capital of France is” до “Q: What is 17 times 23?”, по 60 новых токенов на каждый. Каждый промпт я прогнал в трёх режимах: жадным декодированием в своём цикле с argmax, жадным через generate() и с выборкой (top_k 50, torch.manual_seed(1234) перед каждым промптом). Я ждал, что первой сломается выборка: она зависит от всего распределения, а не только от того, какой токен на первом месте.
Ничего не сломалось. Январь и декабрь дали одни и те же токены на всех 8 промптах во всех режимах, включая выборку. На арифметический вопрос GPT-2 отвечает ” 17 times 23.” и задаёт вопрос заново, по кругу, в обоих venv.
Логиты при этом поменялись. Повторный запуск в том же venv дал те же биты. Декабрь расходится с январём в 6 промптах из 8, на первом шаге не больше чем на 0,000107.
Тогда я стал менять по одному пакету. transformers с 4.15.0 на 4.25.1 при том же torch не поменял ни одного бита. Код внимания GPT-2 за это время в нём менялся, значение маски и деление на масштаб, но ни одна из этих правок здесь бит не сдвинула. torch 1.11.0 даёт ровно те же биты, что 1.10.1, а 1.12.1 ровно те же, что 1.13.1. Версию 1.12.0 я не пробовал, так что вся разница за год возникла где-то между 1.11.0 и 1.12.1.
Где ступенька
2 промпта сохранили свои биты даже после этой ступеньки и я не понимал почему. Сначала я потратил время на MKL. С MKL_VERBOSE=1 torch показывает вызовы SGEMM из MKL 2020.0, то есть умножение матриц MKL действительно считает. Но с MKL_ENABLE_INSTRUCTIONS=SSE4_2 не поменялось вообще ничего, ни одного бита ни в одном venv. Почему, я так и не знаю.
Потом я попробовал ATEN_CPU_CAPABILITY=default. С ним torch берёт свои универсальные ядра вместо ядер AVX2, как на старом процессоре. Логиты сдвинулись максимум на 0,000122 против обычного декабрьского прогона. Январь и декабрь стали побитно одинаковыми. Значит, изменение сидело в одном из ядер AVX2.
Я выгрузил выход каждого слоя на промпте с самым большим расхождением. Это арифметический вопрос, в нём 11 токенов. Эмбеддинги и первый LayerNorm совпадают. Первое расхождение, 0,0000019, появляется на выходе внимания в блоке 0 и к последнему блоку дорастает до 0,00032. Потом я прогнал каждую операцию отдельно на фиксированном случайном входе. Отличается только softmax.
У 2 промптов, сохранивших биты, 5 и 7 токенов. У всех остальных 8 и больше. Я прогнал softmax на строках всех длин от 1 до 64. Длины с 1 по 7 дают одни и те же биты в обеих версиях. Начиная с 8 у каждой длины есть расходящиеся строки, кроме 23. Думаю, это случайность.
8 чисел float это один регистр AVX2. Остальное видно в диффе aten/src/ATen/cpu/vec/functional_base.h между 2 тегами. Softmax суммирует экспоненты через reduce_all, а тот держит 8 частичных сумм в полосах регистра и в конце сводит их в одно число. В 1.11.0 эта свёртка идёт полоса за полосой: к полосе 0 прибавляется полоса 1, потом полоса 2 и так до полосы 7. В 1.12.1 для AVX2 добавили более быструю свёртку:
// 128-bit shuffle
Vec v1 = _mm256_permute2f128_ps(v, v, 0x1);
v = vec_fun(v, v1);
// 64-bit shuffle
v1 = _mm256_shuffle_ps(v, v, 0x4E);
v = vec_fun(v, v1);
// 32-bit shuffle
v1 = _mm256_shuffle_ps(v, v, 0xB1);
v = vec_fun(v, v1);
Она складывает полосы через 4, потом через 2, потом соседние. Числа те же, порядок сложения другой, а во float от этого может поменяться последний бит. Строки короче одного регистра до нового кода не доходят. В универсальных ядрах его нет вовсе. Нет его и в сборках компилятором MSVC: новая свёртка закрыта проверкой на GCC, так что на Windows этой ступеньки, скорее всего, нет.
Почему токены выжили
Сдвиг на 0,0001 важен, только если логиты первого и второго токена отличаются меньше чем на него. На 8 промптах про это ничего не скажешь, поэтому я взял 200 начал предложений из “Алисы в Стране чудес” и сгенерировал жадно по 100 токенов на каждое, 20 000 шагов на прогон.
Прогонов было 6, но наборов зазоров среди них только 3. Повторный декабрьский прогон и прогон на 1 потоке совпадают до бита. Оба прогона на универсальных ядрах тоже совпадают, а январь это третий набор. Все 200 продолжений оказались одинаковыми во всех 6 прогонах.
Наименьший зазор между первым и вторым токеном был 0,000366, на шаге 14 предложения, которое начинается с “So she set to work”. Он совпал до последнего бита во всех прогонах. Ни одно условие не сдвинуло ни один зазор больше чем на 0,000172, так что у самого узкого шага был примерно двойной запас.
Жадная GPT-2 быстро зацикливается и в цикле она очень уверена в себе. Медианный зазор на первых 10 шагах равен 0,70, а на последних 10 уже 6,27. У модели, которая сомневается чаще, шагов около нуля было бы больше. Тогда и у тех же 0,0001 было бы больше шансов перевернуть выбор токена.
1 поток вместо 4 тоже сдвинул логиты первого шага, не больше чем на 0,0000229 в обоих venv. Ни одного токена он не поменял. Почему, я не разбирался.
Чего я не проверял
- GPU и float16, где порядок сложения зависит от других ядер, которые я не читал.
- Батчинг на сервере, с которым имеют дело пользователи облачных API. У меня батч 1 и машина ничем больше не занята.
- Другой реальный процессор. У AVX-512 в том же файле своя свёртка. Старый процессор я только изобразил переменной окружения.
- Сам коммит. Я читал дифф между тегами v1.11.0 и v1.12.1, а не сам pull request.
- Модели побольше и выводы подлиннее. 20 000 шагов зацикленной модели на 124 млн параметров это не страница текста от модели покрупнее.
На этой машине я могу перестать списывать изменившийся вывод на выборку, по крайней мере с GPT-2. Но у самого узкого шага запас был всего двойной и за модель, которая зацикливается реже, я бы этого не обещал.