Como faço para escrever uma máscara de bits de tempo de compilação rápida e sustentável em C ++?

113

Eu tenho um código que é mais ou menos assim:

#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 faz a coisa certa e compila isso em uma única andinstrução (que então fica embutida em todos os outros lugares):

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

Mas cada versão do GCC que tentei compila isso em uma enorme bagunça que inclui tratamento de erros que devem ser estaticamente DCE. Em outro código, ele até colocará o important_bitsequivalente como dados em linha com o código!

.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*, ...)

Como devo escrever este código para que ambos os compiladores possam fazer a coisa certa? Caso contrário, como devo escrever isso para que permaneça claro, rápido e sustentável?

Alex Reinking
fonte
4
Em vez de usar um loop, você não pode construir uma máscara com B | D | E | ... | O?
HolyBlackCat de
6
O enum tem posições de bits em vez de bits já expandidos, então eu poderia fazer(1ULL << B) | ... | (1ULL << O)
Alex Reinking
3
A desvantagem é que os nomes reais são longos e irregulares e não é tão fácil ver quais sinalizadores estão na máscara com todo aquele ruído de linha.
Alex Reinking de
4
@AlexReinking Você poderia torná-lo um (1ULL << Constant)| por linha e alinhe os nomes das constantes nas diferentes linhas, isso seria mais fácil para os olhos.
einpoklum de
Acho que o problema aqui relacionado à falta de uso de tipo sem sinal, o GCC sempre teve problemas com correção de descarte estático para estouro e conversão de tipo em híbrido com / sem sinal. O resultado da mudança de bit aqui é intresultado da operação de bit PODE SER intOU pode ser long longdependendo do valor e formalmente enumnão é equivalente a uma intconstante. clang pede "como se", gcc continua pedante
Swift - Friday Pie

Respostas:

112

Melhor versão é :

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

Então

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

de volta , podemos fazer este truque estranho:

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;
}

ou, se estivermos presos com , podemos resolvê-lo recursivamente:

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 com todos os 3 - você pode mudar a definição CPP_VERSION e obter uma montagem idêntica.

Na prática, usaria o mais moderno que pudesse. 14 bate 11 porque não temos recursão e, portanto, comprimento de símbolo O (n ^ 2) (que pode explodir o tempo de compilação e o uso de memória do compilador); 17 supera 14 porque o compilador não precisa eliminar o código morto desse array, e esse truque do array é simplesmente feio.

Destes, 14 é o mais confuso. Aqui, criamos uma matriz anônima de 0s, enquanto isso, como efeito colateral, construímos nosso resultado e, em seguida, descartamos a matriz. O array descartado tem um número de 0s igual ao tamanho de nosso pacote, mais 1 (que adicionamos para que possamos lidar com pacotes vazios).


Uma explicação detalhada de como versão está fazendo. Este é um truque / hack, e o fato de você ter que fazer isso para expandir os pacotes de parâmetros com eficiência em C ++ 14 é uma das razões pelas quais expressões de dobra foram adicionadas.

É melhor compreendido de dentro para fora:

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

isso apenas atualiza rcom 1<<indexesum índice fixo. indexesé um pacote de parâmetros, então teremos que expandi-lo.

O resto do trabalho é fornecer um pacote de parâmetros para expandir indexesdentro dele.

Um passo para fora:

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

aqui, lançamos nossa expressão para void, indicando que não nos importamos com seu valor de retorno (queremos apenas o efeito colateral da configuração r- em C ++, expressões como a |= btambém retornam o valor definido a).

Em seguida, usamos o operador vírgula ,e 0para descartar o void"valor" e retornar o valor 0. Portanto, esta é uma expressão cujo valor é 0e, como efeito colateral do cálculo 0, define um bit em r.

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

Neste ponto, expandimos o pacote de parâmetros indexes. Então temos:

 {
    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),
  }

no {}. Este uso de não, é o operador vírgula, mas sim o separador de elementos da matriz. Isso é s, que também define bits como efeito colateral. Em seguida, atribuímos as instruções de construção da matriz a uma matriz .sizeof...(indexes)+1 0r{}discard

Em seguida, lançamos discardpara void- a maioria dos compiladores avisa se você cria uma variável e nunca a lê. Todos os compiladores não reclamarão se você lançar para void, é uma espécie de maneira de dizer "Sim, eu sei, não estou usando isso", então suprime o aviso.

Yakk - Adam Nevraumont
fonte
38
Desculpe, mas aquele código C ++ 14 é alguma coisa. Não sei o quê.
James de
14
@James É um exemplo motivador maravilhoso de por que expressões de dobra em C ++ 17 são muito bem-vindas. Ele, e outros truques semelhantes, acabam sendo uma maneira eficiente de expandir um pacote "no local" sem qualquer recursão e que os compiladores acham fácil de otimizar.
Yakk - Adam Nevraumont
4
@ruben multi line constexpr é ilegal em 11
Yakk - Adam Nevraumont
6
Não consigo me ver verificando esse código C ++ 14. Vou ficar com o C ++ 11 já que preciso dele, de qualquer maneira, mas mesmo se eu pudesse usá-lo, o código C ++ 14 requer tantas explicações que eu não faria. Essas máscaras sempre podem ser escritas para ter no máximo 32 elementos, então não estou preocupado com o comportamento de O (n ^ 2). Afinal, se n é limitado por uma constante, então é realmente O (1). ;)
Alex Reinking de
9
Para aqueles que estão tentando entender ((1ull<<indexes)|...|0ull), é uma "expressão dobrada" . Especificamente, é uma "dobra direita binária" e deve ser analisada como(pack op ... op init)
Henrik Hansen
47

A otimização que você está procurando parece ser o peeling de loop, que é ativado em -O3ou manualmente com -fpeel-loops. Não tenho certeza de por que isso cai no campo de aplicação de peeling de loop em vez de desenrolamento de loop, mas possivelmente não está disposto a desenrolar um loop com fluxo de controle não local dentro dele (como há, potencialmente, na verificação de intervalo).

Por padrão, entretanto, o GCC não consegue fazer o peeling de todas as iterações, o que aparentemente é necessário. Experimentalmente, a aprovação -O2 -fpeel-loops --param max-peeled-insns=200(o valor padrão é 100) faz o trabalho com seu código original: https://godbolt.org/z/NNWrga

Sneftel
fonte
Você é incrível, obrigado! Eu não tinha ideia de que isso era configurável no GCC! Embora por algum motivo -O3 -fpeel-loops --param max-peeled-insns=200falhe ... É devido ao -ftree-slp-vectorizeaparentemente.
Alex Reinking de
Esta solução parece estar limitada ao destino x86-64. A saída para ARM e ARM64 ainda não é bonita, o que pode ser completamente irrelevante para OP.
tempo real
@realtime - é algo relevante, na verdade. Obrigado por apontar que não funciona neste caso. Muito decepcionante que o GCC não perceba antes de ser reduzido a um IR específico da plataforma. O LLVM o otimiza antes de qualquer redução adicional.
Alex Reinking de
10

se usar apenas C ++ 11 (&a)[N]é uma forma de capturar arrays. Isso permite que você escreva uma única função recursiva sem usar funções auxiliares de qualquer espécie:

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;
}

atribuindo-o a um 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;
}

Teste

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

Resultado

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

realmente é preciso apreciar a capacidade do C ++ de calcular qualquer coisa que seja computável em tempo de compilação. Certamente ainda me impressiona ( <> ).


Para as versões posteriores C ++ 14 e C ++ 17, a resposta de yakk já cobre isso maravilhosamente.

Stack Danny
fonte
3
Como isso demonstra que apply_known_maskrealmente otimiza?
Alex Reinking de
2
@AlexReinking: Todas as partes assustadoras são constexpr. E embora isso não seja teoricamente suficiente, sabemos que o GCC é perfeitamente capaz de avaliar constexprcomo pretendido.
MSalters
8

Eu encorajaria você a escrever um EnumSettipo adequado .

Escrever um básico EnumSet<E>em C ++ 14 (em diante) baseado em std::uint64_té trivial:

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;
};

Isso permite que você escreva um código simples:

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

    flags &= IMPORTANT;
}

No C ++ 11, isso requer algumas convoluções, mas continua sendo possível:

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;
};

E é invocado com:

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;
}

Até mesmo o GCC gera trivialmente uma andinstrução em -O1 godbolt :

apply_known_mask(EnumSet<Flags>&):
        and     QWORD PTR [rdi], 775946532
        ret
Matthieu M.
fonte
2
Em c ++ 11, grande parte do seu constexprcódigo não é legal. Quer dizer, alguns têm 2 afirmações! (C ++ 11 constexpr sugado)
Yakk - Adam Nevraumont
@ Yakk-AdamNevraumont: Você percebeu que postei 2 versões do código, a primeira para C ++ 14 em diante e uma segunda especialmente adaptada para C ++ 11? (para dar conta de suas limitações)
Matthieu M.
1
Pode ser melhor usar std :: subjacente_type em vez de std :: uint64_t.
James de
@James: Na verdade, não. Observe que EnumSet<E>não usa um valor de Eas valor diretamente, mas em vez disso usa 1 << e. É um domínio totalmente diferente, o que é realmente o que torna a classe tão valiosa => nenhuma chance de indexar acidentalmente por em evez de 1 << e.
Matthieu M.
@MatthieuM. Sim, você está certo. Estou confundindo com nossa própria implementação, que é muito semelhante à sua. A desvantagem de usar (1 << e) é que se e estiver fora dos limites para o tamanho do under_type, então é provavelmente UB, espero que seja um erro do compilador.
James de
7

Desde C ++ 11, você também pode usar a técnica clássica de 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;
}

Link para o Compiler Explorer: https://godbolt.org/z/Gk6KX1

A vantagem dessa abordagem sobre a função constexpr do template é que ela é potencialmente um pouco mais rápida de compilar devido à regra de Chiel .

Michał Łoś
fonte
1

Existem algumas idéias muito "inteligentes" aqui. Você provavelmente não está ajudando na manutenção ao segui-los.

é

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

muito mais fácil de escrever do que

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

?

Então, nada do resto do código é necessário.

ANone
fonte
1
"B", "D", etc. não são sinalizadores em si.
Michał Łoś
Sim, você precisa primeiro transformá-los em sinalizadores. Isso não está claro em minha resposta. Desculpe. Eu vou atualizar.
ANone