Skip to main content

摘要

本文以 XBOX One vs XBOX Series X|S 中所奠定的基础为起点,讨论处理器核心之间共享内存的不同读/写操作所带来的性能问题与开销。要复习 XBOX One 系列与 XBOX Series 主机 CPU 的架构、特性和性能,建议你先查看上述文章。 简介 共享数据 数据共享的开销 建议 附录:代码

简介


XBOX One vs XBOX Series X|S 描述了 L1 缓存未命中所带来的开销。L1 缓存未命中最常见的模式之一,就是向 CPU 核心之间共享的地址写入数据 —— 例如多线程之间共享的数据。本文的目的在于提供有关跨核心共享可写数据所引发的性能损失的相对数值。 我们测试得出的主要结论是:将读/写操作中的数据限制在每个核心内部,比在核心和线程之间共享数据更为可取。跨核心共享数据只会拖慢代码的处理速度,而不会加快它。 XBOX One 系列使用的是 Jaguar 处理器,XBOX Series 主机使用的是 Hercules 处理器。这两种处理器都使用 MOESI 协议 - 维基百科 来维护强内存模型和缓存一致性。每条缓存行可能处于以下五种状态之一。
  • Modified(已修改)
    • 该处理器持有此缓存行唯一的有效副本,并且已经进行了修改。
    • 该缓存行与内存不一致。
  • Owned(拥有)
    • 该处理器持有唯一的有效副本,并且已经进行了修改。
    • 其他处理器可能拥有只读副本。
  • Exclusive(独占)
    • 该处理器拥有此缓存行的唯一副本。
    • 缓存行内容与内存一致。
  • Shared(共享)
    • 该处理器拥有此缓存行的若干副本之一。
    • 如果数据已被修改,则另一处理器可能以 Owned 状态持有它。
  • Invalid(无效)
    • 此缓存行无效,访问前必须先取回数据。
有关 AMD 具体实现的详细信息,请参阅 AMD Developer Central 上的《AMD64 Architecture Programmer’s Manual Volume 2: System Programming》。

共享数据


数据共享可以以多种方式发生。较为明显的一种是变量的直接共享,例如某个结构上的引用计数。在多线程通过 shared_ptr 各自制作副本(例如作为函数调用参数传递)时,这类数据很容易被修改。仅这一操作就会导致资源争用。 另一种数据共享,称为伪共享 (false sharing),则更难被察觉。伪共享出现在两个内存地址落在同一条缓存行上时。Jaguar 与 Hercules 处理器的 L1 缓存行都是 64 字节大小,且按 64 字节对齐,因此位于同一 64 字节块中的两个整数会被视为是共享的。以下面两个数据结构为例:
threadOne、threadTwo、threadThree 和 threadFour 的数据在每个线程中是唯一的。线程一只操作 threadOne 数据,线程二只操作 threadTwo 数据,以此类推。在这两种数据结构之间,当切换为多线程处理仅执行普通写操作时,OuterClassSlow 的速度可能比 OuterClassFast 慢多达二十倍。这是因为 OuterClassSlow 中的数据由于伪共享全都位于同一条缓存行上,它们在内存中彼此相邻。这就是结构体数组 (AoS) 与数组结构体 (SoA) 之间差异的一个典型示例。

数据共享的开销


测试概要

测试概要遵循以下标准:
  • 测试的指令包括一次原始读、一次原始写、一次原子加载、一次原子存储和一次原子比较并交换 (CAS) 操作。每组指令所使用的代码见 附录:代码
  • 测试要么使用所有线程共享的 8 字节内存位置(共享缓存行),要么使每个线程都有唯一的 8 字节内存位置(独占缓存行)。
  • 在紧凑循环中执行了 40,000 次操作。
  • 优化器设置为最小化尺寸,以禁用循环展开。
  • 每种情形共执行 100 次测试;下表中显示的是所有测试的中位数。

单核测试

作为基准,我们首先在单核上测试了每种操作。 | 操作 | XBOX One | XBOX One X | XBOX Series S - 3.4 GHz | XBOX Series X|S - 3.6 GHz | XBOX Series X - 3.8 GHz | |--------------|----------:|----------:|----------:|----------:|----------:| | 原始读 | 68.78 us | 52.34 us | 11.81 us | 11.16 us | 10.56 us | | 原子加载 | 68.78 us | 52.34 us | 11.81 us | 11.16 us | 10.56 us | | 原始写 | 114.61 us | 104.64 us | 11.81 us | 11.16 us | 10.56 us | | 原子存储 | 369.55 us | 278.99 us | 207.11 us | 195.61 us | 185.31 us | | 原子 CAS | 668.79 us | 509.02 us | 203.43 us | 192.13 us | 182.03 us | 首先是 Jaguar 处理器与 Hercules 处理器之间的耗时差异。Hercules 上性能最多提升了四倍。这得益于更高的时钟频率、更高的带宽以及对更多正在进行的内存操作的支持。 对比 40,000 次直接读与 40,000 次原子加载,两者的耗时没有任何差异。这归因于 CPU 的强内存模型以及 MOESI 协议。编译器能够为这两种操作生成相同的指令。 本测试中的原始写和原子存储操作尝试对内存中的某个值进行自增。测试显示原子存储的开销大约是原始写的两倍。原因是原子存储使用的是 xchg 指令,该指令隐式地带有 lock 前缀。这可能会使处理器停顿,直到结果被刷新到缓存中,因为它无法跨带 lock 前缀的指令重排操作。而原始写使用 mov 指令,不会让处理器停顿,处理器可以自由地跨 mov 指令重排操作。 接下来是原子比较并交换 (CAS) 操作。本测试中的操作与原始写和原子存储类似,都是尝试对内存中的某个值自增。CAS 测试使用带 lock 前缀的 cmpxchg 指令,以便与原子存储测试可比。在 Jaguar 处理器上,由于额外的比较操作,CAS 比前面提到的 xchg 更昂贵。但 Hercules 处理器有更好的实现,使得其开销与原子存储相当。

多核测试

多核测试均使用两个线程,在若干核心配置下运行。
  • 两个线程位于同一集群内的不同物理核心上。
  • 两个线程位于不同集群的不同物理核心上。
  • 在启用了 SMT 的情况下,两个线程运行于同一物理处理器的两个逻辑核心上。
每个测试我们都进行了两次不同的运行。第一次运行时两个线程共享同一个 uint64_t 地址。第二次运行时每个线程使用自己独有、不共享缓存行的 uint64_t 地址。这样做是为了更方便观察缓存行在处理器之间共享时的性能损失。每次测试中两个线程同时启动,计时只统计循环迭代部分。为尽可能降低上下文切换的概率,线程也被设置为高优先级。每个测试执行 100 次;下表显示中位数结果。 以下给出的数字均相对于单核基准测试,即当操作跨多个核心运行时开销增加了多少。

原始读 / 原子加载

这是从内存位置直接读取。由于没有任何东西在修改数据,线程之间不存在争用:每个线程在自己的 L1 缓存中都持有数据的副本。因此,读取共享数据与读取独占数据的耗时相同。由于强内存模型,编译器可以为原子加载生成与普通读取相同的代码。这意味着两种操作耗时相同。 | 测试 | XBOX One | XBOX One X | XBOX Series X|S - SMT | XBOX Series X|S - 无 SMT | |--------------|----------:|----------:|----------:|----------:|----------:| | 单核 | 1.00 | 1.00 | 1.00 | 1.00 | | 同物理核共享 | N/A | N/A | 1.97 | N/A | | 同集群共享 | 1.00 | 1.00 | 1.00 | 1.00 | | 跨集群共享 | 1.00 | 1.00 | 1.00 | 1.00 | | 同物理核独占 | N/A | N/A | 1.97 | N/A | | 同集群独占 | 1.00 | 1.00 | 1.00 | 1.00 | | 跨集群独占 | 1.00 | 1.00 | 1.00 | 1.00 | 从此表可以得出两个主要结论。 第一,仅从内存读取对性能没有影响,即使多个核心都从同一位置读取也是如此。每个处理器在缓存中都拥有一份有效副本,由于不止一个处理器持有副本,它们的缓存行处于 Shared 状态。 第二,两个线程运行于同一物理核心时耗时翻倍。启用 SMT 时,核心的资源在两个线程之间共享。通常一个线程无法充分利用所有可用资源,因此可能带来性能提升。但本测试是一个非常紧凑的循环,占满了 Load/Store 单元和 L1 缓存。单线程就能占满 Load/Store 单元的所有槽位,因此两个线程必须以轮询方式共享它们。结果就是每个线程耗时翻倍,但核心的总吞吐量翻倍。整体上核心在相同时间内做了同样多的工作。

原始写

对核心间共享的内存的简单写入,会显著慢于单核向不共享位置的写入。写操作会使当前核心更新缓存行,并使其他核心上的副本失效。 本测试是对共享内存中值进行自增。这意味着如果当前核心的缓存中没有有效副本,就需要从内存或最后一次写入该地址的核心那里请求数据。这就会在两个核心之间产生乒乓效应:一个核心更新数据,第二个核心从第一个核心读取该数据并更新,然后第一个核心再从第二个核心读取以便更新。测试循环的每次迭代都在两个核心之间来回反复。 从下表可以看到这种乒乓效应带来的开销。 | 测试 | XBOX One | XBOX One X | XBOX Series X|S - SMT | XBOX Series X|S - 无 SMT | |--------------|----------:|----------:|----------:|----------:|----------:| | 单核 | 1.00 | 1.00 | 1.00 | 1.00 | | 同物理核共享 | N/A | N/A | 10.69 | N/A | | 同集群共享 | 10.54 | 8.73 | 24.36 | 23.59 | | 跨集群共享 | 13.61 | 12.78 | 18.89 | 23.57 | | 同物理核独占 | N/A | N/A | 1.98 | N/A | | 同集群独占 | 1.05 | 0.93 | 1.03 | 1.03 | | 跨集群独占 | 1.01 | 0.84 | 1.04 | 1.03 | 此表可以得出几点结论。 第一,Jaguar 和 Hercules 处理器上相对性能影响的差异。在 Hercules 上共享缓存行的开销比在 Jaguar 上更高。此例中,Jaguar 从其他核心取数据需要 17 个周期,Hercules 相同操作需要 90 个周期。这是因为 Hercules 具有更深的缓存层级,多了一个 Jaguar 没有的 L3 缓存。 第二,使用共享内存位置与独占位置之间的差异。本测试的操作是对当前值进行自增,属于读-改-写操作。读操作必须由最近一次写入该内存位置的核心来满足。如果不是同一个核心,读取的开销将大幅升高。可以在表中看到,其开销最多可高达二十五倍。 第三,两个线程共享同一物理核心并启用 SMT 时的相对差异要小一些。更新后的缓存行不需要从另一个核心取回,它就在本地。但 store-to-load 转发无法应用:线程 A 更新值之后,必须先将其刷入缓存,线程 B 才能使用该值。 第四,两个线程运行于同一物理核心但操作独占内存时耗时翻倍。启用 SMT 时,核心的资源在两个线程之间共享。通常一个线程无法充分利用所有可用资源,因此可能带来性能提升。但本测试是一个非常紧凑的循环,占满了 Load/Store 单元和 L1 缓存。单线程就能占满 Load/Store 单元的所有槽位,因此两个线程必须以轮询方式共享它们。结果就是每个线程耗时翻倍,但核心的总吞吐量翻倍。整体上核心在相同时间内做了同样多的工作。

原子存储

原子存储使用 xchg 指令,该指令隐式地带有 lock 标志。xchg 指令必须先读取再写入相关内存地址。在此期间缓存行被锁定,其他核心无法访问该缓存行。这可能会使某个待执行的操作停顿,直到 xchg 指令已将数据写入缓存。另一项开销是核心无法跨 xchg 指令重排操作。最后一项开销出现在其他核心修改了该数据时,此时需要从该核心取回数据。在对共享缓存行进行操作时,其他核心必须在整个序列期间等待。 与原始写测试类似,原子存储测试也可能出现乒乓效应,只是更加明显,甚至会影响单核测试。 处理器无法推测性地执行超过循环一次迭代之后的指令。线程 A 执行 xchg 指令时,线程 B 等待访问权。一旦线程 A 完成,线程 B 便可以继续,并在线程 A 从下一次循环迭代开始请求访问之前立即请求一份副本。 下表显示了 xchg 指令相对于单核执行该操作的相对开销。 | 测试 | XBOX One | XBOX One X | XBOX Series X|S - SMT | XBOX Series X|S - 无 SMT | |--------------|----------:|----------:|----------:|----------:|----------:| | 单核 | 1.00 | 1.00 | 1.00 | 1.00 | | 同物理核共享 | N/A | N/A | 2.38 | N/A | | 同集群共享 | 6.84 | 6.85 | 4.17 | 4.04 | | 跨集群共享 | 10.64 | 13.40 | 3.72 | 3.56 | | 同物理核独占 | N/A | N/A | 0.98 | N/A | | 同集群独占 | 1.00 | 1.00 | 1.00 | 1.00 | | 跨集群独占 | 1.01 | 1.00 | 1.00 | 1.00 | 从该表中可以得出两个结论。 对于原子存储,使用共享地址时的相对耗时不像原始写那样夸张。原因是本测试的大部分开销已经在单核测试中就付出了。但原子存储的总体开销在 Hercules 上仍然是原始写的两到四倍,在 Jaguar 上最多可达十三倍。 第二,Hercules 改进了 lock 操作的实现,因此在本例中同集群和跨集群测试耗时大致相同。而在 Jaguar 处理器上,当数据在集群间共享并使用 lock 操作时,开销最多可达两倍。

原子比较并交换 (CAS)

本测试中的原子比较并交换 (CAS) 操作使用带 lock 前缀的 cmpxchg 指令,这意味着操作执行期间缓存行被锁定,其他处理器无法访问。这与原子存储测试中 xchg 指令的工作方式一致。 本测试中,CAS 操作执行的运算与原子存储操作相同,都是尝试对内存中的值自增(无论共享还是独占)。唯一的区别在于,CAS 操作仅在该值等于指定的第三个值时才会写入内存位置。 | 测试 | XBOX One | XBOX One X | XBOX Series X|S - SMT | XBOX Series X|S - 无 SMT | |--------------|----------:|----------:|----------:|----------:|----------:| | 单核 | 1.00 | 1.00 | 1.00 | 1.00 | | 同物理核共享 | N/A | N/A | 2.91 | N/A | | 同集群共享 | 4.32 | 4.32 | 4.01 | 4.24 | | 跨集群共享 | 10.12 | 14.80 | 3.52 | 3.45 | | 同物理核独占 | N/A | N/A | 0.91 | N/A | | 同集群独占 | 1.00 | 1.00 | 1.00 | 1.00 | | 跨集群独占 | 1.00 | 1.00 | 1.00 | 1.00 | 由于原子存储和原子比较并交换测试都使用了 lock 前缀,因此其相对开销相同,原因也一致。 使用共享地址时,单线程与多线程操作之间的相对耗时并不算特别夸张,因为本测试的大部分开销已经在单核测试中付出了。但是在 Hercules 上共享数据时 CAS 的整体开销仍是两到四倍,在 Jaguar 上最多可达十四倍。 第二,Hercules 对 lock 操作有改进的实现,因此本例中同集群和跨集群测试的耗时大致相同。在 Jaguar 处理器上,当数据在集群之间共享并使用 lock 操作时,开销最多可达两倍。

建议


有几种关键模式可以帮助你避免跨核心共享可写数据:
  • 数据结构:从结构体数组 (AoS) 切换为数组结构体 (SoA),让每个线程都操作独立的结构。
  • 将只读数据与读/写数据分开。
  • 填充:可以将结构填充为缓存行大小 (64 字节) 的倍数。如果每个线程操作一个结构,这样可以消除伪共享。
  • 引用计数(shared_ptr、weak_ptr 等):它们都有一个单一的引用计数变量,会被对象的所有实例共享。
  • 明确数据的所有权。当数据被交给另一个线程处理时,数据现在归该线程所有。在该线程用完数据前,避免对数据进行任何操作。
  • 共享作业队列:尽量避免让多个线程共用一个作业队列,尤其是作业很小的情况。可以为每个线程使用独立的作业队列,然后利用工作窃取算法在线程间平衡负载。

附录:代码


原始读

原子加载

原始写

原子存储

原子 CAS

最后修改于 2026年8月24日