为什么一个完全受限于 CPU 的进程在开启超线程后能工作得更好?

26
给定:
  • 一个完全由CPU限制的非常大的任务(即,超过几个CPU周期),以及
  • 一台具有四个物理核心和总共8个逻辑核心的CPU,

在这种情况下,8、16和28个线程是否比4个线程效果更好?我知道,对于具有四个物理核心的机器而言,四个线程的上下文切换和开销都会比8、16或28个线程少。然而,具体的时序如下:

Threads    Time Taken (in seconds)
   4         78.82
   8         48.58
   16        51.35
   28        52.10

下面的原始问题部分提到了用于测试获取时间的代码。CPU规格也在底部给出。
超线程通过复制处理器的某些部分(存储架构状态的部分),但不复制主要执行资源来工作。这使得超线程处理器在主机操作系统中可以看作是一个常规的“物理”处理器和一个额外的“逻辑”处理器。

?

今天在SO上有人提出了这个问题,它测试了多个线程同时执行相同任务的性能。以下是相关代码:
private static void Main(string[] args)
{
    int threadCount;
    if (args == null || args.Length < 1 || !int.TryParse(args[0], out threadCount))
        threadCount = Environment.ProcessorCount;

    int load;
    if (args == null || args.Length < 2 || !int.TryParse(args[1], out load))
        load = 1;

    Console.WriteLine("ThreadCount:{0} Load:{1}", threadCount, load);
    List<Thread> threads = new List<Thread>();
    for (int i = 0; i < threadCount; i++)
    {
        int i1 = i;
        threads.Add(new Thread(() => DoWork(i1, threadCount, load)));
    }

    var timer = Stopwatch.StartNew();
    foreach (var thread in threads) thread.Start();
    foreach (var thread in threads) thread.Join();
    timer.Stop();

    Console.WriteLine("Time:{0} seconds", timer.ElapsedMilliseconds/1000.0);
}

static void DoWork(int seed, int threadCount, int load)
{
    var mtx = new double[3,3];
    for (var i = 0; i < ((10000000 * load)/threadCount); i++)
    {
         mtx = new double[3,3];
         for (int k = 0; k < 3; k++)
            for (int l = 0; l < 3; l++)
              mtx[k, l] = Math.Sin(j + (k*3) + l + seed);
     }
}

(我删掉了一些括号,以便将代码放在一页上以便快速阅读。)
我在我的机器上运行了这段代码来复制问题。我的机器有四个物理核心和八个逻辑核心。上面的代码中的方法DoWork()完全受限于CPU。我觉得超线程可能会带来30%的加速(因为这里有与物理核心数量相同的受限于CPU的线程(即4个))。但实际上它几乎达到了64%的性能提升。当我用四个线程运行这段代码时,大约需要82秒,而当我用8、16和28个线程运行这段代码时,在所有情况下都在大约50秒内完成。
总结时间如下:
Threads    Time Taken (in seconds)
   4         78.82
   8         48.58
   16        51.35
   28        52.10

我可以看到,使用四个线程时,CPU使用率约为50%。难道不应该是100%吗?毕竟,我的处理器只有四个物理核心。而当使用8和16个线程时,CPU使用率约为100%。
我正在努力理解为什么一个完全依赖于CPU的进程在启用超线程后会表现得更好。
为了完整起见,
  • 我有Intel Core i7-4770 CPU @ 3.40 GHz, 3401 MHz, 4个内核,8个逻辑处理器。
  • 我在发布模式下运行了代码。
  • 我知道时间测量的方法不好。这只会给出最慢线程的时间。我将代码原样从其他问题中拿来。

2
这个问题看起来写得很好,如果进入热门问题列表,我也不会感到惊讶。 - D. Ben Knoble
3
上下文切换成本相对较低。与典型的调度器时间片(10-40毫秒)相比,切换非常便宜(仅3微秒?!)。在这种情况下,超线程带来的节省并不是由于减少了调度。 - usr
1
@usr:我现在已经把我在问题中得到的确切时间放上了,如果有帮助的话。 - displayName
2
结果对我来说很有意义。这是典型的 HT 结果。HT 通常提供超过 0% 但少于 100% 的增益。它取决于工作负载的大小。您可以构思工作负载以实现 0 和高数字。 - usr
2
由于超线程技术,它少于4t。这就是HT的意义所在。吞吐量仍在4到8之间增加。之后就没有增益了,反而增加了调度开销。 - usr
显示剩余16条评论
4个回答

11

CPU流水线

每条指令在流水线中都要经过多个步骤才能完全执行。至少,它必须被解码,发送到执行单元,然后在那里实际执行。现代CPU上有几个执行单元,它们可以并行完全执行指令。顺便说一下,执行单元不可互换:某些操作只能在单个执行单元上完成。例如,内存加载通常专门针对一两个单元,内存存储则专门发送到另一个单元,所有计算都由其他单元完成。

了解流水线后,我们可能会想:如果我们编写纯顺序代码,并且每个指令都必须经过许多流水线阶段,那么CPU如何能够如此快速地工作呢?答案在这里:处理器以乱序方式执行指令。它有一个大的重排序缓冲区(例如200条指令),并且同时将许多指令推送到其管道中。如果在任何时刻由于任何原因某些指令无法执行(等待来自慢速内存的数据,依赖于尚未完成的其他指令等),则它被延迟了几个周期。在此期间,处理器执行一些新指令,这些指令在我们的代码中延迟的指令之后,只要它们不以任何方式依赖于延迟的指令。

现在我们可以看到延迟问题。即使指令已被解码并且其所有输入已经可用,它也需要几个周期才能完全执行。这种延迟称为指令延迟。然而,我们知道,在这个时刻,如果有任何独立的指令存在,处理器可以执行许多其他独立的指令。

如果一条指令从L2缓存中加载数据,则需要等待约10个周期才能加载数据。如果数据仅位于RAM中,则需要数百个周期才能将其加载到处理器中。在这种情况下,我们可以说该指令具有高延迟。为了实现最大性能,在此时必须执行一些其他独立的操作。这有时被称为“延迟隐藏”。
最后,我们必须承认,大多数真实代码在其本质上是顺序的。它有一些独立的指令可以并行执行,但不太多。没有指令可执行会导致pipeline bubbles,这会导致处理器晶体管的使用效率低下。另一方面,两个不同线程的指令在几乎所有情况下都是自动独立的。这直接引出了超线程的概念。
附注:您可能想阅读Agner Fog的手册以更好地理解现代CPU的内部结构。
超线程
当在单个核心上以超线程模式执行两个线程时,处理器可以交错它们的指令,从而用第二个线程的指令填充第一个线程的空闲周期。这样可以更好地利用处理器的资源,特别是对于普通程序而言。请注意,超线程不仅有助于大量内存访问的情况,也有助于重度顺序代码的情况。如果编写了经过良好优化的计算代码,则可能完全利用CPU的所有资源,此时您将看不到超线程的益处(例如来自经过良好优化的BLAS的dgemm例程)。
附注:您可能需要阅读英特尔有关超线程的详细说明,其中包括复制或共享哪些资源以及性能讨论的信息。

上下文切换

上下文是CPU的内部状态,至少包括所有寄存器。当执行线程更改时,操作系统必须进行上下文切换(详细描述 here)。根据this answer,上下文切换大约需要10微秒,而调度程序的时间量子为10毫秒或更多(见here)。因此,上下文切换不会对总时间产生太大影响,因为它们很少被执行。请注意,在某些情况下,线程之间的CPU缓存竞争可能会增加切换的有效成本。

然而,在超线程的情况下,每个核心在内部有两个状态:两组寄存器,共享缓存,一组执行单元。因此,在4个物理核心上运行8个线程时,操作系统无需进行任何上下文切换。在四核心上运行16个线程时,将执行上下文切换,但正如上面所解释的那样,它们只占总时间的一小部分。

进程管理器

提到在进程管理器中看到的 CPU 利用率,它并不衡量 CPU 流水线的内部情况。Windows 仅能注意到当一个线程将执行返回给操作系统以便于:休眠、等待互斥锁、等待硬盘驱动器和进行其他缓慢的操作时。因此,如果有一个线程在工作但不休眠或等待任何东西,那么它认为一个核心已经被完全使用了。例如,你可以检查运行无限循环 while (true) {} 会导致 CPU 的完全利用。


感谢您的详细解释。我本来还想问另一个问题,需要这个答案。您在这里添加这些信息非常有帮助。 - displayName

10
我看到4个线程的CPU使用率大约为50%。它不应该是大约100%吗?
不,不应该。
当在一个有4个物理内核的机器上运行4个CPU绑定线程时,50% CPU利用率的理由是什么?
这只是Windows(至少其它一些操作系统也是如此)中报告CPU利用率的方式。HT CPU对操作系统显示为两个内核,并据此报告。
因此,当您拥有四个HT CPU时,Windows会看到一个八核心机器。如果您查看“任务管理器”的“性能”选项卡,则会看到8个不同的CPU图形,并且总CPU利用率是使用这8个内核的全部利用率计算出来的。
如果您仅使用四个线程,则这些线程无法充分利用可用的CPU资源,这就解释了时间差异。它们最多只能使用可用的8个内核中的四个,因此您的利用率将达到50%。一旦超过逻辑核心数(8),运行时间会再次增加;在这种情况下,您增加了调度开销,但没有添加任何新的计算资源。
顺便说一句...
HyperThreading从共享缓存和其他限制的旧日子中获得了很大的改进,但它仍然永远无法提供与完整CPU相同的吞吐量优势,因为在CPU内部仍存在一些争用。因此,即使忽略操作系统开销,您在速度方面获得了35%的提高,这对我来说已经很不错了。我经常发现,在计算上受到瓶颈限制的过程中,添加额外的HT内核最多只能提高20%的速度。

你最后一段话就是我的问题。为什么我看到的增益高达64%,而不应该接近20%?我的计算有误吗? - displayName
改进程度可以根据工作负载的特定性质而大不相同。话虽如此,我绝对不同意避免上下文切换是加速的唯一来源这个想法。超线程(HT)CPU正在进行实际工作,通过利用并行化CPU组件,增加真正的计算并行性。 - Peter Duniho
你似乎有一个场景,其中你的进程能够更好地利用本来未被使用的CPU资源;不同的线程可以利用同一核心中的不同阶段,或者通常用于推测执行的资源能够被用于额外的线程。 - Peter Duniho
1
公平地说,Windows肯定知道它正在运行一个4物理核心和8逻辑核心的盒子 - 而不是“只看到8个核心”。逻辑线程拓扑对于操作系统来说是容易获取的,确实对于做出良好的调度决策至关重要(例如,在将第二个线程调度到同一核心之前使用所有物理核心)。Windows(和其他操作系统)只是选择使用逻辑核心的分母报告CPU使用情况。这通常会给出误导性的值,但很难找到更好的方法来解决它。 - BeeOnRope

4
我无法解释你观察到的速度提升有多快,100%的改进似乎对超线程来说过于夸张。但我可以解释其中所涉及的原则。
超线程的主要优点在于处理器需要在线程之间切换时。当存在比CPU核心数量更多的线程(99.9997%的情况属于此类),并且操作系统决定切换到不同的线程时,它必须执行(大部分)以下步骤:
1. 保存当前线程的状态:这包括堆栈、寄存器状态和程序计数器。它们的保存位置取决于架构,但通常它们会被保存在缓存或内存中。无论哪种方式,这一步都需要时间。
2. 将线程放入“准备就绪”状态(与“运行”状态相对)。
3. 加载下一个线程的状态:同样包括堆栈、寄存器和程序计数器,这再次是一个需要花费时间的步骤。
4. 切换线程到“运行”状态。
在普通(非-HT)CPU中,它拥有的核心数量就是处理单元的数量。每个处理单元都包含寄存器、程序计数器(寄存器)、堆栈计数器(寄存器)、(通常)独立缓存和完整的处理单元。因此,如果普通CPU有4个核心,则可以同时运行4个线程。当一个线程完成(或操作系统决定它花费了太多时间,需要等待才能重新开始),CPU需要在执行新线程之前遵循这四个步骤来卸载线程并加载新线程。
另一方面,在HyperThreading CPU中,上述情况仍然成立,但此外,每个核心都有一组复制的寄存器、程序计数器、堆栈计数器和(有时)缓存。这意味着一个4核心CPU仍然只能同时运行4个线程,但是CPU可以在复制的寄存器上“预加载”线程。因此,有4个线程正在运行,但是有8个线程加载到CPU上,4个活跃的,4个不活跃的。然后,当CPU准备切换线程时,它不必在线程需要切换时执行加载/卸载操作,而是简单地“切换”当前活跃线程,并在新的“非活跃”寄存器上以后台方式执行卸载/加载操作。还记得我后面添加的两个步骤“这些步骤需要时间吗”吗?在超线程系统中,步骤2和4是唯一需要实时执行的步骤,而步骤1和3在硬件中以后台方式执行(与任何线程、进程或CPU核心的概念无关)。
现在,这个过程并不能完全加速多线程软件,但在线程经常具有非常小的工作负载并且非常频繁地执行它们的环境中,线程切换的数量可能很昂贵。即使在不符合该范例的环境中,超线程也可以带来好处。

如果您需要任何澄清,请告诉我。距离CS250已经过去了几年,所以我可能会在某些术语上混淆; 如果我在使用某些术语时出错,请告诉我。我有99.9997%的把握,描述的所有内容在逻辑上都是准确的。


2
使用超线程技术时,步骤2和4根本不会发生:就操作系统而言,即使线程因为核心的资源被主线程占用而停滞不前,备用线程仍在核心上运行。 - Ben Voigt
2
“一个4核心的CPU仍然只能同时运行4个线程”——这是如何超线程工作的一个误导性的过度简化。因为CPU能够实际完成工作,所以产生收益,无论是并发进行(如果两个不同的线程此时不需要相同的执行资源)还是通过在一个线程中完成工作,而另一个线程被阻塞(例如等待内存获取)。一个4核心超线程芯片,即具有8个逻辑核心,在某些情况下实际上可以同时执行8个线程。只有不能100%时间这样做才会妨碍并发算法的完全可伸缩性。 - Peter Duniho
1
很难说。多线程的问题在于,即使两个系统使用相同的CPU,你可能会在一个系统上观察到巨大的性能提升,而在另一个系统上却没有。这通常取决于后台发生了什么。 - Xirema
1
执行资源并不是为了额外的逻辑核心而复制的,但即使在单核CPU中,它们也已经因其他原因而被复制,例如推测执行。例如,如果分支预测确定通常用于推测执行的资源是不需要的,则可以在不同的线程中使用这些重复的执行资源。超线程涉及许多不同的技术;这就是为什么在Stack Overflow上尝试解释它甚至是离题的,因为正确的解释对于这个论坛来说太过广泛了。 - Peter Duniho
1
你的评论试图说服读者,上下文切换是多线程中的主要问题,而超线程只是减少了由此引起的开销。这是不正确的,HT的主要节省是由于交错管道。 - stgatilov
显示剩余2条评论

3
超线程通过在处理器执行管道中交错指令来工作。当处理器在一个“线程”上执行读写操作时,它在另一个“线程”上执行逻辑评估,保持它们分开,并给您感知的性能加倍。
之所以会获得如此大的加速是因为您的DoWork方法中没有分支逻辑。它全部都是一个具有非常可预测执行顺序的大循环。
处理器执行管道需要经过几个时钟周期才能执行单个计算。处理器试图通过预先加载下几条指令来优化性能。如果加载的指令实际上是条件跳转(例如if语句),那么情况就不好了,因为处理器必须清空整个流水线并从内存的不同部分获取指令。
您可能会发现,如果您在DoWork方法中放置if语句,您将无法获得100%的加速...

你的回答有两个方面我没有完全理解:1. 线程中没有读/写操作,因此CPU不应该有“空闲”时间来执行其他线程上的逻辑评估。因此,不应该期望从中获得加速效果。2. If语句可能会降低性能增益,但即使没有if语句,我也应该获得如此高的性能增益吗?如果这两个原因在这种情况下都不成立,那么64%的性能提升背后的秘密是什么? - displayName
我所说的“线程”并不是指软件线程。我只是从“超线程”中回收了这个词,它在这个上下文中有不同的含义。在我看来,这是一个糟糕的名称。我不是专家,只是根据我的一般理解进行了转述。我相信有权威的文本可以更好地解释这个概念。 - Steztric
@displayName:请查看我长答案。即使没有内存访问,顺序代码也无法充分利用处理器的流水线。 - stgatilov

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