web-dev-qa-db-fra.com

Quelles sont les performances de std :: bitset?

J'ai récemment posé une question sur Programmeurs concernant les raisons d'utiliser la manipulation manuelle des bits des types primitifs sur std::bitset.

De cette discussion, j'ai conclu que la principale raison est sa performance relativement moins bonne, bien que je ne sois au courant d'aucune base mesurée pour cette opinion. La prochaine question est donc:

ce qui est les performances, le cas échéant, susceptibles d'être encourues en utilisant std::bitset sur la manipulation de bits d'une primitive?

La question est intentionnellement large, car après avoir cherché en ligne, je n'ai rien trouvé, donc je vais prendre ce que je peux obtenir. Fondamentalement, je recherche une ressource qui fournit un profilage de std::bitset vs "pré-bitset" alternatives aux mêmes problèmes sur certaines architectures de machines courantes utilisant GCC, Clang et/ou VC++. Il existe un article très complet qui tente de répondre à cette question pour les vecteurs de bits:

http://www.cs.up.ac.za/cs/vpieterse/pub/PieterseEtAl_SAICSIT2010.pdf

Malheureusement, il est antérieur ou considéré comme hors de portée std::bitset, il se concentre donc plutôt sur les implémentations de vecteurs/tableaux dynamiques.

Je veux vraiment savoir si std::bitset est mieux que les alternatives aux cas d'utilisation qu'il est censé résoudre. Je sais déjà que c'est plus facile et plus clair que de jouer du bit sur un entier, mais est-ce que rapide?

35
quant

Mise à jour

Cela fait longtemps que je n'ai pas posté celui-ci, mais:

Je sais déjà que c'est plus facile et plus clair que de tripoter des bits sur un entier, mais est-ce aussi rapide?

Si vous utilisez bitset d'une manière qui le rend plus clair et plus propre que le bit fiddling, comme la vérification d'un bit à la fois au lieu d'utiliser un masque de bits, alors vous perdez inévitablement tous ces avantages qui au niveau du bit les opérations fournissent, comme être en mesure de vérifier si 64 bits sont définis en même temps par rapport à un masque, ou utiliser des instructions FFS pour déterminer rapidement quel bit est défini parmi 64 bits.

Je ne suis pas sûr que bitset entraîne une pénalité à utiliser de toutes les manières possibles (ex: en utilisant son bit à bit operator&), mais si vous l'utilisez comme un tableau booléen de taille fixe qui est à peu près la façon dont je vois toujours les gens l'utiliser, alors vous perdez généralement tous les avantages décrits ci-dessus. Nous ne pouvons malheureusement pas obtenir ce niveau d'expressivité en accédant simplement un bit à la fois avec operator[] et demander à l'optimiseur de comprendre toutes les manipulations au niveau du bit et FFS et FFZ et ainsi de suite qui se passent pour nous, du moins pas depuis la dernière fois que j'ai vérifié (sinon bitset serait l'une de mes structures préférées).

Maintenant, si vous allez utiliser bitset<N> bits interchangeable avec comme, disons, uint64_t bits[N/64] comme pour accéder aux deux de la même manière en utilisant des opérations au niveau du bit, cela pourrait être au pair (pas vérifié depuis cet ancien article). Mais vous perdez ensuite de nombreux avantages de l'utilisation de bitset en premier lieu.

for_each méthode

Dans le passé, je suis tombé sur des malentendus, je pense, quand j'ai proposé un for_each méthode pour parcourir des choses comme vector<bool>, deque et bitset. Le point d'une telle méthode est d'utiliser la connaissance interne du conteneur pour parcourir plus efficacement les éléments tout en invoquant un foncteur, tout comme certains conteneurs associatifs offrent leur propre méthode find au lieu d'utiliser std::find pour faire une recherche meilleure que linéaire.

Par exemple, vous pouvez parcourir tous les bits définis d'un vector<bool> ou bitset si vous aviez une connaissance interne de ces conteneurs en vérifiant 64 éléments à la fois à l'aide d'un masque 64 bits lorsque 64 index contigus sont occupés, et utilisez également les instructions FFS lorsque ce n'est pas le cas.

Mais une conception d'itérateur devant faire ce type de logique scalaire dans operator++ devrait inévitablement faire quelque chose de beaucoup plus cher, de par la nature même de la conception des itérateurs dans ces cas particuliers. bitset manque d'itérateurs et cela pousse souvent les gens à vouloir l'utiliser pour éviter d'avoir à utiliser la logique au niveau du bit à utiliser operator[] pour vérifier chaque bit individuellement dans une boucle séquentielle qui veut juste savoir quels bits sont définis. Cela aussi n'est pas aussi efficace que ce qu'un for_each l'implémentation de la méthode pourrait faire l'affaire.

Itérateurs doubles/imbriqués

Une autre alternative au for_each la méthode spécifique au conteneur proposée ci-dessus consisterait à utiliser des itérateurs doubles/imbriqués: c'est-à-dire un itérateur externe qui pointe vers une sous-plage d'un type d'itérateur différent. Exemple de code client:

for (auto outer_it = bitset.nbegin(); outer_it != bitset.nend(); ++outer_it)
{
     for (auto inner_it = outer_it->first; inner_it != outer_it->last; ++inner_it)
          // do something with *inner_it (bit index)
}

Bien que non conforme au type plat de conception d'itérateur actuellement disponible dans des conteneurs standard, cela peut permettre des optimisations très intéressantes. Par exemple, imaginez un cas comme celui-ci:

bitset<64> bits = 0x1fbf; // 0b1111110111111;

Dans ce cas, l'itérateur externe peut, avec seulement quelques itérations au niveau du bit ((FFZ/ou/complément), déduire que la première plage de bits à traiter serait les bits [0, 6), moment auquel nous pouvons parcourir cette sous-plage très bon marché via l'itérateur interne/imbriqué (il incrémenterait simplement un entier, faisant ++inner_it équivalent à juste ++int). Ensuite, lorsque nous incrémentons l'itérateur externe, il peut alors très rapidement, et à nouveau avec quelques instructions au niveau du bit, déterminer que la plage suivante serait [7, 13). Après avoir parcouru cette sous-plage, nous avons terminé. Prenez cela comme un autre exemple:

bitset<16> bits = 0xffff;

Dans un tel cas, le premier et le dernier sous-intervalle seraient [0, 16), et le jeu de bits pourrait déterminer qu'avec une seule instruction au niveau du bit, nous pouvons parcourir tous les bits définis, puis nous avons terminé.

Ce type de conception d'itérateur imbriqué correspondrait particulièrement bien à vector<bool>, deque et bitset ainsi que d'autres structures de données que les gens peuvent créer comme des listes déroulées.

Je dis cela d'une manière qui va au-delà de la spéculation sur le fauteuil, car j'ai un ensemble de structures de données qui ressemblent à des goûts de deque qui sont en fait à égalité avec l'itération séquentielle de vector (toujours sensiblement plus lent pour l'accès aléatoire, surtout si nous stockons juste un tas de primitives et faisons un traitement trivial). Cependant, pour atteindre des temps comparables à vector pour l'itération séquentielle, j'ai dû utiliser ces types de techniques (for_each méthode et itérateurs doubles/imbriqués) pour réduire la quantité de traitement et de branchement en cours à chaque itération. Je ne pourrais pas rivaliser avec le temps autrement en utilisant seulement la conception de l'itérateur plat et/ou operator[]. Et je ne suis certainement pas plus intelligent que les implémenteurs de bibliothèque standard, mais j'ai trouvé un conteneur de type deque qui peut être itéré séquentiellement beaucoup plus rapidement, et cela me suggère fortement que c'est un problème avec la conception d'interface standard de itérateurs dans ce cas qui viennent avec une surcharge dans ces cas particuliers que l'optimiseur ne peut pas optimiser loin.

Ancienne réponse

Je suis de ceux qui vous donneraient une réponse de performance similaire, mais je vais essayer de vous donner quelque chose d'un peu plus en profondeur que "just because". C'est quelque chose que j'ai rencontré à travers un profilage et un timing réels, pas seulement de la méfiance et de la paranoïa.

L'un des plus gros problèmes avec bitset et vector<bool> est que la conception de leur interface est "trop ​​pratique" si vous voulez les utiliser comme un tableau de booléens. Les optimiseurs sont parfaits pour effacer toute la structure que vous établissez pour assurer la sécurité, réduire les coûts de maintenance, rendre les modifications moins intrusives, etc. Ils font un travail particulièrement fin en sélectionnant les instructions et en allouant le nombre minimal de registres pour que ce code s'exécute aussi rapidement que le alternatives pas si sûres, pas si faciles à entretenir/changer.

La partie qui rend l'interface de jeu de bits "trop pratique" au détriment de l'efficacité est l'accès aléatoire operator[] ainsi que la conception de l'itérateur pour vector<bool>. Lorsque vous accédez à l'un de ceux-ci à l'index n, le code doit d'abord déterminer à quel octet appartient le nième bit, puis le sous-index du bit qu'il contient. Cette première phase implique généralement une division/rshifts par rapport à une valeur l avec modulo/bitwise et ce qui est plus coûteux que l'opération de bit réelle que vous essayez d'effectuer.

La conception de l'itérateur pour vector<bool> fait face à un dilemme gênant similaire où il doit soit se ramifier dans un code différent toutes les 8+ fois que vous le parcourez, soit payer le type de coût d'indexation décrit ci-dessus. Si le premier est fait, cela rend la logique asymétrique entre les itérations, et les conceptions d'itérateurs ont tendance à prendre un coup de performance dans ces rares cas. Par exemple, si vector avait un for_each propre méthode, vous pouvez parcourir, disons, une plage de 64 éléments à la fois en masquant simplement les bits contre un masque 64 bits pour vector<bool> si tous les bits sont définis sans vérifier chaque bit individuellement. Il pourrait même utiliser FFS pour déterminer la plage d'un seul coup. Une conception d'itérateur aurait tendance inévitablement à le faire de manière scalaire ou à stocker plus d'états, ce qui doit être vérifié de manière redondante à chaque itération.

Pour un accès aléatoire, les optimiseurs ne semblent pas optimiser cette surcharge d'indexation pour déterminer quel octet et quel bit relatif accéder (peut-être un peu trop en fonction de l'exécution) lorsqu'il n'est pas nécessaire, et vous avez tendance à voir des gains de performances significatifs avec cela de plus bits de traitement de code manuel séquentiellement avec une connaissance avancée de l'octet/mot/dword/qword sur lequel il travaille. C'est en quelque sorte une comparaison injuste, mais la difficulté avec std::bitset est qu'il n'y a aucun moyen de faire une comparaison équitable dans les cas où le code sait à quel octet il veut accéder à l'avance, et le plus souvent, vous avez tendance à avoir ces informations à l'avance. C'est une comparaison de pommes à orange dans le cas d'accès aléatoire, mais vous n'avez souvent besoin que d'oranges.

Ce ne serait peut-être pas le cas si la conception de l'interface impliquait un bitsetoperator[] a renvoyé un proxy, nécessitant l'utilisation d'un modèle d'accès à deux index. Par exemple, dans un tel cas, vous accéderez au bit 8 en écrivant bitset[0][6] = true; bitset[0][7] = true; avec un paramètre de modèle pour indiquer la taille du proxy (64 bits, par exemple). Un bon optimiseur peut être capable de prendre une telle conception et de la faire rivaliser avec la manière manuelle et traditionnelle de faire la manipulation des bits à la main en traduisant cela en: bitset |= 0x60;

Une autre conception qui pourrait aider serait si bitsets fournissait un for_each_bit type de méthode, en passant un peu de proxy au foncteur que vous fournissez. Cela pourrait en fait rivaliser avec la méthode manuelle.

std::deque a un problème d'interface similaire. Ses performances ne devraient pas être ça beaucoup plus lentes que std::vector pour un accès séquentiel. Malheureusement, nous y accédons séquentiellement en utilisant operator[] qui est conçu pour un accès aléatoire ou via un itérateur, et le représentant interne de deques ne correspond tout simplement pas très efficacement à une conception basée sur un itérateur. Si deque a fourni un for_each une sorte de méthode qui lui est propre, alors là, elle pourrait potentiellement commencer à se rapprocher beaucoup plus de std::vector's performances d'accès séquentiel. Ce sont quelques-uns des rares cas où cette conception d'interface de séquence est accompagnée d'une surcharge d'efficacité que les optimiseurs ne peuvent souvent pas effacer. Souvent, de bons optimiseurs peuvent rendre la commodité sans coût d'exécution dans une construction de production, mais malheureusement pas dans tous les cas.

Désolé!

Aussi désolé, rétrospectivement, j'ai erré un peu avec ce post en parlant de vector<bool> et deque en plus de bitset. C'est parce que nous avions une base de code où l'utilisation de ces trois, et en particulier leur itération ou leur utilisation avec un accès aléatoire, étaient souvent des points chauds.

Pommes aux oranges

Comme souligné dans l'ancienne réponse, comparer l'utilisation simple de bitset à des types primitifs avec une logique binaire de bas niveau revient à comparer des pommes à des oranges. Ce n'est pas comme si bitset est implémenté de manière très inefficace pour ce qu'il fait. Si vous avez vraiment besoin d'accéder à un tas de bits avec un modèle d'accès aléatoire qui, pour une raison ou une autre, doit vérifier et définir un seul bit à la fois, alors il pourrait être idéalement mis en œuvre à cette fin. Mais mon point est que presque tous les cas d'utilisation que j'ai rencontrés ne l'exigeaient pas, et quand ce n'est pas nécessaire, la méthode à l'ancienne impliquant des opérations au niveau du bit a tendance à être beaucoup plus efficace.

23
Dragon Energy

A fait un court test de profilage des tableaux std :: bitset vs bool pour un accès séquentiel et aléatoire - vous pouvez aussi:

#include <iostream>
#include <bitset>
#include <cstdlib> // Rand
#include <ctime> // timer

inline unsigned long get_time_in_ms()
{
    return (unsigned long)((double(clock()) / CLOCKS_PER_SEC) * 1000);
}


void one_sec_delay()
{
    unsigned long end_time = get_time_in_ms() + 1000;

    while(get_time_in_ms() < end_time)
    {
    }
}



int main(int argc, char **argv)
{
    srand(get_time_in_ms());

    using namespace std;

    bitset<5000000> bits;
    bool *bools = new bool[5000000];

    unsigned long current_time, difference1, difference2;
    double total;

    one_sec_delay();

    total = 0;
    current_time = get_time_in_ms();

    for (unsigned int num = 0; num != 200000000; ++num)
    {
        bools[Rand() % 5000000] = Rand() % 2;
    }

    difference1 = get_time_in_ms() - current_time;
    current_time = get_time_in_ms();

    for (unsigned int num2 = 0; num2 != 100; ++num2)
    {
        for (unsigned int num = 0; num != 5000000; ++num)
        {
            total += bools[num];
        }
    }   

    difference2 = get_time_in_ms() - current_time;

    cout << "Bool:" << endl << "sum total = " << total << ", random access time = " << difference1 << ", sequential access time = " << difference2 << endl << endl;


    one_sec_delay();

    total = 0;
    current_time = get_time_in_ms();

    for (unsigned int num = 0; num != 200000000; ++num)
    {
        bits[Rand() % 5000000] = Rand() % 2;
    }

    difference1 = get_time_in_ms() - current_time;
    current_time = get_time_in_ms();

    for (unsigned int num2 = 0; num2 != 100; ++num2)
    {
        for (unsigned int num = 0; num != 5000000; ++num)
        {
            total += bits[num];
        }
    }   

    difference2 = get_time_in_ms() - current_time;

    cout << "Bitset:" << endl << "sum total = " << total << ", random access time = " << difference1 << ", sequential access time = " << difference2 << endl << endl;

    delete [] bools;

    cin.get();

    return 0;
}

Veuillez noter: la sortie de la somme totale est nécessaire pour que le compilateur n'optimise pas la boucle for - ce que certains font si le résultat de la boucle n'est pas utilisé.

Sous GCC x64 avec les drapeaux suivants: -O2; -Wall; -march = native; -fomit-frame-pointer; -std = c ++ 11; J'obtiens les résultats suivants:

Tableau de booléens: temps d'accès aléatoire = 4695, temps d'accès séquentiel = 390

Bitset: temps d'accès aléatoire = 5382, temps d'accès séquentiel = 749

12
metamorphosis

En plus de ce que les autres réponses ont dit sur les performances de l'accès, il peut également y avoir une surcharge d'espace importante: Typique bitset<> les implémentations utilisent simplement le type entier le plus long pour sauvegarder leurs bits. Ainsi, le code suivant

#include <bitset>
#include <stdio.h>

struct Bitfield {
    unsigned char a:1, b:1, c:1, d:1, e:1, f:1, g:1, h:1;
};

struct Bitset {
    std::bitset<8> bits;
};

int main() {
    printf("sizeof(Bitfield) = %zd\n", sizeof(Bitfield));
    printf("sizeof(Bitset) = %zd\n", sizeof(Bitset));
    printf("sizeof(std::bitset<1>) = %zd\n", sizeof(std::bitset<1>));
}

produit la sortie suivante sur ma machine:

sizeof(Bitfield) = 1
sizeof(Bitset) = 8
sizeof(std::bitset<1>) = 8

Comme vous le voyez, mon compilateur alloue un énorme 64 bits pour stocker un seul, avec l'approche bitfield, je n'ai besoin d'arrondir que jusqu'à huit bits.

Ce facteur huit dans l'utilisation de l'espace peut devenir important si vous avez beaucoup de petits ensembles de bits.

4
cmaster

Question rhétorique: pourquoi std::bitset est écrit de cette manière inefficace? Réponse: non.

Une autre question rhétorique: Quelle est la différence entre:

std::bitset<128> a = src;
a[i] = true;
a = a << 64;

et

std::bitset<129> a = src;
a[i] = true;
a = a << 63;

Réponse: 50 fois la différence de performances http://quick-bench.com/iRokweQ6JqF2Il-T-9JSmR0bdyw

Vous devez faire très attention à ce que vous demandez, bitset supporte beaucoup de choses mais chacune a son propre coût. Avec une manipulation correcte, vous aurez exactement le même comportement que le code brut:

void f(std::bitset<64>& b, int i)
{
    b |= 1L << i;
    b = b << 15;
}
void f(unsigned long& b, int i)
{
    b |= 1L << i;
    b = b << 15;
}

Les deux génèrent le même assemblage: https://godbolt.org/g/PUUUyd (64 bits GCC)

Une autre chose est que bitset est plus portable mais cela a aussi coûté:

void h(std::bitset<64>& b, unsigned i)
{
    b = b << i;
}
void h(unsigned long& b, unsigned i)
{
    b = b << i;
}

Si i > 64 alors l'ensemble de bits sera nul et en cas de non signé nous aurons UB.

void h(std::bitset<64>& b, unsigned i)
{
    if (i < 64) b = b << i;
}
void h(unsigned long& b, unsigned i)
{
    if (i < 64) b = b << i;
}

Avec la vérification empêchant UB, les deux génèrent le même code.

Un autre endroit est set et [], le premier est sûr et signifie que vous n'obtiendrez jamais d'UB, mais cela vous coûtera une succursale. [] ont UB si vous utilisez une valeur erronée mais est aussi rapide que l'utilisation de var |= 1L<< i;. De corse si std::bitset n'a pas besoin d'avoir plus de bits que le plus grand int disponible sur le système car sinon vous avez besoin d'une valeur fractionnée pour obtenir l'élément correct dans la table interne. Cela signifie pour std::bitset<N> taille N est très important pour les performances. Si elle est plus grande ou plus petite qu'optimale, vous en paierez le coût.

Dans l'ensemble, je trouve que la meilleure façon est d'utiliser quelque chose comme ça:

constexpr size_t minBitSet = sizeof(std::bitset<1>)*8;

template<size_t N>
using fasterBitSet = std::bitset<minBitSet * ((N  + minBitSet - 1) / minBitSet)>;

Cela supprimera le coût de la coupe des bits dépassant: http://quick-bench.com/Di1tE0vyhFNQERvucAHLaOgucAY

3
Yankes

Pas une excellente réponse ici, mais plutôt une anecdote connexe:

Il y a quelques années, je travaillais sur des logiciels en temps réel et nous avons rencontré des problèmes de programmation. Il y avait un module qui dépassait largement le budget-temps, ce qui était très surprenant car le module n'était responsable que de certains mappage et emballage/décompression des bits dans/à partir de mots 32 bits.

Il s'est avéré que le module utilisait std :: bitset. Nous avons remplacé cela par des opérations manuelles et le temps d'exécution est passé de 3 millisecondes à 25 microsecondes. C'était un problème de performance important et une amélioration significative.

Le fait est que les problèmes de performances causés par cette classe peuvent être très réels.

3
Stewart