Привет! Меня зовут Ольга Зайкова, в Яндексе я руковожу группой автоматической генерации рекламы. Сегодня расскажу о соединении тяжёлых процессингов и GPU‑вычислений. Обсудим, как мы реализовали высоконагруженный процессинг, который обрабатывает миллиарды товаров и превращает их в объявления, используя тяжёлые модели, такие как YandexGPT, DSSM, CatBoost и другие. И, конечно, не обойду стороной тему проблем с нагрузкой: они возникали почти на каждом шагу.

Что такое автоматическая генерация рекламы

Представьте, что вы владелец крупного бизнеса, к примеру, сайта, на котором представлено множество товаров, и вы хотите их рекламировать. Как это сделать? Можно зайти в Яндекс Директ и создать рекламу руками: ввести заголовок, текст, который будет отображаться в поиске, добавить картинку, запустить кампанию и увидеть своё объявление на просторах интернета. Но если у вас огромное количество товаров, то, наверное, никаких маркетологов в мире не хватит, чтобы вручную завести такую кампанию.

Чтобы ускорить и упростить процесс, вы можете принести нам описания товаров в так называемых фидах — специальных файлах. Обычно их можно выгрузить с помощью специальных модулей на сайтах. А если такого модуля нет, нам хватит и вашего домена. Мы сами с помощью ML‑алгоритмов обойдём сайт, поймём, какие сущности на нём похожи на товары, и отправим их на генерацию. Для генерации в Директе используется стриминговая контент‑система на технологиях BigRT поверх базы данных YTsaurus, но на её месте можно представить Flink, Spark или другой привычный вам инструмент.

Чтобы реклама появилась, нужны два фактора. Во‑первых, должен быть активный товар — вещь в наличии на сайте. Во‑вторых, должна быть задача на генерацию объявления. Если товаров много, наверняка вы не хотите рекламировать их вместе по одной и той же ставке, в одном регионе, на одну и ту же аудиторию, а хотите разбивать товары на группы. Директ предоставляет такую возможность: объявления в кампании можно группировать, и для каждой группы задавать настройки. Из этих группировок получается задача на генерацию для множества товаров с определённым фильтром и настройками. Мы обращаемся к хранилищу за задачами на генерацию и указываем в них атрибуты, которые в дальнейшем прорастают в рекламное объявление. Это может быть, например, информация о доставке.

Вообще, прежде чем получается рекламное объявление, творится много магии. Самая технологичная часть генерации — подбор заголовка, или тайтла. Именно его вы видите вверху крупным планом на странице Поиска. Но мы также генерируем и «тело» объявления.

Как подходы к генерации развивались со временем 

В далёком 2021 году у нас была шаблонная генерация без ранжирования: мы просто выбирали подходящие по длине заголовки. Потом, конечно, захотелось ранжировать варианты и научиться выбирать лучшие. А ещё чуть позже случился бум GPT‑нейросетей, и мир стал другим. Нам пришлось совершить множество изменений в инфраструктуре, чтобы заголовки генерировались нейросетями. А теперь мы боремся за повышение качества, поэтому внедряем тяжёлые BERT в ранжирование, чтобы сделать его ещё лучше.

Посмотрим, как мир выглядел до 2021 года. Допустим, у вас есть холодильник за 50 тысяч рублей. У нас в процессинге был категоризатор, который понимал, куда отнести этот товар: определял, одежда это, техника или что‑то ещё. Для каждой категории существовал заранее заготовленный набор шаблонов, отсортированных по популярности с помощью асессоров. Мы ничего не ранжировали, а просто брали первый подходящий по длине шаблон. Такая генерация почти не требует CPU, у неё будет достаточно корректный заголовок. Главный минус — выглядит это весьма скучно:

  • холодильник за 50 тысяч рублей;

  • холодильник в наличии;

  • выгодно купите холодильник.

Поэтому идея ранжирования родилась сама собой. Когда мы заказали у команды ML‑генерации модель, которая будет ранжировать заголовки, они принесли нам BERT. Это тяжёлая модель, которая должна считаться на GPU. Мы тут же подумали про real‑time‑процессинг, где происходят десятки тысяч событий в секунду. Не будем же мы на каждое событие ходить в BERT? Тогда мы не умели решать задачу дружбы real‑time‑процессинга и походов во внешние системы, поэтому попросили сделать что‑то полегче, что можно считать на CPU‑процессинге. Нам предложили DSSM плюс CatBoost, который весил всего 3 ГБ и считался наравне с нашей продуктовой логикой. То есть на тех же ресурсах, в тех же машинах.

А потом пришло время YandexGPT и больших инфраструктурных внедрений. Здесь уже не попросишь принести легковесную модель, которая считается на CPU.

Давайте подумаем, как объяснить нейросети, что перед ней находится. Здесь вы видите пример товара, сериализованного в JSON. Это Hyundai Santa Fe. С помощью алгоритма генерации подводки нейросеть уже может разобраться, что перед ней машина. Это возможно благодаря полям «вендор», «год» и modification ID. Если бы рекламировали, к примеру, джинсы, вендора бы точно не было, да и год выпуска вряд ли бы кого‑то заинтересовал.

Теперь подумаем, как сделать, чтобы процессинг «узнал» о нейросетевых заголовках. Первый подход мы хотели реализовать как можно проще, чтобы понять, есть ли деньги, конверсии и счастье пользователей за этим внедрением. В группе ML‑генерации уже был процесс, строящийся поверх MapReduce, который вычитывал баннеры, где‑то в пуле обучения прогонял всё через GPU и получал заголовки, которые записывались в таблицу. Мы решили, что если наш real‑time‑процессинг уже умеет читать товары из очереди, то пусть научится читать и таблицу.

Пайплайн обработки выглядел следующим образом. Мы брали батч товаров, генерировали им заголовки шаблонами и получали рекламные объявления. Объявления сохранялись, когда‑то в будущем их подхватывал процесс офлайн‑генерации, обходил своим MapReduce и создавал альтернативный заголовок. В наш процессинг приходил альтернативный заголовок, мы с помощью DSSM и CatBoost сравнивали его с шаблонным, выбирали лучший и ставили на баннер.

Чем плох такой подход? Тем, что заголовки приходят не в реальном времени. Но при этом нас удивило, насколько они конверсионные. Так мы поняли, что за GPT‑нейросетями действительно будущее. Давайте посмотрим на пример, как выглядели рекламные объявления до появления YandexGPT и после.

Нейросеть смогла вынести всеми любимую киновселенную героев MARVEL на передний план, что наверняка привлечёт внимание, а также сразу обозначила важные атрибуты товара: диаметр шариков — 12 дюймов и их количество в коробке — 5 штук. А уже внизу в описании добавила про большой выбор и выгодные цены, что также привлекает пользователей.

На случаи, когда нейросеть ошиблась и сделала с товаром что‑то некорректное, есть отдельные типы событий, которые мы умеем подкладывать в процессинг, — они блокируют нейросетевые заголовки. Но даже отлаженно работающие системы иногда пропускают ошибки.

Как только мы увидели, что YandexGPT увеличивает конверсионность, было решено перейти в мир онлайн‑генерации. То есть как только приходит товар, мы находим его задачу на генерацию, идём в сервис инференса нейросети и запрашиваем заголовок.

Но была загвоздка — мало GPU‑карт на поток. Дело в том, что в рекламе есть свой пул GPU, на которых мы обучаемся и строим runtime‑процессы, но бо́льшая часть ресурсов уходит на подсчёт эмбеддингов. Эмбеддинг — это векторное представление некоторой сущности. Такой сущностью может быть как поисковый запрос, так и профиль пользователя. Эмбеддинги нужны, чтобы делать отбор рекламы для пользователя в РСЯ (Рекламная сеть Яндекса) и под запрос на Поиске.

Также есть пул карт, на которых проводятся эксперименты, и именно в нём мы запускали офлайн‑генерацию. Естественно, он ограничен, поэтому офлайн‑генерация работала медленно. При этом мы хотели уложиться в наименьшее количество GPU‑карт, потому что ресурс дорогой и важно, чтобы железо на внедрение досталось всем.

Представим, как может выглядеть сервис. У нас есть real‑time‑процессинг, который обрабатывает товары. Он получает несколько шаблонных заголовков и идёт в сервис нейрогенерации. Сервис отвечает, нейросетевой заголовок сравнивается с шаблонами посредством ранжирования, выбирается лучшая версия и крепится на баннер. Неоспоримый плюс — мы мгновенно получаем нейросетевого кандидата для новых товаров и при этом не вредим архитектуре, так как не заливаем внешние таблицы в процессинг. А главный минус — capacity сервиса. Он обрабатывает лишь 10 тысяч объявлений в секунду. Всего на процессинг льётся порядка 70К RPS в спокойном режиме, когда не приходят большие заливки, но о них мы поговорим чуть позднее.

Первый подход к оптимизации нагрузки

Как жить, если данных в процессинге много, а capacity мало? Для начала нужно разобраться, какие события мы обрабатываем:

  • Обновление рекламных данных.

  • События включения и выключения рекламных задач на генерацию: если вы решили остановить кампанию, к нам придёт запрос на выключение нужных баннеров, который мы мгновенно должны обработать.

  • Переобход всего стейта с объявлениями.

Казалось бы, именно в последнем действии возникает лишняя нагрузка на процессинг: зачем переобходить эти 4,5 млрд? Представим, что у нас есть потоковая обработка, и в ней мы допустили баг. Естественно, некоторое количество баннеров будет испорчено. После этого мы находим баг и фиксим его, а баннеры с ошибками по какой‑то причине найти не можем. К примеру, нейросеть испортила часть заголовков и нет явного признака для нахождения ошибок генерации. Эту нейросеть мы выкатили на короткий промежуток времени и отловили ошибку с помощью разметки, а теперь нужно починить стейт. Как раз в этом помогает переобход. То есть каждый баннер, который мы сгенерировали, раз в двое суток попадает в систему заново на перегенерацию. Таким образом, если процессинг восстановлен, восстановятся и сами рекламные объявления. Стоит задуматься: к чему здесь рассказ о переобходе и починке процессинга? Потому что именно здесь — ключ к несоответствию нагрузки на процессинг и сервис.

Если обращаться к нейросети только для переобхода, на 4,5 млрд объявлений уйдёт несколько дней. Это долго, и вот что мы сделали, чтобы улучшить ситуацию:

  • Отбросили незначительные изменения в процессинге. Если, например, цена не влияет на итоговый формат заголовка и описания, не нужно добавлять её в подводку и учитывать при переобходе.

  • Дали приоритет генерации для новых товаров, потому что когда к нам приходит новый рекламодатель, мы хотим, чтобы он сразу увидел в поиске красивые заголовки и понял, что его реклама конверсионная, соответствует интересам пользователей, а объявления быстро набирают статистику.

  • Добрали в квоту запросов рекламные объявления, для которых перегенерации не было дольше всего. Это ключ к быстрому обновлению нейросетевых заголовков при изменении модели: если модель изменилась, за какое‑то время нужно переобойти всё, что у нас есть, и выдать новый формат.

Архитектурная проблема

Благодаря оптимизациям мы должны были срезать большой поток событий и выровнять нагрузку. Но что‑то пошло не так. Проблема крылась в изменении инфраструктуры realtime‑процессинга. У нас есть шардированная (во многих системах она называется партиционированной) очередь с товарами, которую мы читаем. И сам процессинг обрабатывает одну очередь в один поток, вычитывая ключи из таблицы, обращается в стейт, поднимает профили, обрабатывает, сохраняет и записывает в базу данных результат. Получается, процессинг совершает очень много разнообразной работы, которая по‑разному влияет на ядро.

Посмотрев на всё это, мы решили оптимизировать CPU. Чтение из очереди оставили синхронным, а обработку распараллелили, тем самым распределив нагрузку на ядро более равномерно. Результаты мы соединяли и делали одну операцию записи. За счёт распределения шарда на несколько потоков получились так называемые подшарды.

Если раньше один шард обработки очереди давал один запрос с количеством товаров X на сервис YandexGPT, то теперь у нас было уже N потоков, соответственно и запросов N штук. А вот баннеров в батче стало намного меньше, кратно количеству потоков. Как это повлияло на сервис? Нетрудно догадаться, что из‑за увеличения числа запросов нагрузка выросла. При этом на GPU попадало меньше объектов за раз, потому что батч запросов уменьшился и карта оказалась недозагруженной.

Также выросло использование CPU, потому что ядро только и занималось тем, что брало батч с товарами, распаковывало его, ждало, когда карта всё посчитает, и запаковывало обратно. Время ожидания выросло, так же как и время на парсинг запросов. Наконец система деградировала настолько, что мы не успевали начать в сервисе YandexGPT обработку до момента истечения на клиентском сервисе тайм‑аута. То есть когда мы начинали обработку, realtime‑процессинг уже не ждал ответа. Ситуация невесёлая.

Поиски решения

Ключевая проблема здесь, конечно, — неравномерная загрузка GPU: нужно было оптимизировать распределение объектов по картам. Первая мысль — хочется сделать перебатчевание. Теоретически оно возможно в двух местах:

  • В рамках процессинга. Но, наверное, если мы специально стараемся максимально оптимизировать CPU‑time на процессинге, то так делать не нужно. Мы добавим ещё одно блокирующее ожидание, и процессинг замедлится.

  • На сервисе инференса YandexGPT, который очень мал относительно процессинга. Там всего 22 пода, и запросов в него льётся меньше. Поэтому перебатчевание решили сделать там.

Перед тем как отправлять товары на карту, мы собирали из нескольких запросов один большой батч. Таким образом, мы, с одной стороны, загрузили карту целиком и сделали это равномерно, а с другой — появилась возможность не просаживать latency в процессинге, потому что ребатчинг на инференсе выполняется быстро.

Большие клиенты — большие нагрузки

Мы — реклама и потому очень зависим от объёмов данных рекламодателей. Периодически от них приходят большие пачки обновлений. К примеру, огромная компания решает устроить тотальную распродажу, поменяв все цены, или мы сами переобходим товары: время от времени нужно повторять процесс генерации для всех клиентов. В такие моменты график лага на процессинге (количество событий, которые мы ещё не начали обрабатывать) выглядит так.

Всё шло хорошо, пока не пришёл «большой клиент» — после этого лаг взлетел до небес. Итерации процессинга становятся чаще. Если в одной итерации набор товаров, с которыми мы идём на карту, ограничен, то количество запросов растёт, а сервис инференса не справляется с нагрузкой и падает.

Чтобы решить эту проблему, когда приходит большая заливка, мы создаём две очереди. Первую обрабатываем штатно — это все стандартные изменения и перезаливки. Вторую — отдельную очередь с большими изменениями — читаем с ограниченной скоростью. Так мы не начинаем процессинг для неё сразу, а делаем это постепенно. Может показаться, что при таком подходе рекламодатели заметят замедление системы. Но на самом деле «размазывание» на процессинге занимает буквально несколько минут, и такое решение уже позволяет снизить нагрузку на сервис за счёт более медленного процессинга и сокращения количества походов во внешний сервис. Очередь с товарами больше не вызывает такого сильного дисбаланса на лаг чтения, и это видно на графике.

Теперь поток в системе ровный, а конфигурация процессинга не влияет на инференс. То есть мы разработали алгоритмы, которые равномерно загружают сервис, а также научились масштабировать процессинг, меняя в нём как количество шардов, так и количество подшардов, потоков, машин — чего угодно. Сервис стал независимым.

Переносим ML во внешний мир

А потом мы поняли, что умея сочетать процессинговую генерацию и походы в сервисы моделей, можем унести весь наш ML во внешний мир. У нас есть маленькая DSSM и CatBoost, которые являются дистиллированной версией BERT. Пока мы внедряли YandexGPT, эта модель сильно подросла. Раньше она весила 3 ГБ, а теперь — уже 8. Также у нас работало более тысячи машин только в продакшне.

У команды ML‑генерации были амбициозные планы по развитию. Хотелось внедрять что‑то новое, учить новые модели и растить их. На поддержку экспериментов в процессинге тратилось бы колоссальное количество ресурсов, но к тому моменту у нас уже появился опыт нейрогенерации отдельно от процессинга. Ранжирование мы тоже решили попробовать вынести во внешний мир, чтобы избавиться от репликации моделей по 1000 машин и оптимизировать работу ядра, «поселив» интенсивный инференс отдельно.

После получения батча с товарами мы:

  • идём за задачами на генерацию;

  • под каждую задачу делаем рекламное объявление;

  • подбираем шаблонами заголовки;

  • идём в сервис YandexGPT и получаем нейросетевого кандидата;

  • лучший заголовок выбираем уже не в процессинге, а в сервисе ранжирования;

  • выигравший заголовок крепим на баннер.

Сервис получился, как и YandexGPT, небольшим — всего 50 машин, но он позволил делать очень важные вещи — экономить RAM и гибко масштабироваться. Теперь репликация по машинам процессинга не ×1000, а всего лишь ×50, потому что в этот сервис можно безболезненно доливать ядра в каждый под и RAM, сохраняя репликацию невысокой. Да и на самом внедрении мы уже сэкономили 7,5 ТБ RAM.

После таких приятных результатов мы решили пойти дальше. Вспомним, что модель ранжирования получена путем дистилляции BERT. При этом у нас оставались срезы рекламы, на которых заголовки почему‑то подбирались плохо. Смотришь — очевиден лучший кандидат, но модель ранжирования его не выбирает. Раз для ранжирования уже есть сервис, мы добавили в систему внешний поход в BERT — оригинальную модель, которая выдаёт качество намного лучшее, чем после дистилляции, и добавили её как фактор в текущую модель.

Получилась следующая конфигурация: мы идём в сервис ранжирования, для определённых товаров идём в BERT, который может ответить, а может и не ответить. При этом CatBoost заточен на работу как с ответом BERT, так и без него. Благодаря новому сервису появилось пространство для внедрения практически бесконечного количества внешних вызовов. То есть, помимо BERT, мы можем внедрить ещё и внешний сетевой поход или другую нейросеть, а сервис стал своеобразным прокси, который в конечном итоге отвечает процессингу.

Когда сервис не отвечает…

Если не отвечает сервис нейрогенерации, с этим можно жить: нет нейросетевого кандидата — будет шаблонный. А если не отвечает сервис ранжирования, то провести генерацию невозможно: мы не получим рекламное объявление, не зная, какой заголовок выбрать для баннера. Таким образом, рекламодатель остаётся без продвижения.

Обдумав разные варианты борьбы с проблемой, мы пришли к текущему решению: если сервис ранжирования не ответил, мы завершаем с ошибкой итерацию всего процессинга и повторяем генерацию. Благодаря такому подходу у нас не копится вторая очередь с ошибками и не нужно делать двойную работу — получился своеобразный retry.

Если сервис не ответил разово, например был небольшой пик нагрузки, мы просто повторяем итерацию, и на следующий раз всё работает. Если же сервис по какой‑то причине полностью лёг, то дежурному поступит звонок о том, что сервис пятисотит и копится лаг. Дежурный починит сервис, который заново обработает только те товары, по которым произошли изменения.

Чем наш опыт может быть полезен

Давайте подведём итоги. Мы разработали сервис для инференса, причём как для моделей GPU‑intensive, так и для тех, которые считаются на ядре. Наладили взаимодействие процессинга и сервиса ML‑вычислений. Равномерно распределили нагрузку, придумали алгоритмы переобхода и победили большие заливки, придумав способ их равномерного распределения. То есть мы научились не читать всё сразу, а разбивать данные на кусочки, грамотно утилизируя и наши ресурсы, и внешние сервисы. И наконец, мы сэкономили терабайты RAM благодаря тому, что вынесли модели во внешний мир, а также используем для генерации довольно скромное количество GPU‑карт.

Получается, что связать тяжёлый процессинг и инференс моделей вполне реально, даже когда нагрузка в процессинге превышает возможности вашего сервиса. Также мы узнали, что самовосстановление системы может быть полезным для продуктовой разработки. В случае нейрогенерации оно помогло нам переобходить рекламные объекты, для которых не удалось получить нейросетевого кандидата. Ещё один важный вывод — равномерное распределение нагрузки в момент обработки событий поможет как утилизировать внутренние ресурсы полностью, так и экономить ресурсы во внешних сервисах, не получая даунтайм при наплыве новых клиентов или событий.

По возможности ищите способ не блокироваться о GPU‑вычисления. Например, если нейросетевого заголовка нет, мы используем шаблонный. В итоге получается баннер и продуктовую миссию мы выполняем. В случае с ранжированием мы пока не придумали, как обходить потребность во внешнем сервисе, — оказалось удобнее выдать ошибку и повторить процесс. Но, возможно, похожий кейс есть в ваших сервисах, и вы поделитесь идеями в комментариях. Было бы очень интересно обсудить ваши идеи!

Комментарии (1)


  1. BadDancer
    13.09.2024 13:07

    Т.е, когда на Яндекс рекламе книги с author.today висит явно нейрогенеренная картинка полуодетой дамы, причем к тексту книги эта дама относится чуть менее, чем никак - это не автор (рекламодатель) виноват, это Яндекс.Директ нагаллюцинировал?