Avatar ·

Почему Rust не оптимизирует код, предполагая отсутствие алиасинга двух изменяемых ссылок?

Всем привет! Я изучаю Rust и пытаюсь разобраться с тем, как компилятор использует гарантии безопасности для оптимизации кода. Известно, что в Rust изменяемые ссылки (`&mut T`) уникальны: в любой момент времени может существовать только одна изменяемая ссылка на данные. Это правило гарантируется компилятором на этапе проверки заимствований. Казалось бы, это должно давать LLVM мощные подсказки для оптимизации, аналогичные `restrict` в C/C++. Однако я заметил, что в некоторых простых бенчмарках Rust генерирует код, который не использует это предположение. Вот простой пример: ```rust pub fn add_assign(x: &mut u32, y: &mut u32) { *x += *y; *x += *y; } ``` Я ожидал, что компилятор оптимизирует это в `*x += *y * 2`, так как `x` и `y` не могут указывать на одну и ту же память. Но в сгенерированном ассемблере (например, на Compiler Explorer) я вижу два чтения и два сложения. Почему Rust не использует гарантию отсутствия алиасинга для такой оптимизации? Разве это не одно из главных преимуществ системы владения? Буду благодарен за объяснение с примерами кода и указанием, как заставить компилятор всё-таки это сделать. <<>>

Краткий ответ: LLVM не доверяет гарантиям Rust "из коробки"

Вы правы: Rust гарантирует, что две &mut T ссылки не могут алиасить (указывать на одну память). Однако компилятор Rust (rustc) не передаёт эту информацию напрямую в LLVM в виде атрибута noalias для всех случаев. Причина — исторические проблемы с корректностью LLVM и сложность с вложенными ссылками.

Почему так происходит?

В C/C++ ключевое слово restrict — это обещание программиста, которое LLVM использует для агрессивных оптимизаций. Если программист нарушает обещание — это UB. В Rust же гарантии даются компилятором, но LLVM иногда генерирует некорректный код, если слепо верить noalias для &mut T. Проблемы возникают, когда:

  • Ссылка используется для записи в память, которая доступна через другую ссылку (например, через сырой указатель).
  • Есть вложенные структуры с внутренней изменчивостью (Cell, RefCell).
  • Функция принимает &mut &mut T (двойная мутабельная ссылка).

Долгое время LLVM имел баги, связанные с noalias на noalias (пересекающиеся гарантии). Поэтому разработчики Rust пошли на компромисс: для большинства простых функций noalias не ставится, чтобы избежать случайных некорректных оптимизаций.

Как это выглядит на практике?

Ваш пример:

pub fn add_assign(x: &mut u32, y: &mut u32) {
    *x += *y;
    *x += *y;
}

Компилятор не может оптимизировать до *x += *y * 2, потому что x и y могут указывать на одну и ту же память с точки зрения LLVM. Rust не передал атрибут noalias для параметров.

Как заставить компилятор оптимизировать?

Есть несколько способов:

  • Использовать core::hint::assert_unchecked (ночной Rust):
#![feature(hint_assert_unchecked)]
use core::hint;

pub fn add_assign_opt(x: &mut u32, y: &mut u32) {
    unsafe {
        hint::assert_unchecked(!core::ptr::eq(x, y));
    }
    *x += *y;
    *x += *y;
}
  • Переписать через NonNull и сырые указатели (для продвинутых случаев).
  • Использовать #[repr(transparent)] и обёртки — иногда помогает структурирование кода.
  • Просто подождать — в Rust nightly уже есть эксперименты с включением noalias для &mut T в некоторых случаях (флаг -Zmutable-noalias).

Проверка на практике

Соберите с флагом -Zmutable-noalias (ночной Rust):

rustc -C opt-level=3 -Z mutable-noalias example.rs --emit asm

Вы увидите, что LLVM теперь генерирует lea eax, [rsi + rsi] или аналогичную оптимизацию. Но будьте осторожны: этот флаг может сломать код с unsafe, который полагается на алиасинг.

Вывод

Rust даёт гарантии, но LLVM пока не полностью им доверяет. Это компромисс между безопасностью и производительностью. Для большинства кода разница незначительна, но в горячих циклах можно использовать assert_unchecked или дождаться стабилизации -Zmutable-noalias. Система владения Rust всё равно даёт огромный выигрыш в производительности по сравнению с GC-языками, даже без этой конкретной оптимизации.

<<>>

Альтернативный взгляд: это не только проблема LLVM, но и семантики Rust

Проблема глубже, чем кажется. Гарантия "две &mut T не алиассят" верна только для активных заимствований. Но Rust разрешает "мертвые" заимствования, которые уже не используются, но формально существуют. Рассмотрим пример:

fn foo(x: &mut u32, y: &mut u32) {
    let a = x as *mut u32;
    let b = y as *mut u32;
    // Теперь x и y не используются, но ссылки живы
    unsafe { *a += *b; }
}

Здесь x и y не алиассят, но мы создали сырые указатели, которые могут алиасить. Если LLVM решит, что x и y — noalias, и оптимизирует запись через a, это может привести к UB, которого Rust не обещал.

Поэтому Rust предпочитает консервативный подход: не ставить noalias, если есть хоть малейшая возможность обойти гарантию через unsafe. Это жертвует микрооптимизациями ради сохранения safety net для unsafe кода. Если вам нужна максимальная производительность — используйте #[repr(C)], сырые указатели и явные аннотации readonly/noalias через атрибуты LLVM (ночной Rust).