Faster way to read/write a std::unordered_map from/to a file

时间:2018-01-23 19:27:33

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

I am working with some very large std::unordered_maps (hundreds of millions of entries) and need to save and load them to and from a file. The way I am currently doing this is by iterating through the map and reading/writing each key and value pair one at a time:

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);
}

But with around 624 million entries, reading the map from a file took 9 minutes. Writing to a file was faster but still took several minutes. Is there a faster way to do this?

5 个答案:

答案 0 :(得分:6)

C ++ unordered_map实现必须全部使用chaining。有很多很好的理由可以解释为什么你可能想要为通用哈希表执行此操作,讨论here

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

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

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

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

  1. 您的密钥已经以某种方式随机分发。这消除了对哈希函数的需求(尽管很难构建好的哈希函数,即使很难使用很好的哈希函数)。

  2. 您只需将元素添加到哈希表中,您不会删除它们。如果不是这种情况,您需要将used向量更改为可以包含三种状态的内容:USEDUNUSEDTOMBSTONE其中{{1} }是已删除元素的陈述,用于继续线性搜索探测或暂停线性插入探测。

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

  4. 您无需按任何特定顺序遍历您的元素。

  5. 当然,可能存在各种在线开放寻址哈希表的优秀实现,它们解决了上述许多问题。然而,我桌子的简洁性让我能够传达重要的观点。

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

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

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

    TOMBSTONE

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

    <强>代码:

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

答案 1 :(得分:2)

(编辑:我已经在此问题中添加了new answer,这样可以减少95%的停机时间。)

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

然后我删除了unsigned long long int内容并将其替换为uint64_t库中的cstdint。这可以确保我们以相同的数据大小运行,因为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); }
}

在我使用的测试数据集上,这给出了1982ms的写入时间和7467ms的读取时间(使用原始代码)。似乎读取时间是最大的瓶颈,所以我创建了一个新函数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

请注意,从SaveSaveLookup的转换速度提高了8%,从Load with ReserveLoadLookup的转换也提高了8%的速度。这符合我们的理论!

使用连续内存也可以比原始加载时间加快24%的速度,并且比原始加载时间加快47%的速度。

答案 2 :(得分:0)

我认为您需要地图来编写文件中订购的值。最好只加载容器中的值,可能是std::deque,因为数量很大,并且使用std::sort一次,然后迭代std::deque来写值。您将获得缓存性能以及std::sort的运行时复杂度为N * Log(N),这比平衡您的地图~6.24亿次或在无序地图中支付缓存未命中要好。

答案 3 :(得分:0)

保存期间的前缀排序遍历可能有助于减少加载期间内部重新排序的数量?

当然,您无法看到STL地图容器的内部结构,因此您可以做的最好的方法是通过二进制切割迭代器来模拟它,就好像它是线性的一样。假设您知道总N个节点,则保存节点N / 2,然后保存N / 4,N * 3/4等等。

这可以通过访问每个传递中的每个奇数N /(2 ^ p)节点来算法完成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 <1。 S <= 1.0,然后按比例缩放每个p。

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

答案 4 :(得分:0)

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

打开是即时的(仅mmap文件)。

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

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

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

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

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

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

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

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

这样做的好处是你仍然可以mmap整个文件和具有相同散列的所有键/值对靠近在一起,这将它们带入L1缓存(与链接列表相比)