CAS碰撞的CPU-内部特征是什么



我正在努力了解CAS在x86/x64上的低级机制,我非常感谢您的帮助/见解。

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

如果我观察一个没有指数退避的无锁自由列表基准测试,我会发现随着线程数量的增加,性能会迅速持平。

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发生破坏性碰撞。两者都会失败。(主要是因为我在脑海里想着以太网)。

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

经过更多的思考,我相信现在不可能是这样。CAS指令实际上没有故障模式。它会告诉你目的地等于或不等于比较器。仅此而已。它不会回来说"哦,对不起,撞到别人了"。

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

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

但现在我很困惑。

我们看到的是,单个线程执行大约3000万次推送/弹出操作。目的地必须在其中一个操作的持续时间内保持稳定,才能使操作成功,因此我们看到有3000万个"插槽"。如果我们有两个线程,那么我们可以拥有的最大理论性能是每个线程1500万次操作;每个线程使用一半的槽。

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

但现在想象一下,我们有很多线索。开始CAS的第一个线程将成功(假设每个CAS花费完全相同的时间——这不是真的,但这个假设不会改变任何根本性的东西,所以可以推理)。所有其他人都会失败。

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

那么,为什么我们没有看到完美的缩放呢?因为每个"插槽"都应该被使用!

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

阅读Intel的Architecture Software Developer's Manual(体系结构软件开发人员手册),我发现如果所有数据都存在于缓存中(我对这种情况感兴趣),则缓存一致性协议会处理CAS。

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

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

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

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

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

写入操作将缓存行标记为已修改;RFO发生,导致第二核心将其高速缓存线标记为无效。

第二个线程完成其CAS,并注意到其缓存行无效。。。然后呢?我发现很难相信指令在成功之前是在CPU内部循环的——尽管我想知道,因为ARM上的LL/SC需要在程序集中执行这个循环。但是CAS指令知道目的地的值已经改变,所以它的比较结果是无效的。但CAS不可能出现错误;对于比较,它总是返回true或false。但即使指令循环直到完成,我仍然期望完美的缩放。每个"插槽"仍应使用。

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

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

我非常感兴趣地注意到,在同一物理核心规模上的两个线程是完美的。在这种情况下,一些特殊而不同的事情正在发生——位于不同物理核心上的两个线程也有一半的规模。但这还不足以解释这一切。

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

CAS,我想你说的是锁定CMPXCHG

第二个线程开始执行CAS。第一个核心将其缓存行发送到第二个核心和两个核心都有缓存线标记为共享。

您似乎认为操作开始、被中断、继续。CAS相对于内存子系统是原子的。因此,它一次读取、比较和写入值。一旦获得缓存线,它就不会在任何时间段将缓存线丢失给另一个核心。这是怎么回事?它在指令执行期间引发处理器锁定信号,以便其他指令在内存子系统上暂停,直到缓存线再次可用。这就是CMPXCHG指令上有LOCK前缀的原因。您可以阅读LOCK说明以了解更多详细信息。

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

所以,我一直在思考这一切。

目前,对于如何处理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都从读取内存开始,因为另一个核心已经使CAS核心缓存线无效。

在这种情况下,当一个内核完成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)

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

相关内容

  • 没有找到相关文章

最新更新