
Если вы ведете pet‑проекты, требующие большого количества вычислений, то наверняка сталкивались с проблемой их — вычислений — оркестрации. Содержать личный кластер позволить себе могут далеко не все, а каждая арендная платформа имеет свой специфический инструментарий интеграции и свои ограничения, а хуже всего, что ни одна из них не имеет гарантий полного выполнения. Вдобавок к этому, еще и не хотелось бы завязываться на одну конкретную площадку, как минимум потому, что некоторые провайдеры дают бесплатные возобновляемые квоты, и тратить их, вместо своих кровно заработанных, гораздо приятнее. В данной статье будет рассматриваться пример с арендными GPU, однако все основные тезисы полностью переносимы и на другие сценарии использования.
У вас, скорее всего, уже открыт десяток разнообразных вкладок: какая‑нибудь платформа с бесплатными недельными лимитами, вроде Kaggle; что‑нибудь с бесплатными возобновляемыми кредитами, на подобии Lightning AI; возможно, пара площадок со спотовыми машинами по цене чашки кофе, например Vast, но с оговоркой — никто не обещает, что машину не заберут, или у ее владельца не отвалится сеть.
Каждый вариант в отдельности выглядит как «ну, на пару экспериментов может быть и хватит» с различной долей допущения. Все вместе они выглядят как кластер. Нюанс в том, что кластером они становятся не сами: между «у меня есть пяток площадок» и «моя задача точно доедет до конца и не ляжет в непредсказуемый момент» находится довольно много инженерной работы, и почти вся она — не про площадку, а про грамотную оркестрацию.
Эту работу я выполнил для своего проекта и хочу поделиться тем, из чего она состояла. Многие решения оказались неочевидными, а добрая половина появилась лишь после того, как самый явный вариант сломался.
Итак, сегодня на повестке дня стоят следующие проблемы:
Дисклеймер. Статья довольно подробно разбирает не только конечный результат, но и путь к нему, попутно объясняя, почему отвалились те или иные решения, казавшиеся очевидными на первый взгляд. Также есть довольно много разъяснений, нацеленных на то, чтобы не оставлять недосказанности для инженеров, только начинающих свой путь. Для очень опытных senior’ов я постарался выделить шрифтом ключевые тезисы, дабы вы не заскучали в процессе разбора вещей, уже зашитых на подкорке. Однако, я бы рекомендовал, и был благодарен, за прочтение без пропусков. Текст ретроспективен, и каждая глава ссылается на предыдущую. Фидбэк и аргументированная критика всецело приветствуются!
Задача упала. Дальше что?
Самый наивный и первый пришедший в голову вариант — «Упало? Ну и ладно, идем к следующему!» — умирает практически сразу. Поскольку «упало» бывает очень разное:
-
Нет слотов прямо сейчас. Но кто знает, может быть будут через 10 минут…
-
Квота исчерпана полностью. До понедельника ничего не будет.
-
Игрушку отобрали прямо в процессе. Ну что же, это спот, так и задумано.
-
Кончились ресурсы машины. Не рассчитали, бывает.
-
Ошибка в самом коде. Ну все, приплыли…
Переход к следующему провайдеру уместен лишь в части из этих сценариев: в первом будет достаточно просто немного подождать; в третьем продолжить там же, ведь вас не «отвергли», просто оборвалась конкретно взятая сессия; а в пятом вообще нет смысла продолжать — упадет точно так же, но только вы за это еще и заплатите.
Разумеется, первое, что пришло в голову — постараться выделить явные классы ошибок, ведь реакция диспатчера на них будет отличаться. Мой список вышел таким:
-
Нет мест. Паркуем площадку и не тратим бюджет ретраев. Слот освободится чуть позже.
-
Провайдер недоступен. Пытаемся переотправить с прогрессивным интервалом, накапливая серию попыток.
-
Отказано. Этот класс оказался самым проблемным. Увы, не все площадки предоставляют достаточно информативные коды ошибок, которые позволили бы сделать однозначный вывод о причине падения. В общем случае пытаемся отправить повторно, расходуя лимит повторений.
-
Нарушен протокол. Площадка ответила чем‑то, чего не должно было происходить в принципе. Ради безопасности, считаем ошибку фатальной и не пытаемся повторить.
-
Различные кастомные ошибки внутри кода. Тут все зависит от ситуации, единого решения существовать не может.
Вроде очевидно? Мне тоже так показалось… А потом выяснилось, что для принятия решения этой классификации недостаточно. Точнее даже не так — она вообще не про то! Классификация отвечает на вопрос: «был ли это настоящий отказ» — и делает это корректно. Какое‑то время казалось, что отвечает и на другой: «подходит ли конкретный сервис в текущий момент для этой задачи». Но нет, не отвечает. Диспатчеру, по‑сути, абсолютно безразлично, был ли отказ или нет, ему нужно решить — уходить на следующую по приоритету ступень или пока остаться на текущей, и для этого требуются качественные, а не количественные метрики.
Именно этот вывод убивает предполагавшуюся ранее логику ретраев по количеству ошибок. Простой пример: машину отобрали или она упала через пять минут после старта. Отказ? Определенно. А если отобрали через шесть часов, когда половина работы была уже сделана? Тоже отказ? Да. Равновесный? Едва ли… Значит измерять нужно не количество отказов или таймаутов, а то, ради чего этот запуск вообще производился — качество работы площадки, а как следствие генерации артефактов.
Здесь стоит отметить то, что подразумевалось, но еще не объявлялось явно: обучение не должно идти одним единым непрерывным куском. Соответственно, процесс обязательно требуется нарезать на отдельные milestones (вехи). В качестве таких вех у меня были взяты интервалы из эпох, по результатам каждого из которых снимок текущего состояния модели писался в персистентное хранилище вне арендного железа. Просто, понятно, легко запомнить!
Попробуем развить мысль… Первый напрашивающийся вариант довольно очевиден и, на деле, закрывает большую часть вопросов: считать реальным падением и накапливать счетчик ретраев только в случае, когда хост упал, не оставив после себя никаких артефактов. Имеет право на жизнь, но по своей сути тоже является количественной, а не качественной метрикой. Проблема в том, что деплой приложения и подъем пода — это тоже время, а следовательно деньги. Условно, переподнимать под после каждой эпохи влетит в копеечку. Значит, требуется ввести в расчеты то, за что платятся деньги — время. Начинает напрашиваться метрика, вроде количества эпох за единицу времени. Уже лучше… Но как ее нормализовать? Задачи имеют разную длительность, могут выполняться на разных картах с различной производительностью, следовательно использовать эмпирически подобранную константу не получится. Возможным вариантом, разумеется, было бы прогнать сотню‑другую эпох с фиксированной моделью, датасетом и железом. Но, к сожалению, этот вариант полностью разбивается о реальность: фиксация всех трех этих переменных возможна только если вы обучаете одно и то же на одинаковом железе. Это, скорее, про тот самый «кровавый энтерпрайз» со своими стандартизированными датацентрами и высокой инерцией при любых изменениях процессов, что имеет слабое отношение к разбираемой теме. Не говоря уже о том, что полученное число в любом случае оказалось бы бесполезным за пределами того контура, в котором оно было получено. Это прямой аналог пресловутого MIPS, который за свою бесполезность получил ироничную расшифровку «Meaningless Indicator of Processor Speed». Вывод из этого оказывается неожиданно оптимистичным: невозможность фиксации всех переменных — это не препятствие, которое нужно любой ценой обойти, а указание на то, что нужно изменить сам подход, уйдя в сторону безразмерных величин.
Метрика, которая ничего не измеряет
Обычно в схожих ситуациях стремятся чинить знаменатель (например, нормировать на производительность карты), однако в нашем случае «сломан» был именно числитель. Эпоха — это единица не работы, а обхода датасета. Одна эпоха на сотню небольших картинок с трехслойной сверткой и одна эпоха на часовом аудио с conformer’ом — это два числа «1», между которыми несколько порядков реальных вычислений. Следовательно, нам нужна единица, инвариантная к тому, что именно обучается, и таковая, по большому счету, всего одна — FLOPs.
Для трансформероподобных архитектур есть простая аппроксимация («Scaling Laws for Neural Language Models»), Каплан оценивает прямой проход около операций на токен, где
— количество параметров, а обратного примерно вдвое дороже. Отсюда же и каноническая формула, популяризованная Хоффманом («Training Compute‑Optimal Large Language Models»), выглядит она упрощенно так:
D — суммарное количество обработанных элементов, а шестерка раскладывается на три прохода (один прямой и два обратных) по два FLOPs на каждый. В целом, это уже рабочий вариант числителя для наших задач, поскольку для задачи сравнения не важны абсолютные числа, достаточно лишь их отношения, однако для полноты информации важно отметить, что приведенная формула не учитывает внимание, которое квадратично по всей длине последовательности. При длинном контексте обязательно требуется поправка:
L — количество слоев модели, d — ширина модели, s — длина последовательности. Второй вариант формулы «каноничнее», но нужен далеко не всегда. При классическом для трансформера проще всего представить себе влияние внимания на конечный результат как
, то есть более‑менее значимый эффект проявляется примерно при
.
Увы, для не‑трансформеров такой же простой и красивой формулы нет. Целью статьи не является разобрать все существующие архитектуры моделей и варианты расчетов работы для них, здесь я лишь констатирую факт, что этот расчет требуется и привожу небольшие примеры. Для полноты картины кратко пробежимся по сверточным моделям, и на этом будем считать раздел закрытым. Достаточно знать, что задача решаема «с листа» и не требует реальных итераций обучения для оценки, хотя и не дает абсолютной точности. Большим плюсом является то, что эта точность и не нужна для сравнения в контексте описываемой задачи.
Формальное представление для свертки выглядит примерно так:
Первые три множителя определяют количество значений, которые требуется посчитать (произведение размера выходной карты на количество фильтров); оставшиеся три — цена одного значения; groups — поправка на случай разделения каналов по группам. Стоит заметить, что в здесь мы считали MACs, а не FLOPs. Для получения работы обучения требуется домножить результат на ту же шестерку (два FLOPs на операцию, три прохода). Все то же самое, что и , только в ней расчет MACs уже произведен и сведен к самому
.
Если очень хочется обоснование формулы
Ключевая разница в том, что в трансформере каждый вес используется ровно по одному разу на каждый элемент данных, отсюда и вывод, что работы столько же, сколько и весов. Свертка же работает иначе: по своей сути она является скользящим окном, которое перемещается по определенной области, и в каждом доступном положении выполняет одно и то же вычисление. Веса окна не меняются, однако применяются они столько раз, сколько положений окна существует. По этой причине работу для сверточных сетей считают не от весов, а от результата.
На этом тему числителя можно закрыть, мы добились своей цели — измерения работы, а не обхода датасета, можно вернуться к знаменателю, ради которого все и затевалось… Идея до неприличия простая: все карты имеют заявленную заводом паспортную производительность, вот долю от нее‑то мы и будем использовать. Это даст нам метрику от ноля до единицы, которая будет отражать, какая часть возможностей карты конвертировалась в обучение.
Если вам показалось, что вы это уже где‑то слышали… То вам не показалось. Скорее всего в голову пришла Model FLOPs Utilization, она же MFU, введенная в обиход инженерами Google в PaLM. Однако исходная метрика призвана оценивать качество кода и утилизацию GPU, ее знаменатель не учитывает издержки, а для нас их учет является обязательным для оценки, значит мы ее немного доработаем.
— время самих вычислений,
— паспортная производительность GPU,
— доля времени, ушедшего в полезную нагрузку,
— накладные расходы.
Для конечной оценки пригодности площадки мы используем не , а
— ее произведение с долей оплаченного времени, потраченного на полезную нагрузку. Мы умышленно включаем в расчет время инициализации пода, ожидания данных и подобные накладные расходы, поскольку мы за них платим, но фактическую ценность представляет именно время полезной работы.
И сразу важная оговорка: накладные расходы и MFU нужно хранить отдельно! Перемножаются они только при ответе на вопрос о пригодности площадки. Причина в том, что те же самые наблюдения помогают ответить и на еще один вопрос: «с какой скоростью выполняется само обучение». Зачем он нам? Узнаете в третьей главе.
Для полной «красоты» и закрытия данного раздела осталось лишь определиться, какое значение просадки вы считаете приемлемым для себя. Лично я использовал консервативный безопасный порог в 70%, однако данное значение очень зависит от характера нагрузки. Много‑много мелких фолдов? Значение будет тянуть вниз. Продолжительные многочасовые заходы на сотни эпох? Поползет вверх. Но это не столь принципиально, данное значение все равно очень скоро станет не нужно, гораздо важнее одновременно с обучением начать собирать статистику. Теперь все переменные нам известны и подконтрольны, никаких случайностей и привязки к реализации, мы получаем все метрики по времени выполнения, используемым GPU, знаем объемы работ. А это означает последний оставшийся шаг — замену нашего эмпирического порога на отклонение ниже медианы по площадке и карте, взятое из статистики.
Разумеется, важно понимать, что смешивать в одну кучу паспортные пики для fp32 и fp16 — плохая идея. Чисто технически, масштаба метрики должно хватать на то, чтобы сравнивать разные GPU между собой, разброс между картами не катастрофический, я даже пробовал провести такой эксперимент, однако пришел к выводу, что дельта достаточно существенна, чтобы оказывать влияние на результат, в то время как объективных преимуществ от такого смешения практически нет. По крайней мере так вышло в моем сценарии использования, «инклюзивность» карт довольно низкая, в активном использовании буквально 3–4 разных, да и те в основном из‑за ограничения площадок. Например, Kaggle дает наибольшие бесплатные лимиты из всех, но выбор ограничен между парой T4 и P100, в то время как на условном Vast по соотношению цена/производительность гораздо интереснее что‑то вроде 4090/5090.
Что в итоге? Как выяснилось, вопрос «был ли отказ» оказался поставлен неверно — диспатчеру нужно решить, продолжать ли работу с площадкой или идти дальше, а не искать виноватого. Для этого мы дали ему не только классы ошибок, но и безразмерную метрику, которая позволяет оценить, является ли сбой «нормой» для спота или же что‑то действительно идет не по плану. При этом нам не понадобились даже тестовые прогоны. Да даже без сбора статистики решение будет вполне рабочим, вычислять смещение от медианы — это просто финальный штрих, чтобы добиться полностью замкнутого цикла работы. Теперь диспатчер списывает ретраи только в тех случаях, когда качество артефактов, выраженное в соотношении работы ко времени, падает ниже ожидаемого. Это позволяет одновременно как можно дольше оставаться на самых дешевых (или вообще бесплатных) площадках, при этом исключая вероятность «вышибания стенок лбом», если все совсем плохо.
Однако, как вы могли заметить, вся первая глава допускала, что задача на разных площадках будет выполняться одинаково. Допущение, прямо скажем, не верное, и разбираться с ним придется отдельно…
Тот же код, та же модель, другой ответ
Как всем известно с малых лет, одним из важнейших столпов любой робастной системы является воспроизводимость процессов. А чтобы прямо совсем хорошо всем было, очень желателен абсолютный побитовый паритет! Так и вышло в моем pet‑проекте. Специфика системы такова, что разница в один бит из терабайт данных — это приговор. Вся цепочка мутации и использования данных должна всегда давать строго идентичные результаты. Даже хуже, несколько независимых цепочек обязаны переиспользовать строго один и тот же код (да, да, падать с одними и теми же багами…):
-
Сырые данные → параметры → обученная модель
-
Сырые данные → параметры → модель → бэктест
-
Сырые данные → параметры → модель → лайв
Кратко про последние два… Там «все сложно», особенно если и бэктестер, и среда живого исполнения являются сторонними интеграциями и недоступны для правок. Но это по крайней мере чисто архитектурный вопрос: как обеспечить битовый паритет между двумя независимыми системами, когда ты пишешь третью систему. Если тема будет интересна, то можем рассмотреть ее в отдельной статье, расскажу про все подводные камни, на которых поскользнулся, и про все «надводные», которые прилетели по голове…
А теперь про первое: как же обеспечить полную воспроизводимость процесса обучения на абсолютно разном железе? А никак!
Стойте! Не спешите расходиться! Я предлагаю разобрать, почему так вообще происходит, когда можно смириться и закрыть глаза на расхождение, и что делать, если игнорирование проблемы ее почему‑то не решает…
Пойдем по порядку… Кратко разберем, из‑за чего вообще возникает проблема. А виноваты во всем опять числа с плавающей точкой. Почему опять? Потому что корень проблемы ровно тот же, за который бьют по рукам джунов, которые решают хранить, например, деньги в условном double вместо decimal. Сложение чисел с плавающей точкой не ассоциативно, то есть тождество не истинно в данной ситуации. Результаты не будут «примерно равны», они будут разные с точностью до последних битов мантиссы, поскольку каждое промежуточное сложение округляется, и от порядка этих округлений зависит конечный результат.
На CPU в один поток это практически не вызывает проблем, поскольку порядок операций строго определен кодом и не меняется. Один из немногих реальных случаев — это озвученные выше операции с финансами или очень специфический асинхронный код (да и то, это нужно еще постараться). А теперь вспомним, за что мы все так любим GPU… Правильно, те самые «волшебные» тензорные ядра, которые позволяют выполнять бессчетное множество матричных операций в кратчайшие сроки, не давая нам состариться, пока обучение закончится на CPU. Но эти же ядра и приводят к тому, что результаты обучения на разном железе будут отличаться: суммирование на GPU — всегда строго параллельная операция (ну ладно, не всегда, но не будем сейчас рассматривать специфические сценарии с use_deterministic_algorithms и тому подобное, тем более что переносимости они не добавляют). Т.е. сложить чисел обозначает необходимость разбить этот
на подгруппы, потом сложить результаты подгрупп и так далее. Форма полученного дерева полностью зависит от количества вычислительных блоков на карте и количества доступных потоков на блок. Отсюда же очевидный вывод: замена GPU меняет форму дерева, форма дерева изменяет порядок операций, порядок операций меняет последние биты результата. Это не баг, а обратная сторона медали агрессивного параллелизма, который делает обучение возможным в принципе в разумные сроки. Сверху накладывается еще несколько слоев:
-
Логика драйверов и библиотеки‑планировщика. Например, тот же cuDNN (CUDA Deep Neural Network) может посчитать одну и ту же свертку несколькими принципиально разными способами. Выбор же основывается на эвристике, с оглядкой на размеры тензоров, доступную память и прочее. Причем, «разные способы» — это не просто разный порядок сложения, а принципиально различающиеся алгоритмы и арифметика с разным округлением и погрешностями.
-
Отсутствие стандартов для ряда функций. Да, как ни странно, ни IEEE (Institute of Electrical and Electronics Engineers), ни какой‑либо другой стандарт не регламентируют строгие требования к реализации многих математических функций. Экспонента, логарифм, тангенс — все они реализованы по своему в каждой отдельно взятой библиотеке. Что, к слову, ведет к огромным расхождениям между CPU и GPU, гораздо больше, чем между двумя отдельными картами, даже сделанными по разным архитектурам.
-
Некоторые операции неупорядочены по определению. Сюда входит все, что суммирует по индексам с возможными повторами: разреженные градиенты эмбеддингов, scatter_add из PyTorch и TensorFlow и тому подобное. Все эти операции выполняются атомарно в том порядке, в котором добежали потоки. Кстати, использование этих механизмов может сделать невоспроизводимым даже процесс обучения на абсолютно идентичном железе.
Где‑то тут легко может возникнуть соблазн решить проблему способом «сотри‑забей» или «дели на ноль и беги». Ну расходится и расходится, что с того? Там дальше все равно градиентный спуск, он шумный сам по себе. Соблазн понятный и где‑то даже оправданный, но давайте не забывать, что обучение — итеративный процесс. Такие мелочи как разница в пару последних бит на первой эпохе меняют градиент, градиент меняет веса, веса меняют градиент следующего шага, это приводит к тому, что ошибка не стоит на месте, а копится и растет экспоненциально. В итоге, через сотню эпох две «почти одинаковые модели» превращаются в две абсолютно разных, эффект примерно тот же, что и при случайной инициализации. Почти одинаковых моделей в принципе не существует, они либо идентичны, либо являются двумя разными сущностями. При этом важно понимать, что расхождение не делает одну из них «плохой», они просто разные.
Отсюда первый полезный вывод, способный сэкономить кучу времени: никогда не проверяйте результаты обучения с разных площадок сравнением весов. Прогоны на разных платформах всегда будут давать разные модели, это не баг, это норма. Сверять нужно исключительно метрики, и ни в коем случае не по значению, а только по разбросу. Оптимальным вариантом выглядит запустить одно и то же на разных сидах, но на одном железе и зафиксировать разброс. Расхождение между площадками должно в него попасть. Если не попало, то это уже очень громкий звоночек о том, что где‑то есть реальная ошибка. Запомните этот тезис, он нам еще пригодится дальше…
А оно мне надо?..
Хороший вопрос! И ответ на него можете дать только вы сами. Корректная формулировка звучит примерно так: а насколько мне вообще важно, чтобы модель получилась ровно той же самой? Фактический ответ тут зависит не от величины расхождения, а от планируемого сценария использования результатов обучения. Вариантов, по большому счету, два:
-
Вариант I: Вам нужно число
Самый часто встречающийся случай! И определить его можно с легкостью, озвучив причину, по которой вы вообще запустили обучение.
-
Какой из этих вариантов архитектуры лучше решает мою задачу?
-
Была ли эта аугментация полезна?
-
Какой LR (Learning Rate) выбрать для данной ветки?
-
Есть ли вообще потенциал у моей идеи?
Во всех этих — и многих подобных — случаях результатом работы является не сама модель, а какая‑то из метрик валидации. И именно таких запусков подавляющее большинство, при этом артефакт может вообще отправиться в мусорку после первого же взгляда на статистику. По моему опыту, даже до этапа бэктеста доживает хорошо если одна модель из ста, остальные же нужны лишь для проверки гипотез и подбора гиперпараметров.
Более того, я уверен, что вы и так давно живете с недетерминизмом, и куда более грубым! Сильное заявление? Давайте докажу! Другой сид инициализации, dropout, случайные аугментации — это все ведет к «грязным» моделям на абсолютно идентичном железе, и никого почему‑то не смущает! Разные площадки — не более чем еще один источник подобного шума, к тому же зачастую уступающий по величине остальным. И вот тут надо вернуться к логике замера из прошлого раздела. Помните, я говорил, что она нам еще понадобится? Так вот, если вы оцениваете энтропию, выполняя идентичные прогоны на разных сидах, то ваш проект уже защищен от шума при использовании разных площадок.
Важная оговорка: закрывать глаза на расхождение можно лишь до тех пор, пока измеряемый эффект превышает уровень шума. Предположим, вы решили сравнить два варианта, получив разницу в 0.3%, а разброс между сидами — целый процент. В подобной ситуации разница между площадками не способна что‑либо испортить, ибо портить просто нечего: вы измеряете шум вместо эффекта. Так что разумнее всего начать с простого, а уже потом морочить себе голову в попытках минимизировать влияние фаз луны на интимную жизнь тушканчиков.
-
-
Вариант II: Вам нужен конкретный файл
В данном случае результатом работы является конкретный артефакт. Не похожий на него, не эквивалентный по метрикам, а именно этот! Навскидку:
-
Модель должна пройти приемку, а на прод улетит именно та версия, которая ее прошла.
-
Произошел инцидент, теперь для разбора полетов нужно в точности воспроизвести процесс обучения для поиска проблемы.
-
Есть явное внешнее требование к полной воспроизводимости процесса. Это редко касается личных проектов, подобные рамки скорее для биг‑теха.
-
Дальше по цепочке за моделью есть какая‑то отсечка, сравнение значения с порогом или любое другое преобразование, которое превращает сигнал в выбор. Этот сценарий наиболее частый.
Мой случай как раз последний, но заметьте: даже тут требование накладывается не на весь процесс обучения, а на один единственный финальный прогон, который в итоге и уедет дальше. И это, наверное, главный практический вывод из данной главы: в подавляющем большинстве ситуаций воспроизводимый артефакт нужен только на этапе деплоя, вся черновая работа может выполняться на любой карте и площадке!
-
Единственный момент, в котором я остаюсь категоричен, и на который хочу обратить ваше внимание: гарантия должна правильно формулироваться! «Битовый паритет» — это не гарантия, а красивый лозунг, и однажды он вас подведет. «Воспроизводится побитово при использовании того же класса устройства, сборки и версии ПО» — уже гарантия, которую можно проверить и доказать, а следовательно и положиться на нее.
История из жизни про последние пару бит, «неспособных что‑то изменить»
Небольшая бонусная история про «пару последних бит, которые ни на что не влияют» и те самые пороговые значения и гарантии… Во время одной из ранних итераций разработки находка была ровно про это. Устройство, на котором выполнялось обучение, в тот момент нигде не сохранялось и не сверялось. Проверка на исторических данных выполнялась локально на M4, а рабочий процесс был запущен на отдельной машине. Замер дал копеечное расхождение около 2.e-9. Абсолютная мелочь! Целых девять знаков после запятой! Ровно до того момента, пока не выяснилось, что дальше идет сравнение с нулем… У порога, где значение и без того колебалось около нуля в отдельных сценариях, знак от такой «добавки» просто переворачивался, что давало инвертированный сигнал. Отсюда, кстати, можно сделать еще один полезный вывод: независимо от гарантий, если инвариант не запинен тестом, то это не инвариант, а комментарий. Но это отдельная глубокая тема, заслуживающая самостоятельной статьи.
Не морочь голову, так можно или нельзя?
Предположим, в разделе выше вы прошли по второй ветке, и вам все таки требуется паритет. Что же с этим делать? Напрашивается очевидное — пишем в манифест при обучении конкретную конфигурацию и сверяем при запуске. Не совпало? Ну, значит не судьба, ничего не запускаем. Почему я отказался от этой идеи:
-
Строгий запрет сделал бы бессмысленным саму идею распределения вычислений по разным площадкам, что многократно увеличило бы время обучения и сделало неэффективным расходование квот.
-
Блокировки по одному фактору (GPU) в любом случае было бы мало. Если уж мы боремся за воспроизводимость, то, как и было сказано ранее, модель карты — это лишь одна из переменных, ее нужно заменить на полноценный fingerprint (отпечаток) системы. Сломать воспроизводимость можно даже на локальной машине. Сейчас я пишу в манифест все, что имеет какое‑либо влияние на конечный результат: от моделей железа до версий библиотек и драйверов.
-
А о чем бы я тогда статью писал? Слишком коротко бы вышло: не работает, расходимся.
Что я сделал:
-
Предупредил, а не запретил. При расхождении окружения я не запрещаю полностью выполнение, а оставляю выбор, выдавая подробное описание проблемы и последствий. Этот компромисс с одной стороны позволил эффективно параллелить большое количество экспериментальных итераций обучения, с другой предельно снизил вероятность случайного запуска процесса там, где его быть не должно.
-
Разделил потоки разработки. Это основной механизм защиты. Как было разобрано выше, нужно понимать, что процесс исследования и процесс деплоя — разные ветви разработки. В большинстве ситуаций ограничения актуальны только во втором случае, и именно для него в моем проекте появился отдельный независимый пайплайн. Процесс изменился.
Было: обучение → оценка метрик → бэктест → деплой
Стало: обучение → оценка метрик → бэктест → полное переобучение на том же конфиге, но с фиксированным окружением → сверка разброса метрик с исходной моделью → бэктест → деплой
Причем, система сама физически не позволяет внести какие‑либо изменения в процесс. Деплой строго требует пройти бэктест со специальным флагом и набрать определенный score (счет), флаг выставляется только при условии фиксации окружения в манифесте задачи и доступен только для моделей второго поколения, то есть наследников тех, кто уже ранее прошел бэктест и оценку метрик, доказав свою полезность. Таким образом, модель, планирующаяся к использованию на лайве, имеет ту самую гарантию паритета и воспроизводимости, однако не освобождается от повторной оценки эффективности. Это отдельный workflow в системе, а не расширение имеющегося, и он даже имеет отдельные ограничения прав доступа.
Итак, вот мы честно сформулировали гарантии, пишем отпечатки, отлавливаем расхождения — казалось бы, вопрос решен, но остается еще один нюанс, который я во второй главе старательно обходил стороной… Если два прогона одной и той же задачи дают на выходе две разных модели — что же будет, когда оба прогона случатся одновременно?..
Может показаться надуманным сценарием, который еще нужно постараться устроить, однако уверяю — на практике он происходит регулярно, и виноват тут вовсе не злой рок, а наш же диспатчер из первой главы.
И кто из вас настоящий?
Диспатчер отправляет задачу в облако А, связь обрывается, ответа нет. Внимание, вопрос: задача запустилась? Правильный ответ — неизвестно. И вот тут начинается «магия»: диспатчер послушно классифицирует ситуацию как недоступность провайдера, выжидает какое‑то время и переотправляет, или даже уходит на площадку В, в то время как задача жива‑здорова и прекрасно себя чувствует, просто ответ до нас не долетел по какой‑либо причине. И вот уже у нас двое честных работяг, каждый из которых выполняет одну и ту же задачу, съедает деньги или квоты, а что хуже всего — пишут артефакты в одно и то же место, поскольку назначение определяется задачей, которая у них общая. И вот почему это хуже, чем может показаться…
Обычной первой реакцией могло стать: ну потратим мы вдвое больше, грусть‑печаль‑тоска‑беда, но переживем. И это было бы правдой, если бы не одно «но»: близнецы пишут одно и то же. А мы только что целую главу разбирали, что два прогона одной задачи дают на выходе разные модели. Причем, это не просто различные артефакты, а расходящиеся и конфликтующие между собой траектории обучения, которые после первого же шага не имеют ни малейшего отношения одна к другой. Да еще и пишут они по очереди в единую цепочку снапшотов! Кто первым добежал до вехи , тот и записал.
, быть может, запишет кто‑то другой. В итоге получается не дубликат, и, что особенно интересно, даже не мусор, а химера: склейка из кусков разных процессов обучения. И отловить подобный сценарий довольно проблемно: метрики химеры будут выглядеть совершенно нормально, ведь оба близнеца обучались корректно, каждый по‑своему, а воспроизвести нельзя, поскольку и воспроизводить нечего!
Обратите внимание, как это соотносится с предыдущей главой: там мы старательно выстраивали гарантии, писали отпечатки, объявляли уровни воспроизводимости, а тут одна единственная потерянная сетевая посылка обнуляет всю нашу работу! Манифесты и фингерпринты будут корректны, а результат все равно окажется невоспроизводим, поскольку его собирали несколько агентов.
А что, нельзя просто спросить?
Напрашивается очевидное — спросить площадку перед повторной отправкой, запущен там наш под или нет. Проблема в том, что в общем случае данный вопрос не имеет ответа.
-
Мы отправили запрос и не получили ответа. Что произошло? Запрос не дошел? Просто лаг и отвал по таймауту? Арендная машина не шлет хартбиты площадке? Снаружи эти случаи на практике неразличимы, и никакое ожидание их не разделит, поскольку «еще не ответил» и «уже никогда не ответит» выглядят совершенно одинаково со стороны. Кстати, это не частная беда проекта, а классическая задача о двух генералах, имеющая доказательство отсутствия решения.
-
Запрос отправлять просто некуда. Хорошо, некоторые провайдеры, например RunPod или Vast, умеют отдавать список запущенных инстансов. А что делать с остальными? Modal — serverless, Kaggle — вообще блокнот… Плюс к тому, сам по себе этот список (даже со статусами) не является панацеей, поскольку отражает «здоровье» пода, а не состояние приложения внутри него. Например, никакие хартбиты уровня приложения не спасут от крайне медленной сети хоста, а это частенько встречается на спотах. По фильтрам — вроде нормальный хост, деплоишься и понимаешь, что «нет в жизни счастья, нет постоянства». И подобных сценариев множество, предусмотреть абсолютно все на уровне приложения выглядит почти невозможным, так еще снижает обобщенность интерфейса, заставляя подстраиваться под конкретную площадку.
Бороться с этим практически бесполезно, нужно менять постановку, а для этого сперва разобраться, кто или что вообще порождает данную коллизию. Мой список вышел таким:
-
Ретраи. Тот самый класс «Провайдер недоступен» из первой главы. Каждая переотправка по таймауту потенциально создает копию. Проблема частично сглажена нашей оценкой качества площадки в текущий момент времени, однако не устранена полностью.
-
Переход к следующему провайдеру в списке приоритетов. Суть та же, только двойник порождается еще и на другой площадке, что повышает энтропию.
-
Жесткие падения самого оркестратора или гейтвея. По описанным выше причинам мы не можем внедрить в систему транзакционную логику, то есть фиксировать в базе полноценный жизненный цикл задач по факту изменения их статуса и одновременно с ним, нам просто неоткуда брать эту информацию, нам доступны лишь предположения, но не гарантии. Свежезапущенный сервис знает лишь о том, что задача была создана, но понятия не имеет, что с ней стало дальше.
-
Человек. Один из самых частых случаев, у меня даже возникали мысли в принципе запретить ручную коррекцию уже запущенного процесса, поскольку слишком многим не хватает терпения…
Объединяет их всех одно: близнецы рождаются там, где мы сами же обсчитались, списав еще живой процесс в утиль.
Я вас вижу!
И что же нам с этим делать? Поскольку спрашивать площадку бесполезно, а входящего канала к приложению в общем случае не существует в природе (привет блокнотам и облачным функциям), остается лишь перестать этим заниматься. Логика проста: пусть факт запуска и состояние фиксируются самим приложением там, где мы в состоянии полностью контролировать ситуацию — в нашем же хранилище.
К какому решению пришел я: сразу после старта исполнитель закидывает в рабочую область задачи небольшой объект — свидетеля. И дальше главное: перед каждой отправкой того же задания диспатчер его читает. Если свидетель существует — значит, работник уже живет, а вместо порождения близнеца нужно присоединиться к отслеживанию вех в хранилище и ожидать результат. Так ведь, правда?..
Именно такой была первая версия. Сломалось она ровно так, как и должна была: спот был отобран, а убрать свидетеля не успел. Следующая попытка прочитала свидетеля и села наблюдать за трупом, при этом диспатчер оставался со святой наивной верой, что все хорошо.
Проблема решается просто: свидетель не должен быть флагом, он должен быть арендой. Он имеет свой «срок годности», а работник его периодически обновляет. Не продлил вовремя — задача считается потерянной и ее можно попробовать перезапустить. Здесь возникает уже знакомый размен: короткая аренда снижает время реакции на падение, но повышает риск объявить БВП живой процесс. Появляется закономерный вопрос: а где же взять это «оптимальное» время? И тут все сложнее, чем кажется на первый взгляд…
Еще немножко посчитаем…
Начнем с того, что таймеров тут должно быть два: первый — таймер захвата аренды при старте нового процесса, второй — обновление аренды. Смешивать в одну кучу их нельзя, поскольку первый многократно превосходит второй, именно в его отсчет закладываются все издержки: инициализация машины, выгрузка колес, получение датасета и тому подобное. Это минуты, а иногда и десятки минут. Второй же никаких накладных расходов не имеет, его значение, как правило, меньше на порядок, за исключением сетевых заминок (и то зависит от сценария) или предопределенных пауз в цикле. Он измеряется секундами.
Начнем с простого. Таймер захвата легче всего рассчитать из распределения времени от отправки до первой аренды. На первых итерациях можно использовать эмпирически подобранное значение, а по мере накопления статистики перейти на него. Величина полностью наблюдаемая и существенно разнится от площадки к площадке. Где‑то может подняться за десятки секунд (в основном это относится к мощностям в датацентрах), а где‑то уже десятки минут. Особенно больно у агрегаторов, продающих время частных пользовательских машин. Именно они генерируют огромную долю шума и создают проблемы при расчетах. По большей части мне удалось нивелировать проблему фиксацией ширины канала при фильтрации машин для аренды, но, разумеется, разброс все еще ощутимо больше, чем на площадках, хостящихся в ЦОД. Далее все просто — объявляем перцентиль, который считаем приемлемым для себя, и смотрим, какому времени он соответствует.
А теперь посложнее… И начать стоит с главного ограничения: поскольку аренда — это право на запись артефактов, то и продлевать ее должен тот, кто эти артефакты порождает, а именно — цикл обучения. В противном случае, если делегировать задачу стороннему потоку, это самое право перестает быть связано с обязанностью им воспользоваться (выполнить полезную работу), а вся система строится именно вокруг этой связи. Из этого следует немного неочевидный вывод — срок аренды не может быть задан в секундах. Давайте разберем на примере, тогда все встанет на свои места.
Мы выяснили, что молчание нашего работника ограничено длительностью одного шага. Следовательно, выбранный срок аренды должен эту длительность полностью перекрывать с некоторым запасом. Теперь взглянем, что произойдет, если мы зададим его в секундах, игнорируя шаг (скажем, 30 секунд):
-
На условной RTX4090 с легковесной моделью шаг занимает доли секунды. А остальные
секунд молчания — это тысячи циклов, за которые придется платить, хотя хватило бы значения на порядок меньше.
-
И обратная ситуация. Возьмем, допустим, T4 с тяжелой моделью. И вот уже один шаг требует 20 секунд. Минимальная заминка — запись снимка, сетевая задержка — и живой процесс выброшен на помойку.
Одно и то же число дает тысячекратный запас в первом случае и всего полуторократный во втором. Да еще и в «неправильную» сторону: именно медленные карты обычно и выступают теми самыми бесплатными незаменимыми рабочими лошадками для подавляющего большинства задач. Добавьте сюда то, что при изменении размера батча, архитектуры или, например, добавлении CAGrad время шага меняется многократно. И вот уже у вас не просто magic number, а magic number, который всецело зависит от переменных. Следовательно, раз предельное молчание ограничено самым долгим шагом, то и измерять нужно именно его.
И тут имеется хорошая новость: работу на шаг мы умеем выводить из архитектуры, это ровно та же формула из первой главы с единственной правкой — вместо целого прогона подставляем один батч. Сколько операций в секунду выдается на самом деле — это паспортный пик, умноженный на метрику скорости обучения, статистику которой мы собирали ранее. А работа, деленная на фактическую скорость, как раз и дает нам время:
— работа на один батч. Полная запись формул была в первой главе. В таком случае время аренды мы можем выразить так:
Где отражает количество шагов, которые могут быть пропущены до того как мы объявим работника мертвым. Например, единица обозначала бы, что исполнитель списывается, не успев уложиться всего в один шаг. Разумеется, это слишком жестко, так что источник этого
мы разберем дальше. Сейчас главное понять, что настройкой стала безразмерная величина, а секунды появятся сами. Теперь слабая карта с тяжелой моделью получит более длинную аренду, а мощная с легкой — короткую, без каких‑либо ручных манипуляций.
Поговорим про сам множитель… Шаги всегда будут отличаться, даже на идентичном железе, и изредка довольно существенно относительно медианы. Важно то, что нас интересует именно это «изредка», а не типичный сценарий, иначе статистика «убийств» резко возрастет в одном отдельно взятом проекте… Нужно понимать: разброс шагов и их абсолютная длительность имеют разную природу. Абсолютная длительность зависит от GPU и модели, мы уже научились ее считать, и она учтена в нашей формуле. А вот разброс, в свою очередь, сильно завязан на площадку. Ключевая причина заключается в том, что арендовав на условном Vast одну карту из пяти, саму карту‑то вы получите в монопольное пользование, а вот остальные ресурсы машины — CPU, RAM, диски, сеть — придется делить с соседями. На Kaggle же разброс будет много меньше. Там вы, конечно, тоже не становитесь единоличным владельцем сервера, однако запас по ресурсам существенно выше, а выделенные мощности, как правило, бронируются за вами на уровне гипервизора, что сглаживает эффект. Основное преимущество в том, что от модели с картой само это соотношение почти не зависит. При изменении сложности задачи или смене железа вырастут и типичный, и редкий шаги, а отношение между ними скорректируется несущественно, растянется или сожмется вся шкала целиком. Именно по этой причине нужно накапливать статистику не по длительности редкого шага, а по отношению его длительности к медиане. Первое пришлось бы пересчитывать после каждой смены модели, второе является высокоинертной характеристикой площадки. Именно это отношение является нашим коэффициентом .
Казалось бы все? Но нет. Остается еще нюанс: в статистику попадают только те шаги, которые смогли завершиться успешно, а вот ставшие причиной списания работника в нее не войдут. Не приведет ли это к ошибке выжившего? Нет, если все сделать правильно. А правильно — калибровать исключительно сверху вниз. Стартовать калибровку требуется с заведомо завышенного значения, которое позволит увидеть весь хвост. Дальше плавно ужимать, опираясь на реальные данные. Если стартовать со слишком жесткого значения, то мы не увидим ничего за его порогом, так и не узнав, чего лишились. Даже хуже: ужатый порог приводит к зацикливанию на самого себя, поскольку провоцирует списания, а из этого следует, что статистика начинает подтверждать сама себя. Стартовое значение уже не magic number, а величина, которую можно объявить как заведомо избыточную, что является прямым требованием к ней. Обосновать, например, — можно (50 пропущенных шагов на легкую заминку никак не тянут), а вот попробуйте обосновать «60 секунд»? То‑то же! Именно этого мы и добивались.
Остается один вопрос: до какого момента ужимать? И ответ на него тоже находится статистически, а не эмпирически. Имеются два класса ошибки: ложное списание и простой после фактического падения исполнителя. И оба они выражены в одной и той же величине — времени, следовательно сравнимы напрямую. Остается лишь посчитать издержки в валюте или квоте: перевешивают простои — ужимаем, ложные списания — ослабляем. Число само себя находит. Но есть два важных нюанса, которые необходимо учитывать:
-
Издержки асимметричны. Ложные списания в общем случае обходятся дороже, поскольку требуют повторно вложиться в накладные расходы на перезапуск задачи.
-
Цена ложного списания определяется интервалом вехи, а не сроком аренды. Именно с последней вехи придется все переделывать. Так что при желании увеличить агрессию детектора смерти работника дешевле не укорачивать аренду, а попробовать участить вехи. Это удешевит ошибку, сделав более короткие сроки аренды допустимыми. Указанные параметры сильно связаны между собой, и я бы настоятельно рекомендовал рассматривать их в комплексе.
Есть некоторые ограничения, которые нужно учитывать. Из плюсов — все они подконтрольны и следуют из одного и того же корня: логика системы подразумевает, что работник молчит не дольше одного шага.
-
Ретраи внутри деплоя. Подвисла какая‑то сторонняя библиотека внутри логики цикла, например при переподключении. Сам работник жив и здоров, он просто призадумался в сторонней зависимости. Сколько это все продлится? Да кто ж его знает… Лечится очень просто — адекватными таймаутами. Но, как показывает практика, о них частенько забывают. Не о самих таймаутах, а об адекватности. Логика простая: если что‑то висит, оно должно упасть в разумное время, а не ждать у моря погоды. Что считать адекватным? Отталкивайтесь от статистики разброса, которую мы разобрали выше, а именно от верхних границ по всем площадкам. Или можно сделать таймауты параметром задачи и устанавливать их в зависимости от площадки/железа, что было бы даже точнее технически, но уже на грани KISS (Keep it simple, stupid).
-
Атомарность перехвата. Пока диспатчер один — проблемы нет, а что если они кластеризованы и их несколько? Две попытки рискуют одновременно обнаружить протухшего свидетеля и решить, что теперь они тут хозяева. В стремлении починить одну гонку мы создали другую. Закрывается это условной записью: содержимое свидетеля может быть обновлено только в том случае, если оно не изменилось с момента чтения. Большая часть современных объектных хранилищ умеет такое при помощи версий объекта.
-
Запись артефактов. Мелочь, просто не забыть. Шаг может занимать несколько секунд, а выгрузка гигабайтов в хранилище — минуты. Если продлевать аренду только на границах шагов и забыть про параллелизм, то работник будет молчать все время записи. Причем чаще, чем хотелось бы… Решение опять же простое — делегирование записи потоку‑курьеру. Но не забывайте, курьер не должен получить прав на продление аренды, он может лишь отчитаться колбэком об успехе или провале потоку‑рабочему.
Почему нельзя все сделать проще???
Тут внимательный читатель может возразить: а зачем так усложнять, почему не простой пульс на сервис, стандартное решение, все так делают? Вопрос резонный, а ответ на него решает судьбу всей конструкции, так что разберем причины подробнее.
Пульс не равен работе. Хартбиты работников идут по одному пути, а артефакты (полезная работа) по другому. Каждый путь независим и самостоятелен, потому абсолютно штатная ситуация выглядит так: пульс стабилен, а запись в хранилище отвалилась по любой причине — права протухли, сам бакет недоступен, а может быть вообще сеть до хранилища легла. Исполнитель бодро рапортует о своем здоровье, а работа стоит на месте.
-
Перед тем как отчитаться проблему нужно увидеть. Зависшая запись… Висит! Заблокированный работник до отправки пульса может просто не дойти. Ну ладно, дойти фоновым потоком, который про блокировку ничего не знает. Решаемо? Пожалуй, да… Но не так тривиально, как кажется. Плюс к тому, отдельный поток — это разделение состояния, со своим набором способов отвалиться. Да и объявленное выше ограничение, что источником отметки должен быть тот, кто порождает артефакт, никуда не делось.
-
Пульс — это самоотчет. Исполнитель сообщает то, что он сам о себе думает, в то время как нам интересует не его самооценка, а прогресс работы. Это разные утверждения, смотрите: запись произошла, но содержимое битое или же было тут же перезаписано. Что об этом думает наш работник? А ничего, у него все идет по плану.
-
Хартбит — это утверждения, а продление аренды — доказательство. Выполняя продление аренды, работник совершает ровно ту же операцию, которая нужна для полезной работы: запись в то же хранилище и то же место, куда должен лечь артефакт. Если не вышло — исполнитель автоматически признается пропавшим без вести, и это не требует отдельного сообщения.
-
Маршрутизация ошибки. Начнем с того, что отдельный сервис для получения пульса или расширение данным функционалом гейтвея — это еще одна зависимость, которая может упасть, независимо от хранилища. Давайте посмотрим, что может быть, когда деградирует канал до хранилища. Пульс: исполнители продолжают отчитываться, что все хорошо, а фактическая работа стоит. Аренда: продлиться не может никто, система выглядит мертвой, и это правильное поведение, отражающее реальное положение дел.
Каждый пункт по отдельности выглядит не слишком критичным, а проблемы решаемыми. Но все вместе они заставляют усложнять инфраструктуру и повышают стоимость поддержки, в то время как явных преимуществ минимум. Плюс к тому, пульс не отменяет 90% описанного выше, всю самую сложную логику придется делать в любом случае, если целью является робастная и оптимально расходующая ресурсы и квоты система. Именно поэтому лично я в конечном счете остановился на аренде вместо пульса. Есть и еще одна причина, по которой свидетель является предпочтительным решением, и это логика остановки работника. Но пока не будем забегать вперед, этой теме посвящена следующая глава.
Однако, чтобы не быть слишком категоричным, отмечу, что есть ситуации, в которых пульс выигрывает — и, в частности, это скорость реакции. Если вам требуется узнавать о проблеме за доли секунды, то аренда проигрывает всухую, поскольку ее протухание обнаруживается не раньше, чем истечет ее срок. К моей задаче это не относилось. Если бы было иначе, я бы задумался над тем, чтобы держать оба, однако источником истины все равно оставил свидетеля. Почему — будет объяснено в следующей главе. Так же на решение могла бы повлиять инфраструктура, будь речь о геораспределенном кластере на пять‑шесть девяток, который мне не нужно обслуживать в одну каску, а не аренде домашнего «ПеКа» у Феди из Малых Колокольчиков или Джона с фермы в глубинке Техаса — все могло сложиться иначе, но вряд ли проще.
Зеркальная проблема
К этому моменту вы наверняка заметили одно критичное упущение: мы научились не порождать (почти) новых близнецов, но что делать с уже имеющимися? Или, что еще интереснее, объявили мертвым все еще живой процесс. Как и было заявлено, задача полностью исключить такую возможность не имеет решения в заданных условиях, а следовательно нужно придумать, как справляться с последствиями. Риск получить двойную запись все еще вполне реален, хотя и минимизирован.
Ошибка одна, а направления два, но лечатся они одинаково: не угадыванием состояния, а требованием улики. Свидетельство жизни в одну сторону, и свидетельство смерти в обратную. И обе они о том, что молчание фактом не является. Первый вариант мы уже разобрали, остался второй: чтобы отдать чужое место, необходимо точно знать, что предыдущий работник перестал писать, либо результаты его работы не способны повлиять на конечный артефакт. То есть мы вынуждены каким‑то образом остановить процесс на машине, прямого доступа к которой не имеем.
Как надежно остановить процесс, до которого не дотянуться?
Задача выглядит простой ровно до момента, когда садишься ее решать. Вот есть запущенный процесс на какой‑то левой машине, а то и вообще в неуправляемом блокноте, и нам нужно, чтобы горшочек варить перестал. Подчеркиваю, не «попросить» и не «скорее всего остановить», а дать гарантию того, что он больше не влияет на результат дабы с чистой совестью отдать его место другому.
Тут снова должна была быть шутка про «никак», но я ее уже использовал, а ничего лучше в три часа ночи не придумывается. Так что давайте сразу проговорим ограничение, поскольку дальше я буду делать еще более громкие заявления: надежно убить процесс мы не можем. В блокноте — потому что нечем, ну просто физически нет даже подобия нужной ручки. На спотовой машине — потому что ее могут забрать еще до того, как она получит наш сигнал. В случае облачной функции или пода — потому что… А нет, тут можем… Но даже кнопка гарантированной остановки не решает проблему, потому что «остановка» и «безопасная остановка» — разные вещи, и я вам это докажу.
Данная проблема — не косяк площадок, а свойство ситуации, с которым придется жить. Любая абстракция, которая попытается делать вид, что остановка гарантирована, будет нагло и бессовестно врать — да еще как правдоподобно! Единственное гарантированное решение — полный отказ от неподконтрольной инфраструктуры.
А может, пусть живет?
И правда, для чего же нам вообще нужна остановка? Давайте попробуем зайти с другой стороны, сформулировав цели, а не средства:
-
Исключить химерные артефакты. Эта цель наиболее важна, она про безопасность и идемпотентность, возможность продолжить задачу силами нового работника.
-
Вернуть ресурс. Прекратить платить, освободить слот. Важно, но не критично.
Начнем с рассмотренных ранее химер. Даже гарантированная остановка здесь не поможет, и не из‑за ее отсутствия. Представим, будто идеальная кнопка все‑таки существует, и была нажата. Что мы узнали? Процесса больше нет. На этом все. Мы так и не выяснили, была ли доставлена в хранилище его последняя запись, отправленная за миг до смерти. То есть даже такая замечательная кнопка не может дать нам то, ради чего мы ее так хотели — гарантию консистентности, читай ту самую вышеупомянутую «безопасную остановку», это неправильный инструмент по смыслу, а не слабость площадок. И этот вывод намного полезнее, чем «площадки не дают убить процесс», поскольку не может быть опровергнут указанием на площадку, которая дает.
Для записи проблема решается тривиальным и очевидным способом: каждая попытка должна получить монотонно растущий номер, а все записи адресоваться с ее уникальным префиксом. Тогда даже самый упрямый зомби первой попытки физически не сможет повлиять на артефакты второй, что полностью устраняет эффект химеры. Строгий инвариант в данном случае будет звучать так: никакие два писателя не адресуют один ключ.
А вот с ограничением чтения придется повозиться чуть дольше… Что произойдет, если списанный исполнитель прямо сейчас дописывает свой снимок, а новый в это же время читает каталоги, чтобы понять, с какого места ему продолжить? Он увидит объект. Объект существует сразу с момента создания, а не с момента записи последнего байта. Так артефакт может быть вдобавок еще и не один, в реальной системе их несколько: веса, отчеты оптимизатора, метаданные модели, манифесты и много чего еще. Для простоты здесь и далее я буду использовать общее слово «снимок», подразумевая весь комплекс артефактов, порождаемых одной попыткой.
Продолжать с недописанного снимка — это в лучшем случае падение, в худшем — мусор в данных и искажение результатов. И это половина беды, вот вторая:
Попытка дошла до вехи
и сохранила ее, где‑то в этот момент она была признана умершей и списана. Попытка
продолжила с вехи
. В то же время зомби
продолжил работу и успешно записал в своем индексе снимок для
. Теперь
умирает, на его место приходит
, выполняет перечисление, находит завершенную
в префиксе
и продолжает с нее.
Формально, порчи данных тут нет, но есть другая проблема: разрушение структуры процесса как строгой последовательности операций, наше низложение работника оказывается пустым заявлением. Мы объявили, что данная попытка больше не может говорить от имени задачи, а результат, возникший позже отсечки, все равно стал точкой продолжения. А если предположить, что низложение исполнителя произошло не из‑за прямого исключения, а по косвенным признакам: площадка залагала, странные предупреждения в логах, разваливающиеся по какой‑то причине метрики? Тогда все еще хуже.
Оба варианта имеют общий корень — состояние выведено из того, что оказалось в каталоге, а там может быть любой мусор. Кажется очевидным: ну давайте уже запишем куда‑нибудь, откуда продолжать, да и дело с концом! Все верно, именно так мы и поступим. Но тут крайне важно понимать, что последняя попытка и точка возобновления — это абсолютно разные вещи, что следует из примера выше. Если индекс последней попытки известен диспатчеру и мог бы выступать параметром при отправке новой, то вот индекс последней консистентной он не знает. Данной информацией обладает только работник, удовлетворяющий двум условиям:
-
Работник успешно получил подтверждение от хранилища о завершении загрузки.
-
В момент получения подтверждение, а точнее сразу после него, работник все еще владел арендой.
Отсюда вывод: указатель на точку продолжения должен являться частью объекта свидетеля. Помните, в подбивке про пульс я говорил, что совсем без свидетеля все равно не обойтись? Вот именно по этой причине. А алгоритм работы исполнителя получается такой:
-
Досчитав веху, пишем снимок в персистентное хранилище.
-
Дожидаемся подтверждения от хранилища, что запись успешно завершена.
-
Выполняем условное (с подтверждением владения) обновление свидетеля, записывая указатель на новую точку продолжения. Условность все так же можно реализовать через версии объекта или заголовки.
А теперь смотрите, одним единственным решением мы сняли все беспокоившие нас проблемы разом:
-
Недописанный снимок невидим. Поскольку читатель смотрит только на указатель, а он смещается исключительно после успешной записи, никакие промежуточные состояния не влияют на результат.
-
Низложенный работник никогда не станет предком для новой попытки. Сместить указатель может только владелец аренды и никто другой. Вопрос гонок и «недобитков» снимается.
-
Отдельная механика остановки больше не требуется. Отобранная у работника аренда сама по себе является сигналом. Если исполнитель не смог ее продлить по любой причине, то сам завершает работу, чтобы перестать тратить квоты.
-
Ошибка при объявлении работника мертвым становится дешевле. Все еще есть небольшая вероятность, что завис сам хост, из‑за чего агент может не остановиться по сигналу, а площадка продолжит считать его активным. Но этот сценарий крайне маловероятен. Во‑первых, мы максимально перестраховались и сделали все возможное со своей стороны; во‑вторых, площадки как правило отслеживают статус машин, которые сдают в аренду, и при потере пульса перестанут списывать лимиты или деньги.
-
Ошибка безопасна. Вот тут неочевидный нюанс: раньше мы рисковали не переплатой, а работой. Это абсолютно разные категории: при плохом раскладе пришлось бы не только заново оплачивать выполнение задачи с нуля, но и смириться с фактом потери всех данных. В чем разница? А вот в чем: раз вы вообще решили запустить задачу, значит ее результат был для вас важнее стоимости часов. Теперь же все риски сводятся к небольшому перерасходу на зомби, с чем вполне можно смириться.
Один честный нюанс: как вы заметили, наше хранилище окончательно стало центральной осью всей системы. Я уже апеллировал к данной формулировке, но повторюсь: это правильное поведение, поскольку именно в хранилище и падают все результаты работы. Если оно недоступно, то и вся остальная инфраструктура полностью теряет смысл своего существования, какой бы красивой она ни была. Соответственно, это тот компонент, на котором категорически нельзя экономить! Лично я, даже имея два независимых канала подключения к сети из дома, и при наличии собственного NAS, отказался от идеи держать хранилище локально. Аренда S3 у одного из крупных международных хостеров с геораспределенными ЦОД’ами обходится совсем недорого. Да и держать там всю историю никто не заставляет: устаревшие или архивные артефакты можно перенести куда душа пожелает. Но непосредственно взаимодействие площадок с вашей системой должно быть реализовано через максимально надежный канал.
Подведем черту. Реализовав данный механизм, мы не изобрели невозможное, мы переставили границу принятия решения так, что ограничения не стало. Обернитесь назад, по‑сути, в каждой главе мы делали одно и то же — смещали контроль на свою сторону, но применяли этот подход для разных проблем. Если задача не имеет решения — есть повод задуматься, а ту ли задачу вы вообще пытаетесь решить. И это ключевой вывод не только текущей главы, но и всех ей предшествующих.
Теперь вся механика полностью подконтрольна и не зависит от посторонних. Стало абсолютно безразлично, где именно запущен агент, и что собой представляет его обертка — под, блокнот, облачную функцию. Обязательными ручками остаются лишь запуск задачи и освобождение ресурса.
Разные исполнители, одна абстракция
Давайте выясним, что вообще нужно диспатчеру, и начнем не с площадок, а с себя. Выпишем список глаголов, пока что без оглядки, кто и как будет их обеспечивать. Список легко выводится из предшествующих глав:
-
launch(jobs) → Receipt — безопасно запустить запустить задачи, не порождая дубли.
-
status(job) → JobState — узнать, что с задачей сейчас.
-
events(job, offset) → Slice — получить события или метрики, генерируемые работником.
-
log(job, offset) → Slice — логи работника. Де‑факто частный случай events, но нагляднее вынести в отдельную операцию.
-
revoke(job) → Revocation — отозвать аренду.
-
release(receipt) → Settlement — попытаться остановить сам процесс.
Шесть глаголов. Это минимальный и достаточный набор действий, которыми оперирует диспатчер и то, что является абстракцией среды исполнения.
Стоит оговорить, что последние два — revoke и release — выглядят как одно действие с разными формулировками. Но это не так, каждая операция является действием над разными объектами, а что еще важнее — имеет разных исполнителей. Эта деталь будет рассмотрена ниже.
Ищем крайнего
Возникает соблазн выложить рядом пачку площадок, посмотреть, какие у них отличия, и обобщить остальное. Однако, это было бы большой ошибкой. Во‑первых, необходимо определить: а провайдера‑то мы вообще о чем спрашиваем? Во‑вторых, сегодня у вас три площадки с определенным набором отличий, завтра уже пять, и вот абстракция сломалась. Итак:
|
Глагол |
Ответственный |
|
launch |
Площадка, и только она, тут выбора нет. |
|
status |
Аренда в хранилище (главы 3 и 4). |
|
events |
Часть снимка в хранилище. |
|
log |
Там же. |
|
revoke |
Отзыв аренды, отвечаем мы сами. |
|
release |
Площадка. Счета выставляет именно она, однако «убивать нечего» — полноправный ответ, который должен быть учтен. |
В итоге, четыре операции из шести имеют одну единственную общую реализацию, которая у нас уже есть, полиморфны только две. И это — важнейший вывод: хороший интерфейс к чужой системе — это обобщение остатка после разделения обязанностей. Сначала забираем то, что не может гарантировать сторонний сервис, потом генерализуем оставшееся.
С абстракцией среды исполнения разобрались, теперь нужно определиться с контрактом площадки. В нем потребуются два глагола и два свойства:
-
launch(jobs) → Receipt — запустить и вернуть квитанцию.
-
release(receipt) → Settlement — закрыть счет запуска, рассказать, чем все закончилось.
-
job_kinds — допустимые типы задач. Что конкретная площадка может взять в работу: обучение, бэктест, формирование датасета и так далее. Раньше мы не касались этого вопроса, рассматривая отдельно взятый случай потока обучения, однако это не единственный возможный сценарий.
-
capacity(cfg) — сколько задач можно взять в один запуск. Вот тут важный момент: некоторые площадки выдают мощность пачками. Те же две T4 у Kaggle. Варианта взять одну просто нет, они идут парой. Плюс, зачастую дешевле взять одну машину на четыре GPU, чем четыре по одной. И загружать их нужно полностью, чтобы избежать переплат. cfg — конфигурация конкретного запуска.
Интерфейс среды исполнения
from __future__ import annotationsfrom collections.abc import Mapping, Sequencefrom dataclasses import dataclassfrom datetime import datetimefrom typing import ClassVar, Protocoltype JobId = strtype JobKind = strclass Execution(Protocol): """Всё, что диспатчер умеет делать с задачей.""" async def launch(self, jobs: Sequence[Job]) -> Receipt: """Безопасно запустить задачи, не порождая дубли.""" async def status(self, job: JobId) -> JobState: """Узнать, что с задачей сейчас.""" async def events(self, job: JobId, offset: int) -> Slice: """Получить события или метрики, генерируемые работником.""" async def log(self, job: JobId, offset: int) -> Slice: """Получить логи работника.""" async def revoke(self, job: JobId) -> Revocation: """Отозвать аренду.""" async def release(self, receipt: Receipt) -> Settlement: """Попробовать остановить сам процесс."""
Интерфейс площадки
class Runner(Protocol): """Контракт площадки-исполнителя.""" #: Виды работ, которые площадка принимает. JOB_KINDS: ClassVar[frozenset[JobKind]] @classmethod def capacity(cls, config: Mapping[str, object]) -> int: """Сколько задач можно взять в один запуск.""" async def launch(self, jobs: Sequence[Job]) -> Receipt: """Запустить задачи и вернуть квитанцию.""" async def release(self, receipt: Receipt) -> Settlement: """Закрыть счет запуска, рассказать, чем все закончилось."""
Обратите внимание: launch принимает список задач, а release — квитанцию. Это разные сущности. При вызове release мы просим освободить купленный ресурс, а не остановить задачу.
Разберем модели детальнее
Начнем с самого Job. Задача должна определять, что и где запустить, но не как это сделать.
Модель задания
@dataclass(frozen=True, slots=True)class Environment: """Отпечаток системы.""" gpu_model: str # Чем карта представилась драйверу driver_version: str # Версия драйвера cuda_version: str # Версия CUDA packages: Mapping[str, str] # Версии критических библиотек@dataclass(frozen=True, slots=True)class Job: """Одна задача: что считать, где её рабочая область, в чём она обязана поехать.""" job_id: JobId workspace: str # Префикс задачи в хранилище gpu: str # Тип карты requires: Environment | None # Отпечаток системы payload: Mapping[str, object] # Описание работы
payload — это набор параметров, необходимых для старта задачи. Я упростил его до Mapping[str, object], чтобы не лезть в детали конкретно моей реализации. Разумеется, реальные типы будут зависеть от специфики конкретного проекта. Он никак не обрабатывается площадкой, а лишь передается работнику. Тут интереснее взглянуть на Environment. Это тот самый отпечаток, который мы собирали во второй главе, необходимый для обеспечения воспроизводимости. None как раз про «предупреждать, а не запрещать». И не забывайте: расхождение отпечатка — это ошибка, которая должна порождаться работником, а не диспатчером. Во‑первых, в общем случае площадка не предоставляет достаточного объема данных; во‑вторых, данные от площадки — это обещание, а не факт.
Квитанция
@dataclass(frozen=True, slots=True)class Receipt: """Факты о запуске.""" jobs: tuple[JobId, ...] # Список задач в запуске provider: str # Идентификатор площадки gpu_class: str # Карта по заявлению площадки started_at: datetime # Момент старта деплоя
Обратите внимание на одно белое пятно: тут нет ничего про управление финансами, несмотря на название. Изначально у меня был план включить в статью и сверку биллинга, но эта задача гораздо сложнее, чем кажется. И без того длинная статья увеличилась бы до совсем неприличных размеров. И ключевая проблема даже не в том, что многие площадки просто не предоставляют финансовую информацию в своих SDK, с этим можно жить. Ключевая сложность — отсутствие единой валюты. У Vast или Modal — это доллары в час, у Lightning AI — кредиты. А Kaggle — вообще некоммерческая платформа. Но тем интереснее, что недельная квота — это тоже валюта, просто неочевидная. Решить данную дилему с использованием математики реально, но не в рамках одной главы.
Далее ряд простых DTO (Data Transfer Object), объяснять в них особо нечего.
Срез данных для метрик или логов
@dataclass(frozen=True, slots=True)class Slice: """Срез растущего объекта.""" data: bytes next: int # Оффсет для следующего чтения eof: bool # Флаг окончания файла
Статус задачи
@dataclass(frozen=True, slots=True)class Running: """Аренда действительна."""@dataclass(frozen=True, slots=True)class Vacant: """Аренда истекла."""@dataclass(frozen=True, slots=True)class Finished: """Работник завершил свою работу и опубликовал результат.""" exit_code: int@dataclass(frozen=True, slots=True)class Unknown: """Не удалось получить текущий статус аренды.""" reason: strtype JobState = Running | Vacant | Finished | Unknown
Статусы отзыва аренды
@dataclass(frozen=True, slots=True)class Revoked: """Аренда отозвана."""@dataclass(frozen=True, slots=True)class Renewed: """Во время отзыва объект аренды был перезаписан."""@dataclass(frozen=True, slots=True)class Unwritten: """Не удалось записать отзыв."""type Revocation = Revoked | Renewed | Unwritten
Статус запроса закрытия счета
@dataclass(frozen=True, slots=True)class Closed: """Счёт закрыт. Ресурс был гарантировано уничтожен или уничтожать нечего."""@dataclass(frozen=True, slots=True)class Pending: """Требование закрытия было отправлено, но подтверждение не получено, может потребоваться повтор."""@dataclass(frozen=True, slots=True)class Bounded: """Нет возможности повлиять на площадку."""type Settlement = Closed | Pending | Bounded
Присмотревшись к трем последним перечислениям можно увидеть, что каждое из них имеет ветку «не знаю». Это следствие первых четырех глав, мы уже доказали, что гарантии существовать не может, значит система должна иметь право честно объявить неизвестность вместо выбора одного из вероятных, но не гарантированных исходов. Мы защитили себя от перерасхода с другой стороны, не здесь.
В целом, большая часть абстракции уже готова, остается лишь разобрать две платформозависимых операции — запуск и полную остановку.
Как запустить? Как остановить?
Как уже было мимоходом заявлено, одна машина не соответствует ровно одной задаче. Абсолютно нормальным сценарием является наличие нескольких независимых задач на одной машине. Тут важно разделить ответственности: плагин конкретной площадки может и должен декларировать вместимость одного слота при заданной конфигурации, но решение о распределении задач по предоставленным мощностям принимает ядро системы. Потому что графом выполнения задач владеет именно ядро, и только оно знает, будет ли вообще следующая задача или нет. Попытки делегировать эту логику дальше не только будут явным просачиванием, но и несут риски разрушения графа при отказах. Также нужно принять два ограничения:
-
Падение на старте одной задачи обозначает падение всех. Сценарий нечастый и, как правило, связанный исключительно с ошибками в деплое, но он есть.
-
Эффективный расход квот — это не только запуск
задач на
карт. Чтобы система работала оптимально желательно еще и распределять на несколько карт в одной аренде те задачи, которые имеют примерно один объем работ.
Классы ошибок из первой главы, теперь кодом
class LaunchFailed(Exception): """Базовый класс отказов запуска."""class NoSlots(LaunchFailed): """Свободных мест нет."""class Unreachable(LaunchFailed): """Площадка недоступна."""class Rejected(LaunchFailed): """Площадка отказала в запуске."""class ProtocolError(LaunchFailed): """Ответ площадки нарушает контракт."""class SpawnedElsewhere(NoSlots): """Задача уже принята другим исполнителем."""
Единственным нюансом, который стоит явно проговорить, является идемпотентность. Полагаться в этом вопросе на площадку опять же нельзя, поскольку в общем случае она не предоставляет никаких уникальных идентификаторов запуска. Да, многие предоставляют, я знаю, но многие — это не все, а обобщать не гарантированный механизм — плохая идея. Хорошая новость в том, что этот идентификатор и не требуется, вся инфраструктура уже готова. Любой захват аренды — это условная операция. Даже если случайно будет запущен дубль, он сам остановится, не найдя себе доступного жилья.
К revoke относится все то же самое. Он расположен на нашей стороне.
-
Revoked — слот можно отдавать сразу.
-
Renewed — кто‑то продлил аренду после того, как мы запросили остановку. Повторяем отзыв, либо отменяем, если нас устраивает, что работник все еще жив.
-
Unwritten — отдавать слот ни в коем случае нельзя! Ждем, пока хранилище сможет нам ответить.
А вот с release есть вопросы. Я долгое время вообще сомневался, а стоит ли добавлять его в контракт. В итоге пошел на компромисс: добавил вместе с отдельным вариантом ответа, что инструментарий недоступен. Сделка с совестью обосновывалась на том, что это будет «большая красная кнопка» на самый крайний случай, а вызов будет выполняться только оператором с явным выводом предупреждения на UI. И этот случай так и не наступил!!! Почти за десять тысяч часов машинного времени жесткая остановка так ни разу и не понадобилась. Нет, дело не в том, что у меня ничто никогда не зависало, но в реальности все ситуации сводились к одному из двух сценариев:
-
Либо работник «жив достаточно», чтобы увидеть просроченную аренду и остановиться самому.
-
Либо хост завис на столько, что даже арендная площадка теряла его из вида и переставала тарифицировать.
Разумеется, между «не случалось» и «не случится» — пропасть. Выбор полностью за вами, я лишь могу поделиться своей статистикой.
Три слоя, а не два
Различия между площадками, которые мы вычли из контракта, на деле никуда не пропали. Они переехали внутрь имплементации. И с ними там могут возникнуть некоторые проблемы, поскольку естественное желание реализовать два слоя (контракт и плагинов) не работает. И симптом легко проявляется количественно: если у контракта N реализаций, а общего кода в нем на порядок меньше, чем частного — скорее всего было пропущено промежуточное семейство. Давайте посмотрим на примере:
-
Свой собственный процесс. Окружение наше, код наш, доставлять нечего.
-
Чужой рантайм. Сюда попадают Modal, Kaggle и им подобные. Окружение площадка собирает сама. Modal — по нашему же lock’у, Kaggle — выдает свое ядро. Мы отдаем код и параметры, подом не управляем.
-
Чистый контейнер. Здесь различные Lightning, RunPod, Vast. Они просто выдают голый Docker образ, «а дальше как‑нибудь сам».
И вот, например, у третьей группы «дальше сам» — это одна и та же последовательность шагов: собрать колеса, опубликовать, создать временную ссылку, разложить переменные. Все это легко уезжает в общий код. Да, Америку я не открыл, про DRY (Don’t Repeat Yourself) знают все, но могу поделиться тем, что в данном конкретном случае для пяти площадок удалось порезать код более чем на треть. Просто не забывайте искать общее при добавлении новых провайдеров.
Что в итоге?
Несколько площадок без единого общего понятия в API, три способа довезти код, разные модели биллинга — и всего два уровня абстракции:
-
Интерфейс исполнения — шесть глаголов, и уже не изменится, поскольку диктуется задачей, а не площадками.
-
Контракт площадки — два глагола и две декларации.
Две трети интерфейса имеют одну общую имплементацию на все площадки, и именно это, как я считаю — настоящий результат главы. Не «мы написали крутую абстракцию», а «мы максимально сократили область, где эта абстракция вообще нужна». И в эти две трети даже попала остановка задачи — операция, на которую потратили целую главу. Она никуда не делась из интерфейса, просто переехала туда, где ее не ожидали.
Цена за новую площадку — модуль регистрации, модель конфига, обернуть пару вызовов в SDK, переиспользовать один из методов деплоя (четвёртого, вроде как, и не существует). Ядро системы не правится, диспатчер ничего не знает о новом провайдере. И это по своему определению главный критерий, по которому абстракцию можно признать удавшейся: добавление нового частного не меняет общее.
Но главный вывод не про емкость абстракции, а про то, откуда она вообще взялась. Он проявился сам, как остаток после трех глав исключения:
-
Одна запретила площадке оценивать саму себя.
-
Другая определять статус жизни работника.
-
Третья отобрала право его останавливать и заявлять об освобождении слота.
Единственный реальный остаток — биллинг.
И финальным тезисом всей статьи является один вывод: когда что‑то «нельзя сделать надежно», решение находится не в попытке выбрать «самое надежное из ненадежного», а в переносе границы принятия решения в подконтрольную область.
Практическая польза от реализации всего вышесказанного:
-
Появилась возможность эффективно тратить бесплатные лимиты, не следя за ними вручную. Закончились — ничего страшного, задача переедет с минимальными потерями.
-
Нет необходимости вручную контролировать и перезапускать задачи. Можно построить один большой план выполнения и уехать на выходные.
-
Задача не оборвется посреди выполнения, она точно дойдет до конца.
-
Появилась гибкость в выборе площадок. Больше не надо подстраиваться под одну конкретную.
-
Сократились затраты на поддержку. Большая часть кода переиспользуется и не требует корректировок.
ссылка на оригинал статьи https://habr.com/ru/articles/1073954/