Все предыдущие статьи этого цикла крутились вокруг одной темы: как сделать OLTP предсказуемым. Мы разбирали p99 latency и почему оно уезжает при смешанной нагрузке. Писали про API-контракты между слоями ядра, про WAL-before-data как фундамент корректного recovery, про Buffer Pool с Clock-sweep и BufferRing, который изолирует полные сканы от горячего рабочего набора. Всё это фундаментальная инженерная работа для предсказуемого OLTP-ядра.

Но есть одна вещь, которую мы намеренно не трогали в предыдущих статьях.

С самого первого дня архитектура нашей базы закладывалась как HTAP-ready (Hybrid Transactional/Analytical Processing). Не «добавим аналитику потом», а «в ядре с нуля есть место для векторизованного выполнения». Это решение влияло на выбор типов данных, формат батчей, структуру планировщика. Эта статья про это направление.

Что реализовано на момент публикации: PhysicalType и колоночные батчи; SelectionVector; RowToColumnBridge и BatchToRowBridge; векторизованные VectorSeqScan и VectorIndexScan; SIMD-ускоренный lower-bound на листовых страницах B-Tree (под отдельным feature-флагом сборки на nightly Rust); автовекторизуемые компилятором фильтрационные кернелы для i64/f64 колонок на стабильной toolchain; векторизованный Hash Join и хэш-агрегации. Что ещё не готово: планировщик пока не умеет автоматически выбирать между VectorSeqScan и VectorIndexScan на основе актуальной статистики; колоночное хранилище на диске (данные сейчас row-store, конвертация происходит через RowToColumnBridge на лету); колоночные хэш-кернелы по типам уже есть, но в горячий путь Hash Join подключаются отдельным шагом.


Проблема: почему построчное исполнение плохо считает

Исторически OLTP-базы строились вокруг модели Volcano-итератора, или tuple-at-a-time execution. Каждый оператор плана выполнения передаёт следующему данные по одной строке таблицы. Для коротких транзакционных запросов вроде SELECT * FROM users WHERE id = 1 это работает отлично: нашли одну строку, вернули, закончили.

Проблема проявляется, когда нужно посчитать агрегацию по миллиону строк. Построчная модель начинает ощутимо проигрывать по нескольким причинам:

  • Косвенный вызов (dispatch) на каждую строку. Каждое next() в классическом Volcano — это обращение через vtable, и процессор плохо предсказывает такие ветвления (branch misprediction) при высоком темпе чередования операторов.

  • Плохая утилизация кэшей L1/L2. Строки в row-store лежат вперемежку с разными полями, а нам для агрегации часто нужна только одна колонка.

  • Нет возможности применить SIMD. Процессор умеет обрабатывать сразу несколько значений одной SIMD-операцией, но tuple-at-a-time pipeline не даёт ему такой возможности: данные поступают поштучно.

Для транзакционных запросов всё это незаметно: запрос затрагивает 1–10 строк. Но для аналитических сценариев (агрегации, соединения, сканирование диапазонов) векторизованный подход снижает накладные расходы по сравнению с построчным исполнением на тех же данных.


От Value к PhysicalType: как выглядят данные внутри векторизованного батча

В классическом построчном исполнителе каждое значение обычно представляется как enum, примерно вот так:

enum Value {
    Int(i64),
    String(String),
    Null,
}

Удобно для разработчика. Но такой формат плохо подходит для плотной кэш-локальности и SIMD-обработки: массив значений в памяти выглядит как решето с разным выравниванием, тегами перечислений и указателями на heap. Никакого SIMD на таком массиве не сделаешь.

Мы перешли на PhysicalType. Векторизованный батч хранит данные не по строкам, а по колонкам. Колонка Int64 — это непрерывный массив Vec<i64>; для горячих SIMD-путей выравнивание обеспечивается отдельно. Это позволяет загружать несколько значений в SIMD-регистры и обрабатывать их одной операцией, что повышает пропускную способность на подходящих архитектурах.

Батч в памяти имеет фиксированный верхний предел строк (по умолчанию 1024) и состоит из трёх частей:

  1. Набора типизированных колоночных векторов. Для каждой колонки свой непрерывный массив нужного типа.

  2. Null bitmap. Vec<u64>, где 1 бит отвечает за 1 строку (1 значит «значение есть», 0 значит NULL). Упаковка в слова по 64 бита позволяет работать с целыми группами строк через bitwise-операции вместо побитового цикла.

  3. SelectionVector.

Почему 1024 строки? Это компромисс между накладными расходами на вызов оператора и локальностью данных. Для колонки Int64 такой вектор занимает 8 КБ, то есть несколько горячих колонок и служебные структуры всё ещё помещаются в L1/L2 на типичных x86-64 CPU. Размер можно менять через настройку vector_batch_size, но верхняя граница 1024 оставлена как консервативное значение по умолчанию для кэшей и предсказуемого потребления памяти.

На последнем пункте стоит задержаться, потому что он нетривиален.


SelectionVector: как фильтры работают без копирования данных

Допустим, у нас батч из 1024 строк, и мы применяем фильтр WHERE age > 18. После фильтрации подходит, скажем, 300 строк. Как передать результат дальше?

Наивный вариант: удалить неподходящие строки из колонок, сдвинуть элементы. Это O(N) копирований на каждый фильтр, и при цепочке из пяти операторов мы платим за копирование пять раз.

Другой вариант: битовая маска (bitmask). Один бит на строку. Но при последующих операциях (хэшировании, проекции) каждый оператор вынужден распаковывать маску перед работой с данными.

Мы выбрали список индексов. SelectionVector — это Vec<u16>, который содержит индексы строк, прошедших фильтрацию: [0, 3, 7, 8, ...]. При последующих операциях следующий оператор итерируется по плотному массиву индексов, напрямую обращаясь к нужным элементам в колонках, без промежуточного распаковывания.

Этот выбор особенно хорош при умеренной и низкой селективности, когда после фильтра остаётся небольшая доля строк. При высокой плотности прошедших строк, например 900 из 1024, список индексов становится длинным, а доступ к колонкам через него хуже плотного последовательного прохода. В текущей реализации всегда используется SelectionVector; адаптивный выбор между SelectionVector и bitmask по наблюдаемой селективности — задача следующей итерации.


RowToColumnBridge: мост между row-store и векторизованным пайплайном

Наши данные на диске хранятся в формате строк таблицы (row-store). Это стандартное решение для OLTP: точечные чтения и запись по первичному ключу там оптимальны. Но как их передать векторизованному исполнителю?

Для этого есть оператор RowToColumnBridge. Он читает строки с диска (уже после проверки MVCC-видимости, так что векторизованному исполнителю не нужно ничего знать о транзакциях) и транспонирует их в колоночные батчи нужного формата. Проверку видимости делает scan-слой, который читает heap-страницы под snapshot: невидимые версии он отбрасывает до передачи строки в мост.

Да, здесь есть оверхед на конвертацию. Но даже с учётом этого транспонирования последующая векторизованная фильтрация и агрегация могут окупать стоимость моста. А ещё это архитектурный задел: когда данные будут лежать в колоночном формате на диске, этот мост заменится на оператор прямого колоночного чтения с минимизацией копирования; конкретная степень зависит от формата хранения, кодеков и структуры страниц. Весь остальной пайплайн (фильтры, соединения, сортировки) при этом останется неизменным.

RowToColumnBridge работает в потоковом режиме. Строки читаются батчами, каждый батч сразу транспонируется и передаётся вниз по пайплайну. Это позволяет не материализовывать весь результат в памяти и корректно завершать выполнение досрочно при наличии LIMIT.


SIMD в B-Tree: где векторизация помогает, а где нет

Полные сканы таблицы с векторизацией работают хорошо. Но OLTP-нагрузка — это прежде всего точечные запросы и короткие диапазоны по индексам. Можно ли векторизовать обход B-Tree?

Частично. Всё зависит от того, о какой части B-Tree идёт речь.

Спуск по дереву: здесь SIMD не помогает

Когда мы ищем ключ в B-Tree, первая фаза — спуск от корня до листовой страницы. Читаем корневую страницу, находим нужный указатель, спускаемся ниже, и так до листа. Это классический pointer-chasing: процессор упирается в задержку доступа к памяти, ожидая следующий узел дерева. SIMD ускоряет вычисления над плотным массивом данных, а здесь главный расход — ожидание следующей страницы.

Листовые страницы: где SIMD уже работает по данным

Листовая страница содержит плотный массив ключей. Вместо классического partition_point по одному элементу мы загружаем ключи в SIMD-регистры и одной операцией находим первую позицию, удовлетворяющую границе диапазона (lower-bound). Это ускоряет именно начало диапазонного скана — WHERE created_at BETWEEN x AND y, WHERE id >= ? — а дальше плотный последовательный проход по листу делает остальное. На нашем AVX2-стенде в синтетических тестах с диапазонами шириной от 16 ключей на страницу мы наблюдали ускорение 2–3× по сравнению со скалярным partition_point. Это не универсальная константа: конкретный коэффициент зависит от ISA, ширины регистра, типа данных и ширины диапазона.

Скалярный путь на листе выглядит как цикл по одному ключу:

for key in leaf.keys() {
    if key >= lower && key <= upper {
        tids.push(key.tid());
    }
}

Векторизованный путь делает то же сравнение группами: загружает несколько ключей в SIMD-регистр, получает маску совпадений и определяет позицию первой подходящей строки за одну операцию. Мы не пытаемся «векторизовать B-Tree целиком»; ускоряется именно плотный leaf-scan, где данные уже лежат рядом, и в первую очередь lower-bound поиск, с которого начинается любой диапазонный скан.

В текущей реализации std::simd-путь на листе включается отдельным feature-флагом сборки (он опирается на nightly-Rust API), а сборка по умолчанию использует скалярный fallback на стабильной toolchain. В горячих местах самого батчевого исполнителя (фильтры по i64 и f64 колонкам) мы намеренно полагаемся на autovectorization компилятора: код написан так, чтобы LLVM мог сам сгенерировать AVX2/NEON. Это даёт переносимый stable-build «по умолчанию» и явный nightly-путь там, где он действительно нужен.


VectorIndexScan: как индексный поиск вписывается в векторизованный пайплайн

Интеграция индексов в векторизованный движок потребовала нового оператора. Он называется VectorIndexScan, и его пайплайн работает в четыре шага:

Шаг 1. Потоковый обход листовых страниц. Итератор пробегает по листам B-Tree с применением SIMD-сравнений и собирает батч TID-ов (Tuple Identifier). Итератор ленивый: он не материализует весь результат в память, что позволяет корректно завершать выполнение досрочно при LIMIT.

Шаг 2. Heap fetch. По собранным TID-ам читаем сами строки из табличных страниц. Это случайный ввод-вывод, и его стоимость зависит от того, насколько TID-ы кластеризованы на диске. При низкой кластеризации (данные вставлялись не по порядку) этот шаг может стать узким местом.

Шаг 3. Проверка видимости и транспонирование. Строки проходят snapshot-based MVCC-проверку (здесь тоже есть накладные расходы, особенно при длинных цепочках версий) и транспонируются в колоночный батч через RowToColumnBridge.

Шаг 4. Residual filter. Векторизованный фильтр применяется к условиям, которые не вошли в индекс. Например, при WHERE indexed_col = 5 AND non_indexed_col > 10 индекс покрывает первое условие, а второе применяется уже на батче через SelectionVector.

Более честная оценка стоимости выглядит не как просто O(log N + K), а как O(log N + K × heap_fetch_cost + K × visibility_cost). Если TID-ы хорошо кластеризованы, heap_fetch_cost близок к последовательному чтению страниц; если плохо — это серия случайных обращений. Поэтому итоговая цена часто определяется не самим поиском в индексе, а кластеризацией TID-ов, локальностью heap fetch, длиной version chains и тем, покрывает ли индекс нужные колонки. При K в несколько тысяч строк heap fetch и MVCC-проверка могут составлять большую часть времени выполнения, даже если leaf-scan по индексу отработал быстро.


Когда SeqScan, а когда IndexScan: эвристика планировщика

У планировщика теперь есть осмысленный выбор между двумя операторами:

  • VectorSeqScan читает всю таблицу последовательно. Ввод-вывод при этом последовательный, что хорошо для дисковых носителей. Сложность O(N).

  • VectorIndexScan спускается по B-Tree и делает heap fetch по TID-ам. Ввод-вывод при этом может быть случайным. Стоимость ближе к O(log N + K × heap_fetch_cost + K × visibility_cost), где K количество подходящих строк.

В текущей реализации используется эмпирическая эвристика: на нашем стенде индексный путь начинает выигрывать на низкой селективности, когда запрос затрагивает только малую долю таблицы. При более высокой селективности часто дешевле последовательно прочитать таблицу с векторизованной фильтрацией, потому что последовательный ввод-вывод обычно эффективнее случайного. Точный порог сильно зависит от степени кластеризации данных, характеристик носителя, ширины строки и покрытия индекса, поэтому это не «универсальное правило 5%», а рабочая граница для конкретной конфигурации.

Дальнейшее развитие планировщика предполагает актуальную статистику и выбор на основе оценки стоимости, но для текущего состояния системы такая эвристика работает разумно.


Векторизованный Hash Join и агрегации

Помимо сканирования, на векторизованные операторы переведены соединения и агрегации. Это важно: даже если отдельные сканы быстрые, без векторизованного соединения выигрыш теряется при первом JOIN.

Hash Join разделён на две фазы. На build-фазе меньшая сторона соединения материализуется в хэш-таблицу; для HTAP-сценариев это важное ограничение, потому что build-сторона должна помещаться в доступный лимит памяти или оператор должен перейти в более сложный режим со сбросом на диск. На probe-фазе большая сторона приходит батчами: для каждой строки батча вычисляется ключ соединения, и затем выполняется обычный probe по хэш-таблице. Главный выигрыш здесь не в SIMD-хэшировании в горячем цикле, а в устранении косвенных вызовов между операторами: фильтрация и проекция перед соединением уже отработали колоночно по всему батчу. Реальная пропускная способность probe-фазы дополнительно зависит от структуры хэш-таблицы и локальности обращений к памяти. Колоночные хэш-кернелы для i64/bool/utf8 уже есть в коде, но подключение их в горячий путь Hash Join запланировано отдельным шагом.

Агрегации (GROUP BY, SUM, COUNT, AVG) работают аналогично: операции применяются к колонке целиком, а не к каждому элементу по отдельности. Null bitmap при этом обрабатывается через word-level операции над Vec<u64>, что быстрее побитового цикла, хотя проход по массиву слов всё равно необходим.


Что показывают замеры

Мы прогнали сравнительный бенчмарк на внутренней эталонной базе: локальный NVMe, Linux 6.12, Intel Core i5-12400, 32 ГБ RAM, buffer pool 2 ГБ, vector batch size 1024. Таблица seed_order_items содержит 2 250 000 строк и хранится в row-store. Сценарий ниже — warm-cache: перед серией запросов буферный пул прогрет, drop_caches не использовался. Это не cold-cache измерение «с чистого диска», а сравнение двух путей исполнения на одной и той же базе. Режимы включались перезапуском сервера с execution.mode = "force_row" и execution.mode = "force_vector".

Важно: все три запроса в таблице ниже — последовательные сканы. Это не бенчмарк VectorIndexScan; индексный путь зависит от селективности, кластеризации TID-ов и покрытия индекса, поэтому для него нужен отдельный набор измерений.

Для каждого запроса было 5 прогонов в каждом режиме. В таблице указаны среднее, стандартное отклонение по пяти прогонам и диапазон min–max:

Запрос

force_row

force_vector

Ускорение по avg

SUM(qty) + SUM(unit_price) + COUNT(*) по всей таблице

avg 1051 мс; σ 58; min–max 988–1129

avg 865 мс; σ 12; min–max 854–887

1.22×

COUNT(*) WHERE qty > 2 (фильтр без индекса)

avg 1068 мс; σ 43; min–max 990–1116

avg 766 мс; σ 17; min–max 736–788

1.39×

GROUP BY product_id + SUM(qty)

avg 2699 мс; σ 181; min–max 2494–3036

avg 1012 мс; σ 47; min–max 954–1088

2.67×

GROUP BY — это то место, где разрыв наиболее заметен: батчевая обработка ключей хэш-агрегации даёт 2.67× на той же row-store базе. Простые полнотабличные агрегаты дают 1.22–1.39×, что честнее отражает ситуацию с row-store на диске: часть выигрыша съедается конвертацией через RowToColumnBridge, чтением row-store страниц и кэш-поведением.

Несколько оговорок, которые важно держать в голове:

  • Данные хранятся в row-store. Когда появится колоночный формат на диске, RowToColumnBridge уйдёт и картина изменится в пользу векторизованного пути.

  • Порог переключения VectorSeqScan/VectorIndexScan подобран эмпирически на этом стенде. На другом профиле носителя, при другом размере строк или другой кластеризации TID-ов граница сдвигается.

  • Ускорение SIMD на листовых страницах B-Tree зависит от ISA и ширины диапазона. Отдельные замеры по этому пути будут в следующих материалах.


Как это держится вместе

Принципиальный момент в архитектуре: мы не добавляли векторизованное выполнение поверх существующего OLTP-ядра. PhysicalType вместо Value появился не потому что кто-то оптимизировал горячий путь, а потому что такой выбор был сделан на этапе дизайна типов. RowToColumnBridge спроектирован так, чтобы MVCC-слой не знал про колоночный формат вообще: он возвращает строки, а мост транспонирует их дальше. Это позволило держать OLTP-путь (точечные транзакции, pin/unpin, no-steal, WAL) и аналитический путь (батчи, SIMD, SelectionVector) в одном движке без взаимных зависимостей — слои разделены контрактами.

Что пока не сделано: планировщик не использует актуальную статистику для выбора оператора; данные на диске хранятся в row-store (переход на колоночный формат — отдельная задача с собственным форматом страниц и миграцией); оконные функции в векторизованном пайплайне не реализованы. Все эти пункты зафиксированы в дизайн-документах как следующие шаги.


Что проверять дальше

Главный открытый вопрос теперь не в том, можно ли встроить векторизованный пайплайн в OLTP-ядро. Можно. Интереснее посмотреть, как он ведёт себя на реальной смешанной нагрузке: с живыми индексами, неидеальной кластеризацией данных, длинными version chains, разными размерами buffer pool и запросами, которые меняются со временем.

Это направление — отдельный большой блок работы: как собрать воспроизводимый HTAP-бенчмарк, какие метрики смотреть кроме среднего времени запроса, почему один и тот же VectorIndexScan может быть быстрым на одном профиле данных и проигрывать последовательному скану на другом. К этим вопросам мы ещё вернёмся в следующих материалах цикла, по мере того как набираются собственные измерения и наблюдения с реальных стендов.


Закрытое бета-тестирование

Мы рассказываем технические детали и открываем закрытое бета-тестирование, чтобы проверить заявленные в статье решения на реальной нагрузке и не распыляться на «всё для всех». Никаких обязательств по закупкам, никакого enterprise-sales — это техническая проверка с обеих сторон.

Поддерживаемый стек на момент публикации:

  • PostgreSQL wire-протокол: simple query и extended query (Parse/Bind/Execute).

  • Драйверы: psql, psycopg3 (3.3.4), tokio-postgres (0.7.17, simple + extended), sqlx (0.8.6).

  • ORM/прикладные системы: Django 5.x на psycopg3, Odoo 19 (community).

  • Сборка: Linux x86_64, glibc ≥ 2.28 (пакеты .rpm и .deb, tarball).

Подходящий профиль команды:

  • сейчас в production стоит PostgreSQL и есть метрики по нему;

  • стек целиком ложится на список выше, без зависимости от хранимых процедур, триггеров и редких расширений;

  • есть инженер, который будет вести тестирование с вашей стороны и сможет договариваться об измерениях.

Сроки и формат:

  • Заявки принимаются до 25 мая 2026.

  • Дальше обсуждаем индивидуально с каждой командой: сроки, сценарий, объём данных, критерии успеха, что именно фиксируем в бенчмарк-сессии под NDA.

Если интересно поучаствовать — напишите на team@angarabase.com: коротко о вашей системе, стеке и сценарии, который хотите проверить.