Вертим кеш на GPU

от автора

Идут значит: Redis, Docker и Postgres...

Идут значит: Redis, Docker и Postgres…

Идут значит: Redis, Docker и Postgres.
R: Как вы собираетесь надругаться над нами?
D: Я буду вертеть вас на GPU!
P: Ого, прямо на GPU?
D: Да, ресурсов — не жалею!

В данной статье мы сравним Redis и Postgres (в качестве системы кеширования), а также запустим их на GPU!

Предисловие

Я, как backend developer — уже успел поработать в разных проектах с разными базами данных (как с реляционными, так и с не реляционными) и системами кеширования.

MySQL, Postgres, MongoDB, Clickhouse, а список прочих различных решений для хранения данных огромен.

Но когда дело касается кеша, то выбор обычно идет между Redis и Memcached.
На просторах интернета полно статей, в которых авторы сравнивают две эти системы по самым разнообразным критериям и бенчмаркам.
А Redis — уже является вполне классическим выбором на роль системы кеширования у многих разработчиков.

Redis… Memcached… А может лучше Postgres?
Наверняка далеко не все из вас, читателей, даже интересовались функционалом этой СУБД, благодаря которому её тоже можно использовать в качестве альтернативы двум самым популярным системам кеширования.

Несомненно, при проектировании архитектуры проекта лучше лишний раз не изобретать велосипед, а потому выбор Redis’а — вполне разумное решение.
Он прост, быстр, популярен.

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

В данной статье я не буду сравнивать Redis и Memcache или с любыми другими системами из одного ряда — такими статейками интернет уже сыт и я не смогу предложить вам чего-либо нового, до селе неизвестного.

А вот попытки провести рисерч путем поиска уже ранее написанных статей по запросам «Redis vs Postgres» — довольно слабо удовлетворили мой интерес.

У меня возникло желание самостоятельно сравнить две данные системы и их производительность.

Практическая ценность данного рисерча и причем тут вообще GPU?

В эпоху развития AI сервера с GPU (а то и не одной) на борту — уже не являются каким то малосерийным узкоспециализированным решением. Могу предположить, что со временем они будут становиться все дешевле и доступнее.
Да и к тому же ничто не мешает собрать собственное железо с GPU на борту по разумной цене.

Под Linux уже существуют Redis (RedisAI) и плагин для Postgres (PG-Strom), заставляющие их работать на GPU.

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

План действий

  1. Написать +- справедливый бенчмарк для Redis & Postgres, который был бы похож на простенькое веб приложение.

  2. Поднять локальные Redis & Postgres.

  3. Сравнить их производительность.

  4. Заставить Redis & Postgres работать GPU.

  5. Сравнить их производительность на GPU.

  6. Сделать выводы.

Ограничения и честность тестирования

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

  1. Бенчмарк покрывает только основные операции, для работы с кешем:

    Вставка значения по ключу.
    Перезапись значения по ключу.
    Чтение значения по ключу.
    Чтение значения по маске.

  2. Все тесты проводятся на одном и том же железе при одной и той же его конфигурации (вполне очевидный пункт, но все равно зафиксируем его).

  3. Redis и Postgres запускаем в одной среде и только со стандартными параметрами.

    Да, возможно тонкая настройка Redis/Postgres может повлиять на их производительность, все же их стандартные настройки вполне универсальны.

Характеристики стенда

Отдельно для тестов закупать сервер с GPU на борту я думаю что мало кто станет.
Хорошо, что такой стенд уже стоит у меня дома — в роли стационарного ПК.

Пробежимся по его ТТХ:

1. Процессор: I9-11900K — 8 ядер, стабильная рабочая частота 5100-5300 МГц.

Скрин CPU-Z
Камушек хоть и немного устарел, но все ещё достаточно злой

Камушек хоть и немного устарел, но все ещё достаточно злой

Как раз немного ранее я обновил BIOS до последней актуальной версии.
Полную конфигурацию камня выкладывать не вижу смысла, вот основные моменты:

HyperThreading — включен

Виртуализация — включена

На время проведения тестирования я также выставил принудительный boost на все ядра.

Водяное охлаждение держит стабильные 50 градусов на протяжении всего бенчмарка.

2. ОЗУ: 4 плашки xpg по 8гб, итого — 32Gb DDR4, XMP профиль с частотой 4300 МГц.

3. GPU: RTX 3080 TI на 12 Гб видеопамяти.

Скрин GPU Shark
Красотища

Красотища

Как видите, по сравнению с DDR4 ОЗУ на частоте 4300МГц (а это уже разогнанная частота, как и процессор) — видеопамять в любом случае быстрее аж в целых 2 раза! (9501.0 МГц!).

Такое преимущество по частотам имеет в целом любая современная видеокарта.

Бенчмарк я запускал на температурах в 50 градусов для CPU и GPU.

При оценке скорости чтения/записи у систем кеширования счет идет в наносекундах.
Поэтому я постарался сделать так, чтобы условия в которых будут работать наши Redis/Postgres и бенчмарк были максимально одинаковыми. Иначе сравнивать результаты будет бессмысленно.

Версии тестируемых систем

  1. На CPU:
    Redis 7.0.15 x64
    PostgreSQL 16.8

  2. На GPU:
    Redis 6.2.5 x64
    PostgreSQL 16.8
    Nvidia Driver Version: 572.47
    CUDA Version: 12.8

Пишем бенчмарк

Для тестирования наших подопытных, я накидал мини веб сервис на Python + Quart для наглядности (форк Flask под ASGI).

В качестве тестов я решил параллельно засылать по 100000 запросов на вставку/перезапись/чтение в 50 соединений.

Для работы с Redis использовал пакет asyncio_redis.
Для работы с Postgres — asyncpg.

Для визуализации результатов накидал мини фронт на Chart.js.

Запускал все это дело на Uvicorn в 1 поток.

Полный код бенчмарка доступен тут.

Время замерял вот так:
from time import perf_counter_ns  def time_ms() -> int:     return perf_counter_ns() // 1000000  def time_us() -> int:     return perf_counter_ns() // 1000  def time_ns() -> int:     return perf_counter_ns()

Тесты для Redis
async def test_redis_set_worker(start_ms: int, range_from: int, range_to: int) -> dict:     timings = {}     async with current_app.async_redis() as redis:         for i in range(range_from, range_to):             if ((i + 1) % 10 == 0) or (i == 0):                 start = time_ns()                 await redis.set(f'something_key:{i}', f'something_value:{i}')                 end = time_ns()                                      timings[time_ms() - start_ms] = end - start             else:                 await redis.set(f'something_key:{i}', f'something_value:{i}')          return timings   async def test_redis_get_worker(start_ms: int, range_from: int, range_to: int) -> dict:     timings = {}     async with current_app.async_redis() as redis:         for i in range(range_from, range_to):             if ((i + 1) % 10 == 0) or (i == 0):                 start = time_ns()                 await redis.get(f'something_key:{i}')                 end = time_ns()                                      timings[time_ms() - start_ms] = end - start             else:                 await redis.get(f'something_key:{i}')          return timings   async def test_redis_get_by_mask_worker(start_ms: int, range_from: int, range_to: int) -> dict:     timings = {}     async with current_app.async_redis() as redis:         for i in range(range_from, range_to):             if ((i + 1) % 10 == 0) or (i == 0):                 start = time_ns()                 await redis.get(f's*{i}')                 end = time_ns()                                      timings[time_ms() - start_ms] = end - start             else:                 await redis.get(f's*{i}')          return timings

Тесты для Postgres
async def test_postgres_insert_worker(start_ms: int, range_from: int, range_to: int) -> dict:     timings = {}     async with current_app.async_db() as connection:         for i in range(range_from, range_to):             if ((i + 1) % 10 == 0) or (i == 0):                 start = time_ns()                 await connection.execute('INSERT INTO cache VALUES ($1, $2);', f'something_key:{i}', f'something_value:{i}')                 end = time_ns()                                      timings[time_ms() - start_ms] = end - start             else:                 await connection.execute('INSERT INTO cache VALUES ($1, $2);', f'something_key:{i}', f'something_value:{i}')          return timings   async def test_postgres_update_worker(start_ms: int, range_from: int, range_to: int) -> dict:     timings = {}     async with current_app.async_db() as connection:         for i in range(range_from, range_to):             if ((i + 1) % 10 == 0) or (i == 0):                 start = time_ns()                 await connection.execute('UPDATE cache SET value=$2 WHERE key=$1;', f'something_key:{i}', f'something_value:{i}')                 end = time_ns()                                      timings[time_ms() - start_ms] = end - start             else:                 await connection.execute('UPDATE cache SET value=$2 WHERE key=$1;', f'something_key:{i}', f'something_value:{i}')          return timings   async def test_postgres_select_worker(start_ms: int, range_from: int, range_to: int) -> dict:     timings = {}     async with current_app.async_db() as connection:         for i in range(range_from, range_to):             if ((i + 1) % 10 == 0) or (i == 0):                 start = time_ns()                 await connection.fetchrow('SELECT value FROM cache WHERE key=$1;', f'something_key:{i}')                 end = time_ns()                                      timings[time_ms() - start_ms] = end - start             else:                 await connection.fetchrow('SELECT value FROM cache WHERE key=$1;', f'something_key:{i}')          return timings   async def test_postgres_select_by_mask_worker(start_ms: int, range_from: int, range_to: int) -> dict:     timings = {}     async with current_app.async_db() as connection:         for i in range(range_from, range_to):             if ((i + 1) % 10 == 0) or (i == 0):                 start = time_ns()                 await connection.fetchrow("SELECT value FROM cache WHERE key LIKE $1;", f's%{i}')                 end = time_ns()                                      timings[time_ms() - start_ms] = end - start             else:                 await connection.fetchrow("SELECT value FROM cache WHERE key LIKE $1;", f's%{i}')          return timings

Создание тестовой таблицы:

DROP TABLE IF EXISTS cache; CREATE UNLOGGED TABLE cache (   key character varying NOT NULL,    value character varying NOT NULL ); ALTER TABLE cache SET (   AUTOVACUUM_ENABLED = FALSE ); CREATE INDEX ON cache (key);

Чтож, приступим к тестам!

Для начала стоит подметить, что Redis — штука однопоточная и работает на 1 ядре.
Если мы хотим масштабироваться, то можем запустить несколько Redis процессов и разделить наши потоки кеша между ними или организовать кластер на том же Kafka.

Postgres очевидно заведомо должен проигрывать Redis’у по общей производительности. Но Postgres — штука многопоточная.

Postgres я отдельно тестировал на разном количестве ядер.

Итак, первый тест, запустим Redis на CPU:

Total: Insert - 7879ms, Update - 7625ms, Get - 6321ms, Mask - 6065ms.

Total: Insert — 7879ms, Update — 7625ms, Get — 6321ms, Mask — 6065ms.

Получаем стабильное среднее время записи/перезаписи — чуть меньше 4мс и стабильное время чтения и поиска по маске — около 3мс.

Очередь за Postgres:

Total: Insert - 7096ms, Update - 7191ms, Select - 6822ms.

Total: Insert — 7096ms, Update — 7191ms, Select — 6822ms.

Получили очень похожие результаты. Но постойте ка, а где же select по маске ключа?

Вот он.

Вот он.

Выглядит мягко говоря не очень.
Суммарно этот тест выполнялся 45.9 секунд!
И… сделал наш график полностью не информативным.
Выходит, что при кешировании через Postgres нам стоит избегать select like оператора.

Но погодите, Postgres в данных тестах жульничал! Он стабильно грузил 4 ядра против 1 ядра Redis’а.

Давайте запустим Postgres на 1 ядре CPU и посмотрим на результат.

Команда для запуска
docker container run --shm-size=8gb --memory=8gb --cpus=1 -p 5432:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=perfomance_test -itd --name=contPG postgres:16.8

Кеширование в Postgres на 1 ядре.

Кеширование в Postgres на 1 ядре.

Среднее время чтения/записи выходит в районе 4 мс, но общее время работы бенчмарка увеличилось практически до 12 секунд (практически 60%!).

Плюс на графике видно, что некоторые запросы обрабатываются не совсем стабильно, причем отклонение происходит в обе стороны: Часть запросов была обработана за время от 30 мс до 55 мс, а другая часть запросов — от 0.5 мс до 3 мс, это довольно неожиданный результат.

Давайте запустим Postgres и проведем это же тестирование также на 2, 3 и 4 ядрах (т.к. больше 4-х ядер Postgres в нашем бенчмарке не использовал).

Запускаем на 2х ядрах:

Postgres на 2х ядрах.

Postgres на 2х ядрах.

Получаем стабильную скорость чтения/записи в среднем между 2.5 мс и 3.5 мс, а также время работы тестов ниже, чем у Redis в среднем на 1 секунду ~15-20%.

Более того Update & Select выполнились на 0.7-0.8 секунды бысрее, чем при запуске Postgres без ограничений по ядрам.

Движемся дальше, запускаем на 3х ядрах:

Postgres на 3х ядрах.

Postgres на 3х ядрах.

Получаем практически такой же результат, как и на 2х.

Для полноты теста — запускаем на 4х ядрах:

Postgres на 4х ядрах.

Postgres на 4х ядрах.

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

Ну и перед тем, как перебраться на GPU, посмотрим на общий зачет Redis vs Postgres без ограничений:

Redis vs Postgres без ограничений.

Redis vs Postgres без ограничений.

Получаем кашу из пикселей довольно схожие результаты производительности. В целом можно отметить, что запросы на вставку у Postgres проходят на 10-15% быстрее, в то время, как чтение и поиск по маске у Redis — на ~15% быстрее, чем у Postgres.

Интересен тот факт, что поиск значения по маске ключа Redis выполняет быстрее (замерял это неоднократно) чем получение этого значения по полному ключу.

Время GPU!

Давайте также начнем с Redis’а.
Для того, чтобы запустить Redis на GPU, нам нужно поставить его слегка доработанную версию: RedisAI.

Для запуска я использовал Docker и накатил туда образ redislabs/redisai:latest.

Redis на GPU (RedisAI)

Redis на GPU (RedisAI)

Запускаем, замеряем, получаем…
Довольно серьезное падение производительности???
Среднее время чтения/записи сдвинулось вверх по графику на 1 мс, а суммарное время работы бенчмарка увеличилось с ~7.9 до ~9.8 секунд, практически на 2 секунды!

Можно подумать, что у нас криво встала CUDA или по каким то причинам наш контейнер не видит GPU?

Проверяем:

Информация о GPU, дровах и CUDA.

Информация о GPU, дровах и CUDA.

Убеждаемся что все окей и в процессе тестирования нагрузка на GPU поднимается до 20-25%.

Похоже, что Redis под CPU оптимизирован настолько эффективно, что его версия под GPU не может угнаться за ним.

В любом случае это довольно интересный и неожиданный результат.

Переходим к Postgres.

Для работы на GPU у нас есть модуль PG_Strom.
Как понимаю в основном его используют для работы с геоданными, когда этих самых геоданных очень и очень много.
Параллельные вычисления в некоторым образом составленных SQL запросах с этим модулем происходят очень быстро. Как жаль, что нас сегодня интересует лишь кеширование.

Ищем докер контейнеры, их не так уж и много, вот 1:
https://github.com/murphye/pg-strom/blob/docker/docker/README.md

Вот 2:
https://github.com/ytooyama/pg-strom-docker/

1й категорически отказался заводиться на моей Ubuntu (WSL).
Со вторым процесс пошел — собрал контейнер и без особых проблем Postgres подружился с GPU.

Запускаем Postgres на GPU без ограничений:

Postgres на GPU

Postgres на GPU

График довольно схож с тем, что мы видели на CPU, однако — время выполнения половины запросов в целом упало ниже 3 мс до 2.5 мс, а время работы бенчмарка сократилось на ~0.7 секунды.

Что на счет поиска по маске?

Postgres на GPU - select like

Postgres на GPU — select like

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

Посмотрим, как поведет себя Postgres на 1 ядре CPU + GPU:

Postgres на 1 ядре CPU + GPU

Postgres на 1 ядре CPU + GPU

Получаем практически такой же результат, как у нас и было без GPU.
Возможно он стал чуть лучше, но по графикам это не сильно заметно.

Ну и наконец сравним Redis vs Postgres.

Redis на GPU vs Postgres на GPU:

Redis на GPU vs Postgres на GPU

Redis на GPU vs Postgres на GPU

Redis на CPU vs Postgres на GPU:

Redis на CPU vs Postgres на GPU

Redis на CPU vs Postgres на GPU

Получаем четко выраженный и довольно неплохой выйгрыш в производительности, при кешировании через Postgres на GPU.

Выводы

В ходе своего рисерча я постарался организовать максимально равное и честное тестирование Redis & Postgres, с целью получить представление о том в каких случаях Postgres может оказаться более эффективным выбором для работы с кешем.

Перед Postgres была поставлена довольно трудная задача, т.к. все-таки очень редко кто пытается создавать и работать с таблицами вида ключ-значение.
Это максимальный антипаттерн из возможных, при работе с БД.
Тем не менее соперником Postgres’a был Redis, а его основной функционал, который все используют — это как раз таки хранение данных в формате ключ-значение.

Уверен, что при более классическом подходе, в котором мы распределяем данные по разным таблицам и связываем их по id — можно достичь ещё большей производительности при переходе на GPU.

Как и предполагалось, при переносе кеша на GPU у нас получилось выжать дополнительные миллисекунды оптимизации из и без того хорошо оптимизированной Postgres.

Оптимальным количеством ядер для Postgres под эту задачу в моем случае — оказалось число 2.
В прочем, я думаю что это сильно зависит от их частоты.
Если бы наш стенд работал на ядрах с более низкими частотами, то разница между Redis и Postgres на большем количестве ядер была бы куда более ощутимой.

А что такое оптимизация лишних миллисекунд отклика для БД?

Для небольших и средних проектов — это совсем не критично.
Для серьезных hi-load систем, каждая оптимизированная миллисекунда отклика — это серьезная победа.

Всем тем, кто дочитал статью до конца желаю хорошего завершения выходных! 🙂

Ну и конечно же, хоть эта статья и никак не связана с моими текущими рабочими задачами и проектами, тем не менее — не могу упустить возможности добавить в конец:

Немного рекламы

Мы — компания StartDuck (startduck.com), больше года занимаемся внедрением AI автоматизаций в различные бизнес процессы.

Наша платформа AI автоматизации: ai.startduck.com.
Наш конструктор чатботов: bigduck.ai
Телеграм: t.me/startduck

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

А где вы обычно храните ваш кеш?

28% Redis7
8% Memcached2
4% Прямо в dict на Python’е1
16% В HashMap — я пишу на Java4
4% Postgres / любая другая СУБД1
24% Под матрасом6
16% В сберегательной кассе4

Проголосовали 25 пользователей. Воздержались 9 пользователей.

ссылка на оригинал статьи https://habr.com/ru/articles/891298/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *