Skip to content

Latest commit

 

History

History
108 lines (83 loc) · 7.24 KB

assume.md

File metadata and controls

108 lines (83 loc) · 7.24 KB

Атрибут [[assume]]

"Есть некоротая вселенская несправедливость", — подумали в комитете стандартизации C++, — "мы так много всего в языке назначили быть неопределенным поведением, чтоб помочь компиляторам генерировать оптимальный код. Но не дали такую же стандартную возможность нашим пользователям — программистам!"

Да, С++23 наконец-то дал простым пользователям инструмент целенаправленного внедрения неопределенного поведения в их код. Такой инструмент, правда, давно уже был и так, но специфичный для конкретного компилятора. C++23 же всего лишь стандартизировал его. Так что радуйтесь, никаких больше уродливых __builtin_assume!

  • Зачем вообще такая возможность существует?! — первый же вопрос, который возникает после прочтения абзаца выше. Неужели недостаточно ужасов самого языка, нужно еще пользователям позволить создавать новые?!

На самом деле, конечно, причина есть: компиляторы глупые, быстрый и оптимальный код получить хочется, а на ассемблере писать не очень хочется. Хотя, конечно, разработчики ffmpeg с этим не согласятся — они поэтому целенаправленно делают ассемблерные вставки, не доверяя компиляторам С.

Несмотря на то что мы говорим о C и C++, я позволю себе привести пример на Rust, поскольку считаю, что он наиболее ярко может продемонстрировать логику новвовведения С++23.

Возьмем достаточно простую функцию, которая выполняет семплирование отсортированной выборки: разбивает ее на группы равной величины и из каждой группы выбирает медианну величину

use std::num::NonZeroUsize;

pub fn medians(data: &[f32], group: NonZeroUsize) -> Vec<f32> {
    let n = group.get();
    data.chunks_exact(n) // разбиваем на группы по n, 
                         // последняя группа если в ней меньше n -- игнорируется
        .map(move |chunk| chunk[n/2]) // берем медиану
        .collect() // собираем результат
}

Если мы скомпилируем эту функцию довольно старой версией Rustc 1.51 с opt-level=3, мы обнаружим, что код получился так себе

  1. Мы видим в начале функции
    sub     rsp, 120
    mov     qword ptr [rsp + 96], rcx
    test    rcx, rcx
    je      .LBB4_33
    ...
.LBB4_33:
    ...
    call    qword ptr [rip + core::panicking::panic_fmt::hcd56f7f635f62c74@GOTPCREL]
    ud2

Это проверка что n не ноль. Но мы же и так знаем что n не ноль ­— это четко указано в типе входного параметра!

  1. При обработке каждой группы мы находим
    shr     rdi
    cmp     rdi, r15
    jae     .LBB4_27
    ...
.LBB4_27:
    lea     rdx, [rip + .L__unnamed_4]
    mov     rsi, r15
    call    qword ptr [rip + core::panicking::panic_bounds_check::h16537cfb53a1364b@GOTPCREL]

Каждый раз проверяется что индекс n/2 в границах группы. Но ведь это всегда так!

Очень бы хотелось донести до компилятора такие очевидные факты. Собственно [[assume(condition)]] для того в C++23 и добавили. Если компилятор не смог догадаться до чего-то самостоятельно и сгенерировать оптимальный код, мы теперь можем ему подсказать...

Так c GCC14 и C++26 та же самая функция (используя безопасные методы, как в Rust)

struct NonZero {
public:
    explicit NonZero(size_t v) : value {  
        v > 0 ? v : throw std::runtime_error("Zero value")
    } {}

    size_t get() const {
        return value;
    }
private:
    size_t value;
};

template <class T>
auto chunks_exact(std::span<T> data, size_t n) {
    if (n == 0) {
        throw std::runtime_error("zero chunk len");
    }
    return data.subspan(0, data.size() - data.size() % n) 
            | std::views::chunk(n) 
            | std::views::transform([](auto chunk){ return std::span(&chunk.front(), chunk.size()); }); // remap into spans
}


__attribute__((noinline))
std::vector<float> medians(std::span<const float> data, NonZero group) {
    size_t n = group.get();
    // [[assume(n>0)]];
    return chunks_exact(data, n) 
            | std::views::transform([n](auto chunk) { return chunk.at(n/2); }) 
            | std::ranges::to<std::vector>();
}

также компилируется со всеми ненужными проверками. Но стоит нам только лишь добавить [[assume(n>0)]], как ситуация меняется и все избыточные проверки на ноль и на границы групп могут быть успешно выброшены компилятором!


Но что если мы подсказали неправильно? Неопределенное поведение, конечно же! Из ложной посылки следует что угодно.

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

Но прежде чем начинать пользоваться такой замечательной возможностью языка, стоит понимать:

  1. Правильная подсказка ничего не гарантирует. А ложная невероятно опасна
  2. Новые версии компиляторов и сами могут догадаться. Так, например, rustc 1.80 на рассмотренном примере оптимизирует уже все как надо.