NUMA, work-stealing и настройка threads
Мы разобрали параллелизм DuckDB снизу вверх: pipelines, morsel-driven раздача, параллельные скан, join и агрегация. Этот завершающий урок модуля связывает всё с железом и с практикой. Почему morsel-driven подход дружит с NUMA-архитектурой современных серверов. Что такое work-stealing и как оно дочищает остатки нагрузки. И как настраивать число потоков и наблюдать за тем, действительно ли запрос параллелится.
NUMA: память не одинаково далека
Чтобы понять, почему morsel-driven хорош для современного железа, нужно знать про NUMA. Аббревиатура расшифровывается как Non-Uniform Memory Access — неравномерный доступ к памяти.
На простой машине с одним процессором вся оперативная память «одинаково далека» от всех ядер: любое ядро обращается к любому адресу за одно и то же время. На крупных серверах с несколькими процессорными сокетами это уже не так. Память физически поделена на регионы, и каждый регион «прикреплён» к своему сокету. Ядро обращается к памяти своего сокета — локальной — быстро. К памяти чужого сокета — удалённой — медленнее: запрос идёт через межпроцессорное соединение. Доступ стал неравномерным — отсюда и название.
Для параллельной СУБД это создаёт ловушку. Если поток выполняется на одном сокете, а данные, которые он обрабатывает, лежат в памяти другого сокета
Trino: cache-aware scheduling и data locality, каждое обращение к данным идёт по медленному удалённому пути. На NUMA-машине неудачное расположение «поток здесь, данные там» способно ощутимо замедлить запрос — притом что формально все ядра загружены.
Почему morsel-driven дружит с NUMA
Вот здесь morsel-driven parallelism показывает себя с лучшей стороны — он структурно подходит NUMA-машинам, и понять почему стоит.
Дело в гранулярности и в принципе локальности. Morsel — мелкая порция работы. Поток берёт morsel, обрабатывает его в своих локальных структурах (вспомните локальные хеш-таблицы из уроков про join и агрегацию) и возвращается за следующим. Пока поток крутит этот цикл на одном ядре, и его morsel-ы, и его локальные структуры естественно тяготеют к памяти того сокета, на котором он работает. Работа и данные держатся рядом.
Сравните со статическим разбиением. Там один поток получает огромный кусок таблицы заранее. Этот кусок может физически лежать в памяти не того сокета, где поток исполняется, — и весь кусок будет читаться по медленному удалённому пути. Крупная единица работы «прибивает» поток к большому массиву данных, и если они оказались на разных сокетах, исправить это уже нельзя.
Morsel-driven с его мелкой гранулярностью даёт планировщику свободу держать работу рядом с данными: каждый morsel мал, локальные структуры малы, и всё это укладывается в локальную память сокета. Идея morsel-driven parallelism исходно и формулировалась авторами в том числе как ответ на NUMA — как раздавать работу так, чтобы потоки чаще обращались к локальной памяти. DuckDB унаследовал этот подход, и поэтому масштабируется не только на ноутбуке, но и на больших многосокетных серверах.
Work-stealing: дочистить остатки
С morsel-driven раздачей связан ещё один механизм — work-stealing, «воровство работы». Он решает тонкую проблему самого конца исполнения.
Картина такая. В морсель-модели часто бывает не один общий пул, а несколько очередей работы — например, по одной на поток или на группу потоков (это помогает локальности, в том числе NUMA). Обычно поток берёт morsel-ы из своей очереди. Но очереди расходуются неравномерно: один поток разобрал свою очередь и освободился, а у другого в очереди ещё есть необработанные morsel-ы.
Без work-stealing освободившийся поток просто простаивал бы — своя очередь пуста, а в чужую он не лезет. Это снова перекос, только на уровне очередей: ядро простаивает, хотя работа в системе ещё есть.
Work-stealing это чинит. Освободившийся поток, обнаружив свою очередь пустой, не садится ждать — он «крадёт» morsel из очереди другого, ещё загруженного потока. Берёт чужой кусок и обрабатывает его. «Воровство» здесь — добрый термин: поток забирает работу, которую иначе никто бы сейчас не делал, и не даёт ядру простаивать.
Получается двухуровневая защита от перекоса. Первый уровень — мелкая гранулярность morsel-ов: работа нарезана так дробно, что грубого перекоса почти не возникает. Второй уровень — work-stealing: даже мелкие остатки в конце, когда у кого-то очередь ещё не пуста, растаскиваются освободившимися потоками. Вместе это держит все ядра занятыми до самого конца запроса.
Настройка: параметр threads
Перейдём к практике. Степень параллелизма DuckDB управляется одним главным параметром — threads. Он задаёт число рабочих потоков, между которыми диспетчер раздаёт morsel-ы.
По умолчанию threads равен числу логических ядер машины — DuckDB старается задействовать весь процессор. Посмотреть и изменить значение можно через SET и current_setting:
-- Текущее число рабочих потоков
SELECT current_setting('threads');
-- Ограничить до 4 потоков
SET threads = 4;
-- Полностью последовательное исполнение
SET threads = 1;
Когда threads стоит трогать. Значение по умолчанию — почти всегда правильное: morsel-driven сам раздаст работу, и лишних настроек не нужно. Менять threads имеет смысл в нескольких ситуациях. Если DuckDB работает на машине не один и нужно оставить ядра другим процессам — threads уменьшают, чтобы не занять весь CPU. Если важна воспроизводимость замеров или отладка — threads = 1 даёт детерминированное последовательное исполнение, удобное для сравнения. На очень больших серверах иногда полезно поэкспериментировать со значением: занимать абсолютно все логические потоки не всегда оптимально, особенно если нагрузка упирается не в CPU, а в память или диск.
Число потоков — не та ручка, которой стоит крутить наугад в погоне за скоростью. Если запрос упирается в пропускную способность диска (вспомните потолок параллельного скана) или в память, наращивание threads не ускорит его — лишние потоки будут ждать ввод-вывод. Сначала диагностируйте, в чём узкое место, и только потом меняйте threads. По умолчанию morsel-driven уже хорошо балансирует нагрузку.
Наблюдение: параллелится ли запрос
Настройка бессмысленна без наблюдения. Главный инструмент — EXPLAIN ANALYZE. Он исполняет запрос по-настоящему и показывает дерево физических операторов с реальным временем и числом обработанных строк по каждому.
EXPLAIN ANALYZE
SELECT region, sum(amount)
FROM sales
GROUP BY region;
В выводе EXPLAIN ANALYZE смотрят на несколько вещей. Общее время запроса — отправная точка. Время по операторам — видно, какой оператор съедает основную долю: скан, join, агрегация. Число строк через каждый оператор — помогает понять, где данные сужаются фильтром, а где раздуваются.
Как этим пользоваться для диагностики параллелизма. Прямой и надёжный способ — сравнить время одного и того же запроса при разном threads. Запустили на всех ядрах, запомнили время; выполнили SET threads = 1, запустили снова. Если многопоточное исполнение в разы быстрее — запрос параллелится хорошо. Если ускорение слабое, его стоит объяснить: либо запрос упёрся в диск или память (CPU не узкое место, и потоки тут не помогут), либо данных мало и накладные расходы на параллелизм соизмеримы с самой работой, либо в плане есть существенно последовательный участок. EXPLAIN ANALYZE при разном threads показывает, какой именно оператор перестал ускоряться, — и это и есть отправная точка для разбирательства.
| Симптом | Вероятная причина | Что делать |
|---|---|---|
| Время почти не падает с ростом threads | Упёрлись в диск или память, не в CPU | Проверить I/O; сжатие данных; не наращивать threads |
| Хорошее ускорение до N ядер, потом плато | Исчерпана пропускная способность диска/памяти | N потоков уже оптимум для этой нагрузки |
| Многопоточный режим не быстрее однопоточного | Слишком мало данных, overhead соизмерим с работой | Параллелизм не нужен на таком объёме |
| Один оператор в EXPLAIN ANALYZE доминирует по времени | Узкое место локализовано в нём | Разбираться именно с этим оператором |
Так замыкается весь модуль. Параллелизм в DuckDB — это pipelines как каркас, morsel-driven как способ раздачи, параллельные скан, join и агрегация как операторы, NUMA-дружелюбие и work-stealing как связь с железом. А threads и EXPLAIN ANALYZE — две ручки, которыми вы этим параллелизмом управляете и за ним наблюдаете.
Попробуй сам
Научитесь диагностировать параллелизм запроса.
- Создайте крупную таблицу:
CREATE TABLE perf AS SELECT range AS id, (range % 50)::INTEGER AS grp, (random()*1000)::INTEGER AS v FROM range(50000000);иCHECKPOINT. - Узнайте число логических ядер:
SELECT current_setting('threads');. - Включите таймер (
.timer on). Возьмите тяжёлый запрос —SELECT grp, sum(v), count(*) FROM perf GROUP BY grp;— и замерьте его время приthreads, равном 1, 2, 4 и вашему максимуму. Постройте мысленный график. - Для каждого значения
threadsвыполнитеEXPLAIN ANALYZEтого же запроса. Найдите, какой оператор доминирует по времени, и как меняется общее время. - Прогрейте кэш (выполните запрос дважды) и повторите шаг 3 на горячих данных. Сравните, ближе ли теперь масштабирование к линейному, — при горячем кэше нет дискового потолка.
- Найдите точку, где добавление потоков перестаёт ускорять запрос. По таблице симптомов из урока предположите причину: диск, память или нехватка данных.
Этот эксперимент учит главному практическому навыку модуля: не угадывать, а измерять — менять threads, читать EXPLAIN ANALYZE и по ним понимать, параллелится запрос или упёрся в потолок.