7 votes

Moyen plus rapide de lire/écrire un std::unordered_map depuis/vers un fichier

Je travaille avec de très grands std::unordered_map (des centaines de millions d'entrées) et j'ai besoin de les sauvegarder et de les charger depuis un fichier. La manière dont je le fais actuellement est en itérant à travers la map et en lisant/écrivant chaque paire de clé et de valeur une par une :

std::unordered_map map;

void save(){
    std::unordered_map::iterator iter;
    FILE *f = fopen("map", "wb");
    for(iter=map.begin(); iter!=map.end(); iter++){
        fwrite(&(iter->first), 8, 1, f);
        fwrite(&(iter->second), 1, 1, f);
    }
    fclose(f);
}

void load(){
    FILE *f = fopen("map", "rb");
    unsigned long long int key;
    char val;
    while(fread(&key, 8, 1, f)){
        fread(&val, 1, 1, f);
        map[key] = val;
    }
    fclose(f);
}

Mais avec environ 624 millions d'entrées, la lecture de la map à partir d'un fichier a pris 9 minutes. L'écriture dans un fichier était plus rapide mais prenait quand même plusieurs minutes. Existe-t-il un moyen plus rapide de le faire ?

7voto

Richard Points 5991

Les implémentations de C++ unordered_map doivent toutes utiliser le chainage. Il existe de nombreuses bonnes raisons pour lesquelles vous pourriez vouloir faire cela pour une table de hachage à usage général, qui sont discutées ici.

Cela a des implications énormes sur les performances. Plus important encore, cela signifie que les entrées de la table de hachage sont susceptibles d'être dispersées dans toute la mémoire de manière à rendre l'accès à chacune d'entre elles moins efficace d'un ordre de grandeur (ou quelque chose comme ça) que ce serait le cas si elles pouvaient être accessibles de manière séquentielle.

Heureusement, vous pouvez construire des tables de hachage qui, lorsqu'elles sont presque pleines, offrent un accès quasi séquentiel aux éléments adjacents. Cela se fait en utilisant l'adressage ouvert.

Puisque votre table de hachage n'est pas à usage général, vous pourriez essayer cela.

Voici, j'ai construit un conteneur de table de hachage simple avec adressage ouvert et sondage linéaire. Cela suppose quelques choses :

  1. Vos clés sont déjà d'une certaine manière distribuées aléatoirement. Cela élimine le besoin d'une fonction de hachage (bien que de bonnes fonctions de hachage soient assez simples à construire, même si les grandes fonctions de hachage sont difficiles).

  2. Vous n'ajoutez jamais que des éléments à la table de hachage, vous ne les supprimez pas. Si ce n'était pas le cas, vous devriez changer le vecteur used en quelque chose qui pourrait contenir trois états : USED, UNUSED, et TOMBSTONETOMBSTONE est l'état d'un élément supprimé et sert à poursuivre la sonde de recherche linéaire ou à arrêter une sonde d'insertion linéaire.

  3. Vous connaissez la taille de votre table de hachage à l'avance, donc vous n'avez pas besoin de la redimensionner/re-hacher.

  4. Vous n'avez pas besoin de traverser vos éléments dans un ordre particulier.

Évidemment, il existe probablement toutes sortes d'excellentes implémentations de tables de hachage à adressage ouvert en ligne qui résolvent bon nombre des problèmes ci-dessus. Cependant, la simplicité de ma table me permet de transmettre le point important.

Le point important est le suivant : ma conception permet de stocker toutes les informations de la table de hachage dans trois vecteurs. C'est-à-dire : la mémoire est contiguë.

La mémoire contiguë est rapide à allouer, rapide à lire et rapide à écrire. L'effet de ceci est profond.

En utilisant le même test que dans ma réponse précédente, j'obtiens les temps suivants :

Enregistrer. Temps d'enregistrement = 82.9345 ms
Charger. Temps de chargement = 115.111 ms

Cela représente une diminution de 95% du temps d'enregistrement (22 fois plus rapide) et une diminution de 98% du temps de chargement (62 fois plus rapide).

Code :

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

const int TEST_TABLE_SIZE = 10000000;

template
class SimpleHash {
 public:
  int usedslots = 0;

  std::vector keys;
  std::vector vals;
  std::vector used;

  //size0 should be a prime and about 30% larger than the maximum number needed
  SimpleHash(int size0){
    vals.resize(size0);
    keys.resize(size0);
    used.resize(size0/8+1,0);
  }

  //If the key values are already uniformly distributed, using a hash gains us
  //nothing
  uint64_t hash(const K key){
    return key;
  }

  bool isUsed(const uint64_t loc){
    const auto used_loc = loc/8;
    const auto used_bit = 1<<(loc%8);
    return used[used_loc]&used_bit;    
  }

  void setUsed(const uint64_t loc){
    const auto used_loc = loc/8;
    const auto used_bit = 1<<(loc%8);
    used[used_loc] |= used_bit;
  }

  void insert(const K key, const V val){
    uint64_t loc = hash(key)%keys.size();

    //Use linear probing. Can create infinite loops if table too full.
    while(isUsed(loc)){ loc = (loc+1)%keys.size(); }

    setUsed(loc);
    keys[loc] = key;
    vals[loc] = val;
  }

  V& get(const K key) {
    uint64_t loc = hash(key)%keys.size();

    while(true){
      if(!isUsed(loc))
        throw std::runtime_error("Item not present!");
      if(keys[loc]==key)
        return vals[loc];

      loc = (loc+1)%keys.size();
    }
  }

  uint64_t usedSize() const {
    return usedslots;
  }

  uint64_t size() const {
    return keys.size();
  }
};

typedef SimpleHash table_t;

void SaveSimpleHash(const table_t &map){
  std::cout<<"Enregistrer. ";
  const auto start = std::chrono::steady_clock::now();
  FILE *f = fopen("/z/map", "wb");
  uint64_t size = map.size();
  fwrite(&size, 8, 1, f);
  fwrite(map.keys.data(), 8, size, f);
  fwrite(map.vals.data(), 1, size, f);
  fwrite(map.used.data(), 1, size/8+1, f);
  fclose(f);
  const auto end = std::chrono::steady_clock::now();
  std::cout<<"Temps d'enregistrement = "<< std::chrono::duration (end-start).count() << " ms" << std::endl;
}

table_t LoadSimpleHash(){
  std::cout<<"Charger. ";
  const auto start = std::chrono::steady_clock::now();
  FILE *f = fopen("/z/map", "rb");

  uint64_t size;
  fread(&size, 8, 1, f);

  table_t map(size);
  fread(map.keys.data(), 8, size, f);
  fread(map.vals.data(), 1, size, f);
  fread(map.used.data(), 1, size/8+1, f);
  fclose(f);
  const auto end = std::chrono::steady_clock::now();
  std::cout<<"Temps de chargement = "<< std::chrono::duration (end-start).count() << " ms" << std::endl;

  return map;
}

int main(){
  //Perfectly horrendous way of seeding a PRNG, but we'll do it here for brevity
  auto generator = std::mt19937(12345); //Combination of my luggage
  //Generate values within the specified closed intervals
  auto key_rand  = std::bind(std::uniform_int_distribution(0,std::numeric_limits::max()), generator);
  auto val_rand  = std::bind(std::uniform_int_distribution(std::numeric_limits::lowest(),std::numeric_limits::max()), generator);

  table_t map(1.3*TEST_TABLE_SIZE);
  std::cout<<"Table créée de taille "<

0voto

AdvSphere Points 602

Je suppose que vous avez besoin de la carte pour écrire les valeurs ordonnées dans le fichier. Il serait préférable de charger une seule fois les valeurs dans un conteneur, éventuellement un std::deque serait mieux puisque la quantité est grande, et d'utiliser std::sort une fois, puis d'itérer à travers std::deque pour écrire les valeurs. Vous gagneriez en performances de cache et aussi la complexité temporelle pour std::sort est N*Log(N), ce qui serait mieux que d'équilibrer votre carte ~624 millions de fois ou de payer des miss cache dans une carte non ordonnée.

0voto

Gem Taylor Points 3100

Peut-être qu'une traversée ordonnée par préfixe lors de l'enregistrement aiderait à réduire la quantité de réorganisation interne lors du chargement ?

Bien sûr, vous n'avez pas de visibilité sur la structure interne des conteneurs de carte STL, donc le mieux que vous puissiez faire serait de simuler cela en divisant l'itérateur de manière binaire comme s'il était linéaire. Étant donné que vous connaissez le total des N nœuds, enregistrez le nœud N/2, puis N/4, N*3/4, et ainsi de suite.

Cela peut être fait algorithmiquement en visitant chaque nœud impair N/(2^p) à chaque passage p : N/2, N*1/4, N*3/4, N*1/8, N*3/8, N*5/8, N*7/8, etc, bien que vous deviez vous assurer que la série maintient des pas de taille tels que N*4/8 = N/2, mais sans recourir à des pas de taille de 2^(P-p), et que lors du dernier passage, vous visitez chaque nœud restant. Il peut être avantageux de précalculer le nombre de passage le plus élevé (~log2(N)), et la valeur flottante de S=N/(2^P) telle que 0.5 < S <= 1.0, et ensuite mettre à l'échelle cela pour chaque p.

Mais comme d'autres l'ont dit, vous devez d'abord le profiler pour voir si c'est votre problème, et reprofiler pour voir si cette approche aide.

0voto

Danny_ds Points 5095

Étant donné que vos données semblent être statiques et compte tenu du nombre d'éléments, je considérerais certainement l'utilisation d'une structure propre dans un fichier binaire, puis j'utiliserais la mise en mémoire du fichier.

L'ouverture serait instantanée (il suffit de faire mmap du fichier).

Si vous écrivez les valeurs dans l'ordre trié, vous pouvez utiliser une recherche binaire sur les données mappées.

Si ce n'est pas suffisant, vous pourriez diviser vos données en "buckets" et stocker une liste avec des décalages au début du fichier - ou peut-être même utiliser une clé de hachage.

Si vos clés sont toutes uniques et quelque peu contiguës, vous pourriez obtenir un fichier plus petit en ne stockant que les valeurs char à la position du fichier [clé] (et utiliser une valeur spéciale pour les valeurs nulles). Bien sûr, cela ne fonctionnerait pas pour toute la plage uint64, mais en fonction des données, elles pourraient être regroupées dans des "buckets" contenant un décalage.

En utilisant mmap de cette manière, vous utiliserez également beaucoup moins de mémoire.


Pour un accès plus rapide, vous pourriez créer votre propre hachage sur disque (toujours avec un chargement instantané).

Par exemple, si vous avez 1 million de hachages (dans votre cas, il y en aurait beaucoup plus), vous pourriez écrire 1 million de valeurs de positions de fichiers uint64 au début du fichier (la valeur de hachage serait la position de l'uint64 contenant la position du fichier). Chaque emplacement pointe vers un bloc avec une ou plusieurs paires clé/valeur, et chacun de ces blocs commence par un compteur.

Si les blocs sont alignés sur 2 ou 4 octets, une position de fichier uint32 pourrait être utilisée à la place (multipliez la position par 2 ou 4).

Comme les données sont statiques, vous n'avez pas à vous soucier des insertions ou des suppressions possibles, ce qui rend l'implémentation plutôt facile.

Cela a l'avantage que vous pouvez toujours mmap l'ensemble du fichier et toutes les paires clé/valeur avec le même hachage sont regroupées, ce qui les amène dans le cache L1 (par rapport aux listes chaînées par exemple).

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