Выпуск 43 · подкаст «Тысяча фичей»

#43: Как работает JIT в базах данных

2:09:25
↓ скачать mp3

Александр Пахомов и Максим Кита (ментейнер LLVM, коммитер ClickHouse) разбирают Just-In-Time-компиляцию: сначала на классическом примере JVM, где JIT девиртуализирует вызовы, инлайнит `get` у `ArrayList` и убирает bound-check, а потом — как тот же приём работает в базах данных. На примере ClickHouse, Postgres и YDB они показывают, как выполнение выражений над `union`-типами компилируется в одну плотную функцию через `LLVM IR`, чем OLAP-компиляция отличается от OLTP и почему `JIT` — это ещё и постоянный источник проблем с безопасностью и падениями.

Главное

  • `JIT` (just-in-time) компилирует горячий код в рантайме под конкретную машину: он знает `instruction set` процессора (вплоть до `AVX-512`) и потому может сгенерировать код лучше, чем заранее собранный `AOT`-бинарь в C/C++/Rust/Go.
  • Классический выигрыш в JVM: `get` у полиморфного `List` — виртуальный вызов через таблицу диспетчеризации; после того как JIT видит, что там всегда `ArrayList`, он девиртуализирует и инлайнит вызов, даёт `bound check elimination` и разворачивает цикл — ускорение в десятки-сотни раз.
  • В классической СУБД строка — это массив `union`-типов, а выполнение выражений идёт по дереву (точнее, `DAG`) через полиморфный `execute`; это куча виртуальных вызовов и распаковки типов, которая жутко тормозит на миллионах строк.
  • `JIT` в базе компилирует граф выражений в одну функцию с уже подставленными типами и константами (`id * 2` превращается в сдвиг), убирая виртуальные вызовы и промежуточные аллокации; руками покрыть все комбинации типов нереально — для `a + b + c` это тысячи функций.
  • OLAP-системы (ClickHouse) компилируют синхронно прямо в пайплайне — 15 мс на фоне 200 мс запроса терпимы; OLTP (Postgres, YDB) обязаны компилировать асинхронно, иначе один `row` вместо 5 мс даст пик, ломающий SLA.
  • На низком уровне `JIT` через `LLVM` строится из `LLVM IR` (`Module` → `Function` → basic-блоки) с помощью `IRBuilder`, компилируется в `binary object`, линкуется, кладётся в `mmap`-страницы с правами `read+execute` — права `write+execute` одновременно недопустимы, это прямой `RCE`.
  • `JIT` опасен: любой баг — это падение без стектрейса или порча памяти; даже валидные с виду `LLVM IR` могут ронять сам LLVM (баг в векторайзере), поэтому LLVM линкуют статически, а компиляцию форкают в отдельный процесс.
  • Прокачиваться в инженерии стоит от задач с понятным `definition of done` в виде кода, которые доводятся до работающего состояния за пару дней; лучше всего — в open-source-проектах, которые нравятся (ClickHouse, LLVM, Spring).

В выпуске

  • Максим КитаРазработчик ClickHouse, контрибьютор компилятора Swift и коммитер проекта LLVM. GitHub ↗ maksimkita.com ↗
Расшифровка

[00:08] Александр: Здарова! Меня зовут Саша Пахомов, и я инженер, который любит своё дело. Вы слушаете 43-й выпуск подкаста «Тысяча фичей». Сегодня поговорим про JIT. Начнём с того, как это работает в JVM, а потом перейдём к JIT в базах данных на примере ClickHouse и YDB. А поможет мне разобраться в этом постоянный гость подкаста — Максим Кита. Он может быть знаком вам по выпускам про компиляторы и про ClickHouse. Максим — ментейнер проекта LLVM и коммитер в ClickHouse. Как вы понимаете, сегодняшний выпуск из серии хардкорных. Поехали!

[01:05] Максим: Мне кажется, JIT — это более понятная вещь, потому что она не такая низкоуровневая. Поэтому, наверное, проще начать разговор с неё. Для начала нужно сказать, как оно расшифровывается: just-in-time compilation. И подразумевается вот что: ваша программа, ваше приложение будет компилировать код и исполнять его прямо в рантайме — то есть тогда, когда приложение уже используется. Как оно работает, так на ходу и будет себя дособирать. Что мы пытаемся этим ускорить? Возьмём стандартный всем известный пример — виртуальная машина Java, его легко представить себе в голове. У вас есть ArrayList. И есть какая-то функция, которая принимает интерфейс List. Просто List — это родительский тип для ArrayList, если я правильно помню.

[01:58] Александр: Чтобы людям было понятно и чтобы сразу по рукам стукнуть тех, кто собирается писать в комментарии: ArrayList — это класс, а List — это интерфейс.

[02:03] Максим: Да, List — это интерфейс. И вот у вас есть функция, которая принимает этот List и как-то с ним работает: вы, например, итерируетесь по нему и вызываете какой-то метод — скажем, хотите пробежаться и просуммировать элементы. Пусть будет List<Integer>. Вы пробегаетесь по элементам и суммируете их. Написали такую функцию — какие тут сразу проблемы? Компилятор Java, понятное дело, не видит, что за этим листом на самом деле скрывается. И когда вы берёте элемент по индексу, это будет виртуальный вызов: скорее всего, придётся сходить в объект, достать его таблицу диспетчеризации — где описано, какой метод реально нужно вызвать, — взять этот метод и вызвать его с указателем на объект List. Вы попадёте в метод ArrayList, и он сделает своё дело — вернёт элемент по индексу. Но такой прыжок через таблицу в целом недешёвый, это первое. А второе — у компилятора вообще нет информации о том, что стоит за этим методом. Хотя если бы она была, то в реализации ArrayList вернуть элемент по индексу — это просто взять в массиве по оффсету, прыгнуть и отдать. Примитивная операция, буквально несколько инструкций: посчитать адрес и отдать память. И мы бы хотели, чтобы наш код каким-то образом превратился в такой оптимальный. Чтобы это сделать, компилятор Java изначально код интерпретирует. Там очень много эвристик, но если, например, функцию позвали несколько раз, компилятор может запомнить аргументы и скомпилировать всю эту функцию для вашего конкретного ArrayList. И потом, когда вы будете её вызывать, он поймёт, что она для ArrayList, и подсунет эту версию.

[03:56] Александр: И что мы по итогам получим? Во сколько раз в принципе может ускориться перформанс функции?

[04:01] Максим: На самом деле ускорение может быть очень-очень серьёзное. Во-первых, компилятор почти наверняка заинлайнит этот вызов get — «получить объект по индексу». Он уже будет встроен в функцию, и у вас не будет лишнего потенциального кэш-промаха, не надо прыгать по таблице и вызывать виртуальную функцию. Затем компилятор может посмотреть, что вообще делает ваш цикл: вы просто бежите по массиву, достаёте элементы, суммируете — очень простой цикл. Его можно с лёгкостью, например, развернуть. А если цикл сам по себе простой, дальше можно использовать SIMD-инструкции, про которые мы поговорим попозже, и ещё мощнее скомпилировать код конкретно под вашу машину. Тут важно понимать: одна из крутых фич JIT-компиляции в том, что она может сгенерировать код даже лучше, чем C- или C++-компиляторы. Почему? Потому что JIT знает, где он исполняется, — знает конкретное приложение и железо под ним. Например, если вы запустите Java-код на каком-нибудь последнем интеловом процессоре, он поймёт: «О, подо мной интеловый процессор, у него есть самый новый instruction set, AVX-512» — и может заиспользовать самые крутые инструкции. А когда вы компилируете код заранее — просто на C или C++, — вам нужно выбрать какой-то целевой процессор, какие-то instruction set’ы, описать минимальный процессор, который должен всё это поддерживать, чтобы код запустился. Instruction set — это, если совсем просто, набор инструкций, которые процессор умеет. Понятное дело, что это не так круто, как реально понимать, на какой ты машине запущен, и компилировать прямо под неё.

[06:09] Максим: В принципе, на этом простом примере мы всё проговорили. А если сказать чуть более высокоуровнево, я обычно так формулирую: JIT-компиляция позволяет динамическую конфигурацию скомпилировать в статическую. Вот у вас есть очень общая функция, она принимает какую-то динамическую структуру данных — полиморфный лист, и мы не знаем, что за ним стоит. Это динамическая конфигурация. А мы её компилируем в статическую — когда все аргументы уже подставлены, константы подставлены, всё девиртуализовано. Для Java это прямо супер актуально: без JIT-компиляции там были бы нереальные тормоза в продакшн-приложении. По итогам вы получаете функцию, скомпилированную так, будто вы написали её идеально руками — чётко под ArrayList, всё аккуратненько. Это очень серьёзная вещь. И используется она не только в рантаймах вроде Java HotSpot — там это вообще маст-хэв, для любого интерпретируемого языка это обязательно, — потому что в программе всегда есть подмножество горячих функций: какая-нибудь декомпрессия, парсинг JSON, особенно горячие циклы. Когда в них происходят вот эти indirection’ы — вы через указатель куда-то ходите, вызываете виртуальные функции, — всё это очень серьёзно тормозит. А когда всё инлайнится, у компилятора появляется намного больше простора для оптимизации: весь код на месте, весь ассемблер виден, без прыжков. Потому что когда вы вызываете функцию по указателю, компилятору не так много доступно для оптимизации: например, ему надо понимать, есть ли у функции сайд-эффекты, чистая она или нет. А вывести, что какая-то функция чистая, — это просто нереально сложно.

[08:15] Максим: Очень частый пример, который даже многие профессиональные, очень жёсткие разработчики не знают. Есть у вас C++-вектор. Вы пишете цикл for (size_t i = 0; i < v.size(); ++i) и идёте по этому вектору. Так вот, этот код будет работать в разы хуже, чем если бы вы v.size() вытянули за цикл. То есть до сих пор текущий Clang не понимает, что v.size() — это инвариант, во многих таких простых циклах. Вам это всё равно нужно делать руками, а v.size() — примитивная функция: обычно она возьмёт два указателя, начало и конец, или вернёт какой-то счётчик. Если даже на компиляторах это сложно понять, что уж говорить про очень продвинутые функции — вывести про них что-то нереально сложно. И обычно многие компиляторные оптимизации, по крайней мере в Clang, имеют какой-то трешхолд — буквально на то, сколько указателей они могут одновременно трейсить в памяти. Этот трешхолд обычно небольшой, потому что если бы он был большой, компиляция сильно бы замедлялась. Поэтому вот такие вещи надо делать руками. В Java, в подобных языках, это тоже суперактуальная проблема. Мы как люди знаем: если этот лист никто не модифицировал, а мы взяли у него size, то сейчас параллельно его никто не изменит, и мы можем вынести это за цикл. А компилятору, чтобы это понять, нужна прямо невероятная машинерия. Такие паттерны просто с привычкой глазами отлавливаешь, когда пишешь много низкоуровневого кода. Например, часто ещё бывает: создали вектор, напихиваете в него элементы — и перед этим стоит сделать reserve, зарезервировать память, чтобы не было лишних динамических аллокаций.

[10:08] Максим: Я тут немного отошёл в сторону C++. И ещё уточню: в этом примере у нас вообще нет никаких полиморфных типов. Функция size прекрасно видна компилятору, определение видно полностью, она инлайнится. Но компилятор всё равно не может доказать, что ничего не меняется. Ну, условно, кто-то же может держать ссылку на этот же вектор из другого потока и изменить его размер прямо в момент, когда ты итерируешься. Компилятор так рассуждает и говорит: «Ну, я не буду тогда этот вызов инлайнить».

[10:44] Александр: Вызов size, правильно?

[10:46] Максим: Да, на самом деле может быть так. Может, например, в цикле есть какая-то функция, которую он уже не может заинлайнить, и он подумает, что в этом цикле вектор могли как-то заиспользовать. А вот если создать вектор в той же функции, скорее всего, такой проблемы не будет: он поймёт, что этот указатель создан здесь же и никуда не успел убежать. Но это скорее пример к тому, что про такие вещи надо думать вообще во всех языках. Потому что компилятор — не какая-то магическая штука, которая любой ваш код разрулит. Мы говорим про Clang, прямо очень серьёзный компилятор, наверное, один из самых крутых. Понятное дело, что если в языке компилятор попроще — ну, например, в Go он не такой мощный, как Clang, это ручные компиляторы, — то там про такие вещи надо думать ещё больше.

[11:45] Максим: Ещё интересная штука. Есть классная вещь, для чего вам может пригодиться JIT в Java, — она называется bound check elimination. Она очень часто пригождается, когда вы работаете в языках, у которых есть bound check у массивов: это Java, Go, Rust. В таких языках нужно очень аккуратно обращаться к элементам массива. Предположим, есть массив, вам нужно обратиться к первым восьми байтам. Если вы обращаетесь к ним просто по одному — к первому, второму, третьему и так далее, — а между этим есть какая-то нечистая функция, которую компилятор не может доказать, он вставит вам прямо восемь проверок, что вы за границу не выходите. А в Java с JIT-компиляцией такой проблемы нет: когда код компилируется, компилятор может, даже если видит какой-нибудь ArrayList, просто сам вписать его размеры в чек, потому что чётко видит, с чем работает. Это тоже очень важная вещь. А в языках вроде Go или Rust — в Rust люди используют итераторы, и когда ты используешь итератор, понятно, что ты за границы не выскочишь. А в Go можно вначале написать _ = arr[7] — заранее проверить, что там восемь байт есть. И тогда оптимизация bound check elimination поймёт, что дальше вы за границы не выскакиваете. В Java же JIT-компилятор позволяет делать это всё за вас.

[13:28] Александр: Я бы хотел на двух моментах остановиться. Первый — просто ещё раз проговорить своими словами пример проблемы и как её решает JIT. Ты привёл хороший пример с List и ArrayList. Ты описал функцию, которая принимает аргументом лист — это абстракция. За этой абстракцией как минимум в стандартной Java-библиотеке есть две реализации: ArrayList и LinkedList — с совершенно разным кодом на каждую функцию внутри. Но сам метод, который пробегается по листу, по абстракции, и вызывает get, каждый раз плюсуя куда-то Integer, на момент компиляции не знает, что там будет — ArrayList или LinkedList. Поэтому, когда ты вызываешь javac, компилируется байткод, который, по сути, интерпретирует этот цикл так, что на каждый get будет вызван виртуальный метод, который уже в рантайме поймёт, к объекту ArrayList ты обращаешься или LinkedList, — и вот тогда иди в эту функцию, иначе в ту. Это проблема виртуальных вызовов, как она есть. И без JIT это было бы очень медленно, потому что представьте: у вас в листе миллион элементов, вы в цикле считаете сумму — вы миллион раз будете прыгать по памяти, кэши загружать-выгружать. Это просто terrible performance, и оно бы никогда не работало в продакшене в современном мире.

[15:05] Александр: Но что говорит JIT? Just-in-time — вот это важно: он во время исполнения кода может его скомпилировать. Когда я первый раз узнал, что JIT вообще есть, для меня это была высшая степень искусства программирования — написать такую штуку. Думаешь: ну как, она же выполняется, и как-то на лету что-то происходит, и она начинает в сто раз быстрее выполняться — как это возможно? А происходит так: этот байткод интерпретируется — сто, десять тысяч раз он проинтерпретировался, — JVM внутри собирает статистику вызовов. И вот эти первые тысячи вызовов, грубо говоря, будут реально медленно интерпретироваться — это то, что называется временем прогрева. Все знают: вначале ваш сервис на JVM будет подтупливать, но через минутку, две, три, пять, если на него идёт нормальная нагрузка, он начнёт перформить офигенно. За счёт чего? За счёт того, что в подобных местах JVM смотрит: «Ага, на самом деле в эту функцию в ста процентах случаев приходит ArrayList. Давай-ка я скомпилирую отдельную функцию, которая принимает не List, а ArrayList, и скомпилирую для неё код так, чтобы не было виртуальных вызовов». Дальше оно там развернётся, ещё ряд оптимизаций произойдёт — и мы развязываем тему с виртуальным вызовом. При вызове метода с ArrayList JVM такая: «О, у меня есть скомпилированная версия этого метода с ArrayList, отправляю туда» — и там уже супербыстро выполняется. За счёт этого достигаются десятки, сотни иксов производительности конкретного горячего места. Это очень круто, и этим JVM славится, и, мне кажется, во многом благодаря этому язык и живёт, и вся экосистема вокруг JVM.

[16:51] Александр: Тут ещё стоит отметить, что JIT противопоставляется AOT — ahead-of-time compilation, что называется, «просто скомпилируй мне сразу бинарник». Так компилируются Go, Rust, C++: компилятор сразу делает машинный код, процессорные инструкции, которые будут выполняться. Мы компилируем — условно, я у себя на макбуке за пару присестов могу скомпилировать код под какой-нибудь интеловский сервак, — и этот бинарь, собранный у меня на тачке, отправляется как есть на сервер и там выполняется всё время одними и теми же инструкциями. Это ahead-of-time: скомпилировали и отдали. А just-in-time говорит: смотрите, JVM, эта виртуальная абстракция, будет интерпретировать, а в какой-то момент под свой процессор, прямо под ногами у себя, скомпилирует супероптимальный код конкретного горячего места. Я думаю, что в целом пересказал всё, что ты сказал, но иногда другими словами, когда формулируешь, кому-то становится понятнее. Если тебе есть что здесь конкретно добавить.

[17:54] Максим: Ты всё верно рассказал, действительно всё так. Плюс ещё частый пример с Java: там JIT-компиляция очень хорошо работает ещё и в том плане, что в ArrayList у тебя будут валяться объекты — упакованные Integer, так скажем. И за счёт этого компилятору приходится дополнительно по указателю ходить и читать этот объект. У современных процессоров есть инструкции, про которые мы ещё поговорим, — SIMD-инструкции, которые позволяют вам пачку указателей грузануть и по ним сходить. Там можно и дополнительные штуки делать, типа prefetch. Это тоже может быть очень полезно для Java, потому что когда ваша функция полностью специализирована под все ваши аргументы, всё скомпилировано, получается в разы быстрее. Ты правильно сказал — 10–100 раз. Это действительно такие числа, которые на практике можно получать.

[19:08] Александр: Тут ещё ты классную тему затронул. Никто об этом сильно не задумывается, но я очень удивился, когда осознал: вот эти указатели на int или на объекты, которые лежат в Java, дают ещё и преимущество того, что мы знаем размер всего массива заранее и он не будет меняться. Потому что массив типизирован ссылкой, а у ссылки размер всегда один. Есть проблемы, когда большие хипы и всё такое, — мы об этом не говорим, — но в целом размер ссылки в Java у любого объекта одинаковый: будь то Integer, будь то супер-пупер POJO с тридцатью полями. Сам POJO лежит в хипе, да, он большой, но ссылка и структура, содержащая сто Integer и сто POJO-шек, будет одинакового размера. И это офигеть как круто, потому что даёт некоторое преимущество. Например, в Rust у тебя могут лежать не ссылки на объекты, а прямо структуры, аллоцированные вот в этой плашке. И, соответственно, от типа структуры у тебя может меняться размер массива — а это обрезает некоторые возможности для оптимизации. Есть такая проблема, я не помню, в каком контексте хотел это привести, но в официальной документации, в Rust Book, есть офигенный пример, где ты понимаешь, в чём разница. Или, кстати, я это видел где-то на YouTube. Ну ладно. В общем, тоже такая особенность: это не плюс и не минус — просто в одном мире у нас так, есть свои преимущества, но приходится ходить в хип, а в другом мире в хип ходить не приходится, зато размер структур разный.

[20:40] Максим: Ну да, конкретно этот момент, как мне кажется, всё-таки больше вреда приносит.

[20:44] Александр: Ты про ссылки?

[20:47] Максим: Да. Получается, каждый объект в Java аллоцирован как бы отдельно. Обычно это приносит больше проблем, потому что такой код сильно сложнее оптимизировать, плюс возникает проблема, когда приходится ходить по большому количеству указателей. Иногда, правда, такой код может быть быстрее. Бывают кейсы, где у тебя всякие лукап-таблицы, очень хитренькие, много уровней: первый уровень, второй, — и ты хочешь, чтобы эти уровни таблицы помещались в кэши. У тебя, например, весь массив указателей помещается в кэш, и ты знаешь, какой конкретно указатель сейчас пойдёшь тянуть — по хэшу, например, — и в целом это может работать нормально. Но в общем случае, наверное, это минус с точки зрения оптимизации. Потому что если размер объекта известен, как, например, в Rust, это даёт возможность просто не ходить лишний раз по указателю. Плюс когда оно рядом лежит — вот в примере с ArrayList, — если бы там лежала пачка Integer, это можно было бы ещё лучше скомпилировать, потому что для работы с пачкой Integer есть специальные SIMD-инструкции, которые прямо в регистр её грузанут. Но в целом, если у вас кейс, когда вам прямо такое нужно, — вам нужен массив Integer, — в Java можно просто написать массив int, отдельным типом. И у вас просто не будет дженерика, класс будет называться, условно, int[]. И если вы такой массив засунете, там первый раз не будет никаких виртуальных функций: вы просто пробежитесь. Вы, условно, написали такой вектор поверх обычного сырого массива в Java, обычных int, не объектов. А потом, когда он скомпилируется, оно почти наверняка SIMD-инструкцию бахнет — эти int в регистр, — и код будет ещё быстрее. Но это уже для Java скорее редкость, только если что-то очень низкоуровневое, когда прямо нужно таскать массивы сырых типов, а не объектов.

[23:08] Александр: Ну да, есть массив примитивов всегда. Но если мы хотим прямо сырой лист, то есть библиотека — забыл, как называется, — которая как раз определяет примитивные коллекции без дженериков: int-to-string map, string-to-string map и куча таких штук. Зато оно позволяет JIT быстрее работать. И если место реально узкое, тут ещё такой момент про оптимизацию: мы говорим исключительно про горячие места, про горячий код, про циклы. Потому что оптимизировать какой-то код бизнес-логики вашего сервиса — даже думать не надо, потому что, оптимизировав часть бизнес-логики, вы на общих графиках, если меряете перформанс, вообще не увидите разницы. А оптимизировав горячее место, сразу можно увидеть сильный импакт на общую картину. Это всегда стоит понимать. Когда мы говорим про перформанс — вот кто-то использует LinkedList, и ты на ревью говоришь: «Ой, LinkedList медленный, давай ArrayList», — но здесь сто элементов максимум и вызывается раз в секунду, ну какая разница, что там работает. Иногда про перформанс говорить можно, а иногда это чистое сотрясание воздуха. Естественно, мы говорим про случаи, когда что-то жёсткое: считаем математику, матрицы, у нас CPU-bound код — вот его мы сейчас джитуем.

[24:43] Максим: Да, всё ты верно рассказал. Могу, например, привести ClickHouse: у нас вообще вся бизнесовая логика идёт через шаренные указатели — буквально указатели с reference-каунтером. Это позволяет вообще не думать про детали, когда их освобождать. Оно должно вносить какой-то overhead, как в Java, если ты будешь так объектами раскидываться, но на практике у нас горячие места совсем другие. Горячие места — это те места, где происходит I/O, где мы колбасим прямо сырые массивы. А та бизнес-логика, которая есть, — там всегда нужно нормально всё писать. Например, в ClickHouse, в отличие от многих C++-проектов, у нас используются фабрики. Такое часто не любят в C++-комьюнити — используют паттерны проектирования, причём даже не столько паттерны, сколько такие паттерны, которые не compile-time. Просто в C++ очень много паттернов можно реализовать в compile-time, и это будет полиморфизм уровня компиляции. Но в таком случае теряется простота и удобство. Например, у нас функция в ClickHouse — это класс, условно интерфейс, как если бы в Java, IFunction, у него куча виртуальных методов определена, и с ним ты прекрасно работаешь: полиморфный тип засовываем в указатель, который сам себя менеджит, и таскаем повсюду, не паримся за микроаллокации. Про это нужно думать, только когда у тебя реально какая-то беда. Часто бывает, что люди начинают оптимизировать непонятно что — у тебя интерфейсы какие-то странные получаются. Обычно из моей практики, по крайней мере в ClickHouse, простой понятный код обычно не тормозит. Там могут быть тормоза — ты просто заходишь, потихоньку поправляешь. А вот код, который изначально пытается что-то хитрое и умное делать, но сложный, — с ним больше всего проблем.

[27:00] Максим: И, кстати, ещё про пример с LinkedList и ArrayList. В ClickHouse был более-менее такой подход: когда мы пишем код, мы просто пользуемся уже готовыми примитивами. В C++ буквально есть пара вещей, которые используются чаще всего, — например, вектор, хэш-таблица, — и мы просто используем их. А если тебе реально нужен, условно, LinkedList или что-то ещё, ты уже используешь его в конкретном случае. Такой подход, я думаю, правильный, потому что можно писать код и особо не думать. У нас есть практика: вот этот класс используем, если нужен массив, а этот — если нужна хэш-таблица, и особо не думайте. Если нужно что-то хитренькое — тогда думайте. Потому что так получается везде идиоматично, почти одинаково, проблем нет.

[27:52] Максим: Вот, например, LinkedList… Я на самом деле боюсь LinkedList. Могу рассказать интересную историю. Как-то раз у нас в ClickHouse — по-моему, это даже был ClickHouse Cloud — была проблема с перформансом. Такая проблема, когда кластер рестартует, например, наливается — и что-то всё тормозит, медленно встаёт с колен. Или когда происходят какие-то изменения конфига — всё очень тормозит, непонятно почему. И получается, внутри библиотеки Poco — мы тогда использовали Poco в этом месте — есть интерфейс, который как лист: он предоставляет метод сходить по индексу. А конфиг — там может быть дофига полей, ну, тысяча, десять тысяч полей. Пробежаться по десяти тысячам элементов — это в принципе может занять какое-то время, это нормально. Но что интересно: этот интерфейс конфига — какой-то XML-конфиг — предоставлял метод «дай мне вот этот XML-элемент по индексу». И внутри использовался даже не LinkedList, а forward_list. Наверное, там был всё-таки linked list. Суть в том, какой код был написан у нас в ClickHouse: мы берём размер этой структуры — она предоставляет интерфейс, похожий на массив, — бежим по нему и вызываем метод «дай значение по индексу». Во что это превратилось? Представь: у нас десять тысяч элементов в конфиге. Мы берём значение по индексу, а в linked list нельзя взять элемент по индексу — он бежит с самого начала и достаёт его для нас. И получается квадрат на ровном месте — десять тысяч на десять тысяч. Это уже нормально так тормозит. Плюс это куча прыжков по указателям, которые в разных частях памяти находятся.

[29:59] Александр: И вот в этом плане ты хорошо подметил. Я весь наш разговор думаю: мы привели пример с листом — метод принимает лист, абстракцию, но не знает, ArrayList там или LinkedList. И мне кажется, что это была ошибка архитекторов стандартной библиотеки Java в целом. Нельзя так сделать абстракцию — потому что в каком юзкейсе мне всё равно, ArrayList это или LinkedList? На практике я ни разу не использовал так, чтобы «вот тут вот любой лист». Да всегда он ArrayList, всегда. А если тебе нужен LinkedList — очередь, стек, — ты используешь LinkedList. Тебе не нужна эта абстракция, она лишняя. И то, что она в стандартной библиотеке, — благодаря только JIT она живёт. Потому что это реально фигня: у LinkedList get вообще не должно быть в интерфейсе, потому что он приводит к проблемам, которые ты описал.

[30:57] Максим: Ну да, для нас это был шок, потому что этот код — он утильный. Знаешь, мы посмотрели в perf top, видим что-то с этим конфигом, но по факту это утильный код, что он тормозит. А он реально тормозит из-за листа, а там он вообще не нужен. Вот тут самое обидное, что он вообще не нужен. Используй вектор везде — и будет тебе счастье. Но почему-то там был именно лист, и за счёт этого была проблема. Хотя мне на практике лист был нужен, кажется, пару раз. И то, скорее всего, лист мне был нужен в интрузивных структурах данных. Могу немного рассказать. Это, например, когда элемент, который находится в структуре данных, как бы понимает, что он в ней находится, и у него есть какие-то методы. Например, у тебя есть объект, и ссылку на следующий и на предыдущий объект ты прямо у него держишь, а структура данных, в которой он лежит, это сама заполняет. Такой интрузивный LinkedList. Зачем это может быть нужно? Интрузивные контейнеры хороши тем, что, например, у нас есть уже аллоцированные элементы — двести-триста элементов мы как-то уже аллоцировали, — и мы можем просто перевязать их в лист, потому что у них уже есть в себе указатели next, previous. Это может много всего сэкономить. Но конкретно у меня был другой use case.

[32:24] Александр: Давай-давай.

[32:27] Максим: Когда-то в ClickHouse была задача — нужно было написать самый быстрый LRU-кэш. По-моему, тогда самый быстрый кэш, про который нам было известно, был в Яндексе — кэш Антона Полухина, он там использовал Boost. Антон Полухин — известный разработчик на C++ в Яндексе, он и в комитете по стандартизации C++. А нам в ClickHouse нужен был свой кэш, в идеале ещё быстрее и чтобы использовал наши хэш-таблицы. Получается следующая структура данных. Тут надо немного визуализировать. Стандартный LRU-кэш — это, получается, список и хэш-таблица.

[33:13] Александр: Давай сначала про LRU. Что это значит?

[33:16] Максим: LRU — это политика кэширования, least recently used. Мы выкидываем элемент, к которому дольше всех не обращались. Обычно реализация такой структуры делается с использованием linked list и хэш-таблицы. У вас есть хэш-таблица, у неё есть ключ, по которому вы в кэш ходите, и значение — это, условно, элемент списка. Как только вы обращаетесь к элементу, вы берёте его из середины списка, высовываете и засовываете… Давайте удалять из head, а засовывать в tail. И теперь все элементы, к которым вы обращаетесь чаще, постоянно прилетают в tail и там находятся. А когда кэш переполняется, вы удаляете элемент из head. То есть там остаются элементы, к которым вы дольше всего не обращались.

[34:12] Александр: Да. В контексте баз данных, кстати, если кому-то интересно изучить, где это используется, — например, во всяких кэшах страниц, в buffer cache. Это используется в алгоритмах. У меня про это есть целый выпуск, я ссылочку прикреплю — зачем вообще нужна эта структура данных, какой у неё юзкейс, можно будет узнать там. Но в целом визуализировать её можно как linked list, каждый элемент которого индексирован с помощью хэш-таблицы — то есть на него указывает ещё и адресация массива.

[34:43] Максим: Да. И получается такой кейс. Теперь как первую версию сделать быстрее. Если наивно реализовать, у вас получится хэш-таблица, у неё есть ключ, есть значение, и ещё оба объекта дополнительно указывают на элемент в этом списке. В такой супернаивной реализации у вас там чейнинг, и этот элемент уже аллоцирован на куче. И вы можете сделать так, что этот элемент, который аллоцирован на куче, уже сам будет содержать указатели next и previous — то есть будет сам являться элементом списка. А сам список будет предоставлять из себя только head и tail. Такой интрузивный лист.

[35:46] Александр: Я сразу себе представил эту наивную реализацию: просто хэш-таблица, и сквозь неё указатели, правда, они не упорядочены — каждый указывает на каждого, — а мы снаружи только знаем head и tail, и всё.

[35:59] Максим: Да. Такая реализация в целом будет сильно лучше, потому что у вас, в принципе, одна динамическая аллокация на объект: значение в хэш-таблице — это и элемент списка, и ваш объект содержит. Вот так запаковано. Примерно так было реализовано у Антона Полухина. Но можно ещё быстрее — так мы сделали в ClickHouse. Представьте, у вас хэш-таблица, которая динамически представляет из себя просто массив. У неё теперь нет никаких внутренних указателей — просто массив. Это хэш-таблица, реализованная не через чейнинг, а через linear probing. Динамически она представляет из себя одну большую жирную аллокацию, и там находятся и ключи, и значения.

[36:48] Александр: Да. Кому интересно — можно посмотреть выпуск про хэш-таблицы, у меня тоже есть, база данных. Там как раз описывается разница между linear probing хэшингом и бакетингом, и есть разные схемы хэширования, но эти две, наверное, самые популярные. Мы просто представляем себе массив и, обратившись по хэшу в какой-то элемент, разрешаем коллизию с помощью того, что у нас есть индекс следующего элемента прямо здесь, в массиве. То есть это не указатель в памяти, а просто индекс, и мы прыгаем дальше. Или чаще всего идём просто на следующий элемент — они друг за другом. А когда появляются дырочки, приходится прыгать подальше. С этим тоже есть проблемы, но в плане того, что это одна структура, аллоцированная в памяти, просто плашечкой.

[37:38] Максим: Да. Есть два метода. Чейнинг — то, что мы говорили, со списком и динамическими аллокациями. И хэш-таблицы с открытой адресацией, один из вариантов которых — linear probing, но есть и квадратичные пробы, и другие. Суть в том, что мы представили себе такую хэш-таблицу, у неё есть ключ — это ключ в кэше, — а значение — это те же два указателя на next и previous, но они указывают прямо на память внутри этой хэш-таблицы.

[38:13] Александр: То есть прямо внутри, типа offset.

[38:15] Максим: Да, типа offset. И тут есть только одна беда: чтобы это работало, нужно модифицировать хэш-таблицу. Когда она ресайзится, она должна знать, что в ней лежит такой специальный элемент, который держит на неё указатели, и когда она какой-то элемент перемещает, она должна его модифицировать — чтобы у предыдущего элемента поменять next, а у следующего поменять previous, потому что твоя ячейка переместилась. Во время ресайза происходит дополнительная перестройка указателей, и такой вариант работает ещё лучше, потому что кэш супербыстрый: обращение по ключу — это прямо прыжок. Хэш-таблицы с открытой адресацией обычно в разы быстрее, особенно когда не помещаются в кэши процессора, потому что представляют из себя только один лукап в память — это где-то 100 наносекунд. На современных процессорах, может, 100 наносекунд, а если в другую NUMA-ноду попали — может, 200, ну, такие порядки. А в хэш-таблице с динамическими указателями у вас как минимум два лукапа: сначала попали в какой-то бакет — это один лукап, потом внутри бакета сделали следующий лукап на первый элемент. И обычно даже больше, потому что бывает, что не первый элемент взяли, а произошла коллизия — а коллизии часто происходят, — и пошли выбирать следующий элемент. Хэш-таблицы с linear probing с коллизиями лучше справляются, поэтому в основном там можно считать два-три обращения в память, а у хэш-таблиц с открытой адресацией обычно один. Это будет сильно быстрее, можно предполагать, в два-три раза, если не больше.

[40:12] Максим: Такой LRU-кэш получился. И мне кажется, только в таких задачах — где у тебя какие-то хитрые структуры данных, хитрые алгоритмы — только там нужны списки. То есть вы сразу это поймёте.

[40:25] Александр: Да-да-да. И то даже в таких задачах можно без классических списков.

[40:35] Максим: И вот в контексте того, с чего мы начали, — LinkedList, ArrayList, лист как их общий интерфейс, — а ещё выше есть коллекция, есть Iterable.

[40:43] Александр: Есть такая хохма, я её расскажу в подкасте. Мы в другом подкасте её целый выпуск обсуждали. Это наезд такого чувачка, Кейси Моратори, который наехал на Роберта Мартина, автора «Чистого кода», в духе: твой чистый код настолько медленный, что весь современный софт из-за этого чистого кода тормозит. И, ребят, не надо писать чистый код. И показывает на примере, что не так. Ты не в курсе этой истории?

[41:13] Максим: Я не в курсе, но я полностью согласен.

[41:19] Александр: Не читал, но осуждаю, да?

[41:21] Максим: Нет, я полностью согласен. Чистый код — мне кажется, я его давным-давно листал, но это как с паттернами проектирования: когда ты давным-давно прочитал какой-нибудь паттерн, ещё в универе или на работе, — например, адаптер или фабрику, — и у тебя весь код в адаптерах и фабриках. А потом возникает проблема, что в реальности это всё нереально тормозит. И реально чистый код Боба Мартина можно писать только на Java, потому что даже если ты будешь писать это на C++, оно будет работать потенциально ещё хуже, чем на Java. Потому что у вас, по сути, все те же проблемы, что и на Java, но у вас нет JIT-компиляции и вас вообще ничего не спасёт. В Java, если вы попали в какое-то горячее место, JIT-компилятор реально может прийти и спасти вас, а в C++ такого нет. Вы попали в место, где ходите по массиву указателей, — и будете по нему ходить, пока не перезарядите бинарь.

[41:23] Александр: Да-да, и вот что я хотел: он разматывал Мартина как раз на таком примере. Говорит: беру пример из твоей книжки, где идёт итерация по какой-то абстракции. Взял пример из книжки Боба Мартина, у которого все примеры на Java. Говорит: вот, твой пример беру, ничего не придумываю, единственное — написал его на плюсах. И в цикле виртуальных методов там нахватался. Ну, типа Мартин говорит: давайте в конкретном примере не будем использовать switch, а будем использовать полиморфизм. Он вместо switch переписал свой C++-код на полиморфизм, соответственно, на виртуальные вызовы, и в цикле это всё забенчмаркал — там капец как медленно. Говорит: вот, смотри, как плохо, твой же пример. Но когда я это увидел, я такой: блин, ну, претензия понятная, но ты же переписал это на плюсах, где нет JIT. Это вообще некорректное сравнение. Мартин-то всё про Java рассказывает, там нет этой проблемы: этот код зажитуется и будет нормально работать. А тот, на плюсах, действительно не зажитуется, там действительно не надо так писать — потому что на плюсах не надо писать, как говорит Боб Мартин.

[43:32] Максим: Вот это точно.

[43:33] Александр: Но это не значит, что в Java… Вот в этом и есть проблема, что мы поверхностно говорим: да, типа, что-то хорошо, что-то плохо. А если начинаем чуть глубже опускаться, то не всё такое чёрно-белое. И действительно, в Java благодаря JIT все эти проблемы отходят на такой далёкий задний план, что про них даже никто не разговаривает.

[43:53] Максим: Да, мне кажется, таких проблем нет. Поэтому на Java хорошо писать всякие enterprise-штуки. Если взять код какого-нибудь проекта типа Spring, там интерфейсов просто очень много, внутри интерфейсов нереально много всего — всякие коллбэки, адаптеры.

[44:12] Александр: Фабрики, декораторы.

[44:15] Максим: И понятное дело, что такой стиль кода можно писать, потому что вы знаете: если у вас беда произойдёт, JIT-компилятор это разрулит. Он посмотрит, что весь этот декоратор, адаптер — это мусор, и выкинет его нафиг, просто сходит по нужным указателям, позовёт, что вы хотели. А в C++ такого нет, в Rust такого нет, в Go такого нет. Поэтому про это всё равно надо думать — разработчикам на Java. Вот, например, если посмотреть на Go, их стандартная библиотека эти интерфейсы… Смотри: в C++ этих уровней абстракции — шаблоны, классы, концепты — нереально высокий уровень абстракции, просто её очень много. Макросы, по сути, это вообще возможность написать новый язык — максимальный уровень абстракции. В Java уровень поменьше: там дженерики — не шаблоны, там всё в плане абстракции попроще. Но они взяли то, что реально нужно. Например, в C++ есть множественное наследование, и, как показала нам жизнь, оно беда, плохая вещь, сложно — ромбовидное наследование, про которое пишут во всяких книжках. В C++ оно вам никогда в жизни не нужно, так просто не надо делать. Но как фича оно есть, такие абстракции ты теоретически можешь писать, и, может, где-то один раз из десяти тысяч тебе такая абстракция ложится на кейс. А в Go пошли ещё дальше: у них типа интерфейсы и структуры, и вся композиция у них только на уровне интерфейсов. Наследования как такового нет — можно эмбедить структуры, но это всё равно структуры. Всё очень примитивное, но язык они очень аккуратно продумали — как оно должно комбинироваться. Например, в стандартной библиотеке у них есть Reader — интерфейс с одним методом Read. Эта абстракция себя зарекомендовала, хорошая абстракция: на ней, условно, вся стандартная библиотека, которая с I/O работает. Или Writer — просто Write, тоже один метод. И вот эти маленькие интерфейсы с небольшим количеством методов ты комбинируешь — и получается реально мощная вещь.

[46:39] Максим: И часто на Go я ещё на что обратил внимание. Есть тоже интересная штука: любой тип ты можешь привести к интерфейсу без вот этих отношений наследования.

[46:51] Александр: Отношений наследования.

[46:52] Максим: Да, без этих отношений. Это тоже такая штука — нужно подумать, как авторы вообще это видят. Мне кажется, у них видение такое, что в целом…

[46:59] Александр: Выглядит как утка, крякает как утка — значит, это утка. Всё.

[47:07] Максим: Ну да, примерно так. Потому что многих проблем, которые есть в других языках — например, с адаптерами, — в Go просто нет. Так сделан язык, что их просто нет. Если тебе нужно структуру адаптировать, ты просто добавляешь к ней метод. Проблемы, конечно, есть в другом: в другом пакете ты это не можешь достать. Но это как будто бы шаг в сторону того, что нам это, может, вообще не нужно — так много абстракций. Хотя в каких-то местах это сомнительно, потому что я точно знаю: например, у нас в ClickHouse реально нужны шаблоны, чтобы генерировать кучу кода. Без шаблонов у нас была бы беда — нам нужны либо шаблоны, либо макросы, либо compile-time-генерирование кода. В Go такого нет, там пришлось бы просто код генерить, как иногда делают на C. Но я скорее к тому, что абстракции полезно стараться уменьшать. Просто иногда сложно, потому что часто сам язык тебя подстёгивает — например, в Java у тебя куча интерфейсов, и в таком количестве интерфейсов ты должен не запутаться, что-то реализовать, всё это понять. Просто каша из интерфейсов. А, например, на C — это вообще ещё шаг в сторону: там даже нет интерфейсов, ты всё пишешь сам, и оно заставляет тебя очень дисциплинированно делать правильные абстракции. Это язык, в котором ты не можешь просто раскидываться абстракциями, потому что он совсем не безопасный: сделал интерфейс, потом хочешь адаптеры — и просто перестанешь понимать, что происходит, у тебя память потечёт, ты не поймёшь всю картину. Это супер важно понимать. Мне кажется, эти разные подходы полезно понимать: если вы разработчик на Java — возьмите, попишите немного на Go, почувствуйте эту разницу, или даже на C. На C ты вообще как с голыми руками: в стандартной библиотеке ничего нет. Написать даже простое CLI-приложение на C — оно реально людей отрезвляет. Потому что на том же C++ есть конструкторы, деструкторы, и ты часто не думаешь про lifetime объектов — всегда можешь завернуть в shared_ptr, пусть сам разбирается. А в C такого нет: сделал структуру — надо чётко понимать, где я её аллоцирую, зачем, а может, мне и не надо её аллоцировать. В смысле жизни сидишь, думаешь. Реально сильно отрезвляет.

[49:56] Максим: И понятное дело, что это совсем по-другому. Java, ну и про Go так же можно сказать, — они позволяют с интерфейсами разбрасываться в каком-то смысле, потому что ты не боишься, что объект с интерфейсом ты куда-то выкинул, а сборщик мусора придёт и его соберёт. А в низкоуровневых языках ты про это больше думаешь. Знаешь, если бы я сейчас решил написать код на Java, наверное, я бы писал его как-то по-дурацки: я бы очень сильно думал, как сделать класс. Даже вот, например, — это тоже холиварная тема — стоит ли писать геттеры и сеттеры. В Java пишут геттеры и сеттеры, скорее всего, по всяким книжкам про clean code, и основная мотивация в том, что вот у тебя есть геттер, получаешь имя структуры, и ты наверняка захочешь в будущем от этого имени избавиться. Сто процентов. Но каждый программист в будущем хочет написать в геттере if. Вы избавитесь от этого поля, но код не придётся рефакторить, потому что он через геттер к этому полю обращается. А, например, в C++ уже не очевидно. Ещё плюс геттеров, как мне кажется: их по коду проекта чуть проще искать. Бывает такая ситуация — на больших проектах, — когда у тебя есть какой-то класс, и ты хочешь узнать, а кто реально обращается к этому полю, где его реально достали и что-то с ним сделали. Ты взял, стектрейс распечатал, это нашёл. А по-другому это поле публичное — его любой может достать. Плюс в Java ещё одна мотивация: если оставишь поле публичным, его можно и читать, и писать, ты никак не можешь указать, что оно константное — что я не хочу, чтобы кто-то поменял поле. Ты тогда делаешь геттер и защищаешь поле, чтобы туда никто не записал. То есть их приходится делать, потому что наружу поля выставлять — обычно такая страшная вещь. Ну, например, в C или в Go, если посмотришь в стандартную библиотеку, там куча полей просто выставлена наружу. И дизайн там часто продумывается так, что даже если поле инициализировано по дефолту или туда кто-то что-то записал, всё будет нормально работать. Совсем по-другому нужно мыслить в дизайне, чтобы такие решения принимать.

[52:38] Александр: Да, про геттеры и сеттеры ты хорошо наступил, потому что тема холиварная, и её мотает то туда, то сюда. Вначале говорили: да, геттеры и сеттеры в Java — это next level, мы теперь абстрагируемся, это инкапсуляция, сокрытие данных, все эти плюсы. Потом в Java 16 рекорды пришли — и все такие: опа, а рекорды это классно, капец как удобно, просто поля объявил, и всё. Рекорды — это почти как структура в Go, только чуть-чуть посложнее. Затем от боли от всех этих геттеров и сеттеров появился такой проект, как Lombok, который аннотациями в compile-time генерировал геттеры и сеттеры, потому что задалбывает их везде писать. Я хочу просто объект с пятью полями, но мне на ревью обязательно придёт кто-нибудь и скажет, что мне нужны геттеры и сеттеры. Соответственно, для пяти полей нужно пять геттеров, пять сеттеров, ещё toString, hashCode и equals — вот это всё постоянно надо писать для всех POJO-шек, а ты так или иначе данные гоняешь между сервисами. И вот это добивает, и народ Lombok использует — он сам всё генерит. Соответственно, из этого тоже куча проблем: представь, что тебе препроцессор в compile-time генерирует код, и, например, если твоя POJO-шка — это сущность из ORM, хайбернейтовская @Entity, намапленная на базу данных, то toString иногда мог сгенерироваться такой, что ты всю базу данных дампил себе в лог, если вызывал toString на объекте. И, понятно, Lombok сейчас уже сильно теряет актуальность, никто его в больших проектах, я надеюсь, не использует. Поэтому сейчас народ пишет геттеры и сеттеры только тогда, когда это нужно, — если тебе нужна нотация Java Plain Bean, когда через рефлексию фреймворк создаёт объект и сетит поля. А если ты просто делаешь обычную структуру в коде, какую-то абстракцию, которую хочешь передавать, то чаще всего скрывают поля — делают их private final, через конструктор сетят один раз, а геттеры — это просто метод с тем же именем поля, без бесячего слова get: если у тебя есть age, то метод называется age и возвращает age. Сейчас как-то вот так более-менее пишут.

[55:23] Александр: Ну что, может, про JIT продолжим? Мы с тобой остановились как раз на том, что в JVM есть замечательный JIT, но не только в языках программирования и рантаймах есть JIT — ещё и в базах данных используется. Давай поговорим про это.

[55:38] Максим: Да, давай сразу начнём с классического примера — как какой-нибудь Postgres использует JIT, а уже потом я расскажу про ClickHouse.

[55:46] Александр: Давай.

[55:48] Максим: Обычные классические базы данных типа MySQL, Postgres хранят данные построчно в каких-то страницах. Страницы могут быть разного размера, разных типов, но обычно есть страницы с данными, у страницы есть заголовок. И ваша таблица — вот вы написали CREATE TABLE (id INTEGER, value CHAR(10)), — вот этот INTEGER и CHAR(10) будут лежать на странице в бинарном виде. Может, ещё и сжаты, в зависимости от того, есть сжатие или нет. Когда вы читаете данные с такой таблицы, в коде вам часто нужен так называемый union-тип, который будет представлять из себя все возможные значения — атомарные, примитивные, — которые могут быть в этом поле. Например, если у нас есть Integer и String, то нам на уровне базы данных нужна абстракция — предположим, класс называется «кортеж». Кортеж хранил бы массив каких-то значений. В Java это мог бы быть класс, внутри которого три поля — например, строка, Integer и пусть double. И у этой структуры был бы ещё один тип — поле, которое сказало бы, что там реально лежит. Это можно по-разному делать, но в базах данных классически устроено так: когда вы читаете строки, строка представляет из себя просто массив вот этих union-типов — то есть объектов, которые могут содержать любой тип в базе данных, будь то Integer или String. И с такими строками дальше происходит работа.

[57:36] Александр: Давай я эту строку другими словами опишу. Получается, что есть страница, page, в ней есть набор этих строк, их какое-то количество. Сама строка представляет из себя… В заголовке страницы ты знаешь offset. Страница — это, по сути, массив байтов, плашка. В начале этой плашки есть некоторая метаинформация: её размер, сколько там строк и так далее. И вот мы знаем, что нам нужна самая первая строка в этой плашке. Мы знаем её offset из метаданных, перемещаем указатель на этот offset — просто чиселка — и считываем дальше. У нас есть протокол, формат, по которому мы знаем, что первые, например, четыре байта — это ID типа, который дальше за ним следует. Считали эти четыре байта, мы всегда знаем, что это четыре байта, берём switch: если это ноль — Integer, если один — double, если два — float. Такой примитивный кейс. И дальше понимаем: если это double, то там восемь байт, а если float, то четыре. Теперь мы знаем, сколько дальше нужно считать данных, чтобы этот тип представить. И вот эта пара — ID типа и сам пейлоад типа — это, грубо говоря, как ты говоришь, union: какая-то структура, которая представляет из себя кусок памяти, который чем-то является, и чтобы узнать, чем он является, надо кусочек этой структуры прочитать.

[59:09] Максим: Да, именно так. Единственное, что в твоём примере, прямо точь-в-точь как ты описал, такое бывает редко: это бывает, только если база данных хочет, чтобы её страницы были self-contained. То есть ты берёшь эту страницу, и она содержит и метаданные, и данные. Часто бывает, что если страница хочет метаданные, она может держать их в заголовке, чтобы каждый раз не дублировать, какие там типы. В заголовке один раз прочитал. Но у классических баз данных обычно метаданные хранятся в каталоге, и страница не self-contained. То есть если ты достанешь страницу, ты не знаешь, что это за ерунда там лежит, — тебе нужна схема этой страницы, лейаут. По сути, там лежит только полезный payload. Это просто экономит время. А self-contained, наверное, больше используется где-нибудь в протоколах общения между сервисами, где ты передаёшь какой-то payload и на той стороне его хотят интерпретировать. Но суть в целом та, что у тебя один кусок байтов, одна структура типа tuple, row, какой-нибудь union, и ты знаешь, что за ним лежит некоторый массив байтов, но не знаешь, какого он типа. И где-то в каком-то месте у тебя будет некоторый switch по этому.

[1:00:24] Максим: Всё именно так. И получается, на execution-уровне классических баз данных используется Volcano-модель. Она достаёт эти строки из операторов, из всего этого физического плана запроса; достала строку — и должна понять, что лежит тут, что тут, сопоставить то, что мы достали, с именами колонок, по позициям как-то сопоставить, чтобы дальше можно было с этим работать. Например, есть таблица, у неё есть value1 и value2, оба Integer. Прочитали такую строку и хотим выполнить над ней функцию плюсик. Мы сначала по value1 должны распаковать — по switch, по типу понять, что это Integer, — потом то же самое по value2. Такая происходит работа с union-типами. Но на практике это супертормозит.

[1:01:16] Александр: Понимаем тип строк, понимаем маппинг этих строк на то, что вообще есть в запросе. Там есть какое-то выражение, предположим, какой-то плюсик. Плюсик изначально — это что-то полиморфное, потому что бинарных функций может быть очень много — плюс, минус…

[1:01:34] Максим: Разделить, умножить. И вот эти бинарные функции, скорее всего, представлены каким-то классом типа IFunction, и у него есть метод execute. Этот execute берёт два значения и внутри у себя их разматывает. Но вся эта конструкция жутко динамическая: функция динамическая, потому что execute — это полиморфный вызов, а кортежи динамические, потому что болтаться там может что угодно. Очень динамическая конфигурация получается в коде. И если база данных поддерживает JIT-компиляцию… Обычно пример, который мы сейчас обсуждаем, — просто выполнение выражений, простейший compute: мы хотим посчитать какие-то функции над кортежами. Мы берём этот граф. Обычно выполнение выражений называется в литературе expression tree — дерево. Или, точнее, там выступает не дерево, а DAG, граф. Потому что представь, у тебя выражение a + b + a + b. Если бы это было дерево, тебе пришлось бы a + b посчитать дважды: сверху плюсик, слева-справа два плюсика, и у листьев a и b. Обычно это ациклический граф: у тебя, грубо говоря, и левая, и правая ссылка плюсика указывают на одно и то же выражение, которое нужно посчитать. Соответственно, выражение можно посчитать не дважды, а один раз. В литературе это, мне кажется, называется expression DAG — directed acyclic graph, направленный ациклический граф. И ты берёшь эту структуру данных. Что она принимает на вход? В базах данных она принимает константы — потому что банально: была у меня беда в базе, я почему-то ко всем айдишникам случайно прибавил плюс десять, а хочу отнять — напишу id - 10. Вот эта константа, сами колонки — те union-типы, — и, собственно, функции. Всем этим граф параметризуется. И его нужно выполнить. Выполняется он, естественно, от листьев.

[1:03:41] Александр: Снизу вверх.

[1:03:42] Максим: Да, снизу вверх. В графе не обязательно есть какой-то один корень — там может быть несколько условных корней, выражений, которые мы хотим посчитать. Но для простоты можно представить это как дерево, что там по несколько раз что-то считаем; граф — это некоторая оптимизация над деревом. И эта штука в таком виде будет просто невероятно тормозить. Супер-супер тормознуто. Представим: у нас база данных, в ней миллион строк, нужно выполнить этот граф миллион раз. В графе может быть несколько функций — сразу перемножаем это на количество виртуальных вызовов. В каждой функции будет код, который значения распаковывает, это недёшево, нигде не сохраняется — просто код, который постоянно делает одно и то же. В кэше процессора для кода он сохранится, конечно; если несколько раз по одному коду бегаешь, чуть лучше, branch predictor может лучше работать. Но обычно это никак не помогает, потому что параллельно у вас может быть куча запросов, и конкретно эта функция в другом запросе совсем по-другому типы распаковывает. Вас ничего не спасает — никакой branch predictor. Всё ужасно тормозит. И ещё интересный момент: константы тоже не инлайнятся. Например, вы написали id + 0 — до уровня выполнения оно, скорее всего, не дойдёт, реврайтер, планировщик, оптимайзер это уберёт, потому что бесполезно. Или id * 2 — если у вас Integer, можно просто сделать shift. Или разделить на два — тоже shift, намного быстрее. Если вы будете реально делить на два, вы это ещё и никак не переиспользуете, потому что константа будет залетать в функцию теми же union-типами. JIT-компиляция такое умеет убирать, чуть позже расскажу.

[1:06:01] Максим: JIT позволяет… Ну давай сейчас паузу сделаем.

[1:06:03] Александр: Очень хорошо мы сейчас описали весь процесс, и он очень сильно коррелирует и мапится на тот процесс, который мы описывали с интерпретацией в Java и походами по листу. Концептуально происходит плюс-минус то же самое: мы тоже интерпретируем наши выражения каждый раз, и это тоже куча виртуальных вызовов, всё очень медленно. И про константу ты тоже сказал. Вот как выглядел бы плюс в коде — интерпретация этого плюса. Мы говорим compute + expression, left, right, туда доходит, она ещё int-ом будет: compute + int, left, right — два int-а. И один из этих int-ов — это реальное значение из базы данных, а второй — вот эта двоечка, константа, на которую мы делить сделали. Для кода, который это интерпретирует, это просто два числа, которые надо одно на другое поделить. Но если бы мы делали инлайнинг или специализацию, у нас была бы функция, например, «разделить число на два», и она принимала бы только число — потому что мы знаем, что надо делить на два. Соответственно, эта двойка уже в коде учитывается, и, например, может быть, как ты говорил, побитовый сдвиг. Это оптимизация, которая зависит от конкретной константы: код меняется от константы. Если это константа, мы можем позволить себе её заинлайнить и вызвать эту функцию миллион раз на всех данных, чем вызывать миллион раз функцию, передавать туда двойку вторым аргументом и заставлять это всё интерпретировать. Как-то я так перефразировал, чтобы картинка была цельная. И теперь мы можем перейти к тому, как здесь применить JIT и насколько это можно улучшить.

[1:07:36] Максим: Как тут применить JIT? Мы знаем заранее, с какими реальными типами работаем. И эту информацию можем сообщить функциям и попросить их скомпилироваться — условно, для каких-то определённых типов. И мы можем весь наш граф рекурсивно, снизу вверх, аккуратно скомпилировать и подставить в нужные места константы. Потому что мы знаем на уровне выполнения запроса, что вот это значение в строке — константа. Или знаем, что другая колонка — точно Integer. И мы знаем полностью лейаут всех наших типов в базе данных, поэтому можем внутри функций для них оптимально сгенерировать код.

[1:08:16] Александр: И ты разобрал пример с локальной функцией — например, разделить на два или умножить на два. Там и константа сыграла существенную роль в том, как этот код оптимизировать. Плюс, когда мы знаем тип, нам, естественно, проще: Integer умножить на два — это вообще супердёшево, там никаких вещей по типам не будет.

[1:08:34] Максим: Но тут ещё важный момент в том, что мы компилируем цикл, внутри которого выполняем. Изначально к нам на вход может приходить по одной строке, но в целом мы можем прямо пачку строк взять — то есть у нас может быть векторизованная версия Volcano-модели, где мы пачку строк достаём. Батчами берём, чтобы по одной не вызывать. И мы можем скомпилировать цикл, который бежит по этому батчу. Мы полностью скомпилировали граф с константами, знаем, что туда придёт на вход, и просто весь этот код туда инлайним. Весь цикл компилируем, который будет проходиться по строкам, для каждой строки интерпретировать граф, сохранять значение. Мы это можем скомпилировать в одну такую жирную функцию. И чего мы этим добьёмся? Во-первых, после того как всё заинлайнится, очень велика вероятность, что компилятор найдёт намного больше мест для улучшения. Например, у тебя может быть функция, которая что-то одно считает, функция, которая что-то другое, — и между ними есть какие-то интерфанкшн-оптимизации, которые на уровне интерпретатора вообще никак не увидеть, потому что оно всё за функциями и интерфейсами скрыто. А JIT-компилятор это прекрасно увидит. Например, он может посмотреть, что какое-то выполнение выражений общее. Представь выражение a * b * c / (a * b * c) — он просто возьмёт единицу, сунет туда, что всё это единица. В этом месте обычно в качестве JIT-компилятора используется LLVM, и по факту мы получаем то, что если бы мы этот код специализированно написали руками для конкретного входа, конкретных функций, конкретных типов — оптимально написали и скомпилировали C/C++-компилятором. Примерно такой код мы получим на выходе, и оптимизаций там офигеть как много: и inlining, и bound check elimination — грубо говоря, все оптимизации LLVM вам доступны. Это может быть даже векторизация: unroll этого цикла, мы можем сразу его заунроллить. Например, компилятор может понять, что эти функции генерят не так уж много ассемблерного кода, я это могу прекрасно заунроллить — я вижу все определения этих функций, они заинлайнены, никаких сайд-эффектов нет. Компилятор всё это проверит, и по итогам получится одна такая супербыстрая функция.

[1:11:01] Александр: Так, давай тут ещё этот шажочек проговорим, на каком моменте мы находимся. До этого мы были на модели, которая интерпретирует каждый expression, просто интерпретация этого кода. Мы взяли пример более-менее сложной математической функции — плюс-минус, разделить и умножить, четыре, например, A, B, C, D участвуют. ABCD — это входные данные, что-то там происходит. В модели интерпретации это будет дерево высотой, наверное, три плюс-минус, оно будет как Expression3 интерпретироваться. Против этого мы можем сделать что? Мы можем взять руками, написать функцию calculateMySpecificFunction, туда передать ABCD конкретных типов, конкретный возвращаемый тип — просто сишная, C++-функция. И в теле оптимальненько закодировать руками конкретно вычисление конкретного выражения. У нас нет никакого Expression3, никаких прыжков по памяти, по ссылкам. Просто выполняется функция на стеке, грубо говоря. И всё. И мы эту функцию, написав руками, компилируем с помощью GCC-компилятора и получаем маленький бинарь, который может конкретно эту функцию с конкретными параметрами выполнять быстро, без прыжков и интерпретаций. Мы сейчас на этом уровне находимся, верно?

[1:12:25] Максим: Да, верно. Тут ещё хороший момент ты напомнил — что мы избегаем аллокации. Потому что если мы просто делаем интерпретацию, мы, например, вызываем плюсик для A и B, получаем колонку C, но значение C нам нужно где-то сохранить. Понятное дело, если это какая-то строка, будет динамическая аллокация, потому что произвольную строку мы через стек не сможем пробросить; может быть, какая-то короткая строка — что-нибудь такое. В целом у нас появляются дополнительные intermediate-переменные во время интерпретации. Например, посчитали A + B, потом C + D, нам это нужно перемножить — вот такие intermediate-переменные. В JIT они могут вообще выскочить, у них не окажется никакого адреса, они просто между регистрами компилятор их аккуратненько пробросит. Уйдут indirection’ы — все виртуальные вызовы уйдут. Виртуальные вызовы обычно плохо влияют на pipeline процессора, ему тяжело это оптимизировать, это куча кэш-мисов. А в таком коде почти не будет кэш-мисов — только когда вы обращаетесь к данным самого кортежа. Обращаетесь к значению — это какой-то payload, может быть указатель, но иногда это можно держать не как указатель, а заинлайненным. Например, если это Integer, его динамически можно не аллоцировать. А если строку — там что-то динамически к указателю сходит. Но в целом да, это будет намного быстрее.

[1:13:43] Александр: Ну и в чём получается преимущество конкретно технологии JIT, если мы можем так условно руками наклепать сто-двести, нанять разработчиков, чтобы они всё это написали?

[1:13:53] Максим: Основное преимущество технологии в том, что, во-первых, руками это делать суперсложно, но есть люди, которые такое могут. Например, в ClickHouse у нас есть интерпретатор, и он тоже работает: когда он принимает, например, две колонки Integer, он прямо switch-ом пишет по всем вариантам и сам цикл делает. Но в ClickHouse это проще, потому что ты работаешь со своими значениями уже батчами: у тебя будет массив вот этих Integer, тебе нужно по нему пробежаться, по первому массиву, по второму, сложить — это значительно проще. В классических базах данных это значительно сложнее, потому что там такие switch очень тяжело писать: обычно ты работаешь на уровне одной строки, а не батча. В самом Expression3 вход — это всё-таки строка, а не батч. И это сильно сложнее. Плюс даже когда ты принимаешь батч, тебе нужно аккуратно сохранять и оффсеты внутри батча — куча геморроя. И в любом случае твоя специализация будет очень локальная: ты максимум сможешь аккуратно специализировать свои бинарные операторы для всех Integer, для всех float, для всех комбинаций. Но комбинаций получится очень много. Например, в ClickHouse для функции плюс есть десять вариантов: int8, int16, int32, int64, беззнаковые int8, int16, int32, int64, float32 и float64 — то есть float и double. Это десять типов. Плюс есть вариант, что они будут константами, — это уже двадцать, и двадцать для левого, и двадцать для правого, потому что ты можешь сложить Integer с float. По итогам получится двадцать на двадцать — четыреста комбинаций. В ClickHouse это сделано через шаблоны, генерируется очень много кода, и это только для одной функции плюс. А если у тебя уже a + b + c, это четыреста на двадцать — восемь тысяч, кажется. Вы сгенерили просто восемь тысяч мусорных функций, и то покрыли только a + b + c, а у вас может быть умножить. То есть все комбинации никак не запишешь. Для каких-то горячих функций руками написать эти switch — маловероятно, что кто-то будет, слишком сложно. И основной плюс в том, что JIT даёт возможность всю эту механику — вы уже работаете в понятных концепциях, используете JIT-компилятор LLVM, он очень хорошо специфицирован — и по итогам для произвольного графа сгенерить этот код. Руками это никак не сделать: только внутрянку функций можно руками распаковать, а взаимодействие функций вы никак не будете специализировать. Что у меня в графе есть плюс, плюс, умножить — это уже безумие. Поэтому код нужно компилировать в рантайме, потому что только в рантайме вы понимаете, какая у вас конфигурация, какая горячая конфигурация этого графа, всех входов, констант и самих функций. И в этом основное преимущество: вам не надо всё это генерить, и вы можете решить задачу намного более сложную — весь граф скомпилировать, что руками вообще никак не сделать.

[1:17:19] Александр: Давай тогда перейдём к тому, как происходит эта компиляция. У нас есть граф Expression3, который мы вычисляем. По сути, как JVM, начинаем интерпретировать: проинтерпретировали для первого батча, для второго, для третьего. В какой-то момент понимаем, что это место горячее, верно?

[1:17:38] Максим: Да.

[1:17:39] Александр: И принимаем решение — грубо говоря, if там стоит, говорит: если столько-то раз за столько-то времени выполнили этот граф, то давай-ка его параллельно начнём компилировать. В ClickHouse, наверное, так же — то есть у вас параллельно где-то идёт компиляция, а потом уже скомпилированный код начинает работать, верно же? Потому что компиляция — процесс небыстрый.

[1:17:59] Максим: В ClickHouse сделано на уровне выражений. Да, именно как ты сказал: мы понимаем, что выражение уже третий или пятый раз пришло, можно его скомпилировать. В ClickHouse компиляция не асинхронная, она встроена в пайплайн, прямо по месту будет компилировать. Это сделано потому, что для большинства выражений компиляция в пределах того, что мы ожидаем от скорости работы ClickHouse. Например, для простеньких выражений — пять-десять выражений — компиляция такого графа займёт около пятнадцати миллисекунд. Это позволительно, потому что затем сама интерпретация уйдёт и запрос будет работать быстрее. В таких базах данных, как OLAP, можно, конечно, сделать это асинхронно, но большого смысла нет. Когда мы говорим просто про компиляцию таких маленьких вещей, как выражения. Компиляцию запроса мы потом обсудим — там действительно надо выносить это асинхронно, потому что очень много кода может сгенериться. А для простых выражений… У меня в блоге есть статья, я скидывал примеры простых выражений — a + b * c, что-то простенькое. Там даже весь ассемблер помещается на такой экранчик, особо кода и не генерится. Это всё прооптимизировать — проблем нет.

[1:19:14] Максим: Тут есть несколько разных подходов. Вот, например, в YDB, когда я делал JIT-компиляцию, я сделал её асинхронной — но там однозначно должна была быть асинхронная компиляция, потому что эти пятнадцать миллисекунд, они и в OLTP какая-то серьёзная нижняя константа: на запуск всех прогонов по LLVM, на оптимизации, на какие-то внутренние структуры меньше чем за несколько миллисекунд вы точно никак не скомпилируете, а для OLTP это очень тормознуто.

[1:19:42] Александр: Тут как раз хороший кейс. Получается, что для OLAP-систем, аналитических, от которых ожидается, что если ты отправил запрос, то подождать двести миллисекунд — окей, — и в рамках этих двухсот миллисекунд, если где-то затесалось пятнадцать миллисекунд компиляции, но зато потом последующие вычисления были настолько быстрые, что разница нивелировалась, — это окей. Но в OLTP базах данных, где транзакционная нагрузка, где по одному пользователю, по одному ключу, по одному row что-то происходит, мы не ожидаем, что один row будет процесситься двести миллисекунд. Мы ожидаем, что будет процесситься пять миллисекунд, например. И вот в эти пять миллисекунд компиляция уже никак не встаёт. Мы не можем сделать так, чтобы все запросы у нас до этого пять миллисекунд, пять, пять, потом хренак двадцать пять миллисекунд, а потом один-один-один. Такая нагрузка — ну, может, конкретно то, что я привёл, в целом окей, но скорее всего этот пик будет ещё больше, и мы не можем себе это позволить: у нас есть какие-то SLA, которые говорят, что не надо так. Поэтому там, скорее всего, компиляция должна происходить параллельно с интерпретацией — пока что это интерпретируется, а потом в один момент просто подменяется. А в аналитических системах мы просто на каком-то запросе такие: так, пора скомпилироваться, скомпилировались, пошли дальше.

[1:21:04] Максим: Да, именно так, в YDB я именно так и делал. Там у нас есть граф выполнения, он кэшируется, и просто синхронный процесс: через какое-то время видишь, что граф стал горячим, — параллельно его компилируешь. Он, кстати, даже параллельно и выполняется: в другом месте другой процесс его выполняет, и никаких проблем нет.

[1:21:23] Максим: Тут важно заметить, что в OLTP компиляция нужна однозначно. Потому что есть OLAP, есть OLTP как классические базы данных, которые умеют всё. Но есть ещё NewSQL базы данных, которые просто такие транзакционные — может, Cassandra, ScyllaDB, YDB, YugabyteDB. Это базы данных, которые обычно вообще ничего не считают, используются как key-value, может, с какими-то join’ами, но там обычно куча данных не ходит, и эта JIT-компиляция, если мы одну строку будем обрабатывать, вообще ничего не даёт. Обычно в compute мы не упираемся, там упираемся в распределённые транзакции, в координацию — проблема другого порядка. Но, например, в YDB мы это добавляли, потому что хотелось идти в сторону того, чтобы база данных умела всё. Если дали запрос, который читает полтаблицы, чтобы она хоть как-то быстрее это делала. Это в принципе хороший сценарий. А, например, в Postgres компиляция — must-have, потому что у Postgres столько пользователей, что кто-то использует его как key-value, а многие реально хранят свои данные в Postgres и могут ходить по ним, join’ить, группировать, что-то считать. Это нормальная история: Postgres должен быть такой общей базой данных, должен в таких сценариях прекрасно работать. Понятное дело, он не будет работать так быстро, как ClickHouse, потому что в ClickHouse и данных намного меньше обычно читается — за счёт того, что база поколоночная, — плюс обработка данных тоже поколоночная, и код будет значительно лучше сгенерирован, потому что данные запакованы очень-очень рядышком: те же Integer — просто массив байт, где каждые четыре байта это Integer. Совсем другой код генерится, чем когда мы притаскиваем union-типы — там будут дополнительные indirection. Если мы говорим про компиляцию OLTP как в Postgres — но компиляция однозначно должна быть.

[1:23:19] Максим: Тут есть несколько моментов, которые ещё стоит проговорить. Во-первых, в классической базе данных важно учитывать косты. Очень хорошо, если у вас функции размечены — какие реально дешёвые, какие дорогие, — потому что есть функции супердешёвые, и не совсем понятно, имеет ли смысл их компилировать. Плюс нужно сказать, что иногда бывают функции, которые вообще нельзя скомпилировать. Например, какая-нибудь функция, которая сначала инициализирует движок для регэкспов — компилирует regexp, потом пытается применять его на строке. Скорее всего, она в вашем скомпилированном коде вызывалась бы так же, как если бы интерпретировалась, — просто конкретная сишная функция из вашего JIT-кода. Есть функции, которые тяжело скомпилировать, и смысла никакого нет, потому что такая функция может быть фиг какая тяжёлая: если вы очень сложный regexp написали, он может компилироваться дольше, чем весь ваш код. Поэтому всё очень сильно зависит. По-хорошему нужно этот момент учитывать. Обязательно нужно иметь кэширование: если вы скомпилировали код один раз, вы по-хорошему должны его сохранить. Кэширование можно позволить себе весьма хорошее в JIT-компиляции, потому что кода по текущим меркам обычно немного. Например, в ClickHouse компиляция каких-то выражений обычно занимает две-три страницы операционной системы: нужна страница с данными, где какие-нибудь константы прилягут, и одна-две страницы с кодом. Обычно это аллоцируется через mmap. Когда происходит JIT-компиляция, вам нужно сначала инструкции для вашего процессора, так скажем, байтами вбить в память. У меня про это есть пример в блоге, как можно такое сделать голыми руками. Вы аллоцируете память, ставите на неё write permissions — что вы можете в неё писать, — записываете туда код, ну, сгенерировали код LLVM, записали в память, затем у этих страниц памяти меняете permissions на read, execute.

[1:25:22] Максим: Главное, помните всегда: у вас никогда не должно быть write, execute permissions одновременно на какие-нибудь страницы памяти. Потому что это security.

[1:25:32] Александр: Да, вас уничтожат безопасники, это нужно чётко понимать: такого просто никогда не должно быть. По сути, инъекцию можно сделать, remote code execution прямо сразу.

[1:25:42] Максим: Да, потому что код бинаря запротекчен, ты его не можешь поменять. Например, ты получил доступ к чему-то, можешь что-то в адресном пространстве поменять; код бинаря если попробуешь поменять, тебе прилетит сигнал, что ты что-то не то делаешь. Но с кодом, где вы сами аллоцировали память, понятное дело: если в неё приземлиться и поменять инструкции, то всё — у вас remote code execution.

[1:26:11] Александр: Давай этот процесс поподробнее, потому что, мне кажется, у многих людей в голове не выстроилась картинка конкретного перехода — когда мы исполняем какой-то граф, дёргаем execute, compute, compute, compute. И вот что дальше происходит, во что этот compute превращается? Какой код там написан? Сама JIT-компиляция — что это такое? По шагам прямо.

[1:26:32] Максим: Я буду рассказывать на примере LLVM, потому что это самый простой вариант. Как голыми руками это сделать, я, кажется, рассказал: вы аллоцируете память, забиваете туда инструкции прямо байтами.

[1:26:42] Александр: А откуда эти инструкции берутся? Вот у меня есть структура, граф. Что я с этим графом делаю, как он превращается в код?

[1:26:50] Максим: Про граф я сейчас расскажу. Я скорее говорю про интеловые инструкции. Например, у вас процессор Intel или AMD, вы знаете, что ADD имеет такие-то байты, RET — такие-то. Вы просто аллоцируете память, туда эти байты засовываете, делаете read, execute permission, и этот указатель — вы, грубо говоря, функцию пишете, только не на ассемблере, а на том, что получится после того, как ассемблер по вашему коду пройдёт. В ассемблере, если вы написали инструкцию ADD, она в зависимости от вашего процессора поменяется на реальные байты, а не на ассемблер. Такие байты будут в эту память записаны, как и ваш обычный код. Сам процесс JIT-компиляции подразумевает, что вы это сделаете сами: вам нужно end-to-end какой-то код скомпилировать в машинный код и записать в память. То есть если кто-то говорит, что голый ассемблер — это самый низкий уровень, нет: есть ещё уровень ниже — вот этих машинных инструкций, специфичных для конкретного процессора. Этот процессор вот эти байты в этом месте интерпретирует как «сложи вот этот регистр, вот этим положи туда».

[1:27:52] Александр: Да, то есть саму эту память, которую мы аллоцировали, страницу с кодом, нужно интерпретировать как функцию. То есть вы должны правильно раздать ей паддинг, приготовить всё, что нужно для function call. Это всё вы сами делаете, тут никакого ассемблера нет. И когда вы пишете инструкции в ассемблере, всё, что делает ассемблер, — он понимает, что вот эта инструкция соответствует таким-то байтам, и просто бахает эти байты.

[1:28:16] Максим: Ну да, в каком-то смысле, просто он записывает это всё в бинарный файл. А тут вы делаете это сами. Но это на самом низком уровне. Понятное дело, что такое руками делать — застрелиться. Хотя есть люди, которые такое делают. То есть есть JIT’ы, кроме LLVM, — это, кстати, дай проверю, asmjit, по-моему, так называется. Это очень специфичная штука, чтобы генерировать машинный код ещё быстрее, чем LLVM. Там вы генерируете код на уровне ассемблера, работаете с чуть более абстрактными вещами — не с сырыми байтами инструкций, а с чем-то более высокоуровневым. Но, как я понимаю, он не оптимизирует, просто позволяет вам код сгенерить. Такие библиотеки есть. А есть библиотека LLVM, где JIT интерпретирует и генерирует всё сам.

[1:29:15] Александр: В общем, задача понятна. Теперь — как это происходит на уровне LLVM?

[1:29:18] Максим: На уровне LLVM весь код предоставляется с использованием LLVM IR — это intermediate representation, язык в LLVM. Это язык, в котором есть почти всё, что вам нужно в ассемблере, плюс — мы уже обсуждали в одном из подкастов, можно к этому обсуждению вернуться, его нужно послушать — там есть SSA-форма, static single assignment: с регистром можно работать один раз, и вы это используете, когда генерируете код в LLVM IR. Как это происходит? Есть такой класс в LLVM, вы сначала сетапите всю инфраструктуру — оно от версии к версии меняется, про это даже говорить смысла нет. Есть важная штука, называется LLVM Module. Модуль — это ваш translation unit в терминах C/C++, грубо говоря, ваш юнит с кодом. В этом модуле находится LLVM Function — это, собственно, ваши функции. И есть вещь, которая называется LLVM Builder, и через этот LLVM Builder вы можете билдить LLVM-инструкции. По сути, вы работаете как бы с ассемблером, но сами его билдите. Он круче, чем ассемблер, на нём намного проще писать: у регистров есть типы, есть валидация. Например, у инструкции есть валидация. Мы хотим сгенерить плюсик — у IR Builder вызовем метод CreateAdd или что-то такое, передадим ему два LLVM Value. LLVM Value — это регистр. И константы там можно делать. LLVM Builder позволяет вам этот LLVM IR создать и засунуть в функции, а функции лежат в модуле. LLVM Module — контейнер для LLVM-функций, LLVM Function — контейнер для basic-блоков, про которые мы говорили, это просто какие-то последовательные инструкции. Ну и, собственно, там уже инструкции. Затем этот модуль вы отправляете на компиляцию. Но перед этим нужно настроить флаги компиляции — с какими опциями, какие будут доступны оптимизации.

[1:30:16] Максим: Тут нужно сразу сказать, что в JIT-компиляции можно делать по-разному. Можно, например — насколько я знаю, JIT-компиляция в HotSpot имеет несколько стадий: сначала ваш код компилируется без особой оптимизации, но это уже быстрее, чем интерпретация, а затем дополнительно всё это асинхронно компилируется с -O2 или -O3, со всеми включёнными оптимизациями прямо под ваш процессор. В принципе, можно делать именно так: LLVM на этом уровне как захотите, так и делаете. Код вы сгенерили, это LLVM IR, засунули туда — и всё. После этого вы получаете binary object — это уже байты. Всё, что там осталось… Ещё вам может пригодиться, если есть какие-то константы или внешние функции, которые вы хотите вызывать извне вашего JIT-кода. Например, как мы говорили, есть сишная функция, которая regexp считает над какой-то строкой, вы её хотите извне позвать — тогда вам нужен линкер. Это всё в поставке LLVM. Вы позвали этот линкер, он вам этот object-файл слинковал, там это называется релокация — когда какие-то константы нужно подставить, указатели на функции, всё это подставляется туда, и по итогам вы получаете финальные байты. Эти байты вы можете записать в линкер, он сам вам их запишет в страницы в памяти. Вы ему даёте аллокатор, он знает, как аллоцировать страницы с данными, страницы для кода, весь этот процесс прячет для вас. И по итогам всё, что вам остаётся, — у вас есть бинарный файл, который просто лежит в страницах памяти. В C++ это буквально char*, просто байты — это ваш бинарный файл.

[1:33:05] Максим: Потом у внимательного слушателя возникает вопрос: как в этом бинарном файле достать функцию? Вы берёте у этого линкера, который вам файл сгенерил, и спрашиваете: дай мне указатель в мою область памяти, где находится функция с таким-то именем. А функции, когда вы их сами в LLVM IR пишете, вы, естественно, им имена даёте. Вы эти имена достали — и всё, у вас есть указатели на функции. Дальше процесс такой: это у вас сырые указатели на функции, но вы пишете на C/C++ и просто берёте этот указатель на память и интерпретируете его как функцию с нужными вам аргументами и нужным возвращаемым значением — через reinterpret_cast делается. И всё, вы можете вызывать.

[1:33:50] Александр: То есть прямо вот такой хардкор-хардкор: чуть-чуть ошибся — и приехали.

[1:33:54] Максим: Да. Вообще JIT-компиляция с точки зрения безопасности — это, конечно, застрелиться. Почему? Во-первых, любой баг — это, скорее всего, падение программы. В лучшем случае. В худшем вы можете что-то покорраптить, где-то прямо ужас полный. Второй момент, почему есть проблемы с безопасностью: у LLVM есть валидация. Если вы написали какой-то плохой код, то LLVM с высокой вероятностью — но не со стопроцентной — найдёт вам баг и просто упадёт. Это лучше, чем если вы упадёте, когда будете исполнять код, который неправильно написали, уже в машинном коде, — потому что тогда у вас вообще падение, даже не стектрейс. Нет-нет, когда я говорю «стектрейс», это, конечно, смешно, потому что вы, по сути, какие-то байты исполняете: там стектрейса нет, вы просто падаете. Эта валидация реально спасает, потому что LLVM в некоторых случаях может даже вернуть ошибку, но в большинстве случаев на моей практике там просто всё обставлено ассертами, и он просто упадёт. Эти ассерты, насколько я понимаю, в релизе даже должны работать. Но если даже они не работают в релизе, в LLVM можно настроить так, что ты эти ассерты подменишь, например, на выброс исключения: если ассерт не прошёл, вместо того чтобы программа упала, оно поменяет это на выброс исключения. Для этого нужно заморачиваться — LLVM компилировать вместе со своей программой, не использовать её как шареную библиотеку, а прямо чётко скомпилировать со своей программой.

[1:35:30] Александр: То есть LLVM должна знать, какое исключение кинуть?

[1:35:32] Максим: Там скорее даже никакое исключение — просто нужно в билд-процесс LLVM вклиниться, этот макрос, который у них ассерт, поменять на что-то другое.

[1:35:39] Александр: А, то есть настолько.

[1:35:42] Максим: Да, там особо нет какой-то конфигурации, просто макрос подменить. И мы, например, LLVM таскаем с собой — статически билдим. Всем советую, если кто-то надумает с этим экспериментировать, LLVM забилдить статически, просто сделать git clone. Потому что использование shared-библиотек, особенно с LLVM, супер неудобно, как по мне: у вас могут быть очень серьёзные завязки на версии, и LLVM в плане API не предоставляет стабильный API — это не какой-то glibc, который символы версионирует. У вас может очень нетривиально сломаться. И, собственно, есть такие ассерты — мне кажется, они по дефолту в релизе вообще отключаются, то есть, скорее всего, в релизе он просто где-то сам свалится внутри LLVM, потому что его инварианты будут не соблюдены, он там где-нибудь нулевой указатель разыменует и упадёт. Это тоже проблема, потому что можно делать атаки на LLVM. У LLVM есть публичный репозиторий, там есть краши, и можно попытаться… В ClickHouse я находил один баг LLVM на своей практике — не помню, рассказывал или нет. У нас была какая-то функция типа a + b * c / что-то, очень простая, и она падала. Я долго копался, потому что вроде бы этот LLVM IR выглядит валидно, LLVM IR отдельно тоже падает — но про LLVM IR тяжело сказать, что он валидный, это тоже как ассемблер, глазами можешь что-то не заметить. Потом я написал такой же код прямо циклом — в ClickHouse это колонки, я написал функцию, которая принимает колонки как указатель на int, массив, указатель на float, массив, — и написал такой же цикл руками, и обычный Clang просто крашнулся. Clang использует LLVM под капотом, и там, в принципе, был баг. И это опасно, потому что можно понять… Это часть кода, она называется, кажется, SLP Vectorizer — векторайзер в LLVM, который векторизует. Например, вы работаете со структурой, засетали четыре поля, которые подряд идут, четыре int, — и он может одну SIMD-инструкцию заиспользовать, как раз векторизация, про которую мы потом планируем поговорить. Там был другой векторайзер, который действительно падал. Код в LLVM в этих местах прямо суперсложный: файлы по десять-пятнадцать-двадцать тысяч строк, очень большие, и там очень сложные инварианты. Если ты эти инварианты где-то по коду выше не соблюдаешь, он всё просто крашнется. И вот такая нестабильность LLVM, мне кажется, самое опасное, потому что в данном случае вы вообще ничего не можете сделать: в LLVM есть баг, и вы на него завязаны. У JIT в целом есть риски стабильности — баг в компиляторе может уронить процесс. Не знаю, насколько практически люди на это заморачиваются, но такие краши прямо приносят проблему. Например, у нас в ClickHouse была история с одним таким крашем. Мы это пофиксили только новой версией LLVM, а до того, как она появилась, мы какой-то паттерн, который приводил к проблемам, — там был if через тернарный оператор — заблокировали. В LLVM для этого есть специальная инструкция, называется Select: она принимает булевый регистр и два возвращаемых регистра. Как if, но такой conditional, несложный if. Самого if в LLVM нет — ты создаёшь basic-блоки, между ними прыгаешь. А Select — это как простенький if, как тернарный оператор. Тот кейс, который конкретно крашился, я просто заблочил: посмотрел из графа, что там такая штука есть, и мы для такого кейса компилятор вырубили. В принципе, я, наверное, все такие проблемы рассказал.

[1:39:43] Александр: Так, да, много ты навалил. Давай чуть-чуть структуризую самую суть, которую ты вначале сам рассказал. Мне прямо понравилось, как этот intermediate representation ты кодом строишь. Ты берёшь граф, a + b, вот такой маленький графчик. Ты говоришь: вот теперь я знаю, что это у меня expression типа плюс, и говорю — давай теперь я буду билдить этот expression-граф, который в моём коде на C++ представлен, я его сейчас буду представлять с помощью LLVM. Ты его билдишь с помощью специального API, говоришь: это expression, left, right, как-то это сконструируешь, — и эту структуру ты передаёшь уже непосредственно в саму библиотеку LLVM и говоришь: скомпилируй. Она компилирует, ты там передал, например, аллокатор, она тебе возвращает условно массив char, который говорит: всё, вот твой скомпилированный код. И, собственно, ты теперь этот код можешь вызывать — интерпретировать этот массив байтов как функцию с такими-то аргументами, с такими-то возвращаемыми значениями. Ты интерпретируешь это не как данные, а как код. И, по сути, у тебя оно где-то кэшируется, и в следующий раз, когда встречаешь точно такой же expression с такими же типами аргументов в таком же порядке, ты такой: о, а у меня это уже есть скомпилированное, — и сразу передаёшь его туда.

[1:40:57] Максим: Да, именно так. Там есть ещё нюансы. Иногда люди, например, не компилируют константы, а просто вместо них компилируют не конкретную константу, а просто что это, например, константный Integer. Иногда так делают. Там в зависимости от функции может быть можно думать, нужна ли ей константа, или она в зависимости от константы ничего не заоптимизирует. Например, с функцией плюс нет особого смысла её компилировать с константой, она там ничего особо не оптимизирует. А деление — правую часть деления разумно засунуть константой, потому что там компилятор вообще поменяет на умножение или сдвиг, если там константа.

[1:40:59] Александр: Много всяких штук. Грубо говоря, у тебя выражение a + 1, a + 2, a + 10, у тебя куча запросов, ты же база данных, тебе просто в кучу льют. И ты не будешь компилировать, инлайнить константу в каждую разную функцию — вот это для a + 1, это для a + 2, это для a + 3. По сути, будет использоваться одна и та же функция, типа a + c, где c будет вот эта единичка, просто ещё один аргумент. Соответственно, у тебя одна скомпилированная для многих кейсов, либо ты можешь для конкретного специфичного кейса — например, деления на два, если видишь, что там прямо двойка константная, — у тебя для этой двойки специальная скомпилированная функция есть, туда отправляешь, все остальные отправляешь в дженерик-функцию.

[1:42:07] Максим: Да, можно так, можно, например, как ты сказал, просто a + какая-то константа — вместо неё подсунуть как колонку. Но обычно можно понять, что это константа, на уровне, когда мы эту функцию вообще собираемся исполнять, и просто вместо неё какую-то произвольную константу подсунуть — мы просто знаем, что там будет Integer, не какой-то сложный тип, а просто Integer, но константный. В ClickHouse это актуальнее, чем в Postgres. Если ты к строке применяешь, это не сильно актуально — там оба аргумента будут Integer. А в ClickHouse актуальнее, потому что один из аргументов будет массив Integer, а другой — Integer. Там это может быть более актуально. В целом по итогам ты получаешь возможность очень тщательно регулировать, как ты это всё хочешь оптимизировать. Плюс приятное дополнение: вот этот IFunction, который интерфейс реализует, у которого есть условный метод execute, — у него может быть ещё и метод compile, тоже полиморфный, который принимает этот LLVM Builder, и он просто знает свои аргументы, которые регистрирует, знает, как это дальше билдить. В целом граф Expression3 — такая приятная рекурсивная структура, которая примерно так же выглядит и в LLVM, и ты можешь так же рекурсивно запустить эту компиляцию, оно нормально отработает.

[1:43:21] Александр: Ещё знаешь, какая мне понравилась аналогия в мышлении — что прямо хорошо здесь с языками программирования и базами данных провести аналогию. Фронтенды компиляторов, в выпуске про программирование мы говорили, строят некоторое представление intermediate-кода, типа абстрактное синтаксическое дерево и так далее. И вот это те языки, которые компилируются с помощью LLVM-бэкенда, они этот LLVM IR представляют. То есть ваш код, написанный на C, будет представлен такой же IR-структурой и потом скомпилирован под конкретный процессор бэкендом LLVM. Здесь база данных делает то же самое, только фронтендом выступает она: своё представление вашего графа исполнения, кусочек его, представляет в виде этого intermediate representation. По сути, с языками программирования здесь разница заканчивается: бэкенд одинаковый, только фронтенд разный, грубо говоря.

[1:44:16] Максим: Да, кстати, в том примере с Java, где мы говорили про функцию, которая принимает лист, — теоретически если бы она ещё принимала Integer, например, даже запакованный как объект Integer, и это была бы горячая функция, JIT-компилятор мог бы этот Integer понять, что это константа. Оптимизация с константами в JIT-компиляторах — что в Java, что в базах данных, что в других JIT-компиляторах — примерно одинаковая. Потому что ты знаешь, откуда она взялась, у тебя есть контекст, и из этого контекста ты можешь много интересного подсунуть — которым, например, сам LLVM не обладает. Он собирает и собирает, а ты можешь ему подсказать, просто потому что у тебя в контексте выполнения запроса человек задал константу.

[1:44:57] Максим: Да, тут надо ещё дополнительно сказать, что в целом то, что мы сейчас обсудили, — это компиляция выражений, и компиляция выражений — очень-очень простой кейс. В Java мы тоже такую простую функцию разобрали. В голове это нужно обобщить на более сложный сценарий, где, например, в Java у вас может компилироваться чуть ли не целые классы, какие-то взаимодействия классов — прямо большие куски программы. И в базах данных у вас тоже может компилироваться вообще говоря весь запрос.

[1:45:27] Максим: Из систем, которые так и делают, — это делает AWS Redshift, такая система типа OLAP. Они вообще компилируют весь запрос. В ClickHouse мы пошли немного другим путём: у нас есть векторизованный движок, и в него компиляция встроена сбоку. Например, у нас есть выполнение выражений — там мы их компилируем. Есть агрегация — там тоже компилируем, пересчёт агрегатов и ещё некоторые места. Есть сортировка — там компилируем компаратор, например, сравнение a, b. Вообще говоря, сейчас есть системы даже без векторизованного движка, у которых есть только JIT-движок. Например, это система Umbra, она очень интересная. Она не open-source, но, насколько я понимаю, заявляет, что она чисто JIT-движок, и на бенчмарках, на ClickBench, она вроде как обгоняет ClickHouse, и, как я понимаю, компилирует весь запрос. То есть можно делать ещё круче: вообще весь запрос аккуратно придумать, как его скомпилировать, чтобы этот JIT-движок выступал именно основным движком. В разных базах данных по-разному. Вот в Postgres тоже дефолтный движок — это интерпретатор, по-моему, чисто Volcano-style, даже не с батчем, и JIT-компиляция встроена сбоку. В ClickHouse — векторизованный движок плюс компиляция сбоку. А в Umbra, в AWS Redshift сделано именно так, что компиляция — прямо core-компонент.

[1:46:59] Максим: Ещё, кстати, я хотел сказать: если уже речь заходит про компиляцию всей программы, помнишь, я сказал, что хотел бы себя каким-то образом обезопасить? Если речь про такую долгую компиляцию — какого-нибудь крутого запроса в AWS Redshift, — я смотрел их презентацию и видео: у них это происходит на отдельной флотилии, так скажем, машин, которые чисто занимаются компиляцией. И в таком случае ты знаешь этот граф — вот твой запрос, ты его сериализовал, отправил на эту машину, она может сделать форк своего процесса, пытаться это скомпилировать. Если она грохнется, то в целом может сделать rollback на интерпретацию или ошибку выбить. Всё очень сильно зависит от того, про какой сценарий мы говорим. Например, в Postgres тоже не совсем страшно, если она грохнется, потому что у тебя грохнется только один worker. Собственно, она компилируется на воркере, который запрос выполняет, поэтому это не страшно. А в ClickHouse, например, мы не форкаемся, когда компилируем, и у нас весь процесс упадёт. Если хочется безопаснее — можно во время компиляции форкаться, сделать кусок шаренной памяти между процессами. Грубо говоря, второй процесс, ваш child-процесс, сможет в эту шаренную память код, который он скомпилировал, записать и дать вам сигнал, что компиляция прошла успешно. Это в целом обезопасит вас как минимум от крашей в LLVM или от того, что вы код сгенерировали неправильно, а LLVM это поймал на уровне валидации. Но если вы сгенерировали код неправильно, а LLVM это не поймал, то вас всё равно не спасёт — вы упадёте потом.

[1:48:33] Александр: Да, вы не знаете. Кстати, про Redshift ты отлично вспомнил. Они, насколько я знаю, в чём вообще соль? Это же облачный провайдер, у них есть облако. У Amazon есть данные о том, как их база данных исполняется на многих разных клиентах. Они обладают огромным контекстом: у них огромное количество запросов, которые они выполняют, и они компилируют целый запрос. Например, я в своём приложении использую какой-нибудь распространённый паттерн, типа SELECT * FROM users. Вот до меня сто процентов кто-то этот запрос уже выполнял. И этот запрос уже скомпилирован в системе, и у меня сразу скомпилированный запрос будет использован, насколько я понимаю. То есть они шерят компилируемые сущности между вообще всеми инстансами.

[1:49:22] Максим: Идейно компиляция всего запроса — очень интересная штука. Ты берёшь запрос и можешь даже не обязательно по AST, по этому тексту, — ты можешь сначала построить план, и потом по плану, потому что там могут какие-то константы заинлайниться, весь этот мусор, чтобы не прямо по тексту искать. Например, если в одном запросе 1 + 3, в другом 2 + 2, то они оба сольются на уровне того, как ты свой запрос анализируешь, потом пытаешься оптимизировать, — они сольются в одну константу. И дальше ты можешь: о, ну вот у меня такое есть. Или другие оптимизации произойдут, и запрос по итогам станет одинаковый, который уже нужно планировать и исполнять, или планы будут одинаковые. И потом ты достаёшь из кэша уже скомпилированный запрос и с этим работаешь. Тут нужно обратить внимание, что это немного другой уровень: тут нужно прямо JIT полностью себя интегрировать, можно прямо патчить LLVM, добавлять туда всякие чеки. Потому что когда ты компилируешь что-то маленькое — как мы компилируем в ClickHouse агрегацию, сортировку, компаратор, выполнение выражений, — у тебя радиус поражения не очень большой: там можно даже глазами посмотреть по функциям, нормально они компилируются или нет. Плюс мы компилируем не все функции, а только те, которые легко компилировать: с регэкспами, со строковой функцией, с функцией с массивами мы не компилируем, хотя это можно делать. А если ты используешь JIT-компиляцию для всего запроса, там может быть очень много сложных вещей. Например, в той же агрегации тебе нужны хэш-таблицы, скорее всего они будут где-то сбоку, или же тебе нужно будет хэш-таблицу — прямо её код сгенерить и всунуть туда, прямо в IR. Это может быть прямо как компиляция полноценной программы, это очень аккуратно нужно делать. Например, в ClickHouse когда-то Алексей Миловидов пытался сделать так, чтобы из шаблонов C++ можно было через макросы, по-хитренькому, получить шареную библиотеку. Грубо говоря, сгенерить код на шаблонах, как условно файл, засунуть его, натравить туда компилятор и получить шареную библиотеку, открыть её и достать нужный символ. Примерно такая идея. Но эта идея не сработала, потому что там всё очень сложно делать. Если тебе нужно компилировать большие куски кода, наверное, надо очень хорошо это продумать, как оно будет работать. Например, есть хэш-таблицы — скорее всего, ты не хочешь писать хэш-таблицы в LLVM IR, потому что ты задолбаешься. Ты хочешь написать их, например, на C++ или на Rust, потом скомпилировать в LLVM IR, и, видимо, эти файлы захочешь таскать с собой, и когда будет проходить компиляция всего запроса, ты это туда подсунешь на линковку. Это очень сложно, надо прямо очень хорошо понимать, что ты делаешь, и продумать всю архитектуру компиляции.

[1:52:03] Александр: Мы, наверное, для слушателей рассказали такой продакшн-пример, после которого можно уже идти внедрять это себе в проект. Но если вы решили весь проект скомпилировать, то тут, конечно, нужно ещё очень много всего обсуждать.

[1:52:16] Максим: Слушай, я буду очень сильно удивлён, если хоть один человек после прослушивания этого подкаста пойдёт что-то себе в продакшн джитовать.

[1:52:24] Александр: Веб-сервис пришёл такой: так, сейчас я тут…

[1:52:28] Максим: Ну, это больше для общего развития, для инженерной эрудиции, чтобы понимать вообще, как система работает. И если вы разрабатываете такие системы, как базы данных, то, думаю, вы без этого подкаста и так всё это знали. Но в целом для общего развития очень интересно.

[1:52:49] Александр: Мне больше всего нравится то, что мы с тобой общаемся — как раз тот человек, который всё это делал, непосредственно руками трогал, а не тот, кто просто знает и рассказывает то, что знает. Это другого уровня экспириенс — такое общение, мне очень понравилось. Есть ли тебе ещё что-то по JIT рассказать?

[1:53:04] Максим: Ну, наверное, в ClickHouse особо ничего интересного больше рассказать нет. По JIT у меня есть доклад на C++ Russia, который можно посмотреть прямо с хардкором — как сначала без LLVM, просто руками аллоцировать память и писать туда инструкции, а потом уже всё делать с LLVM. Это вроде как ещё довольно актуальный доклад. Посмотреть, ради интереса, можно и на другие JIT’ы. Насколько я по своему опыту знаю, JIT-компиляция используется ещё во многих интересных сценариях, например, в области сжатия и разжатия данных: иногда люди для компрессоров и декомпрессоров прямо на рантайме тоже пишут JIT-компиляторы, но, кажется, уже ручные, типа как asmjit использует такие библиотеки. Эта область весьма интересная, куда можно присунуть JIT. И за одну сессию очень тяжело рассказать все детали, потому что если взять самые частые примеры — языки программирования как Java, базы данных как Postgres, ClickHouse, — это самые основные юзкейсы. Но юзкейсов может быть очень много. Реально JIT-компиляцию можно всовывать в обработку изображений и так далее, потому что бывают алгоритмы, которые в зависимости от каких-то условных параметров, каких-то настроек — вот мы понимаем: давай-ка скомпилируем этот алгоритм под все настройки, которые к нам от пользователя приходят. Или с сжатием данных там может быть много таких нюансов.

[1:54:34] Александр: Да, тема такая — думаешь сначала, что это довольно эзотерическая технология, которая используется только в JVM, а на самом деле много где, и много где её можно применить. Единственное, что довольно сложно всё это делать. Действительно нужно понимать зачем, понимать, сколько на это будет потрачено ресурсов. И инженеры нужны талантливые, чтобы это делать, потому что, мне кажется, не каждый сможет всё это аккуратненько, безопасно написать.

[1:55:07] Александр: Я вот в конце люблю у ребят, с которыми общаюсь, спрашивать какие-то житейские штуки — типа как стать таким же талантливым и заинтересованным инженером в том деле, которым ты занимаешься. У меня лично ответа на этот вопрос нет. Может быть, у тебя есть какие-то житейские лайфхаки или то, как ты развиваешься профессионально, чтобы поддерживать такой высокий уровень профессионализма, чтобы подобные вещи делать.

[1:55:34] Максим: Мне кажется, каких-то супер секретов тут нет. Нужно находить правильные задачи и от задач идти. Если, например, нет интересных задач, то особо и не прокачаешься — это чисто моё мнение. Поэтому нужно пытаться искать интересные задачи. А где их искать — тоже непонятно. Иногда бывает скучно и непонятно, чем бы сегодня заняться. Но нужно стараться держать какой-то список задач. Вот у меня есть такой список — например, в ClickHouse или не только в ClickHouse, в LLVM есть какие-то задачи, которые я хотел бы попробовать сделать, которые я просто знаю, что они есть, но пока не уверен, хочу ли я их делать. И когда такая задача… Бывает, проснёшься и: о, ну вот, кажется, сегодня наступил тот день, когда я хочу сделать эту задачу. У меня такой в прошлом месяце был день — я давно хотел в ClickHouse сделать рекурсивные CTE. Это возможность писать рекурсивные запросы в SQL. А эти делать — жёсткая фигня. Я всё боялся, так не хотел её делать, поэтому никогда не хотел за неё браться. А потом в какой-то день подумал: ну, наверное, когда ты ходишь про задачу — раз подумал, два, три, — ну, кажется, уже это и не такая уж страшная задача, можно попытаться её сделать. И действительно удалось её сделать.

[1:56:52] Максим: Ещё интересный паттерн заиспользовать. Для этой задачи я решил не писать тесты — я решил украсть тесты. Я украл тесты у Postgres. Все тесты, которые у них есть на рекурсивные CTE, потому что я подумал, что с этой фичей сам мало знаком. SQL на таком суперпрофессиональном уровне — знаешь, есть маги SQL, которые пишут десять тысяч строк на SQL, как или там на Bash, — я так не использую. Я обычно использую простые конструкции. А рекурсивные CTE — это уже такая продвинутая конструкция. И в Postgres ребята сделали кучу хороших тестов, и лицензия у них позволяет. Я взял их, и у меня они все прошли. В принципе, остался доволен.

[1:57:32] Александр: Блин, круто, да.

[1:57:33] Максим: И, например, сегодня, кстати, тоже был такой день. Давно была интересная задача в воздухе — сделать коррелированный подзапрос. Это тоже почему-то у меня странная задача на какую-то поддержку SQL. Эта задача, коррелированный подзапрос, тоже такая немного гнилая, очень хитрая. То же самое, как с рекурсивными CTE: нужно чётко понимать, что вообще в SQL для этого есть. И тоже удалось её за сегодня более-менее, как сказать, прототип первый сделать. Потом уже буду дальше. Тонкости есть, будем обсуждать с ребятами, как дальше это можно мержить. Мне кажется, иметь какой-то список задач. Можно просто составить список задач — вот какие штуки ты бы хотел сделать. Хочу, например, написать текстовый редактор. Хочу написать key-value распределённое хранилище с Raft’ом или с Paxos’ом. Таких набросать. И в какой-то день смотришь на список — кажется, какая-то задача приглянулась. Просто мне, например, самому не очень нравится иметь конкретный список задач, мне кажется, лучше по настроению работать. Например, тебе интересно сегодня почитать про JIT — ты нашёл задачу на JIT, взял, почитал и начал делать.

[1:58:52] Максим: Но просто ещё нужно иметь дисциплину того, что у любой задачи в этом списке должен быть понятный definition of done в виде кода. Это самый лучший вариант. Мне самому не нравятся задачи, в которых я должен что-то изучить. Потому что это какая-то странная задача: непонятно, как понять, что ты что-то изучил или нет. А вот если ты написал какой-то код — распределённое key-value хранилище сделать, или, например, написать маленький ClickHouse, — то есть по финалу написал маленький ClickHouse, то ты можешь понять, что я сделал задачу. Единственное, что дополнительно лично для моей мотивации — чтобы это было какое-то, может быть, open-source, чтобы приносило какую-то пользу. И в этом плане, наверное, только вступать в какие-то open-source-проекты, в какие-то community. Потому что по-другому ничего такого не найти. Я более-менее в двух местах стараюсь периодически что-то делать — это ClickHouse и LLVM. Но в LLVM в последнее время сильно меньше, последние полгода я мало чего там делал. У меня там тоже есть список задач, но там всё посложнее, чем в ClickHouse, потому что компилятор — более сложная штука. Поэтому иметь какие-то такие проекты, которые… ну, они ещё, конечно, должны нравиться. Потому что можно найти open-source-проект, который ты ненавидишь, — там очень сложно будет фичи делать. А если проект нравится и позволяет контрибьютить — очень хорошо. И если кому-то интересно поработать с C++, с базами данных, то, наверное, ClickHouse я бы посоветовал, это реально хороший проект. Если хочется поработать с компиляторами, тоже C++, — это LLVM, хороший проект. Из интересных проектов на Java, мне кажется, Spring — неплохой проект. Они принимают pull-request’ы, у меня там был один pull-request, даже два, которые по итогам попали. Там можно чему-то научиться. Из проектов на Go тоже наверняка можно поискать. То есть нужно просто выбрать язык и относительно него смотреть проекты. Но можно даже к языку не привязываться: вы, например, программист на Java — можете всё равно приходить в ClickHouse и пытаться писать на C++, решать какие-то задачи. Потихоньку этот опыт приобретается.

[1:59:54] Максим: Мне кажется, тут главное идти именно от задачи. Изначально самому тяжело придумывать задачи. Для этого тоже очень хорошо подходит community: ты можешь взять, например, в том же LLVM или ClickHouse — просто заходишь в issues, смотришь, какие там есть проблемы. Листаешь-листаешь: вот кажется, это что-то приличное. Нужно ещё силы оценивать, сколько это может занять, потому что иногда бывает, что задача реально может планироваться на месяц. Такие задачи лучше не брать. Лучше в качестве задач — по крайней мере, когда хочется что-то поделать для себя или для развития — брать задачи, которые можно сделать. Даже не то что полностью сделать, может, не полностью, но дойти до какого-то шага коммита, когда уже хоть что-то работает, за один-два дня. Может, за выходные, с пятницы если начинать. Потому что если брать задачи на месяц, вот такие задачи обычно всю силу забирают и тебе очень-очень сложно. Когда, например, в ClickHouse я занимался разработкой анализатора, первая версия анализатора оказалась вообще говоря ерундой. Мы тогда с коллегой поревьюили и всё-таки решили, что в ClickHouse слишком сложный SQL и всё это не налезет на ту архитектуру. Я к этой задаче возвращался — понимал, что её очень сложно сделать. Нужно реально иметь очень хороший настрой, потому что на такие долгие задачи можно даже бояться наступать. Я только через год где-то сделал второй подход. И где-то пару месяцев заняла такая первичная разработка — это не весь solution, но первичная разработка. Такие задачи я не советую, потому что они очень сложные, очень долгие. Лучше, если именно для своего развития, а не проектная работа, брать задачи, которые за пару дней можно довести до какого-то работоспособного состояния и какую-то гипотезу проверить.

[2:02:32] Александр: Да, ты всё говоришь чётко. Я для себя тоже иногда по настроению ловлю какой-то вайб, что ту технологию, которую я использую — всё же мы используем библиотеку, — просто можно по-любому что-то взять и пофиксить какой-то баг, который вы нашли, или какую-то фичу вам не хватает, или просто пойти открыть issues и что-то увидеть. Подобные проекты типа Spring — вот я Micronaut коммитил — так широко используемы, что это глубокое знание не просто поддержит какой-то профессиональный интерес, но и напрямую, условно, следующее собеседование вы пройдёте на грейд выше — просто потому что вы этот код руками ковыряли и два дня потратили, чтобы понять, как оно там работает. Естественно, вы уже не теоретически знаете, чем request scope бин отличается от другого бина, а вы, блин, брали и патчили эти бины, грубо говоря. И это вообще другой уровень: вы знаете лучше, чем интервьюер в этом случае. И если совсем такой шкурной мотивации — ну, зарплата будет повыше, наверное. Это такой житейский, понятный, очевидный совет.

[2:03:43] Александр: А вот такой неочевидный, что ты тоже вскользь упомянул — типа, за два дня, за выходные. Некоторые говорят: нет, выходные нельзя, надо отдыхать. И вот тут как раз конфликт возникает у меня иногда: как потратить два дня, просто сидеть код писать какой-то непонятный, что-то изучать, либо просто отдохнуть качественно. Тут, конечно, баланс у каждого свой. Я для себя нахожу баланс, когда мне интересно, меня это драйвит, я условно дофамин получаю от этого действия — то есть не страдаю, не мучаюсь, не испытываю стресса во время решения задачи, а наоборот испытываю интерес и кайф, тогда это можно делать на выходных. А если что-то совсем такое, что надо дебажить, сидеть, мучиться, что-то понимать, я лучше в рабочее время, если есть возможность, этим буду заниматься, а лучше вообще не буду. Есть ли у тебя какой-то рецепт по тому, как ворваться, как work-life balance соблюдать в плане такого crazy-программирования?

[2:04:45] Максим: Ну, тут, конечно, про work-life balance тяжело рассуждать. Не того человека ты спросил, Саша. У меня-то с work-life balance так себе. Но в целом кажется, что то, что ты говоришь, правильно. Для меня, например, очень хорошо, когда ты понимаешь, зачем ты это делаешь. Бывает просто так, что хочешь что-то изучить, но вообще не понимаешь, зачем ты это делаешь: вроде ты что-то читаешь, но у тебя мозг вообще не соображает, не понимает, что он делает. Самое лучшее — для себя просто понимать, в чём твоя мотивация конкретно этого процесса. Например, если ты на выходных решил поэкспериментировать с какими-то распределёнными алгоритмами, и тебе реально интересно почитать какие-то paper’ы, посидеть, подумать, то это может реально приносить удовольствие. А если ты, например, действительно сразу видишь, что вот тут тысяча багов в ClickHouse, то, наверное, лучше этим не заниматься на выходных, можно как-то отдохнуть.

[2:05:39] Максим: Из таких вещей про отдых… Мне тяжело решить. Часто бывает так, что — по крайней мере, мне раньше это помогало — для отдыха смотреть какие-то видео, например, с конференций. Это в каком-то смысле баланс, потому что видос с конференции, если смотришь на скорости два икс или один икс, ты можешь посмотреть какую-то технологию, послушать людей. Это может быть полезно. Но я и сам в такое не особо верю, потому что, мне кажется, если ты смотришь какие-то видосы про что-то хардкорное — это очень полезно, а если про базовые вещи, то может быть и бесполезно. Я помню, из подкастов мне когда-то давно нравился один — не помню, он назывался, кажется, про Swift. В общем, это был подкаст про Swift, и у них в гостях был Крис Латтнер. И вот такое очень интересно послушать: можно, например, идти по улице и слушать. Прогулка с пользой. Такой контент тоже можно находить: пойти на улицу гулять, но что-то интересное послушать. С точки зрения work-life balance ещё всё зависит от того, как человек на работе устаёт. Бывают разные периоды. Например, человек на работе настолько сильно устал, что уже не может открыть Neovim, потому что ему становится не по себе, — знаешь, он там ногти грызёт, ему слишком тяжело. В таких случаях однозначно нужно придумать какой-то отдых, потому что без отдыха работать очень продуктивно не получается. Наверное, только если задачи какие-то суперинтересные, как ты сказал, ты получаешь от этого удовольствие, — тогда можешь ими заниматься чуть ли не бесконечно.

[2:07:32] Александр: Да, я для себя, знаешь, в последнее время всё чаще сравниваю это с играми. Человек же может играть целыми днями в игру, ему нравится. Но это не сказать, что это отдых: он не лежит на диване, в потолок смотрит — там мыслительный процесс, мозг капец как напрягается, реакции и так далее. Но нравится же. С программированием — более-менее хорошим — примерно так же должно происходить. У меня так иногда происходит, когда я такой: о, да это как игра, я сейчас вообще развлекаюсь — тут что-то сделал, там сделал, тут бенчмарк запустил, о, помогло, прикольно. В таком же флоу абсолютно находишься, как бы и не устаёшь. Если что-то действительно прямо сложное, scientific, — ну да, надо вместить в себя сложный алгоритм. Мне кажется, вот эти common table expressions, рекурсивные, — вот там надо было напрячься.

[2:08:19] Максим: Да я их написать не могу, реально. А чтобы написать код, который их интерпретирует, — это ещё сложнее. Тут надо прямо захотеть, что называется.

[2:08:30] Александр: Вот, а так, наверное, всё. Вообще просто выдали и базу, и про базу кучу всего поговорили. Я думаю, что этот выпуск конкретно у нас будет только про JIT. А наш с тобой кусочек интересный, где мы обсуждали Go, редакторы кода и так далее — в начале тоже очень интересно было, — я выпущу отдельно в телеграм-канале как бонус, для тех, кто подписан. Ребят, заходите и слушайте приколюшки про язык Go, Rust и C++. На этом, наверное, всё. Всем большое спасибо, что дослушали. И пока!

[2:09:03] Максим: Всем спасибо, всем пока!