在boost :: detail :: spinlock_pool中错误共享?

时间:2012-06-14 16:43:04

标签: c++ boost c++11 shared-ptr boost-thread

我遇到了这个SO question并且阅读它最终导致我看boost::detail::spinlock_pool

boost::detail::spinlock_pool的目的是通过在spinlock的地址上进行散列选择shared_ptr数组来减少对全局自旋锁的潜在争用。这似乎是一个合理的解决方案,但目前(Boost v1.49)版本的实现似乎存在问题。

spinlock_pool管理静态分配的41个spinlock个实例的数组。我看到的平台似乎sizeof(spinlock)==4 - 这意味着,例如x64,64字节的高速缓存行,每个高速缓存行将有16 spinlock个。

即。整个数组跨越所有2 1/2缓存行。

即。一个随机螺旋锁错误共享的可能性为40%。

......首先几乎完全违背了游泳池的目的。

我的分析是正确的还是我遗漏了一些重要的东西?

更新:我最后写了一个小基准程序:

#include <boost/shared_ptr.hpp>
#include <boost/thread.hpp>
#include <boost/timer.hpp>
#include <iostream>
#include <vector>
#include <stdlib.h>

using namespace std;

enum { BufferSize = 1<<24,  SLsPerCacheLine = 1 };

int          ibuffer[BufferSize];

using boost::detail::spinlock;
size_t nslp = 41;
spinlock* pslp = 0;

spinlock& getSpinlock(size_t h)
{
  return pslp[ (h%nslp) * SLsPerCacheLine ];
}


void threadFunc(int offset)
{
  const size_t mask = BufferSize-1;
  for (size_t ii=0, index=(offset&mask); ii<BufferSize; ++ii, index=((index+1)&mask))
  {
    spinlock& sl = getSpinlock(index);
    sl.lock();
    ibuffer[index] += 1;
    sl.unlock();
  }
};


int _tmain(int argc, _TCHAR* argv[])
{
  if ( argc>1 )
  {
    size_t n = wcstoul(argv[1], NULL, 10);
    if ( n>0 )
    {
      nslp = n;
    }
  }

  cout << "Using pool size: "<< nslp << endl;
  cout << "sizeof(spinlock): "<< sizeof(spinlock) << endl;
  cout << "SLsPerCacheLine: "<< int(SLsPerCacheLine) << endl;
  const size_t num = nslp * SLsPerCacheLine;
  pslp = new spinlock[num ];
  for (size_t ii=0; ii<num ; ii++)
  { memset(pslp+ii,0,sizeof(*pslp)); }

  const size_t nThreads = 4;
  boost::thread* ppThreads[nThreads];
  const int offset[nThreads] = { 17, 101, 229, 1023 };

  boost::timer timer;

  for (size_t ii=0; ii<nThreads; ii++)
  { ppThreads[ii] = new boost::thread(threadFunc, offset[ii]); }

  for (size_t ii=0; ii<nThreads; ii++)
  { ppThreads[ii]->join(); }

  cout << "Elapsed time: " << timer.elapsed() << endl;

  for (size_t ii=0; ii<nThreads; ii++)
  { delete ppThreads[ii]; }

  delete[] pslp;

  return 0;
}

我编译了两个版本的代码,一个版本为SLsPerCacheLine==1,另一个版本为SLsPerCacheLine==8。使用MSVS 2010优化32位,在4核Xeon W3520 @ 2.67Ghz(禁用超线程)上运行。

我无法从这些测试中获得一致的结果 - 偶尔会观察到高达50%的杂散时序变化。但是,平均而言,SLsPerCacheLine==8版本似乎比SLsPerCacheLine==1版本快了约25-30%,并且自旋锁表格大小为41。

看看它如何与更大数量的内核,NUMA,超线程等进行扩展会很有趣。我目前无法访问那种硬件。

1 个答案:

答案 0 :(得分:14)

<强>附图

你是对的,因为当自旋锁可能位于不同的缓存行中时,它们共享相同的缓存行。阿卡虚假分享。通过在不同的缓存行中分配锁定可能会有一些性能提升(但见下文)。

但是, NOT 与锁争用相同。当一个人握住一个锁,并且一个或多个其他人想要访问它时,就会出现锁争用。

即。 spinlock_pool具有由共同驻留在同一缓存行中的锁引起的缓存行争用。但它有(减少)软件锁争用。

减少的软件锁争用几乎无疑是好的。

缓存行争用可能会受到一些影响,因为您的基准测试在某种程度上显示,但与软件锁争用相比,这是一个二阶效应。

<强> DETAIL

<强>背景

首先,一些背景知识:

经典的spinloops是test-and-test-and-set:

    loop: 
         tmp := load( memory_location_of_sw_lock )
         if( is_locked(tmp) ) goto loop
         was_locked := atomic_hw_locked_rmw( memory_location_of_sw_lock, 
                                             value_that_will_get_sw_lock )
         if( was_locked(tmp) ) goto loop
     got_the_sw_lock:
          ...
          ... critical section
          ...
          // release the lock, e.g.
          memory_location_of_sw_lock := 0

还有测试和设置的自旋锁,看起来像

    loop: 
         was_locked := atomic_hw_locked_rmw( memory_location_of_sw_lock, 
                                             value_that_will_get_sw_lock )
         if( was_locked(tmp) ) goto loop

在大多数具有缓存,直写或回写的现代内存系统上,这些都会有非常糟糕的性能。 (尽管我提出的一些硬件优化使得测试和设置的自旋循环与测试和测试和设置的自旋循环一样快 - 因为它们更小,但速度稍快。)

请注意,这里有两种不同的锁定概念:&#34;软件&#34;锁定自旋锁正在获取,以及atomic_hw_locked_rmw指令使用的硬件锁,例如Intel LOCK INC mem或CMPXCHG。我们不太关心后者,除了知道它通常无条件地写入持有软件锁的高速缓存行,使高速缓存行的其他副本无效。 (使写入条件是另一种可能的硬件优化。)

O(N ^ 2)突发缓存未命中(软件)锁争用

使用测试和测试设置的spinloops进行锁争用特别糟糕。服务员全都旋转锁定,当它被释放时,会有一连串的总线访问。一个人赢了,其他人意识到他们已经输了,最终他们安顿下来再次旋转。这种突发活动特别糟糕,因为对于N个等待的人(线程/进程/处理器),总线活动的突发大小可能是O(N ^ 2),因为在最坏的情况下,每个人都退出测试的部分 - 并且测试并设置了spinloop,并且每个人都尝试同时执行原子锁定的RMW(读 - 修改 - 写)指令,如x86 LOCK INC mem或CMPXCHG。这意味着每个人最终会写出这一行,即使除了第一个之外的所有人都不需要写锁,因为他们不会获得锁定。

E.g。

Lock is held by P0
P1-PN are spinning in test-and-test-and-set spinloops waiting for it.

P0 releases the lock, e.g. by writing it to 0

P1's "test-" instruction reads the lock
...
PN's "test-" instruction reads the lock

All of P1-PN see the lock as released, 
   so they fall out of the "test-" part of the spinloop which uses ordinary instructions
   to the test-and-set part of the spinloop, which uses a hardware atomic RMW like LOCK INC mem

P1's locked RMW happens, and acquires the spinlock for P1
   It invalidates all other cache lines
   P1 goes away and does something with the data protected by the lock

P2's locked RMW fails to acquire the spinlock
   It invalidates all other caches because it has a write
   P1 falls back into its test- spinloop

P3's locked RMW fails
   It invalidates all other caches because it has a write
   P1 falls back into its test- spinloop

...

PN's locked RMW fails

现在,至少,所有剩余的P2..PN处理器必须为其test-spinloop执行普通的未锁定缓存未命中。这意味着至少N +(N-1)个高速缓存未命中。它可能会更糟糕,因为每个写入都可能由未能获得锁定的服务员触发所有其他服务员进行解锁读取。即,根据时间安排,你可能会得到

   1 release the lock
   N reads to test- the lock
   1 locked atomic RMW to acquire the lock
   N reads...
   for each of the remaining N-1 processors
      1 locked RMW that fails to acquire the lock
      N reads 

是O(N ^ 2)。或者可能,对于处理器M,1个锁定的RMW,然后是2..M读取 - 仍然是O(N ^ 2)。

这对这个问题意味着什么?

好的,如果存在真正的锁争用,则在释放竞争锁时会出现此O(N ^ 2)突发缓存未命中。

然而,spinlock_pool将服务员分散到多个锁上。如果自旋锁池中有S锁,那么服务器就会少得多:可能少于N / S(因为在共享同一锁的人数中,争用往往是超线性的)。

即。使用Boost的spinlock_pool,你可能会天真地期望获得1/41的争用 - 并且可能少于那个。

请记住,spinlock_pool是在每个shared_pointer上有一个锁,增加共享指针的大小,以及让所有shared_pointers共享同一个锁之间的折衷。因此,对shared_pointer的自旋锁的任何争用可能是(a)真正的争用,或者(b)由spin_set中相同条目的独立散列的shared_poin引起的错误争用。

现在,是的,如果不是有N个服务员,那么只有&#34;只有&#34; N / 41个服务员,那么突发仍然是O((N / 41)^ 2)或O(N ^ 2)。但是,如果N通常小于41 ......你就明白了。

基本上,散列将shared_Pointers传播到多个spinlock_pool条目上可以快速减少争用量。

但是......自旋锁存在于同一个高速缓存行中?是的......但是其他缓存行上的服务员不会前进到他们的自旋循环的测试和设置部分。

即。如果与M个服务员的竞争锁与M个其他进程共享一个缓存行,您将获得M * N流量。但是如果散列已将M减少到O(1),则只能获得N个流量。

如果大部分时间都没有其他服务员,那么你只能在锁定释放时获得O(1)权限。

BOTTOM LINE

与减少导致硬件缓存行争用的缓存错误共享相比,减少软件锁争用的性能要好得多。

现在,如果不将这么多spinlock_pool条目打包到同一个缓存行中,仍然可能会有好处。但这绝不是显而易见的;这是一个经验问题,我的意思是你需要运行一个实验,它会随着工作量而变化。

有时在同一个缓存行中错误共享这些锁会很糟糕。一个典型的例子是保护处理器运行队列的锁。

有时在同一缓存行中错误共享这些锁是好的 - 它可以提供预取在缓存中所做的相同好处。例如。想象你已经分配了一个shared_pointers数组,人们通常会访问aosptr [i]然后访问aosptr [i + 1]等。

这完全取决于您的工作量。我已经看到它双向下降。通常根据我的经验,每个缓存行一个锁定更好,但通常不是很多。

更有趣

如果您关心,测试和测试和设置spinloops的硬件优化是我的MS论文的主题,&#34;同步基元的功能分类,包括总线放弃锁定&#34; - 硬件更新缓存协议,消除了总线访问的突发。除大学Microfiche外,未在任何正式出版物上发表。

我的工作受到了论文的启发&#34;共享内存多处理器的自旋锁替代方案的性能&#34;作者:T Anderson - IEEE并行和分布式系统交易,1990。

我认为可以说大多数出版物都是关于软件,算法,包括着名的MCS锁。我认为这种技术虽然在理论上很受欢迎,却很少被熟练的程序员所使用。

顺便说一下,这里可以进行更多的硬件优化。例如。 CMPXCHG根本不需要写锁。不幸的是,在目前的英特尔硬件上,或者至少在我1991年设计的英特尔硬件上,我仍然认为使用它,释放硬件锁(用于实现原子锁定的RMW)的唯一方法是使用特殊的微码写&#34;存储 - 解锁&#34;。哎呀,微代码甚至可以使用LoadLinked / StoreConditional(LL / SC),在一些天真的LL / SC在某些线程上没有前进的情况下回退到锁定状态。

英特尔最近有可能修复此问题。我在2009年离开了英特尔。自1991年以来,我一直试图将其修复,改进,优化哦。英特尔最近已经提高了锁的性能。但我认为他们主要致力于无竞争锁定性能,并没有优化竞争锁定性能。

同样,Alain Kagi在他的论文和一些出版物以及专利http://www.google.com/patents/US6460124中表明,通过添加明智的延迟,缓存硬件可以使自旋锁与排队锁一样高效。

其中一些硬件优化使测试和设置的自旋循环比测试和测试和设置的自旋循环更好。

最近的发展是Intel TSX(事务同步扩展),由HLE组成(硬件锁Elision(Ravi Rajwar在UWisc上工作,而I和Alain Kagi在那里工作,尽管我在同步工作早于UIUC) )和RTM(受限制的事务性内存)。这两个都有助于无竞争对手......嗯,它们有助于对抗错误竞争的锁定,例如:粗粒锁保护可能是独立的东西。即假锁争用。在某些方面,HLE可能不需要spinlock_pool。

<强>道歉

我很抱歉:我提供了一个冗长的答案,归结为&#34;减少软件锁争用可能比spinloops&#34;的硬件缓存线争用更重要。

虽然我希望通过为每个缓存行分配1个spinloop可以获得一些性能,但它可能很小,而在某些不完全不常见的工作负载上甚至可能会有损失。

可以肯定的是,你必须测量它。

但是,无论如何,最大的收获来自减少软件锁争用。

相关问题