プロセスに対するCPUコアの割当

はじめに

今回は細かい話で、以下の違いについての理解が必要です。

  • プロセス
  • スレッド
  • CPUコア

シミュレーションに代表される一般的なHPCアプリケーションでは、スレッドはOpenMPで並列化し、プロセスはMPIを使って分散並列化しています。
OpenMPによるスレッド並列化はソースコードで直接スレッド数を指定しない あるいは OMP_NUM_THREADS等の環境変数でスレッド数を指定しない場合、
プロセスで利用できるCPUコアを自動的に認識しスレッド数を決定していますが、MPIは処理系 (Open MPI, MPICH, Intel MPIなど) により各MPIプロセスにCPUコアの割当ルールを設定できます。これをCPU Affinity, Core Affinity, Bindingなどと呼びます。

今回はプロセスに対して計算機がもつCPUコアをどうやって割り当てるのか、またどうなるのかについて考えたいと思います。

CPU Affinityの必要性

CPU Affinityは、プロセス並列化をする上で性能に大きく影響することがあります。
例えば32 CPUコアを持つ1台の計算機上で4プロセスを立ち上げ、それぞれOpenMPでスレッド並列化計算をしたとします。

CPU Affinityを設定していない場合、各プロセスで実行されたOpenMPは32コア = スレッドでの並列化を試みるため、CPU負荷が過密なワークロードを実行すると各プロセスがCPUリソースを食い合う、リソース競合が発生します。
CPUリソース競合が起きると、各プロセスが他プロセスの処理に割り込むため処理性能が大幅に低下し、全プロセスのスループットが悪化します。
このような問題を解決するために、競合が発生しないように各プロセスが使うCPUコアを制御するのがCPU Affinityの1つの目的です。

Affinityの設定方法

CPU Affinityは色々な方法で設定できますが、HPCアプリケーションを対象にすると以下の2つが中心になりそうです。

  1. MPI処理系の機能を使う
  2. numactlで制御する

MPI処理系を使った設定

MPIは処理系によって設定方法が大きく変わりますので、お使いの処理系のドキュメントを読むことを推奨します。
ここでは、NVIDIA HPC SDKが提供しているOpen MPIでの方法を紹介いたします。

mpirunコマンドには--bind-to--map-byなどのCPUやプロセスの割当に対するオプションが非常に多く用意されています。
またどのようにCPUコアを割り当てたかを表示する--report-bindingsというオプションもあります。

例えば32 CPUコアを持つ計算機上で4プロセス、8コアずつ割り当てた場合のログが以下です。(表示がややこしいため省略しています)
[B/B/B/B/B/./././.]のように書かれているのがどのCPUコアを該当プロセス (MCW Rank) に割り当てたかの表示で、Bが割り当てたCPUコアを示しています。

$ mpirun -V mpirun (Open MPI) 4.1.5rc2 $ mpirun -n 4 --cpus-per-rank 8 --report-bindings hostname ... [localhost:0] MCW rank 0 bound to socket 0: [B/B/B/B/B/B/B/B/./././././././.][./././././././././././././././.] [localhost:0] MCW rank 1 bound to socket 1: [./././././././././././././././.][B/B/B/B/B/B/B/B/./././././././.] [localhost:0] MCW rank 2 bound to socket 0: [././././././././B/B/B/B/B/B/B/B][./././././././././././././././.] [localhost:0] MCW rank 3 bound to socket 1: [./././././././././././././././.][././././././././B/B/B/B/B/B/B/B] ... $ mpirun -n 4 --cpus-per-rank 8 --rank-by hwthread --report-bindings hostname ... [localhost:1] MCW rank 0 bound to socket 0: [B/B/B/B/B/B/B/B/./././././././.][./././././././././././././././.] [localhost:1] MCW rank 1 bound to socket 0: [././././././././B/B/B/B/B/B/B/B][./././././././././././././././.] [localhost:1] MCW rank 2 bound to socket 1: [./././././././././././././././.][B/B/B/B/B/B/B/B/./././././././.] [localhost:1] MCW rank 3 bound to socket 1: [./././././././././././././././.][././././././././B/B/B/B/B/B/B/B] ...

サーバーグレードのような複数のCPU socketがある計算機の場合、MPIプロセスはround-robin的にsocket0, socket1, socket0, socket1と割り当てるようです。
--rank-by hwthreadとしてこの挙動から、socketで詰めて割り当てた結果が2つ目のmpirunコマンドです。

numactlを使った設定

MPIを使わない場合でも使えるポータブルな手段として、numactl があります。
numactlは実行コマンドの前に書くことで、後続のコマンドのCPU Affinityを制御できます。類似コマンドにtasksetがありますが、numactlはCPUコアの数やいくつのCPU socketで構成されているかなどを見ることができます。またCPUコアだけでなく、どのCPU socketに接続されたメモリを使うかも制御可能です。

$ numactl -H available: 2 nodes (0-1) node 0 cpus: 0 1 2 3 4 5 6 7 node 0 size: 64000 MB node 0 free: 16000 MB node 1 cpus: 8 9 10 11 12 13 14 15 node 1 size: 64000 MB node 1 free: 32000 MB node distances: node 0 1 0: 10 20 1: 20 10 $ numactl --cpunodebind=0 --localalloc -s policy: default preferred node: current physcpubind: 0 1 2 3 4 5 6 7 cpubind: 0 nodebind: 0 membind: 0

CPU Affinityの効果

CPUを過度に使用する計算密な行列積を使って、CPU Affinityの効果を測定します。
実際のアプリケーションは計算密ではなく、メモリ速度に律速される場合が多いため、今回の評価は影響をわかりやすくするためのものになります。
メモリ律速になるとメモリアクセス待ちでCPUはアイドル状態になることが増え、計算密なコードに比べて性能への影響は少ないことがあります。

測定に使用したコードは、do concurrentを使ったGPU並列化 のsgemvのコードを使っています。

上記をMPIで2プロセス立ち上げて、8 CPUコアの環境でわざと競合状態を起こすとどうなるかを評価しました。overcommit状態を--map-byなどで書くのが難しかったので、rankfileを使って各rankに割り当てるCPUコアを明示しています。
実行時間は目安ですが、使用するCPUコアが完全に同一の場合、実行時間が2倍に増加しており、かなりの影響があると言えます。

$ cat rank-default rank 0=localhost slot=0-3 rank 1=localhost slot=4-7 $ mpirun --report-bindings --rankfile rank-default ./a.out [localhost:0] MCW rank 0 bound to socket 0: [B/B/B/B/./././.] [localhost:1] MCW rank 1 bound to socket 0: [././././B/B/B/B] average: 131.0832000000000 [ms] average: 131.9204000000000 [ms]
$ cat rank-overcommit0 rank 0=localhost slot=4-7 rank 1=localhost slot=4-7 $ mpirun --report-bindings --rankfile rank-overcommit0 ./a.out [localhost:0] MCW rank 0 bound to socket 0: [B/B/B/B/./././.] [localhost:1] MCW rank 1 bound to socket 0: [././././B/B/B/B] average: 287.6200000000000 [ms] average: 287.2660000000000 [ms]
$ cat rank-overcommit1 rank 0=localhost slot=2-5 rank 1=localhost slot=4-7 $ mpirun --report-bindings --rankfile rank-overcommit1 ./a.out [localhost:0] MCW rank 0 bound to socket 0: [././B/B/B/B/./.] [localhost:1] MCW rank 1 bound to socket 0: [././././B/B/B/B] average: 195.5832000000000 [ms] average: 233.9639000000000 [ms]

Note: OSの処理との競合回避

アプリケーションだけではなく、LinuxやWindowsなどのOSの処理についてもCPUコアが利用されることを頭に入れておく必要があります。
多くの場合に影響はありませんが、CPUコアの性能が低い場合、OSの割り込み処理がアプリケーションと競合してアプリケーションの性能を低下させることがあります。

そこで、CPU Affinityでわざとアプリケーションに割り当てないCPUコアを準備することで、暇なCPUコアを作りOSの割り込み処理を行わせ動作安定を狙うことがあります。
OS側にはAffinityを設定していないので完全には競合を防げませんが、ただOSは多くの場合は低負荷なCPUコアに処理をスケジューリングすることが期待されます。

まとめ

CPUで計算をする場合のCPU Affinityの性能への影響について紹介しました。

特に計算密なワークロードの場合、Affinityの適切な設定が性能に大きく影響します。
GPUアプリケーションでは、CPUはGPUの制御やGPUでは困難な処理を担うのがメインとなり、常に100%の負荷状態にはなりにくくAffinityの効果は今回示したデータよりも小さくなると考えられますが、他プロセスによる割り込みを避けるのはGPUアプリケーションにおいても有効と期待されます。

また、現在のGPU搭載計算機は複数台のGPUと複数台のCPUで構成されています。
そのためプロセスで使用するGPUが接続されている方のCPUに対しAffinityを設定することで、CPUメモリとGPUメモリの物理的距離を最小限にしてCPU-GPU間のやりとりのオーバーヘッドを最小化できます。
細かいですがそれなりのインパクトが期待できるので、お試しいただければと思います。