web-dev-qa-db-fra.com

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

Existe-t-il une optimisation possible pour un accès aléatoire sur un très grand tableau (j'utilise actuellement uint8_t et je demande quelle est la meilleure solution)

uint8_t MyArray[10000000];

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

  • ou 1 pour 95% de tous les cas,
  • 2 dans 4% des cas,
  • entre et 255 dans l'autre 1% des cas?

Alors, y a-t-il quelque chose de mieux qu'un tableau uint8_t à utiliser pour cela? Il devrait être aussi rapide que possible de parcourir tout le tableau dans un ordre aléatoire, ce qui pèse lourdement sur la bande passante RAM. Ainsi, lorsque plusieurs threads le font simultanément pour différents tableaux, toute la bande passante RAM est rapidement saturée.

Je pose la question car il me semble très inefficace d’avoir un tableau aussi volumineux (10 Mo) alors que nous savons en fait que presque toutes les valeurs, à l’exception de 5%, seront 0 ou 1. Donc, lorsque 95% de toutes les valeurs du tableau n’aurait réellement besoin que d’un bit au lieu de 8, cela réduirait l’utilisation de la mémoire de presque un ordre de grandeur. Il semble nécessaire de mettre en place une solution plus efficace en termes de mémoire, qui réduirait considérablement la bande passante requise RAM à cette fin et, par conséquent, serait également nettement plus rapide pour les accès aléatoires.

132
JohnAl

Une possibilité simple qui me vient à l’esprit est de conserver un tableau compressé de 2 bits par valeur pour les cas courants et d’un octet séparé de 4 octets par valeur (24 bits pour l’index d’élément d’origine, 8 bits pour la valeur réelle, donc (idx << 8) | value) ) tableau 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 le rechercher dans le tableau secondaire. Ici, vous effectuerez une recherche binaire pour rechercher l’indice de votre intérêt décalé à gauche de 8 (O (log (n)) avec un petit n , comme cela devrait être le 1%), et extraire la valeur de la chose 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 tel que celui que vous avez proposé, cela devrait prendre 10000000/4 = 2500000 octets pour le premier tableau, plus 10000000 * 1% * 4 B = 400000 octets pour le second tableau; soit 2900000 octets, c'est-à-dire moins d'un tiers de la matrice d'origine, et la partie la plus utilisée est conservée ensemble en mémoire, ce qui devrait être bon pour la mise en cache (elle peut même convenir à L3).

Si vous avez besoin d'un adressage supérieur à 24 bits, vous devrez modifier le "stockage secondaire". un moyen trivial de l'étendre consiste à disposer d'un tableau de pointeur à 256 éléments pour basculer entre les 8 bits supérieurs de l'index et le transmettre à un tableau trié indexé à 24 bits comme ci-dessus.


Repère 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 d'éléments de 10 millions avec des données aléatoires distribuées sous la forme d'un OP spécifié dans leur message, initialise ma structure de données, puis:

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

(notez qu'en cas de recherche séquentielle, le tableau l'emporte toujours largement, car il s'agit de la recherche la plus conviviale possible pour le cache)

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 recherche sont calculés et imprimés, ainsi que l'accélération (lookup_mean/array_mean).

J'ai compilé le code ci-dessus avec g ++ 5.4.0 (-O3 -static, plus quelques avertissements) sous Ubuntu 16.04, et je l'ai exécuté sur certaines machines; la plupart d'entre eux utilisent Ubuntu 16.04, certains anciens Linux, d'autres Linux récents. Je ne pense pas que le système d'exploitation devrait être pertinent du tout 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 d'accélération, ou du moins elles sont sur un pied d'égalité.
  2. Les deux cas où le tableau dépasse véritablement la recherche "structure intelligente" concernent des machines avec beaucoup de mémoire cache et pas particulièrement occupées: le Xeon E5-1650 ci-dessus (mémoire cache de 15 Mo) est une machine de construction de nuit, actuellement très inactive; Le Xeon E5-2697 (cache de 35 Mo) est une machine qui permet des calculs très performants, y compris à un moment d'inactivité. Cela a du sens, la matrice originale s’intègre parfaitement dans leur immense cache, de sorte que la structure de données compacte n’ajoute que de la complexité.
  3. À l'opposé du "spectre de 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 le tableau ni la "structure intelligente" ne s'y insèrent. D'autres machines avec un cache suffisamment petit fonctionnent de la même manière.
  4. Le Xeon X5650 doit être pris avec une certaine prudence - ce sont des machines virtuelles sur un serveur de machine virtuelle à double socket très occupé; il se peut bien que, bien que nominalement, il dispose d'une quantité de cache décente, il est préempté plusieurs fois pendant le temps du test par des machines virtuelles totalement indépendantes.
154
Matteo Italia

Une autre option pourrait être

  • vérifier si le résultat est 0, 1 ou 2
  • sinon 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];
}

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

Cette structure est simple à mettre à jour, utilise 25% de mémoire supplémentaire, mais la majeure partie n’est recherchée que dans 5% des cas. Bien sûr, comme d’habitude, si c’est une bonne idée ou non dépend de nombreuses autres conditions, la seule solution consiste à expérimenter avec un usage réel.

33
6502

C'est plus un "long commentaire" qu'une réponse concrète

À moins que vos données ne soient quelque chose de bien connu, je doute que quiconque puisse DIRE DIREMENT répondre à votre question (et je ne suis au courant de rien qui corresponde à votre description, mais je ne connais pas TOUT le monde à propos de toutes sortes de modèles de données pour tous. types de cas d'utilisation). Les données clairsemées sont un problème courant dans l'informatique haute performance, mais c'est généralement "nous avons un très grand tableau, mais seules certaines valeurs sont non nulles".

Pour des schémas peu connus comme ce que je pense sont les vôtres, personne ne saura directement quel est le meilleur choix, 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-il complètement aléatoire, comme dans un générateur de nombre aléatoire uniforme. Les données de la table sont-elles complètement aléatoires ou existe-t-il des séquences de 0 à 1, avec une dispersion des autres valeurs? L'encodage de longueur d'exécution fonctionnerait bien si vous avez des séquences raisonnablement longues de 0 et 1, mais ne fonctionnera pas si vous avez un "damier de 0/1". En outre, vous devez conserver un tableau des "points de départ" afin de pouvoir vous rendre assez rapidement au lieu approprié.

Je sais depuis longtemps que certaines grandes bases de données ne sont qu'une grande table dans RAM (données d'abonné de central téléphonique dans cet exemple), et l'un des problèmes est que les optimisations de caches et de tables de pages dans le processeur est assez inutile. L’appelant est si rarement le même qu’un appelant récemment, qu’il n’existe aucune donnée préchargée, elle est purement aléatoire. Les grandes tables de pages constituent 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 qu’il faut choisir entre le génie logiciel et le génie logiciel [dans d’autres domaines d’ingénierie, ce n’est pas nécessairement un tel compromis]. Ainsi, "gaspiller de la mémoire pour du code plus simple" est assez souvent le choix préféré. En ce sens, la solution "simple" est probablement plus rapide, mais si vous utilisez "mieux" la mémoire vive, optimiser la taille de la table vous donnerait des performances suffisantes et une bonne amélioration de la taille. Vous pouvez y parvenir de différentes façons - comme suggéré dans un commentaire, un champ de 2 bits où sont stockées les deux ou trois valeurs les plus courantes, puis un format de données alternatif pour les autres valeurs - une table de hachage serait ma première approche, mais une liste ou un arbre binaire peut également fonctionner - cela dépend aussi des modèles d’emplacement 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 le tableau: sont-elles en grappes ou sont-elles plus régulières?

Mais un problème avec cela est que vous lisez toujours les données de la RAM. Vous dépensez alors plus de code pour le traitement des données, y compris du code permettant de gérer le "ceci n'est pas une valeur commune".

Le problème avec les algorithmes de compression les plus courants est qu'ils sont basés sur des séquences de décompression, vous ne pouvez donc pas y accéder de manière aléatoire. En outre, il est très peu probable que le fractionnement de vos données massives en fragments de, disons, 256 entrées à la fois, et sa décompression en un tableau uint8_t, de l'extraction des données souhaitées, puis de leur élimination par la suppression des données non compressées. la performance - en supposant que cela ait une certaine importance, bien sûr.

En fin de compte, vous devrez probablement implémenter une ou plusieurs idées dans les commentaires/réponses à tester, voir si cela vous aide à résoudre votre problème ou si le bus de mémoire est toujours le principal facteur limitant.

23
Mats Petersson

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

Cela réduit de moitié l’espace par rapport à la réponse de Matteo, mais peut être plus lent si les recherches "d’exception" sont lentes (c’est-à-dire qu’il existe de nombreuses exceptions).

Souvent, cependant, "le cache est roi".

13
o11c

À moins que vos données ne se modèlent, il est peu probable qu'il y ait une optimisation raisonnable de la vitesse ou de la taille, et - en supposant que vous visiez un ordinateur normal - 10 Mo n'est de toute façon pas si difficile.

Il y a deux hypothèses dans vos questions:

  1. Les données sont mal stockées car vous n'utilisez pas tous les bits
  2. Le stocker mieux rendrait les choses plus rapidement.

Je pense que ces deux hypothèses sont fausses. Dans la plupart des cas, le moyen approprié de stocker des données consiste à stocker la représentation la plus naturelle. Dans votre cas, c’est celui que vous avez choisi: un octet pour un nombre compris entre 0 et 255. Toute autre représentation sera plus complexe et, par conséquent, toutes choses étant égales par ailleurs: plus lente et plus sujette aux erreurs. Pour renoncer à ce principe général, vous avez besoin d'une raison plus solide que potentiellement six bits "gaspillés" sur 95% de vos données.

Pour votre deuxième hypothèse, il sera vrai si, et seulement si, changer la taille de la matrice entraîne beaucoup moins d’occurrences de cache. On ne peut déterminer définitivement si cela se produira en définissant un code de travail, mais je pense qu'il est très peu probable que cela fasse une différence substantielle. Étant donné que vous allez accéder au tableau de manière 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.

11
Jack Aidley

Si les données et les accès sont distribués uniformément de manière aléatoire, les performances vont probablement dépendre de la fraction des accès évités par un cache manquant au niveau externe. Pour optimiser cela, vous devrez savoir quel tableau de taille peut être logé de manière fiable dans le cache. Si votre cache est suffisamment grand pour contenir un octet toutes les cinq cellules, la solution la plus simple consiste peut-être à laisser un octet contenant les cinq valeurs codées en trois bases dans la plage 0-2 (il existe 243 combinaisons de 5 valeurs, de sorte que tenir dans un octet), avec un tableau de 10 000 000 octets qui seraient interrogés chaque fois que la valeur en base 3 indique "2".

Si le cache n’est pas aussi volumineux, mais peut contenir un octet par 8 cellules, il ne serait pas possible d’utiliser une valeur à un octet pour sélectionner parmi les 6 561 combinaisons possibles de huit valeurs en base 3, mais depuis le seul effet de changer un 0 ou un 1 en un 2 provoquerait une recherche inutile, l'exactitude n'exigerait pas la prise en charge des 6 561. Au lieu de cela, on pourrait se concentrer sur les 256 valeurs les plus "utiles".

Surtout si 0 est plus commun que 1, ou vice versa, une bonne approche pourrait être d’utiliser 217 valeurs pour coder les combinaisons de 0 et 1 contenant 5 ou moins de 1, 16 valeurs pour coder xxxx0000 à xxxx1111, 16 pour coder 0000xxxx jusqu'à 1111xxxx et un pour xxxxxxxx. Il resterait quatre valeurs pour n'importe quel autre usage que l'on pourrait trouver. Si les données sont distribuées aléatoirement comme décrit ci-dessus, une faible majorité de toutes les requêtes atteindrait des octets ne contenant que des zéros et des zéros (dans environ les deux tiers des groupes de huit, tous les bits seraient des zéros et des un, et environ 7/8 des ceux qui auraient six bits ou moins); la grande majorité de ceux qui ne l'ont pas fait atterriraient dans un octet contenant quatre x et auraient 50% de chances d'atterrir sur un zéro ou un. Ainsi, seulement environ une requête sur quatre nécessiterait une recherche sur un grand tableau.

Si les données sont distribuées de manière aléatoire mais que le cache n'est pas assez grand pour gérer un octet sur huit éléments, on pourrait essayer d'utiliser cette approche avec chaque octet traitant plus de huit éléments, mais à moins d'un biais important vers 0 ou vers 1 , la fraction de valeurs pouvant être gérée sans avoir à effectuer une recherche dans le grand tableau sera réduite à mesure que le nombre géré par chaque octet augmente.

8
supercat

J'ajouterai à la réponse de @ o11c , car sa formulation pourrait être un peu déroutante. Si j'ai besoin de presser le dernier bit et le dernier cycle de processeur, je procéderais comme suit.

Nous allons commencer par construire un arbre de recherche binaire équilibré qui contient les 5% des cas "autre chose". Pour chaque recherche, vous parcourez l’arbre rapidement: vous avez 10000000 éléments: 5% de ceux-ci sont dans l’arbre: la structure de données de l’arbre contient donc 500000 éléments. Marcher ceci dans O(log(n)) temps, vous donne 19 itérations. Je ne suis pas un expert en la matière, mais j'imagine qu'il existe des implémentations efficaces en termes de mémoire. Let's guesstimate:

  • Arborescence équilibrée, afin de pouvoir calculer la position de la sous-arborescence (les index ne doivent pas nécessairement être stockés dans les nœuds de l’arborescence). De la même manière, un tas (structure de données) est stocké dans la mémoire linéaire.
  • Valeur de 1 octet (2 à 255)
  • 3 octets pour l'index (10000000 prend 23 bits, ce qui correspond à 3 octets)

Totale, 4 octets: 500000 * 4 = 1953 Ko. Convient à la cache!

Pour tous les autres cas (0 ou 1), vous pouvez utiliser un vecteur de bits. Notez que vous ne pouvez pas laisser de côté les 5% d’autres cas d’accès aléatoire: 1,19 MB.

La combinaison de ces deux utilise environ 3 099 Mo. En utilisant cette technique, vous économiserez un facteur 3.08 de mémoire.

Cependant, cela ne bat pas la réponse de @ Matteo Italia (qui utilise 2,76 Mo), dommage. Y a-t-il quelque chose que nous pouvons faire en plus? La partie la plus consommatrice de mémoire est constituée par les 3 octets d'index de l'arborescence. Si nous pouvions réduire ce nombre à 2, nous économiserions 488 ko et la mémoire totale serait: 2,622 Mo, ce qui est inférieur!

Comment faisons-nous cela? Nous devons réduire l'indexation à 2 octets. Encore une fois, 10000000 prend 23 bits. Nous devons pouvoir laisser tomber 7 bits. Nous pouvons simplement faire cela en partitionnant la plage de 10000000 éléments en 2 ^ 7 (= 128) régions de 78125 éléments. Nous pouvons maintenant construire un arbre équilibré pour chacune de ces régions, avec 3906 éléments en moyenne. La sélection de l’arbre de droite se fait par une simple division de l’index cible par 2 ^ 7 (ou un décalage de bits >> 7). Maintenant, l'index requis à stocker peut être représenté par les 16 bits restants. Notez qu'il y a des frais généraux pour la longueur de l'arbre qui doivent être stockés, mais c'est négligeable. Notez également que ce mécanisme de scission réduit le nombre d'itérations nécessaires pour parcourir l'arborescence, il est désormais réduit à 7 itérations, car nous avons supprimé 7 bits: il ne reste que 12 itérations.

Notez que vous pouvez théoriquement répéter le processus pour couper les 8 bits suivants, mais cela nécessiterait de créer 2 ^ 15 arbres équilibrés, avec environ 305 éléments en moyenne. Cela donnerait 2,143 Mo, avec seulement 4 itérations pour parcourir l’arbre, ce qui représente une accélération considérable par rapport aux 19 itérations avec lesquelles nous avons commencé.

En conclusion, cela bat la stratégie de vecteur à 2 bits par un tout petit peu d’utilisation de la mémoire, mais c’est tout un combat à mettre en œuvre. Mais si cela peut faire la différence entre l’adaptation du cache ou non, cela vaut la peine d’essayer.

7

Si vous n'effectuez que des opérations de lecture, il serait préférable de ne pas affecter de valeur à un seul index mais à un intervalle d'indices.

Par exemple:

[0, 15000] = 0
[15001, 15002] = 153
[15003, 26876] = 2
[25677, 31578] = 0
...

Cela peut être fait avec une structure. Vous pouvez également définir une classe similaire à celle-ci si vous préférez une approche OO.

class Interval{
  private:
    uint32_t start; // First element of interval
    uint32_t end; // Last element of interval
    uint8_t value; // Assigned value

  public:
    Interval(uint32_t start, uint32_t end, uint8_t value);
    bool isInInterval(uint32_t item); // Checks if item lies within interval
    uint8_t getValue(); // Returns the assigned value
}

Maintenant, il vous suffit de parcourir une liste d'intervalles et de vérifier si votre index se situe dans l'un d'eux, ce qui peut nécessiter beaucoup moins de mémoire, mais coûte plus de ressources CPU.

Interval intervals[INTERVAL_COUNT];
intervals[0] = Interval(0, 15000, 0);
intervals[1] = Interval(15001, 15002, 153);
intervals[2] = Interval(15003, 26876, 2);
intervals[3] = Interval(25677, 31578, 0);
...

uint8_t checkIntervals(uint32_t item)

    for(int i=0; i<INTERVAL_COUNT-1; i++)
    {
        if(intervals[i].isInInterval(item) == true)
        {
            return intervals[i].getValue();
        }
    }
    return DEFAULT_VALUE;
}

Si vous commandez les intervalles par ordre décroissant, vous augmentez la probabilité que l'élément recherché soit trouvé à l'avance, ce qui diminue encore votre utilisation moyenne des ressources de mémoire et d'UC.

Vous pouvez également supprimer tous les intervalles d'une taille égale à 1. Placez les valeurs correspondantes dans une carte et vérifiez-les uniquement si l'élément que vous recherchez n'a pas été trouvé dans les intervalles. Cela devrait également augmenter un peu la performance moyenne.

5
Detonar

Il y a très longtemps, je ne peux que me rappeler ...

À l'université, nous avons pour tâche d'accélérer un programme de traçage de rayons, qui doit être lu et relu par algorithme à partir de matrices de mémoire tampon. Un ami m'a dit de toujours utiliser des lectures RAM qui sont des multiples de 4 octets. J'ai donc changé le tableau d'un modèle de [x1, y1, z1, x2, y2, z2, ..., xn, yn, zn] en un modèle de [x1, y1, z1,0, x2, y2, z2 , 0, ..., xn, yn, zn, 0]. Cela signifie que j'ajoute un champ vide après chaque coordonnée 3D. Après quelques tests de performance: c'était plus rapide. Histoire longue: lisez plusieurs ressources de votre tableau dans la mémoire vive (RAM), et peut-être aussi à partir de la bonne position de départ. Vous devez donc lire un petit groupe contenant l'index recherché et lire l'index recherché à partir de ce petit groupe dans l'unité centrale de traitement. (Dans votre cas, vous n'aurez pas besoin d'insérer de champs de remplissage, mais le concept devra être clair)

Peut-être aussi que d'autres multiples pourraient être la clé des nouveaux systèmes.

Je ne sais pas si cela fonctionnera dans votre cas, donc si cela ne fonctionne pas: Désolé. Si cela fonctionne, je serais heureux d’entendre parler de résultats de tests.

PS: Oh, et s’il existe un modèle d’accès ou des index accessibles à proximité, vous pouvez réutiliser le cluster mis en cache.

PPS: Il se pourrait que le facteur multiple ressemblait davantage à 16 octets ou quelque chose du genre; il y a trop longtemps, je m'en souviens exactement.

4
Horitsu

En regardant cela, vous pourriez diviser vos données, par exemple:

  • un bitet qui est indexé et représente la valeur 0 (std :: vector serait utile ici)
  • un bitset qui est indexé et représente la valeur 1
  • un std :: vector pour les valeurs de 2, contenant les index qui font référence à cette valeur
  • une carte pour les autres valeurs (ou std :: vecteur>)

Dans ce cas, toutes les valeurs apparaissent jusqu'à un index donné. Vous pouvez même supprimer l'un des bits et représenter la valeur manquante dans les autres.

Cela vous épargnera un peu de mémoire, mais aggravera le pire des cas. Vous aurez également besoin de plus de puissance de calcul pour effectuer les recherches.

Assurez-vous de mesurer!

3
JVApen

Comme Mats le mentionne dans son commentaire-réponse, il est difficile de dire quelle est en fait la meilleure solution sans savoir spécifiquement quel type de données vous avez (par exemple, existe-t-il de longs cycles de 0, etc.) , et à quoi ressemble votre modèle d’accès ("aléatoire" signifie-t-il "partout" ou juste "pas strictement de manière complètement linéaire" ou "chaque valeur exactement une fois, juste randomisée" ou ...).

Cela dit, deux mécanismes viennent à l’esprit:

  • Matrices de bits; c'est-à-dire que si vous n'aviez que deux valeurs, vous pourriez compresser trivialement votre tableau par un facteur de 8; Si vous avez 4 valeurs (ou "3 valeurs + tout le reste"), vous pouvez compresser par un facteur deux. Ce qui pourrait ne pas en valoir la peine et nécessiterait des points de repère, en particulier si vous avez vraiment des modèles d'accès aléatoires qui échappent à vos caches et ne modifient donc pas du tout le temps d'accès.
  • (index,value) ou (value,index) tables. Par exemple, vous avez une très petite table pour le cas 1%, peut-être une table pour le cas 5% (qui n'a besoin que de stocker les index car ils ont tous la même valeur) et un grand tableau de bits compressés pour les deux derniers cas. Et par "table", je veux dire quelque chose qui permet une recherche relativement rapide; c'est-à-dire, peut-être un hachage, un arbre binaire, etc., en fonction de vos disponibilités et de vos besoins réels. Si ces sous-tables entrent dans vos caches de niveau 1/2, vous pourriez avoir de la chance.
2
AnoE

Je ne suis pas très familier avec C, mais dans C++ , vous pouvez utiliser un caractère non signé pour représenter un entier compris entre 0 et 255.

Par rapport à la normale int (encore une fois, je viens de Java et C++ monde) dans lequel 4 octets (32 bits) sont requis, un caractère non signé requiert 1 octet (8 bits). cela pourrait donc réduire la taille totale du tableau de 75%.

1
Adi