12

我正在尝试了解 x86/x64 上 CAS 的低级机制,我非常感谢一些帮助/见解。

我一直在考虑这个问题的原因是我试图对指数退避进行推理,并从原则上找出正确的退避延迟单位应该是多少。

如果我查看一个没有指数退避的无锁 freelist 基准测试,我会看到随着线程数量的增加,性能迅速趋于平缓。

Release 7 Lock-Free Freelist Benchmark #1

   M
   N
   S
  L3U
L2U L2U
L1D L1D
L1I L1I
 P   P
L L L L total ops,mean ops/sec per thread,standard deviation,scalability
0 0 0 1 310134488,31013449,0,1.00
0 1 0 1 136313300,6815665,38365,0.22

0 1 0 1 136401284,6820064,50706,0.22
1 1 1 1 111134328,2778358,23851,0.09

0 0 1 1 334747444,16737372,2421,0.54
1 1 1 1 111105898,2777647,40399,0.09

正如我们所知,可能会发生活锁,每个线程都会阻止其他线程继续进行。

我最初的想法——现在我认为是错误的——认为 CAS 正在干扰 CAS。我的意思是,如果它们同时发生,CAS 指令本身将与另一个 CAS 发生破坏性冲突。两者都会失败。(Prolly,因为我在脑海中思考以太网)。

这“显然”解释了结果 - 所有这些 CAS 指令同时运行,很少有机会在被破坏性中断之前完全执行。

再想一想,我相信现在情况并非如此。CAS 指令实际上没有故障模式。它会告诉你目的地等于或不等于比较对象。就这样。它不会回来说“哦,对不起,撞到别人了”。

破坏性干扰正在发生,但它发生在更高级别,在数据结构算法本身中。当我们从/向自由列表推送或弹出时,我们实际上是在尝试交换。我们需要目标足够长的时间稳定,以便我们可以读取它,做我们需要做的任何工作,然后发现它没有改变,这样我们就可以完成我们的推送/弹出。

如果其他线程继续 CASing,destination 就不稳定——它一直在变化——我们一直不得不重试我们的操作。

但现在我很困惑。

我们看到的是,单个线程执行了大约 3000 万次 push/pop 操作。Destination 必须在其中一项操作期间保持稳定,操作才能成功,因此我们看到有 3000 万个“槽”。如果我们有两个线程,那么我们可以拥有的最大理论性能是每个线程 1500 万次操作;每个线程使用一半的插槽。

现在让我们回到 CAS。CAS 没有故障模式。那么当另一个线程已经在进行 CAS 时,当第二个线程尝试 CAS 时会发生什么?好吧,第二个线程将在数据结构级别失败,因为交换无法发生,所以它将重试交换。

但现在想象我们有很多线程。开始 CAS 的第一个线程将成功(假设每个 CAS 花费完全相同的时间 - 不正确,但该假设不会改变任何基本内容,因此可以推理)。所有其他人都会失败。

但是一旦第一个线程完成,读取新目标值的下一个线程将使其 CAS 成功(所有其他线程,仍在执行它们的 CAS 或现在开始新的 CAS,将失败)。

那么为什么我们看不到完美的缩放比例呢?因为每个“插槽”都应该被使用!

我认为因此我不正确理解CAS。

阅读英特尔的架构软件开发人员手册,我发现如果所有数据都存在于缓存中(我感兴趣的情况),缓存一致性协议会处理 CAS。

Drepper 在他的白皮书中描述了 LL/SC 以及它是如何使用 MESI 工作的。

对我来说,CAS 以类似的方式运作似乎是合理的。

让我们考虑两个线程的情况。第一个线程开始其 CAS。具有目标的缓存行在其缓存中并标记为独占。

第二个线程开始 CAS。第一个核心将其缓存线发送到第二个核心,并且两个核心都将该缓存线标记为共享。

第一个线程完成 CAS 并写入缓存行(写入总是发生在 x86/x64 上,即使比较结果为假;它只是写入原始值)。

写入动作将缓存行标记为已修改;发生 RFO,导致第二个内核将其缓存行标记为无效。

第二个线程来完成它的 CAS 并注意到它的缓存行是无效的......然后,什么?我发现很难相信指令在 CPU 内部循环,直到它成功 - 尽管我想知道,因为 ARM 上的 LL/SC 要求在程序集中执行此循环。但是CAS指令知道destination的值发生了变化,所以它的比较结果是无效的。但是 CAS 不会出错;它总是返回 true 或 false 进行比较。但即使指令确实循环直到完成,我仍然期望完美的缩放。每个“插槽”仍应使用。

那么会发生什么?CAS 发生了什么

我所看到的是,随着线程数的增加,完成的工作越来越少——所有可用的“插槽”肯定都没有被使用。是什么原因造成的。CAS指令之间是否存在破坏性干扰?还是大量 RFO 占用 CPU-> 北桥总线?

我非常感兴趣地注意到的是,同一物理核心上的两个线程可以完美扩展。在这种情况下会发生一些特殊和不同的事情——不同物理内核上的两个线程也可以缩放一半。但这还不足以解释这一切。

4

3 回答 3

4

您在这里看到的是在两个物理内核的 L1 缓存之间移动数据的成本。仅使用一个内核时,数据位于该 L1 缓存中,并且每个 CAS 都与缓存中的数据一起全速运行。另一方面,当有两个内核处于活动状态时,每次一个内核写入数据成功时,都会使另一个缓存失效,这将导致数据需要在缓存之间复制,然后另一个内核才能执行任何操作(一般情况下,它会在 CAS 完成之前阻塞等待加载)。这比实际的 CAS 成本高得多(它至少需要将数据向上移动到 L3 缓存,然后再向下移动到另一个 L1 缓存),并导致您看到速度变慢,因为数据最终会出现乒乓在两个 L1 缓存之间来回切换

于 2011-04-19T18:38:00.770 回答
0

所以,我一直在想这一切。

目前,对于如何处理 CAS,我有两个单独的建议——“缓存锁”和 MESI。

这篇文章纯粹是关于缓存锁的。

高速缓存锁假定一个核心锁定了给定的高速缓存行,而其他尝试在该高速缓存行上进行 CAS 的核心仍然会释放高速缓存。

此外,我还相信 CAS 总是在完成之前将其结果写回内存。

采用该理论,让我们看一下基准并尝试解释结果。

Release 7 Lock-Free Freelist Benchmark #1

   M
   N
   S
  L3U
L2U L2U
L1D L1D
L1I L1I
 P   P
L L L L total ops,mean ops/sec per thread,standard deviation,scalability
0 0 0 1 310134488,31013449,0,1.00
0 1 0 1 136313300,6815665,38365,0.22

0 1 0 1 136401284,6820064,50706,0.22
1 1 1 1 111134328,2778358,23851,0.09

0 0 1 1 334747444,16737372,2421,0.54
1 1 1 1 111105898,2777647,40399,0.09

所以,首先是单线程情况;

L L L L total ops,mean ops/sec per thread,standard deviation,scalability
0 0 0 1 310134488,31013449,0,1.00

在这里,我们有最大的表现。每个“插槽”都由单个线程使用。

现在我们来到同一个核心上的两个线程;

L L L L total ops,mean ops/sec per thread,standard deviation,scalability
0 0 1 1 334747444,16737372,2421,0.54

在这里,我们当然仍然有相同数量的“插槽”——CAS 需要的时间与它一样长——但我们看到它们在逻辑处理器之间均匀分布。这是有道理的;一个核心锁定高速缓存行,另一个停止,第一个完成,第二个获得锁定......它们交替出现。目的地保留在 L1 缓存中,缓存行处于修改状态;我们永远不需要从内存中重新读取目的地,所以从这个意义上说,我们就像一个线程案例。

现在我们来看看不同内核上的两个线程;

L L L L total ops,mean ops/sec per thread,standard deviation,scalability
0 1 0 1 136401284,6820064,50706,0.22

在这里,我们看到了我们的第一次大幅放缓。我们的最大理论缩放比例是 0.5,但我们是 0.22。怎么来的?好吧,每个线程都试图锁定相同的缓存行(当然是在自己的缓存中),这很好 - 但问题是当核心获得锁定时,它需要从内存中重新读取目标,因为它的缓存行将被另一个核心在修改其数据副本后标记为无效。所以我们把速度放慢到我们必须做的内存读取。

现在我们来到四个线程,每个核心两个。

L L L L total ops,mean ops/sec per thread,standard deviation,scalability
1 1 1 1 111105898,2777647,40399,0.09

在这里,我们看到实际上每个核心的操作总数仅略少于一个线程,尽管扩展性当然要差得多,因为我们现在有四个线程,而不是两个。

在每个内核一个线程的情况下,每个 CAS 都以读取内存开始,因为另一个内核已使 CASing 内核缓存行无效。

在这种情况下,当一个核心完成 CAS 并释放缓存锁时,三个线程正在竞争锁,两个在另一个核心上,一个在同一个核心上。所以三分之二的时间我们需要在 CAS 开始时重新读取内存;三分之一的时间我们没有。

所以我们应该更快。但我们实际上更慢。

0% memory re-reading gives 33,474,744.4 total ops per second (two threads, same core)
66% memory re-reading, gives 11,110,589.8 total ops per second (four threads, two per core)
100% memory re-reading, gives 13,640,128.4 total ops per second (two threads, one per core)

这让我很困惑。观察到的事实与理论不符。

于 2011-04-22T14:22:03.803 回答
0

通过 CAS,我假设您在谈论 LOCK CMPXCHG

第二个线程开始 CAS。第一个核心将其缓存线发送到第二个核心,并且两个核心都将该缓存线标记为共享。

您似乎认为操作开始,被打断,继续。CAS 就内存子系统而言是原子的。因此,它一次读取值、比较和写入。没有时隙,一旦它获得缓存线,它就会将缓存线丢失到另一个内核。这是如何运作的 ?它在指令执行期间引发处理器锁定信号,以便其他指令在内存子系统上停止,直到高速缓存线再次可用。这就是 CMPXCHG 指令上有 LOCK 前缀的原因。您可以阅读 LOCK 描述以获取更多详细信息。

因此,发生的大多数争用都发生在 L1 上,试图获得缓存行的独占所有权,而该信号几乎一直被提出。如果 L1 已经有缓存线(例如在同一个内核上有 2 个线程的情况下),唯一的争论是 CAS 本身的持续时间,不包括跨内核的缓存线内存传输(因为它已经存在)。这要快得多。

于 2011-04-20T09:49:25.827 回答