多线程C++程序性能差

5
我在Linux上有一个C++程序,创建了一个新线程以独立于主线程进行一些计算密集型工作(计算工作通过写入结果到文件而完成,这些文件非常大)。然而,我得到的性能相对较差。
如果我直接实现程序(不引入其他线程),它大约需要2个小时才能完成任务。使用多线程程序,即使只生成一个线程,也需要大约12个小时才能完成相同的任务(已经测试过)。
我尝试了几件事情,包括pthread_setaffinity_np将线程设置为单个CPU(在我使用的服务器上有24个可用的CPU),以及pthread_setschedparam设置调度策略(我只尝试了SCHED_BATCH)。但是,这些的效果迄今为止都微不足道。
有没有一般性的原因导致这种问题?

编辑:我已经添加了一些示例代码,这是我正在使用的,希望是最相关的部分。函数 process_job() 实际上是执行计算工作的函数,但将其包含在此处太多了。基本上,它读取两个数据文件,并使用这些文件对一个内存图形数据库执行查询,其中结果在几小时内写入两个大文件。

编辑第二部分:仅澄清,问题不在于我想使用线程来提高算法的性能。而是,我想同时运行我的算法的许多实例。因此,我预计当将算法放在一个线程中时,它将以类似于完全不使用多线程时的速度运行。

编辑第三部分:感谢所有的建议。目前我正在进行一些单元测试(查看哪些部分正在减速),如一些人建议的那样。由于程序加载和执行需要一段时间,所以检查测试结果需要时间,因此对于延迟的回复我深表歉意。我认为我想要澄清的主要观点是使线程可能导致程序运行缓慢的原因。从评论中了解到的情况是,它根本不应该这样。如果我能找到一个合理的解决方案,我会发帖的,再次感谢。

(最终)编辑部分4:事实证明,问题与线程无关。在这一点上描述它将会太过繁琐(包括使用编译器优化级别),但是这里发布的想法非常有用且受到赞赏。

struct sched_param sched_param = {
    sched_get_priority_min(SCHED_BATCH)
};

int set_thread_to_core(const long tid, const int &core_id) {
   cpu_set_t mask;
   CPU_ZERO(&mask);
   CPU_SET(core_id, &mask);
   return pthread_setaffinity_np(tid, sizeof(mask), &mask);
}

void *worker_thread(void *arg) {
   job_data *temp = (job_data *)arg;  // get the information for the task passed in
   ...
   long tid = pthread_self();
   int set_thread = set_thread_to_core(tid, slot_id);  // assume slot_id is 1 (it is in the test case I run)
   sched_get_priority_min(SCHED_BATCH);
   pthread_setschedparam(tid, SCHED_BATCH, &sched_param);
   int success = process_job(...);  // this is where all the work actually happens
   pthread_exit(NULL);
}

int main(int argc, char* argv[]) {
   ...
   pthread_t temp;
   pthread_create(&temp, NULL, worker_thread, (void *) &jobs[i]);  // jobs is a vector of a class type containing information for the task
   ...
   return 0;
}

4
如果不了解您的代码主要内容,就很难给出准确的答复。主线程同时还在做其他事情吗?(如果主线程没有在做其他操作,那么多线程就是浪费时间。如果它同时也在进行磁盘访问,那可能会严重影响性能。) - Ry-
但是非线程程序也以相同的方式写入文件,并且完成任务速度更快...这就是我的困境。 - Aaron
@Aaron:那可能就是你的问题了。尝试在内存中保留一个“待写入事项”的队列,只有当数据可用时才让主线程进行写入。 - Ry-
1
@minitech 是的,没错。不过,对于我所做的测试,我只派遣了一个线程来执行任务(只进行了一次写操作),而这需要12个小时。 - Aaron
2
你尝试过计时线程代码路径的不同部分吗?某些部分较慢,但你不知道是哪些。尝试消除算法的不同部分。例如,不要将输出写入磁盘,而是将其丢弃(在单线程和多线程版本中都是如此)。 - jalf
显示剩余24条评论
9个回答

34
如果您有足够的CPU核心,并且有大量工作要完成,使用多线程模式运行不应该比单线程模式需要更长时间-实际的CPU时间可能会稍微长一些,但“挂钟时间”应该更短。我相信您的代码存在某种瓶颈,其中一个线程正在阻塞另一个线程。
这是因为以下一项或多项原因 - 首先列出它们,然后详细说明:
  1. 某个线程中的锁正在阻止第二个线程运行。
  2. 线程之间数据共享(真正的或“虚假”的共享)
  3. 缓存抖动。
  4. 争用某些外部资源导致抖动和/或阻塞。
  5. 总体设计不良的代码...

某个线程中的锁正在阻止第二个线程运行。

如果有一个线程获取了锁,而另一个线程想要使用被该线程锁定的资源,则必须等待。这显然意味着线程没有执行任何有用的操作。应该通过仅在短时间内获取锁来尽量减少锁的数量。可以使用一些代码来确定锁是否持有您的代码,例如:

while (!tryLock(some_some_lock))
{
    tried_locking_failed[lock_id][thread_id]++;
}
total_locks[some_lock]++;

打印一些锁的统计数据有助于确定锁定内容的争议性。或者您可以尝试“在调试器中打断点并查看当前位置”的老套路-如果一个线程不断等待某个锁,则阻止进度的就是这个锁……

线程之间的数据共享(真正的或“false”共享)

如果两个线程经常使用[并更新]相同的变量,则两个线程将不得不交换“我已经更新了此变量”的消息,并且CPU必须从另一个CPU获取数据,然后才能继续使用该变量。由于“数据”在“每个缓存行”级别上共享,而缓存行通常为32字节,因此类似于:

int var[NUM_THREADS]; 
...
var[thread_id]++; 

会被归类为“伪共享” - 实际更新的数据对于每个CPU是唯一的,但由于数据位于相同的32字节区域内,核心仍然会更新相同的内存区域。

缓存抖动。

如果两个线程频繁读写内存,CPU的缓存可能会不断地丢弃好的数据并用另一个线程的数据填充。有一些技术可用于确保两个线程不在“锁步运行”中使用CPU缓存的哪一部分。如果数据是2^n(2的幂)并且相当大(缓存大小的倍数),最好为每个线程“添加偏移量” - 例如1KB或2KB。这样,当第二个线程读取相同距离的数据区域时,它将不会覆盖第一个线程当前正在使用的完全相同的缓存区域。

竞争某些外部资源导致抖动和/或阻塞。

如果两个线程正在读取或写入硬盘、网络卡或其他共享资源,这可能会导致一个线程阻塞另一个线程,从而降低性能。代码也可能检测到不同的线程,并在与另一个线程开始工作之前执行一些额外的刷新,以确保数据按正确顺序写入或类似操作。
代码内部处理资源(用户模式库或内核模式驱动程序)时,如果有多个线程使用同一资源,则可能存在锁定。
通常是设计不良的"万能解决方案",用于描述"许多其他可能出错的事情"。如果需要从一个计算中的一个线程的结果来推进另一个线程,那么显然在该线程中无法完成很多工作。
过小的工作单元会导致大量时间用于启动和停止线程,而没有足够的工作被完成。例如,如果您将每个线程分配一个小数字来“计算其是否为素数”,则一次只能给线程一个数字,与“实际上这是一个质数”计算相比,可能需要更长的时间 - 解决方法是将一组数字(例如10、20、32、64等)分配给每个线程,然后一次性报告整个结果。
还有很多其他“糟糕的设计”。如果不了解您的代码,很难确定确切原因。
完全有可能您的问题不是我在这里提到的任何一个,但最有可能的情况是其中之一。希望这个答案有助于确定原因。

1
请注意,缓存行现在通常为64字节,而不是32字节。 - Gumby The Green

6

阅读CPU Caches and Why You Care,了解为什么从单线程到多线程的算法简单移植通常会导致极大的性能降低和负的可伸缩性。专门设计用于并行处理的算法可以避免过多的交错操作、虚假共享和其他导致缓存污染的原因。


4
以下是您可能需要关注的几个方面:
1°) 您的工作线程和主线程之间是否进入了任何关键部分(锁,信号量等)?(如果您的查询修改了图形,则应该是这种情况)。如果是这样,那么这可能是多线程开销的来源之一:线程竞争锁通常会降低性能。
2°) 您正在使用一个24核的机器,我假设它是NUMA(非统一内存访问)的。由于在测试期间设置了线程亲和力,因此您应该密切关注硬件的内存拓扑结构。查看 /sys/devices/system/cpu/cpuX/ 中的文件可以帮助您了解这一点(请注意,cpu0 和 cpu1 不一定靠近彼此,因此不一定共享内存)。重度使用内存的线程应该使用本地内存(在执行它们的核心所在的同一 NUMA 节点中分配)。
3°) 您正在大量使用磁盘 I/O。这是哪种类型的 I/O?如果每个线程每次都执行一些同步 I/O,则您可能需要考虑异步系统调用,以便操作系统负责调度这些请求到磁盘。
4°) 其他答案已经提到了一些缓存问题。从经验来看,错误共享可能会像您观察到的那样损害性能。我的最后建议(应该是我的第一个)是使用分析工具,例如 Linux Perf 或 OProfile。在您经历的这种性能下降情况下,原因肯定会变得非常清晰。

2
我无法告诉你程序出了什么问题,因为你没有分享足够的内容进行详细分析。
但是,如果这是我的问题,我会首先尝试在应用程序上运行两个性能分析器会话,一个在单线程版本上,另一个在双线程配置上。性能分析器报告应该可以让你很好地了解额外时间去哪里了。请注意,根据问题的不同,您可能不需要对整个应用程序运行进行分析,只需分析几秒钟或几分钟即可发现时间差异。
至于Linux下的性能分析器选择,您可以考虑 oprofile 或其次选项 gprof
如果您发现需要帮助解释性能分析器输出,请随时将其添加到您的问题中。

2
其他答案已经涉及到了可能导致您症状的一般准则。我会给出自己的版本,并谈一下如何在考虑到所有内容后找到问题所在。
通常,您期望多个线程表现更好的原因有以下几个:
- 一项工作依赖于某些资源(磁盘、内存、缓存等),而其他部分可以独立于这些资源或这种工作负载进行处理。 - 您拥有可以并行处理工作负载的多个 CPU 核心。
以上列举的主要原因都是基于资源争用,这就是你预计多个线程表现不佳的原因。
磁盘争用:已经详细解释过了,并且可能是一个问题,特别是如果您一次只写入小缓冲区而不是批处理;
如果将线程调度到相同的核心,则 CPU 时间争用:如果您设置了“关联性”,那么这可能不是您的问题。但是,您仍然应该再次检查;
缓存抖动:如果您具有关联性,那么同样可能不是您的问题,但如果它是您的问题,它可能非常昂贵;
共享内存:再次详细讨论,看起来不是您的问题,但审核代码以检查它也不会对您造成任何伤害;
NUMA:同样是被讨论过的。如果您的工作线程钉在不同的核心上,那么您将想要检查其需要访问的工作是否为主核心本地的。
到目前为止,没有太多新内容。可能是这其中的任何一种或全部原因。问题是,对于您的情况,如何检测额外的时间来自哪里。有几个策略:
- 审查代码并查找明显的区域。不要花太多时间做这件事,因为如果一开始就编写了程序,则通常是无效果的; - 重构单线程代码和多线程代码以隔离一个 process() 函数,然后在关键检查点进行分析以尝试解释差异。然后缩小范围; - 将资源访问重构为批处理,然后在控制组和实验组上分别对每个批次进行分析,以解释差异。这不仅会告诉您需要集中精力的区域(磁盘访问 vs 内存访问 vs 在某些紧密循环中花费时间),而且进行此重构甚至可能改善您的运行时间。例如: - 首先将图形结构复制到线程本地内存中(在单线程情况下执行简单的复制); - 然后执行查询; - 然后设置异步写入磁盘; - 尝试找到具有相同症状的最小可重现工作负载。这意味着将算法更改为执行其已经执行过的子集; - 确保系统中没有其他噪音可能导致差异(如果其他用户在工作核心上运行类似的系统)。
对于您的情况,我自己的直觉是:
  • 您的图形结构对工作核心不友好。
  • 内核实际上可以将您的工作线程调度到亲和力核心之外。如果您没有为您要固定的核心启用 isolcpu,则可能会发生这种情况。

1

追踪线程为何不能按计划工作可能会让人感到很痛苦。可以通过分析来解决,也可以使用工具来展示实际情况。我一直在使用ftrace,它是Linux版本的Solaris dtrace(而dtrace又基于VxWorks、Greenhill Integrity OS和Mercury Computer Systems Inc进行了长时间的开发)。保留html标签。

特别是我发现这个页面非常有用:http://www.omappedia.com/wiki/Installing_and_Using_Ftrace,尤其是thisthis部分。不用担心它是面向OMAP的网站;我在X86 Linux上使用也很好(虽然您可能需要构建内核来包含它)。还要记住,GTKWave查看器主要用于查看VHDL开发的日志跟踪,这就是为什么它看起来“奇怪”的原因。只是有人意识到它也可以成为sched_switch数据的可用查看器,这样就不用再编写一个了。
使用sched_switch跟踪器,您可以看到线程运行的时间(但不一定知道原因),这可能足以给您一些线索。其他跟踪器的仔细检查可以揭示“为什么”。

0
如果您使用1个线程时出现了减速,很可能是由于使用线程安全库函数或线程设置的开销。为每个作业创建一个线程将导致显着的开销,但可能不像您所说的那么多。换句话说,这可能是某些线程安全库函数的开销。
最好的方法是对代码进行分析,找出时间花费在哪里。如果是在库调用中,请尝试找到替代库或自己实现它。如果瓶颈是线程创建/销毁,请重用线程,例如在C++11中使用OpenMP任务或std::async。
一些库在线程安全方面确实非常讨厌。例如,许多rand()实现使用全局锁,而不是使用线程本地prgn's。这种锁定开销比生成数字要大得多,并且很难在没有分析器的情况下跟踪。
减速也可能源于您所做的小改变,例如声明变量为volatile,这通常是不必要的。

0

我怀疑你正在一台只有一个单核处理器的机器上运行。这个问题在这种系统上无法并行化。你的代码不断地使用处理器,而处理器提供给它的循环次数是固定的。实际上,由于额外的线程增加了昂贵的上下文切换,它运行得更慢。

唯一能够在单处理器机器上良好并行化的问题是那些允许一个执行路径运行而另一个被阻塞等待I/O的问题,以及保持响应GUI等情况,其中允许一个线程获得一些处理器时间比尽可能快地执行代码更重要。


0
如果您只想运行许多独立实例的算法,您可以提交多个作业(使用不同的参数,可以由单个脚本处理)到您的集群中。这将消除对多线程程序进行分析和调试的需要。我在多线程编程方面没有太多经验,但如果您使用MPI或OpenMP,则还可以减少编写“book keeping”的代码量。例如,如果需要一些通用初始化程序并且进程可以独立运行,那么只需在一个线程中初始化并进行广播即可。无需维护锁等。

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