CedrusData Engine — это lakehouse-движок, основанный на Trino. На реальных пользовательских нагрузках наш продукт рутинно превосходит по производительности другие технологии (Trino, Doris, Dremio, StarRocks) в 1.5-3 раза. С еще более значительным отрывом — от устаревших Greenplum и Impala. Эти результаты — следствие постоянных вложений в разработку новейших техник обработки больших данных. В этой статье я расскажу про проект Oxide — нашу инициативу по переписыванию ядра Trino с Java на Rust.

Данная статья — вольный пересказ моего одноименного доклада с ежегодной конференции ИСП РАН, прошедшей в декабре 2025 года. Статья имеет развлекательный уклон, а доклад содержит больше технических деталей.

Мотивация

Обработка данных в lakehouse характеризуется относительно медленным доступом к данным S3 и высокой разнородностью пользовательских (а теперь еще и агентских) запросов. В таких условиях основными источниками производительности являются:

  1. Оптимизация работы со storage: выбор подходящего продукта, data skipping, кэширование.

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

  3. Непосредственно движок: операторы, алгоритмы и т.п.

Наш многолетний опыт разработки аналитических систем говорит о том, что на первые два пункта приходится большинство проблем производительности. А вот особенности работы движка чаще заявляют о себе тогда, когда storage и оптимизатор доведены "до блеска".

Рис. 1 — Вклад в производительность различных компонентов lakehouse (экспертная оценка автора)
Рис. 1 — Вклад в производительность различных компонентов lakehouse (экспертная оценка автора)

На протяжении последних лет мы активно занимались оптимизатором нашего продукта и разными аспектами работы со storage. Так, мы первые в сообществе Trino реализовали локальный дисковый кэш данных S3, внедрили новейшие техники планирования JOIN (DPHyp и планирование OUTER JOIN), научили оптимизатор удалять повторяющиеся подзапросы, разработали систему автоматической подстановки материализованных представлений, внедрили уникальные техники переписывания запросов, которые кратно снижают количество обрабатываемых данных. В ближайшее время мы анонсируем новый storage на основе Apache Ozone, оптимизированный под задачи lakehouse.

По мере накопления наработок в части storage и оптимизатора, перед нами начали все чаще вставать вопросы эффективности рантайма. Один из них — удельная эффективность операций, выполняемых JVM. Мы провели ряд экспериментов по сравнению теоретической производительности Java, C++ и Rust, и летом 2025 года приняли решение начать процесс переписывания ядра Trino на Rust с использованием технологии Apache DataFusion. Так появился проект Oxide.

Уже в процессе реализации мы поняли, что переезд на Rust дал нам гораздо больше, чем просто ускорение Java.

Выбор инструментов

Переписывание ядра — трудоемкая и дорогостоящая задача. Поэтому первым делом нам необходимо было определить архитектуру нового решения. Мы сразу отбросили Velox и Gluten, отказались от полного переписывания кода worker-ов (вариант Prestissimo) и выбрали Rust вместо С++. Объясняем нашу мотивацию.

Velox

Velox — это проект компании Meta* (*запрещена в России). Он представляет собой написанный на C++ движок, который может выполнять запросы Presto и Spark. На первый взгляд это отличный кандидат, учитывая родственные связи между Presto и Trino. Но есть нюансы.

Рис. 2 — Высокоуровневая схема интеграции Velox (источник)
Рис. 2 — Высокоуровневая схема интеграции Velox (источник)

Проблема №1: закрытый процесс разработки. Velox не является полноценным open-source проектом. Процесс и скорость разработки полностью контролирует Meta*. Они принимают все ключевые решения в закрытом режиме, а репозиторий в GitHub по большому счету является зеркалом их внутренней активности. Вот как описывают это сами сотрудники Meta*:

Velox’s codebase was started in Meta’s internal monorepo, which still serves as a source of truth. Changes from pull requests in the Github repository are not merged directly via the web UI. Instead, the changes are imported into the internal review and CI tool Phabricator, as a ‘diff’. There, it has to pass an additional set of CI checks before being merged into the monorepo and in turn, exported to Github as a commit on the ‘main’ branch.

Работа с псевдо-open-source — это высокий риск остаться с неподдерживаемым проектом. Мы наглядно видели это на примере Greenplum и MinIO, прямо сейчас тяжелый момент переживает MySQL, туманным выглядит будущее StarRocks. Но еще более важно то, что Velox заставляет вас подстраиваться под тяжеловесный жизненный цикл продуктов в Meta*, и это постоянно забирает ваше время. Три года назад мы пробовали начать переписывание ядра на Velox и столкнулись с высокими накладными расходами на борьбу со структурой библиотек, системой билда, постоянное подмерживание обновлений и т.п.

Проблема №2: Velox заточен под Presto и таблицы Hive, а нас интересует Trino и Iceberg. Presto и Trino являются родственными движками, но они уже очень сильно отличаются друг от друга. Мы посчитали, что накладные расходы на адаптацию проекта под наши потребности будут слишком высокими.

Сам Velox можно интегрировать двумя способами.

Рис. 3 — Способы интеграции Velox
Рис. 3 — Способы интеграции Velox

Первый вариант: переписать весь код worker-узла на С++. По такому пути пошли в Meta* в проекте Prestissimo. Этот подход гарантирует минимальные кумулятивные трудозатраты на разработку (пишете код с нул��, не копаетесь в легаси), но растягивает процесс миграции на многие годы. Например, после более четырех лет разработки Meta* так и не смогла полноценно выкатить связку Velox/Prestissimo в продакшн. Очень показательно.

Второй вариант: переписывать код worker-узла итеративно, постепенно интегрируя Velox через JNI. На наш взгляд, это хороший подход, который обеспечивает плавную миграцию движка на новые рельсы. Но его нужно делать правильно. В разное время это пытались реализовать команды IBM и Intel, но оба проекта провалились, а команды были расформированы. Причина — неправильная архитектура. Рассмотрим характерные ошибки на примере проекта Intel Gluten-Trino:

  1. Gluten — это middleware для интеграции различных бэкендов в Spark. К Trino он не имеет никакого отношения, а потому лишь добавляет накладные расходы, снижает гибкость и производительность. Когда вы строите высокопроизводительный движок, вы хотите иметь как можно меньше абстракций и минимальное количество операций на горячем пути. Аналогия: пытаться выжать максимум из СУБД, работая через ORM-прослойку.

  2. Неправильные границы преобразования планов. Gluten-Trino переносит вычисления в Velox на уровне пайплайнов (цепочек операторов), тогда как в реальных запросах важно иметь возможность переноса в натив индивидуальных операторов.

  3. Кривая процедура сборки. Проект полагается на неестественную для Java подмену классов при старте приложения, которая не позволяет организовать нормальный процесс сборки и тестирования.

По отдельности с этими проблемами можно как-то жить, но в комплексе они делают решение нежизнеспособным. Занимавшаяся проектом команда Intel была разогнана одним днем в 2023 году, а проект оказался заморожен.

Для сравнения, пример правильной архитектуры в схожей задаче для Spark — проект Comet.

Таким образом, Velox как жизнеспособное решения нашей задачи, мы не рассматривали.

DataFusion

Apache DataFusion — это библиотека для создания аналитических движков. Ключевое преимущество относительно Velox: наличие полноценного open-source сообщества под эгидой Apache Software Foundation. DataFusion написан на Rust, активно развивается и обладает большей модульностью по сравнению с Velox.

Рис. 4 — Компоненты DataFusion (источник)
Рис. 4 — Компоненты DataFusion (источник)

Недостатки:

  • Библиотека не в полной мере разделяет зоны ответственности оптимизатора и экзекьютора. Для данного проекта нас интересовала только часть, связанная с вычислениями. Но архитектура DataFusion предполагает, что если вы реализуете какой-то оператор, то все равно должны потратить время на реализацию интерфейсов оптимизатора.

  • По сравнению с Velox семантика отдельных выражений DataFusion в меньшей степени соответствует требованиям Trino. Это значит, что нам потребуется самостоятельно реализовать больше выражений.

  • К середине 2025 года DataFusion не поддерживал ряд критически важных типов данных. Например, TIMESTAMP и DECIMAL128. К счастью, эта проблема была устранена сообществом прямо в момент разработки наших первых прототипов.

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

C++ или Rust

Важным решением был выбор языка программирования. Очевидные кандидаты — C++ и Rust.

С++ является наиболее зрелым и понятным решением. Но разработка кода промышленного уровня на нем сопряжена с высоким риском "проездов" по памяти. Разрабатывать сложную низкоуровневую логику на С++ одновременно быстро и качественно — не самая простая задача. Характерным примером является StarRocks, который предпочел скорость разработки в угоду качеству: баги, приводящие к падениям сервиса, появляются практически на ежедневной основе, а пользователи месяцами не могут переехать на актуальные версии.

Рис. 5 — Количество багов в StarRocks, вызванных некорректным доступом к памяти, с ноября 2025 года
Рис. 5 — Количество багов в StarRocks, вызванных некорректным доступом к памяти, с ноября 2025 года

По сравнению с С++ Rust является безопасным языком за счет строго контроля семантики операций. Цена этого — более сложный процесс обучения. Но после "вкатывания" вы начинаете создавать безопасный код высокого качества достаточно быстро. Другие преимущества Rust, которые были для нас важны:

  1. Нативная поддержка языком асинхронных примитивов. Folly и корутины C++ сильно проигрывают.

  2. Библиотека работы с Parquet на Rust является одной из самых продвинутых в мире по своему функционалу. Это повышало шансы, что мы сможем обойтись без написания своих Parquet reader/writer.

При этом Rust имеет и ряд достаточно неприятных проблем. Например:

  1. Borrow checker порой вынуждает вас использовать неоптимальные конструкции и алгоритмы. Например, вам будет очень сложно объяснить компилятору, что потокобезопасность конкретного куска кода гарантируется внешними инвариантами. В качестве домашнего упражнения посмотрите, как устроены метрики в DataFusion, и задайтесь вопросом, почему там везде Arc вместо Rc.

  2. На момент начала нашего проекта летом 2025 года библиотека Iceberg для Rust не поддерживала работу с delete файлами.

  3. На момент начала инициативы наша команда имела достаточно скудный опыт работы с Rust.

По совокупности факторов мы решили писать новое ядро именно на Rust. Сильно страдали первый месяц, после чего вышли на стабильно высокий темп разработки и ни разу не пожалели о своем решении.

Архитектура решения

Мы не рассматривали вариант полного переписывания кода worker-узла на натив ввиду высокой трудоемкости и низкой отдачи с точки зрения ожидаемого прироста производительности.

Worker состоит из большого количества компонентов: экзекьютор, локальный планировщик, обработка динамических фильтров, шафлы, управление ресурсами, метрики и мониторинг, огромное количество написанных на Java плагинов. Большая часть этих компонентов не оказывает влияния на производительность, потому переписывать их нет смысла.

Вместо этого мы решили расширить отдельные части worker-а, отвечающие за обработку данных, и организовать взаимодействие с нативом через JNI. Иными словами, мы пошли по пути Comet.

Конвертация планов

Первая точка интеграции — конвертация планов из Java в натив.

В ванильном Trino worker-узел получает от координатора оптимизированный план выполнения, который далее локальный планировщик разбивает на отдельные пайплайны. Пайплайн — это линейная последовательность операторов:

  1. Source — источник данных (таблица Iceberg, вход шафла, и т.д)

  2. Sink — потребитель данных (координатор, выход шафла, Iceberg writer, и т.д)

  3. Промежуточные операторы c произвольными вычислениями (Project, Filter, Aggregation, Join, Sort, TopN, и т.д)

Мы добавили в локальный планировщик дополнительный компонент, который конвертирует индивидуальные Java-операторы в эквивалентные операторы Oxide. В первой итерации мы добавили поддержку сканирования таблиц Iceberg, проекций и фильтрации. Эти три оператора формируют необходимый минимум, который позволяет начать получать выгоду от выполнения кода в Oxide. Далее мы добавили поддержку агрегаций, локальный шафлов и далее по списку.

Рис. 6 — Конвертация логического плана в пайплайны с операторами Oxide
Рис. 6 — Конвертация логического плана в пайплайны с операторами Oxide

В результате работы этого этапа мы получаем набор пайплайнов, каждый из которых может иметь нативные операторы.

Обработка данных в Oxide

После конвертации операторов мы сериализуем их с помощью Protobuf, передаем в Rust, десериализуем и создаем план выполнения DataFusion. Отмечу, что в ряде аналитических запросов с высокоселективными предикатами (AI-агенты, кликстрим, и т.п.), накладные расходы на сериализацию-десериализацию планов могут быть достаточно велики. Нам пришлось реализовать ряд оптимизаций, которые уменьшают количество циклов сериализации-десериализации путем переиспользования нативных планов. Это хороший пример, когда ненужная прослойка в виде Gluten могла бы помешать нам достичь высокой производительности.

Далее рантайм Trino активирует пайплайны по мере получения задач от координатора. В этот момент мы инициируем выполнение операторов в DataFusion и забираем в Java результаты в формате Arrow. Полученные данные мы передаем вышестоящим Java-операторам.

Рис. 7 — Архитектура Oxide
Рис. 7 — Архитектура Oxide

Важной частью решения является интеграция менеджера памяти worker-узла с нативными операторами. Мы реализовали собственный трекер памяти в Rust и интегрировали его в операторы DataFusion. Когда нативный оператор аккумулирует внутри себя новую порцию данных, он сначала накручивает внутренний счетчик, и при превышении определенной дельты нотифицирует memory manager Java об изменении потребления памяти. Таким образом, кластер всегда корректно отслеживает потребление памяти отдельными операторами, независимо от того, работают ли они в Rust или Java. Этот подход схож с unified memory manager в Comet.

Мы также реализовали проброс метрик операторов из Rust в Java, чтобы пользователи могли отслеживать ключевые показатели запроса. Например, скорость сканирования данных в S3. Для сравнения, полноценный сбор метрик не был реализован в Velox на протяжении многих лет существования проекта, что делало невозможным нормальный мониторинг состояния запросов.

Jemalloc

Наши основные сценарии: чтение из S3, декодирование Parquet, векторная обработка. Все это требует большого количества короткоживущих аллокаций непредсказуемого объема. Стандартный аллокатор плохо справлялся с такой нагрузкой, что проявлялось в высоком количестве minor page fault.

Поэтом мы достаточно быстро заменили стандартный аллокатор на Jemalloc и затюнили его в соответствии с лучшими практиками других аналитических движков (см. пример ClickHouse). Это дало существенный прирост производительности на большом количестве запросов.

Нам также очень интересно протестировать tcmalloc и mimalloc, так как Jemalloc более не развивается. Но пока до этого не дошли руки.

Поддержка функций и операторов Trino в DataFusion

Существенный объем трудозатрат пришелся на реализацию операторов и функций Trino в DataFusion. Ключевая проблема, с которой вы неизменно столкнетесь при попытке перенести обработку выражений из одной библиотеки в другую — несоответствие семантик.

Например, стандарт SQL никак не специфицирует, что должно быть результатом выполнения операции 1/3: 0, 0.3 или 0.333333— все это корректные результаты в зависимости от того, с каким движком вы работаете. Арифметика Trino соответствует семантике Hive / SQL Server, а арифметика DataFusion — семантике PostgreSQL. В процессе миграции нам пришлось самостоятельно реализовать арифметические и многие друге операторы, потому что даже мельчайшие отличия в семантике недопустимы: часть вычислений производит координатор в Java (constant folding оптимизатора), и любое несоответствие может привести к некорректному результату запроса.

Другая важная проблема — производительность встроенных выражений и операторов. Например, DataFusion не умеет эффективно выполнять оператор IN, который является важнейшим в аналитических движках, так как через него обычно моделируются runtime-фильтры. В частности, DataFusion всегда выполняет IN как лукап в хэш-таблицу. Плюс на момент реализации он не поддерживал работу со StringView (т.н. "german strings"). Суммарно это приводило к тому, что на ряде запросов новое решение работало медленнее, чем в Java. Для решения этой проблемы мы реализовали ряд специализированных IN операторов: (1) хэш-таблица с поддержкой "german strings" для произвольных значений, (2) массив с лукапом по индексу для целочисленных операндов с небольшими разбросом min/max значений, (3) специализированные программы без лукапов и ветвлений для небольшого количества аргументов. Например, выражение a IN (10, 20, 30) отработает в нашем движке как:

fn contains(a: int32) -> bool {
    a == 10 || a == 20 || a == 30
}

Выполнение данной операции занимает несколько наносекунд, что примерно в 5 раз быстрее, чем лукап в хэш-таблицу в оригинальной версии IN DataFusion. Это лишь один из множества примеров, когда производительность DataFusion из коробки оказывалась недостаточной для нас.

Всего нам пришлось переписать больше половины функций DataFusion на собственные реализации из-за несовпадения семантик или недостаточной производительности. Не ожидайте, что сторонние библиотеки все сделают за вас, какими бы модными они не казались.

Наши измерения показывают, что перенос обработки в Rust увеличивает производительность CPU-bound вычислений примерно вдвое. Реальный эффект крайне сложно измерить объективно, потому что в настоящее время в Rust мы используем только векторизованные вычисления, а оригинальный движок Trino в Java — это микс JIT-компиляции и векторизации. Поэтому в некоторых случаях реализация на Rust может быть в 10 раз быстрее за счет SIMD, а в других JIT в Java может быстрее справляться со сложными выражениями за счет отсутствия промежуточных аллокаций. Рано или поздно нам придется добавить JIT в Rust, но анализ пользовательских нагрузок говорит о том, что приоритет этой оптимизации не очень высокий.

Ниже приведен результат сравнения производительности Oxide и Trino на бенчмарке ClickBench, полученный на ранних этапах разработки. Он интересен тем, что хорошо показывает как преимущества натива, так и накладные расходы, с которыми нам приходилось мириться, пока мы не научили движок переносить в Oxide достаточное количество операций:

  • Запросы 30 и 38 ускорились в три раза, так как содержат большое количество вычислений.

  • Запрос 40 отработал в Oxide медленнее, так как на момент получения результатов Oxide не мог обработать один из важных для запроса операторов, что приводило к большим накладным расходам на копирование данных между Rust и Java.

chart.png
Рис. 8 — Внутренние результаты сравнения производительности Oxide и оригинального ядра Trino на запросах ClickBench, полученные на ранних этапах разработки (меньше — лучше)

Работа с S3

Неочевидное на первый взгляд преимущество: перенос ядра на Rust позволил нам радикально улучшить работу с S3 за счет использования неблокирующего IO.

Операторы Trino являются неблокирующими. Если оператор не в состоянии делать прогресс, он может освободить поток и передать выполнение другому оператору. Такой подход позволяет лучше утилизировать CPU при работе с блокирующими источниками (S3, HDFS, JDBC). Но это в теории. В реальности же движки обычно используют блокирующий API S3, так как он более прост в использовании, чем асинхронный эквивалент. Trino не является исключением и де-факто ожидает ответ от S3 в потоке выполнения запроса.

В Rust очень удобно организована поддержка асинхронных функций, что делает неблокирующую работу с S3 простой и понятной. Поэтому мы сразу же решили сделать взаимодействие с S3 неблокирующим. Запросы на чтение диапазонов Parquet уходят в object_store (дочерний проект Arrow для работы с S3 и другими хранилищами), оттуда в асинхронный HTTP-клиент reqwest, далее в Tokio, потом в Mio, и наконец в сокет через epoll. После этого мы сразу же освобождаем поток для выполнения следующих пайплайнов. Когда ответ готов, бэкграундный поток Tokio вычитывает данные и нотифицирует ядро Java через механизм waker и обратный JNI. Пайплайн просыпается и быстро продолжает обработку с того места, где остановилась асинхронная state-машина Rust. Это позволило нам полностью отделить работу с сетью от CPU-bound нагрузки. Результат: повышенная утилизация CPU и сети, снижение времени выполнения запросов.

Рис. 9 — Работа с S3 в ванильном Trino и Oxide
Рис. 9 — Работа с S3 в ванильном Trino и Oxide

Сейчас мы умеем только читать в асинхронном режиме. Поддержка асинхронной записи на очереди. Стоит отметить, что даже в текущем блокирующем режиме реализация записи в lakehouse в ванильном Trino кратно быстрее аналогичного функционала в ClickHouse, DuckDB, StarRocks, Doris и далее по списку. Но наши оценки показывают, что переход на неблокирующую архитектуру может в ряде случаев дать дополнительный прирост в 1.5-2 раза.

Еще один неочевидный бонус — разделение compute и storage CPU и S3 задач позволило нам добавить точный контроль нагрузки на S3. Если вы отправляете мало запросов в S3, то не утилизируете канал. Если вы отправляете слишком много запросов в S3, то большинство storage отвечают на это фрагментацией TCP-пакетов и снижением пропускной способности. Наша новая архитектура позволяет подобрать оптимальные параметры работы с конкретным S3-решением, и повысить утилизацию ресурсов. В блокирующих архитектурах (даже если вы выносите обработку в отдельный пул потоков) реализация подобного функционала затруднительна.

Рис. 10 — Контроль нагрузки на S3 в Oxide
Рис. 10 — Контроль нагрузки на S3 в Oxide

Наконец, для ускорения работы с S3 мы с нуля разработали новый высокопроизводительный локальный дисковый кэш. Мы смогли реализовать эффективный механизм асинхронного прогрева, который активно объединяет (coalescing) множество запросов к одним и тем же данным S3 в одну операцию. В большинстве других движков ваши запросы будут работать медленнее в процессе прогрева. В нашем же случае запросы начинают ускоряться с первых секунд работы кэша. Без async/await возможностей Rust реализация данного функционала получилась бы значительно более сложной, и скорее всего мы бы ее просто отложили. Наш новый кэш рутинно достигает прироста производительности работы с S3 в 5-10 раз.

Рис. 11 — Data cache в Oxide
Рис. 11 — Data cache в Oxide

Работа с Iceberg

Важнейшим источником данных CedrusData Engine являются файлы Parquet, хранящиеся в таблицах Iceberg. Планирование обработки таблицы Iceberg происходит в Java, и большая ее часть осталась неизменной после переезда в Rust:

  1. Координатор взаимодействует с каталогом Iceberg, чтобы выяснить, из каких файлов состоит таблица.

  2. Координатор принимает решение, какие файлы сканировать, и управляет распределением задач на сканирование между воркерами.

  3. Воркер получает задание на сканирование файла, применяет дополнительные runtime-фильтры и принимает финальное решение о том, содержит ли файл данные, необходимые запросу. Если да — задача на сканирование файла уходит в Rust.

Таким образом, на самых первых этапах нам не требовалась поддержка Iceberg в Rust, так как со стороны натива нужно было просто отсканировать ту или иную часть файла Parquet. Но очень быстро возникла проблема — поддержка delete-файлов.

Iceberg моделирует любые DML операции как последовательное удаление определенных строк с последующей опциональной вставкой обновленных значений. Удаление фиксируется в таблице Iceberg в виде набора delete-файлов. Каждый такой файл описывает либо удаленные позиции в конкретном файле Parquet (positional delete), либо предикаты, которые необходимо применить к оригинальным файлам Parquet, чтобы получить состояние после удаления (equality delete).

Многие движки, например, DuckDB, Doris и StarRocks, не поддерживают обновление таблиц Iceberg, и потому не могут полноценно работать в архитектуре lakehouse. Trino же с первых дней проектировался как движок для работы с табличными форматами, и поддерживает все DML операции, в том числе MERGE. Поэтому нам было очень важно, чтобы новое ядро поддерживало delete-файлы, являющиеся следствием работы ранее выполненных DML-команд нашего же движка.

На момент реализации нового функционала поддержка delete-файлов отсутствовала как в DataFusion, так и в официальной библиотеке Iceberg для Rust. Поэтому нам пришлось реализовать их поддержку самостоятельно. Для этого вместе с задачей на сканирование мы передаем в Rust список delete-файлов. Далее в зависимости от типа delete-файла:

  1. Если это positional delete, мы формируем вектор удаленных строк и передаем его в Parquet reader, который отфильтровывает удаленные строки.

  2. Если это equality delete, мы читаем файл Parquet как есть, но формируем дополнительный оператор фильтрации, который применяет удаление.

Рис. 12 — Работа с Iceberg deletes в Oxide
Рис. 12 — Работа с Iceberg deletes в Oxide

Таким образом, нам потребовалось потратить силы на реализацию поддержки delete, но теперь наше решение не зависит от библиотеки iceberg-rust и ее ограничений.

Работа с Parquet

Parquet — самый важный и востребованный формат хранения в lakehouse. Ввиду высокой сложности формата большинство движков вынуждены разрабатывать собственные библиотеки для работы с ним. Это не только трудоемко, но и приводит к многочисленным проблемам интероперабельности.

Когда мы начинали работу над проектом, то морально готовились, что нам тоже придется писать свой reader и writer для Parquet. К счастью, пока до этого не дошло. Спасибо за это стоит сказать сообществу Arrow, которое разработало и активно развивает самую продвинутую библиотеку для работы с Parquet на Rust!

"Продвинутый" Parquet — это как? Первое — поддерживать весь ключевой функционал (типы данных, чтение/запись, индексы, и т.п). Второе — обеспечивать высокую производительность. В основном за счет data skipping файлов, row group и page. Именно на вопросах производительности "сыпится" большинство open-source библиотек для работы с Parquet, что вынуждает разработчиков движков создавать свои "костыли".

Официальная библиотека Parquet для Rust выгодно отличается от других реализаций тем, что поддерживает эффективный data skipping на основе сложных предикатов. А DataFusion активно использует этот функционал, чтобы отбросить максимальное количество ненужных данных. Для этого DataFusion принимает ваш предикат, разбивает его на отдельные компоненты (коньюнкты), и далее используя min/max/null_count статистики структурного бл��ка Parquet (будь то файл целиком, row group, или page) преобразует оригинальный предикат в новый предикат статистик, на основе которого и принимается решение о пропуске блока данных.

Если вам интересны высокоуровневые идеи продвинутой работы со статистиками Parquet для data skipping, рекомендую ознакомиться со свежим пэпером от Snowflake. Большая часть упомянутых в пэпере техник data skipping блоков данных реализована в DataFusion, а библиотека Parquet для Rust предоставляет необходимую инфраструктуру, которая делает подобные оптимизации возможными.

Рис. 13 — Реализация data skipping в Data Fusion
Рис. 13 — Реализация data skipping в Data Fusion

Таким образом, просто переехав на DataFusion мы практически бесплатно получили лучший в своем классе data skipping Parquet, что внесло существенный вклад в увеличение производительности.

Кроме того, библиотека Parquet в Rust поддерживает lazy materialization, когда в рамках конкретного page вы сначала читаете колонки предиката, вычисляете предикат, и только потом дочитываете остальные необходимые запросу колонки, если в текущем page есть хоть одна интересующая вас запись. Ванильный Trino тоже поддерживает такую механику, причем в гораздо более продвинутом варианте! Поэтому ее наличие в Parquet Rust было важным для нас, чтобы не получить регресии производительности по сравнению со старым ядром.

Но и с Parquet без проблем не обошлось.

Во-первых, data skipping DataFusion ожидает конкретные типы выражений в предикатах. А, как мы описали выше, значительную часть таких выражений мы переписали из-за несоответствия семантик и недостаточной производительности. Поэтому нам пришлось форкнуть часть DataFusion, связанную с data skipping, и адаптировать ее под свои нужды.

Во-вторых, связка DataFusion + Parquet не умеет эффективно обрабатывать dictionary-encoded колонки. Представьте, что у вас есть низкокардинальная колонка с 10 уникальными значениями типа VARCHAR. Вы кодируете ее в dictionary в Parquet, наполняете таблицу десятью миллиардами записей и ожидаете очень быструю обработку предикатов вида WHERE col = 'value'. Не тут то было. В настоящее время DataFusion будет всегда разворачивать колонку-словарь в плоский вид, вынуждая вас делать миллиарды сравнений строк вместо одного сравнения на словарь каждой row group. Мы решили эту проблему форкнув еще одну часть DataFusion, которая отвечает за интеграцию с Parquet.

Рис. 14 — Сравнение механики чтения словарей Parquet в DataFusion и Oxide
Рис. 14 — Сравнение механики чтения словарей Parquet в DataFusion и Oxide

В-третьих, мы выяснили, что сама библиотека Parquet не дает работать со словарями целочисленных колонок. Представьте, что вы работаете с кликстримом, где у вас есть много предикатов к целочисленным хешам вида WHERE app_screen_hash IN (123, 456, 789). Выясняется, что сейчас вы никак не можете добраться до словаря этой колонки, чтобы применить к ней произвольный предикат. Только полное декодирование. А получение словарей доступно только для колонок типа byte array (в терминах SQL — VARCHARVARBINARY). Этот как раз пример ситуации, когда нужно писать свой Parquet reader. К счастью, пока что это единственная серьезная проблема, с которой мы столкнулись. Сейчас мы пытаемся ее решить через контрибьюшен в arrow-rs, что позволит нам отсрочить создание собственного ридера на (надеюсь) неопределенный срок.

Результаты

Oxide — это новое ядро CedrusData, написанное на Rust. Данный проект ускорил наш продукт во многих местах:

  1. Значительный прирост производительности CPU-bound операций. В тесте ClickBench мы сейчас примерно вдвое отстаем от DuckDB, что само по себе является крайне высоким результатом с учетом отличия архитектур локальных и MPP-движков.

  2. Высвобождение ресурсов CPU за счет снижения нагрузки на Java GC.

  3. Полное разделение CPU и IO нагрузок: повышенная утилизация ресурсов, возможность реализации продвинутых алгоритмов quality-of-service.

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

  5. Продвинутый data skipping Parquet, который отлично проявляет себя в реальных пользовательских запросах.

Благодаря кумулятивному эффекту этих и ранее разработаннх улучшений, наш движок уверенно обгоняет любые другие решения.

Дальнейшие планы

В 2026 году мы собираемся применить Oxide для ускорения Spark и Flink, которые являются частью нашей lakehouse-платформы. В настоящий момент мы делаем финальные приготовления для запуска этих работ.

Большие оставшиеся куски:

  1. Перенести в Oxide оператор Join.

  2. Перенести в Oxide шафлы (Exchange). Наши главные ожидания, что это позволит нам эффективно задействовать io_uring и даст стимул полностью поменять архитектуру шафлов Trino с pull на push. Но об этом в другой раз.

  3. Разобраться с озвученными выше проблемами Parquet.

Выводы

Переезд ядра на Rust позволил качественно улучшить производительность CedrusData. Но как показано выше, эта инициатива была вовсе не про "Java тормозит".

Java действительно медленнее выполняет индивидуальные операции над данными, чем нативные языки. Но в реальных запросах вы будете редко упираться в это. В lakehouse важен storage, потом оптимизатор, и только потом непосредственно "молотилка" движка. Если бы перед нами стояла цель улучшить только показатели рантайма Java, то разумнее было бы пойти путем итеративных оптимизаций (улучшения JIT, автовекторизация, zero-copy, и т.п) с точечным переносом отдельных вычислений в натив. Рабочий пример такого подхода — проект QuestDB, над которым трудятся мои бывшие коллеги из Hazelcast.

Мы смотрели шире. Данной инициативой мы заложили прочный фундамент принципиально нового движка, который:

  1. Полагается на современный и безопасный язык программирования.

  2. Может использовать произвольные низкоуровневые оптимизации, будь то SIMD, io_uring или специализированные аллокаторы.

  3. Использует лучшие в своем классе библиотеки по работе с данными, разработкой которых занимается активное незав��симое сообщество Apache Arrow.

CedrusData Engine — не только самый быстрый в своем классе промышленный аналитический SQL-движок, но и единственный на российском рынке, ядро которого полностью контролируется российскими инженерами, и не зависит от планов и настроений дорогих БРАТ... ПАРТНЕРОВ из США, Европы и Китая. Новое ядро Oxide позволило нам сделать качественный шаг вперед в развитии продукта.

Если вы столкнулись со схожей задачей, то наши рекомендации:

  1. Определите цели. Storage >> оптимизатор >> молотилка. Если вы не можете быстро читать данные, а ваш оптимизатор отдает кривые планы, натив вам не поможет.

  2. Если ваша задача просто оптимизировать потребление CPU в отдельных операциях, возможно лучше итеративно улучшать проблемные места без революций. Это я сейчас понимаю "задним умом".

  3. Язык имеет значение. На Rust мы сходу сделали оптимизации, которые бы бесконечно откладывали в случае работы на Java или C++.

  4. Выбирайте правильную технологическую основу. Сообщество Arrow подтолкнет вас к победе, псевдо-open-source Velox будет постоянно вас тормозить.

  5. Переезд движка в натив — это объемная работа, требующая существенной экспертизы в предметной области СУБД и большого количества времени.

И как следствие предыдущего пункта я хотел бы особо акценитровать внимание российского бинзеса на одном важном аспекте. Мы занимаемся подобными вещами, потому что мы — вендор, и это сама суть нашей работы. Мы также понимаем мотивы компаний, которые идут путем самостоятельно доработки движков, желая избежать привязки к вендору. Однако наш опыт общения с заказчиками самого разного размера показывает, что такой путь часто оказывается дороже: скрытые затраты на разработку и поддержку своего решения в итоге превосходят стоимость лицензии, отвлекая команды от ключевых задач. Если вам нужен эффективный движок для анализа данных или цельная lakehouse-платформа, то самое правильное, что вы можете сделать — отправить нам заявку. Так вы не только получите лучший в своем классе продукт, но и сэкономите свои бюджет и время.

Ну а если вам интересна тематика обработки больших данных, то присоединяйтесь в Telegram к сообществу пользователей Trino/CedrusData и российскому сообществу разработчиков СУБД Database Internals.

Удачи!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
На что вы бы переписали ядро Java-движка?
11.43%На С++4
40%На Rust14
48.57%На все деньги17
Проголосовали 35 пользователей. Воздержались 6 пользователей.