使用更多线程(无同步)会导致性能下降。

7

我有一个数据结构(向量),其中的元素需要通过函数进行解析,元素可以由不同的线程解析。

以下是解析方法:

void ConsumerPool::parse(size_t n_threads, size_t id)
{
    for (size_t idx = id; idx < nodes.size(); idx += n_threads)
    {
        // parse node
        //parse(nodes[idx]);
        parse(idx);
    }
}

场景:

  • n_threads是线程的总数
  • id是当前线程的(唯一)索引

线程的创建方式如下:

std::vector<std::thread> threads;

for (size_t i = 0; i < n_threads; i++)
    threads.emplace_back(&ConsumerPool::parse, this, n_threads, i);

很不幸,即使这种方法能够工作,如果线程数太多,我的应用程序的性能也会降低。我想知道为什么即使这些线程之间没有同步,性能也会降低。
根据使用的线程数,以下是经过的时间(在线程启动和最后一个join()返回之间): 2个线程:500毫秒 3个线程:385毫秒 4个线程:360毫秒 5个线程:475毫秒 6个线程:580毫秒 7个线程:635毫秒 8个线程:660毫秒
线程创建所需的时间始终在1/2毫秒之间。该软件已通过其发布版本进行了测试。以下是我的配置:
2x Intel(R) Xeon(R) CPU E5507 @ 2.27GHz

Maximum speed:  2.26 GHz
Sockets:    2
Cores:  8
Logical processors: 8
Virtualization: Enabled
L1 cache:   512 KB
L2 cache:   2.0 MB
L3 cache:   8.0 MB

编辑:

parse() 函数的作用如下:

// data shared between threads (around 300k elements)
std::vector<std::unique_ptr<Foo>> vfoo;
std::vector<rapidxml::xml_node<>*> nodes;
std::vector<std::string> layers;

void parse(int idx)
{
    auto p = vfoo[idx];

    // p->parse() allocate memory according to the content of the XML node
    if (!p->parse(nodes[idx], layers))
        vfoo[idx].reset();
}

1
即使您有2个CPU,您确定操作系统看到/使用了它们吗?这也可能是虚假共享的情况,当所有内容都在一个CPU上运行时,这种情况会最小化,但一旦您切换到使用两个CPU,它就开始变得更加严重。 - NathanOliver
函数parse会修改nodes吗,还是只是读取它? - Adrian Jałoszewski
1
@BiagioFesta:“线程创建所需的时间始终在1/2毫秒之间”,这是不符合逻辑的。 - underscore_d
2
那么在parse()内部应该进行同步或者使用相同的资源,也许这个函数是内存带宽关键。 - Anton Malyshev
1
@shrike 差不多有 300k 个元素。 - Nick
显示剩余7条评论
5个回答

7

1
在这种情况下,您可能会遇到内存瓶颈问题,您应该检查每个线程所需的内存带宽以及是否有足够的内存带宽可用。这可能是导致您目前结果的原因。 - Nemanja Trifunovic
@MatthewFisher 的 parse() 函数基本上读取并解析一个 std::string(对于每个线程都是共同的),并根据其内容分配新的数据结构。 - Nick
1
@Nick 你的意思是每个线程都在查看同一个std::string吗?如果是这样,尝试复制并分配给每个线程。通常情况下,对于具有单个内存(NUMA)银行的盒子,只读访问应该是可以的,但值得一试。另一个可能性是由于字符串实际上非常大而导致的L2 / L3缺失。在这种情况下,尝试将字符串切成缓存行大小的位,然后查看效果如何。此外,请参阅我的有关超线程争用的答案。 - Matthew Fisher
1
@MatthewFisher 是的,这个字符串非常大(几MB),我只能通过使用指向其某些位置的指针来访问该字符串。当您说将字符串切成缓存行大小位时,您能详细说明一下吗?+1 - Nick
1
@Nick 根据业务问题的不同,可能可以将您的字符串分成更易处理的块。如果可以这样做,让不同的线程按照缓存行大小(64字节?)处理这些块将是最具缓存效率的方法。假设现在有一个情况,线程1处理从0到1mb的字节,线程2从1mb到2mb,以此类推。现在每个线程都会处理一部分字符串,而这部分字符串的长度是缓存行大小的某个倍数。通过这种方式,我们可以将输入数据与缓存结构最优化,性能差异可能是数量级的。 - Matthew Fisher
显示剩余3条评论

3

更新:

我们仍然没有关于parse()内存访问模式的大量信息,以及它花费多少时间从内存中读取输入数据,以及多少时间花费在写入/读取私有临时内存上。

您说p->parse()“根据XML节点的内容分配内存”。如果它再次释放,您可能会看到保持足够大的临时缓冲区在每个线程中分配会带来巨大的加速效果。内存分配/释放是一种需要在线程之间进行同步的“全局”操作。一个线程感知的分配器可以通过满足线程刚刚释放的内存的分配来处理分配/释放/分配/释放的模式,因此它可能仍然在该核心的私有L1或L2缓存中处于热状态。

使用某种性能分析工具找到真正的热点。这可能是内存分配/释放,也可能是读取某些内存的代码。


你的双插槽 Nehalem Xeon 没有超线程,因此如果非超线程感知操作系统将两个逻辑核心调度到同一物理核心上,你不会遇到线程相互减速的问题。
你应该使用性能计数器(例如Linux的perf stat或英特尔的VTune)进行调查,以确定一旦超过4个线程,每个线程是否会出现更多的缓存未命中。 Nehalem使用大型共享(整个插槽)L3(又称最后级别)缓存,因此在同一插槽上运行的线程越多,对其造成的压力就越大。 相关的perf事件将类似于LLC_something,如果我没记错的话。

您应该绝对查看L1 / L2未命中情况,并查看它们随线程数的扩展方式,以及如何通过连续/跨步访问node []来更改它们。

您可以检查其他性能计数器,以查找虚假共享(一个线程的私有变量与另一个线程的私有变量共享高速缓存行,因此高速缓存行在核心之间反弹)。 真的只需要寻找任何随着线程数而改变的perf事件; 这可能指向一个解释方向。


一个像你的2插槽 Nehalem 的多插槽系统将具有 NUMA(非统一内存访问)。NUMA 意识的操作系统会尝试分配对执行分配的核心快速的内存。
因此,假设您的缓冲区所有物理页面都在连接到两个插座之一的内存中。在这种情况下,您可能不能或不应该避免它,因为我假设您是以单线程方式填充数组,然后将其交给多个线程进行解析。总的来说,当方便时,请尝试在最常使用它的线程中分配内存(特别是临时缓冲区)。
这可能部分解释了线程数不完美扩展的原因。虽然如果 AntonMalyshev 的回答没有帮助,更可能与此无关。每个线程在连续范围上工作,而不是用步幅 n_threads 穿过数组,应该更好地提高 L2 / L1 缓存效率。

node[] 是一个指针的向量(所以在使用8个线程时,每个线程只使用了它触及到的每个64字节高速缓存行中的8个字节)。然而,每个线程可能会触及指向的数据结构和字符串中更多的内存。如果node 条目指向其他数据结构和字符串中单调递增的位置,则对 node[] 的跨步访问会创建对线程触及的大部分内存的非连续访问模式。


一种可能的分步访问模式的好处:分步意味着如果所有线程以更或少相同的速度运行,它们都会同时查看内存的相同部分。领先的线程将因为L3缺失而减慢速度,而其他线程则会追上,因为它们看到了L3命中。(除非发生某些事情使一个线程落后太远,例如操作系统取消其时间片。)
因此,L3与RAM带宽/延迟可能比有效使用每个核心的L2/L1更重要。也许随着线程数量的增加,L3带宽无法跟上多个核心的L2缓存对相同缓存行的请求。(即使它们都在L3中命中,L3也不足以满足来自所有核心的持续L2缺失。)
这个论点仅适用于node[]指向的所有内容,只有当连续的node[]范围指向其他内存的连续范围时才成立。

@Nick:已更新。内存分配/释放可能是一个问题。你应该进行性能分析,以便更详细地了解热点在哪里。 - Peter Cordes

3
尝试解析线程内的连续元素范围,例如更改。
for (size_t idx = id; idx < nodes.size(); idx += n_threads)
{
    // parse node
    parse(nodes[idx]);
}

为了

for (size_t idx = id * nodes.size()/n_threads; idx < (id+1)*nodes.size()/n_threads; idx++)
{
    // parse node
    parse(nodes[idx]);
}

这样做会更有利于缓存。

另外,最好预先计算 size = (id+1)*nodes.size()/n_threads 并在循环的停止条件中使用它,而不是在每次迭代中计算它。


不幸的是,我没有得到非常不同的结果。当我使用所有核心时,它会有所改善,但同时当我使用4个线程时,情况会变得更糟。 - Nick
你尝试过在处理之前将nodes的所需部分复制到本地数组中吗?这样可以避免内存和缓存多核访问性能下降。 - Anton Malyshev

2
对于CPU绑定进程,超出可用核心数量的线程将降低总体性能。这种降低是由于调度和其他内核交互引起的。对于这种情况,最佳线程数通常是核心数-1。剩余的核心将被内核和其他正在运行的进程使用。
我在这里稍微详细地讨论了这个话题: A case for minimal multithreading 仔细观察硬件和数字,我怀疑你遇到了超线程争用问题。对于一个4核CPU,通过超线程模拟了8个核心。对于完全绑定于CPU的进程,超线程实际上会降低性能。这里有一些有趣的讨论:Hyper-threading 和详细信息请参见 Wikipedia hyper-threading

这不完全正确。超线程有助于某些类型的 CPU 密集型任务,例如受内存或指令延迟、分支预测错误或其他不会饱和任何核心共享资源的任务。共享的资源包括 L1/L2 缓存容量或 ALU 执行资源,如 FP 乘法吞吐量。如果没有两个线程共享同一个核心是有帮助的,那么超线程就没有存在的理由。第一批 HT CPU 是 P4,其执行资源非常有限,几乎不值得使用。但 Nehalem 不同。 - Peter Cordes
例如,HT在视频编码(x264)方面有很大的帮助,这肯定会充分利用所有的硬件线程。 - Peter Cordes
@PeterCordes 有趣的观点!我在这个领域的工作关注的是延迟而不是吞吐量。将线程与 CPU 亲和并关闭超线程是最低延迟的最佳模型。我可以理解你对视频编码示例更感兴趣的是跨所有核心的吞吐量,而不是单个核心的延迟。Nick,正如你所看到的,这个话题很复杂,一定要让我们知道它的结果。 - Matthew Fisher
顺便提一句,那款 Nehalem Xeon 没有超线程技术。是的,这就解释了为什么超线程技术对于延迟不利。当一个核心上有两个硬件线程时,许多资源都被静态分区,例如获取/解码/发布带宽。其他资源是竞争共享的(例如执行端口和缓存)。查看 Agner Fog 的微架构 PDF(http://agner.org/optimize/),他的优化汇编指南中有一些关于何时使用 HT 会有益(例如分支重代码)以及何时会达到平衡点(例如每个时钟周期已经运行近4个 uops 的代码)或产生负面作用(例如缓存大小)的一般建议和讨论。 - Peter Cordes
1
@PeterCordes 很好的观点。在这种情况下,Nick忘记了所有那些超线程垃圾。你的盒子有两个实际的处理器?看起来这就是你原来的帖子所说的,现在我仔细看了一下(2x)。这很可能是一个L3缓存管理问题。确保您不要使用多个线程写入公共区域。这将使缓存失效并导致从主内存中获取。在多CPU系统上,这特别糟糕,因为其中一个CPU将被迫从其他内存库中读取。 - Matthew Fisher
在回答时,我考虑了NUMA问题,但OP对Anton的回答的评论表明将跨步访问模式更改为连续并没有帮助太多/根本没有帮助,这表明内存带宽到“nodes”数组不是问题。或者实际上,让所有线程都在其同一部分上工作可以减少L3占用空间,从而在共享L3缓存中为线程之间提供局部性。因此,也许L3大小才是问题,而不是L2 / L1使用效率。 - Peter Cordes

2

2个CPU(每个CPU有4个内核) 线程在共享内存空间中运行。性能下降是由于在CPU之间移动共享内存引起的(线程无法直接访问不同CPU中的缓存,更多的线程 => 更多的移动 => 更大的性能下降)。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接