Avatar ·

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

Всем привет! Начал изучать Rust и наткнулся на интересный момент. Я знаю, что главное правило безопасности в Rust — это то, что у нас не может быть двух изменяемых ссылок (`&mut T`) на одни и те же данные одновременно. Компилятор это строго проверяет на этапе компиляции через систему заимствования. Однако я заметил нечто странное. Когда я пишу простую функцию, которая складывает два числа, передавая их как изменяемые ссылки, компилятор генерирует не самый оптимальный код. Вот пример: ```rust pub fn add(x: &mut u32, y: &mut u32) -> u32 { *x + *y } ``` Я ожидал, что компилятор (LLVM) поймет: так как `x` и `y` — это `&mut`, они гарантированно не указывают на одну и ту же область памяти (noalias). Значит, можно просто загрузить значения по этим адресам и сложить. Но когда я смотрю на ассемблерный вывод (например, на godbolt.org), я вижу, что компилятор генерирует код с явными инструкциями загрузки и разыменования, а не просто складывает регистры. Почему так происходит? Ведь в C или C++ с ключевым словом `restrict` компилятор делает агрессивные оптимизации. Разве Rust не должен вести себя так же, учитывая, что его гарантии даже строже? Я что-то упускаю в том, как устроен LLVM бекенд или как Rust представляет ссылки на низком уровне? Объясните, пожалуйста, почему компилятор не выкидывает эти лишние чтения из памяти, если он точно знает, что `x` и `y` не пересекаются? <<>>

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

Вы абсолютно правы в своих рассуждениях. С точки зрения семантики Rust, две изменяемые ссылки действительно не могут алиаситься. Это одно из главных правил borrow checker'а. Однако проблема кроется не в Rust, а в том, как это транслируется в промежуточное представление (IR) для LLVM.

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

Почему? Исторически сложилось так, что в ранних версиях Rust (до 1.0 и некоторое время после) включение noalias для всех &mut приводило к генерации неверного кода. LLVM иногда делал слишком агрессивные оптимизации, которые ломали код, особенно при работе с вложенными ссылками, типами с внутренней изменяемостью (например, UnsafeCell) или при передаче ссылок на аргументы, которые на самом деле являются алиасами на глобальное состояние.

Рассмотрим ваш пример:

pub fn add(x: &mut u32, y: &mut u32) -> u32 {
    *x + *y
}

В LLVM IR эта функция выглядит примерно так (упрощенно):

define i32 @add(i32* %x, i32* %y) {
  %val1 = load i32, i32* %x
  %val2 = load i32, i32* %y
  %sum = add i32 %val1, %val2
  ret i32 %sum
}

Без атрибута noalias на указателях %x и %y, LLVM обязан выполнить обе загрузки из памяти. Почему? Потому что он не знает, не изменится ли значение *x после того, как мы прочитали *y (теоретически, они могут указывать на одно и то же место). Если бы атрибут noalias был, LLVM мог бы переставить операции местами или кэшировать значение, но без него — нет.

Как это исправить? Флаг -Zmutable-noalias

Начиная с Rust 1.70 (и особенно активно в ночных сборках), команда Rust работает над возвращением этой оптимизации. Теперь она доступна через флаг компилятора:

RUSTFLAGS="-Z mutable-noalias" cargo build --release

Этот флаг заставляет компилятор добавлять атрибут noalias к аргументам &mut T в LLVM IR. После его включения код вашей функции будет оптимизирован гораздо агрессивнее.

Однако, этот флаг пока считается экспериментальным. Он может сломать код, который использует:

  • UnsafeCell (основа Cell, RefCell, Mutex) — так как LLVM может решить, что запись в ячейку через &mut не влияет на другие указатели, хотя на самом деле влияет через внутреннюю изменяемость.
  • FFI (Foreign Function Interface) — если вы передаете указатель на C-код, который меняет память "за спиной" у Rust, noalias может привести к неправильным предположениям.

Почему не включено по умолчанию?

Rust очень консервативен в вопросах корректности. Включение noalias для всех случаев привело бы к ошибкам в легальном коде, использующем unsafe. Поэтому команда разработчиков сначала хочет убедиться, что MIR (Mid-level IR) может доказать безопасность noalias для каждого конкретного случая, а не просто верить на слово LLVM.

Таким образом, ваш вопрос упирается в разрыв между высокоуровневыми гарантиями Rust и низкоуровневыми возможностями LLVM. Rust знает, что ссылки не алиасятся, но пока не говорит об этом LLVM в полной мере, чтобы не сломать существующий код.

<<>>

Дополнение: Проблема с типами, содержащими UnsafeCell

Ответ выше верен, но стоит подчеркнуть ключевую причину, по которой noalias опасен. Представьте, что у вас есть структура, содержащая UnsafeCell:

use std::cell::UnsafeCell;

struct Foo {
    value: UnsafeCell<u32>,
}

fn example(x: &mut Foo, y: &mut Foo) {
    // С точки зрения Rust, x и y не пересекаются.
    // Но внутри UnsafeCell мы можем получить сырой указатель
    // и сделать так, чтобы они указывали на одно и то же место.
    let ptr1 = x.value.get();
    let ptr2 = y.value.get();
    // Если x и y — это один и тот же объект (что запрещено правилами &mut,
    // но возможно в unsafe коде), то ptr1 и ptr2 равны.
    unsafe {
        *ptr1 = 5;
        *ptr2 = 10; // Перезаписывает *ptr1
    }
}

Если бы LLVM добавил noalias к аргументам x и y, он мог бы переупорядочить запись в *ptr1 и *ptr2, или вообще удалить одну из них, посчитав, что они не влияют друг на друга. Это привело бы к неопределенному поведению (UB).

Поэтому Rust не может просто так раздавать noalias всем подряд. Он должен убедиться, что внутри функции не происходит "небезопасного" обхода системы типов. Текущая реализация с флагом -Z mutable-noalias пытается это делать, анализируя тело функции на наличие UnsafeCell, но это сложная задача, которая пока не завершена.

Таким образом, "отсутствие" оптимизации — это цена за безопасность в присутствии unsafe кода. Как только анализатор MIR станет достаточно умным, чтобы доказать отсутствие UnsafeCell в цепочке заимствования, оптимизация будет включена по умолчанию.