Привет, Хабр!

#[derive(Serialize, Deserialize)] это какая-то одна строка в коде. На холодной сборке за ней прячется двадцать с лишним секунд компиляции, даже если в проекте больше ничего нет. Откройте cargo build --timings на любом не самом маленьком проекте с serde, и serde_derive почти наверняка окажется в первой тройке самых медленных крейтов. При том что в самом serde_derive всего несколько тысяч строк.

Между этой строкой и этими секундами лежит вся инфраструктура процедурных макросов: TokenStream, syn, quote, proc-macro2, watt. Пройдёмся по ней в этой статье.

proc-macros на уровне процессов

Когда вы пишете #[derive(Serialize)], на этапе компиляции происходит примерно следующее.

Сначала rustc обнаруживает, что для разбора вашего крейта нужен proc-macro из serde_derive. Прежде чем продолжить парсить ваш код, компилятору нужно serde_derive собрать. Причём собрать целиком, в исполняемый код, а не ограничиться проверкой типов. Ведь proc-macros это не данные, это код, который rustc запустит в своём процессе.

Дальше rustc загружает скомпилированный serde_derive как динамическую библиотеку (.so на Linux, .dylib на macOS, .dll на Windows) в свой процесс и вызывает его через так называемый мост (bridge). Устроен мост как пара клиент-сервер: proc-macro выступает клиентом, компилятор сервером, а токены между ними передаются через двоичный интерфейс уровня C (C ABI) и целочисленные хэндлы. Зачем такие сложности, станет ясно чуть ниже, когда дойдём до proc-macro2. Пока достаточно знать, что rustc отдаёт макросу токены вашего кода в виде структуры TokenStream.

Внутри serde_derive код достаёт из TokenStream нужное: имя структуры, поля, атрибуты, дженерики. По ним он генерирует реализацию Serialize и Deserialize и возвращает её обратно в rustc, снова в виде TokenStream. Компилятор подставляет полученный код на место #[derive(...)], как будто вы написали impl Serialize for MyType { ... } руками, и продолжает компилировать крейт уже с этой вставкой.

Здесь часто возникает путаница. proc-macro это не какая-то специальная функция компилятора. Это полноценная программа, которую собирают отдельно от вашего проекта и подгружают в процесс rustc. У неё есть всё, что есть у обычного Rust-кода: открыть файл, сходить в сеть, прочитать переменные окружения, упасть с паникой. В этом и сила макросов, и их проблема. К проблеме вернёмся ниже.

TokenStream и зачем появился proc-macro2

Тип proc_macro::TokenStream это «настоящий» интерфейс, который rustc даёт для работы с токенами, и доступен он только внутри proc-macro крейтов. Если вы пишете обычную библиотеку и попробуете импортировать proc_macro::TokenStream, компилятор откажет: тип виден только в крейтах с proc-macro = true.

Сделано это не из вредности. proc_macro::TokenStream по сути обёртка над внутренними структурами rustc, доступ к которым идёт через тот самый мост на C ABI и хэндлах. Внутренности компилятора меняются от версии к версии, и стабильного Rust-интерфейса к ним нет специально: спрятав тип за границей proc-macro крейтов (которые всегда компилируются в контексте конкретной версии rustc), команда оставила себе свободу менять кишки без поломок экосистемы.

Платой за это становится тестируемость. Обычный #[test], который проверял бы, что на такой-то входной TokenStream макрос вернёт такой-то выходной, написать не получится: тестовый код это не proc-macro, и proc_macro::TokenStream там недоступен.

Из этой проблемы и вырос proc-macro2. Это библиотека с собственным типом proc_macro2::TokenStream, совместимым по API с оригиналом, между ними есть бесплатное преобразование через From и Into. Внутри proc-macro proc_macro2::TokenStream просто оборачивает настоящий proc_macro::TokenStream. В обычном окружении, например в тестах, он работает на своей реализации, не завязанной на rustc.

Сложилась стандартная схема. На границе с компилятором макрос принимает и отдаёт proc_macro::TokenStream, а всю внутреннюю работу ведёт в proc_macro2::TokenStream. Так появляется возможность писать тесты, переиспользовать код между разными макросами и не зависеть от того, в каком окружении он выполняется.

proc-macro2 написан тем же человеком, что и большая часть экосистемы процедурных макросов, Дэвидом Толнаем (известным как dtolnay). Заодно он автор почти всей остальной обвязки, которая встретится дальше: syn, quote, watt. И, конечно, за ним стоит serde.

syn: парсер, который весит больше вашего проекта

Теперь самое тяжкое. TokenStream это просто поток токенов: идентификаторы, ключевые слова, скобки, пунктуация, литералы. Чтобы сделать с ними что-то осмысленное, токены нужно превратить в абстрактное синтаксическое дерево (AST): понять, что вот эти токены образуют структуру, эти функцию, а эти выражение определённого типа.

Этим занимается syn. Он разбирает TokenStream в строго типизированные узлы Rust AST: DeriveInput, Item, Expr, Pat, Type и десятки других. Для derive-макроса обычно достаточно DeriveInput, с полями которого вы и работаете.

syn без преувеличения один из самых сложных крейтов в экосистеме. Он парсит весь Rust, каждую конструкцию языка, со всеми нюансами: where-предложения, лайфтаймы, ограничения дженериков, async, const-дженерики. Когда команда rustc меняет язык, изменения приезжают и в syn, параллельно. На круг это около сорока тысяч строк кода в одном крейте.

И здесь кроется главный источник времени компиляции. У syn есть набор feature-флагов, который определяет, какую часть Rust он умеет разбирать:

  • derive: минимум для разбора derive-макросов (parse DeriveInput);

  • full: разбор всего языка, нужен атрибутным и функциональным макросам;

  • parsing: собственно код парсера;

  • printing: обратная конвертация AST в токены (ToTokens);

  • visit / visit-mut: обход дерева;

  • fold: трансформация дерева;

  • clone-impls, extra-traits: стандартные derive для типов самого syn.

По дефолту syn собирается с derive, parsing, printing, clone-impls и proc-macro. Уже это занимает заметную часть холодной сборки. А если включить full, как делают serde_derive и почти все нетривиальные макросы, сверху добавляется ещё ощутимый кусок.

То есть строчка syn = { version = "2", features = ["full"] } в зависимостях это десятки секунд компиляции до того, как соберётся хоть одна строка вашего собственного кода макроса. По оценке самого dtolnay, сложный процедурный макрос вместе со своими зависимостями обходится в двадцать с лишним секунд, и львиная доля этого времени именно syn. Точные цифры сильно зависят от машины и числа ядер, но порядок такой. И платится этот счёт на первой сборке проекта, после cargo clean и при каждой смене версии syn в дереве зависимостей.

Версия тоже важна. До 2023 года стандартом был syn 1.x. В марте 2023 dtolnay выпустил syn 2.0: заметно более быстрый парсер и сильно переработанный API. Перенос экосистемы занял примерно год, многие крупные крейты какое-то время держали обе ветки, разделяя их по фичам. К 2026 году основная масса проектов уже на syn 2.

quote: обратно в токены

Если syn это парсер (токены в AST), то quote его зеркало (AST в токены). Точнее, не вполне AST: quote собирает токены из Rust-подобного синтаксиса, который пишет автор макроса.

Выглядит это так. Внутри макроса вы пишете:

let name = &input.ident;
let expanded = quote! {
    impl Serialize for #name {
        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
            // ...
        }
    }
};

quote! берёт содержимое фигурных скобок и превращает в TokenStream. Переменные с префиксом #, вроде #name, подставляются через трейт ToTokens: так можно вставлять идентификаторы, куски выражений, типы, любые ToTokens-совместимые значения. Есть и повторения через #(...)*, по аналогии с декларативными макросами.

Сам quote, разумеется, тоже процедурный макрос, и для разбора своего ввода ему нужен syn. Поэтому стоит вам взять quote, и в зависимостях оказываются proc-macro2, syn и quote, которые собираются последовательно. Время самого quote невелико, несколько секунд, на фоне syn почти незаметно, но в общую сумму оно тоже идёт.

Почему serde_derive такой тяжёлый

Теперь, когда понятна обвязка (TokenStream в syn, syn в quote, quote обратно в TokenStream), можно говорить про сам serde_derive.

В нём несколько тысяч строк. По меркам крейтов немного: tokio в разы больше, да и сам serde больше. Но компилируется serde_derive дольше tokio. Почему?

Первая причина, зависимости: serde_derive тянет syn с фичей full, потому что обязан корректно работать с любыми структурами, включая хитрые дженерики и where-предложения. Только это уже те самые десятки секунд, и платятся они на каждой холодной сборке вашего проекта.

Вторая, сам код: serde_derive разбирает все мыслимые формы структур и enum, генерирует Visitor для десериализации, обрабатывает гору атрибутов: #[serde(rename = "...")], #[serde(skip)], #[serde(default)], #[serde(with = "...")] и так далее. Каждый атрибут это отдельная кодогенерация, каждый вид типа (struct, кортежная структура, unit-структура, enum с тремя видами вариантов) обрабатывается по-своему.

Третья, и самая обидная: между вашими типами serde_derive не кэшируется. Десять структур с #[derive(Serialize)] в одном крейте это десять запусков serde_derive. Каждый раз он заново разбирает DeriveInput, генерирует TokenStream, отдаёт результат. Идут эти запуски последовательно, потому что макросы внутри крейта не параллелятся, и каждый стоит своих миллисекунд.

Четвёртая, cargo check тут не спасает. Он задуман быстрым, потому что пропускает кодогенерацию. Но макросы пропустить не может: пока serde_derive не выполнен, rustc не знает, какой код встанет на место #[derive(...)], и проверить типы не способен. Поэтому serde_derive собирается и запускается на каждом cargo check, даже без последующего build. Для проекта, где основное время уходит как раз на check (типичный режим во время разработки), это постоянная фоновая боль.

Тут стоит вспомнить историю. В июле 2023 года, в версии 1.0.172, dtolnay добавил в serde_derive предкомпилированный бинарник. Идея очень простая: не пересобирать serde_derive из исходников каждый раз, а положить в крейт уже готовый бинарь, чтобы cargo брал его и весь тридцатисекундный ритуал компиляции исчезал. Бинарник, правда, собрали только под x86_64 Linux, и это сразу аукнулось: сборки под другие платформы и в чужих системах вроде Bazel начали падать.

Реакция сообщества оказалась резкой, и не только из-за платформ. Готовый бинарник в реестре крейтов это потенциальная атака на цепочку поставок: если взломают crates.io или аккаунт самого dtolnay, в каждый проект на serde поедет код, который никто не проверял глазами. Issue serde-rs/serde#2538 завели в конце июля, закрыли в середине августа как wontfix и заблокировали обсуждение. К версии 1.0.184 предкомпилированный бинарник убрали и вернулись к сборке из исходников.

Что с этим делать

cargo check экономит время, пропуская кодогенерацию (генерацию промежуточного представления LLVM, оптимизации, машинный код), и потому на большом проекте идёт в полтора-три раза быстрее cargo build. Но макросы он, как мы видели, пропустить не может, поэтому их стоимость никуда не девается: на холодной сборке это десятки секунд, на тёплой инкрементальной секунды, но «мгновенно» не выходит никогда.

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

sccache. Кэш для rustc, который хранит скомпилированные крейты между проектами: syn 2.x соберётся на машине один раз, дальше будет браться из кэша. Внутри одного проекта инкрементальной сборке он не помогает, зато заметно ускоряет первую сборку нового проекта и сборку после cargo clean. На мак оси работает не идеально и иногда промахивается мимо кэша из-за изменений в окружении.

Cranelift. Альтернативный бэкенд для rustc: вместо LLVM кодоген идёт через cranelift (тот самый, что лежит в основе wasmtime). Код он порождает менее оптимальный, зато компилирует кратно быстрее. Для dev-сборок, где производительность не в приоритете, и для самих макросов, которые исполняются миллисекунды, это ощутимый выигрыш. К середине 2026 года cranelift живёт в nightly, ставится компонентом rustup (rustc-codegen-cranelift-preview) под Linux, macOS и x86_64 Windows, и постепенно стабилизируется.

Разнести макросы и обычный код по разным крейтам. Стандартный приём для больших проектов: вынести процедурные макросы в отдельный крейт-обёртку с минимумом зависимостей и пересобирать его редко. Напрямую для serde не сработает, он не ваш, но для ваших собственных деривов помогает.

Вопрос: нужен ли вам serde вообще?? Для embedded или какого-нибудь cold-start serverless, где время компиляции и размер бинаря критичны, он может оказаться избыточным. Есть минимальные альтернативы: miniserde (тоже dtolnay), nanoserde, bincode без derive. Они беднее по возможностям, но компилируются кратно быстрее.

Watt: предкомпиляция через WebAssembly

Раз уж зашла речь про предкомпиляцию, нельзя обойти watt. Это проект dtolnay, который решает ту же проблему времени компиляции иначе. Вместо готового нативного бинарника он предлагает WASM.

Код макроса заранее собирается в WASM. Вместо того чтобы тащить и компилировать syn с его десятками секунд, конечный пользователь собирает только маленькую обвязку: сам WASM-рантайм (около трёх секунд, общий на все макросы) и тонкий shim-крейт на каждый макрос (доли секунды), который передаёт WASM-байткод в рантайм. Дальше rustc получает обратно TokenStream и подставляет на место derive. По замерам автора это куда дешевле тех самых двадцати с лишним секунд на сложный макрос с зависимостями.

К середине 2026 года watt существует и работает, репозиторий лежит на github.com/dtolnay/watt, но в продакшене его почему-то не используют. Причин несколько.

Первая. Автор макроса должен сам собрать WASM-бинарник и положить его в крейт.

Вторая. Чтобы это стало бесшовным, рантайм хорошо бы встроить в сам rustc, а тут начинается сетевой эффект: компилятор не возьмёт рантайм, пока нет крейтов на watt, а крейты не перейдут на watt, пока в компиляторе нет рантайма.

Третья. Сам serde_derive написан на syn, и его тоже надо собирать в WASM.

Четвёртая, неочевидная. WASM-рантайм в составе rustc это потенциальные сложности для кросс-компиляции и для всех экзотических платформ, где компилятор уже работает.

Короче, watt это «идея, которая может выстрелить через пару лет».

Что изменится в ближайшие пару лет

Несколько направлений, за которыми стоит следить.

Стабилизация cranelift. Пожалуй, самое ожидаемое. К середине 2026 года бэкенд доступен в nightly через -Zcodegen-backend и через профиль [profile.dev] codegen-backend = "cranelift". Полная стабилизация ускорит dev-сборки, в том числе с макросами, заметно, по разным замерам в полтора-два раза.

Кэширование результатов макросов. rustc мог бы кэшировать выход макроса для конкретного входа: не менялся входной TokenStream, значит выходной берётся из кэша. В теории макросы детерминированы, одинаковый вход даёт одинаковый выход, но на практике многие читают окружение или генерируют идентификаторы, поэтому такое кэширование требует явного согласия автора крейта. Обсуждается, не реализовано.

syn 3.0 или крупный рефакторинг. Команда syn периодически возвращается к дальнейшим оптимизациям парсера, но мажорного релиза в планах нет (на середину 2026). Скорее постепенные улучшения внутри ветки 2.x.

Механизм предраскрытия в rustc. Концепция, в которой у макроса несколько фаз: сначала быстрый проход с упрощённым деревом для проверки типов, потом полный для кодогенерации. Это позволило бы cargo check пропускать тяжёлую часть. Обсуждается на уровне RFC, до реализации далеко.

Тренд при этом понятный: и индустрия, и сама команда Rust видят во времени компиляции больную точку, и работа идёт.

Что в итоге

#[derive(Serialize)] это одна строка и десятки секунд компиляции. Между ними обычный путь любого процедурного макроса: rustc грузит скомпилированный макрос как динамическую библиотеку, передаёт ему TokenStream через мост на C ABI, syn разбирает этот TokenStream в AST, макрос работает с деревом как с обычными структурами, quote собирает выходной TokenStream, и результат уходит обратно в компилятор. Основная стоимость не в логике serde_derive, а в его зависимостях: syn с фичей full плюс proc-macro2 и quote. Платится она на каждой холодной сборке проекта с serde.

Готового структурного решения у этой проблемы пока нет. Watt предлагал предкомпилированный WASM и не взлетел из-за экосистемных эффектов. Предкомпилированный бинарник в реестре попробовали в 2023 и откатили из-за рисков цепочки поставок. Cranelift постепенно стабилизируется и ускоряет dev-сборки, но это про сборку вообще, а не про устранение самой стоимости макросов.

Громкие фразы вроде «serde убивает время компиляции» стоит читать спокойно. serde это инструмент, и у инструмента есть цена: примерно полминуты на первой сборке и несколько секунд на каждом cargo check. Если это вам важно, рычаги давления понятны: sccache, минимальные сериализаторы, cranelift, ручные реализации для горячих мест, вынос макросов в отдельный крейт. Если некритично, можно спокойно полистать рилсики, пока собирается.


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться