Come si scrive una maschera di bit in fase di compilazione, veloce e gestibile in C ++?


113

Ho un codice che è più o meno così:

#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 fa la cosa intelligente e la compila in una singola andistruzione (che poi viene inline ovunque):

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

Ma ogni versione di GCC che ho provato compila questo in un enorme pasticcio che include la gestione degli errori che dovrebbe essere staticamente DCE. In un altro codice, posizionerà anche l' important_bitsequivalente come dati in linea con il codice!

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

Come devo scrivere questo codice in modo che entrambi i compilatori possano fare la cosa giusta? In caso contrario, come dovrei scriverlo in modo che rimanga chiaro, veloce e gestibile?


4
Invece di usare un loop, non puoi costruire una maschera con B | D | E | ... | O?
HolyBlackCat

6
L'enumerazione ha posizioni bit piuttosto che bit già espansi, quindi potrei farlo(1ULL << B) | ... | (1ULL << O)
Alex Reinking,

3
Lo svantaggio è che i nomi effettivi sono lunghi e irregolari e non è così facile vedere quali bandiere sono nella maschera con tutto quel rumore di linea.
Alex Reinking

4
@AlexReinking Potresti farne uno (1ULL << Constant)| per riga e allineare i nomi delle costanti sulle diverse righe, che sarebbe più facile per gli occhi.
einpoklum

Penso che il problema qui sia correlato alla mancanza di utilizzo del tipo non firmato, GCC ha sempre avuto problemi con l'eliminazione statica della correzione per l'overflow e la conversione del tipo in ibridi con segno / senza segno.Il risultato dello spostamento di bit qui è il intrisultato dell'operazione di bit PU ESSERE intO può long longdipendere dal valore e formalmente enumnon è un equivalente a una intcostante. clang chiama "come se", gcc rimane pedante
Swift - Friday Pie

Risposte:


112

La versione migliore è :

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

Poi

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

di nuovo dentro , possiamo fare questo strano trucco:

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

o, se siamo bloccati con , possiamo risolverlo ricorsivamente:

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 con tutti e 3 : puoi cambiare la definizione di CPP_VERSION e ottenere un assemblaggio identico.

In pratica userei il più moderno possibile. 14 batte 11 perché non abbiamo la ricorsione e quindi la lunghezza del simbolo O (n ^ 2) (che può far esplodere il tempo di compilazione e l'utilizzo della memoria del compilatore); 17 batte 14 perché il compilatore non deve eliminare in codice morto quell'array, e quel trucco dell'array è semplicemente brutto.

Di questi 14 è il più confuso. Qui creiamo un array anonimo di tutti gli 0, nel frattempo come effetto collaterale costruiamo il nostro risultato, quindi scartiamo l'array. L'array scartato ha un numero di 0 uguale alla dimensione del nostro pacchetto, più 1 (che aggiungiamo in modo da poter gestire i pacchetti vuoti).


Una spiegazione dettagliata di ciò che il versione sta facendo. Questo è un trucco / hack, e il fatto che tu debba farlo per espandere i pacchetti di parametri con efficienza in C ++ 14 è uno dei motivi per cui le espressioni di piegatura sono state aggiunte.

È meglio compreso dall'interno verso l'esterno:

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

questo si aggiorna solo rcon 1<<indexesper un indice fisso. indexesè un pacchetto di parametri, quindi dovremo espanderlo.

Il resto del lavoro consiste nel fornire un pacchetto di parametri da espandere indexesall'interno.

Un passo fuori:

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

qui eseguiamo il cast della nostra espressione void, indicando che non ci interessa il suo valore di ritorno (vogliamo solo l'effetto collaterale dell'impostazione r- in C ++, espressioni come a |= brestituiscono anche il valore che hanno impostatoa ).

Quindi usiamo l'operatore virgola ,e 0per scartare il void"valore" e restituire il valore 0. Quindi questa è un'espressione il cui valore è 0e come effetto collaterale del calcolo 0si imposta un po ' r.

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

A questo punto, espandiamo il pacchetto di parametri indexes. Quindi otteniamo:

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

nel {}. Questo uso di non, è l'operatore virgola, ma piuttosto il separatore di elementi dell'array. Questo è s, che imposta anche i bit come effetto collaterale. Quindi assegniamo le istruzioni di costruzione dell'array a un arraysizeof...(indexes)+1 0r{}discard .

Avanti abbiamo gettato discardal void- la maggior parte dei compilatori avviserà se si crea una variabile e non leggerlo. Tutti i compilatori non si lamenteranno se lo invii a void, è una specie di modo per dire "Sì, lo so, non lo sto usando", quindi sopprime l'avviso.


38
Mi dispiace, ma quel codice C ++ 14 è qualcosa. Non so cosa
James,

14
@ James È un meraviglioso esempio motivante del motivo per cui le espressioni fold in C ++ 17 sono molto gradite. Questo, e altri trucchi simili, risultano essere un modo efficiente per espandere un pacchetto "al posto" senza alcuna ricorsione e che i compilatori trovano facile da ottimizzare.
Yakk - Adam Nevraumont

4
@ruben multi line constexpr è illegale in 11
Yakk - Adam Nevraumont

6
Non riesco a vedere me stesso controllando quel codice C ++ 14. Mi atterrò a quello C ++ 11 poiché ne ho bisogno, comunque, ma anche se potessi usarlo, il codice C ++ 14 richiede così tante spiegazioni che non lo farei. Queste maschere possono sempre essere scritte per avere al massimo 32 elementi, quindi non sono preoccupato per il comportamento O (n ^ 2). Dopo tutto, se n è limitato da una costante, allora è in realtà O (1). ;)
Alex Reinking

9
Per coloro che cercano di capirlo ((1ull<<indexes)|...|0ull)è una "espressione piegata" . Nello specifico si tratta di una "piega destra binaria" e dovrebbe essere analizzata come(pack op ... op init)
Henrik Hansen

47

L'ottimizzazione che stai cercando sembra essere il peeling del ciclo, che è abilitato su -O3o manualmente con -fpeel-loops. Non sono sicuro del motivo per cui questo rientri nell'ambito del peeling del loop piuttosto che dello srotolamento del loop, ma forse non è disposto a srotolare un loop con un flusso di controllo non locale al suo interno (come, potenzialmente, dal controllo dell'intervallo).

Per impostazione predefinita, tuttavia, GCC non è in grado di eliminare tutte le iterazioni, il che apparentemente è necessario. Sperimentalmente, il passaggio -O2 -fpeel-loops --param max-peeled-insns=200(il valore predefinito è 100) porta a termine il lavoro con il codice originale: https://godbolt.org/z/NNWrga


Sei fantastico grazie! Non avevo idea che fosse configurabile in GCC! Anche se per qualche motivo -O3 -fpeel-loops --param max-peeled-insns=200fallisce ... È dovuto a -ftree-slp-vectorizequanto pare.
Alex Reinking

Questa soluzione sembra essere limitata al target x86-64. L'output per ARM e ARM64 non è ancora abbastanza, il che potrebbe essere completamente irrilevante per OP.
tempo reale

@realtime - è piuttosto rilevante, in realtà. Grazie per aver sottolineato che in questo caso non funziona. Molto deludente che GCC non lo rilevi prima di essere abbassato a un IR specifico della piattaforma. LLVM lo ottimizza prima di qualsiasi ulteriore abbassamento.
Alex Reinking

10

se usare solo C ++ 11 è un must (&a)[N]è un modo per catturare gli array. Ciò ti consente di scrivere una singola funzione ricorsiva senza utilizzare alcuna funzione di supporto:

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

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

Test

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

Produzione

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

si deve davvero apprezzare la capacità di C ++ di calcolare qualsiasi cosa possa essere calcolata in fase di compilazione. Sicuramente mi fa ancora impazzire ( <> ).


Per le versioni successive C ++ 14 e C ++ 17 la risposta di yakk lo copre già meravigliosamente.


3
In che modo questo dimostra che apply_known_maskottimizza effettivamente?
Alex Reinking

2
@AlexReinking: Tutti i pezzi spaventosi lo sono constexpr. E anche se teoricamente non è sufficiente, sappiamo che GCC è perfettamente in grado di valutare constexprcome previsto.
MSalters

8

Ti incoraggerei a scrivere un EnumSettipo corretto .

Scrivere un basic EnumSet<E>in C ++ 14 (in poi) basato su std::uint64_tè banale:

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

Questo ti permette di scrivere codice semplice:

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

    flags &= IMPORTANT;
}

In C ++ 11, richiede alcune convoluzioni, ma rimane comunque possibile:

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

Ed è invocato con:

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

Anche GCC genera banalmente andun'istruzione a -O1 godbolt :

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

2
In c ++ 11 gran parte del constexprcodice non è legale. Voglio dire, alcuni hanno 2 affermazioni! (C ++ 11 constexpr succhiato)
Yakk - Adam Nevraumont,

@ Yakk-AdamNevraumont: Ti sei reso conto che ho pubblicato 2 versioni del codice, la prima per C ++ 14 in poi e una seconda appositamente studiata per C ++ 11? (per tenere conto dei suoi limiti)
Matthieu M.

1
Potrebbe essere meglio usare std :: sottostante_type invece di std :: uint64_t.
James,

@ James: In realtà, no. Si noti che EnumSet<E>non utilizza direttamente un valore Eas value, ma utilizza invece 1 << e. È un dominio completamente diverso, che in realtà è ciò che rende la classe così preziosa => nessuna possibilità di indicizzazione accidentale di einvece di 1 << e.
Matthieu M.

@MatthieuM. Sì hai ragione. Lo confondo con la nostra implementazione che è molto simile alla tua. Lo svantaggio dell'utilizzo di (1 << e) è che se e è fuori dai limiti per la dimensione del sottostante_type, allora è probabilmente UB, si spera un errore del compilatore.
James,

7

A partire da C ++ 11 potresti anche usare la classica tecnica 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;
}

Collegamento a Compiler Explorer: https://godbolt.org/z/Gk6KX1

Il vantaggio di questo approccio rispetto alla funzione constexpr del modello è che è potenzialmente leggermente più veloce da compilare a causa della regola di Chiel .


1

Ci sono alcune idee ancora più "intelligenti" qui. Probabilmente non stai aiutando la manutenibilità seguendoli.

è

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

molto più facile da scrivere rispetto a

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

?

Quindi non è necessario il resto del codice.


1
"B", "D", ecc. Non sono flag stessi.
Michał Łoś,

Sì, dovresti prima trasformarli in flag. Non è affatto chiaro nella mia risposta. spiacente. Aggiornerò
ANone il
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.