#36: LLVM: Rust, современный C++, как законтрибьютить
Первый «хардкорный» выпуск: Александр Пахомов и Максим Кита (разработчик ClickHouse, контрибьютор компилятора Swift, коммитер LLVM) разбирают, как устроен современный компилятор — фронтенд, middle-end и бэкенд, `LLVM IR` в `SSA`-форме, basic-блоки и phi-ноды. По пути — почему на C++ тяжело писать без санитайзеров и `clang-tidy`, чем силён `borrow checker` в Rust, что такое compile-time-полиморфизм и `if constexpr`, как деление превращается в умножение, и как реально законтрибьютить в LLVM и компилятор Swift.
Главное
- Компилятор состоит из трёх слоёв: фронтенд переводит язык в промежуточное представление, middle-end оптимизирует его backend-агностично, а бэкенд генерирует ассемблер под конкретную архитектуру; `LLVM` — это middle-end плюс бэкенд, а фронтенды (`clang`, Rust, Swift) отдают ему `LLVM IR`.
- `LLVM IR` — это ассемблер в форме `SSA` (static single assignment) с бесконечным числом регистров: код разбит на basic-блоки, а phi-ноды выбирают значение регистра в зависимости от того, из какой ветки пришло управление.
- На C++ трудно писать безопасно: нужны юнит-, интеграционные и стресс-тесты, фаззеры с покрытием и прогон под всеми санитайзерами (address, memory, thread, undefined behavior), плюс включённые ворнинги и `clang-tidy`.
- Compile-time-полиморфизм (`if constexpr`, шаблонное метапрограммирование, `SFINAE`) генерирует специализации на этапе компиляции без runtime-оверхеда; в ClickHouse так диспетчеризуют операции над колонками разных типов.
- Деление — дорогая операция (64-битное может занимать порядка сотни тактов), поэтому компилятор заменяет деление на константу умножением и сдвигом; почти все операции выражаются через `and` и `not`.
- Перформанс-тесты надёжнее гонять, сравнивая версию «до» и «после» одновременно на одной машине, а не два прогона в разные дни: так исключаются шумы окружения, а нестабильные тесты видно сразу.
- Alive (`alive2`) верифицирует, что оптимизация `LLVM IR` корректна для всех входов — это как `TLA+` для распределённых систем, только для трансформаций компилятора; к пул-реквестам в LLVM прикладывают alive-proof.
- Контрибьютить в LLVM просто: вся разработка на GitHub, есть лейбл `good first issue` и теги вроде performance/vectorizer; недавно процесс переехал с Phabricator на pull-request'ы, а изменение в мастере тут же подтягивается сабмодулем в ClickHouse.
В выпуске
- Максим Кита — Разработчик ClickHouse, контрибьютор компилятора Swift и коммитер проекта LLVM. GitHub ↗ maksimkita.com ↗
Ссылки
Расшифровка
[00:08] Александр: Здорово! Меня зовут Саша Пахомов, и я инженер, который любит своё дело. Вы слушаете 36-й выпуск подкаста «Тысяча фичей». Сегодня мы поговорим про язык программирования Rust, разберёмся в сложностях современного C++, поймём, что такое compile-time-полиморфизм, погрузимся в теорию компиляторостроения на примере open-source-проектов Swift и LLVM, а ещё немножко затронем внутренности базы данных ClickHouse. Такие сложные темы я сам, очевидно, осилить не могу, поэтому я позвал гостя. Это Максим Кита — разработчик ClickHouse, контрибьютор в компилятор языка программирования Swift и коммитер проекта LLVM. В общем, Максим точно шарит в том, о чём говорит. Это очень жирный выпуск с кучей технических деталей. Такие выпуски я буду называть «хардкорными». Если вы планировали послушать подкаст во время работы или за рулём, настоятельно рекомендую отложить прослушивание до момента, когда вы сможете полностью погрузиться в процесс и ничто вас не будет отвлекать. Ну а мы начинаем.
[01:29] Александр: На самом деле из open-source-проектов, мне кажется, C++ супер популярный. Но в последнее время я вижу, что много проектов стартует на Rust. Из того, что мне было интересно недавно, — это векторные базы данных. Я там баловался с ChatGPT. Не знаю, насколько ты вообще в теме… Я решил посмотреть, что там происходит, и там такая интересная штука — агенты. Агент — это, грубо говоря, large language model, которой ты сначала говоришь: вот у тебя есть такие-то тулы, есть браузер, ему можно давать запросы, есть база данных, куда можно что-то сохранить, и так далее. Ты этой large language model задаёшь вопрос, она понимает, что такой информации у неё нет, и решает: схожу-ка я в базу данных. Идёт в базу, из твоего запроса генерит эмбеддинг, находит документы, откуда мог бы взять информацию, они возвращаются обратно, и она уже использует этот контент. И это прикольно, потому что можно, например, взять свою внутреннюю проектную документацию, какие-то приватные вещи, — и всё это очень легко интегрировать через векторную базу данных плюс эти документы. И все векторные базы данных написаны на Rust почему-то.
[02:49] Максим: Абсолютно такая же тема. Я даже из-за этого начал сейчас задротить Rust, потому что понимаю: если в него прям конкретно въехать, можно делать что-то интересное, и как раз в сторону баз данных я хочу разрабатывать подобные штуки. Пока просто читаю Rust Book по главе в день и решаю задачки на LeetCode. Но, конечно, сам язык такой… вот эта модель borrow checker немного ломает мозг в начале. Хочешь написать код — и не можешь, он не компилируется. Ты такой: «Да блин, я же всё написал, вот логика, а оно не компилируется». Где-то что-то забыл.
[03:33] Максим: Я на Rust сам немного пробовал, но из интересного могу рассказать, какой недавно был кейс. Очень интересный. В ClickHouse я нашёл проблему, которая не давала выполнять очень много конкурентных запросов. Когда выполняешь больше тысячи конкурентных запросов, ты упирался во внутренние блокировки в ClickHouse — ну, там мьютексы. И был один мьютекс, в который почти всё и упиралось. Он находился в классе Context. А Context — это, знаешь, во всех таких больших проектах часто просто мусорка: туда кидают всё подряд. Из него можно достать, например, текущую базу данных для запроса, контекст текущего запроса, таблицы, кэши — куча всякого мусора валяется, и это всё было защищено одним мьютексом. Понятное дело, когда у тебя мало запросов, оно ни во что не упирается, но когда запросов много — начинает упираться. Представь: класс, а в нём полей 50–60, и они все защищены одним мьютексом, иногда несколькими, и тебе это нужно как-то отрефакторить. А сам файл Context, который всю эту логику реализует, берёт мьютексы в нужном порядке, всякие такие штуки делает, и там где-то 5–6 тысяч строк. Мозгом это уже очень тяжело отладить.
[05:01] Максим: И в clang есть такая штука — thread safety annotations. Как это работает: ты говоришь, что вот эта переменная защищена таким-то мьютексом, вот эта — таким-то, брать их нужно в таком-то порядке, — и оно на compile time это всё проверит. Самое прикольное, что, во-первых, удалось это нормально отрефакторить, а во-вторых, ещё удалось кучу багов найти после того, как мы это сделали. Мне эта штука очень понравилась. Нетривиальная часть была в том, что у нас мьютексы кастомные — наши мьютексы считают, сколько времени ты провёл в ожидании лока. Я разметил их специальными аннотациями, чтобы компилятор понимал, что это всё-таки мьютекс: класс реализует весь протокол мьютекса, и дальше это можно использовать. Это было прям супер круто, я реально вдохновился: берёшь, нажимаешь compile — и он на compile time подсвечивает тебе все баги.
[05:59] Александр: Ну, это то, что в Rust из коробки, да?
[06:01] Максим: Да, это супер круто. Такой большой рефакторинг реально руками провести очень тяжело. Ты можешь его отрефакторить, но уверенности никакой не будет, и, к сожалению, тесты могут что-то не отловить, потому что эти concurrency-баги даже с thread-санитайзером всё-таки более-менее вероятностные: если потоков много и мьютексов много, оно не всегда стреляет. Поэтому супер круто, когда есть такой compile-time-checker. Думаю, в Rust это сделано намного круче, потому что там оно прям в язык вмонтировано, у тебя всё защищено.
[06:40] Александр: Ну да, там даже есть такие штуки. Вот эта модель ownership говорит, что у объекта, который в куче, может быть только один овнер из текущего стека вызовов. И этот овнер передаётся в следующую функцию. То есть если я объявил s = String и аллоцировал строку в куче, а затем вызываю метод calculateLen и передаю туда s, я передаю ownership в эту функцию. Теперь она владеет объектом, и когда стек уйдёт из этой функции, фрейм снимется, объект перестанет существовать — вызовется деструктор. В C++ ты бы назвал это RAII: паттерн, когда вначале аллоцируешь, в конце деаллоцируешь, — только тут это делает компилятор. И поэтому после того, как ты передал ссылку, ты уже не можешь её использовать, компилятор тебе не даёт. Ты вызвал s = helloWorld, потом s.calculateLen, а printLen уже не скомпилируется: он скажет, что ты передал ownership и больше её использовать не можешь. Чтобы так не было, ты должен её как бы одалживать — передавать по ссылке, borrow.
[07:59] Александр: Ownership-модель реально вшита в компилятор, и то, как ты думаешь над программой, она аффектит. Это не просто какой-то чекер — это меняет весь подход к программированию. И прикольно, насколько мощный компилятор. Он может ещё проверять, что на мутабельный объект — когда ты объявляешь, что он мутабельный (по дефолту всё немутабельно) — может быть только одна мутабельная ссылка. То есть только через одно место ты можешь менять состояние объекта, все остальные ссылки немутабельные, это чтение. По сути, у тебя ридеров может быть много, а райтер один — и это на уровне компилятора. Если ты объявил много ридеров, скажем s1, s2, s3 на один объект, используешь их везде, пересылаешь по функциям, по потокам, — а потом объявляешь мутабельную ссылку на тот же объект, компилятор это запрещает: смотри, у тебя десять ридеров, они ожидают, что объект не меняется, а ты заводишь мутабельную ссылку, через которую кто-то может поменять состояние. Поэтому ты должен сначала сделать мутабельную ссылку, отмутировать, скопировать объект в новую, а её уже передать всем. То есть на уровне структуры кода, порядка вызовов и присвоений ты уменьшаешь вероятность race condition.
[09:25] Александр: Интересно, но я, правда, пока всего на несколько процентов продвинулся в изучении. Язык прям супер. Я, правда, на плюсах профессионально не писал — только для себя и лабы в универе. Некоторые говорят, что плюсы — это жёстко, там undefined behavior и так далее. Давай немножко про плюсы поговорим. Тебе как хардкорному разработчику на плюсах утверждение, что на них писать тяжело, кажется верным или нет?
[10:03] Максим: На самом деле тут всё очень сильно зависит от того, на каком подмножестве языка писать. Например, в ClickHouse было более-менее так устроено, что всегда использовали супер новые фичи. В новых стандартах очень много всего делается, чтобы как-то обезопасить труд человека, который на языке пишет: добавляются вспомогательные классы, куча чекеров. Но, понятное дело, даже это от всего не защищает, потому что в самом языке очень много подводных камней, о которые легко споткнуться, — и у тебя буквально программа сразу перестаёт работать правильно. Это undefined behavior, signed-unsigned, всякие такие вещи — их прям супермного в языке. И если ты просто профессионально пишешь, тебе приходится миллион вещей держать в голове, условно бороться с программой, чтобы заставить её работать безопасно.
[11:06] Максим: В целом, даже если взять все текущие инструменты, нормальные C++-программы нужно покрывать, во-первых, юнит-тестами, какими-то интеграционными тестами, обязательно нужны фаззеры, нужны стресс-тесты, и всё это нужно запускать под всеми возможными санитайзерами. Из стандартных это address sanitizer, memory sanitizer, thread sanitizer, undefined behavior sanitizer — вот это всё нужно запускать вместе. И тогда ты можешь более-менее гарантировать… ну, не то чтобы гарантировать — вообще говоря, ты почти ничего не можешь гарантировать даже в этом случае, — но, по крайней мере, можешь быть уверен, что на основных путях в программе багов нет. Ещё, если ты пишешь свои фаззеры, хорошо бы собирать coverage — смотреть, в какие места программы ты заходишь, а в какие нет. Самое важное — следить за классами, которые называют building blocks: у тебя в программе есть вектор из стандартной библиотеки, он оттестирован, но ты пишешь свой вектор, свой ring buffer или свою деку — и желательно, чтобы для таких важных классов coverage был весьма хороший.
[12:48] Максим: Ещё полезно иметь clang-tidy. Забавно, что в текущем году кажется, будто все уже должны билдать программы со всеми ворнингами и с clang-tidy, а на самом деле многие этого не делают — и это супер зря, потому что ворнинги компилятора и clang-tidy позволяют отлавливать очень много таких shady-мест языка. Например, создал переменную и не используешь — компилятор даст ворнинг. Или, например, в конструкторе что-то не проинициализировал — это вообще забавная вещь, потому что, по-моему, даже сейчас компилятор не всегда пишет ворнинг. Такой логический баг: у тебя есть какой-нибудь интеджер, ты его не проинициализировал, он забит мусором, и потом с этим будут проблемы. Так что, да, язык тяжёлый, очень-очень много нюансов.
[14:00] Максим: Из хороших вещей: если пользоваться определённым подмножеством языка — обязательно использовать умные указатели, не уходить в очень мощное метапрограммирование, — то жить можно. Например, в C++17 есть if constexpr, который позволяет почти никогда не использовать SFINAE. Даже не знаю, стоит ли расшифровывать, что это такое. Наверное, расскажу для слушателей. SFINAE — это техника, которая используется в C++-метапрограммировании. Предположим, у вас есть какой-то класс, и мы хотим, чтобы, например, когда он работает с указателем, у него добавлялись специальные методы или он более эффективно был реализован внутри. То есть добавлять compile-time-специализации, писать код прямо в compile time, чтобы в runtime из-за этого не было никакого оверхеда. Применений намного больше: можно в compile time делать вычисления на типах, много интересных штук. Но в новых стандартах можно использовать концепты — это совсем новая фича, — а в C++17 добавили if constexpr, такой compile-time-if: компилятор сгенерит тебе две версии метода — в одной ты в этот if зашёл, в другой нет. Это суперудобно.
[15:45] Александр: Можешь какой-нибудь пример этой фичи привести? Вот ты сказал: есть класс, который работает с указателем, и мы хотим добавить ему какое-то поведение, но оно будет написано как бы прекомпилятором, каким-то софтом-экстеншеном к компилятору, который меняет итоговый код, — если я правильно понял. Что можно добавить с помощью такой штуки?
[16:20] Максим: На этапе метапрограммирования ты можешь генерить почти всё. В некоторых случаях метапрограммирования не хватает, и тогда приходится использовать макросы — но это уже подстановка на уровне препроцессора: берёшь часть кода и прямо физически подбрасываешь. А метапрограммирование выполняется, кажется, ещё на этапе фронтенда: когда этот код дополнительно генерируется, а потом, когда фронтенд закончил и перешёл к генерации intermediate representation, от него уже ничего не остаётся. Как ты можешь это сделать? Вот, например, в стандартной библиотеке есть vector — динамический массив, он принимает любое значение. И есть специализация для bool: он не хранит bool как есть — чтобы представить булево значение, достаточно одного бита, — и внутри он хранит эти були как биты, запакованные в число. С этой специализацией, кстати, много дебатов, хорошая она или нет, потому что она меняет интерфейс: теперь ты не можешь получить ссылку на элемент — получаешь ссылку на весь интеджер, в котором эти були запакованы. Не скажу, что это прям хороший пример.
[18:16] Максим: Из того, что прям в жизни часто используется, могу рассказать, что я сам применяю из более-менее новых стандартов. В ClickHouse есть колонки, и колонки бывают совершенно разных типов: интеджер-колонки, string-колонки, колонка-массив. И часто нужно, чтобы, например, функция plus работала очень быстро для специализированных типов. Вот у нас есть две колонки с интеджерами, скажем UInt64 и UInt64, мы входим в функцию plus, кастим левую колонку к UInt64 — да, это UInt64, — кастим правую, тоже UInt64, и тогда можем прыгнуть в отдельную специализацию, где написан цикл, складывающий именно UInt64. Но понятное дело, что все эти if-ы вот так руками писать — легко ошибиться. Поэтому часто можно использовать шаблонное метапрограммирование: вынести эти типы за скобочки и для всех возможных вариантов сгенерить специализации. Такой use case дженериков, только на метапрограммировании и на сгенеренных специализациях.
[19:28] Александр: То есть даже не дженерики, а больше полиморфизм. Когда у тебя есть метод add(Number, Number), где Number — родительский класс для интеджеров, лонгов и так далее, и когда ты вызываешь его с интеджером и интеджером, в стандартном ООП в Java интеджеры прикастуются к Number-ам и сложатся по логике Number-ов. А ты хочешь, чтобы вызвался метод именно (Integer, Integer), — и вот этот if и cast написан с помощью метапрограммирования, а не руками в коде.
[20:05] Максим: Можно и так сказать. Но важное отличие в том, что многие из этих вещей, про которые я рассказал, происходят на этапе компиляции — то есть в рантайме с этого всего оверхеда не будет. Из ещё одного интересного примера: представь, у тебя есть вектор, и класс какой-то простецкий. В C++ раньше такие типы называли POD-типами — сейчас точно не скажу, как они называются, но у них должен быть тривиальный конструктор, тривиальный деструктор, простая структура. И мы понимаем, что такую простую структуру можно копировать просто через memcpy. А если у класса есть конструктор, деструктор и прочие сложные штуки, мы обязаны соблюдать инвариант: во время копирования или перемещения аккуратно всё вызывать. И вектор внутри себя как раз может делать такой compile-time-if: если класс простой, я беру массив и копирую его memcpy — это очень быстро, суперспециализированная функция, на одном ядре throughput может быть примерно 14 гигабайт в секунду. А если класс сложный, придётся идти поэлементно, аккуратно копировать через конструктор копирования.
[21:57] Александр: То есть это такой compile-time-полиморфизм, я бы сказал.
[22:04] Максим: Да, именно так. В целом полиморфизм уровня компиляции, так тоже называют.
[22:10] Александр: Прикольно. Про такую технику в плюсах я, честно говоря, не знал. Но я больше программирую на Java и на более высокоуровневых языках, и там у нас это в рантайме происходит. Насколько я понимаю, полиморфизм в рантайме возможен и в C++ — там же есть классы, виртуальные вызовы?
[22:35] Максим: Да, это всё тоже возможно. Обычно код пишут так, что есть абстракции, могут быть виртуальные классы — грубо говоря, классы, у которых есть виртуальный метод, — и для них всё работает как в Java: вызываешь виртуальный метод, у класса есть наследники, есть таблица диспетчеризации, и ты попадаешь в реализацию сабкласса. Прикол плюсов в том, что ты можешь использовать и то, и то. И когда у тебя место, критическое к производительности, — например, resize вектора, где ты часто создаёшь векторы просто интеджеров, флотов, — для таких примитивных типов ты можешь использовать memcpy, и resize будет работать значительно быстрее.
[23:24] Максим: Ещё, когда про это рассказываешь, нужно понимать: представь, у тебя был метод, ты в compile time сгенерировал оптимальную реализацию, и за счёт того, что компилятор оптимизирующий, он заинлайнится в вызывающий метод, там что-то ещё оптимизируется. Компилятор умный — он может понять, что в какой-то кусочек кода мы никогда не зайдём, и удалить лишнее. То есть, если ты сейчас написал кусочек кода, понять, во что он развернётся, не так уж тривиально: во-первых, там может быть куча специализаций, во-вторых, компилятор делает много крутых оптимизаций. Может быть unrolling циклов — написал цикл, смотришь, а там огромная портянка. И ещё может быть векторизация: компилятор цикл и заанроллит, и завекторизует, и твой простецкий цикл становится огромным куском ассемблера.
[25:02] Александр: Ну вот видишь, у нас в Java немножко по-другому. У нас есть Just-in-Time-компилятор, который эти штуки прогревает: когда они прогреваются, он их в рантайме фигачит, и получается тоже довольно оптимально. Что меня всегда удивляет: вроде бы Java сама по себе такая медленная, монструозная — JVM, JDK, JRE, гарбич-коллектор, — но за счёт JIT можно писать очень перформансные штуки, которые, если разогреваются, фигачат довольно быстро. В том числе за счёт того, что JVM знает, на какой машине бежит, какая там архитектура, и как джитовать конкретно под этот паттерн использования на этом процессоре. Поэтому иногда может быть даже круче, чем ahead-of-time скомпилируешь и запустишь: оно же уже не перепишется в рантайме, программа написана.
[25:52] Максим: Всё верно. В плюсах, например в ClickHouse, у нас есть JIT-компиляция, но вот этой фичи, о которой ты говоришь, мне реально не хватает в языке. Я бы хотел, чтобы сам мог это делать. Представь: у тебя написано очень много кода, и ты не хочешь руками его переписывать. Обычно, когда говорят про JIT-компиляцию, ты этот код пишешь на уровне intermediate representation — например, в LLVM ты пишешь на уровне LLVM IR, по сути на таком более высокоуровневом ассемблере. А когда у тебя куча кода, было бы очень круто — вот какую фичу я бы хотел в C++ — чтобы можно было написать функцию, указать классы и методы, которые ты хочешь компилировать в рантайме, прямо написать этот код, и он компилировался бы в рантайме, используя всё, что есть на моей текущей машине. Тогда все косвенные вызовы могут уйти, и будут использоваться самые крутые инструкции.
[27:01] Максим: Это довольно сильно решает. Например, в ClickHouse хочется иметь максимальную производительность, но в то же время хочется иметь портируемый бинарник. На текущий момент, пока мы разговариваем, это всё-таки SSE 4.2 — такой инструкшн-сет, который можно считать весьма стабильным. Другие, например AVX2, AVX512, понятное дело, на некоторых процессорах их уже нет. Если ты свой бинарник загрузишь на сервер и запустишь, он упадёт с illegal instruction. И чтобы этого избежать — то есть иметь портируемый бинарник и при этом использовать самые крутые инструкции, — можно использовать dynamic dispatch: в рантайме проверять инструкции. Или можно использовать JIT-компиляцию: тогда ты тоже в рантайме смотришь, на какой машине исполняешься.
[28:56] Александр: А слушай, меня постоянно посещают мысли в эту тему. Вышли M-процессоры у Apple. Как вообще плюсы с новым процессором работают, во что нужно компилировать? Потому что у нас на Java оно как бы везде работает, а как у вас?
[29:17] Максим: На самом деле всё не так, чтобы очень просто. Если ты используешь стандартное подмножество языка, не используешь супер хитрые штуки, то, если компилятор внутри поддерживает архитектуру, твой код скомпилируется под неё и всё будет ок. А если ты используешь оптимизации под конкретную архитектуру — а 99% проектов их используют, — например под x86 можешь использовать инструкции, которые есть только на x86: посчитать CRC32 одной инструкцией или ещё какие-то вещи. Такие оптимизации для хеширования, SIMD-инструкции, о которых мы говорили, прибиты к определённой архитектуре, и тебе всё это нужно на compile time аккуратно вырезать, чтобы под другой архитектурой оно не запускалось. Это первая проблема.
[29:47] Максим: Вторая проблема — скорее для тех прям супер новых проектов, которые пытаются выходить на рынок ARM. Я довольно много занимался оптимизациями под ARM в последнее время, и там очень много нюансов. Вот на какие проблемы я натыкался. Представь, есть какая-то библиотека где-то в недрах твоего проекта, ты даже не знаешь, что она внутри делает, а она делает диспатч в зависимости от архитектуры: если x86 — буду выполняться так-то, а иначе, скорее всего, какая-то убогая архитектура, поэтому буду исполняться очень медленно. А ты запускаешь это на ARM, смотришь в perf top — это такой профайлер, в котором видно горячие функции, — и видишь, что какая-то функция, про которую ты даже не знал, медленно исполняется. Заходишь туда, а там просто вначале стоял дурацкий диспатч, и внутри реализация даже не использует специальные x86-инструкции. Ты можешь этот диспатч немного поменять — тривиально добавить: если ARM, заходи в более оптимальную реализацию, — и так выигрывать кучу производительности.
[30:24] Максим: Но для больших проектов это реальная проблема, потому что при переходе с одной архитектуры на другую у тебя меняется даже модель стоимостей. Например, из того, что я заметил: на x86-64 виртуальные вызовы получаются сильно дешевле, чем на ARM. За счёт чего — например, там branch predictor получше работает или ещё какие-то штуки внутри процессора. Или, например, на x86 ты намного меньше думаешь про атомики, про то, как у тебя расставлены memory order и в какие инструкции это генерится, потому что там это более-менее без разницы. А на ARM ты очень сильно за это беспокоишься, потому что там уже есть серьёзная разница между sequential consistency и acquire-release-семантикой, — и за это действительно надо переживать. Возьмёшь свою рабочую программу, запустишь на ARM — и можешь очень удивиться, что она в 10 раз медленнее.
[31:19] Александр: Блин. Вот ты сейчас про процессорные инструкции сказал. А сам layout — например, расположение оперативной памяти на M-процессорах, когда они её практически на чип поместили, — это же очень сильно меняет модель костов, когда ты считаешь поход по памяти. Вот в контексте баз данных: когда оптимайзер что-то оптимизирует, он считает, что обращений в буфер будет столько-то, а этот буфер лежит в оперативной памяти. Но это же совсем другой буфер, нежели на процессоре i9, чем на M2, потому что там он лежит прям на чипе, и стоимость этого доступа может быть в разы меньше. Соответственно, вся оптимизация может пойти по-другому на этом процессоре.
[32:07] Максим: Да, но это ты уже говоришь про проблемы более высокоуровневые. Как к ним хорошо подходить — я, наверное, даже сходу ответа дать не могу. Действительно, кроме рантайм-диспатча тебе, наверное, ничего не поможет: ты в рантайме должен понять, что у тебя за процессор. Если модель стоимости зависит от таких вещей, ты должен прям в рантайме это сделать. Я видел, что люди делают так: когда программа запускается, они запускают, например, какой-нибудь горячий цикл, меряют, сколько он выполняется, ходят как-то по памяти, чтобы в рантайме понять, как работают кэши, какой доступ к памяти. Но это уже, конечно, очень специализированный софт: нужно очень аккуратно делать, потому что вдруг ты запустился, а система сейчас нагружена.
[33:11] Александр: А ещё, например, если ты делаешь какие-то оптимизации — вот ты у себя на компьютере их проверяешь, тестируешь, — а на других процессорах как посмотреть, что твой код не стал хуже перформить? И если стал — как это дебажить? То есть где-то должно быть много машин, и на них запускаться?
[33:32] Максим: Хороший вопрос, тут даже прям супер хорошего ответа тяжело дать. Как обычно делается: я, например, ClickHouse разрабатываю локально, у меня x86-машина. Когда мне нужно посмотреть, как оно будет работать на каких-то машинах, которых у меня нет, — например, с AVX512, — я просто поднимаю машину в AWS, подключаюсь, чекаутю код ClickHouse, билжу и смотрю. Или можно бинарь передать. То же самое, если мы говорим про ARM. Единственное, что обязательно нужно, если мы говорим про высокопроизводительное приложение, — иметь тесты на CI. Эти тесты очень хорошо, когда сравнивают версию до и после.
[34:31] Максим: Представь: ты запустил коммит, померил время, через день запустил другой коммит, померил, и оно разошлось для твоего приложения. И ты репортишь, что что-то случилось. На самом деле такой подход работает плохо, на мой взгляд. Хорошо делать так: ты сделал один коммит, запускаешь версию приложения до и версию после — и меряешь их друг с другом одновременно, прям одновременно. Это позволяет, во-первых, раньше детектить проблемы, а во-вторых, ты обычно делаешь это на определённой машине. В идеале, конечно, всегда локально: запустил свою программу до, запустил после, погонял перформанс-тесты. С базами данных это довольно просто — можно запускать SQL-запросы и смотреть время до и после.
[35:28] Максим: И когда сравниваешь, тут тоже несколько нюансов. Если просто смотришь время — посмотрел обе медианы, кажется, они разошлись, например, на 10%, — для этого используются статистические тесты, например тест Стьюдента: разное распределение, неразное, насколько они расходятся. Ещё важно делать вот что: запустил перформанс-тесты, прогнал запрос 10 раз и видишь, что именно для этого запроса разница значительная. То есть запрос нестабильный: сначала работал одну секунду, потом две, потом полсекунды — скачет. В целом это значит, что тест нестабильный, и, скорее всего, либо у тебя проблема в программе, либо кто-то на машине неизолированно запустил своё окружение. Для таких тестов ты ничего особо сказать не можешь, их нужно прям находить и разбираться, что там было за ерунда.
[36:38] Александр: То есть идея, если я правильно понял: вместо того чтобы сравнивать два прогона, вчера и сегодня, на разных версиях коммита, мы запускаем одновременно две версии и сравниваем здесь и сейчас, потому что это на одной машине, в одних и тех же условиях. А вчера на тачке мог бежать ещё пяток агентов, тестирующих перформанс, и она была медленной, а сегодня ты один — и она быстрая, и сравнение вообще не имеет смысла.
[37:07] Максим: Да, всё так.
[37:23] Александр: Прикольная идея. Интересно, а какая обратная сторона такого подхода? Два инстанса на одной тачке могут друг на друга влиять. И продакшн-инсталляция навряд ли гонит два инстанса ClickHouse — там, наверное, эксклюзивно выделяется, и в этом плане тестирование может чуть-чуть отличаться от реального продакшн-юзкейса.
[37:56] Максим: То, что ты говоришь, в целом валидно, потому что часто внутри приложения есть какой-нибудь busy loop, и он выедает у тебя ресурсы CPU — в таких случаях нужно что-то дополнительное делать. Но в ClickHouse обычно так: ClickHouse, который ничего не делает, почти ресурсов не потребляет. Если ты взял версию до и после и запускаешь запросы, они должны друг другу вредить примерно одинаково, поэтому на практике таких проблем у меня не было. Но если приложение специфическое и внутри у него есть какой-нибудь while(true) — прям иногда такое делают, — есть, например, фреймворки типа Seastar, внутри которого свой шедулер, а в шедулере busy loop, — то ты подключишься на машину, посмотришь потребление CPU, а оно очень серьёзное, хотя само приложение ничего не делает: просто крутит этот busy loop и ждёт, пока к нему придут запросы.
[39:06] Александр: Блин, интересно. Про LLVM, кстати, давай поговорить, про компиляторы. Ты в начале, когда рассказывал про метапрограммирование и про фронтенд, говорил, что это где-то исполняется. Я хотел тебя прервать, но подумал — потом скажу. Всё-таки фронтенд — это компиляторный, а не тот фронтенд, который нам привычно слышать в вебе. У компилятора ведь тоже есть и фронтенд, и бэкенд. Расскажи про компиляторы: как среднестатистический абстрактный компилятор в вакууме выглядит, какие у него есть основные части? А потом от общего описания мы перейдём к более частному.
[39:41] Максим: Обычно компиляторы состоят из трёх больших слоёв: это фронтенд, middle-end и бэкенд. На фронтенде есть, условно говоря, языки — например, C++, какой-нибудь Go, Rust, множество языков. Основная задача фронтенда — из языка со всеми его фичами и сложностями сгенерировать некоторое промежуточное представление. И intermediate-слой, middle-end, идёт сразу после фронтенда — он умеет работать как раз с этим промежуточным представлением. Например, LLVM работает с intermediate representation, который называется LLVM IR. И все фронтенды — фронтенд Rust, фронтенд C++ — используют LLVM: они просто генерируют тебе intermediate representation. Затем middle-end оптимизирует его. Там обычно весьма много проходов, и задача этого компонента — максимально оптимизировать intermediate representation. Например, какие там оптимизации: есть автовекторизация, есть instruction combining — представь, есть несколько инструкций, ты можешь их скомбинировать, что-то переставить, пооптимизировать. Есть куча оптимизаций, которые зависят, например, от стандартной библиотеки C: LLVM может понять, что ты написал memcpy на 16 байт, и поменять его просто на несколько mov.
[41:18] Максим: После того как middle-end отработал, у тебя получается оптимизированный intermediate representation, и последняя стадия — стадия бэкенда. Она тоже очень сложная, на её уровне тоже куча оптимизаций. Основная задача этой стадии — из промежуточного представления сгенерировать реальный ассемблер, реальный код. На этой стадии уже много машиноспецифичных оптимизаций: backend x86 может под себя переделывать, использовать какие-то специализированные инструкции, backend ARM может делать то же самое. Есть ещё куча других бэкендов — например, код, который ты хочешь генерировать под GPU. И в заключение: есть три стадии — frontend, middle-end и backend. Если подумать, какая у них мотивация, почему всё вокруг middle-end крутится: чтобы люди, которые пишут свой компилятор, — представь, мы решили придумать новый язык, — просто взяли и написали фронтенд, вместо того чтобы делать кучу работы. Если мы хотим сделать крутую оптимизацию, мы делаем её на уровне middle-end, и все сразу её получают. А если ты изобрёл новый процессор, твоя задача — просто написать бэкенд, и ты сразу автоматически получаешь кучу компиляторов под свой процессор.
[42:54] Александр: И получается, что LLVM — это middle-end плюс бэкенд?
[43:00] Максим: LLVM — это middle-end плюс бэкенд. Но условно говоря, в GitHub-проекте LLVM есть ещё фронтенд к C и C++, вроде бы ещё фронтенд к Fortran, там лежит стандартная библиотека C++, есть экспериментальная стандартная библиотека C, есть ещё куча библиотек — например, LLVM linker. Раньше эти проекты были разбиты на несколько, а сейчас, когда говорят «LLVM-проект», обычно подразумевают все эти проекты. Иногда это называют зонтичным проектом, umbrella-проектом.
[43:37] Александр: И это как бы один репозиторий на GitHub?
[43:41] Максим: Да, это один большущий репозиторий на GitHub.
[43:47] Александр: И получается, что ты можешь заработать street credibility, контрибьютя в один подпроект, а потом тебе дадут коммиттера, и ты сможешь коммитить в другой?
[43:56] Максим: Да, в целом более-менее так и происходит. Куда сначала лучше всего пытаться коммитить — это попытаться разобраться, как работает intermediate-слой, middle-end, потому что на его уровне нужно разобраться с LLVM IR: какие в нём есть инструкции. По факту LLVM IR — это ассемблер с SSA. SSA — это static single assignment: ты работаешь с ассемблером, в котором бесконечное количество регистров, и ты не можешь менять значение в регистре, можешь только создавать новое значение. Это удобно, потому что тогда на уровне оптимизаций тебе не нужно думать про то, как оно физически будет ложиться на регистры: ты просто делаешь оптимизацию, а отдельный проход почистит мусор, если ты его после себя оставил. Плюс такая форма хороша с теоретической точки зрения компиляторостроения, потому что позволяет много оптимизаций делать.
[44:57] Максим: LLVM IR состоит из инструкций, и инструкции тоже очень интересно сделаны. Всё состоит из basic-блоков, а basic-блок — это последовательность инструкций, которая заканчивается терминирующей инструкцией. Терминирующая инструкция — это, например, выход из функции (ret) или переход в другой basic-блок. За счёт того, что это так по-простому сделано, очень много оптимизаций можно делать, и такой код очень легко анализировать. Например, в компиляторостроении часто используется штука, которая называется dominance graph: ты можешь понимать, какая инструкция доминирует другую, то есть всегда выполняется перед ней, и так можешь много интересных оптимизаций делать. Это представление позволяет работать с циклами. Есть ещё специальная phi-нода. Так как у тебя static single assignment, может возникнуть интересная ситуация: идёшь по flow программы, у тебя есть if и else, и в зависимости от того, куда ты попал, значение регистра должно быть разным. Для этого и служит phi-нода: она параметризуется ветками, из которых пришла, и для каждой ветки — своим значением.
[47:23] Александр: Я понял. Это проще представить слушателям, которым, возможно, тяжеловато, так: берём абстрактное синтаксическое дерево, часть, которая выглядит как ромб. Нода вверху, две ноды отходят по бокам, эти две ноды соединяются, и ещё одна внизу. Вот этот ромбик мы себе представили. И в нижнюю ноду мы придём так или иначе — либо по левой стороне, либо по правой. И прикол в том, что эта нижняя нода обладает атрибутом, по которому мы можем понять, через левую ветку мы в неё пришли или через правую.
[48:03] Максим: Ну да, в каком-то смысле именно так.
[48:09] Александр: Это открывает возможности для оптимизации?
[48:18] Максим: Это скорее прям базовый блок. Он не именно для оптимизации, а служит инструментом, чтобы ты мог, например, выражать циклы. Цикл чуть сложнее представить через phi-ноду. Вот представь: ты заходишь в цикл, и если phi-нода пришла из basic-блока внутри цикла, то есть из самой себя, у неё значение — своё же плюс один, иначе значение ноль, то есть она пришла из basic-блока перед циклом — это первый вызов цикла. Циклы так тоже удобно представлять. И затем, когда у тебя всё представлено такими basic-блоками, phi-нодами, простыми инструкциями, ты можешь использовать очень много алгоритмов, построенных на таком представлении. Только тебе ещё нужно проводить некоторый анализ — например, какой basic-блок перед каким выполняется. Но если такой анализ есть, ты можешь довольно много интересных оптимизаций делать.
[49:23] Александр: Получается, если вернуться в ту сторону, пока мы глубоко в бэкенд не ушли: какие у нас языки под LLVM могут компилироваться? Это Rust, C, C++, Swift, наверное?
[49:40] Максим: Да, Swift тоже может. Ещё есть, например, Objective-C на Apple-платформе, Fortran. И, кажется, там очень много языков, которые используют LLVM как intermediate-слой, — возможно, даже те, про которые мы не знаем, потому что люди могут делать какие-то приватные языки, которые просто компилируются в LLVM IR, а дальше ты его докомпилируешь.
[50:11] Александр: Насколько я знаю, у C или C++ компилятора есть не только LLVM-версия?
[50:17] Максим: Да. Есть компилятор под Windows-платформу — компилятор от Microsoft. Под Windows, кажется, LLVM тоже может работать. Есть ещё компилятор GCC, который под Linux работает, — GNU Compiler Collection. Он устроен значительно сложнее, чем LLVM. Сам я туда не контрибьютил, но из того, что слышал: там есть несколько intermediate representations, несколько intermediate-стейджей, которые с разными representations работают, там намного больше бэкендов, которые поддерживают разные странные архитектуры. Потому что GCC появился намного раньше LLVM — на 10 или даже на 20 лет, — и за счёт этого кодовая база сильно больше, не такая молодая, там много legacy-вещей. Поэтому туда контрибьютить, я предполагаю, сильно сложнее.
[51:23] Александр: То есть мы проговорили про фронтенды, про разные intermediate representation, про то, как оно представляется — что это ассемблерные инструкции, но с бесконечным числом регистров, и каждый раз в новый регистр мы кладём значение, и есть всякие плюшки типа того, что мы можем представлять циклы по-своему. И, оперируя этим представлением, мы можем написать ряд оптимизаций и преобразований из одного представления в другое, тем самым из более медленного получить более быстрое. Это делает уже код, который написан в LLVM, — то есть эта intermediate-штука. Поэтому компилятор, который я пишу, например, фронтенд Rust, может генерировать плюс-минус мусор и переложить оптимизацию на плечи intermediate representation, и таким образом получить более-менее сносный результат в бинаре. Правильно?
[52:29] Максим: В каком-то смысле да, но есть нюансы. Нюансы в том, что поверх LLVM IR — да, это ассемблер, да, у него SSA-форма, есть phi-ноды, basic-блоки, — действительно уже очень много компиляторостроителей написали теории: как это анализировать, какие анализы можно собирать, как их потом использовать. Но из интересного: в LLVM IR всё-таки есть функции — это ассемблер, в котором есть функции, — есть структуры. И для функций там весьма много вещей, которые тебе нужно проставлять. Например, то, как ты генерируешь циклы, кажется, сильно может влиять: если будешь странно генерить цикл, компилятор может его векторизовать или не может.
[54:32] Максим: Например, представь, есть функция, которая принимает три массива — A, B, C — и выходной массив, все четыре массива интеджеров. И ты хочешь записать в выходной A + B * C. Если ты просто возьмёшь такую функцию как есть и сгенерируешь, то LLVM, скорее всего, либо цикл не векторизует, либо не сможет сделать unroll, потому что не поймёт, что эти пойнтеры указывают на разные области памяти, то есть не пересекаются, не алиасят друг друга. Но конкретно последняя версия LLVM, скорее всего, в начале твоей функции это проверит: ты размеры массивов знаешь и можешь понять, что массив A от начала до конца не пересекается с массивом C, — а значит, они не алиасятся. Эта информация компилятору очень сильно помогает, и в таком случае цикл наверняка и заанроллится, и завекторизуется. Если ты не хочешь такое писать, то, скорее всего, на уровне своего фронтенда можешь этот момент анализировать, чтобы у компилятора было больше информации.
[55:50] Максим: Со стороны фронтенда ещё приходят всякие аннотации. Например, ты знаешь, что какой-то бранч холодный — в него ты почти никогда заходить не будешь. Или, наоборот, горячий, ты почти всегда будешь в него заходить, и не хочешь, чтобы компилятор пытался менять его на инструкцию cmov (conditional move), потому что ты понимаешь, что всегда будешь в этот бранч заходить, и тебе не нужен этот оверхед. Ты как пользователь можешь помечать бранчи как likely, unlikely, predictable, unpredictable. И в целом задача фронтенда языка — дать тебе инструменты, чтобы ты мог это делать. В C, C++ для этого служат специальные аннотации.
[56:48] Максим: Ещё что приходит с фронтенда для функций — это, условно, инструкции скомпилировать. Например, в C, C++ ты можешь указать, что хочешь, чтобы эта функция была скомпилирована с AVX512, а если у меня инструкций нет, я готов получить illegal instruction. Или, например, ещё интересная штука: на уровне фронтенда можешь говорить, что вот этот интеджер именно в твоей программе может принимать три значения — 0, 1 или 2. Это забавно, потому что компилятор в таком случае понимает, что там может использоваться, и если дальше по коду что-то заинлайнилось в вызывающую функцию, он может понять, какие бранчи можно вырезать. Про всё это хорошо думать так: когда оптимизирующий компилятор работает, на уровне фронтенда ты скорее предоставляешь инструменты для разработчика.
[58:00] Максим: Ещё что важно на уровне фронтенда — это всякие семантические оптимизации. И вот как раз Swift — это компилятор, у которого есть свой intermediate representation, он называется SIL, Swift Intermediate Language. На уровне этого intermediate language, более высокоуровневого, ты тоже можешь делать оптимизации, которые относятся именно к твоему языку. Приведу простую оптимизацию. Представь, ты знаешь, что у тебя есть класс — в стандартной библиотеке это вектор, — и ты сделал три push_back. Или, знаешь, есть языки, где ты можешь взять массив, сделать map, filter. Если ты просто будешь эти функции звать, они как-то не заинлайнятся, компилятор может это не оптимизировать. Но ты знаешь семантику этих функций, поэтому можешь эту цепочку на уровне фронтенда прям раскрыть, чтобы компилятору потом было проще оптимизировать. И, мне кажется, фронтенды Rust и Swift весьма много делают, чтобы похожие оптимизации работали.
[58:57] Максим: И ещё на уровне фронтенда, понятное дело, ты можешь много чего проверять. Вот фронтенд Rust, я полагаю, весьма сложный, потому что там есть borrow checker, там есть куча специальных статических проверок: на этапе компиляции он может, например, посмотреть, что на какую-то переменную нет ownership. Там задействованы нетривиальные алгоритмы, чтобы это быстро компилировалось. Поэтому фронтенд — это тоже весьма большая штука.
[59:57] Александр: Ну да, серьёзная штука получается. И даже во фронтенде есть свой intermediate representation, чтобы это был как бы уровень 1 компиляции, а потом уровень 2 уже передаётся в LLVM, и он делает свою работу. Интересно вообще. Про такие темы можно бесконечно разговаривать — компиляторы, интерпретаторы и так далее.
[1:00:30] Александр: Если продолжить дальше про Swift: ты же контрибьютил какие-то части компилятора в Swift, и, я так понимаю, как раз во фронтенд?
[1:00:41] Максим: Да, я контрибьютил фронтенд и немножко смотрел Swift Intermediate Language, вот этот SIL, — несколько небольших патчей законтрибьютил.
[1:00:55] Александр: И этот компилятор написан на плюсах, да? А можешь рассказать, как этот проект устроен? Я в компилятор Swift не смотрел. Я, конечно, писал на Swift какое-то время приложение — сам язык очень интересный, выразительный, свежий, похож местами на Rust, местами на Kotlin, на C#. Интересно узнать: если я захочу сейчас в нём разобраться, вычитать код, найти issue и законтрибьютить, что мне нужно будет сделать, куда смотреть?
[1:01:35] Максим: Могу рассказать по опыту, условно, несколько лет назад, потому что в последнее время я в Swift ничего не делал. В целом проект устроен так: вся open-source-разработка на GitHub. Сам Swift, как мы поговорили про backend, middle-end, frontend, — на уровне фронтенда там тоже внутри есть разные составляющие. Например, есть лексер, парсер, семантический анализ. Потом есть стадия, где Swift Intermediate Language, и затем он уже преобразуется в LLVM IR, перегоняется в него, и дальше вызывается middle-end LLVM. В самом проекте кроме этого есть много нюансов: например, есть поддержка того, как Swift будет работать с Objective-C, для этого много всего делается. Есть стандартная библиотека Swift — динамический массив, хэш-таблицы, всё вот это.
[1:02:37] Максим: Из задач: на тот момент, когда я смотрел проект, на GitHub и на Swift-форуме весьма много обсуждений происходило. Кстати, Swift-форум — это реально очень крутой форум, потому что там было реально много энтузиастов, которые делились всякими советами по компиляторостроению, по фишкам с оптимизациями. Весьма много полезного там было. Плюс там ещё была Jira, в которой можно было посмотреть задачи, которые люди из Apple делали, засайнить задачу на себя, написать тесты, сделать pull request — и там пройдёт CI, как будто человек-помешан. Заходишь на форум — это как движуха комьюнити, что-то где-то почитать, по ссылочкам походить, поузнавать. На Jira заходишь, чтобы найти тикет, — я так понимаю, их можно отсортировать по сложности, good-first-ish или не очень, — находишь, асайнишь на себя и начинаешь работать через GitHub стандартно: веточку сфоркнул, pull request, CI.
[1:03:49] Максим: Единственное — не все изменения таким образом можно пропихнуть в Swift, потому что это всё-таки язык: могут какие-то вещи меняться, которые не нужно менять, и поэтому нужен какой-то core-комитет, который защищает изменения. Если хочется добавить новое ключевое слово в язык, то процесс намного сложнее.
[1:04:17] Александр: Да, процесс добавления оптимизаций и всяких простых багфиксов — вот как ты рассказал. А процесс, где нужно добавить новую фичу, — это уже RFC?
[1:04:28] Максим: На тот момент это был процесс, где нужно было создавать RFC: ты создаёшь какой-то пропозал, призываешь туда людей из core-темы, и этот пропозал ещё дублируется на форуме, люди всё это обсуждают. Процесс, понятное дело, непростой, потому что любая фича — как ты сказал, например, ключевое слово в языке, — если ты его добавил, скорее всего, оно с языком уже надолго. Когда язык молодой, многие вещи ты, конечно, не будешь депрекейтить. И если ты добавил какую-то ерунду, это прям будет супер больно, потому что её потом всю жизнь всем поддерживать.
[1:05:06] Александр: Ну да, такой энтузиаст пришёл, запалил что-то за две недели без сна, зафигачил ключевое слово супер-пупер — и всё.
[1:05:18] Александр: Но вообще удивительно в какой-то степени, что в такие сложные проекты — понятно, что Swift это язык программирования, проект Apple, понятно, зачем он им нужен, — но что в него можно вот так вот зайти и что-то поменять или улучшить.
[1:05:37] Максим: Да, скорее всего, всё-таки улучшить перформанс — это самый безболезненный контрибьюшн: он очевидный, ничего не ломает, тесты проходят.
[1:05:55] Александр: И контрибьюшн в язык программирования уровня Swift — это довольно крутое достижение даже в плане карьеры. Хотя вот сейчас поговорил с тобой, и кажется, что при достаточном желании это просто можно сделать. Ну и знание плюсов, конечно, и каких-то основ компиляторостроения. Я думаю, там книжечку с драконом нужно обязательно прочитать. Кстати, по компиляторам я только её одну знаю. Есть ли ещё какие-то образовательные ресурсы, чтобы вкатиться в компиляторостроение и хотя бы понять, что в тикете написано?
[1:06:31] Максим: Хороший вопрос. Наверное, скажу так: ресурсов прям хороших не то чтобы очень много, к сожалению. Из того, что я считаю полезным — можно не сильно вчитываться, но полистать, — это книга дракона. Потом есть книга Никлауса Вирта, как построить компилятор: по его курсу записывались материалы, как раз её рекомендовал Крис Латнер в одном из своих подкастов. Плюс этой книги в том, что она очень маленькая, примерно 100 страниц, и там реально много всего покрывается, её легко загуглить. Есть отдельная книга Мучника по бэкенду, по бэкенд-оптимизациям, но она уже намного хардовее, чем книга дракона, — нужна, только если ты реально пишешь свои бэкенды. И есть очень полезная книга, которая лично мне была полезна, — «Hacker’s Delight». Там очень много оптимизаций, которые происходят на уровне LLVM, на уровне instruction combining. Например, у тебя есть какие-то инструкции — A and B xor C, — и там очень много таких оптимизаций, полезно посмотреть, как последовательность инструкций может превращаться в более простую. Про деление, про всё — это супер круто, эту книгу тоже рекомендую.
[1:07:57] Максим: А из курсов — честно скажу, хороших курсов по компиляторостроению я, наверное, не видел. Книги, которые можно почитать, плюс можно брать и делать свой игрушечный компилятор. Например, ты берёшь тот же LLVM, и внутри него есть прям туториал, по-моему, называется Kaleidoscope. Ты делаешь свой маленький язык, и там прям end-to-end: лексер, парсер — всё пишешь. Мне кажется, это самый понятный способ. А дальше, как начинающему разработчику, если ты хочешь именно заниматься компиляторостроением, очень полезно читать код LLVM. Потому что там в коде, если происходит какая-то нетривиальная оптимизация, сразу ссылка, где она была описана. И что мне очень нравится — там реально, мне кажется, 50% кода, если не больше, это комментарии. Это очень круто, потому что заходишь в какую-то функцию, там реально надо подумать, осознать, что происходит, но нет таких проблем, как часто бывает: приходишь в функцию, там супер сложный код, документации нет, оно покрылось кучей костылей. В LLVM код сложный, но у тебя есть всё, чтобы его осознать, — комментарии и всё такое. Тебе не страшно по такому коду ходить.
[1:11:12] Максим: Плюс, что мне ещё нравится в проекте: она всё переписывает тебе, весь класс. Ты коммитишь такой: «Да блин, я же две строчки поменял», а по факту весь класс изменился, потому что всё переписалось. Это неудобно, когда есть один понятный инструмент и один способ написать код, один формат — это снимает столько боли, и все эти вкусовщины «я хочу писать так, я хочу писать так» — это мелочь по сравнению с бенефитами от стандартизованного единственного способа написать код.
[1:11:38] Максим: И вот ещё добавлю, что мне нравится: у них не сильно много контрибов — по-моему, даже почти нет, — то есть у них всё своё написано: свои компоненты, свои хэш-таблички, которые нужны для их алгоритмов. И хорошо, что это всё задокументировано: у них очень много стайлгайдов, как реально писать код именно в LLVM. Их код сильно отличается от другого кода на C++, потому что, например, они не используют исключения. Иногда приходишь туда, смотришь — там сырой new, там сырой pointer, ты думаешь: «Оооо, а если исключение вылетит?» — а потом понимаешь: «О, да тут не может быть исключения, мы без исключений живём». И это становится реально намного проще: вот это про ownership — тебе намного проще про всё это думать, потому что не нужно думать, как у тебя там конструкторы, деструкторы. Ну, про это тоже надо думать, но в целом как-то проще рассуждать.
[1:12:40] Максим: Кстати, если заговорили — ещё немного про Go. В Go мне прям супер нравится, как всё сделано, и нравится их философия: давайте всё по-простецки сделаем. У них есть свои стайлгайды, есть, условно, как должно код-ревью проходить, и многие проекты закладываются на то, чтобы так же делать. И Go — как «Effective Go» или «Efficient Go» называется, — это, условно, огромная активная документация, я бы так назвал: там расписано, как идиоматически должен код выглядеть, со всякими интересными, возможно, мотивационными объяснениями, почему лучше делать так, а не по-другому. Мне это реально нравится, потому что эти документы можно прочитать и в целом понимаешь, вроде как нормально или плохо.
[1:13:40] Максим: А, например, в языках типа C++ — в Java, я думаю, примерно то же самое, потому что языки не молодые, — возникает проблема, что очень по-разному люди хотят писать код. Например, частый дебат: как вообще стиль должен выглядеть? Классы с большой буквы или с маленькой, методы с большой или с маленькой, переменные, аргументы методов, члены класса — с нижним подчёркиванием или без? И ты приходишь на какой-то другой проект, а там вообще всё может быть по-другому, и ты думаешь: «О, как же вы тут пишете всё это».
[1:14:18] Александр: Да-да, это ещё контекст-свитчинг усложняет: если тебе нужно в разные проекты одновременно писать код — довольно стандартная ситуация, — ты постоянно переключаешься. Или, например, у меня бывают кейсы, когда я использую библиотеку на работе. Недавно progress bar использовал в CLI-аппликейшене, который мы пишем для базы данных: файлики подгружаются, и рисуется прогресс-бар. И не было кастомизации цвета у этого прогресс-бара, а у нас разноцветная CLI, там жёлтенький цвет означает прогресс везде. Я хотел добавить этот цвет, вычитал исходники этой библиотечки прогресс-бара и за выходные законтрибьютил туда поддержку ANSI-цветов. И прикол в том, что там всё по-другому написано, тесты выглядят совершенно по-другому — ну, это Java-код.
[1:15:17] Александр: Представь, тебе нужно протестировать progress bar, который рендерит в консоль последовательность символов. Чтобы ты видел в консоли не ёлочку возрастающую, а один прогресс-бар, который просто меняет своё состояние, тебе нужно — по сути, консоль это текстовый вывод — вывести сначала одну палочку, за ней две, и если ты это чистым текстом выводишь, у тебя ёлочка начинает возрастать. А консоль хочет интерактивно показывать на одном и том же месте, поэтому после каждой строки нужно символ возврата каретки делать, \r. Я только когда туда писал код, понял, что такое возврат каретки: ты его ставишь, и терминал перерендеривает — возвращает каретку, убирает её и рендерит новые символы, а для тебя это выглядит как нормальный рендеринг на одном месте. И код, который это делает, производит вот эту последовательность символов. И вместо того чтобы тестировать строки — была такая строка, получил такую, ожидал такую, тест прошёл, — они это просто в консоль пишут, и всё, это весь тест: если оно написалось и ты глазами посмотрел, что оно выглядит так, потому что в консоли ты видишь интерактив, а в тестах этот интерактив не воспроизведёшь, — значит, успешный тест.
[1:16:45] Александр: И код-стайл, естественно, там разный. Понятно, что это моя привычка: у нас в IDEA, когда пишем, прям на кончиках пальцев есть этот Ctrl-Shift-Command-L — «отформатируй мне всё». Ты пишешь вообще фигню, потом жмяк, нажал, Enter — и оно всё отформатировалось красиво, ты об этом не думаешь. Там я, естественно, не мог этим пользоваться, потому что у меня IDEA настроена под себя, поэтому я аккуратненько сидел. В этом плане мне, кстати, нравится Vim- и Emacs-подход: ты более точно контролируешь исходный код и понимаешь, что делаешь на каждом этапе, потому что IDEA может реально переписать весь класс просто из-за автоформатинга, и ты можешь даже не заметить. Я часто коммичу код, поменял реально две строчки, а потом открываю ревью, и там куча комментариев типа «unrelated changes, unrelated changes», потому что я вечером в запаре закоммитил, а с утра увидел, что IDEA мне отформатировала. С Vim’ом, мне кажется, ты более осознанно это делаешь.
[1:17:56] Максим: Ну, про Vim не знаю, можно потом поговорить отдельно, я вот сейчас тоже прям восторгаюсь этой идеей и активно учу все биндинги. Насколько я знаю, ты тоже вроде Vim-биндингами пользуешься, кеймапом?
[1:18:09] Максим: Да, я использую VS Code с Vim-биндингами. В целом я пытался обычный Vim использовать, но, знаешь, чего не понравилось? Мне пришлось кучу плагинов наставить, и когда ты ставишь кучу плагинов, это уже как бы не трушный Vim: он там что-то делает, это настраивать надо. А по факту мне нужно только перемещаться по коду, иметь небольшие suggestions, иметь интеграцию с Git, чтобы посмотреть, какие файлы я поменял, и быстро по ним поскакать. И когда я это всё добавил, у тебя уже Vim похож на VS Code на минималках, и проще просто взять готовую вещь.
[1:18:57] Максим: Хотел ещё дополнительно добавить, что круто. Представь, ты зашёл в LLVM — не в Swift, а именно в LLVM — и закоммитил там какую-то оптимизацию в intermediate-стадию. И вообще все языки её сразу получили. Я, например, оптимизацию обычно смотрю с C/C++-фронтенда, но то же самое можно с другим фронтендом: заставить его сгенерить такой же код, при котором твоя оптимизация затригерится.
[1:19:25] Александр: То есть, условно говоря, написав оптимизацию в LLVM, которая какой-то специальный паттерн того же самого цикла оптимизирует на инструкции, ты понимаешь, что сейчас этот паттерн ни один фронтенд не воспроизводит, но ты можешь прийти в Swift, в Rust, в C++-компилятор и этот паттерн воспроизвести на фронтенде.
[1:19:53] Максим: Да, ты можешь так сделать. А можешь даже, когда сделал какую-то оптимизацию внутри LLVM, просто написать руками такой код на C или C++, на языке фронтенда, который сгенерит вот такой код, — и посмотреть, что до твоего изменения он не оптимизировался, а после стал оптимизироваться.
[1:20:17] Александр: Оправдать свою оптимизацию.
[1:20:19] Максим: Ну да. Таких оптимизаций действительно может быть очень много, и внутри LLVM их очень много, весьма хитрых, которые используют много математических хитростей. Из интересного, это в целом многие знают: если ты делишь на число, которое степень двойки, ты можешь это поменять просто на shift. А вот что интересно: когда ты делишь на какое-то константное число, компилятор может это поменять на умножение и сдвиг. За счёт того, что там всё по модулю в какой-то степени, это тоже можно сделать — есть пейперы, как это делать. И внутри компилятора это очень прикольно: заходишь в какую-то division optimization, а деление — это на самом деле супердорогая операция, потому что на современных процессорах 64-битное деление может занимать около сотни тактов, примерно такие порядки. И если ты хочешь суперпроизводительный код, ты не хочешь делить — хочешь умножать и складывать, как иногда говорят. И вот там оптимизация на деление: открываешь какой-то файл, а там прям куча ссылок на пейперы, на ресурс-статьи, куча edge-кейсов, а по факту деление на константу просто меняется на умножение и сдвиг. Человек, который пишет код, может про это не знать, но представь, как много паттернов, где ты просто делишь на какую-то константу.
[1:21:47] Александр: Да, прикольно, половина лидкода — разделить на два по модулю, разделить на десять по модулю, чтобы взять остаток, и разделить на десять, чтобы взять остальные части. Прикольно, что этот паттерн деления оптимизируется в умножение и сдвиг. У меня, кстати, недавно удивительно простая вещь была, но почему-то я её не осознал, когда изучал в университете: что вообще очень много операций, почти все, можно выразить с помощью небольшого множества логических операций — таких как and и not. По-моему, их две. or можно через них выразить, xor можно выразить, и потом через эти операции можно представить сложение, умножение, через умножение — сдвиг, деление. И таким образом мы с помощью тех же самых примитивных инструкций представляем более сложные штуки, которые нам, людям, в голове кажутся элементарными: «ну, деление, ты разделил» — а по факту это комбинация and, not. И вот так процессор думает — именно такими примитивами. И прикольно, что, чтобы процессору было проще, компиляторы говорят: «Не, мы тут делить не будем, мы сделаем умножение», потому что ему проще с его представлением это сделать.
[1:23:12] Александр: Кстати, а почему деление сложная операция, в чём её косты?
[1:23:18] Максим: Честно скажу, из того, что я когда-то читал — может, полгода или год назад, в книге Агнера Фога, там есть прям хорошая глава про деление, — в целом получается так, что это просто дорого реализовывать, это очень сложная операция. И там предлагается весьма много оптимизаций, как ты можешь избавляться от деления в своих программах в принципе.
[1:23:43] Максим: Из того, что интересно, — даже не наброс, но просто скажу тебе: в C++ даже нет пакетного менеджера до сих пор, стандартизированного. И если тебе нужно подтянуть библиотеку, эта задача нетривиальная. Во-первых, нужно понять, какой системой сборки пользуется эта библиотека. Если она использует CMake, то ок. Но вдруг это старая библиотека, которая использует Make, а у тебя проект на CMake. Или у тебя Bazel — я сам им не пользовался, но это одна из новых вещей, ну, для тех, кто им пользуется, уже не новая, но, кажется, ещё не так сильно распространено. Большинство проектов всё-таки используют CMake. Потом есть ещё пакетный менеджер Conan, который тоже люди пытаются использовать: делать репозиторий, туда бинари, библиотеки выгружать. Я тоже им почти не пользовался. В основном у меня такая стратегия — она, наверное, не всем подходит, — это использовать сабмодули. Ты просто добавляешь гитовый сабмодуль и его CMake к себе прицепливаешь.
[1:25:18] Александр: И ты вообще всегда всё собираешь as-is. Ты из тех, кто ядро Linux сам собирает из сорцов.
[1:25:29] Максим: Просто этот подход позволяет — по крайней мере из тех проектов, на которых я был, вот в ClickHouse мы такое использовали — как-то жить: у тебя есть CI, на нём оно работает, несколько конфигураций, значит, скорее всего, у всех будет работать. И ты не переживаешь, что где-то какой-то пакет не подтянуло или dependency не так сошёлся. Плюс очень понятно, что происходит, потому что по факту у тебя просто сабмодули, ты их понадобавлял себе — и всё. Но, понятное дело, в огромном проекте такое может плохо подходить. Например, у вас могут быть приватные библиотеки, которые тебе просто отдадут файлом с символами — статическую библиотеку и .h-файл — и скажут: «Теперь это твоя библиотека, используй её». То есть это не всем подходит. Но если проект весь в open source, то такая стратегия более-менее хорошая.
[1:26:31] Александр: Интересно. То есть ты находишь удобным подтягивание сорцов, CMake и собирание всего локально. Но там же, наверное, возникает небольшая сложность с версионированием. GitHub как бы не предназначен сильно для этого. Есть теги, наверное, ты по тегам подтягиваешь? Просто у нас в Java это очень просто реализовано: есть Maven, репозиторий Maven Central, там лежат джарники с чек-суммами, и их уже никто не поменяет, они подписаны, и ты понимаешь, что это именно тот джарник. А как при таком подходе с сабмодулями версионирование решать?
[1:27:27] Максим: В целом сабмодуль сам версионируется git-хэшом: ты подтянул сабмодуль под хэш-коммит, и он записывается. А само версионирование, как с этим всем жить, — вот как мы в ClickHouse делали: у нас есть какие-то библиотеки, мы периодически смотрим, какие у них есть версии. Например, поднялась версия, мы зашли в этот сабмодуль, поменяли в нём версию, собрали проект, вроде собирается, CI прошёл — всё, перешли на новый коммит в сабмодуле. Но, понятное дело, мы стараемся всегда использовать именно теги, чтобы не жить, как говорят, на live-at-head. Это немного опасно, потому что в мастере может быть сколько угодно багов. Даже по LLVM в мастере часто баги просачиваются: ты между релизами случайно взял мастер, а вдруг как раз в этот момент туда неприятный баг попал, и из-за этого всё очень плохо.
[1:28:38] Максим: Могу привести пример, почему такое версионирование — супер важная вещь. В ClickHouse у меня был интересный юзкейс какое-то время назад. Пришло issue, в нём был запрос типа select a * b + c, что-то такое. И этот запрос ты выполняешь три раза, а на четвёртый он падает. В ClickHouse есть JIT-компиляция, как раз которую я делал, и она использует под собой LLVM. Кажется, мы могли генерировать неправильный intermediate representation. Если ты генерируешь неправильный intermediate representation, LLVM внутри себя может упасть, — просто потому что он предполагает, что твой IR, который ты ему сгенерил, эти phi-ноды, basic-блоки, — валидный. Там есть некоторая валидация, и если он её прошёл, то в целом считается валидным. А если потом, когда он начинает генерировать код, какие-то инварианты не срослись, то за счёт того, что там нет исключений, обычно просто происходит terminate. Там можно задать свой хук — что делать, — и на свой хук можно кинуть исключение, но с этим немного опасно, потому что в этот момент, когда инварианты поломались, у тебя все эти объекты, проходы, внутри тоже могли покарраптиться: они явно не готовы к тому, что ты можешь исключение выбросить. Код написан без исключений, поэтому супербезопасно это сделать не получается. И поэтому нужно очень надёжно генерить IR: проверять, что с ним всё ок, и тесты на это должны быть.
[1:30:16] Максим: И что интересно: я долго смотрел на этот intermediate representation, поигрался, потом решил его просто дампнуть в файл и собрать отдельно компилятором. И компилятор упал. Потом я сделал даже более прикольную вещь: взял, написал на C++ цикл, такой примерно A + B * C, но там был if, и у переменных должны быть правильные типы — у одной uint8, у другой uint16, у третьей float32. И тогда оно падало где-то в недрах векторизации, когда пыталось это векторизовать. Что забавно: ты подтянул себе как бы сабмодуль, у них полностью публичные репозитории на GitHub, можно посмотреть все ищущие баги. И чисто технически человек, условный злоумышленник, мог бы пойти в ClickHouse, посмотреть, какой запрос мог бы привести LLVM к падению, попытаться такой запрос скрафтить в ClickHouse и тем самым сделать так, чтобы ClickHouse упал. От такого действительно очень трудно защищаться. Тот момент мы пофиксили тем, что обновили версию, — и всё.
[1:31:39] Максим: А в целом похожие вещи может быть весьма тяжело пофиксить, если ты какие-то зависимости подтягиваешь. Если в зависимости баг закрался в мастер, то человек, который использует ClickHouse, реально может пытаться это проэксплуатировать — прям чуть ли не с уровня SQL просто написать такой запрос. Поэтому нужно очень аккуратно всё это делать: переходить на новую версию, если тебе это реально надо. С этим тоже немного тяжело, потому что часто в организациях бывает так… Может быть, ты читал книгу «Software Engineering at Google»?
[1:32:04] Александр: Лежит в бэклоге, по-моему, вторая у меня.
[1:32:07] Максим: Там рассказывается несколько интересных вещей про то, как нужно большие системы поддерживать, монорепозитории и всё такое. Интересная мысль: когда ты долго не обновляешь версии своих библиотек, это становится почти нереально сделать в один момент, потому что у тебя просто столько всего, столько новых версий, непонятно, как всё тестировать. И нужно стараться делать это по шажкам — постоянно, итеративно какие-то библиотеки обновлять, а не заниматься в один момент супернереально большим рефакторингом. Поэтому приходится выделять время: смотришь, какие у тебя есть библиотеки, какие вышли новые версии, обновляешь их. Под это в больших компаниях даже есть специальные люди, обычно они называются DevTools. И в Google, и в Яндексе есть такие люди, которые реально ходят, обновляют библиотеки, весь монорепозиторий чистят от старых зависимостей. Это действительно очень важная работа, потому что иначе в какой-то момент ты сидишь на старых библиотеках, у них куча уязвимостей, проблем с перформансом, а обновиться нереально сложно за счёт того, что ты не обновлялся вообще между версиями.
[1:33:22] Александр: Да, то есть в какой-то момент времени стоимость обновления превышает стоимость переписать всё заново, и просто перестают обновляться: «Всё, ребят, без вариантов».
[1:33:35] Максим: Ну да, типа того.
[1:33:40] Александр: Но это, видишь, ещё актуально для таких больших репозиториев, такая мысль. А если мы говорим про микросервисы, то, мне кажется… Если у тебя протухает версия библиотеки в большом репозитории, то все продукты, которые написаны в этом репозитории, подвержены этому протуханию. А если у тебя микросервисы и не монорепа, то кто-то может и не обновлять, но так как сервисы взаимодействуют через REST, RPC или даже через файлы, — пофиг, система продолжит работать, но какие-то критичные части, например security, будут обновляться просто потому, что они не завязаны. В этом плане, наверное, основное отличие монорепы от не монорепы, микросервисов от монолита в том, что протухание и переписывание становится сильно дороже для всего.
[1:34:30] Максим: Прикольная мысль, инсайт.
[1:34:36] Александр: Да, слушай, вообще говоря, вот этот последний блок про ClickHouse и то, как там собирается с CMake, мне кажется, будет неплохой затравочкой на вторую часть подкаста — как раз про базы данных и ClickHouse в частности. Возможно, немножко поговорим про YDB. Потому что Максим не только интересуется компиляторами, контрибьютит в Swift и является коммитером в LLVM, но ещё состоит, наверное, в основной команде разработки ClickHouse, и поэтому в следующем выпуске подкаста мы поговорим про базы данных.
[1:35:15] Александр: Слушай, а если, например, я, Саша Пахомов, энтузиаст, контрибьютор open source, захочу законтрибьютить не в какую-то там библиотеку прогресс-бара на Java, а в LLVM, что мне для этого нужно будет сделать?
[1:35:27] Максим: Сейчас LLVM полностью находится на GitHub. Чтобы найти задачу, над которой ты хочешь поработать, ты просто заходишь в issues, и там есть какой-то лейбл типа good first issue. Ты можешь какую-то из таких issues взять, или, как я обычно смотрю, — по тегам: есть, например, теги performance, optimizations, я за ними слежу, там что-то интересное появляется, и понимаешь, что и тебе самому интересно разобраться, и проекту будет полезно. Меня, например, интересовали некоторые задачи с делениями, я в эту сторону смотрел оптимизации.
[1:36:00] Максим: Из хорошего: ты, например, хочешь разобраться в какой-то теме поподробнее — тебе даже не обязательно брать случайную задачу. Ты прям можешь поискать оптимизацию, которая именно тебе интересна. Хочешь разобраться, как циклы анроллятся или как векторизуются, — открываешь SLP-векторайзер или какой-то тег с векторайзером, смотришь, и обычно там оптимизация предлагается. Когда мы говорим про intermediate representation, оптимизации там кроссплатформенные, вернее, скорее backend-агностик: для всех бэкендов сразу работают. Если ты хочешь что-то под x86, или, представь, ты решил стать экспертом по RISC-V, — как тебе стать экспертом по RISC-V? Можно писать backend RISC-V. Ты выбираешь backend RISC-V, смотришь, какие там issues. И что круто: под некоторые бэкенды, под x86, очень много оптимизаций, а под RISC-V, я думаю, не так много — люди ещё сделали, — и там можно какую-то issue себе найти, поработать.
[1:36:59] Максим: И раньше весь процесс контрибьюшена происходил в Phabricator — это отдельная тула, настройка поверх GitHub, и он работал с использованием патчей. Ты делал патчи, человек их ревьювил, и затем, если у тебя есть merge-право, ты его мержишь, если ready to land; если не мог, то человек, который тебя апрувнул, за тебя мержит. Сейчас процесс немножко поменялся — происходит на GitHub, вся разработка на GitHub. Это совсем недавно было, пару месяцев назад. Поэтому ты просто делаешь pull request, и его помержат на GitHub. Это удобнее, потому что до этого приходилось возиться с патчами: сделал патч, приходилось его отправлять, смотреть, — а теперь ты можешь просто разрабатываться на GitHub, а в конце всё засквошить.
[1:38:40] Александр: И получается, что, попав в мастер или main-ветку в этот момент, твоё изменение считается уже как бы законтрибьюченным, и, например, ClickHouse, который использует, возможно, мастер как сабмодуль, сразу может — ты, знаешь, в Tmux перейдёшь в другое окно, сделаешь git fetch, и у тебя твоё изменение уже в ClickHouse.
[1:39:04] Максим: Да, именно так.
[1:39:06] Александр: Прикольно. Вот если бы я в Java, например, в какой-нибудь Spring контрибьютил, то с момента попадания в upstream, чтобы оно появилось, зарелизилось и было в Maven, это не так быстро, но зависит от проекта, от жизненного цикла. Если бы я сейчас свою библиотеку делал на Java, я бы построил pipeline, который по тегу или по новой ветке просто запускает процесс релиза и выкладывает всё в Maven Central под следующей версией, автоматически инкрементирует. Но некоторые библиотеки накапливают в одной ветке. Micronaut, например, я недавно смотрел исходники — это типа Spring, только используют не runtime-штуки прокси, а во время компиляции генерируют код из аннотаций, такая compile-time dependency injection. И там есть отдельные ветки — версия 4.0, 4.1, 5.0, — и они параллельно живут, каждый раз новая ветка это новая версия, ветками версионируют. А тут получается, что всё идёт в мастер. А релиз какой-то есть у LLVM? То есть понятие релиза вообще что значит?
[1:40:29] Максим: Да, релиз есть. Я точно не уверен, кажется, релиз два раза в год, точно сейчас не скажу. Во время релиза просто от мастера отводится тег — и всё. И тогда в основном люди используют определённые релизы: от мастера в какой-то момент ты отбранчёвываешься, у тебя, например, 17-й релиз LLVM, там тег добавляется, и ты можешь по этому тегу достать этот коммит с релизом и использовать именно его. На мастере, как я уже сказал, немного опасно сидеть, потому что мастер, конечно, хорошо тестируется, все тесты проходят, но всё равно за счёт того, что люди могли совсем недавно что-то смержить. И ещё бывает интересная штука: человек смержил какую-то оптимизацию, а ты делал свою, и получается так, что его код был смержен, а у тебя все тесты прошли, ты следом смержился — а вместе ваши изменения, если сложить, дают ерунду.
[1:41:35] Александр: Да, это боль. У нас иногда тоже бывает такое, когда CI прошёл, день-два прошло, я поревьювил, хочу мержить, а апстрим уже ушёл вперёд, и тесты на CI прогнались два дня назад, сейчас они уже не прогонялись, и ты жмакаешь — и потом может быть беда, иногда я исправляю эти ошибки. В этом плане какой подход, как мне кажется, должен быть? Естественно, ты как ответственный коммитер должен самую последнюю версию всегда себе подмерживать и что-то прогонять локально, если есть такая возможность. Либо после мержа должен перезапускаться на мастере CI, и если он сломался, то автоматизация должна говорить: вот, между этим и этим коммитом CI сломался на мастере, — нотифицировать и быстрее устранять проблему. Иначе это может найти какой-то другой разработчик, который на следующий день возьмёт с чекаута, а у него локально main не собирается, хотя CI зелёный, и непонятно, сколько он времени потратит, чтобы понять, в чём проблема.
[1:42:43] Максим: Ну да, всё как ты говоришь, обычно так и делается. Можно локально перепрогнаться перед тем, как мержишь, можно после того, как смержил мастер, посмотреть, прошли ли тесты. Если тесты не прошли, всегда можно просто отревертить — это в GitHub очень просто делается: нажимаешь revert, он создаёт тебе pull request на реверт, ты его быстро мержишь, а потом делаешь реверт реверта.
[1:43:05] Александр: То есть в LLVM понятие релиза — это реально отведённый стабильный тег, нет никакого бинари, где я brew install llvm могу сделать? Или это просто исходный код и есть релиз?
[1:43:19] Максим: Во время релиза всё-таки, да, всё, что ты говоришь, происходит: этот пакет прям паблишится, все библиотеки паблишатся. Внутри LLVM есть бинари — например, C/C++ компилятор, clang-format, clangd, language-сервер, — это всё тоже релизится. Это как стандартный релизный цикл: все твои релизные таргеты паблишатся, а brew их потом тоже подтягивает.
[1:43:51] Александр: Ну да, я, кстати, писал свой пакет brew. Там довольно интересный подход к тому, как они следят за новыми версиями: когда ты делаешь brew update локально, он тоже через git это всё делает, делает локальный git pull из всех репозиториев и потом подтягивает новые версии из git’а. И может локально тоже собрать: ты можешь написать такой brew-пакет, который будет LLVM чекаутить тебе на машину, собирать CMake и потом в качестве бинаря предоставлять. То есть ты не скачиваешь бинарь, как у нас в Java принято, откуда-то с репозитория, а собираешь его локально и получаешь конечный продукт.
[1:44:31] Максим: Да.
[1:44:37] Максим: Что очень интересно — такую штуку я хотел рассказать, которая меня сильно удивила. В LLVM есть тула, которая называется Alive. Она позволяет делать такую вещь. Представь, у тебя есть какой-то intermediate representation, и ты хочешь предложить оптимизацию. Твои оптимизации могут быть очень сложными: там что-то делишь, что-то умножаешь, какие-то очень сложные вещи. Ты пишешь свой LLVM IR после оптимизации, и эта тула проверяет, что вот эта трансформация валидна. Всегда. Это даже не статический чекер, это скорее как TLA+ — если ты слышал про такую вещь для верификации распределённых систем, — вот это такой же верификатор для LLVM IR. Это очень круто, потому что человек предлагает какую-то оптимизацию — например, простую: мы знаем, что если a поделим на степень двойки, можем просто поменять на shift. Ты в Alive её вставляешь, меняешь на shift, и она скажет: «Всё сходится, делайте». Такая штука используется, и там ещё много всяких интересных штук используется для тестирования. Это к тому, что если в этом проекте разбираться, ты можешь ещё очень много других интересных областей покрыть.
[1:46:00] Александр: Прикольно. То есть эта тула на вход принимает, как я понял, вот это конечное представление, оптимизированное. Просто там кодом написал структуру данных, описал это представление, передал туда. И ещё сам код, который трансформацию делает, или что ещё она принимает?
[1:46:18] Максим: Вот тут немного не так. Смотри: LLVM IR — это ассемблер, да? Этот ассемблер поступает на вход и выглядит просто функцией. Эта функция называется src, она параметризуется какими-то параметрами. И есть target-функция — это то, во что твоя оптимизация её преобразовала после того, как ты её написал. И затем он для всех — условно, как ты попросишь его проверифицировать, для тех типов, для которых тебе нужно, — проверифицирует. Это очень круто.
[1:46:49] Александр: А, то есть это не структура, это функция, и он все возможные варианты применения этой функции даёт на вход, а на выходе смотришь, что ни один не ломается ни в каком кейсе. Что твоя оптимизация валидная, то есть валидное преобразование ты делаешь. Я-то думал, что я потом как разработчик такие тесты должен буду написать.
[1:47:06] Максим: В плане тестов да, ты там должен написать ещё кучу тестов, которые проверяют твою трансформацию. Но в целом к pull request обычно крепятся alive-proof — так это называется, — чтобы человек, который смотрит твою оптимизацию, мог открыть этот alive-proof: «Вроде оптимизация верная, теперь посмотрим, какие ты тесты написал». В тестах обычно ещё много всего нужно написать, потому что, например, в LLVM есть векторные типы, есть типы, для которых эти alive-тесты плохо работают. Они хорошо работают, когда типы чисел — например, int8, int16, — там не очень много комбинаций, но когда ты используешь int64, он уже начинает плохо работать, для флотов тоже пока плохо. Но в целом обычно эта оптимизация просто пишется для int8, а дальше оно как бы по индукции на все типы большей размерности работает.
[1:48:00] Александр: Прикольный аналайзер, интересно. Мне кажется, это ещё один подход ко всему набору проверок, которые мы можем делать: юнит-тесты, обычные функциональные тесты, фаззинг-тестирование, property-based testing, — и вот такой аналайзер тоже рядом может стоять.
[1:48:22] Максим: Да. Ещё из хороших тестов, которые есть в ClickHouse, — раз мы затронули тему тестирования: есть тесты, где происходит рандомизация всех настроек. В ClickHouse куча настроек, да и вообще в любой базе данных в какой-то момент становится куча настроек, и знаешь — одну настройку выставил в такое-то значение, другую в такое-то, а как они вместе начнут работать? Десять настроек поменял, может быть, какая-то опасная комбинация. Все ты их не проверишь, потому что пространство получается невероятно огромное, но ты можешь просто их рандомизировать — и постоянно все твои тесты будут запускаться с разными настройками, ещё со всеми санитайзерами, и оно всё будет хорошо тестироваться.
[1:48:48] Александр: Наверное, это больше такой property-based configuration testing: у тебя есть property, свойство, что система работает валидно, а входные данные — это разные вариации, комбинации разных настроек.
[1:49:24] Александр: Знаешь, что бы я хотел напоследок — такую мотивацию закинуть тем, кто дослушал до конца. Во-первых, ребята, вам огромный респект, потому что мы уже больше двух часов разговариваем, и если вы это слушаете, то вы молодцы, у вас всё будет хорошо, и вы будете талантливым, хорошим программистом, я в это верю. Хочу мотивации подкинуть слушателям. Я как-то мотивирую себя заниматься open source, изучать новые языки и в целом за всем этим следить. Не знаю, почему на самом деле, я для себя ещё не открыл рецепта, почему я это делаю. Простой ответ — просто потому что это интересно, но для себя я это, например, нахожу как замену играм. Иногда, знаешь, как бывает: я очень много проектирую архитектуру, просиживаю время в гугл-доках, в согласованиях, изменениях контрактов, протоколов. И когда я начинаю программировать, возвращаться в этот мир Vim-биндингов, в Emacs, — вот я сейчас активно изучаю, — ты этот чёрный экран перед собой открыл, сидишь, просто код пишешь, настроил себе всё, по ходу у тебя перезагружается компилятор, рядом тесты прогоняются, и ты в этот луп входишь, погружаешься. Очень похоже на игру: в игру тоже, когда начинаешь играть, ты прям весь в ней и ни о чём больше не думаешь — как раз это состояние потока, даже книжка такая есть, «Поток», я её, кстати, читал. И из-за этого состояния потока оно как будто бы доставляет некоторое удовольствие. Вот я для себя так оправдываю, почему этим стоит заниматься. Есть ли у тебя, может быть, своё личное мнение, почему стоит заниматься, стоит смотреть в сложные проекты, контрибьютить и вообще развиваться в программировании?
[1:51:08] Максим: Ну, в сложные проекты основная мотивация лично для меня — это то, что ты приносишь супер много пользы. Если ты просто контрибьютишь в какие-то небольшие проектики, то пользу ты тоже, конечно, приносишь, но когда ты контрибьютишь в очень большой, крупный проект, ты приносишь больше пользы, потому что у него больше клиентов. Есть некоторая зависимость размера проекта от его пользы. Понятное дело, LLVM — крупный проект, он очень много пользы приносит. Или, например, языки, как мы сказали, Swift — он повсюду используется, и это действительно очень круто. Когда ты этим занимаешься, приносишь много пользы.
[1:51:49] Максим: Вторая мотивация — это то, что это суперинтересно. Например, алгоритмы, которые в компиляторостроении используются: на работе ты с ними редко будешь сталкиваться, но они позволяют иногда решать сложные проблемы реально простым способом. Это реально пригодится на работе, там можно будет грейды поднимать — это действительно помогает в карьере. Не обязательно даже: ты сидишь, разбираешься в компиляторах, а сам делаешь какие-то сервисы. На самом деле это всё равно может быть каким-то образом связано, потому что ты посмотришь, как какие-то вещи делаются, или идейно очень много всего почерпнёшь. Многие алгоритмы, которые используются в компиляторах, похоже, используются и в базах данных, и в каких-то других сервисах. Например, если мы говорим про формальную верификацию, про которую мы немного поговорили, она используется и в распределённых системах, и в компиляторах. То есть всё в некотором смысле взаимосвязано, и понятное дело, что это всё будет приносить супермного пользы.
[1:52:53] Максим: Ну вот с теми же компиляторами: возможно, алгоритмы, которые ты там используешь, тебе не понадобятся, но ты будешь много копошиться в компиляторах, будешь понимать, какой условно ассемблер получается, будешь понимать, что из такого-то кусочка кода генерится такой-то ассемблер, — то есть если я что-то поменял, то такая-то ерунда произошла в компиляторе, и поэтому он смог так оптимизировать. Это реально очень полезно на работе: ты будешь понимать, как лучше код писать, потому что понимаешь, как внутри компиляторы устроены.
[1:53:24] Александр: Супер, тут ничего не добавишь. Я бы на этом закончил.