132 votes

Une optimisation pour l'accès aléatoire sur un très grand tableau lorsque la valeur dans 95% des cas est soit 0 soit 1 ?

Existe-t-il une optimisation possible pour l'accès aléatoire à un très grand tableau (j'utilise actuellement uint8_t et je demande ce qui est le mieux).

uint8_t MyArray[10000000];

lorsque la valeur à n'importe quelle position dans le tableau est

  • 0 o 1 para 95% de tous les cas,
  • 2 en 4% de cas,
  • entre 3 y 255 sur l'autre 1% de cas ?

Alors, y a-t-il quelque chose de mieux qu'un uint8_t tableau à utiliser pour cela ? Il doit être aussi rapide que possible de boucler sur l'ensemble du tableau dans un ordre aléatoire, et cela est très lourd sur la bande passante de la RAM, donc lorsque plusieurs threads font cela en même temps pour différents tableaux, la bande passante de la RAM est rapidement saturée.

Je pose la question car il semble très inefficace d'avoir un tableau aussi grand (10 Mo) alors que l'on sait que presque toutes les valeurs, à l'exception de 5 %, seront soit 0 soit 1. Ainsi, lorsque 95 % de toutes les valeurs du tableau n'ont besoin que d'un bit au lieu de 8 bits, l'utilisation de la mémoire est réduite d'un ordre de grandeur. J'ai l'impression qu'il doit y avoir une solution plus efficace en termes de mémoire qui réduirait considérablement la bande passante de la RAM nécessaire pour cela, et qui serait par conséquent beaucoup plus rapide pour les accès aléatoires.

155voto

Matteo Italia Points 53117

Une possibilité simple qui me vient à l'esprit est de conserver un tableau compressé de 2 bits par valeur pour les cas courants, et un tableau séparé de 4 octets par valeur (24 bits pour l'indice de l'élément d'origine, 8 bits pour la valeur réelle, soit (idx << 8) | value) ) trié pour les autres.

Lorsque vous recherchez une valeur, vous effectuez d'abord une recherche dans le tableau 2bpp (O(1)) ; si vous trouvez 0, 1 ou 2, c'est la valeur que vous voulez ; si vous trouvez 3, cela signifie que vous devez la rechercher dans le tableau secondaire. Ici, vous allez effectuer une recherche binaire pour trouver la valeur indice de votre intérêt décalé à gauche de 8 (O(log(n) avec un petit n, car cela devrait être le 1%), et extraire la valeur du bidule de 4 octets.

std::vector<uint8_t> main_arr;
std::vector<uint32_t> sec_arr;

uint8_t lookup(unsigned idx) {
    // extract the 2 bits of our interest from the main array
    uint8_t v = (main_arr[idx>>2]>>(2*(idx&3)))&3;
    // usual (likely) case: value between 0 and 2
    if(v != 3) return v;
    // bad case: lookup the index<<8 in the secondary array
    // lower_bound finds the first >=, so we don't need to mask out the value
    auto ptr = std::lower_bound(sec_arr.begin(), sec_arr.end(), idx<<8);
#ifdef _DEBUG
    // some coherency checks
    if(ptr == sec_arr.end()) std::abort();
    if((*ptr >> 8) != idx) std::abort();
#endif
    // extract our 8-bit value from the 32 bit (index, value) thingie
    return (*ptr) & 0xff;
}

void populate(uint8_t *source, size_t size) {
    main_arr.clear(); sec_arr.clear();
    // size the main storage (round up)
    main_arr.resize((size+3)/4);
    for(size_t idx = 0; idx < size; ++idx) {
        uint8_t in = source[idx];
        uint8_t &target = main_arr[idx>>2];
        // if the input doesn't fit, cap to 3 and put in secondary storage
        if(in >= 3) {
            // top 24 bits: index; low 8 bit: value
            sec_arr.push_back((idx << 8) | in);
            in = 3;
        }
        // store in the target according to the position
        target |= in << ((idx & 3)*2);
    }
}

Pour un tableau comme celui que vous proposez, cela devrait prendre 10000000 / 4 = 2500000 octets pour le premier tableau, plus 10000000 * 1% * 4 B = 400000 octets pour le second tableau ; donc 2900000 octets, c'est-à-dire moins d'un tiers du tableau original, et la partie la plus utilisée est conservée ensemble en mémoire, ce qui devrait être bon pour la mise en cache (cela peut même convenir à L3).

Si vous avez besoin de plus d'un adressage de 24 bits, vous devrez modifier le "stockage secondaire" ; une façon triviale de l'étendre est d'avoir un tableau de pointeurs de 256 éléments pour basculer les 8 premiers bits de l'index et les transmettre à un tableau trié indexé de 24 bits comme ci-dessus.


Comparaison rapide

#include <algorithm>
#include <vector>
#include <stdint.h>
#include <chrono>
#include <stdio.h>
#include <math.h>

using namespace std::chrono;

/// XorShift32 generator; extremely fast, 2^32-1 period, way better quality
/// than LCG but fail some test suites
struct XorShift32 {
    /// This stuff allows to use this class wherever a library function
    /// requires a UniformRandomBitGenerator (e.g. std::shuffle)
    typedef uint32_t result_type;
    static uint32_t min() { return 1; }
    static uint32_t max() { return uint32_t(-1); }

    /// PRNG state
    uint32_t y;

    /// Initializes with seed
    XorShift32(uint32_t seed = 0) : y(seed) {
        if(y == 0) y = 2463534242UL;
    }

    /// Returns a value in the range [1, 1<<32)
    uint32_t operator()() {
        y ^= (y<<13);
        y ^= (y>>17);
        y ^= (y<<15);
        return y;
    }

    /// Returns a value in the range [0, limit); this conforms to the RandomFunc
    /// requirements for std::random_shuffle
    uint32_t operator()(uint32_t limit) {
        return (*this)()%limit;
    }
};

struct mean_variance {
    double rmean = 0.;
    double rvariance = 0.;
    int count = 0;

    void operator()(double x) {
        ++count;
        double ormean = rmean;
        rmean     += (x-rmean)/count;
        rvariance += (x-ormean)*(x-rmean);
    }

    double mean()     const { return rmean; }
    double variance() const { return rvariance/(count-1); }
    double stddev()   const { return std::sqrt(variance()); }
};

std::vector<uint8_t> main_arr;
std::vector<uint32_t> sec_arr;

uint8_t lookup(unsigned idx) {
    // extract the 2 bits of our interest from the main array
    uint8_t v = (main_arr[idx>>2]>>(2*(idx&3)))&3;
    // usual (likely) case: value between 0 and 2
    if(v != 3) return v;
    // bad case: lookup the index<<8 in the secondary array
    // lower_bound finds the first >=, so we don't need to mask out the value
    auto ptr = std::lower_bound(sec_arr.begin(), sec_arr.end(), idx<<8);
#ifdef _DEBUG
    // some coherency checks
    if(ptr == sec_arr.end()) std::abort();
    if((*ptr >> 8) != idx) std::abort();
#endif
    // extract our 8-bit value from the 32 bit (index, value) thingie
    return (*ptr) & 0xff;
}

void populate(uint8_t *source, size_t size) {
    main_arr.clear(); sec_arr.clear();
    // size the main storage (round up)
    main_arr.resize((size+3)/4);
    for(size_t idx = 0; idx < size; ++idx) {
        uint8_t in = source[idx];
        uint8_t &target = main_arr[idx>>2];
        // if the input doesn't fit, cap to 3 and put in secondary storage
        if(in >= 3) {
            // top 24 bits: index; low 8 bit: value
            sec_arr.push_back((idx << 8) | in);
            in = 3;
        }
        // store in the target according to the position
        target |= in << ((idx & 3)*2);
    }
}

volatile unsigned out;

int main() {
    XorShift32 xs;
    std::vector<uint8_t> vec;
    int size = 10000000;
    for(int i = 0; i<size; ++i) {
        uint32_t v = xs();
        if(v < 1825361101)      v = 0; // 42.5%
        else if(v < 4080218931) v = 1; // 95.0%
        else if(v < 4252017623) v = 2; // 99.0%
        else {
            while((v & 0xff) < 3) v = xs();
        }
        vec.push_back(v);
    }
    populate(vec.data(), vec.size());
    mean_variance lk_t, arr_t;
    for(int i = 0; i<50; ++i) {
        {
            unsigned o = 0;
            auto beg = high_resolution_clock::now();
            for(int i = 0; i < size; ++i) {
                o += lookup(xs() % size);
            }
            out += o;
            int dur = (high_resolution_clock::now()-beg)/microseconds(1);
            fprintf(stderr, "lookup: %10d µs\n", dur);
            lk_t(dur);
        }
        {
            unsigned o = 0;
            auto beg = high_resolution_clock::now();
            for(int i = 0; i < size; ++i) {
                o += vec[xs() % size];
            }
            out += o;
            int dur = (high_resolution_clock::now()-beg)/microseconds(1);
            fprintf(stderr, "array:  %10d µs\n", dur);
            arr_t(dur);
        }
    }

    fprintf(stderr, " lookup |   ±  |  array  |   ±  | speedup\n");
    printf("%7.0f | %4.0f | %7.0f | %4.0f | %0.2f\n",
            lk_t.mean(), lk_t.stddev(),
            arr_t.mean(), arr_t.stddev(),
            arr_t.mean()/lk_t.mean());
    return 0;
}

(code et données toujours mis à jour dans mon Bitbucket)

Le code ci-dessus remplit un tableau de 10M éléments avec des données aléatoires distribuées comme OP l'a spécifié dans son post, initialise ma structure de données et ensuite :

  • effectue une recherche aléatoire de 10M éléments avec ma structure de données
  • fait la même chose à travers le tableau original.

(remarquez que dans le cas d'une recherche séquentielle, le tableau est toujours largement gagnant, car il s'agit de la recherche la plus respectueuse de la mémoire cache que vous puissiez faire)

Ces deux derniers blocs sont répétés 50 fois et chronométrés ; à la fin, la moyenne et l'écart-type pour chaque type de consultation sont calculés et imprimés, ainsi que la vitesse (moyenne de la consultation/moyenne du tableau).

J'ai compilé le code ci-dessus avec g++ 5.4.0 ( -O3 -static et quelques avertissements) sur Ubuntu 16.04, et je l'ai exécuté sur quelques machines ; la plupart d'entre elles fonctionnent sous Ubuntu 16.04, certaines sous des Linux plus anciens, d'autres sous des Linux plus récents. Je ne pense pas que le système d'exploitation devrait être pertinent dans ce cas.

            CPU           |  cache   |  lookup (µs)   |     array (µs)  | speedup (x)
Xeon E5-1650 v3 @ 3.50GHz | 15360 KB |  60011 ±  3667 |   29313 ±  2137 | 0.49
Xeon E5-2697 v3 @ 2.60GHz | 35840 KB |  66571 ±  7477 |   33197 ±  3619 | 0.50
Celeron G1610T  @ 2.30GHz |  2048 KB | 172090 ±   629 |  162328 ±   326 | 0.94
Core i3-3220T   @ 2.80GHz |  3072 KB | 111025 ±  5507 |  114415 ±  2528 | 1.03
Core i5-7200U   @ 2.50GHz |  3072 KB |  92447 ±  1494 |   95249 ±  1134 | 1.03
Xeon X3430      @ 2.40GHz |  8192 KB | 111303 ±   936 |  127647 ±  1503 | 1.15
Core i7 920     @ 2.67GHz |  8192 KB | 123161 ± 35113 |  156068 ± 45355 | 1.27
Xeon X5650      @ 2.67GHz | 12288 KB | 106015 ±  5364 |  140335 ±  6739 | 1.32
Core i7 870     @ 2.93GHz |  8192 KB |  77986 ±   429 |  106040 ±  1043 | 1.36
Core i7-6700    @ 3.40GHz |  8192 KB |  47854 ±   573 |   66893 ±  1367 | 1.40
Core i3-4150    @ 3.50GHz |  3072 KB |  76162 ±   983 |  113265 ±   239 | 1.49
Xeon X5650      @ 2.67GHz | 12288 KB | 101384 ±   796 |  152720 ±  2440 | 1.51
Core i7-3770T   @ 2.50GHz |  8192 KB |  69551 ±  1961 |  128929 ±  2631 | 1.85

Les résultats sont... mitigés !

  1. En général, sur la plupart de ces machines, il y a une sorte de gain de vitesse, ou du moins, elles sont sur un pied d'égalité.
  2. Les deux cas où le tableau l'emporte vraiment sur le lookup de la "structure intelligente" sont sur des machines avec beaucoup de cache et pas particulièrement occupées : le Xeon E5-1650 ci-dessus (15 Mo de cache) est une machine de construction de nuit, en ce moment plutôt inactive ; le Xeon E5-2697 (35 Mo de cache) est une machine pour les calculs de haute performance, dans un moment d'inactivité aussi. C'est logique, le tableau original tient entièrement dans leur énorme cache, donc la structure de données compacte ne fait qu'ajouter de la complexité.
  3. De l'autre côté du "spectre des performances" - mais là encore, la matrice est légèrement plus rapide - il y a l'humble Celeron qui alimente mon NAS ; il a si peu de cache que ni la matrice ni la "structure intelligente" n'y trouvent leur place. D'autres machines avec un cache suffisamment petit ont des performances similaires.
  4. Le Xeon X5650 doit être pris avec une certaine prudence - il s'agit de machines virtuelles sur un serveur de machines virtuelles à deux sockets très occupé ; il se peut que, bien qu'il dispose nominalement d'une quantité décente de mémoire cache, il ait été préempté plusieurs fois par des machines virtuelles sans aucun rapport pendant la durée du test.

33voto

6502 Points 42700

Une autre option pourrait être

  • vérifier si le résultat est 0, 1 ou 2
  • si non, faire une recherche régulière

En d'autres termes, quelque chose comme :

unsigned char lookup(int index) {
    int code = (bmap[index>>2]>>(2*(index&3)))&3;
    if (code != 3) return code;
    return full_array[index];
}

donde bmap utilise 2 bits par élément, la valeur 3 signifiant "autre".

Cette structure est triviale à mettre à jour, elle utilise 25% de mémoire en plus mais la partie la plus importante n'est consultée que dans 5% des cas. Bien sûr, comme d'habitude, si c'est une bonne idée ou non dépend de beaucoup d'autres conditions, donc la seule réponse est d'expérimenter avec une utilisation réelle.

23voto

Mats Petersson Points 70074

Il s'agit davantage d'un "long commentaire" que d'une réponse concrète.

À moins que vos données ne soient quelque chose de bien connu, je doute que quiconque puisse répondre DIRECTEMENT à votre question (et je ne connais rien qui corresponde à votre description, mais je ne connais pas TOUT sur toutes sortes de modèles de données pour toutes sortes de cas d'utilisation). Les données éparses sont un problème courant dans le calcul haute performance, mais il s'agit typiquement de "nous avons un très grand tableau, mais seules quelques valeurs sont différentes de zéro".

Pour les schémas peu connus comme le vôtre, personne ne SAURAIT directement lequel est le meilleur, et cela dépend des détails : quel est le caractère aléatoire de l'accès aléatoire - le système accède-t-il à des grappes d'éléments de données, ou est-ce complètement aléatoire comme à partir d'un générateur de nombres aléatoires uniformes. Les données de la table sont-elles complètement aléatoires, ou y a-t-il des séquences de 0 puis des séquences de 1, avec un éparpillement d'autres valeurs ? Le codage de la longueur d'exécution fonctionnerait bien si vous avez des séquences raisonnablement longues de 0 et de 1, mais ne fonctionnera pas si vous avez un "damier de 0/1". De plus, il faudrait tenir un tableau des "points de départ", afin de pouvoir se rendre assez rapidement à l'endroit voulu.

Je sais depuis longtemps que certaines grandes bases de données ne sont qu'une grande table dans la RAM (les données des abonnés du central téléphonique dans cet exemple), et l'un des problèmes est que les caches et les optimisations des tables de pages dans le processeur sont plutôt inutiles. L'appelant est si rarement le même que celui qui a récemment appelé quelqu'un, qu'il n'y a aucune donnée préchargée d'aucune sorte, c'est juste purement aléatoire. Les grandes tables de pages sont la meilleure optimisation pour ce type d'accès.

Dans de nombreux cas, le compromis entre "vitesse et petite taille" est l'une des choses que vous devez choisir en ingénierie logicielle [dans d'autres domaines, ce n'est pas nécessairement un compromis]. Ainsi, "gaspiller de la mémoire pour un code plus simple" est très souvent le choix préféré. Dans ce sens, la solution "simple" est probablement meilleure pour la vitesse, mais si vous avez une "meilleure" utilisation de la RAM, alors l'optimisation de la taille de la table vous donnera des performances suffisantes et une bonne amélioration de la taille. Il existe de nombreuses façons d'y parvenir - comme suggéré dans un commentaire, un champ de 2 bits où les deux ou trois valeurs les plus courantes sont stockées, puis un autre format de données pour les autres valeurs - une table de hachage serait ma première approche, mais une liste ou un arbre binaire peut également fonctionner - encore une fois, cela dépend des modèles où se trouvent vos "pas 0, 1 ou 2". Encore une fois, cela dépend de la façon dont les valeurs sont "dispersées" dans la table - sont-elles en grappes ou plutôt réparties de façon égale ?

Mais le problème est que vous lisez toujours les données depuis la RAM. Vous dépensez alors plus de code pour traiter les données, y compris du code pour faire face au "ceci n'est pas une valeur commune".

Le problème des algorithmes de compression les plus courants est qu'ils sont basés sur le déballage de séquences, et qu'il est donc impossible d'y accéder de manière aléatoire. Et les frais généraux liés à la division de vos données volumineuses en morceaux de, disons, 256 entrées à la fois, et à la décompression de ces 256 entrées dans un tableau uint8_t, à l'extraction des données que vous voulez, puis à la mise au rebut des données non compressées, sont très peu susceptibles de vous donner de bonnes performances - en supposant que cela ait une certaine importance, bien sûr.

En fin de compte, vous devrez probablement mettre en œuvre une ou quelques idées dans les commentaires/réponses pour les tester, voir si elles aident à résoudre votre problème, ou si le bus mémoire est toujours le principal facteur limitant.

13voto

o11c Points 1687

Ce que j'ai fait dans le passé est d'utiliser un hashmap dans avant d'un ensemble de bits.

Cela réduit de moitié l'espace nécessaire par rapport à la réponse de Matteo, mais peut être plus lent si la recherche des "exceptions" est lente (c'est-à-dire s'il y a beaucoup d'exceptions).

Souvent, cependant, "le cache est roi".

11voto

Jack Aidley Points 3993

À moins que vos données ne soient structurées, il est peu probable qu'il y ait une optimisation sensible de la vitesse ou de la taille, et - en supposant que vous vous adressiez à un ordinateur normal - 10 Mo ne sont pas si importants de toute façon.

Il y a deux hypothèses dans vos questions :

  1. Les données sont mal stockées parce que vous n'utilisez pas tous les bits.
  2. En le stockant mieux, les choses seraient plus rapides.

Je pense que ces deux hypothèses sont fausses. Dans la plupart des cas, la manière appropriée de stocker des données est de stocker la représentation la plus naturelle. Dans votre cas, c'est celle que vous avez choisie : un octet pour un nombre compris entre 0 et 255. Toute autre représentation sera plus complexe et donc - toutes choses égales par ailleurs - plus lente et plus sujette aux erreurs. Pour s'écarter de ce principe général, il faut une raison plus forte que six bits potentiellement "gaspillés" sur 95% de vos données.

Pour votre deuxième hypothèse, elle sera vraie si, et seulement si, la modification de la taille du tableau entraîne une réduction substantielle des manques dans le cache. Cela ne peut être déterminé de manière définitive qu'en profilant le code en cours, mais je pense qu'il est très peu probable que cela fasse une différence substantielle. Comme vous accéderez au tableau de façon aléatoire dans les deux cas, le processeur aura du mal à savoir quels bits de données mettre en cache et conserver dans les deux cas.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X