Есть такой ритуал у растеров. Открываешь профилировщик, видишь функцию с миллионом вызовов, и рука сама тянется написать #[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)] оправдан только после трёх проверок:

  1. cargo asm показал, что LLVM сам не встраивает

  2. Бенчмарк подтвердил, что принудительное встраивание даёт выигрыш

  3. 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% при первом пополнении.

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