
Есть такой ритуал у растеров. Открываешь профилировщик, видишь функцию с миллионом вызовов, и рука сама тянется написать #[inline(always)]. Ну а что, название же говорит само за себя, правда? Встрой тело в место вызова.
А потом бинарник толстеет, сборка ползёт, и бенчмарк показывает ровно ту же цифру. Или хуже. И ты сидишь, смотришь на это и думаешь — а что я не так понял?
Рассмотрим, что не так.
Два механизма с одним именем
Вся путаница начинается с того, что слово инлайнинг в Rust означает две абсолютно разные вещи. Мы используем одно слово, а в голове смешиваем два процесса, которые происходят на разных этапах компиляции, разными инструментами, по разным правилам.
Есть инлайнинг-оптимизация. Это когда LLVM — не rustc, не ваш атрибут, а именно LLVM-бэкенд — смотрит на конкретный call и думает:
«А не заменить ли мне этот вызов телом функции?»
У него для этого целая модель стоимости — InlineCost. Он прикидывает, сколько весит тело после упрощений, и сравнивает с порогом. При -O2 порог — 225 условных единиц. Легче — встраиваем. Тяжелее — оставляем вызов. LLVM принимает это решение сам, для каждого call-сайта отдельно, с учётом контекста.
А есть инлайнинг-видимость. И вот это — то, чем управляет #[inline].
Штука в том, что rustc не компилирует крейт целиком как один кусок. Он разбивает его на codegen units — CGU, независимые блоки, которые летят в LLVM параллельно. По умолчанию их шестнадцать. Каждый CGU — отдельный LLVM-модуль. LLVM оптимизирует каждый модуль в изоляции. Он не знает, что лежит в соседнем.
Функция foo живёт в CGU-3. Вызов foo() случился в CGU-7. LLVM в CGU-7 открывает рот, чтобы встроить foo, а тела нет. Он видит declare foo — голое объявление. Всё, встраивать нечего. Даже если foo — одна строчка.
#[inline] это чинит. Он говорит компилятору: «Возьми MIR этой функции и скопируй во все CGU, где она нужна». MIR — это промежуточное представление Rust, ступень перед LLVM-IR. Компилятор дублирует его, каждый CGU опускает свою копию до LLVM-IR — и вот теперь LLVM видит тело. Может встроить. А может и не встроить — это уже его решение, не ваше.
Чувствуете? #[inline] — это «покажи тело тем, кто захочет встроить». Разница — как между тем, чтобы открыть дверь, и тем, чтобы заставить кого-то войти.
Конвейер: где что срабатывает
Чтобы не запутаться окончательно, давайте один раз посмотрим на путь кода от исходника до бинарника.
Исходный код → HIR → MIR ← #[inline] копирует тело здесь → распределение по CGU → LLVM-IR (по модулю на CGU) → оптимизации LLVM ← решение о встраивании здесь → машинный код → линковка
Между точкой, где #[inline] делает своё дело, и точкой, где LLVM решает встраивать — два полных шага конвейера. Атрибут раскладывает MIR по нужным юнитам. LLVM потом отдельно, глядя на свою модель стоимости, решает, что с этим делать.
С кросс-крейтовым инлайнингом то же самое, только MIR путешествует через метаданные. rustc сериализует тело помеченной функции в .rmeta-файл. Зависимый крейт десериализует, вставляет в свои CGU — и дальше по конвейеру. Без #[inline] в метаданных лежит одна сигнатура. Тела нет. LLVM на той стороне видит declare — и всё.
Кстати, есть простой способ пощупать это руками. Соберите крейт с RUSTFLAGS="-C codegen-units=1". Весь код — один CGU, один LLVM-модуль. В этом режиме #[inline] на приватных функциях ни на что не влияет: всё и так видно. Переключитесь обратно на 16 — и поведение поменяется.
Почему дженерики чаще всего не нуждаются в #[inline]
Открываешь чужой крейт — и видишь #[inline] на обобщённой функции. Ну, думаешь, опытные ребята, знают что делают.
Обобщённая функция мономорфизируется в месте вызова. Вы написали foo(42u32) для fn foo<T: Display>(x: T) — компилятор породил конкретную foo::<u32> прямо в вашем CGU. Тело уже здесь. Не потому что #[inline] что-то копировал, а потому что мономорфизация по определению создаёт специализированную функцию там, где она используется. MIR дженериков всегда хранится в метаданных крейта — обязан храниться, иначе из другого крейта их не инстанцировать.
Vec::push, Iterator::map, HashMap::insert — обобщённые. Их MIR лежит в метаданных core/alloc/std. Код получает конкретные версии для конкретных типов. Никакой #[inline] для этого не нужен.
Если видите #[inline] на дженерике — автор перестраховывался. Формально атрибут не совсем пустой: он добавляет inlinehint к LLVM-IR, чуть-чуть сдвигая эвристики.
Другое дело — #[inline(always)] на дженерике. Это может работать, если вы хотите заставить LLVM встроить конкретную мономорфизированную версию.
Граница крейта: вот здесь #[inline] реально нужен
Окей, дженерики сами разбираются. Приватные функции LLVM обычно тоже видит. Так зачем вообще этот атрибут существует?
Ради обычных, не обобщённых, публичных функций, которые вызываются из чужого крейта.
Классика:
// crate: my_lib pub fn is_valid(x: u32) -> bool { x > 0 && x < 1000 }
// crate: app fn process(values: &[u32]) -> usize { values.iter().filter(|&&v| my_lib::is_valid(v)).count() }
is_valid — два сравнения. Три инструкции на x86. Но без #[inline] на каждой итерации цикла будет честный call: сохранить регистры, прыгнуть по адресу, вернуться, восстановить. На миллионе элементов это заметно.
Но ещё хуже другое. call — чёрный ящик для LLVM. Он не может заглянуть внутрь вызова, а значит, не может автовекторизировать цикл. Оптимизатор видит «тут вызывается какая-то функция, я не знаю что она делает» — и пасует. Добавляете #[inline], тело прилетает через метаданные, LLVM подставляет два сравнения прямо в цикл, видит картину целиком, и может применить SIMD.
Одно слово. Разница — автовекторизация или call в цикле.
Загляните в core::option. Option::is_some, Option::map, Option::unwrap_or — все с #[inline]. Без атрибута каждый ваш if let Some(x) = ... превращался бы в вызов через границу крейта. Авторы стандартной библиотеки знают, что делают.
Проверить можно так:
cargo asm app::process # cargo-show-asm
Или грубее:
cargo rustc --release -- --emit=llvm-ir grep "define\|declare\|call.*is_valid" target/release/deps/*.ll
define с телом — встроилось. declare — нет.
#[inline(always)]: почему усердие наказывает
А теперь почему #[inline(always)] делает хуже.
Этот атрибут транслируется в LLVM как alwaysinline. LLVM обязан встроить функцию в каждое место вызова, игнорируя модель стоимости. Ему всё равно, что тело тяжёлое, что вызовов двадцать, что регистров не хватает. Приказ есть приказ. Единственное исключение — прямая рекурсия; бесконечно разворачивать он откажется.
И тут функция, которая на Rust выглядит короткой, в LLVM-IR может быть огромной:
#[inline(always)] pub fn lookup(map: &HashMap<String, Vec<u32>>, key: &str) -> Option<&[u32]> { map.get(key).map(|v| v.as_slice()) }
Две строчки. Но HashMap::get после мономорфизации — это хеширование, бакеты, сравнение ключей. Option::map добавляет ещё. В LLVM-IR — сотни инструкций. И alwaysinline копирует всё это в каждый call-сайт. Двадцать вызовов — двадцать копий.
Дальше начинается каскад.
Кеш инструкций. Секция .text растёт. Горячий цикл, который раньше умещался в L1i (32–64 КБ на x86-64), вываливается за границу. Процессор промахивается. В perf stat это видно как скачок L1-icache-load-misses — базированный симптом раздутого кода.
Регистры. Встроенный код живёт внутри вызывающей функции — и конкурирует с ней за регистры. Когда регистров не хватает, LLVM начинает сбрасывать значения на стек (spill) и потом читать обратно (reload). Бывает так, что один честный call — с аккуратным сохранением callee-saved регистров — дешевле, чем россыпь spill по телу встроенной функции. LLVM умеет это взвешивать. Но alwaysinline ему рот затыкает.
Компиляция. Больше IR в модуле — больше работы для каждого прохода оптимизации. GVN, SROA, instcombine — все проходят по раздутому телу. На проекте в пару сотен тысяч строк я видел, как снятие #[inline(always)] с нескольких обёрток ускоряло release-сборку на 8–12 секунд. Производительность бинарника при этом не менялась — LLVM и без принуждения встраивал большинство из них, потому что они были легче порога. Остальные были тяжелее — и правильно делал, что не встраивал.
Правило простое. #[inline(always)] оправдан только после трёх проверок:
cargo asmпоказал, что LLVM сам не встраиваетБенчмарк подтвердил, что принудительное встраивание даёт выигрыш
perf statне показал рост промахов по кешу инструкций
Пропустили хотя бы один пункт — не ставьте. С
#[inline(never)]: недооценённая штука
А вот обратный атрибут почему-то никто не любит. Зря. #[inline(never)] транслируется в noinline и запрещает встраивание. Я всё чаще ловлю себя на том, что ставлю его чаще, чем #[inline].
Первое — холодные пути. Функции, которые срабатывают при ошибках, при панике, при логировании — то, что в нормальном потоке выполняется редко или никогда. LLVM может решить их встроить: тело маленькое, порог не превышен, почему бы и нет. А результат — код обработки ошибки торчит посреди горячего цикла и раздувает его.
fn hot_loop(data: &[u32]) -> u32 { let mut sum = 0; for &x in data { if x == 0 { report_error(x); } sum += x; } sum } #[inline(never)] #[cold] fn report_error(val: u32) { eprintln!("unexpected zero: {val}"); }
Тут #[inline(never)] и #[cold] работают в паре. #[cold] подсказывает LLVM, что ветку с этим выз��вом стоит считать маловероятной — это влияет на layout базовых блоков и предсказание переходов. #[inline(never)] гарантирует, что тело не скопируется в горячий код. Стандартная библиотека использует ровно эту связку: std::panicking::begin_panic помечена обоими атрибутами.
Второе — время компиляции. Каждое встраивание увеличивает объём работы. Расставив #[inline(never)] на вспомогательных функциях, вы снимаете с LLVM даже необходимость думать о них. Если функция и так тяжелее порога — потери производительности нет, а сборка быстрее.
Третье — профилирование. Встроенная функция исчезает из стека вызовов. В perf, в samply, во flamegraph — она растворяется в вызывающей функции. DWARF-информация может содержать inline-фреймы, но не все инструменты их показывают, и разбирать такой профиль то ещё развлечение.
LTO: когда границы исчезают (почти)
Всё, что я рассказал выше, работает при дефолтных настройках сборки. Но есть штука, которая меняет расклад — LTO. Она убирает границы между CGU и крейтами, и LLVM начинает видеть больше кода. Но LTO — не одна кнопка, а три режима, и ведут они себя по-разному.
lto = false — дефолт. Каждый CGU сам по себе. Кросс-крейтовый инлайнинг только через #[inline] и MIR в метаданных.
lto = "thin" — компромисс, и обычно лучший выбор. На этапе линковки LLVM строит суммарный граф вызовов, и каждый модуль может подтянуть тела из соседних. Компиляция всё ещё параллельная. ThinLTO покрывает большинство случаев, где помог бы #[inline] для внутрикрейтового инлайнинга. Для кросс-крейтового не всегда: у ThinLTO свои пороги импорта, и функцию из чужого крейта он может не подтянуть, решив, что овчинка выделки не стоит.
lto = true — полный LTO. Все модули в один. Оптимизация в один поток. LLVM видит вообще всё. В теории #[inline] не нужен. На практике сборка проекта на 100-200 тыс. строк может занять минуты.
Сюда же — codegen-units. При codegen-units = 1 весь крейт — один CGU. Внутрикрейтовый #[inline] бессмыслен: всё в одном модуле.
Типичная конфигурация для финального релиза:
[profile.release] lto = "thin" codegen-units = 1
При такой связке внутрикрейтовый #[inline] избыточен. Но #[inline] на публичных функциях библиотечных крейтов по-прежнему нужен. Вы не знаете, что стоит в Cargo.toml у того, кто подключил вашу библиотеку. Может, у него lto = false и 16 CGU. Ваш #[inline] — единственный шанс для его LLVM увидеть тело.
Как перестать гадать
Я перечислил кучу нюансов, но сводится всё к одному: не гадайте — проверяйте. Вот минимальный набор инструментов.
Встроил ли LLVM функцию?
cargo asm my_crate::my_function
Или через IR:
cargo rustc --release -- --emit=llvm-ir grep "define\|declare\|call.*my_function" target/release/deps/*.ll
Стало ли быстрее?
cargo bench # criterion / divan
Замеряете с атрибутом и без. Не наоборот — сначала гипотеза, потом подтверждение.
Не раздулся ли кеш?
perf stat -e L1-icache-load-misses,instructions ./target/release/my_app
Кто занимает место в бинарнике?
cargo bloat --release -n 20
Мономорфизированные копии с #[inline(always)] — частые гости.
Самый продуктивный инлайнинг, который я видел — не добавление атрибутов, а их удаление.
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

