Learning Platform
Глоссарий Troubleshooting
Урок 09.06 · 22 мин
Средний
parallelismnumawork-stealingtuning

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-машине неудачное расположение «поток здесь, данные там» способно ощутимо замедлить запрос — притом что формально все ядра загружены.

NUMA: локальная и удалённая память
Сокет 0: ядра + память 0Ядра сокета 0 обращаются к памяти 0 — локальной — быстро.
межпроцессорная связь
Сокет 1: ядра + память 1Ядрам сокета 0 память 1 — удалённая. Доступ медленнее: запрос идёт через межпроцессорное соединение.

Почему 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: даже мелкие остатки в конце, когда у кого-то очередь ещё не пуста, растаскиваются освободившимися потоками. Вместе это держит все ядра занятыми до самого конца запроса.

Work-stealing: освободившийся поток забирает чужую работу
Поток A: очередь пустаПоток A разобрал свою очередь morsel-ов и освободился. Без work-stealing он бы простаивал.
Поток B: очередь полнаУ потока B в очереди ещё остались необработанные morsel-ы.
A крадёт morsel у B
Поток A обрабатывает украденный morselОсвободившийся поток A забирает morsel из очереди B и обрабатывает его — ядро не простаивает, остатки нагрузки растаскиваются.

Настройка: параметр 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, а в память или диск.

WARNING

Число потоков — не та ручка, которой стоит крутить наугад в погоне за скоростью. Если запрос упирается в пропускную способность диска (вспомните потолок параллельного скана) или в память, наращивание 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 — две ручки, которыми вы этим параллелизмом управляете и за ним наблюдаете.

Попробуй сам

Научитесь диагностировать параллелизм запроса.

  1. Создайте крупную таблицу: CREATE TABLE perf AS SELECT range AS id, (range % 50)::INTEGER AS grp, (random()*1000)::INTEGER AS v FROM range(50000000); и CHECKPOINT.
  2. Узнайте число логических ядер: SELECT current_setting('threads');.
  3. Включите таймер (.timer on). Возьмите тяжёлый запрос — SELECT grp, sum(v), count(*) FROM perf GROUP BY grp; — и замерьте его время при threads, равном 1, 2, 4 и вашему максимуму. Постройте мысленный график.
  4. Для каждого значения threads выполните EXPLAIN ANALYZE того же запроса. Найдите, какой оператор доминирует по времени, и как меняется общее время.
  5. Прогрейте кэш (выполните запрос дважды) и повторите шаг 3 на горячих данных. Сравните, ближе ли теперь масштабирование к линейному, — при горячем кэше нет дискового потолка.
  6. Найдите точку, где добавление потоков перестаёт ускорять запрос. По таблице симптомов из урока предположите причину: диск, память или нехватка данных.

Этот эксперимент учит главному практическому навыку модуля: не угадывать, а измерять — менять threads, читать EXPLAIN ANALYZE и по ним понимать, параллелится запрос или упёрся в потолок.


Проверка знанийKnowledge check
Почему morsel-driven parallelism хорошо подходит NUMA-архитектуре, что такое work-stealing и как на практике проверить, действительно ли запрос параллелится?
ОтветAnswer
NUMA (Non-Uniform Memory Access) — это устройство крупных многосокетных серверов, где память поделена на регионы, прикреплённые к сокетам: ядро обращается к памяти своего сокета (локальной) быстро, а к памяти чужого (удалённой) медленнее, через межпроцессорное соединение. Если поток исполняется на одном сокете, а его данные лежат в памяти другого, каждое обращение идёт по медленному удалённому пути. Morsel-driven parallelism подходит NUMA структурно благодаря мелкой гранулярности и принципу локальности: morsel — мелкая порция работы, поток обрабатывает его в своих локальных структурах (локальные хеш-таблицы join и агрегации), и пока поток крутит цикл на одном ядре, его morsel-ы и локальные структуры тяготеют к памяти того же сокета — работа и данные держатся рядом. Статическое разбиение, наоборот, прибивает поток к огромному куску данных, который может лежать на чужом сокете, и исправить это нельзя; мелкие morsel-ы дают планировщику свободу держать работу рядом с данными. Идея morsel-driven исходно и формулировалась как ответ на NUMA. Work-stealing (воровство работы) решает проблему конца исполнения: в морсель-модели часто несколько очередей работы (по одной на поток для локальности), и они расходуются неравномерно — один поток разобрал свою очередь и освободился, а у другого в очереди ещё есть morsel-ы. Без work-stealing освободившийся поток простаивал бы; с ним он крадёт morsel из чужой ещё загруженной очереди и обрабатывает его — забирает работу, которую иначе никто бы сейчас не делал. Это второй уровень защиты от перекоса: первый — мелкая гранулярность morsel-ов, второй — work-stealing дочищает мелкие остатки, и вместе они держат все ядра занятыми до конца запроса. Проверить параллельность запроса на практике надёжнее всего так: замерить время одного и того же запроса при разном значении параметра threads — запустить на всех ядрах, затем SET threads = 1 и запустить снова. Если многопоточное исполнение в разы быстрее — запрос параллелится хорошо; если ускорение слабое, причину показывает EXPLAIN ANALYZE, который выводит дерево операторов с реальным временем и числом строк по каждому: запрос мог упереться в диск или память (CPU не узкое место), данных могло быть слишком мало (overhead соизмерим с работой), или в плане есть последовательный участок. EXPLAIN ANALYZE при разном threads показывает, какой оператор перестал ускоряться.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что такое NUMA и почему morsel-driven parallelism хорошо к ней подходит?

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

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

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

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