Всем привет! Меня зовут Василий Копытов, я руковожу группой разработки рекомендаций Авито. Мы занимаемся системами, предоставляющими пользователю персонализированную рекламу на сайте и в приложениях. На примере нашего основного сервиса я покажу, когда переходить с Python на Go, а когда оставить все как есть. В конце я дам несколько советов по оптимизации сервисов Python.
Как работают рекомендации на Авито
Любой, кто заходит на главную страницу сайта или приложения, видит персонализированную рекламную ленту — рекомендации. Нагрузка на наш основной рекомендательный сервис, отвечающий за генерацию бесконечной ленты объявлений на главной странице, составляет около 200 000 запросов в минуту. Общий трафик до 500 000 запросов в минуту на рекомендации.
Сервис выбирает наиболее подходящие объявления из 130 миллионов активных объявлений (элементов) для каждого пользователя. Рекомендации генерируются на основе каждого действия человека за последний месяц.
Представление работает по следующему алгоритму:
1. Сервис обращается к хранилищу истории пользователя и извлекает из него агрегированную историю действий и интересов.
Интересы — это набор категорий и подкатегорий рекламных объявлений, которые человек недавно просматривал. Например, детская одежда, домашние животные или товары для дома.
2. Затем он передает историю и интересы как набор параметров, воздействие на модели машинного обучения первого уровня.
Модели машинного обучения первого уровня являются базовыми сервисами. Сейчас у нас есть 4 таких модели. Они предсказывают элементы, используя различные алгоритмы машинного обучения. На выходе каждого сервиса получаем список id (рекомендуемых).
3. Мы фильтруем идентификатор на основе истории пользователя. У нас получается около 3000 элементов на пользователя.
4. И самое интересное, что представление внутри использует модель машинного обучения второго уровня, основанную на CatBoost, для ранжирования рекламы из моделей машинного обучения первого уровня в реальном времени.
5. Из данных готовим характеристики. Это параметры ранжирования рекомендаций. Для этого мы используем id элемента для получения данных в хранилище (осколочная база данных 1 ТБ, Redis). Данные товара — название, цена и еще около 50 полей.
6. Сервис передает функции и элементы в модель машинного обучения второго уровня на основе библиотеки CatBoost. Результатом является ранжированная лента объявлений.
7. Далее представление выполняет бизнес-логику. Например, подбирает в ленте те объявления, за премиум размещение которых взимается плата (boost VAS).
8. Кешируем и отдаем пользователю рекомендации сгенерированного фида, в нем около 3000 объявлений.
Почему мы решили переписать движок рекомендаций
Представительство как услуга очень загружено в Авито. Он обрабатывает 200 000 запросов в минуту. Сервис стал таким не сразу: мы постоянно вносили что-то новое и улучшали качество рекомендаций. В какой-то момент он начал потреблять почти столько же ресурсов, сколько и весь остальной монолит Авито. Нам стало сложно выкатывать сервис в дневное время, в часы пик, из-за нехватки ресурсов в кластере — в это время большинство разработчиков развертывали свои сервисы.
Вместе с ростом потребления ресурсов росло и время отклика сервиса. Во время пиковых нагрузок пользователи могли ждать свои рекомендации до 1,6 секунды — это в 8 раз больше, чем за последние 2 года. Все это могло заблокировать дальнейшее развитие и совершенствование рекомендаций.
Причины этого достаточно очевидны:
- Высокая нагрузка, связанная с вводом-выводом. В представлении каждый запрос состоит примерно из 20 сопрограмм — блоков кода, которые выполняются асинхронно при обработке сетевых запросов.
- Нагрузка на ЦП от вычислений в реальном времени по ML-модели, которые полностью заняты ЦП, пока происходит ранжирование рекламы.
- GIL — представление изначально было написано на однопоточном Python. В этом языке программирования невозможно совместить рабочие нагрузки, связанные с вводом-выводом и с привязкой к ЦП, чтобы служба эффективно использовала ресурсы.
Как мы решили проблему с системой рекомендаций
Позвольте мне рассказать вам, что помогло нам справиться с нашими нагрузками в Python.
1. Исполнитель пула процессов
ProcessPoolExecutor создает пул воркеров из процессорных ядер. Каждый воркер — это отдельный процесс, работающий на отдельном ядре. Вы можете передать нагрузку, связанную с ЦП, на рабочий процесс, чтобы он не замедлял другие процессы.
В представлении мы изначально использовали ProcessPoolExecutor для разделения рабочих нагрузок, связанных с ЦП, и рабочих нагрузок, связанных с вводом-выводом. В дополнение к основному процессу Python, который обслуживает запросы и ходит по сети (с привязкой к IO), мы выделили три воркера для ML-модели (с привязкой к CPU).
У нас есть асинхронная служба на aiohttp, которая обслуживает запросы и успешно обрабатывает нагрузку, связанную с вводом-выводом. ProcessPoolExecutor создает пул рабочих процессов. Рабочую нагрузку, связанную с ЦП, можно передать такому рабочему процессу, чтобы он не замедлял основной процесс службы и не влиял на задержку всей службы.
Выигрыш по времени от использования ProcessPoolExecutor составляет около 35%. Для эксперимента мы решили сделать код синхронным и отключили ProcessPoolExecutor. То есть рабочие нагрузки, связанные с вводом-выводом и процессором, начали выполняться в одном процессе.
Как это выглядит в коде:
async def process_request(user_id): # I/O task async with session.post( feature_service_url, json={'user_id': user_id}, ) as resp: features = await resp.json() return features
У нас есть асинхронный обработчик, который обрабатывает запрос. Для тех, кто не знаком с синтаксисом асинхронного ожидания, это ключевые слова, обозначающие точки переключения сопрограммы.
То есть на седьмой строке кода одна сопрограмма уходит в сон и отдает выполнение другой сопрограмме, которая уже получила данные, тем самым экономя процессорное время. Таким образом Python реализует совместную многозадачность.
def predict(features) preprocessed_features = processor.preprocess(featured) return model.infer(preprocessed_features) async def process_request(user_id): # I/O task async with session.post( feature_service_url, json={'user_id': user_id}, ) as resp: features = awat resp.json() # blocking CPUtask return predict(features)
Внезапно нам нужно выполнить загрузку с привязкой к процессору из ML-модели. И так в функции predict наша сопрограмма заблокирует процесс python. Чтобы все сервисные запросы не стояли в очереди и время ответа сервиса не увеличивалось, как мы видели ранее.
executor = concurrent.futures.ProcessPoolExecutor(man_workers=N) def predict(features): preprocessed_features = processor.preprocess(features) return model.infer(preprocessed_features) async def process_request(user_id): # I/O task async with session.post( feature_service_url, json={'user_id': user_id}, ) as resp: features = await resp.json() # Non blocking CPU task return await loop.run_in_executor(executor, predict(features))
Вот тут-то и появляется ProcessPoolExecutor со своим собственным пулом воркеров, который решает эту проблему. В строке 1 мы создаем пул. В конце блока кода мы берем рабочего оттуда и перемещаем задачу, связанную с процессором, на отдельное ядро. Таким образом, функция прогнозирования будет выполняться асинхронно по отношению к родительскому процессу, а не блокировать его. Приятно то, что все это будет завернуто в обычный синтаксис async-await, а задачи, привязанные к процессору, будут выполняться асинхронно, как и задачи, связанные с вводом-выводом, но под капотом будет дополнительная магия с процессами.
ProcessPoolExecutor позволил нам снизить накладные расходы от модели ML в реальном времени, но и с ним в какой-то момент стало плохо. Первое, с чего мы начали, было самым очевидным — профилированием и выявлением узких мест.
2. Профилирование службы
Даже если сервис написан опытными программистами, его можно улучшить. Чтобы понять, какие части кода медленные, а какие быстрые, мы профилировали сервис с помощью профилировщика py-spy.
Профилировщик строит диаграмму, на которой горизонтальные полосы показывают, сколько процессорного времени тратится впустую на участок кода. Первое, что вы видите, это 3 полосы справа. Это всего лишь наши дочерние процессы для оценки функций модели ML.
На графике пламени мы увидели несколько интересных деталей:
- 7% процессорного времени тратится на сериализацию данных между процессами. Сериализация — это преобразование данных в байты. В Python этот процесс называется pickle, а обратный — unpickle.
- 3% времени уходит на оверхед ProcessPoolExecutor — подготовка пула воркеров и распределение нагрузки между ними.
- 6,7% времени тратится на сериализацию данных для сетевых запросов в json.loads и json.dumps.
В дополнение к процентному распределению мы хотели знать конкретное время, которое занимают различные участки кода. Для этого мы снова отключили ProcessPoolExecutor, запустили модель ML для ранжирования синхронно.
Но проблема осталась — конкретный кусок кода стал быстрее, но сам сервис стал медленнее.
После экспериментов мы выяснили:
- Накладные расходы ProcessPoolExecutor составляют около 100 миллисекунд.
- Связанные с вводом-выводом запросы от сопрограмм ждут 80 миллисекунд, то есть сопрограмма засыпает, и Event Loop снова обращается к ней через 80 мс, чтобы возобновить ее выполнение. В Representation есть три большие группы IO-bound запросов — всего на IO-wait тратится 240 миллисекунд.
Именно тогда мы впервые подумали о переходе на Go, так как он имеет более эффективную модель планирования подпрограмм из коробки.
3. Разделите рабочую нагрузку, связанную с процессором и вводом-выводом, на две отдельные службы
Одним из больших изменений, которые мы попробовали, было удаление модели машинного обучения в отдельный сервис повторного ранжирования. То есть мы сохранили наш сервис представления только с сетевыми запросами, а скоринг ML-модели был на отдельном сервисе rec-ranker, куда мы передавали все необходимые данные и возвращали скоринг для ранжирования. Казалось, что мы немного уменьшим латентность и масштабируем обе части по отдельности.
Эксперимент показал нам, что мы экономим время на работе модели, но получаем задержку в 270 миллисекунд при передаче данных по сети и json.loads/json.dumps. Нам нужно передать около 4 Мб на запрос, а для очень активных пользователей до 12 Мб данных для модели ML. После масштабирования rec-ranker реплики стали ненамного меньше старого представления, а время отклика не изменилось. Для нашего случая разбиение на сервисы оказалось неудачным решением, поэтому мы вернулись к предыдущей реализации Representation.
4. Оценка общей памяти
В сервисе Представление данные передаются между процессами через pickle/unpickle. Вместо этого процессы, которые совместно используют данные, могут указывать на общую область памяти. Это экономит время сериализации.
Максимальная оценка заключается в том, что мы могли бы выиграть около 70 миллисекунд на сериализацию с таким же сокращением времени для объема выполнения запросов, поскольку сбор/разбор — нагрузка, связанная с ЦП, блокировала основной процесс Python, обрабатывающий запросы от пользователей. Такой вывод мы сделали по профилю: pickle/unpickle занимает всего 7% процессорного времени, от разделяемой памяти особого профита не будет.
5. Подготовка фич в Go
Мы решили проверить эффективность Go сначала на части сервиса. Для эксперимента мы выбрали самую ресурсоемкую задачу в сервисе — подготовку фич.
Возможности в сервисе рекомендаций — данные о товарах и действиях пользователей. Например, название объявления, цена, информация о показах и кликах. Существует около 60 параметров, влияющих на результат модели машинного обучения. То есть мы готовим все эти данные для 3000 элементов и отправляем их в модель, и она дает нам оценку для каждого элемента, которую мы используем для ранжирования фида.
Чтобы связать код Go для подготовки функций с остальным кодом сервиса на Python, мы использовали ctypes.
def get_predictions( raw_data: bytes, model_ptr: POINTER(c_void_p), size: int, ) -> list: raw_predictions = lib.GetPredictionsWithModel( GoString(raw_data, len(raw_data)), model_ptr, ) predictions = [raw_predictions[i] for i in range(size)] return predictions
Так выглядит подготовка фич внутри Python. Модуль lib представляет собой скомпилированный пакет Go с функцией GetPredictionsWithModel. В него мы передаем байты с данными об элементах и указатели на ML-модель. Все функции подготовлены кодом Go.
Результаты были впечатляющими:
- Го фичи считаются в 20–30 раз быстрее;
- весь шаг ранжирования в 3 раза быстрее с учетом дополнительной сериализации данных в байты;
- отклик главной страницы упал на 35%.
Полученные результаты
После всех экспериментов мы сделали четыре вывода:
- Функции Go для 3000 элементов на запрос учитываются в 20–30 раз быстрее, что экономит 30% времени.
- ProcessPoolExecutor тратит впустую около 10% времени.
- Три группы запросов, связанных с вводом-выводом, занимают 25% времени пустых ожиданий.
- После перехода на Go мы сэкономим около 65% времени.
Переписал все на Go
Есть модель ML в представлении-го. Естественно кажется, что ML хорош только для Python, но в нашем случае модель ML на CatBoost и у нее есть C-API, который можно вызывать из Go. Этим мы и воспользовались.
Ниже приведен небольшой код на Go. Не буду на этом особо останавливаться, отмечу только, что логический вывод дает те же результаты, что и в Python. C — это псевдопакет, предоставляющий Go интерфейс для библиотек C.
if !C.CalcModelPrediction( model.Handler, C.size_t(nSamples), floatsC, C.size_t(floatFeaturesCount), CatsC, C.size_t(categoryFeaturesCount), (*C.double)(&results[0]), C.size_t(nSamples), ) { return nil, getError() }
Проблема в том, что модель машинного обучения все еще обучается на Python. И для того, чтобы он изучал и строил одни и те же функции, важно, чтобы они не расходились.
Мы начали их готовить с помощью кода Go-сервиса. Обучение происходит на отдельных машинах, туда загружается сервисный код в Go, по этому коду готовятся фичи, сохраняются в файл, затем Python-скрипт скачивает этот файл и обучает на них модель. В качестве бонуса обучение также стало в 20–30 раз быстрее.
Представительство-го показало отличные результаты:
- Время отклика главной страницы сократилось в 3 раза с 1280 миллисекунд до 450 миллисекунд.
- Потребление процессора снизилось в 5 раз.
- Потребление оперативной памяти снизилось в 21 раз.
Мы разблокировали дальнейшее развитие рекомендаций — мы можем продолжать реализовывать тяжелые функции.
Когда переносить сервис с Python на Go
В нашем случае переход на Go принес желаемый результат. Основываясь на нашем опыте работы с механизмом рекомендаций, мы определили три условия, при которыхвам следует задуматься о переходе на Go:
- служба имеет высокую загрузку ЦП
- в то же время нагрузка, связанная с вводом-выводом, высока
- вам нужно отправить большой объем данных по сети для подготовки функций.
Если у вас есть только рабочие нагрузки, связанные с вводом-выводом, вам лучше придерживаться Python. Переход на Go не выиграет вам много времени, вы только сэкономите ресурсы, что не так важно для малых и средних рабочих нагрузок.
Если сервис использует обе нагрузки, но не передает по сети столько данных, сколько мы, есть два варианта:
- Используйте процесспулэкзекутор. Накладные расходы времени не будут очень большими, а обслуживание не огромным.
- Поскольку нагрузка трафика становится слишком высокой, разделите его на 2 службы — службы, привязанные к процессору, и службы, связанные с вводом-выводом, чтобы масштабировать его отдельно.
Оптимизация сервиса, с чего начать
Профилируйте свой сервис. Используйте py-spy, как мы, или другой профилировщик Python. Скорее всего, ваш код не имеет огромных неоптимальных областей. Но вам нужно повнимательнее присмотреться ко всем небольшим областям, которые будут значительно улучшены. Возможно, вам не потребуется переписывать весь код.
Запустите py-spy в неблокирующем режиме:
record -F -o record.svg -s - nonblocking -p 1
Это первый флейм, который мы получили без какой-либо оптимизации. Первое, что тут бросилось в глаза, это то, что заметный кусок времени уходит на валидацию json-запроса, которая в нашем случае не очень нужна, поэтому мы ее убрали. Еще больше времени ушло на json загрузки/дампы всех сетевых запросов, поэтому мы заменили его на orjson.
В заключение я дам несколько советов:
- Используйте валидатор запросов с умом.
- Используйте orjson для Python или jsoniter для Golang для синтаксического анализа.
- Снизить нагрузку на сеть — сжать данные (zstd). Оптимизация хранения базы данных, чтение/запись данных (Protobuf/MessagePack). Иногда быстрее сжимать, отправлять и распаковывать, чем отправлять несжатые данные.
- Посмотрите на участки кода, выполнение которых занимает больше всего времени.