多线程会提供性能提升吗?

15

请注意,我对编程一窍不通,请在回答我的问题时考虑这一点。

我有一个程序,它使用一个大的3D数组(10亿个元素)沿各轴求和,以生成数据每侧的投影的二维数组。问题在于程序非常占用内存,因为它不断从RAM中获取信息,读取和写入。

我的问题是,如果将程序多线程化,是否会提高性能,还是最终会遇到RAM访问瓶颈?当我说多线程时,我只是指2或4个核的多线程。

如果有帮助的话,我的当前计算机配置是2.4ghz core2四核、1033 fsb、667mhz的4gb RAM。

谢谢您的帮助,

- Faken

编辑:

看起来人们对这个问题的兴趣超出了我的预期。我将扩展问题并发布一些代码,供有兴趣的人参考。

首先,让我介绍一下自己的背景,以便您了解我的想法。我是一名机械工程研究生,但我的课题几乎与机械工程无关。我曾经上过一门Java入门课(被迫),大约5年前,从那以后我就再也没有碰过编程,直到一个月前我开始认真写我的论文。此外,我还上过(同样被迫的)一门电子和计算机工程课程,我们处理了微控制器(8位)的内部运作,并为它们编写了一些ASM代码。除此之外,我对编程一无所知。

以下是代码:

int dim = 1000;
int steps = 7 //ranges from 1 to  255

for (int stage = 1; stage < steps; stage++)
for (int j = 0; j < dim; j++)
    for (int i = 0; i < dim; i++)
    {
        sum = 0;
        for (int k = 0; k < dim; k++)
            if (partMap[(((i * dim) + k) * dim) + j] >= stage)
                sum++;

        projection[(j*dim) + i] = sum;
    } 

这段代码仅在z轴上操作。由于数据结构的构造方式,主要数据具有奇怪的寻址系统,但您不需要担心这一点。还有其他代码用于处理立方体其他面的投影,但它们执行非常不同的任务。


4
这取决于您使用的线程实现和操作系统。在某些情况下,线程可能不会被适当地分配到不同的核心上。另外,我不确定编译器优化是否能够处理这个问题,但是有一些内存访问策略可以确保您充分利用CPU缓存并减少提取时间,从而获得更好的性能效益。当进行微控制器和小型处理器的低级编程时,通常会使用这些策略。 - dborba
只需回复我的帖子 :) 每个人都想知道你去哪里了,只要顺便来打个招呼! - John T
嗨!谁一直在编辑我的问题?你为什么要这样做?你为什么要这样做?你改了什么?请停止这样做! - Faken
好的,那么每个阶段都在计算每行中大于或等于“阶段”的值的数量。我认为你可以更有效地完成这项任务。预先为每个阶段分配一个投影数组。遍历partMap的每一行。保持一个256个整数的数组,并为每个值增加对应的字节值。这被称为桶排序。在每行结束时,更新每个步骤的“projection”数组:pjctn_stage[255] = array[255];然后对于每个n从大到小,pjctn_stage[n-1] = pjctn_stage[n]+array[n-1]。 - Steve Jessop
或者等我明天尝试一下,还有其他几个方法;-) - Steve Jessop
显示剩余13条评论
19个回答

32

跨多个核心的多线程可以减少沿轴求和所需的时间,但需要特别注意。您可能会从对单线程代码进行一些更改中获得更大的性能提升:

  1. 您只需要与可用的核心匹配相同数量的线程。这是一个CPU密集型操作,线程不太可能等待I/O。

  2. 如果整个数组不能适合RAM,则上述假设可能不成立。如果数组的部分页面进入和退出,则某些线程将等待页面操作完成。在这种情况下,程序可能会受益于拥有比核心更多的线程。然而,如果线程过多,则由于上下文切换的成本而导致性能下降。您可能需要尝试不同的线程计数。通常规则是最小化就绪线程之间的上下文切换次数。

  3. 如果整个数组不能适合RAM,则要最小化页面!每个线程访问内存的顺序都很重要,所有运行的线程的内存访问模式也很重要。尽可能地,在移动到下一个区域之前,应该完成数组的一部分,永不返回已覆盖的区域。

  4. 每个核心从完全独立的内存区域中受益。您要避免由于锁定和总线争用引起的内存访问延迟。至少对于立方体的一个维度,这应该是简单明了的:将每个线程设置为拥有自己的立方体部分。

  5. 每个核心还将受益于从其高速缓存中访问更多数据,而不是从RAM中获取。这意味着按顺序排列循环,使内部循环访问附近的单词,而不是跨行跳过。

  6. 最后,根据数组中的数据类型,英特尔/AMD处理器的SIMD指令(SSE,在它们的各个世代中)可以通过一次汇总多个单元来帮助加速单个核心的性能。VC ++具有一些内置支持

  • 如果你需要优先处理工作,你可能会想要首先最小化磁盘分页,然后集中优化内存访问以利用CPU缓存,最后再处理多线程。


  • 就是这个!非常感谢,这正是我一直在寻找的! - Faken
    就空间局部性而言,我也会参考 http://en.wikipedia.org/wiki/Hilbert_curve - 这是一种在移动空间时最大化空间局部性的算法 - 它应该有助于提高缓存使用率并加快访问速度。 - DaveR
    抱歉,戴夫,你说的话对我来说意义不大。在这种情况下,3D数组实际上是一个分配给堆的巨大的10亿元素1D数组...它是线性的,在空间局部性方面,仅沿着1D路径是有效的,这仅适用于我在一个轴上的投影(我可以重新排列数据以便应用于其他轴,但计算时间和头痛不值得)。 - Faken
    1
    @Faken:啊,是的,抱歉我误解了你的数据结构。话虽如此,你将会使CPU缓存失效,因为你将会访问在3D空间中相邻的数组元素(即一列),而这些元素在1D数组中会非常分散。下面onebyone的回答描述得很好。 - DaveR
    3
    "您希望避免由于锁定和总线争用导致的内存访问延迟。在其他维度上避免写入争用的一种方法是将“总数”进行“分片”。这意味着每个线程都向其自己的总数数组中写入,并且最后在单线程中将它们全部加起来。对于只有四个核心的情况,复制是一个相当大但不是极大的内存开销,而代码几乎肯定比确保工作包同时“对角线”(即立方体面的投影不相交)要简单。" - Steve Jessop
    提前适当距离获取数据可能会大大提高性能。这将非常容易,因为你正在以非常规律的方式遍历数组。 - Greg Rogers

    12

    优化代码的唯一方法是找出减慢速度的原因,然后尽量少做这些事情。“少做这些事情”的一个特例是改为做其他更快的事情。

    所以首先,根据您发布的代码,这是我正在做的事情:

    #include <fstream>
    #include <sstream>
    using std::ios_base;
    
    template<typename Iterator, typename Value>
    void iota(Iterator start, Iterator end, Value val) {
        while (start != end) {
            *(start++) = val++;
        }
    }
    
    int main() {
    
        const int dim = 1000;
        const int cubesize = dim*dim*dim;
        const int squaresize = dim*dim;
        const int steps = 7; //ranges from 1 to  255
        typedef unsigned char uchar;
    
        uchar *partMap = new uchar[cubesize];
        // dummy data. I timed this separately and it takes about
        // a second, so I won't worry about its effect on overall timings.
        iota(partMap, partMap + cubesize, uchar(7));
        uchar *projection = new uchar[squaresize];
    
        for (int stage = 1; stage < steps; stage++) {
            for (int j = 0; j < dim; j++) {
                    for (int i = 0; i < dim; i++)
                    {
                            int sum = 0;
                            for (int k = 0; k < dim; k++)
                                if (partMap[(((i * dim) + k) * dim) + j] >= stage)
                                    sum++;
    
                            projection[(j*dim) + i] = sum;
                    }
            }
    
            std::stringstream filename;
            filename << "results" << stage << ".bin";
            std::ofstream file(filename.str().c_str(), 
                ios_base::out | ios_base::binary | ios_base::trunc);
            file.write((char *)projection, squaresize);
        }
    
        delete[] projection;
        delete[] partMap;
    }
    

    (编辑说明:刚刚注意到“projection”应该是int数组,而不是uchar。我的错。这会对一些计时产生影响,但希望不会太大。)
    然后我将result*.bin复制到gold*.bin,以便按以下方式检查我的未来更改:
    $ make big -B CPPFLAGS="-O3 -pedantic -Wall" && time ./big; for n in 1 2 3 4 5
    6; do diff -q results$n.bin gold$n.bin; done
    g++  -O3 -pedantic -Wall   big.cpp   -o big
    
    real    1m41.978s
    user    1m39.450s
    sys     0m0.451s
    

    好的,目前是100秒。

    所以,如果猜测遍历十亿项数据数组是慢的原因,那么让我们尝试只进行一次遍历,而不是每个阶段都进行一次:

        uchar *projections[steps];
        for (int stage = 1; stage < steps; stage++) {
             projections[stage] = new uchar[squaresize];
        }
    
        for (int j = 0; j < dim; j++) {
                for (int i = 0; i < dim; i++)
                {
                        int counts[256] = {0};
                        for (int k = 0; k < dim; k++)
                                counts[partMap[(((i * dim) + k) * dim) + j]]++;
    
                        int sum = 0;
                        for (int idx = 255; idx >= steps; --idx) {
                            sum += counts[idx];
                        }
                        for (int stage = steps-1; stage > 0; --stage) {
                            sum += counts[stage];
                            projections[stage][(j*dim) + i] = sum;
                        }
                }
        }
    
        for (int stage = 1; stage < steps; stage++) {
            std::stringstream filename;
            filename << "results" << stage << ".bin";
            std::ofstream file(filename.str().c_str(),
                ios_base::out | ios_base::binary | ios_base::trunc);
            file.write((char *)projections[stage], squaresize);
        }
    
        for (int stage = 1; stage < steps; stage++) delete[] projections[stage];
        delete[] partMap;
    

    它会更快一些:

    $ make big -B CPPFLAGS="-O3 -pedantic -Wall" && time ./big; for n in 1 2 3 4 5
    6; do diff -q results$n.bin gold$n.bin; done
    g++  -O3 -pedantic -Wall   big.cpp   -o big
    
    real    1m15.176s
    user    1m13.772s
    sys     0m0.841s
    

    现在,在这个示例中,steps 很小,因此我们在 "counts" 数组上做了很多不必要的工作。即使没有进行性能分析,我猜测两次计算到 256(一次清空数组,一次求和)与沿列运行到 1000 相比是相当显著的。所以让我们来改变它:
        for (int j = 0; j < dim; j++) {
                for (int i = 0; i < dim; i++)
                {
                        // steps+1, not steps. I got this wrong the first time,
                        // which at least proved that my diffs work as a check
                        // of the answer...
                        int counts[steps+1] = {0};
                        for (int k = 0; k < dim; k++) {
                            uchar val = partMap[(((i * dim) + k) * dim) + j];
                            if (val >= steps) 
                                counts[steps]++;
                            else counts[val]++;
                        }
    
                        int sum = counts[steps];
                        for (int stage = steps-1; stage > 0; --stage) {
                            sum += counts[stage];
                            projections[stage][(j*dim) + i] = sum;
                        }
                }
        }
    

    现在我们只使用实际需要的桶。

    $ make big -B CPPFLAGS="-O3 -pedantic -Wall" && time ./big; for n in 1 2 3 4 5
    6; do diff -q results$n.bin gold$n.bin; done
    g++  -O3 -pedantic -Wall   big.cpp   -o big
    
    real    0m27.643s
    user    0m26.551s
    sys     0m0.483s
    

    欢呼!这个代码的速度几乎比第一个版本快了4倍,而且产生了相同的结果。我所做的只是改变了数学计算的顺序:我们甚至还没有考虑多线程或预取功能。我也没有尝试过任何高度技术的循环优化,只是留给了编译器。因此,这可以被认为是一个不错的开始。
    然而,它仍然比iota运行的1秒慢一个数量级。因此,可能仍然有很大的收益空间。一个主要的区别是iota按顺序在1d数组上运行,而不是到处跳跃。正如我在第一个答案中所说,您应该始终在立方体上使用顺序。
    因此,让我们进行一行更改,交换i和j循环的顺序:
                for (int i = 0; i < dim; i++)
        for (int j = 0; j < dim; j++) {
    

    这仍然不是顺序的顺序,但它意味着我们一次只关注100万字节的立方体切片。现代CPU至少有4MB的缓存,所以运气好的话,我们在整个程序中对于任何给定的立方体部分只需要一次命中主内存。如果局部性更好,我们也可以减少进出L1缓存的流量,但主内存是最慢的。

    这会带来多大的差异呢?

    $ make big -B CPPFLAGS="-O3 -pedantic -Wall" && time ./big; for n in 1 2 3 4 5
    6; do diff -q results$n.bin gold$n.bin; done
    g++  -O3 -pedantic -Wall   big.cpp   -o big
    
    real    0m8.221s
    user    0m4.507s
    sys     0m0.514s
    

    不错。事实上,仅这个更改就将原始代码的时间从100秒缩短到20秒。因此,这对应了一个5的因素,而我所做的其他一切则对应另一个5的因素(我认为上面的“用户”和“实际”时间之间的差异主要是由于我的病毒扫描程序正在运行,而之前没有运行。“用户”是程序占用CPU的时间,“实际”包括挂起的时间,无论是等待I/O还是给另一个进程运行的时间)。
    当然,我的桶排序依赖于每列中对值执行的操作是可交换且可结合的事实。减少桶的数量之所以有效是因为所有大值都被视为相同。这可能并不适用于您的所有操作,因此您需要逐个查看每个内部循环以找出该怎么处理它。
    而且代码有点复杂。我们不再在数据上运行“blah”来执行每个阶段,而是在单次运行数据时同时计算所有阶段。如果您开始在单次遍历中执行行和列计算,就像我在第一个答案中建议的那样,情况会变得更糟。您可能需要开始将代码拆分成函数以保持可读性。
    最后,我获得了很多性能提升,这是因为“步骤”很小的优化。使用steps=100时,我得到:
    $ make big -B CPPFLAGS="-O3 -pedantic -Wall" && time ./big; for n in 1 2 3 4 5
    6; do diff -q results$n.bin gold$n.bin; done
    g++  -O3 -pedantic -Wall   big.cpp   -o big
    
    real    0m22.262s
    user    0m10.108s
    sys     0m1.029s
    

    这并不算太糟糕。使用steps=100,原始代码可能需要大约1400秒的时间,虽然我不会运行它来证明这一点。但值得记住的是,我并没有完全消除对“步骤”时间依赖性,只是使其次线性。


    我快速地阅读了一遍,但并不完全理解。给我一天时间,我会坐下来仔细研究。我不会使用我完全不理解的代码,即使理解了,我也不会将代码复制粘贴到我的程序中。你提出的五倍时间缩短因素很有趣。我需要对计算机结构等方面进行一些研究。如果最终我确实使用了你向我解释的概念,我一定会给你以荣誉。感谢你为此付出的时间和努力,非常感激。 - Faken
    哈哈!一个多月过去了,但我从未忘记你的帖子。我终于明白了。直到我获得了更多的编程经验和现代CPU知识,我才能真正理解这个问题。我会在有时间的时候实现自己的版本。整个问题不是关于多线程,而是关于获取缓存命中率!我不需要更多的时钟周期,我需要更多的内存带宽,唯一的方法就是利用缓存! - Faken
    谢谢您的评论 - 我将记住在未来,新的C++程序员需要更接近基本原理的解释。 - Steve Jessop

    5
    你的代码是如何运作的?它是否像这样进行操作?
    for each row: add up the values
    for each column: add up the values
    for each stack: add up the values
    

    如果是这样的话,您可能需要了解“引用局部性”。根据数据的存储方式,您可能会发现,在执行堆栈操作时,每个值都需要拉入整个缓存行,因为内存中的值彼此相距很远。实际上,对于十亿个值,您可能需要从磁盘拉取数据。具有长步幅(值之间的距离)的顺序访问是缓存最糟糕的使用方式。尝试进行分析,并且如果您发现添加堆栈所需时间比添加行长,则几乎肯定是这个原因。
    我认为您可能会饱和内存总线(*),在这种情况下,如果core2 quad为不同核心使用不同的总线,则多线程只会有所帮助。但是,如果您没有饱和总线带宽,则即使在多线程情况下也无法以最佳性能运行。您将会看到4个核心花费大量时间在缓存未命中上而不是一个。
    如果您受限于内存缓存,则您的目标应该是尽可能少地访问每个页面/内存行。因此,我建议您一次运行数据,将每个值添加到三个不同的总计中。如果这在单个核心上运行得更快,那么我们就可以着手处理了。下一步是,对于一个1000x1000x1000的立方体,您有300万个总数在进行中。这也无法适应缓存,因此写入时需要考虑与读取相同的缓存未命中问题。
    您希望确保在沿着RAM中的1000个相邻值运行并将其添加到行总计时,它们都共享,同时还要为列和堆栈的相邻总计添加。因此,列总计的“平方”和堆栈的“平方”应以适当的方式存储。这样,您只需将大约12k的内存拉入缓存中(1000个值的4k,加上1000个列总计的4k,再加上1000个堆栈总计的4k),即可处理十亿个值中的1000个。相比之下,如果集中在1个总计上,则会进行更多的存储操作(因此可以在寄存器中)。
    因此,我不能保证任何东西,但我认为值得查看内存访问顺序,无论是否多线程。如果您可以在访问相对较少的内存时执行更多的CPU工作,则会加速单线程版本,并使自己处于更好的多线程状态,因为核心共享有限的缓存,内存总线和主RAM。
    (*) 信封背面的计算:在互联网上随机查看的评论中,目前我找到的Core2处理器FSB带宽最高估计值是Extreme的12GB/s,每个通道为4x199MHz。缓存行大小为64字节,小于您的步幅。因此,如果以每秒200 million个值的速度对一列或堆栈进行求和,每个值获取64字节,那么总线只会饱和。我猜它不可能这么快(整个过程需要10-15秒),否则您就不会问如何加速了。

    所以我的第一个猜测可能完全错误。除非您的编译器或CPU插入了一些非常聪明的预取,否则单个核心无法使用2个通道和每个周期的4个同时传输。同样地,4个核心也不能使用2个通道和4个同时传输。一系列请求的有效总线带宽可能远低于物理极限,在这种情况下,您希望通过多线程看到良好的改进,因为您有4个核心请求4个不同的缓存行,所有这些都可以同时加载而不会影响FSB或缓存控制器。但延迟仍然是致命的,因此,如果您可以每个求和的值加载少于一个缓存行,您将做得更好。


    我只有一个1033 MHz的前端总线,这是第一代Core2四核处理器,这台电脑已经超过2年了。你们似乎对这个问题比我最初预期的要更感兴趣...我想我会发布实际的代码,你们似乎相当感兴趣。 - Faken

    4

    一般而言很难确定,因为您没有说明CPU和RAM的速度。但很有可能会改善性能,因为我无法想象即使4个线程并行相加也能够使RAM饱和成为瓶颈(而非CPU)。


    即便如此,实验可能是唯一的方法。你有一台多核机器,所以我猜你可以提高速度。这取决于计算的强度与从 RAM 到 CPU 缓存再返回获取数据的成本相比较的情况。 - Scott Langham

    3

    我的直觉告诉我你会看到一些小的改进。然而,预测优化结果是一个出了名容易出错的事情。

    试试看,并对结果进行基准测试。


    呵呵,如果我知道我在做什么的话,我会这么做的 :) 我问这个问题的原因是想看看学习多线程编程是否值得投入时间。如果大多数人说我看不到任何真正的改进,那么我就不应该浪费时间了。毕竟,我是一个初学者程序员,如果没有背景,新概念是很难掌握的。 - Faken
    2
    多线程是一个相当重要的东西,没有比现在学习它更好的时机了。 :) - Kevin Montrose

    2
    如果计算可以分解成可以独立且并行处理的块,则多线程才能使您的代码更快。我说上述内容是因为我看到许多开发人员花费大量时间在多线程代码上,但实际上根本没有性能提升。当然,他们最终得到了相同(甚至更慢)的性能和管理多个线程的额外复杂性。在考虑到您的特定情况后,再次阅读您的问题后,确实可以看出您会从多线程中受益。RAM非常快,因此除非有许多许多线程,否则很难饱和内存带宽。

    我同意:某些任务适合使用多线程,而某些则不适合。 - Gordon Gustafson
    我的应用程序绝对是可多线程的,实际上我认为它可以被认为是“尴尬地并行”的,因为每个操作都可以独立完成,而且读写可以同时进行,而不会相互干扰,因为我的代码的每个“操作”都在操作一组单独的数据,并写入到没有其他东西会触及的某些内容。问题不在于它是否可多线程,而在于如果我这样做,是否会遇到RAM访问瓶颈。 - Faken
    线程不是独立的,因此由于数据结构的共享可能会相互干扰。我假设数据位于共享堆或其他线程全局区域中,而不是每个线程都有所需数据的副本,比如数据的行或列,这对于数据的独立使用是不明智的。仅仅说多线程可能并不一定是解决问题的方法。 - jeffD

    2
    如果它被适当编码,那么你肯定会看到加速的效果。但正如我的一位教授经常指出的那样,人们常常试图将算法线程化,在最后却变得更慢。这通常是由于不高效的同步造成的。所以如果你想深入了解线程(如果你是编程新手,我真的不建议这样做),可以试试。
    在您的特定情况下,同步可能非常简单。也就是说,您可以将每个线程分配给大型3D矩阵的一个象限,每个线程都可以确保独立访问输入和输出矩阵的特定区域,因此没有必要“保护”数据免受多次访问/写入的影响。
    总之,在这种特定简单情况下,线程可能很容易,但通常情况下,同步处理不当会导致程序运行时间更长。这完全取决于具体情况。

    2

    我认为,尽管多线程可以提高性能,但这不是优化的正确方法。多核心处理器变得很受欢迎,是因为它们是CPU制造商以可销售的速率提供更快的CPU速度的唯一途径,而不一定是因为它们是一个神奇的编程工具(仍然需要很多成熟的技术)。

    在所有情况下,请先看看您使用的算法。您说您的程序非常依赖于RAM - 有什么方法可以提高缓存命中率吗?是否有一种方法可以对数组进行排序,使计算可以按顺序应用?您正在使用哪种编程语言,使用低级语言进行优化是否会更有益?是否有一种方法可以使用动态规划来存储您的结果?

    总之,要想更高效地优化,请将所有资源都用于开发一个更加高效的算法,包括数学和编译器优化,然后再考虑使用多核心。当然,您可能已经处于那个阶段了,如果是这样,这个评论可能就没什么用了;p


    2
    您需要回答的问题对于您特定的应用程序是众所周知的。
    首先,工作是否可并行化?Amdahl's Law将为您提供使用多线程可以加速多少的上限。
    其次,多线程解决方案是否会引入大量开销?您说程序“RAM密集型,因为程序不断从RAM中获取信息,包括读和写”。因此,您需要确定读/写是否会导致显着的协调开销。这并不容易。尽管每个CPU都可以随时访问计算机的整个RAM(包括读和写),但这样做可能会减慢内存访问速度——即使没有锁定——因为各个CPU保留自己的缓存,并且需要相互协调其缓存中的内容(CPU 1在缓存中有一个值,CPU 2在RAM中更新该值,CPU 2必须告诉CPU 1使其缓存失效)。如果您确实需要锁定(由于您正在“读写”内存,这几乎可以肯定),则需要尽可能避免争用。
    第三,你是否受到内存限制?“RAM密集型”并不等同于“内存限制”。如果你当前是CPU限制,则多线程将加速处理。如果你当前是内存限制,则多线程甚至可能会减慢处理速度(如果一个线程速度过快而导致内存不足,那么多个线程会发生什么情况?)。
    第四,你是否因为其他原因而变慢?如果你在算法中频繁使用newmalloc分配大量内存,那么你可能会看到这些开销导致的性能下降。在许多平台上,newmalloc都不能很好地处理多线程,因此如果你现在因为malloc效率低而变慢,那么一个多线程程序将会更慢,因为malloc将会更差。 总的来说,不过如果没有看到你的代码,我会预计它是CPU密集型的,而且我会期望多线程可以加速运行 -- 实际上几乎可以达到阿姆达尔定律所建议的那样。你可能需要考虑使用OpenMP或英特尔的Threading Building Blocks库,或者一些线程队列来实现它。

    2
    在进行多线程之前,您应该对代码运行进行分析。可能需要寻找一个好的(可能是免费的)C++分析器。
    这将有助于您确定代码中需要大量计算时间的部分。经过一些分析后,稍微调整一下有时可以显著提高性能。

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