每一个程序员都应该了解的内存知识-Part6

多线程优化

注意:这篇文章使用 Kimi AI 进行翻译

6.4 多线程优化

当涉及到多线程时,有三个不同的缓存使用方面很重要:

  • 并发性
  • 原子性
  • 带宽

这些方面也适用于多进程情况,但由于多个进程(大多数情况下)是独立的,因此优化它们并不容易。可能的多进程优化是多线程场景中可用优化的一个子集。因此,我们将在这里专门处理后者。

这里的并发性指的是,当同时运行多个线程时,进程经历的内存效应。线程的一个属性是它们都共享相同的地址空间,因此都可以访问相同的内存。在理想情况下,线程使用的内存区域是不同的,在这种情况下,这些线程之间的耦合度很低(例如,共同的输入和/或输出)。如果多个线程使用相同的数据,则需要协调;这时原子性就发挥作用了。最后,根据机器架构,处理器可用的内存和处理器之间的总线带宽是有限的。我们将在以下各节中分别处理这三个方面——尽管它们当然密切相关。

6.4.1 并发性优化

最初,在本节中,我们将讨论两个单独的问题,实际上它们需要相反的优化。多线程应用程序在其某些线程中使用公共数据。正常的缓存优化要求保持数据在一起,以便应用程序的占用空间小,从而最大化适合任何时候缓存的内存量。

这种方法存在一个问题:如果多个线程写入同一内存位置,缓存行必须在各自核心的 L1d 中处于“E”(独占)状态。这意味着会发送很多 RFO(远程填充操作)请求,在最坏的情况下,每个写入访问都要发送一个。因此,正常的写入突然变得非常昂贵。如果使用相同的内存位置,需要同步(可能通过使用原子操作,这在下一节中处理)。问题也很明显,当所有线程都使用不同的内存位置并且应该是独立的时。

图 6.10:并发缓存行访问开销

图 6.10 显示了这种“错误共享”的结果。测试程序(在第 9.3 节中显示)创建了一些线程,它们除了递增一个内存位置(5 亿次)之外什么都不做。测量的时间是从程序开始到程序在等待最后一个线程后结束。线程被固定在单独的处理器上。机器有四个 P4 处理器。蓝色值表示将每个线程的内存分配放置在单独缓存行的运行。红色部分是当线程的位置移动到仅有一个缓存行时发生的惩罚。

蓝色测量值(使用单独的缓存行)符合预期。程序在多线程上没有惩罚地进行扩展。每个处理器将其缓存行保持在自己的 L1d 中,并且没有带宽问题,因为不需要读取太多代码或数据(实际上,它们都被缓存了)。测量的轻微增加确实是系统噪声,可能是一些预取效果(线程使用顺序缓存行)。

通过将使用一个缓存行的时间与每个线程使用单独缓存行的时间进行比较计算出的开销是 390%,734% 和 1,147%。这些大数字乍一看可能会令人惊讶,但考虑到所需的缓存交互,应该是显而易见的。缓存行在一个处理器的缓存中被拉出,就在它完成写入缓存行之后。除了在任何给定时刻拥有缓存行的那个处理器之外,所有处理器都被延迟并且无法做任何事情。每个额外的处理器只会带来更多的延迟。

从这些测量中可以清楚地看出,必须避免在程序中的这种情况。鉴于巨大的惩罚,这个问题在许多情况下是显而易见的(至少分析会显示代码位置),但现代硬件上存在一个陷阱。图 6.11 显示了在单处理器、四核机器(Intel Core 2 QX 6700)上运行代码时的等效测量。即使这种处理器的两个独立的 L2,测试用例也没有显示出任何可扩展性问题。当多次使用相同的缓存行时,会有轻微的开销,但它不会随着核心数的增加而增加。{ _我无法解释当所有四个核心都使用时数字较低的原因,但它是可重现的。} 如果使用这些处理器中的多个,我们当然会看到类似于图 6.10 的结果。尽管多核处理器的使用日益增加,许多机器仍将继续使用多个处理器,因此正确处理这种情况很重要,这可能意味着在真正的 SMP 机器上测试代码。

图 6.11:开销,四核

有一个非常简单的“修复”方法:将每个变量放在自己的缓存行上。这是与前面提到的优化相冲突的地方,具体来说,应用程序的占用空间将大大增加。这是不可接受的;因此,有必要提出一个更聪明的解决方案。

需要的是识别哪些变量只由一个线程一次使用,那些只由一个线程使用的,以及可能在某些时候有争议的变量。每种情况都有不同的解决方案,并且是有用的。区分变量的最基本标准是:它们是否曾经被写入,以及这种情况发生的频率如何。

从不写入的变量以及那些只初始化一次的变量基本上是常量。由于 RFO 请求只需要写操作,常量可以在缓存中共享(‘S’ 状态)。因此,这些变量不需要特别处理;将它们组合在一起是可以的。如果程序员正确地用 const 标记变量,工具链将把变量从正常变量移开,进入 .rodata(只读数据)或 .data.rel.ro(重新定位后的只读)节。不需要其他特殊操作。如果出于某种原因,变量不能正确地用 const 标记,程序员可以通过将它们分配到特殊节来影响它们的放置。

当链接器构建最终二进制文件时,它首先将所有输入文件中同名的节附加在一起;然后这些节按照链接器脚本确定的顺序排列。这意味着,通过将所有基本上只是常量但未标记为如此的变量移动到特殊节中,程序员可以将所有这些变量组合在一起。在它们之间不会有经常写入的变量。通过适当对齐该节中的第一个变量,可以确保不会发生错误共享。假设这个小例子:

1
2
3
4
int foo = 1;
int bar __attribute__((section(".data.ro"))) = 2;
int baz = 3;
int xyzzy __attribute__((section(".data.ro"))) = 4;

如果编译,这个输入文件定义了四个变量。有趣的部分是,变量 foobaz,以及 barxyzzy 分别组合在一起。没有属性定义,编译器将在它们在源代码中定义的顺序中将所有四个变量分配到名为 .data 的节中。{ _这不是由 ISO C 标准保证的,但是 gcc 的工作方式。} 有了代码本身,变量 barxyzzy 被放置在名为 .data.ro 的节中。节名 .data.ro 大体上是任意的。前缀 .data. 保证了 GNU 链接器将与其它数据节一起放置该节。

同样的技术可以应用于分离大多数时候读取但偶尔写入的变量。只需选择一个不同的节名。在某些情况下,例如 Linux 内核,这种分离似乎是有意义的。

如果变量只由一个线程使用,则有另一种指定变量的方式。在这种情况下,使用线程局部变量(见 [mytls])是可能且有用的。

在 gcc 中,C 和 C++ 语言允许使用 __thread 关键字将变量定义为每个线程的。

1
2
3
4
int foo = 1;
__thread int bar = 2;
int baz = 3;
__thread int xyzzy = 4;

变量 barxyzzy 不是在正常的数据段中分配的;相反,每个线程都有自己的单独区域,用于存储这样的变量。这些变量可以有静态初始化器。所有线程局部变量都可以被所有其他线程访问,但除非一个线程将指向线程局部变量的指针传递给其他线程,否则其他线程无法找到该变量。由于变量是线程局部的,因此不会出现错误共享——除非程序人为地创建了问题。这个解决方案很容易设置(编译器和链接器做了所有工作),但它有它的成本。当创建线程时,它必须花一些时间设置线程局部变量,这需要时间和内存。此外,访问线程局部变量通常比使用全局或自动变量更昂贵(见 [mytls] 以了解如何自动最小化这些成本,如果可能的话)。

使用线程局部存储(TLS)的一个缺点是,如果变量的使用转移到另一个线程,旧线程中的当前变量值对新线程不可用。每个线程的变量副本是不同的。通常这根本不是问题,如果它是,那么在转移到新线程时需要协调,此时可以复制当前值。

第二个更大的问题是可能的资源浪费。如果一个变量一次只由一个线程使用,所有线程都必须在内存方面付出代价。如果一个线程在 DSO 中没有使用任何 TLS 变量,TLS 内存区域的延迟分配将防止这成为一个问题(除了应用程序本身的 TLS)。如果一个线程在某个 DSO 中只使用了一个 TLS 变量,该对象中所有其他 TLS 变量的内存也将被分配。如果大规模使用 TLS 变量,这可能会累积起来。

通常可以给出的最佳建议是:

  1. 至少将只读(初始化后)和读写变量分开。也许可以将读取为主的变量作为第三类扩展这种分离。
  2. 将一起使用的读写变量组合成一个结构体。使用结构体是确保所有这些变量的内存位置以一致的方式紧密组合在一起的唯一方法,这种方式由所有 gcc 版本一致翻译。
  3. 将经常由不同线程写入的读写变量移动到它们自己的缓存行上。这可能意味着在末尾添加填充以填满缓存行的剩余部分。如果与第 2 步结合,这通常并不真的是浪费。扩展上面的例子,我们可能会得到如下代码(假设 barxyzzy 打算一起使用):
1
2
3
4
5
6
7
8
9
10
int foo = 1;
int baz = 3;
struct {
struct al1 {
int bar;
int xyzzy;
};
char pad[CLSIZE - sizeof(struct al1)];
} rwstruct __attribute__((aligned(CLSIZE))) =
{ { .bar = 2, .xyzzy = 4 } };

一些代码更改是必要的(对 bar 的引用必须替换为 rwstruct.bar,对 xyzzy 也是如此),但就是这样。编译器和链接器做了所有其他工作。{ _此代码必须使用 -fms-extensions 在命令行上编译。}

  1. 如果一个变量由多个线程使用,但每次使用都是独立的,将变量移动到 TLS 中。

6.4.2 原子性优化

如果多个线程同时修改相同的内存位置,处理器不保证任何特定结果。这是一个故意的决定,以避免在 99.999% 的所有情况下都不必要的成本。例如,如果一个内存位置处于‘S’状态,并且两个线程同时必须递增其值,执行管道不必等待缓存行在‘E’状态下可用后再从缓存中读取旧值以执行加法。相反,它读取当前在缓存中的值,并且一旦缓存行在‘E’状态下可用,新值就会被写回。如果两个线程中的两次缓存读取同时发生,结果将不是预期的;一次加法将会丢失。

为了确保这不会发生,处理器提供了原子操作。这些原子操作例如,不会在可以以原子方式执行加法的方式进行加法之前读取旧值。除了等待其他核心和处理器外,一些处理器甚至为特定地址的原子操作向主板上的其他设备发出信号。所有这些都使原子操作变慢。

处理器供应商决定提供不同的原子操作集。早期的 RISC 处理器,符合‘R’代表减少的原则,提供的原子操作非常少,有时只有一个原子位设置和测试。{ HP Parisc 仍然没有提供更多…} 在另一个极端,我们有 x86 和 x86-64,它们提供了大量的原子操作。通常可用的原子操作可以分为四类:

位测试

这些操作原子地设置或清除一个位,并返回一个状态,指示位之前是否设置。

加载锁定/存储条件(LL/SC)

{ _有些人使用“链接”而不是“锁定”,这都是一样的。}

这些操作作为一对工作,其中特殊的加载指令用于启动事务,最终的存储只有在同时间没有被修改的情况下才会成功。存储操作指示成功或失败,因此程序可以根据需要重复努力。

比较并交换(CAS)

这是一个三元操作,它只将作为参数提供的值写入地址(第二个参数),如果当前值与第三个参数值相同。

原子算术

这些操作只在 x86 和 x86-64 上可用,可以在内存位置执行算术和逻辑操作。这些处理器支持这些操作的非原子版本,但 RISC 架构不支持。因此,它们的可用性受到限制。

一个架构支持 LL/SC 或 CAS 指令,而不是两者。两种方法基本上等效;它们同样好地实现原子算术操作,但 CAS 似乎是目前首选的方法。所有其他操作都可以使用它间接实现。例如,原子加法:

1
2
3
4
5
6
int curval;
int newval;
do {
curval = var;
newval = curval + addend;
} while (CAS(&var, curval, newval));

CAS 调用的结果表明操作是否成功。如果它返回失败(非零值),循环再次运行,执行加法,并再次尝试 CAS 调用。这会重复,直到成功。代码值得注意的是,内存位置的地址必须在两个单独的指令中计算。{ x86 和 x86-64 上的 CAS 操作码可以在第二和后续迭代中避免加载值,但在这个平台上,我们可以以更简单的方式编写原子加法,使用单个加法操作码。} 对于 LL/SC,代码看起来大致相同。

1
2
3
4
5
6
int curval;
int newval;
do {
curval = LL(var);
newval = curval + addend;
} while (SC(var, newval));

这里我们必须使用特殊的加载指令(LL),并且我们不需要将内存位置的当前值传递给 SC,因为处理器知道同时间内存位置是否被修改了。

大的区别是 x86 和 x86-64,我们有原子操作,在这里,选择适当的原子操作以实现最佳结果很重要。图 6.12 显示了实现原子增量操作的三种不同方式。

1. 加法并读取结果 for (i = 0; i < N; ++i) __sync_add_and_fetch(&var,1);
2. 加法并返回旧值 for (i = 0; i < N; ++i) __sync_fetch_and_add(&var,1);
3. 原子替换新值 for (i = 0; i < N; ++i) { long v, n; do { v = var; n = v + 1; } while (!__sync_bool_compare_and_swap(&var, v, n)); }


图 6.12:循环中的原子增量

所有三种方式在 x86 和 x86-64 上产生不同的代码,而在其他架构上代码可能相同。性能差异很大。下表显示了四个并发线程执行 100 万次增量的执行时间。代码使用了 gcc 的内置原语(__sync_ *)。

1. 交换加法 2. 加法并读取结果 3. CAS
0.23s 0.21s 0.73s

前两个数字相似;我们看到返回旧值稍微快一些。重要的信息是突出显示的字段,使用 CAS 时的成本。不出所料,它的成本要高得多。原因有几个:1. 有两次内存操作,2. CAS 操作本身更复杂,甚至需要条件操作,3. 如果两个并发访问导致 CAS 调用失败,整个操作必须在循环中重复。现在读者可能会问:为什么有人使用复杂且更长的代码,使用 CAS?答案是:复杂性通常被隐藏了。如前所述,CAS 目前是所有有趣架构的统一原子操作。因此,有些人认为用 CAS 定义所有原子操作就足够了。这使得程序更简单。但正如数字所示,结果可能远非最优。CAS 解决方案的内存处理开销是巨大的。以下说明了两个线程的执行,每个线程在自己的核心上。

线程 #1 线程 #2 var 缓存状态
v = var ‘E’ on Proc 1
n = v + 1 v = var ‘S’ on Proc 1+2
CAS(var) n = v + 1 ‘E’ on Proc 1
CAS(var) ‘E’ on Proc 2

我们看到,在这个短暂的执行期内,缓存行状态至少改变了三次;其中两次是 RFO。另外,第二个 CAS 将失败,因此该线程必须重复整个操作。在该操作期间,同样的情况可能会再次发生。

相比之下,当使用原子算术操作时,处理器可以将执行加法(或任何其他操作)所需的加载和存储操作保持在一起。它可以确保同时发出的缓存行请求被阻塞,直到原子操作完成。因此,示例中的每个循环迭代最多导致一个 RFO 缓存请求,没有其他。所有这些意味着,定义机器抽象的级别至关重要,以便可以利用原子算术和逻辑操作。不应将 CAS 普遍用作统一机制。

对于大多数处理器,原子操作本身始终是原子的。只有通过为不需要原子性的情况提供完全独立的代码路径来避免它们。这意味着更多的代码、条件语句和进一步的跳转到适当执行的直接执行。对于 x86 和 x86-64,情况有所不同:相同的指令可以以原子和非原子方式使用。要使它们原子,可以使用特殊前缀:lock 前缀。这为原子操作提供了避免高成本的机会,如果给定情况下不需要原子性要求。例如,库中的通用代码,如果需要,总是必须是线程安全的,可以从中受益。编写代码时不需要信息,决策可以在运行时做出。技巧是跳过 lock 前缀。这个技巧适用于 x86 和 x86-64 处理器允许使用 lock 前缀的所有指令。

1
2
3
4
    cmpl $, multiple_threads
je 1f
lock
1: add $1, some_var

如果这段汇编代码看起来难以理解,不用担心,它很简单。第一条指令检查变量是零还是非零。在这种情况下,非零表示多个线程正在运行。如果值为零,则执行第二条指令跳转到标签 1。否则,执行下一条指令。这是棘手的部分。如果 je 指令没有跳转,add 指令将带 lock 前缀执行。否则它将不带 lock 前缀执行。

添加一个相对昂贵的操作,如条件跳转(如果分支预测失败则昂贵)似乎适得其反。确实可能是:如果大多数时间运行多个线程,性能会进一步降低,特别是如果分支预测不正确。但如果许多情况下只使用一个线程,代码会快得多。使用 if-then-else 结构的替代方案在两种情况下都会引入一个额外的无条件跳转,可能会更慢。鉴于原子操作的成本大约为 200 个周期,使用该技巧(或 if-then-else 块)的交叉点非常低。这绝对是一个值得记住的技术。不幸的是,这意味着不能使用 gcc 的 __sync_ * 原语。

6.4.3 带宽考虑

当使用许多线程时,如果它们不通过在不同核心上使用相同的缓存行来引起缓存争用,仍然存在潜在问题。每个处理器有一个最大带宽到内存,这个带宽由该处理器上的所有核心和超线程共享。根据机器架构(例如,图 2.1 中的一个),多个处理器可能共享通往内存的相同总线或北桥。

处理器核心本身以频率运行,在满速下,即使在完美条件下,与内存的连接也无法在不等待的情况下满足所有加载和存储请求。现在,将可用带宽除以共享北桥连接的核心、超线程和处理器的数量,突然并行性成为一个大问题。理论上非常高效的程序可能受到内存带宽的限制。

在图 3.32 中,我们已经看到增加处理器的 FSB 速度可以有很大帮助。这就是为什么,随着处理器上核心数量的增加,我们也将会看到 FSB 速度的增加。然而,如果程序使用大的工作集并且足够优化,这仍然永远不够。程序员必须准备好识别由于带宽限制导致的问题。

现代处理器的性能测量计数器允许观察 FSB 争用。在 Core 2 处理器上,NUS_BNR_DRV 事件计数核心必须等待的周期数,因为总线未就绪。这表明总线高度使用,从或存储到主内存的加载甚至比平常更长。Core 2 处理器支持更多事件,可以计数特定的总线操作,如 RFO 或总线利用的一般情况。后者在开发期间调查应用程序的可扩展性可能性时可能会很有用。如果总线利用率已经接近 1.0,则可扩展性机会很小。

如果识别出带宽问题,可以采取几件事情。它们有时是矛盾的,所以可能需要一些实验。一种解决方案是购买更快的计算机,如果有一些可用的话。获得更多的 FSB 速度、更快的 RAM 模块,可能还有靠近处理器的内存,可以——并且很可能会——有所帮助。它可能成本很高。如果问题程序只在一台(或几台机器)上需要,硬件的一次性费用可能比重写程序的成本更低。总的来说,最好还是改进程序。

在优化程序本身以避免缓存未命中之后,实现更好带宽利用的唯一选项是更好地在可用的核心上放置线程。默认情况下,内核中的调度器将根据其自己的策略将线程分配给处理器。尽可能避免将线程从一个核心移动到另一个核心。调度器实际上对工作负载一无所知。它可以从缓存未命中等收集信息,但这在许多情况下并没有太多帮助。

图 6.13:低效调度

可以导致大量 FSB 使用的情况之一是,当两个线程被安排在不同的处理器(或核心上,它们不共享缓存)上,并且它们使用相同的数据集时。图 6.13 显示了这种情况。核心 1 和 3 访问相同的数据(通过访问指示器和内存区域的相同颜色表示)。同样,核心 2 和 4 访问相同的数据。但是线程被安排在不同的处理器上。这意味着每个数据集必须从内存中读取两次。这种情况可以更好地处理。

图 6.14:高效调度

在图 6.14 中,我们看到它应该如何理想地看起来。现在使用的总缓存大小减少了,因为现在核心 1 和 2 以及核心 3 和 4 处理相同的数据。数据集只需从内存中读取一次。

这是一个简单的例子,但通过扩展,它适用于许多情况。如前所述,内核中的调度器对数据的使用没有任何了解,因此程序员必须确保调度是高效的。没有多少内核接口可用于传达此要求。事实上,只有一个:定义线程亲缘性。

线程亲缘性意味着将线程分配给一个或多个核心。调度器然后将在这些核心中选择(仅)决定在哪里运行线程。即使其他核心处于空闲状态,它们也不会被考虑。这听起来可能是一个缺点,但这是必须付出的代价。如果太多线程专门在一组核心上运行,剩余的核心可能大部分时间都处于空闲状态,除了改变亲缘性之外,没有什么可以做的。默认情况下,线程可以在任何核心上运行。

有几种接口可用于查询和更改线程的亲缘性:

1
2
3
4
5
#define _GNU_SOURCE
#include <sched.h>

int sched_setaffinity(pid_t pid, size_t size, const cpu_set_t *cpuset);
int sched_getaffinity(pid_t pid, size_t size, cpu_set_t *cpuset);

这两个接口旨在用于单线程代码。pid 参数指定应更改或确定哪个进程的亲缘性。调用者显然需要适当的权限来执行此操作。第二和第三个参数指定核心的位掩码。第一个函数要求位掩码被填充,以便它可以设置亲缘性。第二个函数用所选线程的调度信息填充位掩码。这些接口在 <sched.h> 中声明。

cpu_set_t 类型也在该头文件中定义,以及许多用于操作和使用此类对象的宏。

1
2
3
4
5
6
7
8
9
#define _GNU_SOURCE
#include <sched.h>

#define CPU_SETSIZE
#define CPU_SET(cpu, cpusetp)
#define CPU_CLR(cpu, cpusetp)
#define CPU_ZERO(cpusetp)
#define CPU_ISSET(cpu, cpusetp)
#define CPU_COUNT(cpusetp)

CPU_SETSIZE 指定可以在数据结构中表示的 CPU 数量。其他三个宏操作 cpu_set_t 对象。要初始化对象,应使用 CPU_ZERO;其他两个宏用于选择或取消选择单个核心。CPU_ISSET 测试特定处理器是否是集合的一部分。CPU_COUNT 返回集合中选定的核心数。cpu_set_t 类型为 CPU 数量提供了一个合理的默认值作为上限。随着时间的推移,它肯定会被证明太小;在那时,该类型将被调整。这意味着程序始终要注意大小。上面的便利宏根据 cpu_set_t 的定义隐式处理大小。如果需要更动态的大小处理,则应使用一组扩展的宏:

1
2
3
4
5
6
7
8
#define _GNU_SOURCE
#include <sched.h>

#define CPU_SET_S(cpu, setsize, cpusetp)
#define CPU_CLR_S(cpu, setsize, cpusetp)
#define CPU_ZERO_S(setsize, cpusetp)
#define CPU_ISSET_S(cpu, setsize, cpusetp)
#define CPU_COUNT_S(setsize, cpusetp)

这些接口接受一个额外的参数,大小。为了能够动态分配大小的 CPU 集合,提供了三个宏:

1
2
3
4
5
6
#define _GNU_SOURCE
#include <sched.h>

#define CPU_ALLOC_SIZE(count)
#define CPU_ALLOC(count)
#define CPU_FREE(cpuset)

CPU_ALLOC_SIZE 宏返回必须为 cpu_set_t 结构分配的字节数,该结构可以处理 count 个 CPU。要分配这样一个块,可以使用 CPU_ALLOC 宏。以这种方式分配的内存应使用 CPU_FREE 释放。这些函数可能会在幕后使用 mallocfree,但这并不一定非要保持这种方式。

最后,定义了 CPU 集合对象上的一些操作:

1
2
3
4
5
6
7
8
9
10
11
#define _GNU_SOURCE
#include <sched.h>

#define CPU_EQUAL(cpuset1, cpuset2)
#define CPU_AND(destset, cpuset1, cpuset2)
#define CPU_OR(destset, cpuset1, cpuset2)
#define CPU_XOR(destset, cpuset1, cpuset2)
#define CPU_EQUAL_S(setsize, cpuset1, cpuset2)
#define CPU_AND_S(setsize, destset, cpuset1, cpuset2)
#define CPU_OR_S(setsize, destset, cpuset1, cpuset2)
#define CPU_XOR_S(setsize, destset, cpuset1, cpuset2)

这两组四个宏可以检查两个集合是否相等,并对集合执行逻辑 AND、OR 和 XOR 操作。这些操作在使用一些 libNUMA 函数(见第 12 节)时可能会派上用场。

进程可以使用 sched_getcpu 接口确定它当前正在哪个处理器上运行:

1
2
3
4
#define _GNU_SOURCE
#include <sched.h>

int sched_getcpu(void);

结果是 CPU 集合中的 CPU 索引。由于调度的本质,这个数字并不总是 100 % 正确。线程在返回结果和线程返回用户级之间可能已经被移动到不同的 CPU 上。程序始终必须考虑不准确的可能。更重要的是,无论如何,线程被允许在其上运行的 CPU 集合。这个集合可以通过 sched_getaffinity 检索。集合由子线程和进程继承。线程不能依赖集合在其生命周期内保持稳定。亲缘性掩码可以从外部设置(见上述原型中的 pid 参数);Linux 还支持 CPU 热插拔,这意味着 CPU 可以从系统中消失——因此,也从亲缘性 CPU 集合中消失。

在多线程程序中,各个线程官方上没有 POSIX 定义的进程 ID,因此不能使用上述两个函数。相反,<pthread.h> 声明了四个不同的接口:

1
2
3
4
5
6
7
8
9
10
#define _GNU_SOURCE
#include <pthread.h>

int pthread_setaffinity_np(pthread_t th, size_t size,
const cpu_set_t *cpuset);
int pthread_getaffinity_np(pthread_t th, size_t size, cpu_set_t *cpuset);
int pthread_attr_setaffinity_np(pthread_attr_t *at,
size_t size, const cpu_set_t *cpuset);
int pthread_attr_getaffinity_np(pthread_attr_t *at, size_t size,
cpu_set_t *cpuset);

前两个接口基本上与我们已经看到的两个接口相同,只是它们在第一个参数中接受线程句柄,而不是进程 ID。这允许在进程中单独解决线程。这也意味着这些接口不能从另一个进程中使用,它们严格用于进程内使用。第三和第四个接口使用线程属性。这些属性用于创建新线程。通过设置属性,可以从一开始就在特定的核心集合上调度线程。在线程实际开始之前选择目标处理器——而不是在线程已经开始之后——在许多不同的层面上可能是有利的,包括(尤其是)内存分配(见第 6.5 节的 NUMA)。

谈到 NUMA,亲缘性接口在 NUMA 编程中也扮演着重要角色。我们很快就会回到这个案例。

到目前为止,我们已经讨论了两个线程的工作集重叠的情况,使得让两个线程在同一个核心上有意义。相反的情况也可能是正确的。如果两个线程在不同的数据集上工作,将它们调度在同一个核心上可能会有问题。两个线程争夺同一个缓存,从而减少了彼此对缓存的有效使用。其次,两个数据集都必须加载到同一个缓存中;实际上,这增加了必须加载的数据量,因此可用带宽减半。

在这种情况下的解决方案是设置线程的亲缘性,使它们不能被调度到同一个核心上。这与前面的情况相反,因此在进行任何更改之前,理解要优化的情况进行很重要。

为了缓存共享而优化带宽实际上是 NUMA 编程的一个方面,这在下一节中介绍。我们只需要将“内存”的概念扩展到缓存。随着缓存级别的增加,这将变得越来越重要。因此,多核调度的解决方案在 NUMA 支持库中可用。有关如何确定亲缘性掩码而不使用硬编码系统详细信息或深入 /sys 文件系统底层的代码示例,请参见第 12 节。

6.5 NUMA 编程

对于 NUMA 编程,到目前为止关于缓存优化的所有内容也都适用。差异仅在该级别以下开始。NUMA 在访问地址空间的不同部分时引入了不同的成本。在统一内存访问中,我们可以优化以最小化页面错误(见第 7.5 节),但就是这样。所有页面都是平等的。

NUMA 改变了这一点。访问成本可能取决于被访问的页面。不同的访问成本也增加了优化内存页面局部性的重要性。对于大多数 SMP 机器来说,NUMA 是不可避免的,因为 Intel 使用 CSI(对于 x86、x86-64 和 IA-64)和 AMD(对于 Opteron)都采用了它。随着每个处理器上核心数量的增加,我们可能会看到 SMP 系统的使用急剧减少(至少在数据中心和 CPU 使用要求极高的人的办公之外)。大多数家用机器只需要一个处理器,因此没有 NUMA 问题。但这 a) 不意味着程序员可以忽略 NUMA,b) 并不意味着没有相关问题。

如果一个人思考对 NUMA 的概括,很快就会意识到概念扩展到处理器缓存。使用相同缓存的核心上的两个线程将比不共享缓存的核心上的线程更快地协作。这不是一个虚构的案例:

  • 早期的双核心处理器没有 L2 共享。
  • 例如,Intel 的 Core 2 QX 6700 和 QX 6800 四核芯片分别有两个独立的 L2 缓存。
  • 如早期所推测的,随着芯片上核心数量的增加和统一缓存的愿望,我们将拥有更多级别的缓存。

缓存形成了它们自己的层次结构,核心上的线程放置对于共享(或不共享)缓存变得重要。这与 NUMA 面临的问题并没有太大不同,因此这两个概念可以统一。因此,即使只对非 SMP 机器感兴趣的人也应该阅读本节。

在第 5.3 节中,我们已经看到 Linux 内核提供了许多有用且在 NUMA 编程中需要的信息。然而,收集这些信息并不容易。目前 Linux 上可用的 NUMA 库对此目的来说完全不合适。作者目前正在构建一个更合适的版本。

现有的 NUMA 库 libnuma,是 numactl 包的一部分,不提供对系统架构信息的访问。它只是对可用系统调用的包装,以及一些常用操作的便利接口。Linux 今天可用的系统调用是:

  • mbind 为指定的内存页面选择节点的绑定。

  • set_mempolicy 设置默认的内存绑定策略。

  • get_mempolicy 获取默认的内存绑定策略。

  • migrate_pages 将进程的所有页面从一个给定的节点集迁移到不同的节点集。

  • move_pages 将选定的页面移动到给定的节点或请求关于页面的节点信息。

这些接口在 <numaif.h> 中声明,它随 libnuma 库一起提供。在我们深入更多细节之前,我们必须理解内存策略的概念。

6.5.1 内存策略

定义内存策略的背后思想是允许现有代码在不需要大量修改的情况下,在 NUMA 环境中合理地工作。策略由子进程继承,这使得使用 numactl 工具成为可能。这个工具可以用来启动一个带有给定策略的程序。

Linux 内核支持以下策略:

MPOL_BIND 内存仅从给定的节点集分配。如果不可能,则分配失败。

MPOL_PREFERRED 内存最好从给定的节点集分配。如果失败,考虑来自其他节点的内存。

MPOL_INTERLEAVE 内存从指定的节点等量分配。节点是通过 VMA 基于策略的虚拟内存区域的偏移选择的,或者是通过任务基于策略的自由运行计数器选择的。

MPOL_DEFAULT 根据区域的默认值选择分配。

这个列表似乎是递归定义策略。这是半真的。事实上,内存策略形成了一个层次结构(见图 6.15)。

图 6.15:内存策略层次结构

如果地址被 VMA 策略覆盖,则使用此策略。共享内存段使用一种特殊类型的策略。如果没有为特定地址指定策略,则使用任务的策略。如果这也不存在,则使用系统的默认策略。

系统的默认策略是将内存分配给请求内存的线程本地。默认情况下,没有提供任务和 VMA 策略。对于具有多个线程的进程,本地节点是“主”节点,即首先运行进程的节点。上述系统调用可以用来选择不同的策略。

6.5.2 指定策略

set_mempolicy 调用可以用来设置当前线程(内核术语中的任务)的任务策略。只有当前线程受到影响,而不是整个进程。

1
2
3
4
5
#include <numaif.h>

long set_mempolicy(int mode,
unsigned long *nodemask,
unsigned long maxnode);

mode 参数必须是上一节中介绍的 MPOL_* 常量之一。nodemask 参数指定要使用的内存节点,maxnodenodemask 中的节点数(即位数)。如果使用 MPOL_DEFAULT,则忽略 nodemask 参数。如果 nodemaskMPOL_PREFERRED 的空指针,则选择本地节点。否则,MPOL_PREFERRED 使用 nodemask 中相应位设置的最低节点号。

设置策略不会影响已经分配的内存。页面不会自动迁移;只有未来的分配受到影响。请注意内存分配和地址空间保留之间的区别:使用 mmap 建立的地址空间区域通常不是自动分配的。对内存区域的第一次读写操作将分配适当的页面。如果策略在访问同一地址空间区域的不同页面之间发生变化,或者如果策略允许从不同的节点分配内存,那么一个看似统一的地址空间区域可能会分散在许多内存节点上。

6.5.3 交换和策略

如果物理内存耗尽,系统必须丢弃干净页面并保存脏页面到交换空间。Linux 交换实现在将页面写入交换空间时丢弃节点信息。这意味着当页面被重用并且页面被分页时,将从头选择使用的节点。这种变化的关联意味着程序不能将节点关联存储为页面的属性。关联可以随着时间的推移而改变。对于与其他进程共享的页面,这种情况也可能发生,因为进程可能要求它(见下面的 mbind 讨论)。内核本身可以在一个节点空间不足而其他节点仍然有空闲空间时迁移页面。

因此,用户级代码了解到的任何节点关联只能在短时间内是正确的。它更像是一个提示,而不是绝对信息。每当需要准确知识时,都应该使用 get_mempolicy 接口(见第 6.5.5 节)。

6.5.4 VMA 策略

要为地址范围设置 VMA 策略,必须使用不同的接口:

1
2
3
4
5
#include <numaif.h>

long mbind(void *start, unsigned long len,
int mode, unsigned long *nodemask,
unsigned long maxnode, unsigned flags);

这个接口为地址范围 [ start, start + len) 注册了一个新的 VMA 策略。由于内存处理操作在页面上进行,因此起始地址必须与页面对齐。len 值会向上舍入到下一页大小。

mode 参数再次指定策略;值必须从第 6.5.1 节中的列表中选择。与 set_mempolicy 一样,nodemask 参数仅在某些策略中使用。它的处理是相同的。

mbind 接口的语义取决于 flags 参数的值。默认情况下,如果 flags 为零,系统调用为地址范围设置 VMA 策略。现有的映射不受影响。如果这还不够,目前有三个标志可以修改这种行为;它们可以单独或一起选择:

  • MPOL_MF_STRICT

如果 nodemask 未指定的所有页面都在 mbindstartlen 参数描述的地址范围内,则调用 mbind 将失败。如果将此标志与 MPOL_MF_MOVE 和/或 MPOL_MF_MOVEALL 一起使用,并且任何页面无法移动,则调用将失败。

  • MPOL_MF_MOVE

内核将尝试移动在 nodemask 未指定的节点上分配的地址范围内的任何页面。默认情况下,只有当前进程的页表单独使用的页面才会被移动。

  • MPOL_MF_MOVEALL

MPOL_MF_MOVE,但内核将尝试移动所有页面,而不仅仅是当前进程的页表单独使用的页面。这有系统级的影响,因为它会影响其他进程(可能是不同用户拥有的)的内存访问。因此,MPOL_MF_MOVEALL 是一个需要特权的操作(需要 CAP_NICE 能力)。




请注意,对 MPOL_MF_MOVEMPOL_MF_MOVEALL 的支持仅在 Linux 内核的 2.6.16 版本中添加。

不带任何标志调用 mbind 在需要在实际分配任何页面之前为新保留的地址范围指定策略时最有用。

1
2
3
void *p = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_ANON, -1, 0);
if (p != MAP_FAILED)
mbind(p, len, mode, nodemask, maxnode, 0);

这段代码序列保留了 len 字节的地址空间范围,并指定应该使用引用 nodemask 中内存节点的 mode 策略。除非与 mmap 一起使用 MAP_POPULATE 标志,否则在 mbind 调用时不会有内存被分配,因此新策略适用于该地址空间区域的所有页面。

MPOL_MF_STRICT 标志单独使用可以用来确定 mbindstartlen 参数描述的地址范围内的任何页面是否分配在 nodemask 指定的节点之外。未更改任何已分配的页面。如果所有页面都在指定节点上分配,则根据 mode 更改地址空间区域的 VMA 策略。

有时可能需要重新平衡内存,这种情况下可能需要将一个节点上分配的页面移动到另一个节点。将 MPOL_MF_MOVE 设置为 mbind 的调用将尽力实现这一目标。只有进程的页表树单独引用的页面才被考虑移动。可能有多个用户以线程或其他进程的形式共享该部分页表树。不可能影响其他进程,因为它们恰好映射了相同的数据。这些页面不共享页表条目。

如果将 MPOL_MF_STRICTMPOL_MF_MOVE 都传递给 mbind,则内核将尝试移动所有未在指定节点上分配的页面。如果不可能,调用将失败。这样的调用可能有助于确定是否有一个节点(或节点集)可以容纳所有页面。可以连续尝试几种组合,直到找到合适的节点。

使用 MPOL_MF_MOVEALL 更难以辩解,除非运行当前进程是计算机的主要目的。原因是即使是出现在多个页表中的页面也会被移动。这很容易对其他进程产生负面影响。因此,应谨慎使用此操作。

6.5.5 查询节点信息

get_mempolicy 接口可以用来查询有关给定地址的 NUMA 状态的多种信息。

1
2
3
4
5
6
#include <numaif.h>

long get_mempolicy(int *policy,
const unsigned long *nmask,
unsigned long maxnode,
void *addr, int flags);

get_mempolicy 被调用时,在 flags 中没有设置标志,有关地址 addr 的策略的信息存储在指向 policy 的指针指向的字中,以及指向 nmask 的指针指向的节点位掩码中。如果 addr 落在已指定 VMA 策略的地址空间区域中,则返回有关该策略的信息。否则,返回有关任务策略或必要时系统默认策略的信息。

如果 flags 中设置了 MPOL_F_NODE 标志,并且管理 addr 的策略是 MPOL_INTERLEAVE,则存储在指向 policy 的指针指向的字中的值是下一次分配将要发生的节点的索引。这些信息可能用于设置将要使用新分配内存的线程的亲缘性。这可能是实现接近性的一种成本较低的方式,特别是如果线程尚未创建。

MPOL_F_ADDR 标志可用于检索另一种完全不同的数据项。如果使用此标志,则存储在指向 policy 的指针指向的字中的值是已为包含 addr 的页面分配内存的内存节点的索引。这些信息可用于决策可能的页面迁移,决定哪个线程可以最有效地工作在内存位置,等等。

CPU(因此内存节点)是线程使用的比其内存分配更易变。除非有明确的请求,否则内存页面只有在极端情况下才会移动。程序可以查看当前的 CPU 和节点信息;只是必须避免假设关联不会改变。

libNUMA 提供了两个接口,用于查询给定虚拟地址空间范围的节点信息:

1
2
3
4
5
6
7
#include <libNUMA.h>

int NUMA_mem_get_node_idx(void *addr);
int NUMA_mem_get_node_mask(void *addr,
size_t size,
size_t __destsize,
memnode_set_t *dest);

NUMA_mem_get_node_maskdest 中设置位,以表示根据管理策略,范围 [ addr, addr + size) 中的页面分配在(或将分配在)的所有内存节点。NUMA_mem_get_node 仅查看地址 addr 并返回此地址分配在(或将分配在)的内存节点的索引。这些接口比 get_mempolicy 更易于使用,可能应该被优先选择。

线程当前使用的 CPU 可以使用 sched_getcpu 查询(见第 6.4.3 节)。使用这些信息,程序可以确定对 CPU 本地的内存节点(s),使用 libNUMA 中的 NUMA_cpu_to_memnode 接口:

1
2
3
4
5
6
7
#include <libNUMA.h>

int NUMA_cpu_to_memnode(size_t cpusetsize,
const cpu_set_t *cpuset,
size_t memnodesize,
memnode_set_t *
memnodeset);

对这个函数的调用将在指向第四个参数的内存节点集对象中设置所有位,以对应于任何在指向第二个参数的 CPU 集合中本地的内存节点。就像 CPU 信息本身一样,这些信息只有在机器配置发生变化时才是正确的(例如,CPU 被移除和添加)。

memnode_set_t 对象中的位可以在调用底层函数(如 get_mempolicy)时使用。使用 libNUMA 中的其他函数更方便。反向映射可通过以下方式获得:

1
2
3
4
5
6
7
#include <libNUMA.h>

int NUMA_memnode_to_cpu(size_t memnodesize,
const memnode_set_t *
memnodeset,
size_t cpusetsize,
cpu_set_t *cpuset);

结果 cpuset 中设置的位是本地于 memnodeset 中相应位设置的任何内存节点的 CPU。对于这两个接口,程序员必须意识到这些信息会随着时间的推移而变化(特别是 CPU 热插拔)。在许多情况下,输入位集只有一个位被设置,但是,例如,将 sched_getaffinity 调用检索到的整个 CPU 集合传递给 NUMA_cpu_to_memnode 以确定线程可以直接访问哪些内存节点,也是有意义的。

6.5.6 CPU 和节点集

通过改变代码以使用迄今为止描述的接口来调整 SMP 和 NUMA 环境可能非常昂贵(或不可能),如果源代码不可用。此外,系统管理员可能想要对用户和/或进程可以使用的资源施加限制。对于这些情况,Linux 内核支持所谓的 CPU 集。这个名字有点误导,因为它们也涵盖了内存节点。它们也与 cpu_set_t 数据类型无关。

CPU 集的接口目前是特殊的文件系统。它通常不挂载(至少到目前为止)。这可以通过以下命令更改:

1
mount -t cpuset none /dev/cpuset

当然,挂载点 /dev/cpuset 必须存在。这个目录的内容是默认(根)CPU 集的描述。它最初包括所有 CPU 和所有内存节点。该目录中的 cpus 文件显示了 CPU 集中的 CPU,mems 文件显示了内存节点,tasks 文件显示了进程。

要创建一个新的 CPU 集,只需在层次结构中的任何位置创建一个新目录。新的 CPU 集将从父级继承所有设置。然后,可以通过向新目录中的 cpusmems 伪文件中写入新值来更改新 CPU 集的 CPU 和内存节点。

如果一个进程属于一个 CPU 集,那么该进程的 CPU 和内存节点的设置将用作亲缘性和内存策略位掩码的掩码。这意味着程序不能选择任何不在进程使用的 CPU 集的 cpus 文件中的 CPU(即,在 tasks 文件中列出的 CPU)。对于内存策略的节点掩码和 mems 文件也是如此。

程序不会遇到任何错误,除非掩码之后掩码为空,因此 CPU 集是控制程序执行的几乎不可见的手段。这种方法在具有许多 CPU 和/或内存节点的大型机器上特别有效。将进程移动到新的 CPU 集就像将进程 ID 写入适当 CPU 集的 tasks 文件一样简单。

CPU 集的目录包含许多其他文件,可以用来指定内存压力下的行为和对 CPU 和内存节点的独占访问等细节。感兴趣的读者可以参考内核源代码树中的 Documentation/cpusets.txt 文件。

6.5.7 明确的 NUMA 优化

所有的本地内存和亲缘性规则都不能帮助,如果所有线程在所有节点上都需要访问相同的内存区域。当然,可以通过简单地将线程数量限制为直接连接到内存节点的处理器支持的数量来解决问题。然而,这并不利用 SMP NUMA 机器的优势,因此不是一个真正的选择。

如果所讨论的数据是只读的,那么有一个简单的解决方案:复制。每个节点可以获取数据的自己的副本,这样就不需要节点间的访问。执行此操作的代码可能如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void *local_data(void) {
static void *data[NNODES];
int node =
NUMA_memnode_self_current_idx();
if (node == -1)
/* Cannot get node, pick one. */
node = 0;
if (data[node] == NULL)
data[node] = allocate_data();
return data[node];
}
void worker(void) {
void *data = local_data();
for (...) {
compute using data
}
}

在这个代码中,worker 函数通过调用 local_data 获取数据的本地副本的指针。然后它继续循环,使用这个指针。local_data 函数保留了一个已经分配的数据副本的列表。每个系统有有限数量的内存节点,因此指向每个节点内存副本的指针数组的大小是有限的。NUMA_memnode_system_count 函数从 libNUMA 返回这个数字。如果当前节点的指针,由 NUMA_memnode_self_current_idx 调用确定,尚未知道,则分配一个新的副本。

重要的是要意识到,如果线程在 sched_getcpu 系统调用后被调度到连接到不同内存节点的另一个 CPU 上,并不会发生可怕的事情。这只意味着 worker 中使用 data 变量的访问将访问另一个内存节点上的内存。这会减慢程序的速度,直到 data 被重新计算,但仅此而已。内核将始终避免无故重新平衡每个 CPU 的运行队列。如果发生这样的转移,通常是有充分理由的,并且不会在不久的将来再次发生。

当涉及到的内存区域是可写的时,情况会更加复杂。在这种情况下,简单的复制将不起作用。根据具体情况,可能有一些可能的解决方案。

例如,如果可写内存区域用于累积结果,可能有可能首先为每个内存节点创建一个单独的区域以累积结果。然后,当这项工作完成时,将所有每个节点的内存区域合并以获得总结果。即使工作从未真正停止,但需要中间结果,这种技术也可以工作。这种方法的要求是,累积结果的操作是无状态的,即,它不依赖于之前收集的结果。

然而,直接访问可写内存区域总是更好的。如果对内存区域的访问次数很大,可能有必要强制内核将涉及的内存页面迁移到本地节点。如果访问次数非常高,并且不同节点上的写入没有同时发生,这可能会有所帮助。但请注意,内核不能创造奇迹:页面迁移是复制操作,因此并不便宜。这个成本必须摊销。

6.5.8 利用所有带宽

图 5.4 中的数字显示,当缓存无效时,对远程内存的访问并不比对本地内存的访问明显变慢。这意味着程序可能通过将不必再次读取的数据写入连接到另一个处理器的内存中,从而节省本地内存的带宽。连接到 DRAM 模块的带宽和互连的带宽大多是独立的,因此并行使用可以提高整体性能。

是否真的可能取决于许多因素。一个人必须非常确定缓存是无效的,因为否则与远程访问相关的减速是可测量的。另一个大问题是远程节点是否对自己的内存带宽有需求。在采取这种方法之前,必须详细检查这种可能性。理论上,利用处理器可用的所有带宽可能会产生积极的效果。10h 系列 Opteron 处理器可以直接连接到多达四个其他处理器。利用所有这些额外的带宽,可能结合适当的预取(特别是 prefetchw)可以带来改进,如果系统的其余部分配合的话。

参考资料


每一个程序员都应该了解的内存知识-Part6
https://ysc2.github.io/ysc2.github.io/2024/04/29/每一个程序员都应该了解的内存知识-Part6/
作者
Ysc
发布于
2024年4月29日
许可协议