Performance: что делает DuckDB быстрым, бенчмарки и тюнинг
DuckDB регулярно показывают как «очень быстрый» движок, и это правда — но «быстрый» само по себе ничего не объясняет. За скоростью стоят конкретные архитектурные решения, и если понимать их, становится ясно, в каких задачах DuckDB выигрывает, а в каких нет, и как его тюнить. Этот урок собирает воедино механизмы, которые курс разбирал по модулям, и переводит их в практику: чем мерить производительность, что именно делает движок быстрым и какие настройки реально влияют.
TPC-H: чем мерят аналитические движки
Чтобы сравнивать движки честно, нужен общий стандарт. Для аналитических нагрузок отраслевой стандарт — TPC-H: набор из 22 SQL-запросов поверх схемы оптового бизнеса (заказы, позиции заказов, поставщики, клиенты). Запросы покрывают типичную OLAP-нагрузку: крупные сканы, многотабличные join, агрегации, сортировки, подзапросы.
Размер датасета задаётся параметром scale factor (SF): SF1 — примерно 1 ГБ данных, SF100 — примерно 100 ГБ, и так далее. Это позволяет мерить, как движок держит рост объёма. Есть и родственный бенчмарк TPC-DS — больше и сложнее, 99 запросов.
DuckDB поставляет генератор данных прямо в виде расширения, так что воспроизвести бенчмарк можно в несколько строк:
-- Расширение tpch генерирует данные и хранит сами 22 запроса
INSTALL tpch;
LOAD tpch;
-- Сгенерировать датасет scale factor 1 (примерно 1 ГБ)
CALL dbgen(sf = 1);
-- Запустить, например, запрос 1 из набора TPC-H
PRAGMA tpch(1);
-- Запустить весь набор и увидеть время каждого запроса
.timer on
FROM tpch_queries();
Конкретные числа из бенчмарков (X секунд, во столько-то раз быстрее Y) сильно зависят от железа, версии, scale factor и того, кто бенчмарк проводил. Относиться к ним стоит как к иллюстрации, а не как к константам. Полезнее не запоминать цифры, а понимать причины скорости — они стабильны и переносятся на ваши собственные запросы. Поэтому дальше — про механизмы, а не про числа.
Пять причин, почему DuckDB быстрый
Скорость DuckDB — не один трюк, а совокупность решений. Каждое курс разбирал отдельно; здесь они собраны как единая картина.
Векторизованное push-based исполнение. Классический построчный движок (Volcano) вызывает операторы по одной строке — на каждую строку накладные расходы интерпретации. DuckDB обрабатывает данные батчами по 2048 значений (vector). Стоимость интерпретации размазывается на батч, тело внутреннего цикла оператора — это плотный проход по массиву одного типа, который компилятор векторизует через SIMD и который хорошо ложится в кэш CPU. Push-based модель (данные проталкиваются вверх по дереву операторов) убирает накладные расходы итераторов pull-модели.
Колоночное хранение и zonemaps. Данные лежат по колонкам. Запрос, которому нужны 3 колонки из 50, читает с диска только эти 3 — остальные 47 не трогаются. На каждый сегмент колонки хранится min/max-статистика (zonemap); если фильтр запроса не пересекается с диапазоном сегмента, весь сегмент пропускается без чтения.
Лёгкое (lightweight) сжатие. DuckDB сжимает колонки набором быстрых схем — RLE, bit packing, Frame of Reference, dictionary, FSST для строк, ALP для чисел с плавающей точкой. Ключевое слово — lightweight: декомпрессия настолько дешёвая, что не становится узким местом, идёт на скорости сканирования. Сжатие при этом сокращает объём чтения с диска — а I/O часто и есть узкое место.
Morsel-driven параллелизм. План делится на pipelines, а данные внутри pipeline раздаются рабочим потокам мелкими порциями — морселами. Потоки работают независимо, синхронизируясь только на pipeline breakers. Такая модель масштабируется по числу ядер почти линейно, поддерживает work-stealing (освободившийся поток забирает работу) и дружелюбна к NUMA.
Pushdown-оптимизации. Оптимизатор проталкивает projection (список нужных колонок) и filter (условия) как можно ближе к источнику данных. В итоге projection pushdown читает только нужные колонки, а filter pushdown через zonemaps превращается в пропуск целых row groups. Лучший способ обработать данные быстро — вообще не читать те, что не нужны.
К этим пяти добавляется свойство embedded-движка: DuckDB работает внутри вашего процесса, и при чтении Pandas/Arrow-данных нет ни сериализации, ни сетевого транспорта — данные доступны напрямую, zero-copy.
Тюнинг: что реально влияет
Архитектура даёт скорость по умолчанию, но несколько настроек заметно меняют поведение под конкретную нагрузку.
threads — число рабочих потоков. По умолчанию равно числу аппаратных ядер; обычно это и есть оптимум. Уменьшать имеет смысл, чтобы оставить ресурсы другим процессам или снизить пиковую память (каждый поток держит свои буферы). Увеличивать сверх числа ядер бессмысленно.
memory_limit — потолок памяти движка. При его превышении DuckDB не падает, а спиллит промежуточные данные на диск (external aggregation, join, sort — это разбирал модуль про larger-than-memory). Слишком низкий лимит провоцирует ранний и частый спилл и замедляет запрос; разумный лимит уменьшает спилл.
temp_directory — куда складывать спилл-файлы. Под larger-than-memory нагрузкой важно, чтобы это был быстрый диск (SSD/NVMe), а не медленный или почти полный.
-- Базовый тюнинг под машину
SET threads = 8;
SET memory_limit = '12GB';
SET temp_directory = '/fast-ssd/duckdb_tmp';
-- Посмотреть текущие значения
SELECT current_setting('threads'), current_setting('memory_limit');
Диагностика: читаем EXPLAIN ANALYZE
Тюнинг вслепую бесполезен — сначала надо увидеть, где запрос тратит время. Главный инструмент — EXPLAIN ANALYZE: он исполняет запрос и показывает фактическое дерево операторов с временем и числом строк на каждом.
EXPLAIN ANALYZE
SELECT l_returnflag, sum(l_quantity) AS qty
FROM lineitem
WHERE l_shipdate <= DATE '1998-09-01'
GROUP BY l_returnflag;
Что искать в выводе:
- Сколько строк прошло через скан. Если фильтр должен был отсечь большую часть данных, а скан всё равно отдал почти всё, — значит filter pushdown не сработал и row groups не пропускаются. Часто причина в том, что фильтр по колонке, по которой данные не упорядочены, и zonemaps не помогают.
- Самый дорогой оператор по времени. Обычно это hash join или агрегация. Дорогой join — повод проверить порядок соединения; дорогая агрегация на большом числе групп с маленьким
memory_limit— кандидат на спилл. - Признаки спилла. Если запрос неожиданно медленный, а данные больше
memory_limit, оператор почти наверняка спиллит на диск.
Дисциплина быстрого DuckDB-запроса: дать движку отсечь данные как можно раньше. Фильтруйте по колонкам, по которым данные физически упорядочены, — тогда zonemaps реально пропускают row groups. Выбирайте только нужные колонки, а не SELECT *, — это включает projection pushdown. Соединяйте так, чтобы крупные таблицы фильтровались до join. Эти приёмы важнее любого SET, потому что они уменьшают объём работы, а не перераспределяют его.
Частые ошибки, которые убивают скорость
Несколько типичных антипаттернов, которые сводят на нет архитектурные преимущества:
SELECT *там, где нужны 3 колонки. Отключает выгоду колоночного хранения и projection pushdown — движок читает все 50 колонок.- Фильтр по неупорядоченной колонке в расчёте на pruning. Zonemaps пропускают сегменты, только если данные по колонке упорядочены или кластеризованы; на случайно разбросанной колонке min/max каждого сегмента покрывает весь диапазон и ничего не отсекается.
- Слишком низкий
memory_limitна join/aggregation-тяжёлом запросе. Провоцирует спилл там, где данные могли бы поместиться в память. - Импорт данных в DuckDB ради одного запроса. DuckDB читает Parquet/CSV напрямую; лишний шаг загрузки часто дороже самого запроса.
- Игнорирование
EXPLAIN ANALYZE. Тюнинг настроек без диагностики плана — это угадывание.
Попробуй сам
Понадобится DuckDB 1.5.x с расширением tpch.
Задания:
- Выполните
INSTALL tpch; LOAD tpch; CALL dbgen(sf = 1);. Включите.timer onи прогоните несколько запросов черезPRAGMA tpch(N)для разных N. Запишите времена. - Возьмите один тяжёлый запрос и запустите его под
EXPLAIN ANALYZE. Найдите самый дорогой оператор и оцените, сколько строк прошло через сканlineitem. - Прогоните тот же запрос с
SET threads = 1и сSET threadsравным числу ядер. Сравните время — это прямая иллюстрация morsel-driven параллелизма. - Поставьте намеренно маленький
memory_limit(например,'200MB') и быстрыйtemp_directory, прогоните агрегацию по большому числу групп. ЧерезEXPLAIN ANALYZEнайдите признаки спилла и сравните время с запуском при достаточном лимите.