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

Posted

技术标签:

【中文标题】在一个线程上删除具有数百万个字符串的大型哈希图会影响另一个线程的性能【英文标题】:Deleting large hashmaps with millions of strings on one thread affects performance on another thread 【发布时间】:2020-06-11 06:25:55 【问题描述】:

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

Evaluator* oldEvaluator = mEvaluator;
Evaluator* newEvaluator = parseDataSet();
mEvaluator = newEvaluator;
delete oldEvaluator;

//And then on request processing:
mEvaluator.lookup(request)

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

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

它曾经是一个使用事件驱动模型的单线程程序,但是每次放置一个完整的新集合并调用销毁时,删除整个事物花费的时间太长,因此阻塞了请求处理。

所以我将删除此类地图放到单独的线程中。问题是,虽然现在删除和请求处理似乎同时发生,但我可以看到请求处理线程的速度非常明显、急剧下降。

当然还有其他进程在主机上运行,​​我确实希望这 2 个线程竞争 CPU 周期。但我没想到请求匹配线程的速度会急剧下降。平均而言,一个请求应该处理 500us 级别,但在删除线程运行时,它的速度慢到 5ms。有时 cpu 会中断匹配线程(因为它花费的时间太长),它可能会持续 50 毫秒或 120 毫秒等。在极端情况下,一个请求可能会占用整个 1000 毫秒来处理,这大约是整个处理的时间数据结构删除需要另一个线程。

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

编辑:感谢几个 cmets/answers 似乎已经指出了几个可能的原因:

    内存碎片。因为访问频率较低的字符串存储在更昂贵的内存位置(因此缓存未命中),或者因为它存储在带有许多指针的 unordered_map 中,或者因为系统在删除整个地方的漏洞的同时进行内存压缩?但是为什么这会影响另一个线程的缓慢呢? 有一条评论提到它是由于线程安全锁定导致的堆争用?所以这个程序的整个堆被锁定是因为一个线程忙于删除阻止另一个线程访问堆内存的漏洞?澄清一下,该程序故意从不同时分配东西和释放其他东西,它只有 2 个线程,一个专用于删除。

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

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

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

【问题讨论】:

如果你有一个包含数百万个字符串的哈希映射,那么你的内存肯定会非常碎片化。考虑将字符串累积存储在某些容器中。并使哈希图为std::string_view 而不是std::string。其他选择是使用 std::pmr。 @MartinMorterol 非常感谢!我会好好阅读并尝试理解您分享的相关帖子并给出您的答案反馈! 您的数据是什么样的?键和值有多大?数据集有何不同?也许有比键值映射更好的存储方式。 请记住,C++ 运行时的堆是共享数据结构,因此对堆的访问(即内存分配和内存释放)可能会使用互斥锁(或类似的) 在大多数情况下,以避免在多线程操作期间损坏堆的元数据。为避免该瓶颈,您可能会调查将庞大的数据结构分配到其自己的私有堆上,以便在释放所有数据时,程序的其余部分可以继续不受干扰地运行。 (您甚至可以将拆解设为 O(1) 操作,只需将其堆重置为“空白”) 使用分析器查找瓶颈,例如perf record -g -cycles:ppp <app> 然后perf report 作为开始。或者在销毁旧缓存时附加perf record,然后将其分离。它比根据您的描述而不是代码来征求猜测要快得多,也最准确。 【参考方案1】:

可能值得为所有合并的数据存储一个std::string,并在地图中使用std::string_view。这消除了互斥争用,因为只需要一个内存分配。 string_view 有一个微不足道的析构函数,所以你不需要线程。

我之前曾成功使用此技术将程序速度提高 2500%,但这也是因为此技术减少了总内存使用量。

【讨论】:

仍然分配不是这里的问题。我编辑了这篇文章,以澄清分配是以节流的方式逐步完成的。出现的争用似乎是一个线程只访问内存中的这些字符串,而另一个线程删除了在该堆的其他部分分配的其他字符串。可能是由于大量缓存未命中,太多字符串破坏需要从 RAM 获取字符串到缓存,导致缓存争用(尝试将要删除的字符串加载到请求匹配线程尝试访问其字符串的同一缓存行)?听起来可能吗? @Superziyi 如果你只分配一个字符串,你也必须只释放一个字符串。那可能会更快。 @Superziyi:字符串销毁不需要触及字符串内容本身。但是 hashmap 会将字符串分散在内存中,并且您将有很多缓存未命中(当然 - 数百万个字符串无法放入缓存中。)。此外,访问字符串内容不需要堆互斥锁,但需要缓存。【参考方案2】:

您可以尝试使用std::vector 来存储内存。 std::vector 元素是连续存储的,因此会减少缓存未命中(参见What is a "cache-friendly" code?)

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

【讨论】:

哦刚刚意识到我忘了提到我的字符串被存储为键,并用于查找......所以这有点令人沮丧。抱歉我一开始没说清楚【参考方案3】:

如果您使用MVCE 重新创建您遇到的问题并将其展示出来,那就太好了:您知道,很多时候您认为的问题是您的问题......不是问题。

我怎样才能确定上述 2 个内存问题是原因(任何 工具/指标?)

鉴于此处的信息,我建议使用分析器 - gprof(使用 -g -pg 编译)是基本的。如果您有可用的 Intel 编译器,则可以使用 vtune。

有一个free version of vtune但我个人只用过商业版。

除此之外,您还可以在代码中插入计时:从文字描述来看,尚不清楚填充地图的时间是否与擦除地图所需的时间相当,或者在同时运行时它会持续增长。我会从如果开始。请注意,malloc() 的当前版本是 greatly optimized for concurrency too(这是 Linux 吗? - 请在问题中添加标签)。

当然,当您擦除地图时,std::~string() 调用了数百万个 free() - 但您需要确定这是否是问题所在:您可以使用更好的方法(许多在answers/cmets) 或由您作为单个单元创建/销毁的巨大内存块支持的自定义分配器。

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

澄清一下,程序故意从不分配东西和 同时释放其他人,它只有2个线程,一个 专门用于删除。

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

你在地图的“价值观”中有什么?

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

因此,您在构建结构时确实有很多分配:您的内存在一侧非常碎片化,并且您的代码看起来确实“malloc 密集型”,原则上这可能导致内存调用饿死。

多线程内存分配/释放

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

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

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

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

你有两个线程,重复执行这个任务。

注意:您需要 enoguht RAM 来处理并发评估器,但现在您已经在使用其中 2 个并发加载双缓冲方案(一个填充,一个清理)。您确定您的系统没有因为 RAM 耗尽而进行交换吗?

此外,这种方法具有可扩展性:您可以使用任意数量的线程。在您的方法中,您仅限于 2 个线程 - 一个构建结构,一个破坏它。

优化

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

用排序的向量替换集合,在创建时保留 将映射键替换为等间距、排序字符串的平面向量 将字符串键顺序存储在平面向量中,添加哈希以跟踪映射的键。添加哈希映射以跟踪向量中字符串的顺序。

【讨论】:

我已经插入了时间,所以这就是为什么我可以描述我的观察结果(fwiu 这就是分析器的用途)并且我编辑了帖子以反映填充地图不是一个问题,因为这是在节流中逐步完成的时尚,在请求匹配发生的同一个主线程上。我只是不能为删除做同样的节流(因为我不是手动滚动数据结构)。我没有在堆上同时分配和释放,但是从 2 个线程访问 L3/RAM 会导致争用?这些值是一组整数,所以map<string, set<int>>。我将尝试在 MVCE 上工作。谢谢! 目前尚不清楚删除过程是否偶尔会很慢,甚至是按顺序删除 - 它可能由于堆重组/碎片而发生 - 或者仅在多线程时发生 - 我在答案中添加了一些信息更多想法。 "从 2 个线程访问 L3/RAM 会导致争用吗?" - 这最终将与不同线程的内存使用有关,而不是 malloc()/free()【参考方案4】:

因此,感谢所有给出的答案和 cmets,我无法选出最佳答案,部分原因是问题本身含糊不清,没有一个答案能真正涵盖所有内容。但我确实从这些答案中学到了很多东西,因此对其中的大部分都投了赞成票。以下是我经过各种实验后发现的主要问题:

    删除线程操作缓慢的原因会影响另一个线程。鉴于它不会在两个线程上同时执行 malloc/dealloc,不应该有任何堆争用,一般 CPU 或可用内存也不是瓶颈,唯一合理的解释是 内存带宽耗尽。我发现this answer to another post 说:it's generally possible for a single core to saturate the memory bus if memory access is all it does. 我的所有删除线程所做的就是遍历一个巨大的映射并删除其中的每个元素,因此可以想象它会使内存总线饱和,因此另一个同时进行内存访问和其他计算的线程会急剧慢下来。从这里开始,我将重点介绍删除速度缓慢的各种原因

    地图很大,包含数百万个元素和数百兆字节的大小。删除它们中的每一个都需要先访问它们,而且显然很少有甚至可以放入 L1/L2/L3 缓存中。所以会有大量缓存未命中并从 RAM 中获取

    正如这里提到的几个答案/cmets,我将std::string 对象存储在地图中。每个都分配有自己的空间,必须一个一个地取出和删除。 The advise from MSalters 通过将string_view 存储在映射中 可以更好地提高性能,同时将每个字符串的实际字节内容存储在预先分配的连续内存块中。现在,删除映射中的一百万个对象几乎变成了对仅是指针的 string_view 对象的微不足道的破坏,并且所有字符串内容的破坏都是对该预分配块的破坏。

    我没有在程序的其他部分提到我还在其他映射中存储其他 C++ 对象。他们同样有问题。对此类 C++ 对象进行类似的“扁平化”是必要的,尽管如果没有像 string_view 这样的现成类就很难做到。我们的想法是如果我们可以尽可能多地存储原始类型和指针,并将所有内容(其中大部分可以归结为字符串)放在连续的字节缓冲区中。 目标是让一切变得微不足道

    最后,地图容器本身的销毁成本很高,尤其是在它很大的时候。对于Node-based std 容器,遍历和删除每个节点句柄需要时间。我发现真正扁平的哈希图的替代实现,将使删除更快。这种映射的示例包括Abseil flat_hash_map 和this blogger's flat_hash_map。请注意,即使它们是平坦的,它们也是真正的 hash_maps。 Boost 的 flat_map 也可以很快被删除,但它不是真正的 hashMap,它由严格有序的向量支持,这使得插入(当我的输入未排序时)非常慢。

【讨论】:

【参考方案5】:

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

读取过程

当您阅读某些内容时,您开始为您的应用分配内存。现在这在正常情况下是可以的,当您不需要性能时,问题就从哪里开始了。

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

适当的容器

STL图说明: Why is std::map implemented as a red-black tree?

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

根据您的描述,您读取了一个海量文件,然后您按顺序将其流式传输给某人。我的问题是dis数据可以作为STL对存储到连续内存中吗,因为你说你必须流式传输它?

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

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

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

Is there any advantage of using map over unordered_map in case of trivial keys? 在您开发出更精确的解决方案之前,这里有一个替代地图的方法,它可能是一种廉价的快速破解方法。

内存管理

我的问题是您能否清理并重复使用您的容器? 由于释放容器是一项昂贵的业务。

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

编辑:这是关于在容器中频繁删除期间发生的内存碎片的答案。 What is memory fragmentation?

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

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

现在这也可能完全没用。如果您在 Linux 上,您需要分析您的应用程序以查看最佳方式 perf 和 Flamgraphs 的情况。

【讨论】:

谢谢!抱歉,如果我没有说清楚,我的意思是“流式传输”,意思是批量数据更新的替代解决方案(在内存中分配新的并销毁旧的),我已经对其进行了编辑。我对这些地图的用例实际上只是为了快速查找,这也是为什么 unordered_map 用于存储数百万个可能字符串的容器的原因。重用此内存的问题是它需要以连续的方式分配(但每个字符串对象都是单独分配的),然后我需要手动执行 malloc 并预先知道大小。我不能使用向量 cos 字符串作为键,必须查找。 是的,所有数据都需要存在以确保准确性,所以不能做环形缓冲区。这只是非常标准的 hashmap 用例

以上是关于在一个线程上删除具有数百万个字符串的大型哈希图会影响另一个线程的性能的主要内容,如果未能解决你的问题,请参考以下文章

如何使用纯 Redis 原子地删除数百万个匹配模式的键?

如何有效地计算数百万个字符串之间的余弦相似度

如何在 C++ 中使用 Slot Map / Object Pool 模式管理数百万个游戏对象?

比较数百万个 mongoDB 记录中的变化的最佳方法

在 OpenGL 中实例化数百万个对象

给定数百万个点,找到位于线上或距线 0.2 毫米距离范围内的点 [关闭]