Задача
Рекомендации устаревали быстрее, чем пересчитывались. Просмотры и покупки идут непрерывно, ассортимент меняется, товар уходит из наличия. А матрица оценок обновлялась крупными пакетами, потому что каждый полный проход стоил минут машинного времени. В итоге покупателю показывали персональную выдачу, отставшую и от его последних действий, и от текущего состояния каталога.
Ограничение было не в качестве модели, а в стоимости обновления. CPU-реализация выполняла значительную часть работы последовательно и многократно обходила одни и те же данные: при сопоставлении признаков, вычислении весов, агрегации промежуточных результатов и отборе Top-N. С ростом клиентской базы стоимость полного пересчёта увеличивалась почти пропорционально размеру матрицы, то есть со временем проблема усугублялась.
Требование заказчика сужало пространство решений: ускорить существующий алгоритм, не меняя его бизнес-логику и не ухудшая качество результатов. Модель собственная и настроенная, переписывать её было нельзя.
Решение
Профилирование и граница переноса. Сначала измерили, куда уходит время, и выделили вычислительное ядро, занимавшее основную часть расчёта. На GPU уехало только оно. Подготовка входных данных и оркестрация остались на CPU: переносить всё подряд означало бы получить сложную систему с тем же временем ответа.
Компактное представление разреженных данных. Матрица взаимодействий «клиент — товар» разрежена: подавляющее большинство ячеек пусты. Хранить её целиком в видеопамяти бессмысленно, поэтому взаимодействия преобразовали в компактный формат. Так уменьшился объём копирования и ушла часть лишних чтений.
Разбиение на независимые блоки. Расчёт разложили по блокам пользователей и товарных кандидатов: каждый CUDA-блок обрабатывает свою часть матрицы, потоки внутри блока параллельно считают оценки. Позже независимость блоков позволила масштабировать решение на несколько устройств без общей синхронизации.
Раскладка памяти. Данные в памяти GPU расположены так, чтобы соседние потоки читали последовательные участки. Часто используемые параметры алгоритма помещены в быструю память, промежуточные значения агрегируются внутри блока. Основной эффект дала эта перестройка, а не перенос циклов на видеокарту сам по себе.
Top-N на стороне GPU. Типичная ловушка GPU-задач: быстрое ядро легко обесценить дорогой обратной передачей. Поэтому редукции и первичный отбор лучших кандидатов выполняются до возврата результата. На CPU приходит небольшой список рекомендаций, и объём передаваемых данных уменьшился на порядки.
Перекрытие передачи и счёта. Работа разделена по CUDA streams: пока один пакет считается, следующий уже загружается в видеопамять, а результат предыдущего возвращается на сервер. Неизменившиеся части каталога и признаков кэшируются на GPU между пересчётами, что дополнительно сокращает копирование.
Пакеты и несколько видеокарт. Полная матрица может не поместиться в видеопамять, поэтому расчёт идёт пакетами, а размер пакета выбирается по доступной памяти конкретной карты. Для больших запусков пользовательские сегменты распределяются между несколькими GPU, и итоговые списки собираются независимо, без синхронизации всей матрицы между устройствами.
Плавная деградация при нехватке памяти. Система контролирует переполнение памяти и в ответ уменьшает пакет или выполняет неподдерживаемую операцию на CPU. Благодаря этому один и тот же код работает на разных поколениях видеокарт, и под каждый сервер не нужна своя версия алгоритма.
CPU-реализация как эталон. Старая версия осталась источником истины для проверки. На фиксированных наборах пользователей результаты сравнивались по трём признакам: численные значения с допустимой погрешностью, состав Top-N и порядок рекомендаций. Отдельные тесты закрывали граничные случаи — пустую историю клиента, новые товары, крайне популярные позиции, большие разреженные сегменты и повторяемость результата от запуска к запуску.
Замеры по стадиям. Производительность измерялась после прогрева, на одинаковых данных и отдельно по стадиям: подготовка, передача, GPU-расчёт, обратное копирование. Иначе быстрое вычислительное ядро скрывает дорогую загрузку данных, и общая цифра ускорения оказывается вымышленной.
Результат
Полный расчёт рекомендаций сократился с нескольких минут на CPU до нескольких секунд на GPU, ориентировочно в десятки раз. Магазин смог обновлять персональные предложения для сотен тысяч клиентов заметно чаще, и выдача стала успевать за текущим поведением покупателя и состоянием каталога.
С ростом каталога и клиентской базы задержка уже не увеличивается линейно: расчёт масштабируется пакетами и распределяется между видеокартами, а матрица, которая не помещается в видеопамять, считается по частям.
Главное, что получил заказчик, — ускорение без переписывания модели. Результаты новой реализации совпадают с исходным алгоритмом в пределах заданной численной точности, поэтому качество выдачи не пришлось проверять заново на живых пользователях.