Out-of-core агрегация датасета больше RAM
В капстоун-датасете 41 миллион поездок и несколько десятков гигабайт — заведомо больше оперативной памяти типичного ноутбука. При этом в прошлом уроке витрина mart_daily_zone посчиталась без ошибок. Это не случайность: DuckDB умеет out-of-core (larger-than-memory) исполнение — обрабатывать данные, которые не помещаются в RAM, спиллингом промежуточных результатов на диск.
Этот урок — про то, как именно это работает на нашей агрегации. Разберём механику external hash aggregation, научимся управлять memory_limit и temp_directory и увидим, как контролировать спилл осознанно. Модуль про larger-than-memory дал теорию; здесь мы применяем её к конкретному расчёту витрины.
Почему агрегация рискует не поместиться в память
Витрина mart_daily_zone группирует поездки по (trip_date, borough, zone_name). Чтобы посчитать sum и avg по группам, движку нужна хеш-таблица: ключ — комбинация группировки, значение — накапливаемые агрегаты. Эта хеш-таблица растёт с числом уникальных групп.
Прикинем масштаб. Поездки за два с лишним года, около 260 зон города: число групп (дата, зона) — это сотни тысяч записей. Сама хеш-таблица групп для нашей витрины невелика. Но опасность в другом — в промежуточных данных пайплайна. Скан 41 миллиона строк, hash join поездок со справочником зон, сортировки — всё это держит в памяти векторы и буферы. На запросе с тяжёлым join или агрегацией по миллионам групп пиковая память легко превышает RAM.
Вопрос не «поместится ли датасет в память» — целиком 41 миллион строк туда и не загружается, DuckDB обрабатывает поток. Вопрос в том, поместятся ли структуры состояния — хеш-таблицы join и агрегации. Когда не помещаются, включается external-механизм.
External hash aggregation: как это работает
Когда хеш-таблица агрегации перестаёт помещаться в memory_limit, DuckDB не падает с ошибкой памяти. Он переходит на external hash aggregation — алгоритм, который досчитывает группы с использованием диска.
Идея — партиционирование по хешу ключа группировки. Движок делит пространство групп на партиции по хеш-значению ключа. Партиции, которые не помещаются в память, целиком сбрасываются (спиллятся) на диск во временные файлы. Затем партиции обрабатываются по одной: партиция загружается с диска обратно, по ней досчитываются агрегаты, результат отдаётся дальше. Так как одна партиция много меньше всего набора, она помещается в память, даже когда весь набор групп — нет.
Тот же принцип работает для join (external hash join спиллит обе стороны, обрабатывает по партициям) и для сортировки (external merge sort). Все три механизма курс разбирал в модуле про larger-than-memory; в капстоуне они срабатывают сами, когда расчёт витрины упирается в лимит памяти.
Спилл не бесплатен: запись и чтение временных файлов медленнее работы в RAM. External-режим — это про корректность («запрос завершится»), а не про скорость. Цель тюнинга — не запретить спилл любой ценой, а не дать ему случаться там, где данные могли бы поместиться в память.
Управление памятью: memory_limit и temp_directory
Поведением out-of-core управляют две настройки. Зададим их для капстоуна осознанно.
memory_limit — потолок памяти движка. По умолчанию DuckDB берёт около 80% физической RAM. Это и есть граница, после которой начинается спилл. temp_directory — папка для временных файлов спилла.
-- Капстоун-расчёт на ноутбуке с 16 ГБ RAM
SET memory_limit = '10GB'; -- потолок памяти движка
SET temp_directory = '/fast-ssd/duck_tmp'; -- спилл-файлы на быстрый SSD
SET threads = 8; -- степень параллелизма
-- Тяжёлый расчёт: материализуем витрину из 41 млн поездок
CREATE OR REPLACE TABLE mart_daily_zone_mat AS
FROM mart_daily_zone;
-- Результат:
-- 287_044 строк витрины посчитано.
-- Если памяти не хватило — в /fast-ssd/duck_tmp появлялись
-- временные файлы спилла, но запрос завершился корректно.
Два правила для temp_directory. Первое — это должен быть быстрый диск (SSD/NVMe): под спилл-нагрузкой скорость временного диска становится скоростью запроса. Спилл на медленный HDD превращает out-of-core расчёт в очень долгий. Второе — на нём должно хватать места: спилл-файлы могут занять объём, сравнимый с промежуточными данными запроса.
Существенно и распределение памяти по потокам. Бюджет memory_limit делится между threads рабочими потоками. Грубая прикидка: агрегационно-тяжёлой нагрузке нужно порядка 1-2 ГБ на поток, join-тяжёлой — порядка 3-4 ГБ. Если memory_limit поделить на слишком большое число потоков, каждому достанется мало, и спилл начнётся рано. Иногда уменьшение threads при фиксированном memory_limit ускоряет тяжёлый запрос — каждому потоку достаётся больше памяти, и спилла меньше.
Наблюдаем за спиллом
Тюнинг вслепую бесполезен — нужно видеть, спиллит запрос или нет. Главный инструмент — EXPLAIN ANALYZE: он исполняет запрос и показывает фактическое дерево операторов с временем и числом строк.
EXPLAIN ANALYZE
CREATE OR REPLACE TABLE mart_daily_zone_mat AS
FROM mart_daily_zone;
Признаки и диагностика:
- Появление временных файлов в
temp_directoryво время запроса — прямой признак, что спилл идёт. - Самый дорогой по времени оператор в выводе
EXPLAIN ANALYZE— обычно hash aggregate или hash join. Если он неожиданно долгий, а данные большеmemory_limit, он почти наверняка работает в external-режиме. - Эффект изменения
memory_limit. Если поднять лимит и запрос резко ускорился — он спиллил, и теперь данные поместились. Если время не изменилось — спилла не было, и узкое место в другом.
Практические выводы для капстоуна
Сведём в правила, которые применим в остальных уроках модуля.
- Не бояться датасета больше RAM. DuckDB досчитает агрегацию и join через external-механизмы; запрос завершится корректно. Это штатный режим, а не аварийный.
- Задавать
memory_limitиtemp_directoryявно. Раз данные больше памяти, полагаться на умолчания нельзя.temp_directory— обязательно на быстрый SSD с запасом места. memory_limit— это про «где начнётся спилл». Слишком низкий лимит провоцирует ранний спилл и замедляет расчёт. Разумный лимит — настолько большой, насколько позволяет свободная RAM.threadsвлияет на память на поток. Бюджет делится между потоками; при тяжёлом расчёте иногда меньше потоков означает быстрее, потому что каждому достаётся больше памяти и меньше спилла.- Диагностировать через
EXPLAIN ANALYZE. Любой тюнинг памяти начинается с того, чтобы увидеть, спиллит ли запрос и какой оператор самый дорогой.
В следующем уроке мы возьмём этот материализованный расчёт и сделаем витрину партиционированной и инкрементально обновляемой через MERGE — чтобы новый месячный файл не требовал пересчёта всех 41 миллиона строк.
Попробуй сам
Понадобится DuckDB 1.5.x и датасет, заметно больший, чем выставленный memory_limit (можно сгенерировать синтетику через range() или взять большой Parquet).
Задания:
- Поставьте намеренно маленький
memory_limit(например,'500MB') иtemp_directoryна быстрый диск. Запустите тяжёлую агрегацию по большому числу групп. Во время выполнения проверьте папкуtemp_directory— появились ли временные файлы спилла. - Прогоните тот же расчёт под
EXPLAIN ANALYZE. Найдите оператор hash aggregate и оцените, сколько строк через него прошло. - Поднимите
memory_limitтак, чтобы расчёт целиком поместился в память, и сравните время с шагом 1. Зафиксируйте, насколько спилл замедлял запрос. - При фиксированном
memory_limitпрогоните расчёт сthreads = 2и сthreads, равным числу ядер. Объясните результат через распределение бюджета памяти по потокам.
pandas: что происходит, когда DataFrame не помещается в RAM