【问题标题】:C++ design: How to cache most recent usedC++ 设计:如何缓存最近使用的
【发布时间】:2009-12-20 12:59:40
【问题描述】:

我们有一个 C++ 应用程序,我们试图提高其性能。我们发现数据检索需要很多时间,并且想要缓存数据。我们无法将所有数据存储在内存中,因为它很大。我们希望在内存中存储多达 1000 个项目。这些项目可以通过long 键进行索引。但是,当缓存大小超过 1000 时,我们希望删除最长时间未访问的项目,因为我们假设某种“引用位置”,即我们假设缓存中的项目最近被访问可能会再次访问。

你能建议一种实现它的方法吗?

我最初的实现是使用map<long, CacheEntry> 来存储缓存,并将accessStamp 成员添加到CacheEntry,每当创建或访问条目时,该成员将设置为递增计数器。当缓存满了,需要一个新的entry时,代码会扫描整个cache map,找到accessStamp最低的那个entry,将其移除。 这样做的问题是,一旦缓存已满,每次插入都需要对缓存进行全面扫描。

另一个想法是在缓存映射之外保存CacheEntries 的列表,并在每次访问时将访问的条目移动到列表的顶部,但问题是如何在列表中快速找到该条目。

您能提出更好的方法吗?

谢谢
分裂者

【问题讨论】:

  • @Neil 在这种情况下不能使用accessStamp
  • 不,因为它会改变。密钥必须保持不变。
  • @Niel 谢谢你的回答。我没有仔细考虑是我的错。
  • 我更喜欢 accessStamp。我认为可以理解,“时间戳”可能是随时间增加的任何内容,不一定是访问的​​实际日期/时间。我会发现 accessCount 令人困惑,因为我想知道它是对所有缓存条目的所有访问计数(最近的缓存),还是仅对这个条目的访问计数(最常用的缓存)。
  • @Steve 我真的不明白你在说什么。如果您想要 MRU 缓存,则必须使用时间戳。如果你想要一个 MFU,你必须使用计数。在第一种情况下,我将其称为 accessStamp,在后一种情况下称为 accessCount。

标签: c++ caching


【解决方案1】:

拥有您的map<long,CacheEntry>,但不要在CacheEntry 中设置访问时间戳,而是放入两个指向其他CacheEntry 对象的链接,以使条目形成一个双向链表。每当访问条目时,将其移动到列表的头部(这是一个恒定时间操作)。这样你就可以很容易地找到缓存条目,因为它是从地图访问的,并且能够删除最近最少使用的条目,因为它位于列表的末尾(我的偏好是使双向链表循环,所以指向头部的指针也足以快速访问尾部)。还记得将您在地图中使用的密钥放入CacheEntry,以便在将条目从缓存中逐出时将其从地图中删除。

【讨论】:

  • 我喜欢这个 - 可能是使用两个集合的最佳解决方案。但是,我想做测试以证明它实际上比简单的地图扫描实现更快。
  • 奇怪,我在写自己的答案之前错过了这个答案,这是相同的。然后+1。
  • 这看起来确实是问题的正确答案。但是,考虑到我们时间紧迫,调试能力有限(我们使用 SAS/,C 编译器编译代码并将其复制到 z/OS 大型机),管理我们自己的链表似乎太冒险了。因此,我想我会坚持我最初的设计,并使用 Neil Butterworth 的建议,即扫描包含 1000 个项目的地图需要很短的时间。
  • 很难说——正如我所说,我的问题的“正确答案”似乎就是这个答案,但考虑到我的情况,你的答案更适合我。我希望我可以将两者都标记为答案。但是,既然我指望你的答案,我想你应该得到这个答案......
  • 我只是在开玩笑!给 jk 分数 - 他比我更需要他们!
【解决方案2】:

扫描一张包含 1000 个元素的地图将花费很少的时间,并且只会在项目不在缓存中时执行扫描,如果您的参考思想的位置正确,应该只占一小部分时间。当然,如果你的想法是错误的,那么缓存无论如何都可能是在浪费时间。

【讨论】:

  • 正如我刚刚想到的那样,我在回答中说:如果您可以执行异步 I/O,那么您可以在等待新对象时扫描缓存以删除某些内容到达。所以它实际上是免费的,只要它比 I/O 花费的时间少,而且你也不受 CPU 限制。因此,它可以扩展到您在 I/O 所花费的时间内可以访问的尽可能多的项目。如果你不能做异步 I/O,你可能没有优化缓存的事情 ;-)
  • 除了设置和处理异步 I/O 会花费运行时的时间以及设计和实现时的工作量。这个世界上没有什么是“有效免费的”。我非常不同意你的最后一句话——即使在纯粹的同步设置中,缓存也可能很有用。
  • 我同意缓存可能有用。我的意思是,如果您的缓存性能不佳,那么在您至少尝试并行化 I/O 之前,您就没有业务周期来计算缓存维护代码。不过,在这种情况下,我所说的只是“启动异步 I/O;从缓存中清除某些内容;阻塞 I/O;”。与阻塞 I/O 相比,它的设计工作要多一些,但使用了良好的异步 API,仅此而已。而且它可能不是更多的运行时工作,因为系统在异步之上实现阻塞 I/O 并不少见:反之则更难。
  • ... 总体而言,您可能是对的,扫描 1000 个项目是微不足道的,但即使它不是微不足道的,例如,如果将来增加缓存大小,它通常也会是可以安排这无关紧要。在性能方面,提问者忽略的问题是,将数据提取到缓存中几乎可以肯定是很慢并且不受 CPU 限制。
  • 我想我会听从你的建议。在这里使用异步 I/O 可能会让人头疼,因为我们使用 SAS/C 编译器进行编译并将其复制到 z/OS 大型机,我不确定我们是否有合适的异步 I/O 库。
【解决方案3】:

另一种可能使元素“老化”更容易但以降低搜索性能为代价的替代实现是将您的 CacheEntry 元素保留在 std::list 中(或使用std::pair<long, CacheEntry>。最新元素获取添加在列表的前面,因此它们随着年龄的增长“迁移”到列表的末尾。当您检查缓存中是否已经存在元素时,您会扫描列表(这无疑是一个 O(n) 操作与地图中的 O(log n) 操作相反)。如果找到它,则将其从当前位置删除并重新插入到列表的开头。如果列表长度超过 1000 个元素,则删除列表末尾所需的元素数量,以将其修剪回 1000 个元素以下。

【讨论】:

  • 年龄不是问题。缓存对象的访问次数是保留它的标准。
  • 嗯... OP 似乎自相矛盾。 “......我们假设最近访问过的缓存中的项目可能会再次被访问。”
  • 我认为通过“将设置为递增计数器”,OP 意味着他有一个全局递增计数器,并将 accessStamp 设置为该计数器的值。他没有说,“accessStamp 是一个递增计数器”。
  • OP 说“我们要删除最长时间未访问的项目”,在我看来,这听起来像是他在使用某种最近最少使用的算法。因此建议将最近使用的条目保留在列表的开头,并从列表的末尾删除“太旧”的条目。
  • 我想到了这个解决方案,但正如您所说,我希望发生的搜索操作远比从完整缓存中删除项目要花费更长的时间。这就是我拒绝此选项的原因。
【解决方案4】:

更新:我现在知道了...

这应该相当快。警告,前面有一些伪代码。

// accesses contains a list of id's. The latest used id is in front(),
// the oldest id is in back().
std::vector<id> accesses;
std::map<id, CachedItem*> cache;

CachedItem* get(long id) {
    if (cache.has_key(id)) {
         // In cache.
         // Move id to front of accesses.
         std::vector<id>::iterator pos = find(accesses.begin(), accesses.end(), id);
         if (pos != accesses.begin()) {
             accesses.erase(pos);
             accesses.insert(0, id);
         }
         return cache[id];
    }

    // Not in cache, fetch and add it.
    CachedItem* item = noncached_fetch(id);
    accesses.insert(0, id);
    cache[id] = item;
    if (accesses.size() > 1000)
    {
        // Remove dead item.
        std::vector<id>::iterator back_it = accesses.back();
        cache.erase(*back_it);
        accesses.pop_back();
    }
    return item;
}

插入和擦除可能有点贵,但考虑到局部性(很少有缓存未命中),也可能不会太糟糕。无论如何,如果它们成为一个大问题,可以更改为 std::list。

【讨论】:

  • 是长键索引,不是访问时间。
  • 地图没有键入他要删除的值。
  • 我觉得这段代码基本正确。这是我在帖子中提到的另一个想法的伪实现。恐怕每次访问时在访问列表中搜索访问的项目会花费太多时间,尽管地方性确实应该减少那个时间。
【解决方案5】:

在我的方法中,需要有一个哈希表来快速查找存储的对象,并有一个链表来维护上次使用的顺序。

请求对象时。 1)尝试从哈希表中查找对象 2.yes)如果找到(该值具有链表中对象的指针),则将链表中的对象移动到链表的顶部。 2.no) 如果没有,则从链表中删除最后一个对象,并从哈希表中删除数据,然后将对象放入哈希表和链表顶部。

例如 假设我们只有 3 个对象的缓存。

请求序列为 1 3 2 1 4。

1) 哈希表:[1] 链表:[1]

2) 哈希表:[1, 3] 链表:[3, 1]

3) 哈希表:[1,2,3] 链表:[2,3,1]

4) 哈希表:[1,2,3] 链表:[1,2,3]

5) 哈希表:[1,2,4] 链表:[4,1,2] => 3 out

【讨论】:

    【解决方案6】:

    创建一个 std:priority_queue::iterator>,带有访问标记的比较器。对于插入,首先从队列中弹出最后一项,然后从映射中删除它。比将新项目插入地图,最后将它的迭代器推入队列。

    【讨论】:

    • 当访问计数增加时,这不起作用。如果里面的东西发生变化,优先队列不会自行调整。
    【解决方案7】:

    我同意 Neil 的观点,扫描 1000 个元素根本不需要时间。

    但是,如果您仍然想这样做,您可以只使用您建议的附加列表,并且为了避免每次扫描整个列表,您可以存储 CacheEntry 和指向与此条目对应的列表元素的指针。

    【讨论】:

    • 但是我将不得不处理无效的迭代器(假设我使用 std:list),因为列表会随着缓存条目的访问而不断变化。
    • 嗯,是的,你是对的,我没想到!但是您始终可以创建自己的双链表,这很容易,因为您需要的唯一操作相当简单。这样您就不会丢失任何参考资料。因此,会发生以下情况:当新条目到达时,在列表的头部创建新节点。在映射中添加 CacheEntry 以及指向该节点的指针。当重新访问一个条目时,找到列表中的节点(使用 key 获取映射中正确的元素,然后使用指向列表中节点的指针)并将该元素移动到列表的头部跨度>
    • 这基本上是 jk 的建议,我们都同意维护我自己的链表虽然很有趣,但可能风险太大,尤其是对于我们项目的当前阶段和条件。
    【解决方案8】:

    作为一种更简单的替代方法,您可以创建一个无限增长的地图,并每隔 10 分钟左右自行清理一次(根据预期流量调整时间)。

    您还可以通过这种方式记录一些非常有趣的统计数据。

    【讨论】:

    • 添加后台线程和多线程锁来处理这个问题似乎有点过头了。
    • 我认为无论如何你的缓存都必须是线程安全的。
    • 不,一点也不。该应用程序目前是单线程的,抱歉。
    【解决方案9】:

    我相信这是treaps 的理想人选。优先级是时间(虚拟时间或其他时间),按升序排列(根处较旧),long 作为键。

    还有second chance algorithm,这对缓存很有用。虽然你失去了搜索能力,但如果你只有 1000 个项目,影响不大。

    最简单的方法是将映射与优先级队列相关联,包装在一个类中。您使用地图搜索并使用队列移除(首先从队列中移除,抓取项目,然后从地图中按键移除)。

    【讨论】:

    • 我会研究一下,但乍一看似乎太复杂了。
    【解决方案10】:

    另一个选项可能是使用boost::multi_index。它旨在将索引与数据分开,并允许对同一数据进行多个索引。

    我不确定这是否真的会比扫描 1000 个项目更快。它可能会使用更多的内存然后很好。或减慢搜索和/或插入/删除速度。

    【讨论】:

    • 多索引将需要在每次访问时删除和添加一个项目,因为每个索引键都是恒定的,并且我应该索引的 accessStamp 在每次访问时都会更改。所以我想我不会从这个解决方案中获得时间收益。
    猜你喜欢
    • 2012-01-09
    • 2011-04-08
    • 1970-01-01
    • 1970-01-01
    • 2017-07-31
    • 1970-01-01
    • 2011-02-05
    • 1970-01-01
    • 2011-07-08
    相关资源
    最近更新 更多