删除一个线程上有数百万个字符串的大型哈希映射会影响另一个线程的性能

Deleting large hashmaps with millions of strings on one thread affects performance on another thread

本文关键字:线程 哈希 大型 映射 性能 另一个 影响 字符串 数百万 一个 删除      更新时间:2023-10-16

所以我有一个C++程序,它基本上解析巨大的数据集文件,并将内容加载到内存中的哈希图中(这部分在主线程中被限制了,所以它永远不会占用巨大的时间)。完成后,我将指针翻转到新的内存位置,并对旧的内存位置调用delete。除此之外,程序通过在内存映射中查找内容(在主线程上)来进行传入请求匹配。假设这些巨大的地图被包裹在Evaluator类中:

Evaluator* oldEvaluator = mEvaluator;
Evaluator* newEvaluator = parseDataSet();
mEvaluator = newEvaluator;
delete oldEvaluator;
//And then on request processing:
mEvaluator.lookup(request)

映射可以包含数百万个字符串对象作为。它们是常规字符串,可以是ip、UserAgent等请求属性,但每个字符串都是插入到STL无序映射中的字符串对象。

数据集会定期更新,但大多数时候程序只是针对内存中的数据集进行请求属性匹配,而且它很好、高效,没有错误,除非发生新数据集的大量消耗。使用这个大型数据集的另一种方法是使用流,但这是一个相对较长的解决方案。

它曾经是一个使用事件驱动模型的单线程程序,但每次放置一个完整的新集合并调用销毁时,都会花费很长时间来删除整个程序,从而阻止请求处理。

因此,我将删除此类映射放到一个单独的线程上。问题是,现在删除和请求处理似乎同时发生,我可以看到请求处理线程的速度明显减慢。

当然,主机上还有其他进程在运行,我确实希望这两个线程能够争夺CPU周期。但我没想到会看到请求匹配线程的速度大幅下降。平均来说,一个请求应该在500us级别上处理,但当删除线程运行时,它的速度慢到了5ms。有时cpu会中断匹配线程(因为时间太长),它可能长达50ms或120ms等。在极端情况下,一个请求可能需要整个1000ms才能处理,这大约是另一个线程删除整个数据结构的时间。

了解这种放缓的根本原因的最佳方法是什么是CPU还是内存带宽瓶颈?我一直在想象,只要我把它放在一个单独的线程上,我就不在乎它的速度有多慢,因为它毕竟必须一个接一个地删除字符串对象,所以我没想到它会影响另一个线程。。。

编辑:感谢几条评论/答案似乎已经指出了几个可能的原因:

  1. 内存碎片。因为访问频率较低的字符串存储在更昂贵的内存位置(所以缓存未命中),或者因为它存储在具有许多指针的无序映射中,或者因为系统在删除所有漏洞的同时进行内存压缩?但为什么这会影响另一个线程的缓慢
  2. 一条评论提到,由于线程安全锁定,这是堆争用?所以这个程序的整个堆都锁定了,因为一个线程正忙于删除阻止另一个线程访问堆内存的漏洞?为了澄清,该程序故意从不分配内容,同时释放其他内容,而且它只有两个线程,其中一个专门用于删除

那我该怎么办?我尝试了Jemalloc,但不确定我是否完全正确地使用了它——似乎在链接行中包含-ljemalloc只是神奇地取代了libc的malloc?我试过了,没有任何性能差异,但我可能用错了。我的程序不做任何显式malloc,所有东西都是预先未知大小的new,并与指针和STL映射连接在一起。

而且存储在Key中的所有字符串都专门用于快速查找,因此它们不能存储在带索引的向量中,即使这会产生连续的内存空间,定位它们也会很困难。所以,

  1. 如何确定以上2个内存问题是原因(有任何工具/指标吗?)
  2. 在不将我的消费模式更改为流媒体的情况下,我可以做些什么来修复它?假设根本原因是上面的2个,我似乎应该做两件事中的一件/两件:1)分配我所有的STL映射以及来自一个池的对象?我该怎么做?2) 减少堆争用(我不知道Jemalloc在我的情况下是否解决了这两个问题)

对于合并的所有数据,只存储一个std::string并在映射中使用std::string_view可能是值得的。这消除了互斥争用,因为只需要一个内存分配。string_view有一个琐碎的析构函数,所以不需要线程。

我以前曾成功地使用过这种技术将程序加速2500%,但这也是因为这种技术减少了总内存使用量。

您可以尝试使用std::vector来存储内存。std::vector元素是连续存储的,因此它将减少缓存未命中(请参阅什么是"缓存友好"代码?)

因此,您将有一个map<???,size_t>而不是map<???,std::string>,您将还有一个间接方法来获取字符串(这意味着额外的运行时间成本),但它允许您以更少的缓存未命中来迭代所有字符串。

如果你用MVCE重新创建你遇到的问题并展示出来,那就太好了:你知道,很多时候你想的问题就是你的问题。。。不是问题所在。

我如何确定以上2个内存问题是原因(任何工具/指标?)

根据这里的信息,我建议使用探查器-gprof(使用-g-pg编译)作为基本探查器。如果您有可用的英特尔编译器,您可以使用vtune。

vtune有一个免费版本,但我个人只使用了商业版本。

除此之外,您还可以在代码中插入时间:从文本描述来看,尚不清楚填充映射的时间是否与擦除映射所需的时间相当,或者它在并发运行时会持续增长。我会从if开始。注意,malloc()的当前版本也针对并发性进行了极大的优化(这是Linux吗?请在问题中添加一个标签)。

当然,当你擦除映射时,std::~string()会调用数百万个free(),但你需要确定这是否是问题所在:你可以使用更好的方法(答案/评论中提到了很多),或者使用一个由巨大内存块支持的自定义分配器,将其作为一个单元创建/销毁。

如果你提供一个MVCE作为起点,我或其他人将能够提供一个一致的答案(这还不是一个答案,但太长了,不能作为评论)

只是为了澄清,程序故意从不分配东西同时释放其他线程,并且它只有两个线程,一个专门用于删除。

请记住,映射中的每个字符串都需要一个(或多个)new和一个delete(分别基于malloc()free()),它们是键或值中的字符串。

你在地图的"价值观"中有什么

由于您有map<string,<set<int>>,因此您有许多分配:每次执行新键的map[string].insert(val)时,代码都会隐式调用字符串和集合的malloc()。即使键已经在映射中,集合中的新int也需要在集合中分配一个新节点。

因此,在构建结构时,您有很多分配:您的内存在一侧非常分散,并且您的代码看起来非常"malloc密集型",原则上可能会导致内存调用不足。

多线程内存分配/释放

现代内存子系统的一个特点是,它们针对多核系统进行了优化:当一个线程在一个内核上分配内存时,不存在全局锁,而是线程本地池的线程本地或内核本地锁。

这意味着,当一个线程需要释放另一个线程分配的内存时,会涉及非本地(较慢)锁。

这意味着最好的方法是每个线程分配/释放自己的内存。说原则上你可以用需要更少malloc/free交互的数据结构来优化很多你的代码,你的代码在内存分配方面会更本地化,如果你让每个线程:

  • 获取一个数据块
  • 构建map<string,<set<int>>
  • 释放它

您有两个线程,重复执行此任务。

注意:您需要足够的RAM来处理并发评估器,但现在您已经使用了其中两个同时加载的双缓冲方案(一个填充,一个清理)。你确定你的系统没有因为RAM测试而交换吗?

此外,这种方法是可扩展的:您可以使用任意数量的线程。在你的方法中,你被限制为两个线程——一个构建结构,一个去库存

优化

如果没有MVCE,就很难给出指示。只是你现在才知道是否可以应用的想法:

  • 用排序向量替换集合,在创建时保留
  • 用等距排序字符串的平面矢量替换贴图关键点
  • 将字符串关键字按顺序存储在平面向量中,添加哈希来跟踪映射的关键字。添加一个散列映射来跟踪向量中字符串的顺序

所以,多亏了所有的答案和评论,我没能选出最好的答案,部分原因是问题本身很模糊,没有一个答案能真正涵盖所有内容。但我确实从这些答案中学到了很多,因此我对其中的大多数都投了赞成票。以下是我在各种实验后发现的主要问题:

  1. 删除线程操作缓慢的原因会影响另一个线程。考虑到它不会在两个线程上同时执行malloc/delloc,不应该有任何堆争用,也不应该有通用CPU或可用内存处于瓶颈,剩下的唯一合理解释是内存带宽耗尽。我在另一篇帖子中找到了这个答案:it's generally possible for a single core to saturate the memory bus if memory access is all it does.我的删除线程所做的就是遍历一个巨大的映射并删除其中的每个元素,所以可以想象它会使内存总线饱和,所以同时进行内存访问和其他计算的另一个线程会大大减慢速度。从这里开始,我将重点讨论这种删除可能是缓慢的各种原因

  2. 这张地图是巨大的,有数百万个元素和数百兆字节的大小。删除它们中的每一个都需要首先访问它们,而且很明显,很少有能够放入L1/L2/L3缓存。因此,存在大量缓存未命中和从RAM提取的情况。

  3. 正如这里提到的两个答案/注释,我将std::string对象存储在地图中。每个都分配了自己的空间,必须逐个提取和删除。MSchanges的建议通过将string_view存储在映射中,同时将每个字符串的实际字节内容存储在预先分配的连续内存块中,从而更好地提高了性能。现在,在映射中删除一百万个对象变成了对仅仅是指针的string_view对象的几乎微不足道的破坏,而对所有字符串内容的破坏就是对预分配块的破坏。

  4. 在程序的其他部分中,我没有提到我也将其他C++对象存储在其他映射中。它们也同样存在问题。对这样的C++对象进行类似的"扁平化"是必要的,尽管如果没有像string_view这样的现成类,这很难做到。这个想法是,如果我们能存储尽可能多的基元类型和指针,并将所有内容(其中大多数可以归结为字符串)放在连续的字节缓冲区中我们的目标是让一切琐碎的东西都被破坏

  5. 最后,事实证明,地图容器本身的销毁成本可能相当高,尤其是当它很大的时候。对于基于节点的std容器,遍历和删除每个节点句柄都需要时间。我发现的替代实现是真正扁平的hashmap,这将使删除速度更快。这类地图的例子包括Abseil的flat_hash_map和这位博主的flat_ash_map。请注意,它们都是真正的hash_map,尽管它们是平面的。Boost的flat_map也可以很快删除,但它不是一个真正的hashMap,它由严格排序的向量支持,这使得插入(当我的输入没有排序时)非常慢。

这将是一个冗长的答案,因为您的问题非常复杂。

读取过程

当你读到一些东西时,你开始将内存分配到你的应用程序中。现在,在正常情况下,当您不需要性能时,问题就从哪里开始了。

STL映射是红黑树,因此它们有很多指针,这意味着每个元素都是单独分配的,这会造成内存空间非常分散的情况,系统很难有效地释放元素。原因:系统必须遵循指针。

合适的容器

STL映射说明:为什么std::map被实现为红黑树?

以下是关于映射内存管理行为的基本讨论。https://bytes.com/topic/c/answers/763319-stl-map-memory-management

根据你的描述,你阅读了一个巨大的文件,然后依次流式传输给某人。我的问题是,既然你说你必须流式传输数据,那么数据可以作为STL对存储到连续内存中吗?

你必须在里面搜索元素吗?如果是,那么你应该找出频率或频率,这个答案会告诉你STL映射是否是一个好的容器,因为它在搜索活动中很有效。

现在,在这个链接中有一些关于指针引用容器和连续容器的基准测试。https://baptiste-wicht.com/posts/2012/12/cpp-benchmark-vector-list-deque.html

这个想法是使用适当的容器,这样您就可以有正确的内存管理行为。

在使用琐碎密钥的情况下,使用map比使用unordered_map有什么优势吗?这里有一个替代地图的方法,在你开发出更精确的解决方案之前,它可能是一个廉价的快速破解。

内存管理

我在你的问题中的问题是,你能清理并重复使用你的容器吗?因为释放集装箱是一项昂贵的业务。

您可以使用STL映射的环形缓冲区,其中:一个已读取->一个已准备就绪->一个写入这将非常有效,并且可以为您提供优势,因为您不必释放任何缓冲区,只需在使用后清除即可。

编辑:以下是关于容器中频繁删除时发生的内存碎片的答案。什么是内存碎片?

你的问题是,你使用字符串,它们可以扩展内存,但在它们下面是字符的mallocs。现在我不会删除东西,而是标记它未使用或其他什么。

如果您在创建字符串时使用字符串保留函数,有一件小事可能会有所帮助。然后你可以说128,这意味着128个字节,会占用一点内存,但会使碎片处理更容易,字符串的重新分配行为也不那么困难。

现在,这可能也是完全无用的。若您在Linux上,您需要对您的应用程序进行评测,以了解最佳方式perf和Flamgraphs的运行情况。