Learning Platform
Глоссарий Troubleshooting
Урок 16.06 · 24 мин
Средний
performancetpc-hbenchmarkingtuning

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();
WARNING

Конкретные числа из бенчмарков (X секунд, во столько-то раз быстрее Y) сильно зависят от железа, версии, scale factor и того, кто бенчмарк проводил. Относиться к ним стоит как к иллюстрации, а не как к константам. Полезнее не запоминать цифры, а понимать причины скорости — они стабильны и переносятся на ваши собственные запросы. Поэтому дальше — про механизмы, а не про числа.


Пять причин, почему DuckDB быстрый

Скорость DuckDB — не один трюк, а совокупность решений. Каждое курс разбирал отдельно; здесь они собраны как единая картина.

Пять архитектурных источников скорости DuckDB
Векторизованное исполнениеОператоры обрабатывают батчи по 2048 значений, а не по одной строке. Стоимость интерпретации SQL размазывается на весь батч, а тело цикла дружелюбно к SIMD и кэшу CPU.
Колоночное хранение + zonemapsДанные хранятся по колонкам: читается только то, что нужно запросу. На каждый сегмент есть min/max-статистика (zonemap), позволяющая пропускать целые row groups.
Лёгкое сжатиеRLE, bit packing, FOR, dictionary, FSST, ALP. Декомпрессия настолько дешёвая, что идёт на скорости сканирования, а данных с диска читается меньше.
Morsel-driven параллелизмПлан делится на pipelines, данные раздаются потокам морселами. Параллелизм масштабируется по ядрам почти линейно, с work-stealing и дружелюбием к NUMA.
Pushdown-оптимизацииProjection pushdown читает только нужные колонки, filter pushdown превращает условия в пропуск row groups по zonemaps. Лишние данные не доходят до движка вообще.

Векторизованное 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');
Тюнинг: на что влияет каждая настройка
threadsСтепень параллелизма. По умолчанию число ядер. Меньше — освобождает ресурсы и снижает пиковую память; больше числа ядер не помогает.
memory_limitПорог, после которого начинается спилл на диск. Слишком низкий — ранний частый спилл и замедление. Разумный — меньше спилла.
temp_directoryМесто спилл-файлов. Под larger-than-memory нагрузкой должно быть на быстром SSD/NVMe.

Диагностика: читаем 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, оператор почти наверняка спиллит на диск.
TIP

Дисциплина быстрого 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.

Задания:

  1. Выполните INSTALL tpch; LOAD tpch; CALL dbgen(sf = 1);. Включите .timer on и прогоните несколько запросов через PRAGMA tpch(N) для разных N. Запишите времена.
  2. Возьмите один тяжёлый запрос и запустите его под EXPLAIN ANALYZE. Найдите самый дорогой оператор и оцените, сколько строк прошло через скан lineitem.
  3. Прогоните тот же запрос с SET threads = 1 и с SET threads равным числу ядер. Сравните время — это прямая иллюстрация morsel-driven параллелизма.
  4. Поставьте намеренно маленький memory_limit (например, '200MB') и быстрый temp_directory, прогоните агрегацию по большому числу групп. Через EXPLAIN ANALYZE найдите признаки спилла и сравните время с запуском при достаточном лимите.
ClickHouse на TPC-H: сравнение с DuckDB и другими аналитическими движками
Проверка знанийKnowledge check
Какие архитектурные механизмы делают DuckDB быстрым и почему конкретные числа из бенчмарков вроде TPC-H стоит считать иллюстрацией, а не главным?
ОтветAnswer
Скорость DuckDB — это совокупность пяти архитектурных решений, а не один трюк. Первое — векторизованное push-based исполнение: операторы обрабатывают батчи по 2048 значений, размазывая стоимость интерпретации SQL на весь батч, а плотный цикл по массиву одного типа векторизуется через SIMD и хорошо ложится в кэш CPU. Второе — колоночное хранение с zonemaps: читаются только нужные колонки, а min/max-статистика сегментов позволяет пропускать целые row groups. Третье — лёгкое (lightweight) сжатие: RLE, bit packing, FOR, dictionary, FSST, ALP декомпрессируются на скорости сканирования, сокращая объём чтения с диска. Четвёртое — morsel-driven параллелизм: план делится на pipelines, данные раздаются потокам морселами, масштабирование по ядрам почти линейное. Пятое — pushdown-оптимизации: projection pushdown читает только нужные колонки, filter pushdown через zonemaps превращается в пропуск row groups. Плюс embedded-природа даёт zero-copy доступ к Pandas/Arrow без сериализации. TPC-H (22 запроса, размер задаётся scale factor) — стандарт для сравнения аналитических движков, но конкретные числа из бенчмарков сильно зависят от железа, версии, scale factor и постановки, поэтому их стоит считать иллюстрацией. Понимание причин скорости стабильно и переносится на собственные запросы, а числа — нет. На практике это даёт дисциплину тюнинга: фильтровать по упорядоченным колонкам ради zonemaps, выбирать только нужные колонки, диагностировать план через EXPLAIN ANALYZE, а настройки threads/memory_limit/temp_directory подбирать уже по факту.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что такое TPC-H и зачем он нужен?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6