Как мне написать поддерживаемую, быструю битовую маску времени компиляции на C ++?


114

У меня есть код, который более или менее похож на этот:

#include <bitset>

enum Flags { A = 1, B = 2, C = 3, D = 5,
             E = 8, F = 13, G = 21, H,
             I, J, K, L, M, N, O };

void apply_known_mask(std::bitset<64> &bits) {
    const Flags important_bits[] = { B, D, E, H, K, M, L, O };
    std::remove_reference<decltype(bits)>::type mask{};
    for (const auto& bit : important_bits) {
        mask.set(bit);
    }

    bits &= mask;
}

Clang> = 3.6 делает умную вещь и компилирует это в одну andинструкцию (которая затем вставляется везде):

apply_known_mask(std::bitset<64ul>&):  # @apply_known_mask(std::bitset<64ul>&)
        and     qword ptr [rdi], 775946532
        ret

Но каждая версия GCC, которую я пробовал, компилирует это до огромного беспорядка, который включает обработку ошибок, которая должна статически выполняться DCE. В другом коде он даже поместит important_bitsэквивалент в виде данных в соответствии с кодом!

.LC0:
        .string "bitset::set"
.LC1:
        .string "%s: __position (which is %zu) >= _Nb (which is %zu)"
apply_known_mask(std::bitset<64ul>&):
        sub     rsp, 40
        xor     esi, esi
        mov     ecx, 2
        movabs  rax, 21474836482
        mov     QWORD PTR [rsp], rax
        mov     r8d, 1
        movabs  rax, 94489280520
        mov     QWORD PTR [rsp+8], rax
        movabs  rax, 115964117017
        mov     QWORD PTR [rsp+16], rax
        movabs  rax, 124554051610
        mov     QWORD PTR [rsp+24], rax
        mov     rax, rsp
        jmp     .L2
.L3:
        mov     edx, DWORD PTR [rax]
        mov     rcx, rdx
        cmp     edx, 63
        ja      .L7
.L2:
        mov     rdx, r8
        add     rax, 4
        sal     rdx, cl
        lea     rcx, [rsp+32]
        or      rsi, rdx
        cmp     rax, rcx
        jne     .L3
        and     QWORD PTR [rdi], rsi
        add     rsp, 40
        ret
.L7:
        mov     ecx, 64
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:.LC1
        xor     eax, eax
        call    std::__throw_out_of_range_fmt(char const*, ...)

Как мне написать этот код, чтобы оба компилятора могли делать правильные вещи? В противном случае, как мне написать это, чтобы оно оставалось ясным, быстрым и удобным в обслуживании?


4
Вы не можете создать маску вместо цикла B | D | E | ... | O?
HolyBlackCat

6
В перечислении есть позиции битов, а не уже развернутые биты, так что я мог бы это сделать,(1ULL << B) | ... | (1ULL << O)
Алекс

3
Обратной стороной является то, что фактические имена длинные и неправильные, и не так легко увидеть, какие флаги находятся в маске со всем этим линейным шумом.
Alex Reinking

4
@AlexReinking Вы могли бы сделать это одним (1ULL << Constant)| на строку и выровняйте имена констант на разных строках, чтобы было легче для глаз.
einpoklum

Я думаю, что проблема здесь связана с отсутствием использования беззнакового типа, у GCC всегда были проблемы со статическим отбрасыванием поправки на переполнение и преобразование типа в подписанном / беззнаковом гибридном. Результат битового сдвига здесь является intрезультатом битовой операции МОЖЕТ БЫТЬ intИЛИ может long longзависеть от значения и формально enumне является эквивалентом intконстанты. clang требует «как будто», gcc остается педантичным
Swift - Friday Pie

Ответы:


112

Лучшая версия :

template< unsigned char... indexes >
constexpr unsigned long long mask(){
  return ((1ull<<indexes)|...|0ull);
}

затем

void apply_known_mask(std::bitset<64> &bits) {
  constexpr auto m = mask<B,D,E,H,K,M,L,O>();
  bits &= m;
}

обратно в , мы можем проделать этот странный трюк:

template< unsigned char... indexes >
constexpr unsigned long long mask(){
  auto r = 0ull;
  using discard_t = int[]; // data never used
  // value never used:
  discard_t discard = {0,(void(
    r |= (1ull << indexes) // side effect, used
  ),0)...};
  (void)discard; // block unused var warnings
  return r;
}

или, если мы застряли с , мы можем решить ее рекурсивно:

constexpr unsigned long long mask(){
  return 0;
}
template<class...Tail>
constexpr unsigned long long mask(unsigned char b0, Tail...tail){
  return (1ull<<b0) | mask(tail...);
}
template< unsigned char... indexes >
constexpr unsigned long long mask(){
  return mask(indexes...);
}

Godbolt со всеми 3 - вы можете переключить CPP_VERSION define, и получить идентичную сборку.

На практике я бы использовал самое современное, что мог. 14 лучше 11, потому что у нас нет рекурсии и, следовательно, длина символа O (n ^ 2) (что может резко увеличить время компиляции и использование памяти компилятора); 17 лучше 14, потому что компилятору не нужно удалять этот массив мертвым кодом, и этот трюк с массивом просто уродлив.

Из этих 14 самых запутанных. Здесь мы создаем анонимный массив всех нулей, тем временем в качестве побочного эффекта создаем наш результат, а затем отбрасываем массив. В отброшенном массиве есть количество нулей, равное размеру нашего пакета, плюс 1 (который мы добавляем, чтобы мы могли обрабатывать пустые пакеты).


Подробное объяснение того, что версия делает. Это уловка / хакерство, и тот факт, что вы должны сделать это для эффективного расширения пакетов параметров в C ++ 14, является одной из причин, почему выражения сворачивания были добавлены в.

Лучше всего это понять изнутри:

    r |= (1ull << indexes) // side effect, used

это просто обновляет rс 1<<indexesдля фиксированного индекса. indexes- это пакет параметров, поэтому нам придется его расширить.

Остальная часть работы - предоставить пакет параметров для расширения indexesвнутри.

Один шаг вперед:

(void(
    r |= (1ull << indexes) // side effect, used
  ),0)

здесь мы приводим наше выражение к void, показывая, что нас не волнует его возвращаемое значение (нам просто нужен побочный эффект установки r- в C ++ такие выражения, как a |= bтакже возвращают значение, которое они установили a).

Затем мы используем оператор запятой ,и, 0чтобы отбросить void"значение" и вернуть значение 0. Итак, это выражение, значение которого есть, 0и в качестве побочного эффекта его вычисления 0устанавливается немного r.

  int discard[] = {0,(void(
    r |= (1ull << indexes) // side effect, used
  ),0)...};

На этом этапе мы расширяем пакет параметров indexes. Получаем:

 {
    0,
    (expression that sets a bit and returns 0),
    (expression that sets a bit and returns 0),
    [...]
    (expression that sets a bit and returns 0),
  }

в {}. Такое использование ,является не оператор запятой, а разделитель элемента массива. Это sizeof...(indexes)+1 0s, который также устанавливает биты в rкачестве побочного эффекта. Затем мы назначаем {}массиву инструкции по построению массива discard.

Далее мы приводим discardк void- большинство компиляторов предупредит вас, если вы создадите переменную и никогда ее не прочитаете. Все компиляторы не будут жаловаться, если вы его приведете void, это своего рода способ сказать «Да, я знаю, я не использую это», поэтому предупреждение подавляется.


38
Извините, но этот код C ++ 14 - это что-то. Не знаю что.
Джеймс

14
@James Это прекрасный мотивирующий пример того, почему выражения свёртки в C ++ 17 очень приветствуются. Это и аналогичные уловки оказались эффективным способом расширения пакета «на месте» без какой-либо рекурсии, и компиляторы считают, что его легко оптимизировать.
Якк - Адам Неврамонт

4
@ruben multi line constexpr является незаконным в 11
Yakk - Адам Неврамонт

6
Я не вижу себя проверяющим этот код C ++ 14. Я буду придерживаться кода C ++ 11, так как он мне все равно нужен, но даже если бы я мог его использовать, код C ++ 14 потребовал бы так много объяснений, что я бы не стал. Эти маски всегда могут содержать не более 32 элементов, поэтому меня не беспокоит поведение O (n ^ 2). В конце концов, если n ограничено константой, то это действительно O (1). ;)
Alex Reinking

9
Для тех, кто пытается понять, ((1ull<<indexes)|...|0ull)это «складное выражение» . В частности, это «двоичная правая складка», и ее следует анализировать как(pack op ... op init)
Хенрик Хансен

47

Оптимизация, которую вы ищете, похоже, связана с отслаиванием петель, которое включается -O3или вручную -fpeel-loops. Я не уверен, почему это подпадает под действие отслаивания цикла, а не развертывания цикла, но, возможно, он не хочет развернуть цикл с нелокальным потоком управления внутри него (как, возможно, есть из проверки диапазона).

Однако по умолчанию GCC не может очистить все итерации, что, по-видимому, необходимо. Экспериментально, передача -O2 -fpeel-loops --param max-peeled-insns=200(значение по умолчанию 100) выполняет работу с вашим исходным кодом: https://godbolt.org/z/NNWrga


Вы великолепны, спасибо! Я понятия не имел, что это можно настроить в GCC! Хотя почему-то -O3 -fpeel-loops --param max-peeled-insns=200не получается ... Это -ftree-slp-vectorizeвидимо из-за .
Alex Reinking

Это решение, похоже, ограничено целью x86-64. Результат для ARM и ARM64 все еще не очень хорош, что опять же может быть совершенно неактуальным для OP.
Realtime

@realtime - это вообще-то актуально. Спасибо, что указали, что в этом случае это не работает. Очень досадно, что GCC не улавливает его до того, как будет понижен до IR для конкретной платформы. LLVM оптимизирует его перед дальнейшим понижением.
Alex Reinking

10

если использование только C ++ 11 является обязательным (&a)[N], это способ захвата массивов. Это позволяет вам написать одну рекурсивную функцию без использования вспомогательных функций:

template <std::size_t N>
constexpr std::uint64_t generate_mask(Flags const (&a)[N], std::size_t i = 0u){
    return i < N ? (1ull << a[i] | generate_mask(a, i + 1u)) : 0ull;
}

присваивая его constexpr auto:

void apply_known_mask(std::bitset<64>& bits) {
    constexpr const Flags important_bits[] = { B, D, E, H, K, M, L, O };
    constexpr auto m = generate_mask(important_bits); //< here
    bits &= m;
}

Тест

int main() {
    std::bitset<64> b;
    b.flip();
    apply_known_mask(b);
    std::cout << b.to_string() << '\n';
}

Вывод

0000000000000000000000000000000000101110010000000000000100100100
//                                ^ ^^^  ^             ^  ^  ^
//                                O MLK  H             E  D  B

действительно нужно ценить способность C ++ вычислять все, что можно вычислить по Тьюрингу, во время компиляции. Это наверняка до сих пор поражает меня ( <> ).


Для более поздних версий C ++ 14 и C ++ 17 ответ yakk уже прекрасно охватывает это.


3
Как это демонстрирует, что на apply_known_maskсамом деле оптимизирует?
Alex Reinking

2
@AlexReinking: Все страшные вещи constexpr. И хотя этого теоретически недостаточно, мы знаем, что GCC вполне способен выполнять оценку, constexprкак задумано.
MSalters

8

Я бы посоветовал вам написать правильный EnumSetшрифт.

Написание базового кода EnumSet<E>на C ++ 14 (и далее) на основе std::uint64_tтривиально:

template <typename E>
class EnumSet {
public:
    constexpr EnumSet() = default;

    constexpr EnumSet(std::initializer_list<E> values) {
        for (auto e : values) {
            set(e);
        }
    }

    constexpr bool has(E e) const { return mData & mask(e); }

    constexpr EnumSet& set(E e) { mData |= mask(e); return *this; }

    constexpr EnumSet& unset(E e) { mData &= ~mask(e); return *this; }

    constexpr EnumSet& operator&=(const EnumSet& other) {
        mData &= other.mData;
        return *this;
    }

    constexpr EnumSet& operator|=(const EnumSet& other) {
        mData |= other.mData;
        return *this;
    }

private:
    static constexpr std::uint64_t mask(E e) {
        return std::uint64_t(1) << e;
    }

    std::uint64_t mData = 0;
};

Это позволяет писать простой код:

void apply_known_mask(EnumSet<Flags>& flags) {
    static constexpr EnumSet<Flags> IMPORTANT{ B, D, E, H, K, M, L, O };

    flags &= IMPORTANT;
}

В C ++ 11 это требует некоторых сверток, но тем не менее остается возможным:

template <typename E>
class EnumSet {
public:
    template <E... Values>
    static constexpr EnumSet make() {
        return EnumSet(make_impl(Values...));
    }

    constexpr EnumSet() = default;

    constexpr bool has(E e) const { return mData & mask(e); }

    void set(E e) { mData |= mask(e); }

    void unset(E e) { mData &= ~mask(e); }

    EnumSet& operator&=(const EnumSet& other) {
        mData &= other.mData;
        return *this;
    }

    EnumSet& operator|=(const EnumSet& other) {
        mData |= other.mData;
        return *this;
    }

private:
    static constexpr std::uint64_t mask(E e) {
        return std::uint64_t(1) << e;
    }

    static constexpr std::uint64_t make_impl() { return 0; }

    template <typename... Tail>
    static constexpr std::uint64_t make_impl(E head, Tail... tail) {
        return mask(head) | make_impl(tail...);
    }

    explicit constexpr EnumSet(std::uint64_t data): mData(data) {}

    std::uint64_t mData = 0;
};

И вызывается:

void apply_known_mask(EnumSet<Flags>& flags) {
    static constexpr EnumSet<Flags> IMPORTANT =
        EnumSet<Flags>::make<B, D, E, H, K, M, L, O>();

    flags &= IMPORTANT;
}

Даже GCC банально генерирует andинструкцию на -O1 Godbolt :

apply_known_mask(EnumSet<Flags>&):
        and     QWORD PTR [rdi], 775946532
        ret

2
В С ++ 11 большая часть вашего constexprкода незаконна. Я имею ввиду, у некоторых есть 2 утверждения! (C ++ 11 constexpr отстойный)
Якк - Адам Неврамонт

@ Yakk-AdamNevraumont: Вы понимали, что я опубликовал 2 версии кода: первую для C ++ 14 и далее, а вторую специально для C ++ 11? (чтобы учесть его ограничения)
Matthieu M.

1
Возможно, лучше использовать std :: under_type вместо std :: uint64_t.
Джеймс

@ Джеймс: Вообще-то нет. Обратите внимание, что EnumSet<E>не использует значение Eas напрямую, а вместо этого использует 1 << e. Это совершенно другой домен, что на самом деле делает класс таким ценным => нет шансов случайно проиндексировать eвместо 1 << e.
Matthieu M.

@MatthieuM. Да, ты прав. Я путаю это с нашей собственной реализацией, которая очень похожа на вашу. Недостаток использования (1 << e) заключается в том, что если e выходит за пределы размера under_type, то это, вероятно, UB, надеюсь, ошибка компилятора.
Джеймс

7

Начиная с C ++ 11, вы также можете использовать классическую технику TMP:

template<std::uint64_t Flag, std::uint64_t... Flags>
struct bitmask
{
    static constexpr std::uint64_t mask = 
        bitmask<Flag>::value | bitmask<Flags...>::value;
};

template<std::uint64_t Flag>
struct bitmask<Flag>
{
    static constexpr std::uint64_t value = (uint64_t)1 << Flag;
};

void apply_known_mask(std::bitset<64> &bits) 
{
    constexpr auto mask = bitmask<B, D, E, H, K, M, L, O>::value;
    bits &= mask;
}

Ссылка на Compiler Explorer: https://godbolt.org/z/Gk6KX1

Преимущество этого подхода перед функцией шаблона constexpr заключается в том, что он потенциально немного быстрее компилируется из-за правила Chiel .


1

Здесь есть несколько далеких от "умных" идей. Вы, вероятно, не улучшите ремонтопригодность, следуя им.

является

{B, D, E, H, K, M, L, O};

намного проще написать чем

(B| D| E| H| K| M| L| O);

?

Тогда никакой остальной код не нужен.


1
«B», «D» и т. Д. Сами по себе не являются флагами.
Michał Łoś

Да, вам нужно сначала преобразовать их во флаги. В моем ответе это совсем не ясно. Прости. Буду обновлять.
ANone
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.