Привет!
В стандартной библиотеке лежит макрос pin!. Стабильный с версии 1.68, живёт в core, зовёт его каждый, кто хоть раз закреплял футуру на стеке. Значащая для нашего разговора строчка внутри у него одна, и повторить её у себя не выйдет: конструкции, на которой она держится, в языке официально нет.
Случай не единственный, второй лежит вообще на виду. Откройте объявление Vec и посмотрите внимательно: там не Vec<T>, а Vec<T, A = Global>, два параметра. Попробуйте назвать второй своими руками, и на пять строк кода прилетят три ошибки, потому что Global нестабилен.
Прикол в том, что на фичах, которых в стабильном Rust как бы нет, держится куча всего, чем мы пользуемся каждый день. В первой части так вышло с gen-блоками, pattern types, специализацией, become и #[loop_match]. Сегодня разберёмся, зачем pin! выписал себе особый пропуск. Почему второй параметр Vec сидит под замком дольше всех остальных. Как компилятор посчитает вашу корзину до копейки ещё до запуска программы. Что лежит внутри толстого указателя, если раскрутить его на запчасти. И почему SIMD руками обгоняет автовекторизацию только до того момента, пока вы не разрешите компилятору лишнего.
Строчка, которую вам не дадут написать
Вот она целиком, кусок из core/src/pin.rs в исходниках stable (служебные атрибуты и комментарии и прочее убрал):
#[stable(feature = "pin_macro", since = "1.68.0")] #[allow_internal_unstable(pin_macro_internals, super_let)] pub macro pin($value:expr $(,)?) { 'p: { super let mut pinned = $value; break 'p unsafe { $crate::pin::Pin::new_unchecked(&mut pinned) }; ... } }
Атрибут allow_internal_unstable — это пропуск на служебный вход: библиотека разрешает себе нестабильное внутри стабильного макроса. Вашему крейту такой пропуск никто не выпишет.
Зачем это вообще pin!?
Затем, что макрос должен создать значение, закрепить его и отдать наружу ссылку. Причём именно на стеке: весь смысл в том, чтобы не ходить за памятью в кучу, как Box::pin. Значит, значение должно пережить сам макрос, а обычная переменная умирает на закрывающей скобке блока, и ссылка вместе с ней.
Проблема конечно, прям древняя, на неё натыкался каждый, кто писал макрос сложнее однострочника. Вот она:
macro_rules! fmt_hex { ($v:expr) => {{ let s = format!("{:x}", $v); &s }}; } fn main() { let r: &String = fmt_hex!(255u32); println!("{r}"); }
Компилятор отвечает error[E0597]: s does not live long enough и тычет пальцем: s объявили внутри блока, там же она и кончилась, а ссылку унесли наружу. Выкручиваются обычно так: макрос возвращает владеющее значение, а вызывающий сам кладёт его в переменную.
Муторно, но работает.
super let может решить тоже самое, но уже напрямую. Он отдаёт переменную наверх, тому, кто блок позвал.
#![feature(super_let)] macro_rules! fmt_hex { ($v:expr) => {{ super let s = format!("{:x}", $v); &s }}; } fn main() { let r: &String = fmt_hex!(255u32); println!("{r}"); let two = (fmt_hex!(48879u32), fmt_hex!(255u32)); println!("{} {}", two.0, two.1); }
Печатает ff, потом beef ff. Второй вызов тут интереснее первого, обе строки живут в кортеже одновременно и друг друга не затирают.
Механику проще всего поймать на порядке уничтожения. Заведём тип, который сообщит нам о своей смерти, и сравним два блока:
struct Noisy(&'static str); impl Drop for Noisy { fn drop(&mut self) { println!(" умер {}", self.0); } } // обычный let let _a = { let x = Noisy("внутренний"); println!(" блок кончился"); 1 }; println!(" после блока"); // super let let _b = { super let _x = Noisy("внутренний"); println!(" блок кончился"); 1 }; println!(" после блока");
обычный let: блок кончился умер внутренний после блока super let: блок кончился после блока умер внутренний
В первом случае значение кончается на скобке внутреннего блока, во втором доживает до конца внешнего, также как временное значение в обычном let.
Владение при этом у нас никуда не девается. Вынесем ссылку за пределы функции, и получим привычную error[E0515]: cannot return reference to local variable. Граница сдвигается на уровень наружу, не дальше.
Следы этих правил попадаются чаще, чем кажется. Раньше format_args! нельзя было положить в переменную, ругался borrowck. Сейчас на stable вот такое собирается и печатает 2 x:
let a = format_args!("{} {}", 1 + 1, String::from("x")); println!("{a}");
Трекинг-задача висит открытой, и там всё глуховато. В планах Rust на 2026 год есть отдельный пункт про редизайн, где прямым сказано: прогресс встал из-за нерешённых вопросов, а работает эта штука пока только на внутренние нужды std.
Перейдем к следующему блоку.
У Vec два параметра, второй под замком
Строчка 436 из alloc/src/vec/mod.rs, версия stable:
pub struct Vec<T, #[unstable(feature = "allocator_api", issue = "32838")] A: Allocator = Global> { buf: RawVec<T, A>, len: usize, }
Нестабильный атрибут висит прямо на параметре типа, внутри объявления структуры, которой пользуются вообще все. Не в каком-то отдельном модуле и не под cfg. И Vec тут не одинок, тот же параметр несут Box, Rc, Arc, VecDeque, BinaryHeap, BTreeMap и вся мелочевка вокруг них вроде IntoIter и Drain. Упоминаний A: Allocator в исходниках alloc на stable я насчитал 682.
Попытка выписать умолчание руками кончается так:
use std::alloc::Global; fn main() { let v: Vec<u32, Global> = Vec::new(); println!("{}", v.len()); }
error[E0658]: use of unstable library feature `allocator_api` error[E0658]: use of unstable library feature `allocator_api` error[E0658]: use of unstable library feature `allocator_api` error: aborting due to 3 previous errors
Три ошибки на пять строк из-за одного слова, которое компилятор в эту же сигнатуру подставляет сам, стоит написать просто Vec<u32>.
С включённой фичей мы можем сказать конкретной коллекции, откуда брать память. Трейт Allocator требует всего двух методов, allocate и deallocate. Остальные, grow, shrink, grow_zeroed и allocate_zeroed, идут с реализациями по умолчанию, и переписывать их нужно только ради скорости.
Проще всего тут подсмотреть, как коллекция память берёт. Напишем аллокатор, который считает и передаёт вызовы дальше:
#![feature(allocator_api)] use std::alloc::{AllocError, Allocator, Global, Layout}; use std::ptr::NonNull; use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; static BYTES: AtomicUsize = AtomicUsize::new(0); static CALLS: AtomicUsize = AtomicUsize::new(0); struct Counting; unsafe impl Allocator for Counting { fn allocate(&self, layout: Layout) -> Result<NonNull<[u8]>, AllocError> { BYTES.fetch_add(layout.size(), Relaxed); CALLS.fetch_add(1, Relaxed); Global.allocate(layout) } unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: Layout) { unsafe { Global.deallocate(ptr, layout) } } } fn main() { let mut v: Vec<u32, Counting> = Vec::new_in(Counting); for i in 0..1000 { v.push(i); } println!("вызовов {}, байт {}", CALLS.load(Relaxed), BYTES.load(Relaxed)); println!("len {} cap {}", v.len(), v.capacity()); }
вызовов 9, байт 8176 len 1000 cap 1024
Девять вызовов на тысячу push. Ёмкость шла 4, 8, 16 и дальше до 1024, а 8176 байт — это сумма всех промежуточных буферов, 4 × (4 + 8 + ... + 1024). В живых остался последний на 4096, остальные четыре килобайта прошли транзитом. На stable такое видно только через глобальный аллокатор, т.е сразу по всей программе, а тут мы смотрим на один конкретный вектор и больше ни на что.
Польза покрупнее вылезает там, где мелких коллекций много и живут они пачкой. Классика тут арена: выделили один большой кусок, раздаём из него кусочки сдвигом указателя, отпускаем всё разом. deallocate в таком аллокаторе не делает ничего.
unsafe impl Allocator for &Bump { fn allocate(&self, layout: Layout) -> Result<NonNull<[u8]>, AllocError> { let off = (self.next.get() + layout.align() - 1) & !(layout.align() - 1); let end = off + layout.size(); if end > self.cap { return Err(AllocError); } self.next.set(end); let p = unsafe { NonNull::new_unchecked(self.start.add(off)) }; Ok(NonNull::slice_from_raw_parts(p, layout.size())) } unsafe fn deallocate(&self, _ptr: NonNull<u8>, _layout: Layout) { // арену отпускаем целиком, поштучно тут делать нечего } }
Погоняем четыре миллиона маленьких векторов по восемь u32, половину через Vec::with_capacity, половину через Vec::with_capacity_in(8, &arena). Каждый вектор пропущен через black_box: без него LLVM выкидывает аллокацию целиком, и меряете вы пустоту.
Global 70.6 мс | Bump 21.2 мс Global 71.0 мс | Bump 21.1 мс Global 71.4 мс | Bump 21.1 мс
Четыре миллиона выделений за 70,6 мс — это 17,7 наносекунды на штуку, у арены 5,3. По номинальным 2,8 ГГц это примерно полсотни тактов против полутора десятков ( но все равно реальная частота под нагрузкой гуляет, да и всё зависит от того, насколько фрагментирована куча к моменту замера).
Окей, а на stable так можно?
Отчасти. #[global_allocator] меняет аллокатор сразу всей программе, включая чужие крейты, и выбрать арену для одного конкретного вектора им нельзя. Крейты вроде bumpalo заходят с другой стороны и приносят собственный bumpalo::collections::Vec, несовместимый со стандартным по типу, так что тащить его придётся через все сигнатуры до единой.
Трекинг-задача у этой фичи самая старая из сегодняшней пятёрки: её завели раньше, чем появилось большинство того, о чём мы вообще тут говорим. Сам трейт давно одобрили и написали, Vec параметр получил, и на этом всё замерло: в обсуждении до сих пор спорят о семантике grow и shrink, о нулевых аллокациях и о том, до чего неудобно писать этот трейт руками.
Со следующей фичей вопрос уже другой: когда считать значения.
Компилятор знает слово const impl, а мы нет
Напишем самый скучный код на свете: тип из двух чисел, сложение, константа.
#[derive(Clone, Copy)] struct V2 { x: i32, y: i32 } impl std::ops::Add for V2 { type Output = V2; fn add(self, o: V2) -> V2 { V2 { x: self.x + o.x, y: self.y + o.y } } } const ORIGIN: V2 = V2 { x: 0, y: 0 }; const STEP: V2 = V2 { x: 3, y: 4 }; const SUM: V2 = ORIGIN + STEP;
На stable не собирается, и текст ошибки тут стоит прочитать внимательно:
error[E0015]: cannot call non-const operator in constants | 11 | const SUM: V2 = ORIGIN + STEP; | ^^^^^^^^^^^^^ | note: impl defined here, but it is not `const` | 4 | impl std::ops::Add for V2 { | ^^^^^^^^^^^^^^^^^^^^^^^^^
«Impl defined here, but it is not const». Стабильный компилятор прекрасно знает про константные реализации трейтов, отличает их от обычных и докладывает, какой именно не хватает. Написать такую он нам не даст, хотя себе разрешает давно:
pub const trait Add<Rhs = Self> { ... } pub const trait Index<Idx: ?Sized> { ... } pub const trait IndexMut<Idx: ?Sized>: [const] Index<Idx> { ... } pub const trait RangeBounds<T: ?Sized> { ... }
Это core на stable. Всего там и в alloc набралось больше пятисот константных реализаций и 470 вхождений границы [const]. Отсюда этот перекос: 1 + 2 в константе работает, а ORIGIN + STEP нет, потому что у целых чисел реализация константная, у нашего типа обычная.
На nightly разница снимается одним словом перед impl:
#![feature(const_trait_impl, const_ops)] const impl std::ops::Add for V2 { type Output = V2; fn add(self, o: V2) -> V2 { V2 { x: self.x + o.x, y: self.y + o.y } } }
Фич, кстати, понадобилось две: сначала компилятор ругнётся trait is not stable as const yet со ссылкой на свою задачу в трекере и сам подскажет добавить const_ops. Механизм включают одной фичей, а пускать конкретные трейты из std в константы разрешают отдельно, группа за группой. Запомните это.
Самое любопытное в фиче — квадратные скобки в границе. [const] Trait читается как «может быть константным»:
const fn total<T: [const] Default + [const] std::ops::Add<Output = T> + Copy>(xs: &[T]) -> T { let mut acc = T::default(); let mut i = 0; while i < xs.len() { acc = acc + xs[i]; i += 1; } acc }
Одна функция и два режима. Подставим тип с константными реализациями, и её можно звать в константе. Подставим тип с обычными, и она суперски отработает в рантайме, просто в const её уже не позовёшь. Я все это проверил на структуре Slow, у которой Default и Add объявлены без всякого const: тот же total посчитал u32 на этапе компиляции и Slow в рантайме, дублировать функцию не пришлось.
Ловится и обратная ситуация. Позовём total с типом Slow уже в константе, и компилятор поймает это на месте вызова:
error[E0277]: the trait bound `Slow: const Default` is not satisfied | 15 | const BAD: Slow = total(&[Slow(1), Slow(2)]); | ----- ^^^^^^^^^^^^^^^^^^^ note: required by a bound in `total` help: make the `impl` of trait `Default` `const`
Компилятор проверяет там, где вычисление реально должно случиться на этапе компиляции, и не раньше. Сама total остаётся одна, скрытых копий никто не плодит.
Теперь ради чего всё затевалось. Считаем корзину:
#![feature(const_trait_impl, const_ops, const_default)] #[derive(Clone, Copy)] pub struct Money { pub rub: i64, pub kop: i64 } const impl Default for Money { fn default() -> Money { Money { rub: 0, kop: 0 } } } const impl std::ops::Add for Money { type Output = Money; fn add(self, o: Money) -> Money { let k = self.kop + o.kop; Money { rub: self.rub + o.rub + k / 100, kop: k % 100 } } } pub const PRICES: [Money; 3] = [ Money { rub: 19, kop: 90 }, Money { rub: 5, kop: 50 }, Money { rub: 0, kop: 75 }, ]; pub const CART: Money = total(&PRICES); #[unsafe(no_mangle)] pub fn cart_kop() -> i64 { CART.rub * 100 + CART.kop }
А вот и примеру понадобилась третья фича. const impl Default требует const_default со своей отдельной задачей. Та самая история про «каждую группу трейтов пускают в константы отдельно».
Зато с -C opt-level=3 функция cart_kop выглядит вот так:
cart_kop: mov eax, 2615 ret
В машинном коде лежит одно число: двадцать шесть рублей пятнадцать копеек, которые посчитало наше сложение с переносом копеек в рубли. Так, как если бы мы сами вбили 2615 руками, только опечататься уже негде. Ни цикла, ни массива, ни вызова.
На stable подобные таблицы считают при старте и кладут в OnceLock, генерируют скриптом в build.rs или расписывают литералами вручную, а потом ловят опечатки. В трекере эта история успела один раз переехать: старую задачу закрыли, работу перенесли в новую под заголовком «сделать методы трейтов вызываемыми в константах», и там на сегодня закрыт 1 пункт из 61. Судя по тому, сколько [const] уже расползлось по core, дизайн устаканился, и вопрос скорее в очерёдности: какие трейты объявлять константными и когда.
Следующая фича ничего нового в язык не добавляет. Она просто показывает то, что и так есть.
Давайте разберём толстый указатель
Про dyn Trait я уже писал отдельно: два слова вместо одного, первое на данные, второе на таблицу методов, таблица одна на пару «тип плюс трейт». Тогда всё это было на словах и на ассемблере. С ptr_metadata толстый указатель можно взять и раскрутить руками:
#![feature(ptr_metadata)] use std::ptr; trait Draw { fn draw(&self) -> u32; } struct Circle(u32); impl Draw for Circle { fn draw(&self) -> u32 { self.0 } } struct Square(u32); impl Draw for Square { fn draw(&self) -> u32 { self.0 * 2 } } fn main() { let c = Circle(7); let d: &dyn Draw = &c; let (data, meta) = ptr::from_ref(d).to_raw_parts(); println!("данные {:p}", data); println!("size {} align {}", meta.size_of(), meta.align_of()); }
данные 0x7ffe606b4d5c size 4 align 4
Размер и выравнивание он вытащил из таблицы методов прямо в рантайме: те самые служебные поля, из-за которых Box<dyn Trait> умеет освобождать память, не зная конкретного типа. Поставим вместо Circle структуру из трёх u64, и та же строчка напечатает size 24 align 8.
Слова про «одну таблицу на пару типа и трейта» теперь можно проверить в пару строк:
let (_, meta2) = ptr::from_ref(d2).to_raw_parts(); // другой Circle let (_, meta3) = ptr::from_ref(d3).to_raw_parts(); // Square println!("Circle==Circle: {}", meta == meta2); println!("Circle==Square: {}", meta == meta3);
Circle==Circle: true Circle==Square: false
Два разных экземпляра Circle дают буквально одинаковые метаданные, Square другие. Никакого скрытого поля внутри объекта, как в C++: указатель на таблицу живёт в ссылке.
Полагаться на это равенство, правда, нельзя.
Одна и та же пара типа и трейта может получить разные таблицы, если код разъехался по разным единицам кодогенерации. А две одинаковые по содержимому таблицы разных типов компилятор вправе схлопнуть в одну.
Обратная операция тоже есть. Берём данные от одного значения, метаданные от другого и собираем трейт-объект вручную:
let other = Circle(100); let rebuilt: &dyn Draw = unsafe { &*ptr::from_raw_parts::<dyn Draw>(ptr::from_ref(&other).cast::<()>(), meta) }; println!("собран вручную: {}", rebuilt.draw());
Печатает собран вручную: 100. Метаданные тут от совсем другого Circle, но раз тип тот же, таблица подходит и вызов работает. Подсуньте сюда метаданные от Square, и четыре байта начнут читать как что-то другое, так что unsafe стоит не для красоты.
Еще порадовал трейт Pointee, через которые все это описано:
#[lang = "pointee_trait"] #[rustc_deny_explicit_impl] pub trait Pointee: PointeeSized { type Metadata: fmt::Debug + Copy + Send + Sync + Ord + Hash + Unpin + Freeze; }
Реализовать его нельзя, а размер ассоциированного типа сам по себе рассказывает про указатель всё:
&dyn Draw 16 DynMetadata<dyn Draw> 8 метаданные [u8] 8 метаданные str 8 метаданные u32 0
У обычного u32 метаданные занимают ноль байт, потому что это (). Тонкий указатель и толстый различаются в системе типов ровно тем, пустой у них довесок или нет.
Трекинг-задача открыта. Пользы немного: свои умные указатели, низкоуровневая сериализация, самописные схемы хранения объектов.
От разглядывания указателей вернёмся к тому, что меряется секундомером.
Портируемый SIMD: один код под любой процессор
Возьмём максимально простую задачу — подсчитать пробелы в тексте. Скалярный вариант умещается в одну строку:
pub fn count_spaces_scalar(s: &[u8]) -> usize { s.iter().filter(|&&b| b == b' ').count() }
Вариант на std::simd длиннее, но тоже не представляет ничего сложного:
#![feature(portable_simd)] use std::simd::prelude::*; pub fn count_spaces_simd(s: &[u8]) -> usize { let (head, mid, tail) = s.as_simd::<32>(); let target = Simd::splat(b' '); let mut n = head.iter().filter(|&&b| b == b' ').count(); for chunk in mid { n += chunk.simd_eq(target).to_bitmask().count_ones() as usize; } n + tail.iter().filter(|&&b| b == b' ').count() }
Метод as_simd делит срез на три куска: невыровненный префикс, выровненную середину из 32-байтовых векторов и хвост. Середина обрабатывается целыми векторами: simd_eq сравнивает сразу все 32 байта и возвращает маску, to_bitmask превращает её в число, а count_ones подсчитывает выставленные биты. Края добираются скалярным циклом — в сумме это меньше 64 байт.
Тест — 256 МБ случайного текста, три запуска:
scalar 48810488 169.1 мс | simd 48810488 45.1 мс scalar 48810488 169.3 мс | simd 48810488 44.3 мс scalar 48810488 168.7 мс | simd 48810488 44.0 мс
В пересчёте на пропускную способность это 1,6 ГБ/с против 6,1 ГБ/с. При номинальной частоте 2,8 ГГц скалярный код расходует около 1,8 такта на байт, векторный — меньше половины такта. Разрыв почти четырёхкратный, результаты совпадают до единого пробела.
Красиво, но я едва не сделал из этого неверный вывод, и от ошибки меня удержала только перепроверка с другими флагами сборки.
Соберём тот же код с -C target-cpu=native, разрешив LLVM задействовать все возможности конкретного процессора (в моём контейнере есть и AVX2, и AVX-512):
scalar 48810488 46.0 мс | simd 48810488 32.7 мс scalar 48810488 46.0 мс | simd 48810488 32.9 мс scalar 48810488 45.7 мс | simd 48810488 32.3 мс
Скалярная версия ускорилась в четыре раза и практически сравнялась с векторной. Точная величина отрыва зависит от железа: на другой машине тот же бенчмарк показал преимущество SIMD-версии всего вдвое. Закономерность одна: как только компилятору разрешают больше, разница резко сжимается.
Дело в том, что LLVM отлично векторизует такой цикл самостоятельно — просто по умолчанию ему это запрещено, ведь бинарник обязан запускаться на любом x86-64. В базовой сборке у скалярной версии видны pxor и paddq: SSE2 она использует, но дальше без явного разрешения не идёт.
Внутренний цикл SIMD-версии в той же базовой сборке выглядит так:
.LBB6_14: movdqa xmm1, xmmword ptr [rax + rsi] pcmpeqb xmm1, xmm0 pmovmskb edi, xmm1 movdqa xmm1, xmmword ptr [rax + rsi + 16] pcmpeqb xmm1, xmm0 pmovmskb r10d, xmm1 shl r10d, 16 or r10d, edi mov edi, r10d shr edi and edi, 1431655765 ...
32-байтовый вектор распался на две 16-байтовые загрузки — шире SSE2 не позволяет. Затем pcmpeqb сравнивает байты, pmovmskb извлекает по одному биту на байт в скалярный регистр, и обе половины склеиваются сдвигом. Дальше начинается самое интересное, ради чего я и полез в листинг: count_ones разворачивается в классический битовый трюк с масками 0x55555555 и 0x33333333, потому что инструкции popcnt в базовом x86-64 просто нет. Это отдельная целевая фича, причём +sse4.2 её не активирует, я проверил отдельной сборкой через -C target-feature.
С target-cpu=native картина полностью другая: девять настоящих popcnt, двадцать vpcmpeqb и сорок упоминаний ymm. А вот регистров zmm — ни одного, хотя AVX-512 в системе присутствует. Причина в том, что я запросил вектор ровно на 32 байта — его и получил, и никто не станет растягивать его до 64. Возможно, сказалась и известная осторожность LLVM в отношении AVX-512 из-за влияния на частоту процессора, но этот вопрос я не исследовал.
Напоследок
Картина получается любопытная. Пять фич, отсутствующих в стабильном Rust, тем не менее работают в нём уже сейчас: без super let не собрался бы pin!, без allocator_api у Vec не появился бы второй параметр, без константных трейтов нельзя было бы сложить 1 + 2 в константе, а ptr_metadata просто оголяет нам механизм, на котором с первого дня стоит каждый Box<dyn Trait>.
Отсюда вывод: «нестабильно» здесь означает лишь «мы пока не готовы пообещать, что интерфейс останется неизменным навсегда».
Все замеры сделаны на одной машине в контейнере, примеры учебные, а nightly обновляется каждую ночь, так что где-то я мог и ошибиться. Если ваши цифры окажутся другими или какой-то листинг перестанет собираться, напишите — исправлю. Материала хватит ещё на пару частей.
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.
