多核机器上多线程内存敏感程序的可伸缩性

 

原文出处:http://www.codeproject.com/KB/cpp/rtl_scaling.aspx

翻译此文也是一个学习的过程,因为在多CPU下的编程和单CPU下看起来区别巨大,就方向而言,OpneMP,MPI,TBB发展的都很快,当然快速也许就意味着一些技术会被淘汰。而本文只是对现有单核编程的改良,和上述方向无关,只是为了尽可能解决现有程序的问题,而尽量做最小改动。

 


内容列表:

1.  简介

2.程序不可伸缩

3.测试和结果

4.相关知识

5.解决方案一:对每一线程进行内存管理

6Unix思考方式vs. Windows思考方式

7.解决方案二:多进程

8.结论

9.附录A:未来

10.附录B:附件说明

11.附录C:参考文献

 

简介:

当你把程序放在双核机器上运行时,程序的运行速度将2倍于单核机器?1.5倍于单核机器?或者一个奇怪的结果双核跑的比单核机器更慢?

 

在这里,本文对一个运行在SMMPSMMP指共享内存的多处理器系统)架构计算机上的服务器程序的可伸缩性进行了研究。由于目前关于这个话题并没有太多的资料,因此我在分享经验的同时,也希望大家能够对这个问题进行更深入的讨论和研究。事实上,这篇文章涉及到的内容只是开发者面对多处理器编程时遇到的众多问题中的一个,我的经验并不能完全覆盖这个话题,还有非常多的有趣问题值得我们研究。

 

 

程序不可伸缩:

肯定既是否定,提到程序的不可伸缩就必须定义程序的伸缩性。程序可伸缩意味着程序能够完全利用硬件所提供的所有资源(金钱)。在有两个CPU的情况下,程序应该工作的更快。比如说,对于两台独立的计算机,分别运行于其上的程序毫无疑问将充分利用资源,尽其所能的使工作更加迅速。

 

非常凑效的是,我所负责的项目中有一个背景服务使用了一个第三方COM组件。这个组件被用于数据统计,一旦它开始运行将分配大量的内存并占用CPU直到100%。由于第三方代码的不可知性,因此对于我而言,它的执行消耗相当于一个常量,能够很好的被用于我的测试分析。(实话实说,组件本身的执行效率相当不错)

 

在一个单核机器上,当程序启用多个线程运行COM组件时,背景服务的执行情况非常的好预测。23个并行工作线程在处理相应多的文档时,花费的总时间只略微比运行单线程时稍稍多了一些而已。而单个线程处理文档的所占用的时间,和线程数目成线形关系。因此讨论这个问题并没有太多的意义,它和单个CPU上只运行一个线程的情况类似。

 

有了上面的结果,我们很自然的期待:当程序在一个双核机器上运行时,其效率能够得到明显的提升,如果没有2倍的话,至少也有1.7倍吧。但是测试的结果出乎我们的意料,使用两个并行线程竟比使用一个线程整整慢了1.5倍。不仅如此,它的速度甚至比在单核机器上使用单线程运行时的速度还慢。测试的结果表明:第二个CPU由于某种原因使程序运行变慢了,因此服务程序在多核机器上不可伸缩。

 

在对于上述测试结果分析时,我首先想到的是第三方COM组件中的同步问题,但是根据开发者的描述,他们仅在对象实例中使用了一小点的内部同步,测试结果不应会表现出如此巨大差异。并且即使第三方组件真的有问题,在我的项目中也没有可以替换它的其它组件,同时对组件进行修改的主意也相当的不现实。因此我被迫去查找其它可能的原因。

 

在网上查阅了相关的书籍和资料后,我开始怀疑问题出自于实时库的动态内存管理。一个多线程的应用程序必然使用多线程的内存管理器(指VCMulti thread lib),而这个内存管理器必然采用某种策略为所有线程从同一个内存池中分配和释放内存,如果一个应用程序在DLL       中创建进程内组件的多个对象时,每个COM组件对象必将为取得同一个内存管理者而竞争。

 

因此,对于一个内存敏感的多线程程序而言,线程之间会彼此等待公用内存管理器的释放。这在单CPU的系统中并不是什么大的问题,因为在一个时刻只有一个线程在运行。但是在一个共享内存的多核系统上问题就来了。因为当有一个线程用到内存管理器时,内存管理器将停止所有的其它的CPU运算(注:SMMP的多个CPU共享内存)(我的理解是由于程序是内存敏感的,线程的绝大多数时间用在分配内存上,所以一个线程使用公共内存管理器时会阻塞其他CPU的运行)。甚至在一个单核的计算机上,如果我们考虑具有超线程的奔四处理器时,这也将是一个问题。

 

很好,上面的解释能够说明为什么程序在一个多核机器上性能不能提高,但如何解释效率出现下降呢?这个原因我将在章节4中给出。现在还是来看一下实验给出的数据。

 

 

测试和结果:

为了验证问题是由内存管理器引起的,我写了一个简单的测试程序来配合本文进行说明。

 

下面是我想到的四种测试方案:

1.  纯的计算测试

这个测试只进行一系列的数学运算,它在多CPU机器上的伸缩性应该表现良好。这里我没有牵涉到任何算法,我只是简单的想测试CPU的性能,牵涉到的数据只缓存在CPUCACHE中,并不会牵涉到内存操作。

2.  纯的内存分配和释放测试

如果猜测正确的话,这个测试的结果应为:在多CPU机器上可伸缩性非常差。为了模拟大量的内存分配,我简单的使用了STL STD::LIST容器,在容器中我申请了一系列的单个字母。由于LIST是个非顺序的容器,它将分配并且释放每一个字母,而这正是我想要的。由于STL库中默认的内存分配方式为使用运行时库的内存管理器,所以任何使用运行时库的内存管理器的程序应该都有相同的行为。

3.  混合内存分配和释放以及写/读测试用例

这个测试用例的可伸缩性的结果应该是未知的。它依赖于两个部分的比例。它的实现部分同测试用例2 非常的相似。我只是对容器中的数据不断进行加法,并且做一些简单的计算。在循环中,内存管理器应该可以得到释放,并为其它线程所使用。我并没有对测试用例的这两个部分进行过多平衡,以使的测试结果趋向可伸缩或者不可伸缩。

4.  纯的内存读/写测试

这个测试应该同第一个纯计算测试用例一样,是可伸缩的,当然它可能会损失一点效率,这是由于线程共用内存总线,而总线带宽有限制造成的。

 

在测试时,每个测试方案的不同线程的测试用例的单个迭代时间,都被设计成只有4-6秒。测试的硬件环境:一台为单核奔3 850MHz256兆内存;一台为奔4 2.4GHz2Gb内存。任何产生于寄存器中的测试数据其大小都被精心设计,能够直接存储于内存中,因此硬盘的IO读写不会影响测试结果。顺便说一句,当单线程运行的时候,我注意到我的奔3 850MHz只比奔4 2.4G的机器慢两倍。我猜想其原因是由于不同版本Windwos2000对于前台任务具有不同优先级。这一点你能够从Window 2000为家用电脑和服务器电脑提供不同版本得到验证。

 

测试时,我对于不同数量的并行线程用例每个都执行了24次循环,我称为一套测试。然后每套测试再重复3次,并记录其结果。其中并行线程的数目分别是:12346

1.  一个线程执行24次测试迭代。(1 * 24 = 24

2.  两个并行线程各执行12次测试迭代(2 * 12 = 24

3.  三个并行线程各执行8次测试迭代(3 * 8 = 24

4.  四个并行线程各执行6次测试迭代(4 * 6 = 24

5.  六个并行线程各执行4次测试迭代(6 * 4 = 24

 

于是测试执行的总时间是(5 * 24个文档 * 5 种线程组合 * 4 种测试用例 * 3次循环 = 2个小时或者更多(在奔3 机器上)

 

在单核的机器上,结果非常的可预测。测试14 不管开多少个并行线程,都花费了差不多的运行时间。当然理论上随着线程数的增多,测试用例所花费的时间将由于线程上下文切换而增加,但是在实际当中我并没有观察到这一点。对于14而言,多线程测试用例所花费的时间随着测试的迭代次数呈线性增加。而存在着内存分配的测试用例23随着线程数的增加其执行效率则呈二次函数而下降。出现这种现象的原因是CPU的缓冲已经不能够容纳所有并行线程的数据。线程的数目越多,则内存被占用的越多,则CPU缓存的效率越低。而测试用例4 不受这个问题的影响是因为所有并行线程都能够接近同一块的内存,因此充分的利用了CPU的缓存。

 

 

为了使测试的分析结果看起来更加的直观,我对所有的测试结果进行了归一化,以百分比代替了直接得到的时间数据。并且在3套重复的测试数据中,我也同样的只选择了1套更加合理的数据结果。在文档的测试附件里,我仍然保留了最初的测试数据。

 

同单核机器比较,双核机器上的测试结果显得非常有趣。纯的计算测试方案在双线程下,速度达到了单线程的200%,而在4线程下速度则达到单线程的400%CPU的效率得到的充分的体现。这里我必须提到的是测试用的这台计算机具有超线程能力,因此Windows 2000认为系统拥有4CPU从而在任务管理器中显示出4CPU窗口。而纯内存读/写测试就像预测的那样有点损失了效率(两个CPU和两个缓冲会彼此竞争一个内存总线),但仍然可伸缩。

 

同上面两个用例相比,在同一时刻,当多个线程并行运行时,纯内存分配测试用例中程序执行的效率会显著的下降。有着4个并行线程的测试用例处理相同多的文档,所花费的时间是单个线程的两倍。不要把这个结果同单核机器的4 线程测试用例相混淆,这里我们有2个真实的物理的CPU,如果算虚拟的话将有4 个(因为在CPU内部采用超线程技术)。在6 个线程运行时,操作系统也许作了某些优化。当每个真实的CPU有超过2 个的线程允许访问缓存时,通过内存管理器分配数据,看起来更加有效率(这个结论可能和我自己的推测相违背,不过对于2个缓存的系统来说,缓存之间的协作对于效率影响要甚于单缓存的无效率性)。混合测试用例在2个并行线程运行时,效率非常高,但是当线程数增加时,其曲线走势和纯内存分配测试用例相同。

 

 

现在我们来研究一下测试程序在多核机器上执行效率下降的如此的利害的原因。首先,我们必须了解基于Intel x86架构和微软Windows操作系统上的多核处理器的基本特性。

 

 

相关知识准备:

NT4.0开始,微软窗口系统就是SMP操作系统(SMP指对称多处理器)。简单的说,它意味它的内核可以处理来自任何一CPU的中断。(Windows 3.51的执行是异步多处理系统并且只能够在CPU 0上处理中断,这样的系统在特定工作环境下,CPU很自然的将成为系统的瓶颈)。NT4.0的这种结构也意味着所有的拥有相同优先级的线程将会被平均的分配到所有可能的核上。当操作系统为线程分配CPU时间的时候,总是喜欢为线程选择最后一次运行的那个核,这样的话缓存会更加有效率(这种结果称为CPU共鸣)。而从某一个特定的时候去看,总是只有一个激活的线程运行在多核的机器上。拥有多核和单核没有什么区别,第二个核并没有工作。这种细微的差别通常只有在运行多个并行请求的服务器上才能体现出来。

 

Intel家族系列处理器采用了多处理器共享内存的架构(简称SMMP)。这意味着每个CPU拥有自己的一级缓存和二级缓存以及内部总线,但是所有CPU工作时却使用同一个共享的内存总线。在实际当中,在多个处理器共享主存的情况下,任意处理器访问共享主存时,任一处理器的内部缓存发生变化,都将会把相应数据立刻写到主存当中去。虽然大多数时候,这种Intel缓冲协作协议(MESI)的做法非常的有效率(这一点可以在Windows 2000的性能手册,5.2节处找到相关内容)。但是,如果两个或多个线程同时从同一块内存区读/写将引起CPU频繁的刷取对应的缓存内容到主存中,而这个操作将停止了线程原有的操作流程。这种情况在一个单线程中并不会发生,因为只有在缓存有新任务时,才会把缓存内容写到主存当中去。

 

理论上说,在没有超线程支持的双核上,Windows 2000将比单核机器效率上快1.7倍(见Windows 2000 性能手册 5.2节)。一个四核的系统将比单核机器快3倍(这同我们纯计算测试的结果非常的相似)。但是观察我们的纯内存分配测试用例,在并行线程中的过度数据依赖性使得缓存的连贯性受到严重的冲击,从而影响运用程序的性能。更加有趣的事情是这种数据的依赖性并不是由用户非正确的算法和错误的程序数据共享所造成。当我们使用c++语言编程时,我们习惯于使用new/delete操作符去分配内存,每个线程维护其自身的数据独立性。可是问题的根源在于,运行实时库在并行线程之间共享了内部数据结构。

 

大多数时候运行实时库并不会共享数据结构,不幸的是在分配内存上,数据结构确实被共享了。当程序启动后,实时内存管理器从操作系统处申请了大块的虚拟内存供所有线程在需要内存时使用。通过运行实时库的内存分配方式获取内存的策略要比直接使用操作系统内存分配策略更加令人满意。c++语言其本身并不支持并行线程,每一个指针都可以被进程中其他线程随意的访问。因此运行实时库的内存分配原则是使用共享数据结构去管理内存块。c++版本的运行实时库出于程序安全的考虑,对所有的共享数据结构作了同步,以使其免于崩溃的命运。在这种情况下,如果应用程序频繁的分配和释放内存,那么这些共享数据就必然成为了多核机器上的效率瓶颈。

 

当然,这个结论决不意味着多核计算机没有效率,这也不意味着编程语言本身缺乏效率,这仅仅意味着我们在使用编程语言时有一些需要注意的地方。这个结论也同时表明在一个单核机器上运行良好的程序在多核机器上可能效率并不能得到提高并且会有所下降。

 

为了使应用程序变得可伸缩,最好能够隔离并行线程。更准确的说,必须尽可能隔离每个线程的标准内存分配。这可以使得线程之间的自旋锁定和缓存一致性问题得以最小化,从而使CPU变得更加有效率。

 

 

解决方法1:每个运行COM对象的线程一个内存管理器

最明显并且是最有效率的解决方法是每个需要内存的对象实例拥有一个定制的内存管理器。这里我还是想用具体的上面提到的那个复杂的统计COM对象事例来说明问题。当使用一个用户定制的内存管理器后,用户定制的内存管理器将接管分配计算用到的所有内存,从而使运算变得有效率。当然,最简单的方法是:在程序一开始就分配好所有的内存,反复使用,并在程序最后一次性的释放它,并且使用这种用法不用考虑任何同步问题。

 

对于一个真实的具体应用而言,许多种内存分配策略都可以被使用。你可以简单的使用HeapCreate函数为每一个线程或对象创建一个动态的堆。Windows xp/2003引进了一种建立在HeapCreate上的新的低内存碎片的动态堆概念。对于这个有趣的概念,你可以阅读Andrei Alexandrescu的文章《现代C++设计:范型编程和设计模式的应用》。当然你也可能习惯运用来自开源库ACE Framework的内存分配类。同样的关于这个话题,在CodeProject网站上也有相关文章。

 

而在我的特殊例子里,这个解决方案是行不通的,因为有问题的组件来自于第三方的DLL。于是我开始寻找别的替代解决方案。

 

 

 

Linux思考方式 vs Windows 思考方式

在很长很长的时间里,Unix社区里都没有任何线程的概念。在Unix里复制一个进程是如此的快速和有效,因此后台任务可以被设计成为一个分离的进程而独立运行。这非常符合Unix设计哲学,通过一系列的细小的高度自治的命令行工具以管道形式组成所有的一切。由于每个进程运行在各自独立的地址空间上,因此当一个进程失败崩溃时绝对不会危及其它的进程。复制进程的一个坏处是在进程间交换数据时或多或少需要用到复杂的进程间通讯协议(IPC协议)。

 

Windows操作系统,差不多从一开始就支持线程。其好处在于,在同一进程中的线程因为拥有相同地址空间,从而拥有着更高效的内部数据交互效率。但是这也带来了例子中的坏处,由于多线程协作工作的需要,设计会变得更加复杂从而有可能影响程序的运行以及稳定性。

 

不过,就像其它任何技术一样,只要合理的使用线程,线程可以成为一个非常有用的工具。许多Unix版本已经开始支持线程(比如说,在Linux中线程就是最新的增加物)。一个合理设计的多线程服务器在大多数时候都将比同样合理设计的多进程程序要更富有效率(因为线程设计不需要牵涉到任何IPC协议)。

 

而换个角度来看这个问题,复制进程的设计能使进程彼此之间相互隔离,也同样能够避免内部运行实时库函数(RTL)带来的同步问题。事实上,正是这一点给了我主意去解决这个问题。如果每一个并行任务都能在一个并行进程中运行或者能够在一个单独的COM对象的实例中创建的话,任何多线程实时运行库带来的基础函数带来的同步问题的由于没有并行访问的问题而都将不存在。每一个进程对于同一个DLL都有自己单独实例,因此它们不会彼此影响。

 

 

解决方案2:复制进程

实际上这个方案的实施非常的简单。我需要做的唯一一件事就是选一个合适的IPC协议来管理多进程通讯。除此之外还需要解决昂贵的进程创建问题,即为每一个处理文档创建一个进程将使整个系统的执行变得非常的无效率(因为每秒钟需要处理上百个文档)。另外一个令我棘手的问题是客户程序是由java写成,并使用了JNIJava Native Calls)接口,因此它需要的DLL必须是严格能够重入的(由于我重复使用了java对象的属性并改变了它的值,所以多进程调用可能导致最终运算结果得到一个错误的值。但是最终我还是决定匹配一些指针到java数据类型上,虽然我不确定这种做法具有安全性)。

 

首先我必须保证输入和输出数据的简洁性(大约1 KB左右),同一时刻只有非常少的进程在并行计算(无论如何,同一时刻如果有2-3个并行程序运行在CPU的一个核上始终是种无效率的行为)。每个进程依输入数据的不同,处理文档的时间从数十秒到几分钟不等,但大部分时间都在控制在0.1秒到10秒之间。

 

其次我决定采用java程序所需用的JNI DLL中的共享内存表来完成输入输出数据的交互。这个DLL申明了一个共享内存表,每个进程分别传入参数并得到结果。而共享内存表通过有名互斥量和事件来实现同步。进程任务载入DLL并自我注册。系统本身是自我调节的,如果有一个进程崩溃的话,DLL将创建一个新进程来继续运算;如果一个进程任务长时间得不到输入数据,将自行退出。虽然共享内存表有一个真正的瓶颈,即它本身;但是在我所设计的系统中,这个问题并不存在;一个4核的服务器当然可以运行8个并行进程任务(这里假设系统为超线程设计),在一个共享内存表中运行8个进程根本不可能对系统效率有显著的冲击。

 

在方案设计和运行时,我已经证明方案本身是可伸缩的。为了证明这一点,我运行了多个并行进程来测试。观察下面的图表,它们很能说明问题。内存分配测试程序的结果可伸缩性非常的好。

 

 

其中有两个现象很值得注意。其中一个是,在4个线程或进程下运行时,效率最佳,这是由于CPU超线程特性的存在的结果,两个处理器工作起来就像四个。另一重要的现象是在四个并行进程运行时,虽然处理速度变成了原来的两倍时,但是单个文档处理的时间也增加了2倍。在六个线程或进程的情况总的效率并没有大的提高,总的处理时间已经很少变化,单个文档的处理时间也相应增加。

 

在一个单核的CPU上我也进行了同样的并行进程的实验,但由于我只做了实验二,所以没有提供最终的表格。在第一个测试用例中,多进程的图形是平行于x轴的一条线。具体测试的结果可以见代码附件。

 

在现实生活中,这个方案确确实实的对我们现在的项目起了作用。在没有改变第三方组件的情况下,我们平均效率提高了1.52倍。更重要的是,方案使组件和服务器程序变得可伸缩。

 

结论:

作为一个小研究的结果,我总结如下:

1.  多线程的C++运行库的内存管理模块在多核机器上可能会对程序的效率有明显的冲击。而这种冲击在程序设计的时候很容易被忽略,因为这种冲击是由于运行实时库的缺陷造成的,不易被人发觉。

2.  对于应用程序来说,为复杂数据结构分配内存并且支持多线程是非常重要的。(OOP技术极其强调内存分配)。

3.  在大多数例子中,使用用户自定义的内存分配器可以避免问题的发生。在无法使用或者使用定制的内存分配器代替运行库的分配器有困难的情况下,使用变通方法,即并行任务或并行进程代替并行线程也可行。

4.  Intel处理器提高了它们的多核设计结构(在奔腾4中开始使用了超线程设计)。或迟或早,普通程序也将遭遇到服务器程序的相同可伸缩性问题。

 

我是否漏掉了一些重要结论?是否犯了错误?或者有其他的一些结论?总之,我很期待关于这片文章的反馈。

 

附录A:未来发展

基于Windows API的服务器在移植到多核处理器上时,可能会遭遇到严重的性能问题。举个例子,一旦我们发现某些组件的可测量性问题,开发它的公司确定这个问题是由于过度使用GloballAlloc函数。他们计划在未来修复这个问题。也可能是其他的一些不琐碎的问题和方案。可能在未来我们可以看到更多的关于可测量性的信息和文章。

 

附录B:附件

附件包括了源代码和可已编译过的可执行程序。测试测序为命令行程序,它的代码目录在“sources”子目录中。测试程序由vc++6.0sp5编译,可以方便的被移植到新版本vc上。为了调试测试程序,我使用了自己的debug宏(QAFDebug.h/cpp),详情可见我的文章<代码的自我调试>.

 

这里有好几个适用于Windows2000/xp的命令行程序。

运行thread.bat执行完整的测试用例(3套)使用并行线程。这个bat文件不需要参数,并调用run_threads_imp.bat执行程序。这个测试程序产生三个日志文件,名字分别为run.log表示测试的序列号。

run_threads_imp.bat执行部分的并行进程操作。使用Windows命令行去等待好几个进程的完成是非常困难的,如果真的可以话,因此我手动运新每一个循环。这个bat文件需要指定4个参数,并且需要调用run_thread_impl.bat。它产生了许多命名为,,

的小文件,代表数字,是并行进程的数量,

是进程号。Bat文件的参数是:

run_processes.bat

代表每个并行进程的迭代数。,同上面描述

同样结果包括了4个子目录:

Threads1CPU_results:包括并行线程在单核机器上的测试结果

Threads2CPU_results:包括并行线程在多核机器上的测试结果

Processes1CPU_results 包括并行进程在单核机器上的测试结果

Processes2CPU_results:包括并行进程在多核机器上的测试结果

 

附录C:引用文档

Windows 2000性能指南》 作者Mark Friedman, Odysseas Pentakalos, 2002

这本书关于多核处理有非常棒的描述(确切的说,是第五节)。事实上也真是这本书给出了我解决问题的思路。本文第四节的方案大部分同书上内容相似。

 

《服务器效率与可测试性杀手》 作者 George V. Reilly, 1999

这片文章描述了相同的问题,并且给出了数条关于服务器程序的可测量性的简单原则。

 

《应用程序设计和多核效率》Windows 2000 server资源工具包

我没有能从在线的msdn上发现它,不过可能你可以从你的MSDN或者Windows 2000服务器cd中找到。文章给出了在多核系统上写多线程应用程序的通用准则。

 

《堆:快乐与痛苦》 作者Murali R. Krishnan, 1999

这偏文章解释了window heap潜在的效率问题并给出了有趣的解决方案

 

《最优化:你最坏的敌人》作者 Joseph M. NewCOMer, 2000

对于效率优化非常优秀的文章。它可能和这偏文章不相关,但是我无论如何也要推荐你阅读一下。

 

《现代c++设计:范型编程和设计模式的应用》 作者by Andrei Alexandrescu, 2002

这本书的相关章节对于客户对于小内存对象的自定义内存管理描写的相当出色。

 

ACE 框架》

这是一个开源的跨平台的面向实时程序和网络程序的c++库。它为客户内存管理提供了一系列的类。

 

《程序优化技术:使用内存的的效率》作者 Kris Kaspersky, 2003

关于执行效率非常有效的文章。虽然它没有覆盖多核系统,但是它解释了现代INTEL处理器和内存芯片的架构和执行事项。不幸的是,这本书目前有俄文版,因此你在亚马逊上无法找到(如果你找到,请通知我)

 

《高效STL 作者by Scott Meyers, 2001

这本书对于客户订制STL的内存分配器给出了有趣的描述,并且给出了非常有用的参考。见1011条。

 

Windows 基础服务:内存章节》见MSDN

在这一节里,你能够找到关于Windows xp/2003动态堆和低碎片的内存堆的的介绍。

 

c++分布式应用程序编程》作者by Randall A. Maddox, 2000

这本书只设计了基础内容,但是上面有一些关于多线程和多进程的不同点的通用图表(11章的末尾).

 

你可能感兴趣的:(并发编程,C/C++,MFC)