...

суббота, 2 мая 2020 г.

C++ быстрее и безопаснее Rust, Yandex сделала замеры

Недавно я пытался заманить коллегу, сишника из соседнего отдела, на Тёмную сторону Rust. Но мой разговор с коллегой не задался. Потому что, цитата:


В 2019 году я был на конференции C++ CoreHard, слушал доклад Антона antoshkka Полухина о незаменимом C++. По словам Антона, Rust еще молодой, не очень быстрый и вообще не такой безопасный.

Антон Полухин является представителем России в ISO на международных заседаниях рабочей группы по стандартизации C++, автором нескольких принятых предложений к стандарту языка C++. Антон действительно крутой и авторитетный человек в вопросах по C++. Но доклад содержит несколько серьёзных фактических ошибок в отношении Rust. Давайте их разберём.

Речь идет об этом докладе с 13:00 по 22:35.


Для примера сравнения ассемблерного выхлопа Антон взял функцию возведения в квадрат(link:godbolt):

Цитата (13:35):


Получаем одинаковый ассемблерный выхлоп. Отлично! У нас есть базовая линия. Пока что C++ и Rust выдает одно и тоже.

В самом деле, ассемблерный листинг арифметического умножения в обоих случаях выглядит одинаковым, но это только до поры до времени. Дело в том, что с точки зрения семантики языков, код делает разные вещи. Этот код определяет функции возведения числа в квадрат, но в случае Rust область определения [-2147483648, 2147483647], а в случае C++ это [-46340, 46340]. Как такое может быть? Магия?

Магические константы -46340 и 46340 — это максимальные по модулю аргументы, квадрат которых умещается в std::int32_t. Все что выше будет давать неопределенное поведение из-за signed overflow. Если не верите мне, послушайте PVS-Studio. И если вы достаточно удачливы, чтобы работать в командах, которые настроили себе CI с проверкой кода на определенное поведение, вы получите такое сообщение:

runtime error: signed integer overflow: 46341 * 46341 cannot be represented in type 'int'
runtime error: signed integer overflow: -46341 * -46341 cannot be represented in type 'int'

В Rust такая ситуация с неопределенным поведением в арифметике невозможна в принципе.

Давайте послушаем, что об этом думает Антон (13:58):


Неопределенное поведение заключается в том что у нас тут знаковое число, и компилятор C++ считает что переполнения знаковых чисел не должно происходить в программе. Это неопределенное поведение. За счет этого компилятор C++ делает множество хитрых оптимизаций. В компиляторе Rust'а это задокументированное поведение, но от этого вам легче не станет. Ассемблерный код у вас получается тот же самый. В Rust'е это задокументированное поведение, и при умножении двух больших положительных чисел положительных вы получите отрицательное число, что скорее всего не то, что вы ожидали. При этом за счет того что они документируют это поведение Rust теряет возможность делать многие оптимизации. Они у них прям где-то на сайте написаны.

Я бы почитал, какие оптимизации не умеет Rust, особенно с учётом того, что в основе Rust лежит LLVM — тот же самый бэкенд, что и у Clang. Соответственно, Rust «бесплатно» получил и разделяет с C++ большую часть независящих от языка трансформаций кода и оптимизаций. И хотя в представленном примере мы и получили одинаковый ассемблер, на самом деле, это случайность. Хитрые оптимизации и наличие неопределённого поведения при переполнении знакового в языке C++ могут приводить к веселью и порой порождают такие статьи. Рассмотрим эту статью подробнее.

Дан код функции, вычисляющей полиномиальный хеш от строки с переполнением int'a:

unsigned MAX_INT = 2147483647;

int hash_code(std::string x) {
    int h = 13;
    for (unsigned i = 0; i < 3; i++) {
        h += h * 27752 + x[i];
    }
    if (h < 0) h += MAX_INT;
    return h;
}

На некоторых строках, в частности, на строке «bye», и только на сервере (что интересно, на своем компьютере все было в порядке) функция возвращала отрицательное число. Но как же так, ведь в случае, если число отрицательное, к нему прибавится MAX_INT и оно должно стать положительным.

Как подсказывает PVS-Studio, неопределенное поведение действительно не определено. Если посчитать 27752 в 3 степени, можно понять, почему хэш от двух букв считается нормально, а от трех уже с какими-то странными результатами.

Аналогичный код на Rust будет вести себя корректно(link:playground):

fn hash_code(x: String) -> i32 {
    let mut h = 13i32;
    for i in 0..3 {
        h += h * 27752 + x.as_bytes()[i] as i32;
    }
    if h < 0 {
        h += i32::max_value();
    }
    return h;
}

fn main() {
    let h = hash_code("bye".to_string());
    println!("hash: {}", h);
}

Выполнение этого кода отличается в Debug и Release по понятным причинам, а для унификации поведения можно воспользоваться семейством функций: wrapping*, saturating*, overflowing* и checked*.

Как видите, документированное поведение и отсутствие неопределённого поведения при переполнении знакового действительно делают жизнь легче.

Вычисление квадрата числа — это отличный пример того, как можно выстрелить себе в ногу с помощью C++ в трех строчках кода. Зато быстро и с оптимизациями. Если от обращения к неинициализированной памяти ещё можно откреститься вдумчивым взглядом, то проблема с арифметикой в том, что беда может прийти совершенно внезапно и на «голом» арифметическом коде, где ломаться на первый взгляд нечему.

В качестве примера приводится следующий код(link:godbolt):

Антон (15:15):


Компилятор Rust'а и компилятор C++ скомпилировали оба этих приложения, и функция bar ничего не делает. При этом оба компилятора выдали сообщения-предупреждения, что возможно здесь что-то не то. К чему я все это говорю… Когда вы слышите, что Rust супер замечательный безопасный язык, то его безопасность заключается только в анализе времени жизни объектов, UB — либо документированное поведение, которое вы не очень ожидаете, по-прежнему в нем есть. Компилятор по-прежнему компилирует код, который явно делает какую-то чушь. И-и-и так уж получается.

Здесь мы наблюдаем бесконечную рекурсию. Опять-таки код компилируется в одинаковый ассемблерный выхлоп, то есть NOP для функции bar как в C++, так и в Rust. Но это баг LLVM.

Если вывести LLVM IR кода с бесконечной рекурсией, то мы увидим(link:godbolt):

ret i32 undef — и есть ошибка, сгенерированная LLVM.

В самом LLVM бага живет с 2006 года. И это важный вопрос, ведь необходимо иметь возможность пометить бесконечные цикл или рекурсию так, чтобы LLVM не мог оптимизировать это в ноль. К счастью, есть прогресс. В LLVM 6 добавили интринсик llvm.sideeffect, а в 2019 году в rustc был добавлен флаг -Z insert-sideeffect, который добавляет llvm.sideeffect в бесконечные циклы и рекурсии. И бесконечная рекурсия становится действительно бесконечной(link:godbolt). Надеюсь, что в скором времени этот флаг перейдет и в stable rustc по-умолчанию.

В C++ бесконечная рекурсия и цикл без побочных эффектов считаются неопределённым поведением, так что от этой баги LLVM страдают только Rust и C.

Итак, после того, как мы разобрались с ошибкой LLVM, давайте перейдем к главному заявлению: "его безопасность заключается только в анализе времени жизни объектов". Это заявление ложно, так как безопасное подмножество Rust защищает от ошибок, связанных с многопоточностью, гонками данных и выстрелам по памяти.

Антон (16:00):


Посмотрим более сложные функции. Что с ними делает Rust. Поправили нашу функцию bar и теперь она вызывает функцию foo. Мы видим, что Rust сгенерировал две лишних инструкции: одна инструкция сохраняет что-то в стек, другая инструкция в конце вытаскивает со стека. В C++ этого нету. Rust два раза потрогал память. Как-то уже не очень.

Вот этот пример(link:godbolt):

Вывод ассемблера для Rust длинный, но мы разберемся в причинах такой разницы. В этом примере Антон использует флаги -ftrapv для C++ и -C overflow-checks=on для Rust, чтобы включить проверку на переполнение знаковых. При переполнении C++ прыгает на инструкцию ud2, которая приводит к "Illegal instruction (core dumped)", а Rust прыгает на вызов функции core::panicking::panic, подготовка к которой занимает половину ассемблерного кода. В случае переполнения core::panicking::panic дает нам красивое объяснение падения:

$ ./signed_overflow 
thread 'main' panicked at 'attempt to multiply with overflow', signed_overflow.rs:6:12
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Так откуда взялись эти "лишние" инструкции, которые трогают память? Соглашение о вызове функции x86-64 требует, чтобы стек был выравнен до 16 байт, инструкция call кладёт 8-байтовый адрес возврата на стек, что ломает выравнивание. Чтобы это исправить, компиляторы кладут всякие инструкции типа push rax. И так делает не только Rust, но и C++(link:godbolt):

И C++, и Rust сгенерировали одинавый выхлоп ассемблера, оба добавили push rbx для выравнивания стека. Q.E.D.

Самое интересное заключается в том, что именно C++ нуждается в деоптимизации кода путём добавления аргумента -ftrapv, чтобы ловить неопределенное поведение при переполнении знаковых. Выше я уже показал, что Rust будет вести себя корректно даже без флага -C overflow-checks=on, так что можете сравнить сами(link:godbolt) стоимость корректного кода на C++, либо почитайте статью на эту тему. К тому же -ftrapv в gcc сломан с 2008 года.

Антон (18:10):


Чуть медленнее Rust плюсов...

На протяжении всего доклада Антон выбирает примеры, написанные на Rust'е, которые компилируются в чуть больший ассемблер. Не только примеры выше, которые "трогают" память, но и пример на 17:30(link:godbolt):

Складывается впечатление, что весь этот анализ ассемблерного выхлопа был нужен лишь для того, чтобы сказать, что больше асма → медленнее язык.

В 2019 на конференции CppCon был интересный доклад There Are No Zero-cost Abstractions от Chandler Carruth. Вот он там на 17:30 сильно страдает из-за того, что std::unique_ptr стоит дороже сырых указателей (link:godbolt). И чтобы хоть как-то приблизиться к ассемблерному выхлопу кода на сырых указателях ему приходится добавлять noexcept, rvalue ссылки, и использовать std::move. А на Rust всё будет работать без дополнительных усилий. Давайте сравним два кода и ассемблер. В примере на Rust мне пришлось дополнительно извратиться с extern "Rust" и unsafe, чтобы компилятор не заинлайнил вызовы (link:godbolt):

При меньших трудозатратах Rust генерирует меньше ассемблера. И не нужны подсказки компилятору в виде noexcept, rvalue ссылок и std::move. В сравнениях языков нужны нормальные бенчмарки. Нельзя вытащить понравившийся пример, и утвержать, что один язык медленнее другого.

В декабре 2019 Rust превосходил по производительности C++ согласно результатам Benchmarks Game. С тех пор C++ немного укрепил свои позиции. Но на таких синтетических бенчмарках языки будут раз за разом обходить друг друга. Я бы не отказался посмотреть нормальные бенчмарки.

Антон (18:30):


Мы берем большое десктопное плюсовое приложение, пытаемся его переписать на Rust и понимаем, что наше большое плюсовое приложение использует сторонние библиотеки. А очень много сторонних библиотек, написаных на си, имеют сишные заголовочные файлы. Из С++ эти заголовочные файлы мы можем брать и использовать, по возможности оборачивая все в более безопасные конструкции. В Rust'е нам придется переписать эти заголовочные файлы либо сгенерировать какой-то программой из сишных заголовочных файлов.

Вот тут Антон смешал в одну кучу объявление сишных функций и их последующее использование.

Действительно, объявление сишных функций в Rust требует либо их ручного объявления, либо автоматической генерации, потому что это разные языки программирования. Подробнее можно прочитать в моей статье про бота для Starcraft, либо посмотреть на пример генерации этих оберток.

К счастью, у языка Rust есть пакетный менеджер cargo, который позволяет один раз сгенерировать объявления и поделиться ими со всем миром. Как вы понимаете, люди делятся не только сырыми объявлениями, но и безопасными и идиоматичными обёртками. На 2020 год в реестре пакетов crates.io находится около 40 000 крейтов.

Ну а само использование сишной библиотеки занимает буквально одну строчку в вашем конфиге:

# Cargo.toml
[dependencies]
flate2 = "1.0"

Всю работу по компиляции и линковке с учетом версий зависимостей cargo выполнит автоматически. Пример с flate2 примечателен тем, что в начале своего существования этот крейт использовал сишную библиотеку miniz, написанную на C, но со временем сообщество переписало сишный код на Rust. И flate2 стал работать быстрее.

Антон (19:14):


Внутри блока unsafe отключаются все проверки Rust'а, он там ничего не проверяет, и целиком полагается на то, что вы в этом месте написали все правильно.

Данный пункт является продолжением темы про интеграцию сишных библиотек в Rust'овый код.

Увы, мнение об отключении всех проверок в unsafe — это типичное заблуждение, потому что в документации к языку Rust сказано, что unsafe позволяет:


  1. Разыменовывать сырой указатель;
  2. Вызывать и объявлять unsafe функции;
  3. Читать или измененять статическую изменяемую переменную;
  4. Реализовывать и объявлять unsafe типаж;
  5. Получать доступ к полям union.

Ни о каких отключениях всех проверок Rust здесь и речи не идет. Если у вас ошибка с lifetime-ами, то просто добавление unsafe не поможет коду скомпилироваться. Внутри этого блока компилятор продолжает проверять код на соответствие системы типов, отслеживать время жизни переменных, корректность на потокобезопасность и многое-многое другое. Подробнее можно прочитать в статье You can’t "turn off the borrow checker" in Rust.

К unsafe не стоит относиться как "я делаю, что хочу". Это указание компилятору, что вы берете на себя ответственность за вполне конкретный набор инвариантов, которые компилятор самостоятельно проверить не может. Например, разыменование сырого указателя. Это мы с вами знаем, что сишный malloc возвращает NULL или указатель на аллоцированный кусок неинициализированной памяти, а компилятор Rust об этой семантике ничего не знает. Поэтому для работы с сырым указателем, который вернул, к примеру, malloc, вы должны сказать компилятору: "я знаю, что делаю; я проверил, там не нулл, память правильно выравнена для этого типа данных". Вы берете на себя ответственность за этот указатель в блоке unsafe.

Антон (19:25):


Из десяти ошибок за последний месяц которые я встречал и которые возникали в C++ программах три были вызваны тем что сишным методом неправильно работают, где-то забыли освободить память где-то не тот аргумент передали, где-то не проверели на null и передали нулевой указатель. Огромное количество проблем именно в использовании сишного кода. И в этом месте Rust вам никак не поможет. Получается как-то не очень хорошо. Вроде бы у Rust с безопасностью намного лучше, но только мы начинаем использовать сторонние библиотеки, нужно иметь такую же бдительность как в C++.

По статистике Microsoft, 70% уязвимостей связаны с нарушениями безопасности доступа к памяти и с другими классами ошибок, от которых Rust предотвращает ещё на этапе компиляции. Это ошибки, которые физически невозможно совершить в безопасном подмножестве Rust.

С другой стороны, существует и unsafe подмножество Rust, которое позволяет разыменовывать сырые указатели, вызывать сишные функции… и прочие небезопасные вещи, которые могут сломать вашу программу, если ими пользоваться неправильно. В общем, именно то, что делает Rust системным языком программирования.

И, казалось бы, можно поймать себя на мысли, что если в Rust и в C++ надо следить за корректностью вызовов сишных функций, то Rust ничуть не выигрывает. Но особенностью Rust является возможность разграничения кода на безопасный и потенциально опасный с последующей инкапсуляцией последнего. А если на текущем уровне гарантировать корректность семантики не удаётся, то unsafe надо делегировать вызывающему коду.

На практике делегация unsafe наверх выглядит вот так:

// Warning: Calling this method with an out-of-bounds index is undefined behavior.
unsafe fn unchecked_get_elem_by_index(elems: &[u8], index: usize) -> u8 {
    *elems.get_unchecked(index)
}

slice::get_unchecked — это стандартная unsafe функция, которая получает элемент по индексу без проверок индекса на выход за границы. Так как в нашей функции get_elem_by_index мы тоже не проверяем индекс, а передаем его как есть, то наша функция потенциально опасна. И любое обращение к такой функции требует явного указания unsafe(link:playground):

// Warning: Calling this method with an out-of-bounds index is undefined behavior.
unsafe fn unchecked_get_elem_by_index(elems: &[u8], index: usize) -> u8 {
    *elems.get_unchecked(index)
}

fn main() {
    let elems = &[42];
    let elem = unsafe { unchecked_get_elem_by_index(elems, 0) };
    dbg!(elem);
}

Если вы передадите индекс, выходящий за границы, то получите обращение к неинициализированной области памяти. И сделать вы это сможете только в unsafe.

Тем не менее, с помощью этой unsafe функции мы можем построить безопасную версию(link:playground):

// Warning: Calling this method with an out-of-bounds index is undefined behavior.
unsafe fn unchecked_get_elem_by_index(elems: &[u8], index: usize) -> u8 {
    *elems.get_unchecked(index)
}

fn get_elem_by_index(elems: &[u8], index: usize) -> Option<u8> {
    if index < elems.len() {
        let elem = unsafe { unchecked_get_elem_by_index(elems, index) };
        Some(elem)
    } else {
        None
    }
}

fn main() {
    let elems = &[42];
    let elem = get_elem_by_index(elems, 0);
    dbg!(&elem);
}

И эта безопасная версия никогда не выстрелит по памяти, какие бы аргументы вы туда не передали. Если что, я не призываю вас писать подобный код на Rust (есть функция slice::get), я показываю, как можно перейти из unsafe подмножества Rust в безопасное подмножество с сохранением гарантий безопасности. На месте нашей unchecked_get_elem_by_index могла быть аналогичная функция, написанная на C.

Благодаря межъязыковой LTO вызов сишной функции может быть абсолютно бесплатен:

Я выложил проект с флагами компилятора на гитхаб. Результирующий выхлоп ассемблера аналогичен коду, написанному на чистом C(link:godbolt), но имеет гарантии кода, написанного на Rust.

Антон (20:38):


Есть у нас замечательный язык программирования X. Этот язык программирования математически верифицируемый язык программирования. Если вдруг ваше приложение собралось и оно написано этом языке программирования X, то значит математически доказано, что в этом приложении нет ошибок. Звучит круто. Очень. Есть проблема. Мы используем сишные библиотеки, и когда мы их используем из такого языка программирования X, то разумеется все математические доказательства немного отваливаются.

В 2018 году доказали, что система типов Rust, механизмы заимствования, владения, времён жизни и многопоточности корректны. Так же было доказано, что если мы используем семантически правильный код из библиотек внутри unsafe и смешаем это с синтаксически правильным safe кодом, мы получим семантически правильный код, который не позволяет стрелять по памяти или делать гонки данных.

Из этого следует, что если вы подключаете и используете крейт(библиотеку), которая содержит unsafe, но предоставляет правильные безопасные обертки, то ваш код от этого не станет небезопасным.

В качестве практического применения своей модели авторы доказали корректность некоторых примитивов стандартной библиотеки, включая Mutex, RwLock, thread::spawn. А они используют сишные функции. Таким образом, в Rust невозможно случайно расшарить переменную между потоков без примитивов синхронизации; а, используя Mutex из стандартной библиотеки, доступ к переменной всегда будет корректен, несмотря на то, что их реализация опирается на сишные функции. Круто? Круто.

Объективно обсуждать относительные преимещества того или иного языка сложно, особенно если вам сильно нравится один язык и не нравится другой. Весьма часто новый апологет очередного "новоявленного языка-убийцы C++" делает громкие заявления, не разобравшись толком с C++, за что ожидаемо получает по рукам.

Однако от признаных экспертов я ожидаю взвешенного освещения ситуации, которое, как минимум, не содержит грубых фактических ошибок.

Большое спасибо Дмитрию Кашицыну и Алексею Кладову за ревью статьи.

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий