线程数越多(没有同步),性能就越低

Performance decreases with a higher number of threads (no synchronization)

本文关键字:性能 同步 线程      更新时间:2023-10-16

我有一个数据结构(一个向量),其中的元素必须由函数解析,其中的元素可以由不同的线程解析。

解析方法如下:

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 ms 3线程:385 ms 4线程:360 ms 5线程:475 ms 6线程:580 ms 7线程:635 ms
  • 8线程:660 ms

创建线程所需的时间总是在1/2 ms之间。该软件已经通过使用其发布版本进行了测试。以下是我的配置:

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

您正在使用的处理器Intel(R) Xeon(R) CPU E5507只有4个核心(参见http://ark.intel.com/products/37100/Intel-Xeon-Processor-E5507-4M-Cache-2_26-GHz-4_80-GTs-Intel-QPI)。因此,如果线程数超过4个,就会因为上下文切换而导致速度变慢,这从您提供的数据中可以看出。

您可以在以下链接阅读更多关于上下文切换的信息:https://en.wikipedia.org/wiki/Context_switch

update:

我们仍然没有很多关于parse()的内存访问模式的信息,以及它从内存中读取输入数据所花费的时间与写/读私有刮擦内存所花费的时间。

您说p->parse()"根据XML节点的内容分配内存"。如果它再次释放它,您可能会看到在每个线程中分配足够大的暂存缓冲区会带来很大的加速。内存分配/释放是一个"全局"的事情,需要线程之间的同步。线程感知的分配器可以通过满足线程释放的内存分配来处理分配/释放/分配/释放模式,因此它可能在该核心的私有L1或L2缓存中仍然很热。

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


你的双插槽Nehalem Xeon没有超线程,所以如果一个非ht感知的操作系统在同一个物理核心的两个逻辑核心上调度两个线程,你就不会遇到线程相互减慢的问题。


你应该调查性能计数器(例如Linux的perf stat,或英特尔的VTune)是否你得到更多的缓存丢失每线程一旦你通过4个线程。Nehalem使用大型共享(用于整个套接字)L3(又名最后一级)缓存,因此在同一个套接字上运行的线程越多,对其造成的压力就越大。相关的性能事件类似于LLC_something, IIRC。

你一定要看看L1/L2缺失,看看它们是如何随着线程数的变化而变化的,以及在跨行访问和连续访问node[]时是如何变化的。

还有其他性能计数器,您可以检查以查找虚假共享(一个线程的私有变量与另一个线程的私有变量共享缓存行,因此缓存行在内核之间反弹)。实际上只是寻找任何随线程数变化的性能事件;这可能会给我们一个解释。


一个多套接字系统,如你的2套接字Nehalem将有NUMA (Non-uniform_memory_access)。支持numa的操作系统将尝试为内核分配更快的内存。

因此,假定您的缓冲区将其内存中的所有物理页面附加到两个套接字中的一个。在这种情况下,这可能不是您可以或应该避免的事情,因为我假设您在将其交给多个线程进行解析之前以单线程方式填充数组。但是,一般来说,在方便的情况下,尽量在使用内存最多的线程中分配内存(尤其是刮擦缓冲区)。

这可能在一定程度上解释了线程数的不完美缩放。虽然这更有可能与事物无关,如果@AntonMalyshev的回答没有帮助。让每个线程在一个连续的范围内工作,而不是以n_threads的步长跨越数组,应该会更好地提高L2/L1缓存效率。

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


跨行访问模式的一个可能的好处是:跨行访问意味着如果所有线程都以或多或少相同的速度运行,它们都在同一时间查看相同的内存部分。领先的线程会因为L3命中而减慢速度,而其他线程会因为看到L3命中而赶上来。(除非发生了什么事情,让一个线程落后太多,比如操作系统为一个时间片取消调度。)

所以也许L3与RAM带宽/延迟比每核L2/L1的有效使用更重要。也许有更多的线程,L3带宽不能跟上来自多核L2缓存的相同缓存行的所有请求。(L3的速度不够快,无法同时满足所有内核的恒定L2失误,即使它们都在L3中命中。)

只有当node[]的连续范围指向其他内存的连续范围时,这个参数才适用于node[]所指向的一切。

尝试解析线程内元素的连续范围,例如change

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并在循环的停止条件中使用它,而不是在每次迭代中计算它。

对于CPU受限的进程,添加超出可用内核数量的额外线程将降低整体性能。减少的原因是调度和其他内核交互。对于这种情况,最佳的线程数通常是核数-1。剩下的内核将由内核和其他正在运行的进程使用。

我在这里更详细地讨论这个主题一个最小多线程的例子

仔细观察硬件和数字,我怀疑您遇到了超线程争用。对于4核cpu,使用超线程模拟8核。对于一个完全受cpu限制的进程,超线程实际上会降低性能。这里有一些有趣的讨论超线程和细节维基百科超线程

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