web-dev-qa-db-ja.com

最速のインライン-アセンブリスピンロック

パフォーマンスが重要なC++でマルチスレッドアプリケーションを作成しています。スレッド間で小さな構造をコピーするときは、多くのロックを使用する必要があります。このため、スピンロックを使用することを選択しました。

私はこれについていくつかの調査と速度テストを行いましたが、ほとんどの実装はほぼ同じくらい高速であることがわかりました。

  • SpinCountを1000に設定したMicrosoftのCRITICAL_SECTIONは、約140時間単位を記録します。
  • 実装 このアルゴリズム MicrosoftのInterlockedCompareExchangeスコアは約95時間単位です
  • また、__asm {}このコード のようなものを使用してインラインアセンブリを使用しようとしましたが、スコアは約70時間単位ですが、適切なメモリバリアが作成されました。

編集:ここに示されている時間は、2つのスレッドがスピンロックを1,000,000回ロックおよびロック解除するのにかかる時間です。

これはそれほど大きな違いではないことは知っていますが、スピンロックは頻繁に使用されるオブジェクトであるため、プログラマーはスピンロックを作成するための可能な限り最速の方法に同意したと思います。しかし、グーグルすると多くの異なるアプローチにつながります。 32ビットレジスタを比較する代わりに、インラインアセンブリを使用し、命令CMPXCHG8Bを使用して実装すると、 この前述の方法 が最速になると思います。 さらにメモリバリアを考慮する必要があります。これは、共有メモリに対する「排他的権利」を保証するLOCK CMPXHG8B(私は思いますか?)によって行うことができます。コア間。最後に[いくつかの提案]は、ビジー待機には、ハイパースレッディングプロセッサが別のスレッドに切り替えることを可能にするNOP:REPを伴う必要があることを示唆していますが、私はこれが本当かどうかわからない?

さまざまなスピンロックのパフォーマンステストから、大きな違いはないことがわかりますが、純粋に学術的な目的のために、どれが最も速いかを知りたいと思います。ただし、アセンブリ言語とメモリバリアの経験は非常に限られているため、LOCK CMPXCHG8Bと適切なメモリバリアを使用して提供した最後の例のアセンブリコードを誰かが記述できれば幸いです。 次のテンプレート:

__asm
{
     spin_lock:
         ;locking code.
     spin_unlock:
         ;unlocking code.
}
24
sigvardsen

ここを見てください: cmpxchgを使用したx86スピンロック

そして、CoryNelsonに感謝します

__asm{
spin_lock:
xorl %ecx, %ecx
incl %ecx
spin_lock_retry:
xorl %eax, %eax
lock; cmpxchgl %ecx, (lock_addr)
jnz spin_lock_retry
ret

spin_unlock:
movl $0 (lock_addr)
ret
}

そして別の情報源は言う: http://www.geoffchappell.com/studies/windows/km/cpu/cx8.htm

       lock    cmpxchg8b qword ptr [esi]
is replaceable with the following sequence

try:
        lock    bts dword ptr [edi],0
        jnb     acquired
wait:
        test    dword ptr [edi],1
        je      try
        pause                   ; if available
        jmp     wait

acquired:
        cmp     eax,[esi]
        jne     fail
        cmp     edx,[esi+4]
        je      exchange

fail:
        mov     eax,[esi]
        mov     edx,[esi+4]
        jmp     done

exchange:
        mov     [esi],ebx
        mov     [esi+4],ecx

done:
        mov     byte ptr [edi],0

そして、ここにロックフリーとロックの実装についての議論があります: http://newsgroups.derkeiler.com/Archive/Comp/comp.programming.threads/2011-10/msg00009.html

すでに受け入れられている回答がありますが、すべての回答を改善するために使用できる、見逃したものがいくつかあります。 このIntelの記事、上記のすべての高速ロックの実装

  1. アトミック命令ではなく、揮発性の読み取りでスピンします。これにより、特に競合の激しいロックで、不要なバスロックが回避されます。
  2. 激しく争われているロックにはバックオフを使用する
  3. ロックをインライン化します。インラインasmが有害なコンパイラー(基本的にはMSVC)の組み込み関数を使用することが望ましいです。
10
Necrolis

私は通常、高速コードを達成しようと努力している人に不満を抱く人ではありません。これは通常、プログラミングと高速コードの理解を深める非常に優れた演習です。

ここでも不満はありませんが、高速スピンロックの3命令の長さ、またはそれ以上の問題は、少なくともx86アーキテクチャでは無駄な追跡であると明確に述べることができます。

理由は次のとおりです。

典型的なコードシーケンスでスピンロックを呼び出す

lock_variable DW 0    ; 0 <=> free

mov ebx,offset lock_variable
mov eax,1
xchg eax,[ebx]

; if eax contains 0 (no one owned it) you own the lock,
; if eax contains 1 (someone already does) you don't

スピンロックを解放するのは簡単です

mov ebx,offset lock_variable
mov dword ptr [ebx],0

Xchg命令は、プロセッサのロックピンを上げます。これは、事実上、次の数クロックサイクル中にバスが必要であることを意味します。この信号は、キャッシュを通過して、通常はPCIバスである最も遅いバスマスタリングデバイスに到達します。すべてのバスマスタリングデバイスが完了すると、locka(ロック確認)信号が返送されます。その後、実際の交換が行われます。問題は、ロック/ロックシーケンスに非常に長い時間がかかることです。 PCIバスは33MHzで動作し、数サイクルのレイテンシがあります。 3.3 GHz CPUでは、各PCIバスサイクルに100CPUサイクルかかることを意味します。

経験則として、ロックが完了するまでに300〜3000 CPUサイクルかかると思いますが、最終的には、ロックを所有するかどうかさえわかりません。したがって、「高速」スピンロックで節約できる数サイクルは、次のようなロックがないため、蜃気楼になります。それは、その短い時間のバスの状況によって異なります。

________________編集________________

スピンロックは「よく使われるオブジェクト」だと読んだばかりです。まあ、あなたは明らかに、スピンロックが呼び出されるたびに膨大な量のCPUサイクルを消費することを理解していません。または、言い換えると、呼び出すたびに、処理能力のかなりの量が失われます。

スピンロック(またはそれらのより大きな兄弟、クリティカルセクション)を使用するときの秘訣は、意図したプログラム機能を達成しながら、可能な限り控えめに使用することです。あらゆる場所でそれらを使用するのは簡単であり、結果としてパフォーマンスが低下することになります。

高速なコードを書くだけでなく、データを整理することも重要です。 「スレッド間で小さな構造をコピーする」と書くときは、実際のコピーよりもロックが完了するまでに数百倍の時間がかかる可能性があることに注意してください。

________________編集________________

平均ロック時間を計算するとき、それは意図されたターゲットではないかもしれない(それは完全に異なるバス使用特性を持っているかもしれない)あなたのマシンで測定されるのでおそらくほとんど何も言わないでしょう。お使いのマシンの平均は、個々の非常に速い時間(バスマスタリングアクティビティが干渉しなかった場合)から非常に遅い時間(バスマスタリングの干渉が大きかった場合)までで構成されます。

最も速いケースと最も遅いケースを決定するコードを導入し、商を計算して、スピンロック時間がどれだけ大きく変化するかを確認できます。

________________編集________________

2016年5月の更新。

Peter Cordesは、「競合のない場合にロックを調整することは理にかなっている」という考えと、ロック変数がずれている状況を除いて、最新のCPUでは数百クロックサイクルのロック時間が発生しないという考えを推進しました。私の 以前のテストプログラム -32ビットのWatcom Cで書かれた-は、64ビットのOSであるWindows 7で実行されていたため、WOW64によって妨げられるのではないかと考え始めました。

そこで、64ビットプログラムを作成し、TDMのgcc5.3でコンパイルしました。このプログラムは、暗黙的にバスロックする命令バリアント「XCHG r、m」をロックに使用し、単純な割り当て「MOV m、r」をロック解除に使用します。一部のロックバリアントでは、ロック変数を事前にテストして、ロックを試行することさえ可能かどうかを判断しました(単純な比較「CMPr、m」を使用して、おそらくL3の外に出ることはありません)。ここにあります:

// compiler flags used:

// -O1 -m64 -mthreads -mtune=k8 -march=k8 -fwhole-program -freorder-blocks -fschedule-insns -falign-functions=32 -g3 -Wall -c -fmessage-length=0

#define CLASSIC_BUS_LOCK
#define WHILE_PRETEST
//#define SINGLE_THREAD

typedef unsigned char      u1;
typedef unsigned short     u2;
typedef unsigned long      u4;
typedef unsigned int       ud;
typedef unsigned long long u8;
typedef   signed char      i1;
typedef          short     i2;
typedef          long      i4;
typedef          int       id;
typedef          long long i8;
typedef          float     f4;
typedef          double    f8;

#define usizeof(a) ((ud)sizeof(a))

#define LOOPS 25000000

#include <stdio.h>
#include <windows.h>

#ifndef bool
typedef signed char bool;
#endif

u8 CPU_rdtsc (void)
{
  ud tickl, tickh;
  __asm__ __volatile__("rdtsc":"=a"(tickl),"=d"(tickh));
  return ((u8)tickh << 32)|tickl;
}

volatile u8 bus_lock (volatile u8 * block, u8 value)
{
  __asm__ __volatile__( "xchgq %1,%0" : "=r" (value) : "m" (*block), "0" (value) : "memory");

  return value;
}

void bus_unlock (volatile u8 * block, u8 value)
{
  __asm__ __volatile__( "movq %0,%1" : "=r" (value) : "m" (*block), "0" (value) : "memory");
}

void rfence (void)
{
  __asm__ __volatile__( "lfence" : : : "memory");
}

void rwfence (void)
{
  __asm__ __volatile__( "mfence" : : : "memory");
}

void wfence (void)
{
  __asm__ __volatile__( "sfence" : : : "memory");
}

volatile bool LOCK_spinlockPreTestIfFree (const volatile u8 *lockVariablePointer)
{
  return (bool)(*lockVariablePointer == 0ull);
}

volatile bool LOCK_spinlockFailed (volatile u8 *lockVariablePointer)
{
  return (bool)(bus_lock (lockVariablePointer, 1ull) != 0ull);
}

void LOCK_spinlockLeave (volatile u8 *lockVariablePointer)
{
  *lockVariablePointer = 0ull;
}

static volatile u8 lockVariable = 0ull,
                   lockCounter =  0ull;

static volatile i8 threadHold = 1;

static u8 tstr[4][32];    /* 32*8=256 bytes for each thread's parameters should result in them residing in different cache lines */

struct LOCKING_THREAD_STRUCTURE
{
  u8 numberOfFailures, numberOfPreTests;
  f8 clocksPerLock, failuresPerLock, preTestsPerLock;
  u8 threadId;
  HANDLE threadHandle;
  ud idx;
} *lts[4] = {(void *)tstr[0], (void *)tstr[1], (void *)tstr[2], (void *)tstr[3]};

DWORD WINAPI locking_thread (struct LOCKING_THREAD_STRUCTURE *ltsp)
{
  ud n = LOOPS;
  u8 clockCycles;

  SetThreadAffinityMask (ltsp->threadHandle, 1ull<<ltsp->idx);

  while (threadHold) {}

  clockCycles = CPU_rdtsc ();
  while (n)
  {
    Sleep (0);

#ifdef CLASSIC_BUS_LOCK
    while (LOCK_spinlockFailed (&lockVariable)) {++ltsp->numberOfFailures;}
#else
#ifdef WHILE_PRETEST
    while (1)
    {
      do
      {
        ++ltsp->numberOfPreTests;
      } while (!LOCK_spinlockPreTestIfFree (&lockVariable));

      if (!LOCK_spinlockFailed (&lockVariable)) break;
      ++ltsp->numberOfFailures;
    }
#else
    while (1)
    {
      ++ltsp->numberOfPreTests;
      if (LOCK_spinlockPreTestIfFree (&lockVariable))
      {
        if (!LOCK_spinlockFailed (&lockVariable)) break;
        ++ltsp->numberOfFailures;
      }
    }
#endif
#endif
    ++lockCounter;
    LOCK_spinlockLeave (&lockVariable);

#ifdef CLASSIC_BUS_LOCK
    while (LOCK_spinlockFailed (&lockVariable)) {++ltsp->numberOfFailures;}
#else
#ifdef WHILE_PRETEST
    while (1)
    {
      do
      {
        ++ltsp->numberOfPreTests;
      } while (!LOCK_spinlockPreTestIfFree (&lockVariable));

      if (!LOCK_spinlockFailed (&lockVariable)) break;
      ++ltsp->numberOfFailures;
    }
#else
    while (1)
    {
      ++ltsp->numberOfPreTests;
      if (LOCK_spinlockPreTestIfFree (&lockVariable))
      {
        if (!LOCK_spinlockFailed (&lockVariable)) break;
        ++ltsp->numberOfFailures;
      }
    }
#endif
#endif
    --lockCounter;
    LOCK_spinlockLeave (&lockVariable);

    n-=2;
  }
  clockCycles = CPU_rdtsc ()-clockCycles;

  ltsp->clocksPerLock =   (f8)clockCycles/           (f8)LOOPS;
  ltsp->failuresPerLock = (f8)ltsp->numberOfFailures/(f8)LOOPS;
  ltsp->preTestsPerLock = (f8)ltsp->numberOfPreTests/(f8)LOOPS;

//rwfence ();

  ltsp->idx = 4u;

  ExitThread (0);
  return 0;
}

int main (int argc, char *argv[])
{
  u8 processAffinityMask, systemAffinityMask;

  memset (tstr, 0u, usizeof(tstr));

  lts[0]->idx = 3;
  lts[1]->idx = 2;
  lts[2]->idx = 1;
  lts[3]->idx = 0;

  GetProcessAffinityMask (GetCurrentProcess(), &processAffinityMask, &systemAffinityMask);

  SetPriorityClass (GetCurrentProcess(), HIGH_PRIORITY_CLASS);
  SetThreadAffinityMask (GetCurrentThread (), 1ull);

  lts[0]->threadHandle = CreateThread (NULL, 65536u, (void *)locking_thread, (void *)lts[0], 0, (void *)&lts[0]->threadId);
#ifndef SINGLE_THREAD
  lts[1]->threadHandle = CreateThread (NULL, 65536u, (void *)locking_thread, (void *)lts[1], 0, (void *)&lts[1]->threadId);
  lts[2]->threadHandle = CreateThread (NULL, 65536u, (void *)locking_thread, (void *)lts[2], 0, (void *)&lts[2]->threadId);
  lts[3]->threadHandle = CreateThread (NULL, 65536u, (void *)locking_thread, (void *)lts[3], 0, (void *)&lts[3]->threadId);
#endif

  SetThreadAffinityMask (GetCurrentThread (), processAffinityMask);

  threadHold = 0;

#ifdef SINGLE_THREAD
  while (lts[0]->idx<4u) {Sleep (1);}
#else
  while (lts[0]->idx+lts[1]->idx+lts[2]->idx+lts[3]->idx<16u) {Sleep (1);}
#endif

  printf ("T0:%1.1f,%1.1f,%1.1f\n", lts[0]->clocksPerLock, lts[0]->failuresPerLock, lts[0]->preTestsPerLock);
  printf ("T1:%1.1f,%1.1f,%1.1f\n", lts[1]->clocksPerLock, lts[1]->failuresPerLock, lts[1]->preTestsPerLock);
  printf ("T2:%1.1f,%1.1f,%1.1f\n", lts[2]->clocksPerLock, lts[2]->failuresPerLock, lts[2]->preTestsPerLock);
  printf ("T3:%1.1f,%1.1f,%1.1f\n", lts[3]->clocksPerLock, lts[3]->failuresPerLock, lts[3]->preTestsPerLock);

  printf ("T*:%1.1f,%1.1f,%1.1f\n", (lts[0]->clocksPerLock+  lts[1]->clocksPerLock+  lts[2]->clocksPerLock+  lts[3]->clocksPerLock)/  4.,
                                    (lts[0]->failuresPerLock+lts[1]->failuresPerLock+lts[2]->failuresPerLock+lts[3]->failuresPerLock)/4.,
                                    (lts[0]->preTestsPerLock+lts[1]->preTestsPerLock+lts[2]->preTestsPerLock+lts[3]->preTestsPerLock)/4.);

  printf ("LC:%u\n", (ud)lockCounter);

  return 0;
}

このプログラムは、DDR3-800、2.7GHz対応の2コア/ 2 HT、および共通のL3キャッシュを搭載したDelli5-4310Uベースのコンピュータで実行されました。

そもそも、WOW64の影響はごくわずかだったようです。

競合のないロック/ロック解除を実行する単一のスレッドは、110サイクルごとに1回実行できました。競合のないロックを調整することは無意味です。単一のXCHG命令を拡張するために追加されたコードは、速度を低下させるだけです。

4つのHTがロック変数をロック試行で攻撃すると、状況は根本的に変化します。ロックを成功させるために必要な時間は994サイクルに跳ね上がり、その重要な部分は2.2回のロック試行の失敗に起因する可能性があります。言い換えると、競合の激しい状況では、ロックを成功させるために平均3.2個のロックを試行する必要があります。明らかに、110サイクルは110 * 3.2ではなく、110 * 9に近づいています。そのため、古いマシンでのテストと同様に、他のメカニズムがここで機能しています。また、平均994サイクルは、716〜1157の範囲を含みます。

事前テストを実装するロックバリアントは、最も単純なバリアント(XCHG)によって必要とされるサイクルの約95%を必要としました。平均して、17個のCMPを実行して、1.75個のロックを試行し、そのうち1個が成功したことを確認します。事前テストを使用することをお勧めします。それは、高速であるだけでなく、複雑さが少し増しても、バスロックメカニズムにかかる負担が少なくなります(3.2-1.75 = 1.45ロック試行が少なくなります)。

6
Olof Forshell

ウィキペディアにはスピンロックに関する優れた記事があります。これがx86の実装です。

http://en.wikipedia.org/wiki/Spinlock#Example_implementation

このStackoverflowの説明で説明されているように、実装では「xchg」命令のx86で冗長であるため、「lock」プレフィックスが使用されていないことに注意してください。

マルチコアx86では、XCHGのプレフィックスとしてLOCKが必要ですか?

REP:NOPはPAUSE命令のエイリアスです。詳細については、こちらをご覧ください。

x86一時停止命令はスピンロックでどのように機能しますか?また、他のシナリオで使用できますか?

メモリバリアの問題について、あなたが知りたいと思うかもしれないすべてがここにあります

メモリバリア:Paul E.McKenneyによるソフトウェアハッカーのハードウェアビュー

http://irl.cs.ucla.edu/~yingdi/paperreading/whymb.2010.06.07c.pdf

5
amdn