【问题标题】:Faster way to read/write a std::unordered_map from/to a file从/向文件读取/写入 std::unordered_map 的更快方法
【发布时间】:2018-07-02 17:25:02
【问题描述】:

我正在处理一些非常大的std::unordered_maps(数以亿计的条目),并且需要将它们保存到文件中以及从文件中加载它们。我目前这样做的方式是遍历映射并一次读取/写入每个键和值对:

std::unordered_map<unsigned long long int, char> map;

void save(){
    std::unordered_map<unsigned long long int, char>::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);
}

但在大约 6.24 亿个条目中,从文件中读取地图需要 9 分钟。写入文件更快,但仍然需要几分钟。有没有更快的方法来做到这一点?

【问题讨论】:

  • 您可以提供自己的Allocator 实现来优化内存管理以创建新的映射条目。
  • 你可怜的 std::map 不得不在它收到的每个新条目上重新平衡它的红黑树 - 你正在按排序顺序读取所有值,因此它或多或少可能具有最差的性能.
  • 与您的问题无关,但使用的最高密钥是什么?或者:你的地图有多稀疏?
  • 用load读完地图后你会改变地图吗?
  • 好的,所以没有办法用普通的char[624000000]来表示“地图”...

标签: algorithm performance c++11 unordered-map


【解决方案1】:

C++ unordered_map 实现必须全部使用 chaining。有很多很好的理由说明您可能希望对通用哈希表执行此操作,这已在 here 中进行了讨论。

这会对性能产生巨大影响。最重要的是,这意味着哈希表的条目很可能分散在整个内存中,这使得访问每个条目的效率降低了一个数量级(或左右),如果它们可以以某种方式被串行访问,就会出现这种情况。

幸运的是,您可以构建哈希表,当它快满时,可以对相邻元素进行近乎顺序的访问。这是使用open addressing 完成的。

由于你的哈希表不是通用的,你可以试试这个。

下面,我构建了一个带有开放寻址和linear probing 的简单哈希表容器。它假设了一些事情:

  1. 您的密钥已经以某种方式随机分布。这消除了对散列函数的需求(尽管体面的散列函数构建起来相当简单,即使出色的散列函数很困难)。

  2. 您只能将元素添加到哈希表中,而不会删除它们。如果不是这种情况,您需要将used 向量更改为可以保持三种状态的东西:USED、UNUSED 和TOMBSTONE 其中TOMBSTONE 是已删除元素的声明并使用继续线性搜索探测或停止线性插入探测。

  3. 您提前知道哈希表的大小,因此无需调整大小/重新哈希。

  4. 您不需要以任何特定顺序遍历元素。

当然,网上可能有各种优秀的开放寻址哈希表实现,可以解决上述许多问题。然而,我的表格的简单性让我能够传达重要的一点。

重点是:我的设计允许将哈希表的所有信息存储在三个向量中。即:内存是连续的。

连续内存分配速度快,读取速度快,写入速度快。这样做的影响是深远的。

使用与我的previous answer 相同的测试设置,我得到以下时间:

Save. Save time = 82.9345 ms
Load. Load time = 115.111 ms

保存时间减少 95%(快 22 倍),加载时间减少 98%(快 62 倍)。

代码:

#include <cassert>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <functional>
#include <iostream>
#include <random>
#include <vector>

const int TEST_TABLE_SIZE = 10000000;



template<class K, class V>
class SimpleHash {
 public:
  int usedslots = 0;

  std::vector<K> keys;
  std::vector<V> vals;
  std::vector<uint8_t> 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<uint64_t, char> table_t;

void SaveSimpleHash(const table_t &map){
  std::cout<<"Save. ";
  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<<"Save time = "<< std::chrono::duration<double, std::milli> (end-start).count() << " ms" << std::endl;
}

table_t LoadSimpleHash(){
  std::cout<<"Load. ";
  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<<"Load time = "<< std::chrono::duration<double, std::milli> (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<uint64_t>(0,std::numeric_limits<uint64_t>::max()), generator);
  auto val_rand  = std::bind(std::uniform_int_distribution<int>(std::numeric_limits<char>::lowest(),std::numeric_limits<char>::max()), generator);

  table_t map(1.3*TEST_TABLE_SIZE);
  std::cout<<"Created table of size "<<map.size()<<std::endl;

  std::cout<<"Generating test data..."<<std::endl;
  for(int i=0;i<TEST_TABLE_SIZE;i++)
    map.insert(key_rand(),(char)val_rand()); //Low chance of collisions, so we get quite close to the desired size

  map.insert(23,42);
  assert(map.get(23)==42);

  SaveSimpleHash(map);
  auto newmap = LoadSimpleHash();

  //Ensure that the load worked
  for(int i=0;i<map.keys.size();i++)
    assert(map.keys.at(i)==newmap.keys.at(i));
  for(int i=0;i<map.vals.size();i++)
    assert(map.vals.at(i)==newmap.vals.at(i));  
  for(int i=0;i<map.used.size();i++)
    assert(map.used.at(i)==newmap.used.at(i));    
}

【讨论】:

  • +1 像你这样的无指针设计还有一个优点:如果K 和V 也是无指针的,那么很容易将必要的数组放入 mmap 中' ed 文件或共享内存。将这个教给std::vector 意味着必须处理 C++ 分配器模型,这有点令人困惑,尤其是传播和相等的东西。
  • 无指针是指分配的内存不包含指针。顶层对象显然包含一些指针。内存映射哈希表不会像那样完全自包含(甚至事务安全)。
【解决方案2】:

(编辑:我在这个问题中添加了一个new answer,它使挂墙时间减少了 95%。)

我制作了一个最小工作示例来说明您要解决的问题。这是您在问题中应该始终做的事情。

然后我删除了unsigned long long int 的东西,并用cstdint 库中的uint64_t 替换它。这确保我们在相同的数据大小上运行,因为 unsigned long long int 可能意味着几乎任何东西,具体取决于您使用的计算机/编译器。

生成的 MWE 如下所示:

#include <chrono>
#include <cstdint>
#include <cstdio>
#include <deque>
#include <functional>
#include <iostream>
#include <random>
#include <unordered_map>
#include <vector>

typedef std::unordered_map<uint64_t, char> table_t;
const int TEST_TABLE_SIZE = 10000000;

void Save(const table_t &map){
  std::cout<<"Save. ";
  const auto start = std::chrono::steady_clock::now();
  FILE *f = fopen("/z/map", "wb");
  for(auto iter=map.begin(); iter!=map.end(); iter++){
      fwrite(&(iter->first), 8, 1, f);
      fwrite(&(iter->second), 1, 1, f);
  }
  fclose(f);
  const auto end = std::chrono::steady_clock::now();
  std::cout<<"Save time = "<< std::chrono::duration<double, std::milli> (end-start).count() << " ms" << std::endl;
}

//Take advantage of the limited range of values to save time
void SaveLookup(const table_t &map){
  std::cout<<"SaveLookup. ";
  const auto start = std::chrono::steady_clock::now();

  //Create a lookup table
  std::vector< std::deque<uint64_t> > lookup(256);
  for(auto &kv: map)
    lookup.at(kv.second+128).emplace_back(kv.first);

  //Save lookup table header
  FILE *f = fopen("/z/map", "wb");
  for(const auto &row: lookup){
    const uint32_t rowsize = row.size();
    fwrite(&rowsize, 4, 1, f);
  }

  //Save values
  for(const auto &row: lookup)
  for(const auto &val: row)
    fwrite(&val, 8, 1, f);

  fclose(f);
  const auto end = std::chrono::steady_clock::now();
  std::cout<<"Save time = "<< std::chrono::duration<double, std::milli> (end-start).count() << " ms" << std::endl;
}

//Take advantage of the limited range of values and contiguous memory to
//save time
void SaveLookupVector(const table_t &map){
  std::cout<<"SaveLookupVector. ";
  const auto start = std::chrono::steady_clock::now();

  //Create a lookup table
  std::vector< std::vector<uint64_t> > lookup(256);
  for(auto &kv: map)
    lookup.at(kv.second+128).emplace_back(kv.first);

  //Save lookup table header
  FILE *f = fopen("/z/map", "wb");
  for(const auto &row: lookup){
    const uint32_t rowsize = row.size();
    fwrite(&rowsize, 4, 1, f);
  }

  //Save values
  for(const auto &row: lookup)
    fwrite(row.data(), 8, row.size(), f);

  fclose(f);
  const auto end = std::chrono::steady_clock::now();
  std::cout<<"Save time = "<< std::chrono::duration<double, std::milli> (end-start).count() << " ms" << std::endl;
}

void Load(table_t &map){
  std::cout<<"Load. ";
  const auto start = std::chrono::steady_clock::now();
  FILE *f = fopen("/z/map", "rb");
  uint64_t key;
  char val;
  while(fread(&key, 8, 1, f)){
      fread(&val, 1, 1, f);
      map[key] = val;
  }
  fclose(f);
  const auto end = std::chrono::steady_clock::now();
  std::cout<<"Load time = "<< std::chrono::duration<double, std::milli> (end-start).count() << " ms" << std::endl;
}

void Load2(table_t &map){
  std::cout<<"Load with Reserve. ";
  map.reserve(TEST_TABLE_SIZE+TEST_TABLE_SIZE/8);
  const auto start = std::chrono::steady_clock::now();
  FILE *f = fopen("/z/map", "rb");
  uint64_t key;
  char val;
  while(fread(&key, 8, 1, f)){
      fread(&val, 1, 1, f);
      map[key] = val;
  }
  fclose(f);
  const auto end = std::chrono::steady_clock::now();
  std::cout<<"Load time = "<< std::chrono::duration<double, std::milli> (end-start).count() << " ms" << std::endl;
}

//Take advantage of the limited range of values to save time
void LoadLookup(table_t &map){
  std::cout<<"LoadLookup. ";
  map.reserve(TEST_TABLE_SIZE+TEST_TABLE_SIZE/8);
  const auto start = std::chrono::steady_clock::now();
  FILE *f = fopen("/z/map", "rb");

  //Read the header
  std::vector<uint32_t> inpsizes(256);
  for(int i=0;i<256;i++)
    fread(&inpsizes[i], 4, 1, f);

  uint64_t key;
  for(int i=0;i<256;i++){
    const char val = i-128;    
    for(int v=0;v<inpsizes.at(i);v++){
      fread(&key, 8, 1, f);
      map[key] = val;
    }
  }

  fclose(f);
  const auto end = std::chrono::steady_clock::now();
  std::cout<<"Load time = "<< std::chrono::duration<double, std::milli> (end-start).count() << " ms" << std::endl;
}

//Take advantage of the limited range of values and contiguous memory to save time
void LoadLookupVector(table_t &map){
  std::cout<<"LoadLookupVector. ";
  map.reserve(TEST_TABLE_SIZE+TEST_TABLE_SIZE/8);
  const auto start = std::chrono::steady_clock::now();
  FILE *f = fopen("/z/map", "rb");

  //Read the header
  std::vector<uint32_t> inpsizes(256);
  for(int i=0;i<256;i++)
    fread(&inpsizes[i], 4, 1, f);

  for(int i=0;i<256;i++){
    const char val = i-128;    
    std::vector<uint64_t> keys(inpsizes[i]);
    fread(keys.data(), 8, inpsizes[i], f);
    for(const auto &key: keys)
      map[key] = val;
  }

  fclose(f);
  const auto end = std::chrono::steady_clock::now();
  std::cout<<"Load time = "<< std::chrono::duration<double, std::milli> (end-start).count() << " ms" << std::endl;
}

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<uint64_t>(0,std::numeric_limits<uint64_t>::max()), generator);
  auto val_rand  = std::bind(std::uniform_int_distribution<int>(std::numeric_limits<char>::lowest(),std::numeric_limits<char>::max()), generator);

  std::cout<<"Generating test data..."<<std::endl;
  //Generate a test table
  table_t map;
  for(int i=0;i<TEST_TABLE_SIZE;i++)
    map[key_rand()] = (char)val_rand(); //Low chance of collisions, so we get quite close to the desired size

  Save(map);

  { table_t map2; Load (map2); }
  { table_t map2; Load2(map2); }

  SaveLookup(map);
  SaveLookupVector(map);

  { table_t map2; LoadLookup      (map2); }
  { table_t map2; LoadLookupVector(map2); }
}

在我使用的测试数据集上,这给了我 1982 毫秒的写入时间和 7467 毫秒的读取时间(使用您的原始代码)。似乎读取时间是最大的瓶颈,所以我创建了一个新函数Load2,它在读取之前为 unordered_map 保留足够的空间。这将读取时间减少到 4700 毫秒(节省 37%)。

编辑 1

现在,我注意到您的 unordered_map 的值只能采用 255 个不同的值。因此,我可以轻松地将unordered_map 转换为RAM 中的一种查找表。也就是说,而不是:

123123 1
234234 0
345345 1
237872 1

我可以重新排列数据,使其看起来像:

0 234234
1 123123 345345 237872

这样做有什么好处?这意味着我不再需要将值写入磁盘。这样可以为每个表条目节省 1 个字节。由于每个表条目由 8 个字节的键和 1 个字节的值组成,这应该可以节省 11% 的读取和写入时间减去重新排列内存的成本(我希望它很低,因为 RAM) .

最后,一旦我完成了上述重新排列,如果我的机器上有很多空闲 RAM,我可以将所有内容打包到一个向量中并将连续数据读/写到磁盘。

这样做会产生以下时间:

Save. Save time = 1836.52 ms
Load. Load time = 7114.93 ms
Load with Reserve. Load time = 4277.58 ms
SaveLookup. Save time = 1688.73 ms
SaveLookupVector. Save time = 1394.95 ms
LoadLookup. Load time = 3927.3 ms
LoadLookupVector. Load time = 3739.37 ms

请注意,从Save 到SaveLookup 的转换提供了8% 的加速,从Load with Reserve 到LoadLookup 的转换也提供了8% 的加速。这符合我们的理论!

同时使用连续内存可使原始保存时间总共加快 24%,与原始加载时间相比总共加快 47%。

【讨论】:

    【解决方案3】:

    由于您的数据似乎是静态的并且考虑到项目的数量,我当然会考虑在二进制文件中使用自己的结构,然后在该文件上使用内存映射。

    立即打开(只需mmap 文件)。

    如果您按排序顺序写入值,则可以对映射的数据使用二进制搜索。

    如果这还不够好,您可以将数据拆分到桶中,并在文件开头存储一个带有偏移量的列表 - 或者甚至可以使用一些哈希键。

    如果您的键都是唯一的并且有些连续,您甚至可以通过仅将 char 值存储在文件位置 [key] 中来获得更小的文件(并对空值使用特殊值)。当然,这不适用于整个 uint64 范围,但根据数据的不同,它们可以组合在包含偏移量的存储桶中。

    以这种方式使用mmap 也会使用更少的内存。


    为了更快地访问,您可以在磁盘上创建自己的哈希映射(仍然使用“即时加载”)。

    例如,假设您有 100 万个哈希值(在您的情况下会有更多),您可以在文件开头写入 100 万个 uint64 filepos 值(哈希值将是 @ 的位置987654326@ 包含文件位置)。每个位置都指向一个包含一个或多个键/值对的块,每个块都以计数开头。

    如果块在 2 或 4 个字节上对齐,则可以使用 uint32 filepos 代替(将 pos 与 2 或 4 相乘)。

    由于数据是静态的,您不必担心可能的插入或删除,这使得实现起来相当容易。

    这样做的好处是您仍然可以mmap 整个文件和具有相同哈希的所有键/值对靠近在一起,从而将它们带到 L1 缓存中(与链表相比)

    【讨论】:

    • 二进制搜索对于我正在做的事情来说太慢了。我需要在地图中查找值可能数百亿次,甚至更多
    • @Ben 在这种情况下,您可以在磁盘上创建自己的“哈希图”(请参阅​​我更新的答案)。几分钟加载数据真的很长,使用mmap它是即时的并且使用更少的内存。
    • 你完全正确!我相信这是最好的答案。使用mmap 将是避免重新散列的方式,在最后一个例子中,这是从文件读取时需要很长时间的原因(读取导致正常插入并因此重新散列)
    【解决方案4】:

    也许在保存期间按前缀排序的遍历将有助于减少加载期间的内部重新排序量?

    当然,您无法看到 STL 映射容器的内部结构,因此您能做的最好的事情就是通过二进制切分迭代器来模拟它,就好像它是线性的一样。假设您知道总共 N 个节点,请保存节点 N/2,然后是 N/4、N*3/4,依此类推。

    这可以通过在每个 pass p 中访问每个奇数 N/(2^p) 节点来通过算法完成:N/2、N*1/4、N*3/4、N*1/8、N*3 /8、N*5/8、N*7/8 等,尽管您需要确保序列保持步长,使得 N*4/8 = N/2,但不采用步长 2^( Pp),并且在最后一遍中,您访问了每个剩余的节点。您可能会发现预先计算最高通过数 (~log2(N)) 和 S=N/(2^P) 的浮点值使得 0.5

    但正如其他人所说,您需要先对其进行分析以查看这是否是您的问题,然后再次进行分析以查看此方法是否有帮助。

    【讨论】:

      【解决方案5】:

      我假设您需要地图来写入文件中排序的值。最好只加载一次容器中的值,可能std::deque会更好,因为数量很大,使用std::sort一次,然后遍历std::deque写入值。您将获得缓存性能,并且std::sort 的运行时间复杂度为 N*Log(N),这比平衡您的地图约 6.24 亿次或在无序地图中支付缓存未命中要好。

      【讨论】:

      • 条目的顺序并不重要,实际上我更关心std::unordered_map 的性能。我会将其添加到我的原始帖子中。
      • 那你需要map/unordered_map的原因是什么?
      • 为什么是std::deque 而不是std::vector?
      • 由于内存太大,向量必须是连续的,其中 std::queue 是按块连续的(之间可以有间隙)。
      • 看来您需要对您写入的数据进行 O(1) 访问。如果你将使用的键是连续的,你可以只索引和 std::queue, O(1),并使用我上面提到的 std::sort。
      猜你喜欢
      • 1970-01-01
      • 2011-10-14
      • 1970-01-01
      • 1970-01-01
      • 2017-02-06
      • 1970-01-01
      • 2017-11-21
      • 1970-01-01
      • 2023-03-19
      相关资源
      最近更新 更多