В данной статье я хочу рассказать о проверке одной гипотезы - возможности использования shared memory для ускорения параллельной агрегации методом хэширования. Статья Xu&Marcus, PVLDB, 2025 утверждает, что общая хэш-таблица — незаслуженно списанный со счетов способ параллельной агрегации, если использовать т.н. тикетинг и разделить операции поиска группы и обновления агрегата.
Звучит завлекательно. Собираем в кучу свои знания Internals, подписку на Claude и, поскольку в период летних Heat Wave выходные всё-равно проходят дома под кондиционером - разрабатываем идею так глубоко, насколько это получится.
Введение
Время от времени мне попадаются репорты от пользователей PostgreSQL, которые жалуются на то, что простой с виду запрос выполняется очень долго. EXPLAIN ANALYZE в таком случае выглядит примерно следующим образом:
Finalize HashAggregate (actual time=22022.997..24117.957 rows=592000.00 loops=1) Group Key: f008_type, f008_rtref, f008_rrref, f009rref, ... Batches: 1 Memory Usage: 4481049kB -> Gather (actual time=2024.452..6237.613 rows=2440146.00 loops=1) Workers Planned: 8 Workers Launched: 8 -> Partial HashAggregate (actual time=2018.297..4047.409 rows=271127.33 loops=9) Group Key: f008_type, f008_rtref, f008_rrref, f009rref, ... Batches: 1 Memory Usage: 1949721kB -> Parallel Seq Scan on bench_shagg_num (actual time=0.160..252.678 rows=333333.33 loops=9) Planning Time: 0.839 ms Execution Time: 25071.708 ms
Здесь мы видим обычное сканирование таблицы и агрегат. Сканирование занимает пренебрежимо мало ( ~250 мс), частичный агрегат укладывается в четыре секунды, Gather отдаёт строки к шестой — а дальше примерно 18 секунд из 25 съедает Finalize, работающий в одном процессе. Все операции происходят в памяти, значит спиллинг ни при чём. Агрегация хэшированием, значит скрытых сортировок тоже не ожидается.
Часть причины видна прямо здесь: на входе 3 млн строк, на выходе почти 600 тыс. групп. Пять строк на группу означает, что частичная агрегация почти не сжимает поток данных, хэш-таблица раздувается и в воркерах, и в лидере, а через Gather пересылается ~2.44 млн частичных состояний.
Вторую составляющую можно увидеть в самом запросе:
SELECT f008_type, f008_rtref, f008_rrref, f009rref, f010rref, f011_type, f011_rtref, f011_rrref, f012, f013rref, f014rref, f015rref, f016rref, SUM(v1), SUM(v2), SUM(v3), SUM(v4), SUM(v5), SUM(v6), SUM(v7) FROM <table> GROUP BY f008_type, f008_rtref, f008_rrref, f009rref, f010rref, f011_type, f011_rtref, f011_rrref, f012, f013rref, f014rref, f015rref, f016rref;
Группировка по тринадцати variable-length полям из середины таблицы, что говорит нам о вероятно необычно дорогой операции хэширования. И семь SUM(numeric) также не являются дешёвой операцией, памятуя о количестве обработанных строк. Причём Finalize фактически делает всю работу с ключами повторно: каждое выданное нодой Gather состояние надо снова захэшировать и сравнить по тем же тринадцати полям, посчитать агрегаты.
Без воркеров ситуация в этом запросе становится лучше:
HashAggregate (actual time=10392.242..12376.055 rows=592000.00 loops=1) Batches: 1 Memory Usage: 4481049kB -> Seq Scan on bench_shagg_num (actual time=0.015..831.849 rows=3000000.00 loops=1) Execution Time: 12958.788 ms
Параллельный план почти вдвое медленнее последовательного. Эффект такого «лечения» ограничен, и остаётся вопрос, как принимать это решение по стоимости.
Вопросы, на которые я хотел ответить:
Работает ли гипотеза в PostgreSQL — и если да, то как именно.
Чем за неё платим.
Действительно ли конкуренция на LWLock — единственное узкое место?
Можно ли переиспользовать данный подход для ускорения иных операций плана запроса?
Короткий ответ на третий вопрос: нет, не единственное. Поиск под локом — самая крупная из проблем, и она снимается. Под ней обнаружились минимум три, из которых две не видны из статьи вообще, потому что она написана про движок с потоками, а не с процессами.
Как это может быть устроено
Попробуем представить себе возможные варианты реализации распараллеливания операции агрегации:
Маршрутизация строк - перед агрегированием кортежи распределяются по воркерам в соответствии с функцией хэширования по условию группировки “партиционирование”.
Слияние частичных состояний - текущая реализация Partial/Finalize.
Общая “shared” таблица агрегации.
Маршрутизация сгруппированных состояний - “partial” агрегацию выполнять независимо, но перед слиянием выполнять перераспределение значений между воркерами.
Приватное накопление со сбросом в общую таблицу - комбинация “partitioned” и “shared” вариантов (гибрид вариантов 1 и 3).
Вариант 5 выглядит интересным, если не принимать во внимание сложность реализации. Однако что в реальности используют реляционные СУБД сейчас?
Индустриальный стандарт
По моему опыту, SQL Server обычно задействует оператор Parallelism (Repartition Streams) с хэш-распределением по группам. Строки одной группы гарантированно попадают на один поток, после чего каждый поток независимо и полностью считает свой Hash Aggregate. Финальной перегруппировки не требуется, что в идеале может обеспечивать масштабирование, близкое к линейному. Тот же паттерн, судя по всему, у Oracle и Greenplum. DuckDB выбрал вариант 4. HyPer делает вариант того же — thread-local предагрегация, потом партиционированная фаза слияния, где каждый поток берёт во владение свои партиции (Leis et al., Morsel-Driven Parallelism, SIGMOD 2014).
Однако в PostgreSQL с его моделью независимых процессов-воркеров и одним Gather в конце, ничего похожего нет. В принципе, достаточно несложно с помощью extension module изобрести Custom 'SPLIT' node, которая будет перераспределять строки по воркерам в соответствии с хэш-значением по атрибутам группировки и предоставлять оптимизатору альтернативный план, вроде следующего:
Gather Parallel Aggregate Parallel Split Parallel Scan <table>
Подход хорош - ничего фактически менять не нужно, функции агрегации работают как есть. Однако попытки делать такое почти сразу сталкиваются с вопросом балансировки загрузки и неизбежным в реальной жизни перекосом по данным, который в условиях независимых процессов почти нивелирует эффект параллелизма. А внедрять потоки слишком инвазивно и ненадёжно.
Спиллинг на диск как способ репартиционирования
В поиске Postgres-way варианта реализации стоит посмотреть по сторонам. Первое, что приходит в голову - минимальный вариант с репартиционированием через спиллинг.
Идея эксплуатирует то, что в Postgres уже есть готовая машинерия: при переполнении work_mem оператор HashAgg делит входной поток по хэшу группировочных ключей на батчи и сбрасывает "лишние" в файлы. Вопрос в том, можно ли на этой же машинерии, но управляемой не памятью, а числом воркеров, построить репартиционирование для параллельной финализации.
С первого взгляда, этот подход реализуем с минимальными изменениями. В существующий код спиллинга добавляется общая очередь бакетов с выборкой по мере освобождения воркеров. Корректность обеспечивается тем же способом, что и в HashJoin: если бакетирование идёт по тем же ключам группировки, то каждая группа целиком попадает в один бакет.
Однако, в данном подходе появляется обязательный I/O даже тогда, когда данные полностью помещаются в память, что означает регрессию для типового случая: пример из начала поста мог бы стать даже медленнее, ведь спиллинг там был вообще не нужен. Во-вторых, это два полных прохода по данным (запись во все бакеты, затем чтение из них). Наконец перекос по данным никуда не девается: доминирующий по частоте ключ группировки целиком осядет в одном бакете и один воркер будет разбирать этот бакет в одиночку - тот же bottleneck, что и у SPLIT-варианта.
Это может сработать в качестве улучшения кейсов, когда данные и так не влезают в память (спилл всё равно неизбежен) и нужна хоть какая-то параллельность на Finalize-стадии, путь рабочий и дешёвый в реализации. Но как основной путь к параллельной агрегации он не годится.
Параллельное вычисление агрегата на shared hash table
Очевидно, что каждый метод хорош в определённой ситуации - см, для примера Adaptive Aggregation on Chip Multiprocessors, VLDB 2007 либо Scalable Aggregation on Multicore Processors, DaMoN 2011. Вариант 4 очевидно хорош в условиях одного производительного инстанса, вариант 1 - базовый способ для shared-nothing параллельной обработки запросов, а вариант с общей таблицей должен быть максимально экономичен и эффективен при отсутствии “heavy hitters” групп. Однако что-то должно ложиться на архитектуру конкретной СУБД лучше, а что-то - хуже. Значит нужно пробовать.
Свежая публикация Xue & Marcus, Global Hash Tables Strike Back! An Analysis of Parallel GROUP BY Aggregation, PVLDB, 2025 предлагает вариант реализации с shared таблицей, который по их результатам догоняет и обгоняет партиционирование на большинстве профилей нагрузки. Суть подхода в том, что можно организовать общую память, специализированную под задачу агрегации. Главный приём специализации — разделить операции «найти группу» и «обновить состояние» через так называемый ticketing, исключая необходимость тяжёлого LWLock в процессе поиска.
Из той же статьи следует, что альтернативные режимы параллельной агрегации также имеют свою нишу. И выбор между стратегиями обязан быть cost-based.
Реализуем прототип
Итак, чтобы лучше понять конкретные достоинства и недостатки shared-подхода в архитектуре Postgres, а также степень инвазивности решения, мы запустили проект выходного дня по его реализации. К счастью, возможностей AI-агентов сейчас достаточно, чтобы в опытной команде быстро реализовать код (в формате прототипа), затрагивающий все основные архитектурные вопросы и стабильный достаточно, чтобы выдерживать регрессионные тесты и сложные бенчмарки.
Ради простоты и скорости разработки от идеи тикетинга решено было пока отказаться, поскольку даже по признанию авторов самой статьи, данный метод имеет несколько открытых вопросов (например спиллинг).
Итоговый код параллельной агрегации можно найти в ветке на GitHub.
Что пришлось сделать с нуля
Хэш-таблица. Первым делом пришлось расстаться с мечтой переиспользования shared hash table из parallel hash join. Shared hash table операции агрегирования требует введения блокировки, поскольку в отличие от PHJ, где есть разделение на фазы вставка/чтение здесь результатом поиска по таблице будет либо вставка нового элемента, либо обновление существующего. Отсюда растут ноги и дальнейших (достаточно сложных) оптимизаций хеш-таблицы, цель которых - снизить эффект от введения локов. Авторы статьи приходят к тому же выводу: join hash table «generally incompatible with the needs of fully concurrent aggregation».
Слой by-reference состояний. У ядра Postgres и агрегатных функций есть контракт (см. AGG_CONTEXT_AGGREGATE) на то, что такая функция может изменять промежуточное состояние агрегата (например, вызвав repalloc). Агрегат выполняет такие операции вне предоставленного ему участка DSA. Делает ли так каждый конкретный агрегат или нет понять невозможно, поскольку это свойство определяется реализацией конкретной функции. Поэтому, параллельную агрегацию потребовалось дополнить механизмом предварительного копирования из общей памяти и последующего копирования обратно (или аллокации новой DSA). Речь идёт о by-ref типах вроде numeric или text и о min/max по ним.
Memory Limit. Та же особенность by-ref состояний усложнила и учёт потреблённой памяти, который нужен, чтобы понять, когда переходить к спиллингу. В хэш-таблице PHJ гранулярность учёта совпадает с гранулярностью выделения: память отдаётся чанками, кортеж кладётся в чанк и больше в размере не меняется, поэтому общий счётчик трогается раз на чанк и всегда точен — он обновляется под тем же локом, под которым идёт выделение. В нашей реализации так только для by-value состояний. By-ref состояние может изменить размер, поэтому его блоб нельзя раздать из чанкового аллокатора: он умеет выделять аддитивно, а освобождать всё разом, а тут нужны индивидуальные allocate/free. Здесь мы решили это путём локального накопления счётчика памяти c публикацией её в общий счётчик порциями. Вероятно, не лучшее решение.
Область применения. Добавлена проверка валидности применения параллельной агрегации в add_paths_to_grouping_rel() для каждого конкретного типа агрегата. Сам по себе механизм наводит на мысль, что для такой оптимизации pg_aggregate требуется новый флаг aggsharedsafe, задаваемый для каждого агрегата отдельно. Однако остаётся открытым вопрос, как контролировать правильность назначения данного флага. Кроме того, помимо безопасности самого агрегата оптимизатор должен проверять и ключи группировки, подаваемые на вход такого агрегата.
Интерпретатор выражений. Для by-reference состояний интерпретация выражений (ExecInterpExpr/ ExecBuildAggTrans) в текущем виде не может быть использована и разделяемый путь её обходит: интерпретируемое (в случае JIT — скомпилированное) состояние «микропрограммы» ExprState генерирует промежуточное значение агрегата в локальной памяти, ссылки на которое нельзя помещать в DSA. Интерпретация выражений для by-value агрегатов может быть выполнена традиционным способом, однако процесс вычисления выражений всё ещё будет проходить под LWLock’ом.
Что переиспользуется
Практически вся машинерия низкого уровня используется в качестве строительных блоков в данном коде. SharedTuplestore — весь спиллинг, который в статье Xue & Marcus назван открытым вопросом, обошёлся почти даром.
Вся фазовая синхронизация, временные файлы, DSA используются как есть. В ядро (по образцу Parallel Hash Join) добавлены пять новых wait events и два транша LWLock, чтобы pg_stat_activity различал два разных вида конкуренции.
Адаптировано по образцу, но написано заново
Менеджмент DSM. Конкурентность аллокатора DSA в PostgreSQL остаётся актуальной задачей [1]. Поэтому, как и Parallel Hash Join (PHJ), мы не обращаемся к DSA на каждый объект, а надстраиваем над ней чанковое размещение. Для агрегатов с by-value состоянием каждый участник запрашивает чанк 32 КБ и последовательно отрезает от него записи по мере появления новых групп; освобождение происходит оптом, вся цепочка чанков разом. Агрегаты с изменяемым, by-reference, состоянием по-прежнему обращаются к dsa_allocate/dsa_free, потому что размер состояния может меняться. Главная оптимизация здесь — перезапись на месте: если размер нового состояния не увеличился, машинерия DSA не задействуется вовсе. Для параллельной агрегации этот код является критически важным, поскольку управление памятью происходит под локом.
Правила партиционирования при спиллинге. Плоская схема партиционирования PHJ применима и к агрегации — она является частным случаем рекурсивной с широким первым уровнем, — но её код завязан на структуры хеш-джойна и потребовал бы написания заново. Поэтому, поскольку in-core partial/finalize агрегация уже имеет свои механизмы спиллинга, то решено было пойти по тому же пути и использовать ту же рекурсивную схему спиллинга и для shared parallel aggregate.
Принципиальное отличие агрегации от джойна в том, что объём потребной памяти определяется числом групп, а оно с числом строк напрямую не связано. Поэтому фиксировать максимальный размер батча бессмысленно: границей служит не размер, а момент, когда таблица при обработке батча упирается в потолок памяти. С этого момента участники переходят в режим спиллинга — существующие группы продолжают обновляться на месте, а новые уходят в дочерние партиции.
При этом используя существующую в ядре машинерию HyperLogLog можно как оценить кардинальность следующего этапа репартиционирования батча, так и эффективность репартиционирования: уменьшается ли существенно количество групп в каждом конкретном батче.
Барьерная машинерия. Общая хеш-таблица здесь одна, и в целях упрощения реализации прототипа батчи обрабатываются строго последовательно. Отсюда и малое число барьеров: build_barrier (события ELECT, ALLOCATE, BUILD) и scan_barrier (события EMIT и BATCH) — против трёх глобальных плюс по одному на каждый батч у Parallel Hash Join. Это не экономия, а следствие меньшего параллелизма: на границе каждого батча стоят три глобальные точки синхронизации, где все ждут самого медленного участника, тогда как участники PHJ обходят батчи кольцом и забирают свободный без общей синхронизации. Более совершенный вариант может быть реализован через слияние мелких батчей одного уровня в одну загрузку (bucket tuning), — но он требует оценки кардинальности батчей, что пока вынесено из задач прототипа.
Изменения в подсистемах
Пересечение с существующим nodeAgg.c незначительно — восемь статических функций: fetch_input_tuple, prepare_hash_slot, select_current_set, finalize_aggregates, prepare_projection_slot, project_aggregates, build_hash_tables, hash_agg_check_limits. В принципе, параллельный агрегат можно реализовать в отдельном модуле nodeAggShared.c, вынеся общую машинерию в отдельный common-модуль.
Вне nodeAgg.c пришлось изменить около ~800 строк, из которых ~600 приходится на планировщик (механизм принятия решения и cost модель) и ~60 — на EXPLAIN. То есть решение о применимости стратегии и её стоимость обошлись дороже, чем вся остальная интеграция с деревом плана, вместе взятая.
Правки в ядре executor’a составили семь строк. По одному case T_AggState в ExecParallelReInitializeDSM и в ExecShutdownNode_walker. Больше исполнителю ничего не понадобилось: механизм ExecParallel* оказался достаточно общим, чтобы новый узел можно было добавить через уже существующую диспетчеризацию.
Тестирование
Для определения реального эффекта от параллельной агрегации были выполнены тестовые прогоны на GCP VM.
Замеры делались на разном железе. Первый — 16 физических ядер (c4-standard-32, Xeon 8581C, SMT выключен, один узел NUMA, 118 ГБ): все раунды из раздела «Тестирование», кроме отдельно помеченных и 14-ядерный ноутбук, на котором домерены зипфовы профили, которых не было в первых двух заходах. Смешивать абсолютные времена разных заходов нельзя; отношения внутри одного захода сопоставимы.
Пара базовых правил нашего тестирования:
Форсируется только план для случая parallel aggregate и количество воркеров в плане (за счёт минимизации костов). В базовом случае оптимизатор сам решает, использовать ли последовательный вариант агрегации хэшированием или Partial/Finalize подход с воркерами.
Величина shared_buffers и work_mem достаточно большая, чтобы во время выполнения запроса как сканирование, так и агрегация не требовали как чтения страниц данных с диска, так и спиллинга.
С учётом перечисленных ограничений эти прогоны покрывают только агрегаты без Internal-состояния. Семь SUM(numeric) из вводного примера в их число не входят: у sum(numeric) состояние как раз Internal, и ускорить этот запрос прототип пока не может — см. «Выводы».
Результаты
На графиках ниже под «ускорением» понимается отношение времени выполнения запроса на "ванильном мастере" Postgres к времени выполнения плана с параллельной агрегацией. Выбор в ванильном случае определяется моделью стоимости, а параллельная агрегация форсируется при помощи специально добавленного GUC debug_parallel_hash_agg = 'force'.
Первый результат - позитивный. При равномерном распределении значений по группам мы видим заметное превосходство shared-memory распараллеливания над текущим состоянием. Агрегаты с фиксированным состоянием очевидным образом показывают заведомо лучшие цифры, нежели те, которым требуется дополнительное копирование из/в общую память, однако даже 2х превосходство над текущей моделью обнадёживает.

Однако, негативные эффекты для разделяемых ресурсов обычно ярко проявляются на перекосах по обращению - так называемых “heavy hitters” строках. Смоделируем перекос и сгенерируем данные таким образом, чтобы на одну группу приходилось доминирующее количество строк таблицы, а остальные строки распределены равномерно по оставшимся группам. Число строк (20 млн) и число групп (1 млн) фиксированы, меняется только концентрация “heavy hitter” группы.

До 2 % включительно разделяемая таблица выигрывает уверенно: 4.82× при восьми воркерах на однородном ключе и 4.49× при доле 2 %. Дальше преимущество тает и между 10 и 20 % сменяется проигрышем, доходящим до 0.04× при 95 %.
Таким образом, для безопасного использования parallel aggregate требуется качественная статистика по колонкам таблицы и кост-модель, основанная на MCV-статистике - чтобы отслеживать frequency самых популярных значений. Если максимальный “heavy hitter” находится в диапазоне 5-10% значений, то метод ещё можно использовать, для 2% и меньше он даст отличный эффект.
Однако перекос в стиле “heavy hitter” - это не очень практичный эксперимент. Гораздо лучше посмотреть в более общепринятое правило перекоса - 80-20. На графике ниже приведено ускорение, которое даёт parallel aggregate в сравнении с текущим мастером в зависимости от количества воркеров.

Следующий шаг — проверить, как на ускорение влияет форма перекоса. На рис. 4 сравниваются разные варианты плотности значений в первых 20 % групп: (а) для агрегатов с by-value состоянием, (б) с by-reference.

Получаем, что 80-20 является крайним вариантом, где ещё есть позитивный эффект от распараллеливания: при агрегатах с by-value состоянием там уже примерно паритет, а запас остаётся только у by-reference. При этом в кост модели придётся регулировать количество потребных воркеров в зависимости от предполагаемого перекоса в распределении.
Ещё одно любопытное наблюдение - если сравнить эти два графика то заметно, что агрегация с by-reference состоянием ведёт себя лучше в параллельном случае, чем казалось бы простой by-value вариант. Это говорит о том, что в текущем ядре PostgreSQL есть запас на оптимизацию вычисления агрегатов путём пересмотра (или специализации) агрегации для некоторых случаев - например, когда колонка с типом numeric объявлена с известной и достаточно небольшой точностью, позволяющей уместить все значения в фиксированную длину int64, int128 или int256.
Ну и наконец, посмотрим, имеется ли существенная зависимость эффекта распараллеливания от количества агрегатов, вычисляемых на каждой группе. Ниже (см. рис. 5) - график, показывающий как ведет себя ускорение для разного количества воркеров. Можно заметить позитивный эффект для количества агрегатов от 4 до 12 штук, впрочем небольшой: при восьми воркерах ускорение растёт с 4.64× на двух агрегатах до 5.80× на двенадцати. Дальше эффект сходит на нет, а на 32 агрегатах ускорение падает до 4.40× — ниже, чем на двух. Но уже тот факт, что это не деградирует parallel aggregate - позитивный результат.

Анализ
Основной вывод достаточно тривиальный - процессная модель является основным препятствием на пути использования shared-модели.
Результаты в пользу "Shared Parallel Aggregation" метода получены на модели с тредами. Общая хэш-таблица в таком случае сильно дешевле сама по себе, а состояние агрегата — обычный объект с обычными указателями, и код самого агрегата менять не нужно вообще.
В PostgreSQL общая память для агрегации выделяется в DSM. Это означает, что нет прямых указателей, нет palloc, нет MemoryContext, нет repalloc, нельзя разжать TOAST на месте. Таким образом, для расширения области применения параллельной агрегации требуется менять сами агрегаты: как минимум отказываться от Internal-состояния и увеличивать долю агрегатов с фиксированным размером промежуточного результата.
Также, решение получается инвазивным и не обеспечивает одну из базовых оптимизаций - быстрое вычисление выражений за счёт интерпретатора, и скорее всего потребует его дальнейшей доработки.
Тикетинг, предложенный в статье, снимает только один вид нагрузки, при этом сохраняются следующие основные проблемы:
Лок остался, и он не бесплатен. Лок дорог сам по себе, а даже не тем, что он защищает. Хуже того, LWLock — неправильный примитив для критической секции такого размера: для count(*) под локом одно приращение может составлять наносекунды, а LWLockAcquire() в неудачном случае требует переключения контекста, что может быть сильно дольше.
Отсутствует скомпилированная программа переходов, а с ней и JIT. Все фильтры, все аргументы и все переходные вызовы вычисляются в локальной памяти с локальными ссылками одной программой из ExecBuildAggTrans(), которую JIT компилирует в единую функцию. В случае parallel aggregate такой путь не работает: аргументы надо вычислить до захвата лока, а вызвать переходную функцию после.
Под локом выполняется фактически произвольный код. Даже на встроенных типах возможны ситуации когда (например, enum_cmp_internal()) при определённых OID уходит в каталог, то есть делает table_open() и читает буферы, что потенциально означает ещё один тяжёлый лок внутри LWLock.
Выводы
Таким образом, PostgreSQL платит за разделяемое изменяемое состояние гораздо больше, чем движок с потоками, а получает то же самое. Для меня, это существенный довод в пользу партиционирования.
При всём при том, подход выглядит заманчиво: он существенно уменьшает требования к памяти, а значит задерживает начало спиллинга. Также уменьшает количество операций, исключая Finalize этап. Ещё один довод за - он может быть адаптирован в SetOp операторе, где сейчас никакого распараллеливания не предусмотрено и будет крайне эффективен для ситуаций простой группировки (например, SELECT DISTINCT), где отсутствуют агрегаты и большая часть проблемных мест нашего кода просто не затрагивается. Также заманчивым выглядит механизм “Parallel Memoize”, который однако потребует доработки механизма, поскольку сейчас оператор Memoize предполагает возможность вытеснения из кэша старых записей.
Дополнительным стимулом может служить DuckDB, который в качестве дальнейшего направления исследований описывает очень похожий метод:
Currently, thread-local data is only combined after all data has been seen. This is potentially problematic where there are many distinct groups. If all of the threads see the same group just once, then this group resides in memory in each thread. This is wasteful since the group can be reduced in a thread-global state, reducing the memory needed. In a future PR, I will implement "early and often" thread-global combines during the
Sinkphase of the operator.
Так что метод может иметь смысл пробовать дальше если пойти на некоторые компромиссы. Основной компромисс - это переход на lock-free. Для этого придётся ограничиться состояниями фиксированной ширины by-value, у которых слияние — одна атомарная операция: count, sum целых и float. Тогда LWLock можно будет исключить вовсе. Это снимает вопрос о конкуренции, механизм сериализации для by-reference состояний и возвращает эффективный интерпретатор выражений.
Для многих применений это хороший компромисс. Кост модель может ограничить применение нового метода только подходящей для него областью. А поскольку сейчас часть агрегатов с internal state (например, sum по bigint или numeric) всё равно весьма неэффективны, их рано или поздно придётся соптимизировать. К примеру для финансовых приложений numeric часто ограничивается точностью - numeric(x,y), который вполне может быть покрыт типом c фиксированной длиной int64, int128, или int256. Однако это уже тема для отдельной статьи ... .
THE END,
6 августа 2026 г., Мадрид, Испания.
Интересно? Подписывайтесь на наш блог.
Tzimie
Значит параллелизм до сих пор не починили. То что MSSQL умел делать из коробки 20 лет назад...
danolivo Автор
Ну так кто бы инвестировал в разработку хотя бы небольшую долю от SQL Server'ных вливаний. Так ведь нет, все хотят и хорошо, и бесплатно. Так что полагаю этот гэп обусловлен самой структурой бизнесов.
Разве что AI как-то ситуацию изменит - вроде бы он эффективнее на открытых, хорошо структурированных кодах, чем на всякой лапше, свойственной энтерпрайзам ... .
alex7six
Я уже проектирую свое первое расширение.
Fable предложил 3 варианта проработки:
Уровень 1: грубая эвристика в расширении, без ядра
Уровень 2: честный провенанс, форк или инвазивное расширение
Уровень 3: патч в ванильное ядро
И как он пишет " Уровень 1 я бы реализовал уверенно". Скоро узнаем..