
Привет, Хабр!
У меня есть для вас очень простой способ испортить себе вечер. Поставьте cargo-llvm-lines, запустите на своём проекте и посмотрите не на первую колонку, а на вторую, которая называется Copies. Сверху почти наверняка будет core::ptr::drop_in_place, дальше что-нибудь из Vec, потом хвост от serde. И напротив каждой строчки будет стоять не единица.
Обычная реакция в этот момент: ну да, дженерики, зато быстро, а одинаковые копии потом схлопнет LLVM. Часть действительно схлопнет. Только не тем механизмом, о котором вы подумали, и точно не тем, который вы включили в Cargo.toml.
В статье узнаем откуда берутся копии, почему Vec<u32> и Vec<i32> в бинарнике сливаются в одну функцию, а Vec<u32> и Vec<u64> не сольются ни при каких настройках, что от этого видно в именах символов, как с той же проблемой воюет сам std и куда делась полиморфизация, которую пять лет ждали как лекарство.
Начнем
Заводим пустой проект и пишем самое скучное, что можно придумать:
fn main() { let mut a: Vec<u32> = Vec::new(); let mut b: Vec<u64> = Vec::new(); a.push(1); b.push(1); println!("{} {}", a[0], b[0]); }
В отладочной сборке ничего не инлайнится, так что все инстанциации видно прямо в LLVM-IR:
cargo rustc -- --emit=llvm-ir grep "^define" target/debug/deps/*.ll | rustfilt | grep -i "push\|grow"
Получим Vec::<u32>::push и Vec::<u64>::push, а рядом RawVec::<u32>::grow_one и его двойник под u64. Две строчки исходника, два независимых куска кода.
Если хочется глянуть на список ещё до LLVM, на nightly есть флаг:
cargo +nightly rustc -- -Zprint-mono-items=lazy 2>&1 | rustfilt | grep push
Именно lazy, а не eager. Жадный режим дополнительно инстанцирует drop glue для каждого типа в крейте, даже если его никто не роняет, и картина выходит страшнее реальности.
Ещё сразу предупрежу про релиз. Там push заинлайнится в main, и в готовом бинарнике отдельных символов вы не найдёте. Копии никуда не делись, просто растворились в вызывающем коде. Осталось понять, откуда компилятор вообще берёт список того, что надо породить.
Компилятор не подставляет типы, он обходит граф
rustc начинает с корней. Корень — это всё, что не обобщено и обязано оказаться в бинарнике: main, функции с #[no_mangle] и #[export_name], экспортируемые символы библиотеки. Дальше сборщик мономорфизаций (живёт в rustc_monomorphize::collector, работает прямо перед кодогенерацией) обходит тела и строит транзитивное замыкание: кто кого зовёт, с какими подстановками, что из вызванного само обобщено и требует новой инстанциации.
Слово «транзитивное» тут главное. Размножается не функция, размножается её конус вызовов:
fn parse_all<T: FromStr>(lines: &[&str]) -> Vec<T> where T::Err: std::fmt::Debug, { let mut out = Vec::new(); for l in lines { out.push(l.parse::<T>().unwrap()); } out }
Вызвали с i32, u64, f32 и IpAddr. Получили четыре копии parse_all, четыре копии Vec::<T>::push, четыре копии RawVec::<T>::grow_one, четыре копии drop_in_place::<Vec<T>>, четыре копии Result::<T, T::Err>::unwrap и четыре копии <T as FromStr>::from_str.
А вот код паники, который дёргает unwrap, размножится всего один раз на всю программу. Загляните в core::result:
#[inline(never)] #[cold] #[track_caller] fn unwrap_failed(msg: &str, error: &dyn fmt::Debug) -> ! { panic!("{msg}: {error:?}") }
&dyn fmt::Debug вместо обобщённого параметра стоит там сознательно. Холодный путь, который выполняется ноль раз за жизнь программы, незачем инстанцировать под каждый тип ошибки. К этому мы ещё вернёмся.
Отсюда мысль, из которой растёт всё остальное: дженерик в Rust ближе к макросу с проверкой типов, чем к дженерику из Java. Это шаблон для генерации кода, а не абстракция над кодом. Ну ладно, копии есть. Вопрос, почему их нельзя просто слить в одну.
Почему u32 и u64 не помирятся
Ответ «типы разного размера» верный, но мелкий.
Vec::<T>::push в MIR работает с T абстрактно: положить значение по адресу ptr + len, увеличить длину. В LLVM-IR такой абстракции нет вообще. Каждый доступ к памяти требует конкретного типа, каждый getelementptr — конкретного шага, каждый memcpy — конкретного числа байт. Запись элемента, если огрубить, превращается вот в это:
; Vec<u32>::push, горячая ветка mov dword ptr [rdi + 4*rsi], edx ; Vec<u64>::push, та же строчка исходника mov qword ptr [rdi + 8*rsi], rdx
Разный размер операнда, разный множитель в адресации. Дальше цепляется остальное: аллокатор просит cap * size_of::<T>() байт с выравниванием align_of::<T>(), проверка переполнения ёмкости считает от другого множителя, реаллокация копирует другое количество байт.
И даже при совпадении размеров копии разъехались бы. Уничтожение Vec<u32> — это один dealloc, а уничтожение Vec<String> — цикл по элементам с деструктором каждого и только потом dealloc. Ниши тоже вмешиваются: Option<u32> хранит дискриминант отдельно, а Option<&u32> использует под None нулевой указатель, и is_some() у них компилируется в разные инструкции. От layout зависит даже способ передачи аргумента, потому что «в регистре, в паре регистров или по указателю на стек» решает ABI. Ну и вырожденные случаи вроде T = (), где половина тела схлопывается в ничто ещё до всякой оптимизации.
Зато есть пары, которые расходятся только на бумаге.
А вот тут копии совпадают буквально
Vec::<u32>::push и Vec::<i32>::push дают идентичный LLVM-IR. У LLVM целые типы беззнаковые: i32 означает «32 бита», а знаковость живёт не в типе, а в инструкции — sdiv против udiv, icmp slt против icmp ult. push ничего не делит и не сравнивает, он пишет четыре байта и увеличивает счётчик, так что код совпадает байт в байт.
Проверяется так:
#[inline(never)] pub fn count_u32(v: &[u32]) -> usize { v.iter().filter(|x| **x != 0).count() } #[inline(never)] pub fn count_i32(v: &[i32]) -> usize { v.iter().filter(|x| **x != 0).count() }
RUSTFLAGS="-Ccodegen-units=1" cargo build --release nm target/release/libdemo.rlib | rustfilt | grep count_
Скорее всего обе строчки окажутся по одному адресу: одна функция стала алиасом другой. Единица кодогенерации тут не для красоты, ниже станет понятно почему.
А вот пара, которая выглядит одинаковой, но одинаковой не оказывается:
use std::alloc::Layout; fn main() { println!("{:?}", Layout::new::<u32>()); // size: 4, align: 4 println!("{:?}", Layout::new::<[u8; 4]>()); // size: 4, align: 1 }
Размер совпал, выравнивание нет. В alloc уедет другой аргумент, у записи в память будет другой атрибут выравнивания, и Vec<u32> с Vec<[u8; 4]> не схлопнутся.
С указателями история поинтереснее. Vec<Box<T>>, Vec<*mut T> и Vec<usize> для push делают одно и то же, кладут восемь байт. Но у Box<T> в сигнатуре висят noalias, nonnull и dereferenceable, у сырого указателя их нет, а у Box вдобавок своя drop glue. Совпадут они или нет, решается уже после всех оптимизаций, и заранее это не угадать.
Отсюда правило, к которому я буду возвращаться: сколько у вас настоящих дубликатов, знает только бинарник. И проще всего расспросить его через имена символов.
Имя символа как чистосердечное признание
Чтобы копии не столкнулись в таблице символов, компилятор кодирует в имя всё, что их различает.
Раньше rustc пользовался схемой, унаследованной от Itanium C++ ABI и годами доращиваемой под задачи, для которых она не проектировалась. Беда у неё была по нашей теме: информация об инстанциации в имени не сохранялась. Функция foo::<Vec<(String, &[u8; 123])>> превращалась в f::foo плюс непрозрачный хеш от внутренностей компилятора. В бэктрейсе вы видели f::foo и гадали, какая из сорока копий упала.
Схема v0 появилась опцией ещё в 1.59, с nightly-2025-11-21 стала умолчанием на nightly, а в 1.97 от 9 июля 2026 доехала до stable. Legacy теперь живёт только на nightly под -Csymbol-mangling-version=legacy -Zunstable-options, и её собираются выпилить совсем.
Имя стало полностью обратимым: никакого хеша, только A-Z, a-z, 0-9 и _. Вот разбор для example::<i32, 1>:
_RINvCsgStHSCytQ6I_7mycrate7examplelKj1_EB2_ │└──────────────┬───────────────┘││││││ │ │ │││││└── конец списка аргументов │ │ ││││└─── значение const-аргумента: 1 │ │ │││└──── тип const-аргумента: usize │ │ ││└───── маркер const-обобщения │ │ │└────── обобщённый тип i32 │ │ └─────── путь к mycrate::example │ └──────────────────────── крейт и его дизамбигуатор └──────────────────────────────────────── маркер обобщённых аргументов
Примитивы кодируются одной буквой: l для i32, m для u32, x для i64, y для u64, j для usize, h для u8, b для bool, e для str, u для (). Составные типы получают префикс: R для ссылки, S для слайса, A для массива, T для кортежа, D для dyn Trait. Идентификаторы идут с длиной впереди, так что push в символе выглядит как 4push.
Разница между двумя нашими инстанциациями сводится к одной букве:
mycrate::example::<u32> → _RINvCs…_7mycrate7example m E… mycrate::example::<u64> → _RINvCs…_7mycrate7example y E…
Дальше идёт хвост, ради которого стоило открывать спецификацию. В v0 есть необязательный элемент instantiating-crate, и в документации прямым текстом написано, зачем он: мономорфизация функции из внешнего крейта может продублироваться, если ту же обобщённую функцию с теми же типами инстанцирует ещё какой-то крейт, и символы надо как-то развести.
Дублирование мономорфизаций между крейтами заложено в формат имени как штатная ситуация. .
Практическая польза от v0 простая: раньше nm по релизному бинарнику давал кашу, теперь по нему можно считать статистику.
nm target/release/app | rustfilt | grep -o "Vec<[a-z0-9:]*>" | sort | uniq -c | sort -rn
Демангл лучше делать через rustfilt, а не через nm -C: системный демангл из binutils рассчитан на C++ и с v0 в зависимости от версии может справиться, а может вывалить вам сырой _RINv….
Имена показывают, что копии есть. Теперь про то, где именно они заводятся.
Две фабрики дубликатов
Первая — единицы кодогенерации внутри одного крейта. rustc пилит крейт на CGU (по умолчанию шестнадцать в релизе), каждая уезжает в LLVM отдельным модулем. Инстанциация кладётся в ту CGU, откуда на неё ссылаются, чтобы LLVM в этом модуле увидел тело и смог его заинлайнить. Ссылки из трёх модулей дают три копии. В отладочной сборке этого почти нет, в релизе это штатное поведение, и оно осознанное: возможность заинлайнить дороже лишних байт.
Вторая — границы крейтов. Каждый крейт, которому понадобился Vec::<u32>::push, генерирует его сам. Ваш крейт, serde_json, regex, ваша внутренняя библиотека — четыре копии одной функции в четырёх rlib. Работает и в отладке, и в релизе.
Если бы каждая мономорфизация инстанцировалась ровно один раз на весь граф крейтов, компилятор не порождал бы примерно четверть всех определений LLVM. Плюс к этому заметная часть уже сгенерированного кода до бинарника не доезжает вовсе: линкер выбрасывает её как ненужную. Собрали, прооптимизировали, разложили по объектным файлам и в мусор.
В C++ ровно эта же проблема решается сама собой.
В C++ повезло больше
Clang и GCC эмитят инстанциации шаблонов слабыми символами в COMDAT-группах. Линкер видит десяток одинаковых имён std::vector<int>::push_back, оставляет одно, остальные выбрасывает. Дедупликация происходит даром, по именам, без всякого анализа тел.
rustc эмитит мономорфизации локальными символами. Локальный символ не участвует в разрешении имён между объектными файлами, линкеру просто нечего сравнивать. А с v0 сверху ложится instantiating-crate, то есть у двух копий из разных крейтов имена разные по построению.
Вторая сторона у этого решения тоже есть, и она про скорость: локальный символ можно интернализовать, а интернализованную функцию LLVM свободно инлайнит и выкидывает после инлайна. Экспортируемую не выкинешь, вдруг её дёрнут снаружи. Rust здесь осознанно платит размером за скорость, просто мало кто в курсе, что вообще платит.
Но кто-то же схлопывает те самые идентичные копии. Просто не линкер.
Кто в итоге схлопывает копии
Механизм, который принято путать с LTO, называется MergeFunctions. Это проход LLVM: он структурно сравнивает функции, находит идентичные, оставляет одну, а остальные превращает в алиасы на неё. Clang его по умолчанию не гоняет, а rustc гоняет, потому что от мономорфизации выигрыш куда больше. На реальном коде это порядка десяти процентов размера итогового артефакта.
Ограничений у него два.
Работает он не на всех уровнях оптимизации: по коду rustc — на -O2, -O3, -Os и -Oz. На нуле и единице дедупликации не будет вообще никакой.
И работает он внутри одного LLVM-модуля, а модуль здесь равен одной CGU. При codegen-units = 16 получается шестнадцать независимых попыток схлопнуть дубликаты, и каждая видит свою шестнадцатую часть кода. Вот почему в примере с count_u32 выше стоял codegen-units=1: иначе две функции могли разъехаться по разным модулям и не встретиться.
Отсюда и роль LTO:
[profile.release] lto = "thin" # модули обмениваются сводками и тянут тела друг у друга codegen-units = 1 # весь крейт одним модулем, MergeFunctions видит его целиком
При lto = true всё сливается в один модуль на всю программу, схлопывание становится глобальным, и платить придётся минутами сборки на проекте в пару сотен тысяч строк.
Сам ThinLTO дедупликацией не занимается. Он импортирует тела для инлайнинга, интернализует то, что перестало быть нужным снаружи, и даёт мёртвому коду умереть. Размер после его включения обычно падает, отсюда и стойкое ощущение, что «LTO схлопнул дубликаты». Схлопнул их MergeFunctions, которому LTO дал модуль пошире, а удаление мёртвого кода — отдельный эффект, к дедупликации отношения не имеющий.
К тому же MergeFunctions экономит размер, но не время сборки. К моменту его запуска весь дублирующийся IR уже сгенерирован, разложен по модулям и прооптимизирован. Схлопывание случается в самом конце и на скорость компиляции не влияет никак.
Третий механизм живёт в линкере и называется ICF, identical code folding. Он складывает идентичные секции независимо от имён символов, то есть умеет то, чего не умеет разрешение имён:
# .cargo/config.toml [target.x86_64-unknown-linux-gnu] rustflags = ["-Clink-args=-Wl,--icf=all"]
Это не самодеятельность: ICF предлагают в самой документации как способ отыграть назад лишний вес, который lld добавляет по сравнению с ld. С 1.90 rust-lld включён по умолчанию на x86_64-unknown-linux-gnu, так что флаг работает из коробки.
После схлопывания две логически разные функции получают один адрес, и сравнение указателей на функции начинает врать:
fn on_click() {} fn on_hover() {} let a: fn() = on_click; let b: fn() = on_hover; assert_ne!(a, b); // после ICF может не выполниться
Не случайно в 1.85 приехал линт unpredictable_function_pointer_comparisons. Если у вас где-то живёт диспетчер, который сравнивает указатели на функции, --icf=all может. Всё перечисленное лечит симптом. Механизм, который лечит причину один.
Единственный механизм, который не даёт копиям родиться
Называется он -Zshare-generics, и идея у него такая: перед тем как инстанцировать мономорфизацию, компилятор лезет в метаданные апстрим-крейтов, вдруг кто-то уже сгенерировал ровно эту. Нашёл — ссылается на готовую. Взамен инстанциации перестают интернализоваться и уезжают в список экспортируемых символов.
Самое интересное здесь то, что это давно не экспериментальный флаг. Логика в rustc такая:
pub fn share_generics(&self) -> bool { match self.unstable_opts.share_generics { Some(setting) => setting, None => match self.optimize { OptLevel::No | OptLevel::Less | OptLevel::Size | OptLevel::SizeMin => true, OptLevel::Default | OptLevel::Aggressive => false, }, } }
То есть ваш cargo build уже собирается с разделением обобщённых инстанциаций, а cargo build --release — нет. По моим ощущениям, это одна из главных причин, почему релизная сборка идёт в разы дольше отладочной, хотя обычно всё сваливают на оптимизации.
Заодно обратите внимание на забавное расхождение. Разделение инстанциаций включено на нуле, единице, s и z. Дедупликация через MergeFunctions включена на двойке, тройке, s и z. Пересекаются они ровно на уровнях по размеру, и это единственный режим, где работают оба механизма сразу. Так что opt-level = "z" даёт вам не одну только сдержанность в инлайнинге.
Бесплатным разделение инстанциаций тоже не выходит. Карта апстрим-мономорфизаций разрастается до сотен тысяч записей, её надо десериализовать, и для крейтов ближе к концу графа сборки это уже ощутимые миллисекунды на ровном месте — при том что попаданий в неё единицы процентов.
Так что универсального рычага нет ни одного.
Как с этим живёт сам std
Смотреть на неё полезно потому, что она мономорфизируется в каждой программе на планете, и цена ошибки там максимальная.
Начнём с RawVec. Летом 2024 в него приехал патч с прекрасным названием «Apply "polymorphization at home" to RawVec», то есть буквально «полиморфизация на дому». Идея — вынести всю логику в функции, которые принимают размер и выравнивание явными параметрами, вместо того чтобы быть обобщёнными по T. Упрощённо вышло так:
// обобщён по T, но логики не содержит pub(crate) struct RawVec<T, A: Allocator = Global> { inner: RawVecInner<A>, _marker: PhantomData<T>, } // содержит всю логику, но про T ничего не знает struct RawVecInner<A: Allocator = Global> { ptr: Unique<u8>, cap: Cap, alloc: A, } impl<T, A: Allocator> RawVec<T, A> { #[inline] fn grow_one(&mut self) { self.inner.grow_one(T::LAYOUT) // единственное место, где нужен T } }
Арифметика роста, проверки переполнения, разговор с аллокатором — одна копия на всю программу вместо копии на каждый T. Мономорфизируется только тонкая обёртка, передающая вниз Layout.
Второй пример интереснее. Как устроен println!? Форматирование обобщено по типам аргументов, значит, весь движок должен размножаться на каждую комбинацию? Нет. format_args! собирает массив стёртых аргументов, каждый из которых — указатель на данные плюс указатель на функцию:
// то, во что примерно раскрывается format_args!("{x} {y}") pub struct Argument<'a> { value: NonNull<()>, // стёртые данные formatter: fn(NonNull<()>, &mut Formatter<'_>) -> Result, // как их печатать }
Мономорфизируется здесь только <T as Display>::fmt, по одной штуке на тип, который вы вообще печатаете. Разбор строки формата, флаги, ширина, точность, выравнивание — одна копия на программу, потому что этот код работает с массивом указателей, а не с обобщёнными типами. Динамическая диспетчеризация в самом часто используемом месте стандартной библиотеки.
Или может вы видели, но не задумывались, зачем это:
// std::fs, слегка упрощённо pub fn read_to_string<P: AsRef<Path>>(path: P) -> io::Result<String> { fn inner(path: &Path) -> io::Result<String> { let mut file = File::open(path)?; let mut string = String::new(); file.read_to_string(&mut string)?; Ok(string) } inner(path.as_ref()) }
Вложенная inner — не стилистический прикольчик. Обобщённая обёртка компилируется в вызов as_ref и переход, то есть в несколько байт на каждый P, а всё тело живёт в бинарнике в единственном экземпляре. Так написана половина std::fs.
И еще вспомним unwrap_failed(&dyn fmt::Debug) из начала статьи. Рядом с ним в исходниках core лежит комментарий, который стоит запомнить: трейт-объект вынесен в отдельную функцию, потому что если сконструировать dyn Debug и тут же выбросить, vtable всё равно останется в бинарнике. Удаление мёртвого кода до неё не добирается.
Что с этим делать у себя
Приём с вложенной функцией переносится один в один. Было:
pub fn load_config<P: AsRef<Path>>(path: P) -> Result<Config, ConfigError> { let path = path.as_ref(); let raw = fs::read_to_string(path)?; let mut cfg = Config::default(); for (n, line) in raw.lines().enumerate() { // ...ещё сотня строк разбора, валидации и понятных ошибок... } Ok(cfg) }
Стало:
pub fn load_config<P: AsRef<Path>>(path: P) -> Result<Config, ConfigError> { load_config_inner(path.as_ref()) } fn load_config_inner(path: &Path) -> Result<Config, ConfigError> { // та же сотня строк, ровно одна копия в бинарнике }
Вы зовёте это с &str, String, PathBuf и &Path. Было четыре копии сотни строк, стало четыре копии из пары инструкций плюс одна копия сотни строк. То же самое работает с impl Into<String>, impl IntoIterator, impl Read. Писать обёртки руками лень — есть крейт momo, который разворачивает такую пару макросом.
Второй приём — стирание типа внутрь, когда обобщённый параметр честно нужен, но только на входе:
pub fn process(events: impl IntoIterator<Item = Event>) -> Stats { process_dyn(&mut events.into_iter()) } fn process_dyn(events: &mut dyn Iterator<Item = Event>) -> Stats { // одна копия тела, косвенный вызов на каждый next() }
Здесь вы платите косвенным вызовом за элемент и теряете инлайнинг итератора. Для холодного пути размен выгодный, для цикла на миллион итераций нет, и это ровно тот случай, когда обе версии надо померить, а не обсудить.
С Vec<Box<dyn Trait>> обычно смешивают две разные вещи. Гетерогенная коллекция — это про то, чего дженерик не умеет в принципе: Vec<T> хранит ровно один тип. А экономия на размере кода живёт не в самой коллекции, а в функциях, которые по ней ходят:
// N полных копий тела, по копии на каждый T fn render_all<T: Widget>(items: &[T]) { for it in items { it.render(); } } // одно тело плюс vtable на каждый тип, попавший в вектор fn render_all(items: &[Box<dyn Widget>]) { for it in items { it.render(); } }
Vtable весит несколько машинных слов, тело на пару сотен строк после мономорфизации весит килобайты. На двух типах менять нечего, на пятнадцати разница видна в cargo bloat. Только цену надо считать целиком: косвенный вызов отрезает инлайнинг с автовекторизацией, каждый элемент такого вектора лежит в своей аллокации, а итерация прыгает по памяти вместо того, чтобы идти по кеш-линиям.
Отдельно про serde, которого в этом месте принято приводить примером, причём приводить неправильно. serde_json::from_str::<T>() действительно мономорфизируется на каждый T и тащит за собой всю цепочку обобщённых вызовов, так что на проекте с полусотней структур он уверенно живёт в верхушке cargo llvm-lines. Вот только заменить его на dyn нельзя: T здесь результат, а чтобы вернуть значение, компилятору нужен его размер. Стирать надо другую половину. erased-serde даёт объектно-безопасные версии Serialize, Serializer и Deserializer, и это убирает не количество типов, а их произведение на количество форматов: было по копии на каждую пару (тип, формат), стало по копии на тип плюс по копии на формат. Если одни и те же структуры ездят у вас в JSON, CBOR и MessagePack, разница заметная. Если формат ровно один, выигрыша не будет, и остаётся сокращать число типов, через которые вы гоняете производные реализации.
Когда множество типов закрыто, есть третий путь: enum плюс match даёт гетерогенность без vtable, одну копию кода и прямые переходы, которые процессор нормально предсказывает.
И совсем отдельно — константные обобщения, которые в отчётах почему-то никто не ищет:
// копия тела на каждое значение N fn hash_block<const N: usize>(block: [u8; N]) -> u64 { /* ... */ } // одна копия, длина в рантайме fn hash_block(block: &[u8]) -> u64 { /* ... */ }
Буферы на 16, 32, 64 и 256 байт дают четыре копии тела ровно с тем же эффектом, что четыре разных типа.
Всё это приходится делать руками. А ведь была попытка научить тому же самому компилятор.
Полиморфизация: пять лет надежд и одна кнопка Delete
Идея выросла из наблюдения, которое напрашивается само: далеко не всё тело обобщённой функции зависит от обобщённых параметров. Если проанализировать MIR и увидеть, что T в теле по-настоящему не используется, копию под каждый T делать незачем.
Самый жирный случай тут даже не функции, а замыкания, потому что замыкание наследует все обобщённые параметры родителя, даже если в глаза их не видело:
fn process<T: Debug>(items: &[T]) { let log = |stage: &str| eprintln!("[worker] {stage}"); log("start"); for it in items { println!("{it:?}"); } log("done"); }
log про T не знает ничего, но живёт внутри обобщённой функции. Двадцать типов на входе дают двадцать одинаковых копий log. Часть из них потом схлопнет MergeFunctions, но сгенерировать, разложить по модулям и прооптимизировать компилятор их всё равно обязан, а это время сборки, которое никто не вернёт. Полиморфизация должна была убирать их до кодогенерации.
Работало это через запрос unused_generic_params, который для каждого элемента возвращал битовое множество неиспользуемых параметров и кодировался в метаданные крейта. Неиспользуемые параметры заменялись плейсхолдером, инстанциации схлопывались в одну. След остался прямо в грамматике v0: там есть плейсхолдер p для обобщённых аргументов, которые в мономорфизации не участвуют.
Флаг -Zpolymorphize прожил на nightly с 2020 года и был удалён в 1.85 от 20 февраля 2025. Причин набралось несколько.
Анализ был консервативным и ловил мало. Он ломал важный инвариант: после мономорфизации всё должно быть мономорфно и оставаться таким, а полиморфизация оставляла часть тел частично обобщёнными, из-за чего нельзя было спокойно опираться на «здесь уже всё конкретно» при разработке оптимизаций MIR. Запрос unused_generic_params вызывался всегда и кодировался в метаданные независимо от того, включён флаг или нет, то есть за фичу платили все, а пользовался ей никто. Шлейф падений компилятора тянулся годами. А добил его тот факт, что публичных пользователей флага не нашлось вообще ни одного.
Идею при этом никто не объявлял плохой, просто решили, что новую реализацию имеет смысл писать с нуля. Но статус на сегодня такой: трекер закрыт как not planned, никто за него не взялся, и просьбы вернуть полиморфизацию упираются в то же самое.
Заодно разведу две вещи, которые в таких просьбах путают примерно все. Полиморфизация убирала копии, различающиеся неиспользуемым параметром, и это чистый выигрыш: рантайм от неё не страдает никак. А «пусть компилятор сам заменит дженерики на динамическую диспетчеризацию, где сможет» — совсем другая задача, от которой размер кода может и вырасти, потому что оптимизатор перестаёт видеть тела. Просят обычно второе, а вспоминают при этом первое.
Почему даже первое сделать непросто, тоже понятно:
fn looks_polymorphic<T>() -> usize { std::mem::size_of::<T>() // T формально не в теле, фактически он и есть тело } fn also_looks_polymorphic<T>(x: T) { // ни одного упоминания T ниже, но на выходе из функции // компилятор обязан уронить x, а drop glue у каждого типа своя }
Плюс TypeId::of::<T>() и type_name::<T>(), плюс зависимость соглашения о вызове от layout, плюс вопрос, на который нет хорошего ответа: если одна физическая функция обслуживает двадцать типов, что писать в её символе и что показывать в бэктрейсе. Схема v0 как раз про то, чтобы в бэктрейсе была видна конкретная инстанциация, и полиморфизация с этим желанием немного спорит.
Взамен сейчас чинят другое.
Что делают вместо неё
Идея отложить кодогенерацию до момента, когда известно, что реально понадобится, жила под названием MIR-only rlib и заглохла: после раскрытия макросов промежуточное представление уже слишком привязано к целевой платформе, и пользы выходило меньше, чем сложности.
Зато поехал -Zhint-mostly-unused. Флаг говорит компилятору, что из крейта понадобится малая часть, и переводит его функции в режим отложенной кодогенерации, тем же механизмом, что и кросс-крейтовый инлайнинг. Целевая аудитория — гигантские API-крейты вроде windows, rustix и aws-sdk-*, из которых вы используете три функции. На такой зависимости сборка вполне может ускориться вдвое.
Пока это nightly и включается через profile-rustflags:
cargo-features = ["profile-rustflags"] [profile.release.package.aws-sdk-s3] rustflags = ["-Zhint-mostly-unused"]
Что бы вы ни включали из перечисленного, дальше нужны замеры.
Инструменты, чтобы не гадать
cargo install cargo-llvm-lines cargo-bloat cargo llvm-lines --release | head -30
Колонка Lines показывает объём порождённого LLVM-IR, колонка Copies — число инстанциаций. Вторая интереснее первой: она прямо тычет пальцем в функцию, которая размножилась сильнее всех. drop_in_place на первом месте — норма жизни почти в любом проекте.
cargo bloat --release --crates # кто из зависимостей занимает секцию кода cargo bloat --release -n 30 # конкретные функции
На nightly есть ещё -Zdump-mono-stats: он кладёт рядом файл с таблицей мономорфизаций, сгруппированных по определению, с оценкой размера каждой. Для охоты за временем сборки удобнее, чем -Zprint-mono-items, потому что сразу агрегирует.
Дальше уже в сам бинарник:
nm --size-sort -S target/release/app | rustfilt | tail -40 cargo rustc --release -- --emit=llvm-ir grep -c "^define" target/release/deps/*.ll
Работать с этим стоит по одному изменению за раз: замерили, вынесли тело в необобщённую функцию, замерили снова. Иначе непонятно, что сработало, а что просто совпало с обновлением зависимости.
Как теперь смотреть на свой бинарник
Копии заводятся в двух местах: между CGU внутри крейта (в релизе) и между крейтами (всегда). Идентичные схлопнет MergeFunctions, но только внутри одного модуля LLVM и только от -O2. Всё, что различается по существу, не схлопнется никогда: Vec<u32> и Vec<u64> — это два куска машинного кода, а не два имени одного.
LTO помогает не тем, чем кажется. Он даёт дедупликации модуль пошире и убивает мёртвый код, но сам дубликаты не ищет. А единственный механизм, который не даёт копиям родиться, включён у вас в отладочной сборке и выключен в релизной.
Отдельно обидно, что C++ обходит нас тут на ровном месте: его линкер схлопывает одинаковые шаблоны даром, по именам, просто потому что они weak-символы. Кажется, это первое, чему мне у него захотелось позавидовать. И, надеюсь, последнее.
Вывод скучный: обобщённой должна быть граница API, а не тело функции. Тонкая обёртка снаружи, одна конкретная реализация внутри. Стандартная библиотека пришла к этому руками — в RawVec, в fmt, в fs и даже в холодном пути unwrap. Работает независимо от версии компилятора, флагов линкера и настроения LLVM.
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.


