В прошлом уроке мы посмотрели, как тратится 95% CPU на простом OLAP-запросе в Postgres: function call overhead, tuple deform, MVCC checks, и только 2-5% — на полезную работу. В этом уроке смотрим, как vectorized execution перестраивает этот пирог.
Тема для senior-backend и data-инженеров критическая: к 2026 году аналитическая нагрузка почти везде утекла с Postgres на специализированные движки (DuckDB, ClickHouse, Snowflake, BigQuery). Понять «почему» — значит понимать ровно один архитектурный приём.
Одна правка в next()
Vectorized model (она же batched execution) родилась в проекте MonetDB/X100 (CWI, ~2005) и популяризирована в статье “MonetDB/X100: Hyper-Pipelining Query Execution”. Изменение в volcano минимальное:
- Было:
next()возвращает 1 строку. - Стало:
next()возвращает batch (vector) из 1024-10240 значений одной колонки.
То есть вместо Tuple узел отдаёт Vector<int32> или Vector<varchar> — массив значений фиксированной длины. Колонки a, b, c идут отдельными векторами, организованными в DataChunk (DuckDB) или Block (ClickHouse).
Слева: volcano отдаёт по одной row. Справа: vectorized отдаёт vector из 1024 значений одной колонки. Узел делает работу за весь vector сразу.
Всё, что было модульно в volcano, остаётся модульным: HashJoin всё ещё не знает, что под ним. Только теперь он принимает не Tuple, а DataChunk — структуру с N векторами. И вся работа узла выполняется батчем.
Почему это меняет всё
Кажется, мелкая правка: «отдаём не одну, а 1024». Но из неё выливается серия архитектурных эффектов.
1. Function call amortized
Вместо 100M вызовов next() — 100K. Function call overhead падает в 1000 раз. И поскольку в каждом batch одинаковый тип данных, indirect call всегда попадает в branch predictor — никаких misprediction.
2. SIMD естественно работает
CPU имеет
[10, 11, 12, 13, 14, 15, 16, 17]. Это ровно то, что даёт vector.
Compiler сам генерирует SIMD-код для простого цикла:
// псевдокод vectorized filter
for (int i = 0; i < 1024; i++) {
selection[i] = data[i] > 50000;
}
GCC/Clang компилирует это в AVX2-инструкции, и фильтр выполняется со скоростью 8 значений за такт. Сравни с row-by-row: каждое значение — отдельный compare + branch.
3. CPU cache hit rate
Vector из 1024 int32 = 4 KiB. Два-три вектора умещаются в L1 (32-64 KiB). Узел работает с горячим L1-кэшом на протяжении всего batch’а, потом переключается на следующий узел — но и тот тоже видит batch в L2 (свежий из L1).
В volcano модели в любой момент времени в L1 лежит один tuple slot, один buffer page (8 KiB), несколько структур узлов. Любой межузловой переход — потенциально L1 miss.
4. Branch misprediction исчезает
В volcano filter выглядит так: if (tuple.c > 50000) emit(tuple). На случайных данных predictor ошибается 50% времени. В vectorized — мы не «эмиттим» сразу, а накапливаем selection vector (bitmask или массив индексов), и потом передаём дальше уже только прошедшие. Branch заменяется на data-dependent move (CMOV), у которого нет predictor.
5. Tuple deform уходит
В volcano распаковка row из heap tuple — 20-30% времени. В vectorized данные уже хранятся как колонки (см. следующий урок про column store) или, как минимум, decode’ятся в колонки на первом узле — и дальше живут в этом виде. Никаких alignment-paddings, null-bitmap’ов на каждой строке.
6. Selection vector вместо if/break
В volcano filter работает так: для каждой строки if (predicate(row)) emit(row); else continue;. Это branchy код. В vectorized filter не emit’ит сразу — он строит selection vector: либо bitmask (1 бит на позицию vector’а), либо плотный массив индексов прошедших.
// псевдокод vectorized filter с selection vector
int sel_count = 0;
for (int i = 0; i < 1024; i++) {
sel[sel_count] = i;
sel_count += (data[i] > 50000) ? 1 : 0; // branchless через CMOV
}
Этот код компилируется в branchless машинный — никаких jump-инструкций по condition. CPU предсказатель ветвлений не трогается. Дальше вектор sel передаётся следующему узлу: «работай только с позициями [sel[0], sel[1], …, sel[sel_count-1]] исходных векторов». Это selection vector pattern, изобретённый ещё в MonetDB.
Сравним стоимости
SELECT sum(amount) FROM orders на 100M строк. Условная разбивка по типам циклов CPU. Реальная разница на бенчмарках TPC-H — около 30-50x в пользу vectorized.
Не «volcano + SIMD = vectorized». А именно архитектурная перестройка операции на batch level. Все остальные оптимизации (SIMD, cache locality, branch elimination) — следствия.
Вариации vectorized: pure vs hybrid
Vectorized execution не монолит — есть нюансы реализации:
- Pure vectorized (DuckDB, MonetDB): всё внутреннее состояние engine — векторы. Каждый узел получает
DataChunkи возвращаетDataChunk. - Vectorized + code generation (Photon Databricks, Snowflake, HyPer): на стадии планирования генерится специализированный C++/LLVM-код, который уже знает типы колонок и обходит interpret-loop. Это гибрид: batched + JIT, теоретически даёт ещё +2-3x поверх pure vectorized.
- Morsel-driven parallelism (HyPer): vector чанки (морселы) распределяются по workers через work-stealing. Это позволяет почти линейный speedup на многоядерных CPU без блокировок shared-state.
ClickHouse начинал с pure vectorized с огромным vector size (65536), сейчас активно добавляет code generation для горячих путей. DuckDB ставит на pure vectorized с фокусом на простоту code-path’а и предсказуемость performance.
Размер vector’а: почему 1024-10240
Выбор размера батча — компромисс:
- Слишком маленький (< 256) — overhead next() начинает доминировать. Function call amortization падает.
- Слишком большой (> 65536) — vector не помещается в L1/L2 cache, преимущество cache locality исчезает.
- Sweet spot: 1024-2048 значений × 4-8 байт = 4-16 KiB на vector. Помещается в L1 (32-64 KiB) вместе с парой соседних структур.
DuckDB: 2048. ClickHouse: 65536 (большой, потому что заточен под огромные плотные сканы distributed). Apache Arrow recordbatch: рекомендуется 1024-65536, конкретный размер выбирает приложение.
Где встречаешь vectorized в реальности
- DuckDB — embedded аналитический движок. Самый чистый vectorized: chunk size = 2048, в памяти, intra-process. Главный конкурент SQLite для OLAP.
- ClickHouse — open-source columnar OLAP. Vectorized + columnar storage + distributed. Vector size = 65536 (огромный, чтобы амортизировать межсерверный transfer).
- Snowflake / Photon (Databricks) — облачные columnar warehouses. Vectorized движки на C++ под Spark/SQL.
- DataFusion (Apache Arrow) — вектор-фреймворк на Rust. Используется в Ballista, InfluxDB IOx, многих коммерческих движках.
Postgres сам по себе vectorized не делает. Есть расширения (см. следующий урок) — Citus columnar и pg_lakehouse — которые добавляют vectorized путь поверх. Но в core PG до сих пор честный volcano. На roadmap PG 18-19 обсуждаются эксперименты с “batched execution”, но это пока research.
Память: где живёт vector
Vector в vectorized engine — это плотный массив в RAM. Базовый аллокатор обычно — арена / bump-allocator на запрос: при начале запроса резервируется большой блок RAM, и все векторы выделяются из него последовательно. По окончании запроса арена сбрасывается одним движением.
Это даёт двойную выгоду: (1) нулевая фрагментация, (2) аллокация = бамп указателя (10-20 циклов CPU вместо malloc’a в сотни циклов). Postgres использует похожий приём через memory contexts (palloc в ExecutorState context), но granularity там — отдельные структуры, не векторы.
Важный нюанс: для строковых колонок vector — это массив указателей на heap-allocated значения, плюс отдельная арена, где живут сами байты строк. То есть varchar[1024] — это 1024 указателя по 8 байт (8 KiB указателей) + сами данные строк в соседней арене. Это позволяет SIMD-операции над указателями (например, фильтр по IS NULL), но строковые сравнения остаются скалярными.
Как это видно на сравнении
В sandbox мы можем запустить Postgres-запрос и потом представить, как тот же запрос вёл бы себя на vectorized engine:
Простой OLAP в Postgres. На 100K строк это работает быстро, но представь масштаб 100M: умножь время на 1000. Это и есть аналитическая боль OLTP-движков.
Тот же запрос на DuckDB на 100M строк проходит за ~0.5 секунды (на ноутбуке). Postgres на тех же данных, с теми же индексами, без расширений — 2-3 минуты. Не из-за «плохого Postgres», а из-за разницы архитектур.
Попробуем второй запрос — с filter + aggregation, где volcano особенно слаб:
Filter + aggregate. Volcano прокачивает каждую строку через узлы. Vectorized сделал бы то же самое одним SIMD-сканом vector'а — 8 значений за такт CPU.
В Postgres это Seq Scan + Filter + HashAggregate. На vectorized engine — batched scan по колонке placed_at (SIMD compare), затем selection vector кладёт прошедшие индексы, и aggregation по trois колонкам идёт SIMD-add’ами. Та же логика, в 30-50 раз быстрее.
Null handling: отдельная боль
Vectorized движкам нужно эффективно обрабатывать NULL. Реализуется обычно через validity bitmap (Apache Arrow): отдельный 1-bit-per-value битмап рядом с массивом данных. Bit = 1 значит valid, 0 значит null.
Это важно для SIMD: data-операции работают на raw байтах независимо от null-bitmap, а потом null-bitmap применяется как маска. На сравнениях типа WHERE x > 5 результат — это AND двух bitmap’ов: «прошёл сравнение» AND «не null». SIMD умеет AND’ить bitmap’ы со скоростью 256 бит за такт.
В row store null хранится в null bitmap каждого tuple отдельно, и проверка делается на каждой строке через att_isnull(slot, attno). Это ещё один источник tuple deform overhead’а.
Постгрес и vectorized: фантомные планы
В PG-сообществе периодически появляются proof-of-concept’ы vectorized executor’а для core PG. Самая заметная попытка — VOPS (Vectorized OPerations), forks на GitHub. Идея: добавить специальные типы данных vop_int4 и т.д., которые внутри — векторы из ~1024 значений. SQL переписывается, чтобы оперировать этими типами, и planner выбирает specialized execution path.
Результаты: 10-20x speedup на TPC-H Q6, но требует переписать запросы и таблицы. То есть полноценной интеграции не получилось — нативный vectorized в Postgres core означал бы перепроектировать executor, MVCC и tuple slot.
Поэтому реальный путь Postgres-экосистемы — это расширения через TAM API (Citus columnar, Hydra), и отдельный движок через CDC (ClickHouse, DuckDB), а не переписывание core.
Когда vectorized не выигрывает
Архитектура не панацея.
- Высокая селективность фильтра. Если запрос трогает 1 строку из миллиарда, batched loop всё равно прокачивает batch’и впустую (хотя селективные оптимизации помогают). Volcano с index lookup всё ещё король.
- OLTP-нагрузка. Транзакция, которая делает один UPDATE одной строки, — это не то, ради чего batched execution существует. Vectorized engines обычно вообще не имеют MVCC или имеют примитивный (Snapshot Isolation на immutable storage).
- Очень узкие плоские таблицы. Если у тебя таблица с 2 колонками и запрос трогает обе, выигрыш меньше — меньше «лишней» работы было в volcano.
- Запросы с десятками тысяч мелких сабквери. Каждый сабквери = свой batched loop с собственным overhead’ом инициализации.
Поэтому DuckDB не пытается заменить Postgres. И поэтому ClickHouse прямо в документации пишет «не используйте нас для transaction processing». Это инструмент конкретной формы.
Где это потрогать руками
Хочешь увидеть разницу не на словах — DuckDB ставится как single-binary за минуту:
brew install duckdbили скачать сduckdb.org. Никаких серверов.- Стандартный SQL, очень близкий к Postgres SQL.
- Можно подключиться к Postgres напрямую:
INSTALL postgres; LOAD postgres; ATTACH 'host=... dbname=...' AS pg (TYPE postgres);и потомSELECT * FROM pg.orders— DuckDB прочитает данные Postgres и обработает их своим vectorized engine. - Или экспорт в Parquet:
COPY (SELECT * FROM pg.orders) TO 'orders.parquet' (FORMAT PARQUET);— и дальше работа с локальным Parquet, который весит в 5-10 раз меньше heap’а.
Это самый дешёвый способ почувствовать разницу: тот же SQL, те же данные, разные движки. Time EXPLAIN ANALYZE рядом — наглядное доказательство теории.
Чек-лист
- Vectorized execution = volcano, где
next()возвращает batch (vector) из 1024-10240 значений. - Эффекты: function call amortized, SIMD возможен, L1 cache hit, branch misprediction исчезает, tuple deform не нужен.
- Real-world skoki: 30-50x на TPC-H класс запросов против row-by-row.
- Используется в DuckDB, ClickHouse, Snowflake, Photon, DataFusion.
- Postgres core — volcano. Vectorized-расширения существуют (следующий урок), но это не «нативный» путь PG.
- Не панацея: проигрывает на OLTP, на index lookup, на mostly-skip нагрузке.
- Архитектурная перестройка важнее, чем «добавим SIMD» — последнее без батчей бесполезно.