目次


Linux on Power への移植: 妥当な移植結果を最良の移植結果に変えるための 5 つのヒント

ビルドを壊したりパフォーマンスを損ねたりする可能性のある重要ながらも捉えにくい違い

Comments

はじめに

Linux on x86 から Linux on Power にアプリケーションを移植することを考えている場合、「Linux は Linux だ」と言われているように、ほとんどのコードは再コンパイルして再リンクするだけで移植できます。この言葉は、まさに、大半のコードに当てはまります。けれどもあいにく、x86 上では問題なくコンパイル、リンクでき、動作するコードでも、POWER プロセッサー上ではそうではないコードを作成するのはごく簡単なことです。よくある問題の原因としては、次のようなコードが挙げられます。

  • x86 用に極めて最適化されているコード
  • x86 以外のプラットフォーム上で実行されることは期待されていなかった (したがって、移植性を考慮して作成されていない) コード
  • x86 上でしか実行されたことがない (したがって、意図せずに x86 の「先入観」が入り込んでいる) コード

上記のコンテキストがどのように組み合わさったとしても、問題が発生します。そのような場合に備え、Linux on x86 と Linux on Power の間の重要ながらも捉えにくい違い、その違いを軽減させるための手法、そして問題を特定して軽減するのに役立つツールを把握しておくと大いに役立ちます。

コードを検査することで特定できる問題の中には、Linux on Power 用の IBM Software Development Toolkit (SDK) に含まれる Migration Advisor を使用すれば自動的に特定できるものがあります。したがって、移植作業の最初のステップとして、このツールの使用を検討してください。実行時に検出可能な捉えにくいパフォーマンス上の問題は、この SDKSource Code Advisor によって特定できます。さらに捉えにくいパフォーマンス上の問題には、SDKCPI Breakdown ツールが役立つはずです。CPI Breakdown ツールを使用すると、さまざまな偶然やリソースの競合によってプロセッサーが効率的に使用されていないコードの領域を特定することができます。この先、これに類したツールが Open Power SDK GitHub 組織内で追加されていくことが見込まれます。

キャッシュ・ラインのサイズ

x86 POWER
キャッシュ・ラインのサイズ 64 128

主記憶装置の速度は、プロセッサーのパフォーマンスの向上に追いついていません。そのため、プロセッサーの設計者たちはキャッシュと呼ばれるある種のメモリーをプロセッサーに組み込んでいます。キャッシュは主記憶装置よりもはるかに高速ですが (低レイテンシー)、主記憶装置と比べると、かなり小さいサイズです。そのため、通常は複数のキャッシュが階層構造を取って配列されます。レベル 1 (L1) キャッシュは最速 (レイテンシーが最も低い) でサイズが最も小さいキャッシュです。レベル (L2、L3 など) が下がるにつれ、レイテンシーは高くなっていきますが、サイズは大きくなっていきます。

図 1. キャッシュ階層の図

上記の例 (図 1) では、各コア内の同時マルチスレッド化 (SMT) スレッドが L1 キャッシュを共有し、プロセッサー内のコアが L2 キャッシュを共有しています。

プロセッサーがフェッチする最小のメモリー単位は、「キャッシュ・ライン」と呼ばれます。なぜなら (ここでの説明では) フェッチされたメモリーのすべてはキャッシュ階層に取り込まれて、キャッシュ階層のすべてのレベルに保管されるためです。キャッシュ階層のレベル間での移動だけでなく、あるコア専用のキャッシュと別のコア専用のキャッシュ間を移動する際のプロトコルもあります。プロセッサーでは、メモリー内のデータの「ビュー」が、システム上のすべてのコアに同じように映るよう徹底しなければなりません。

キャッシュ・ラインのサイズは、x86 プロセッサー上では 64 バイト、POWER プロセッサー上では 128 バイトです。キャッシュ・ラインのサイズの違いは、プログラムの正確さには影響しませんが、シナリオによってはプログラムのパフォーマンスに顕著な影響を与えることがあります。

システム上のすべてのプロセッサーが確実に一貫性をもってメモリーを認識するために、前述のとおり、共有されないキャッシュの間でデータを移動する際のプロトコルがあります。これらのプロトコルは、「コヒーレンシー・プロトコル」と呼ばれます。あるコア上でキャッシュ・ライン内のデータが少しでも変更された場合、別のコアもそのデータにアクセスするとしたら、アクセス試行の前に、変更が発生したキャッシュ・ライン全体をその別のコアのキャッシュ階層にコピーする必要があります。

複数のコアが同じ 1 つのキャッシュ・ライン内のデータを巡って拮抗する場合、それぞれのメモリーの範囲が重なっていないとしても、パフォーマンス問題が発生する可能性があります。例えば、キャッシュ・ライン境界上に並ぶ 16 個の 64 ビット (8 ビット) の整数からなる配列があるとします。あるコア上で実行されるタスクは 8 番目の整数に頻繁にアクセスしたり、この整数を頻繁に変更したりします。別のコア上で実行されるタスクは、9 番目の整数に頻繁にアクセスするか、またはこの整数を変更します。この 2 つのデータは別々の場所にあるため、表向きは何の矛盾も競合もありません。さらに、キャッシュ・ラインのサイズが 64 バイトのシステム上では、8 番目の整数が最初のキャッシュ・ラインの末尾に位置し、9 番目の整数が 2 番目のキャッシュ・ラインの先頭に位置します。したがって、コアの間でそれぞれのデータが競合することはありません。一方、キャッシュ・ラインのサイズが 128 バイトのシステム上では、8 番目の整数と 9 番目の整数が同じキャッシュ・ライン内に存在することになります。したがって、キャッシュ・コヒーレンシー・プロトコルによって、このメモリーの認識が 2 つのコアの間で一貫するよう徹底しなければなりません。一方のコア上で発生した変更を、もう一方のコアに反映させるためには、もう一方のコアがアクセスを試みる前に、変更が発生したキャッシュ・ラインをそのコアのキャッシュ階層にコピーする必要があります。この処理は、明示的なデータの競合がないとしても行われます。したがって、この処理によってメモリー・アクセスのレイテンシーが大幅に増えることになります。

こうした問題を排除するには、頻繁にアクセスされるデータ、近接して位置するもののコアに固有のデータ、または十分に独立したデータを、個別のキャッシュ・ライン上に分離する必要があります。それには、ミューテックスの配列、コア別カウンター、隣接データを持つミューテックスなどが必要になってくることが考えられます。静的アラインメントを実現するには、属性を使用するのも 1 つの方法です。

struct {
     int count__attribute__ ((aligned(128)));
} counts[N_CPUS];

最新のカーネルと glibc 上には、プログラムによってプロセッサーのキャッシュ・ライン・サイズを判別する手段があります。その一例は以下のとおりです。

unsigned long cache_line_size;
unsigned long cache_geometry = getauxval(AT_L2_CACHEGEOMETRY);
cache_line_size = cache_geometry & 0xFFFF;

さらに単純な (推奨される) 方法は、次のようになります。

long cache_line_size;
cache_line_size = sysconf(_SC_LEVEL2_CACHE_LINESIZE);

キャッシュ・ラインのサイズを判別した後、各プロセッサーのデータを、データ固有の (一連の) キャッシュ・ラインに慎重に割り振ります。以下に、一例を示します。

#define ROUND_UP(a,b) ((((a) + (b) - 1) / (b)) * (b))

// calculate the size of each counter (int) when each is aligned to a cache line
unsigned long stride = ROUND_UP(sizeof(int),cache_line_size);

// get Number of CONFigured PROCESSORS
long cpus = sysconf(_SC_NPROCESSORS_CONF);

// allocate an array of counters, one per CONFigured PROCESSOR
// such that each counter is on its own cacheline
void *counters = calloc(cpus,stride);

long cpu = cpus - 1; // pick a cpu (the last one)

// increment the counter for PROCESSOR #<cpu>
(*(int *)(counters + cpu * stride)) ++;

ページ・サイズ

x86 POWER
ページ・サイズのデフォルト (キロバイト数) 4 64

最近のほとんどのオペレーティング・システムでは、仮想メモリー管理に、「ページ」と呼ばれるセグメントにメモリーを分割する機能が使用されています。具体的に説明すると、プログラムがメモリー内のデータにアクセスするたびに、そのデータのアドレスがメモリーのページにマッピングされます。このマッピングを管理するテーブルは、ページ・テーブルと呼ばれます。マッピング・テーブル内の各エントリーは (当然ながら) ページ・テーブル・エントリー (PTE) と呼ばれます。アドレスをページにマッピングする処理は、「変換」と呼ばれます。当然、この変換の速度は極めて重要です。そのため最新のプロセッサーには、変換ルックアサイド・バッファー (TLB) と呼ばれる変換支援機能やその他のメカニズムが組み込まれています。TLBのサイズには限りがあるため、1 つのプログラムによってアクティブに使用されているページの数を制限することが功を奏します。変換が失敗して TLB 内でページをメモリーのアドレスに解決できなかった場合 (TLB ミス)、ページ・テーブルに直接アクセスする必要が生じます。これによって、処理速度がかなり低下することになります。

ページの数を減らしてページのサイズを大きくすると、メモリーの量は同じであっても必要になるPTE の数と TLB の数が減るため、パフォーマンスに有利に働きます。

x86 システムでは通常、4096 バイト (4 KB) のメモリー・ページが使用されています。POWER プロセッサー・ベースのシステムでは通常、65536 バイト (64 KB) のメモリー・ページが使用されています。さらに、最近の多くのシステム上ではメモリーをプロセッサーのグループ (ノード) 間で分割できるようになっています。あるノード上で実行されているプログラムが別のノード上のメモリーにアクセスする場合、同じノード上のメモリーにアクセスする場合よりも待機時間が長くなります (レイテンシーが増えます)。この影響は、不均等メモリー・アクセス (NUMA) と呼ばれます。最新のカーネルは、特定のメモリー・ページにアクセスする可能性が最も高いノードにそれらのメモリー・ページを移行するか、特定のプロセッサー上のメモリーに頻繁にアクセスするタスクを、そのプロセッサーに移行しようと試みます。これは、自動 NUMA 再バランシングと呼ばれるヒューリスティックな手法です。この手法は、ワークロードの特性によっては役立つこともあれば、害になることもあります。

したがって得策となるのは、複数のコアを同時に利用するプログラムが異なるノードから同じページに対して頻繁にメモリー・アクセスを行わないようにすることです。ページ共有を防ぐ明らかな手段は、個別のプロセスやタスクによって使用されるデータを固有のメモリー・ページ上に分離することです。データをページ上で分離する必要があるプログラムでは、明らかに、メモリー・ページのサイズを把握していなければなりません。メモリー・ページのサイズは固定されていて、多くのプログラムではページ・サイズが (x86 システム上での場合のように) 4 KB であると想定し、この想定に応じてデータを配列します。こうしたプログラムが求める分離は、POWER プロセッサー・ベースのシステム上では実現されません。

オペレーティング・システムによって使用されている (デフォルトの) メモリー・ページ・サイズをプログラムによって判別する手段があります。以下はその一例です。

unsigned long page_size = getauxval(AT_PAGESZ);

Or:

long page_size = sysconf(_SC_PAGE_SIZE);

Allocating page-aligned memory is simple. For example (no error checking is performed):

void *data;
size_t data_size = (size_of_data);
posix_memalign(&data, page_size, data_size);

ベクトル処理: 単一命令多重データ処理 (SIMD)

x86 POWER
テクノロジー MMX, SSE, AVX VMX/Altivec, VSX
C インクルード mmintrin.h (MMX)
xmmintrin.h (SSE)
emmintrin.h (SSE2)
pmmintrin.h (SSE3)
tmmintrin.h (SSSE3)
smmintrin.h (SSE4.1)
nmmintrin.h (SSE4.2)
immintrin.h (AVX, AVX2, ...)
altivec.h
__m64*, __m128*, __m256*, __m512*, ... 符号付き char 型ベクトル (または符号なし)
符号付き short 型ベクトル (または符号なし)
符号付き int 型ベクトル (または符号なし)
符号付き long long 型ベクトル (または符号なし)
float 型ベクトル、duoble 型ベクトル...
組み込み関数 _mm* (_mm_add_ps, …) vec_* (vec_add, ...)

最近の多くのプロセッサーには、データのセット (ベクトル) を同時に処理する機能が備わっています。この機能を利用することでパフォーマンスは大幅に向上しますが、あいにく、低位レベルのプロセッサー命令とそれに対応する C/C++ API (コンパイラーの組み込み関数) はベクトルに対応していません。この問題に対処すべく、POWER と互換性のある x86 ベクトル組み込み関数の実装を追加する新しい手法が登場しています。このリンク先の記事「Porting x86 vector intrinsics code to Linux on Power in a hurry」を参照してください。

同時マルチスレッド化

x86 POWER
コアごとのスレッド数 (最大) 2 POWER7 4
POWER8 8
POWER9 4 または 8
CPU エミュレーション プライマリー優先
{0,c} {1,c+1} …{c-1,2c}
(c = #cores)
コア別
{0,1,...t-1} {t+1,t+2,...}
(t = #threads)

最近のほとんどのプロセッサーでは、大容量のプロセッサー・リソースを最大限に利用するために、各コアで複数のスレッドを実行できるようになっています。つまり、同じコア上で複数のプログラム (あるいは 1 つのプログラム内の複数のスレッド) を同時に実行できるということです。この機能は、同時マルチスレッド化 (SMT) と呼ばれています。この手法の明らかな利点は、上述のとおり、プロセッサー・リソースの使用効率を高められることです。なぜなら、コアのコンポーネントのうち、処理中にアイドル状態となっているコンポーネントの数が少なくなるからです。その一方で、欠点もあります。それは当然のことながら、同時にアクティブなスレッドの間で競合が発生する可能性があることです。また、プロセッサー・リソースがスレッド間で分割されることから、各スレッドで自由に使用できるリソースが少なくなります。結局のところ、正味のパフォーマンス・インパクトはワークロードに依存しますが、概して、スレッドの数が多ければコアあたりのスループットが高くなる一方、スレッドの数が少なければシングルスレッドのパフォーマンスが向上し、レイテンシーが低くなります。

x86 システムでは、コアあたり最大 2 つのスレッドをサポートすることができます。IBM POWER7 ではコアあたり最大 4 つのスレッド、IBM POWER8 ではコアあたり最大 8 つのスレッドをサポートしています。IBM POWER9 については、モデルによってコアあたり最大 4 つ、または最大 8 つのスレッドをサポートしています。

シングルスレッドのワークロードは、SMT が無効化されていると最も有効に動作します。多くの場合、x86 システム上では BIOS 設定を変更することで SMT を無効化できます。POWER プロセッサー・ベースのシステム上では、次のコマンドを実行して、システム内のすべてのコアをシングルスレッド (ST、あるいは SMT=off または SMT=1) モードにすることができます。

# ppc64_cpu –smt=1

SMT を使用したマルチスレッド・アプリケーションのパフォーマンスはワークロードによって大きく異なるため、代表的なワークロードを SMT の設定をいろいろと変えてテストして、最適な構成を判断することをお勧めします。POWER 上では、同様に次のコマンドを使用して SMT モードを変えることができます。

# ppc64_cpu –smt=n

ここで、n の値は 1、2、または 4 に設定できます。POWER9 プロセッサー・ベースのシステムの一部では、この値を 8 に設定することもできます。

複雑なワークロードで、コアごとに異なる SMT モードを適用するほうが望ましい場合は、個々のスレッドを無効化することも可能です。例えば、次のコマンドを使用します。

# echo 0 > /sys/devices/system/cpu/cpu0/online

上記のコマンドは、最初のコア上にある最初のスレッド (CPU) である cpu0 を無効化します (CPU とコアの違いと、CPU エミュレーションについては、以下で説明します)。echo 1 を使用すると、CPU が有効化されます。

したがって、例えば次のような構成が可能です。

  • 最初のコア上にあるすべてのスレッドを有効化する。
  • cores 1 から 4 までを SMT=2 モードにして、レイテンシーとスループットの間でワークロードに適切なバランスをとる (そしてワークロードをこれらの CPU にバインドする)。
  • 残りのコアを SMT=1 モードにして、レイテンシーが低く、パフォーマンスの高いシングルスレッド・マルチコア・ワークロードにする (そしてワークロードをこれらの CPU にバインドする)。

この記事の著者は、平凡な API によって最大の SMT モードを判別したり、コア番号とスレッド番号を CPU 番号にマッピングしたりできることを見落としています。リスト 1 に、この両方をクロスプラットフォームの形で実行するサンプル・コードと、メイン・ルーチン内でのいくつかの使用例を記載します。

コアごとのスレッドを判別して CPU に {コア、スレッド} をマッピングする
#include <stdio.h>
#include <unistd.h>

static int thread_enumeration_contiguous = -1;

int max_smt() {
  static int max_smt_save = 0;
  if (max_smt_save) return max_smt_save;

  FILE *f = fopen("/sys/devices/system/cpu/cpu0/topology/thread_siblings","r");
  if (!f) {
    max_smt_save = 1;
    return 1;
  }

  int c, b = 0, inarow = 0, maxinarow = 0;
  while ((c = fgetc(f)) != EOF) {
    int v = 0, last = 0, bit;
    if (c >= '0' && c <= '9')
      v = c – '0';
    if (c >= 'a' && c <= 'f')
      v = c - 'a' + 10;
    for (bit = 0x1; bit <= 0x8; bit <<= 1) {
      if (v & bit) {
        b++;
        if (last == 1) inarow++;
        else inarow = 1;
        if (inarow > maxinarow) maxinarow = inarow;
        last = 1;
      } else {
        last = 0;
        inarow = 0;
      }
    }
  }

  thread_enumeration_contiguous = (maxinarow > 1) ? 1 : 0;

  max_smt_save = b;
  return b;
}

int core_thread_to_cpu(int core, int thread) {
  int smt = max_smt();
  int cpus = sysconf(_SC_NPROCESSORS_CONF);
  int cores = cpus / smt;
  if (thread >= smt) return -1;
  if (core >= cores) return -1;
  if (thread_enumeration_contiguous)
    return core * smt + thread;
  else
    return core + thread * cores;
}

int main(int argc, const char * const argv[]) {
  int smt = max_smt();
  printf("%d %s\n",smt,thread_enumeration_contiguous ? "contiguous" : "non-contiguous");
  int core, thread;
  for (core = 0; core < 5; core++) {
    for (thread = 0; thread < 10; thread++) {
      printf("core %d thread %d is CPU%d\n",core,thread,core_thread_to_cpu(core,thread));
    }
  }
  return 0;
}

おびただしい数の CPU

最近のシステムが (システムあたりの CEC 数、CEC あたりのソケット数、ソケットあたりのチップ数、チップあたりのコア数、コアあたりのスレッド数について) スケーリングの規模を広げていく中、それに比例してシステムの CPU 数も増えてきています。非常に大規模な POWER8 プロセッサー・ベースのシステムでは、192 個ものコアが使用されることがあります。さらに、コアあたりのスレッド数が 8 つのシステムでは、コア数が 1536 にも上るほどです!複数の CPU にわたってスケーリングしようと試みるアプリケーションが、ここまでのレベルのスケーラビリティーに対応できないことはそれほど珍しくありません。特に、二次的な NUMA の影響によって、所定の CPU に関して一部のメモリー領域のレイテンシーが他のメモリー領域より高くなるといった事態に備えていないのが一般的です。

スケーラビリティーを強化する戦略としては、例えば以下が挙げられます。

  • 階層型または複数レベルのロッキング・スキーム (例えば、コア単位、ノード単位、CEC 単位でロックを適用するなど)
  • ロックレス・アルゴリズム
  • タスクの慎重な配置とバインディング (関連するタスクを同じノード上に配置するなど)
  • メモリーおよび共有メモリー・セグメントの慎重な割り振り (優先 NUMA の配置)
  • 自動 NUMA バランシング (および、その利点と欠点への配慮)
  • 不用意なキャッシュ・ラインの共有とページ共有への配慮 (上述のとおり)

次回の予告

このシリーズの次回の記事が間もなく公開される予定です。お見逃しなく。

リソース


ダウンロード可能なリソース


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Linux
ArticleID=1064517
ArticleTitle=Linux on Power への移植: 妥当な移植結果を最良の移植結果に変えるための 5 つのヒント
publish-date=01242019