numa_balancing の挙動 パート1

Red Hat コンサルタントの菅原です。先週に引き続いての投稿になりますが、今回は NUMA アーキテクチャにおけるメモリチューニングに関連した話題です。

この記事を書くきっかけとなったできごとは、あるお客様の2ソケットサーバーで片方の NUMA ノードのメモリはほぼフリーであったのにもう片方の NUMA ノードのメモリが一杯で OOM Kill を引き起こしたことです。その事象の直接的な原因としては、大量にメモリを使用していたプロセスの NUMA アフィニティが手動で固定されていて、もう一方の NUMA ノードのメモリがいくら空いていてもそちらからアロケートできないようにそもそも設定されていたのが原因だったのですが、アフィニティが設定されていない他のプロセスを空いている NUMA ノードに移動させればまだ空きが作れたのでは、NUMA ノードごとのメモリ利用率が極端に異なっているのに両者をバランスさせるような挙動が起きないのはなぜか、といったお客様の疑問を解消するために調べ始め、自分でも湧いてきた疑問を解消するためにさらに調べたことを、記録としてブログに残すことにしました。

書き始めたときは1本の記事で完結するつもりだったのですが、書いているうちにひどく長くなってしまったので、これは2パート構成のパート1とします。

NUMA アーキテクチャとは

本題に入る前に、NUMA アーキテクチャについて軽くおさらいをしておきましょう。

NUMA は Non-uniform Memory Access の略で、「共有メモリ型マルチプロセッサコンピュータシステムのアーキテクチャのひとつで、複数プロセッサが共有するメインメモリへのアクセスコストが、メモリ領域とプロセッサに依存して均一でないアーキテクチャ」です [1]。1990 年代末期に実用化された初期の NUMA システムは、4 プロセッサ程度の SMP サーバー筐体が単一 NUMA ノードを構成し、複数の筐体を専用インターコネクトで相互接続したような形態となっていましたが、2000 年代の AMD Opteron を皮切りに、それ以降の CPU ではチップレベルで NUMA が組み込まれた製品が多数開発・販売されており、現在では複数ソケットを備えるコンピュータはほぼ 100% が NUMA アーキテクチャと言えるまでに普及しています。CPU 自体が NUMA 対応の製品では、単一の CPU チップとそれに直結したメモリとで、単一の NUMA ノードを構成しています。

相互接続された複数の NUMA ノード上のメモリは全体としてひとつ物理アドレス空間上に配置され、どの CPU からどのメモリに対してもアクセスが可能です。そのため、ソフトウェア的には単なる SMP システムのように扱うことも可能ですが、性能的には「ローカルメモリアクセス性能」>「リモートメモリアクセス性能」となっています。ここでいう「ローカル」はある CPU にとって同じ NUMA ノード内のメモリ、「リモート」はある CPU にとっては別の NUMA ノード内のメモリを指します。CPU とメモリの組み合わせによってメモリアクセス性能が不均一 (Non-uniform) な特性から、NUMA と名付けられました。

CPU とメモリの位置関係によってアクセスコストが変化するので、リモートアクセスが多くなるとアプリケーションの実行性能が低下してしまいます。しかし幸いなことに、多くのソフトウェアには「参照の局所性」という、ざっくりとは大半のプログラムは大方決まったメモリばかりを頻繁に参照しているという特性があるので、実行している CPU とそれが頻繁にアクセスするメモリを同一 NUMA ノードにうまいこと配置してあげることによって、大部分のメモリアクセスをローカルアクセスとして高速に行えるので性能低下を最小限に抑えつつ多数のプログラムを並列実行できる、というのが NUMA アーキテクチャの狙いとなります。

ここで重要なのが、「実行している CPU とそれが頻繁にアクセスするメモリを同一 NUMA ノードにうまいこと配置してあげる」という部分です。常に最適な配置を保とうとするとメモリの移動が増えてそちらのコストが増大してしまうことになったり、あまりカリカリにチューニングして最適値を求めたとしても前提が少し変化しただけで最適ではなくなってしまいますので、あまり最適を追い求めるのも意味がないのですが、全く無頓着でも思った結果が出ないことがあるでしょう。チューニング可能なポイントを知っておくことにより、完全に自動でシステムに自律的に調整させる、と、完全に手動ですべてのアフィニティを明示的に設定する、の間の丁度良いポイントを探してみると良いと思います。

Linux での NUMA 構成の確認方法

実際の Linux システムで NUMA 構成がどのように認識されているかを確認するには、numactl -H コマンドを使います。以下は実行例です。

# numactl -H
available: 2 nodes (0-1)
node 0 cpus: 0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30
node 0 size: 63765 MB
node 0 free: 422 MB
node 1 cpus: 1 3 5 7 9 11 13 15 17 19 21 23 25 27 29 31
node 1 size: 64507 MB
node 1 free: 25923 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10

この例の出力を見ると、NUMA ノード 0 と 1 の 2 ノード構成で、それぞれのノードに論理 16 コア (実は HT 有効なので物理 8 コア) と 64GB のメモリ、システム全体では論理 32 コア (物理 16 コア) と物理 128GB メモリが搭載されたハードウェア構成であることがわかります。

最後の node distances は各 NUMA ノード間の「相対距離」を表現したマトリックスで、ローカルメモリアクセスコストを 10 とした相対値でリモートメモリアクセスコストが表現されています。ただし、このアクセスコストはファームウェアから得られる情報であって、アクセス遅延実測値を正しく反映しているとは限りません。

Linux カーネルの NUMA チューニングポイント

sysctl パラメータ kernel.numa_balancing

RHEL 7 の製品ドキュメント [2] に記載されていたパラメータです。RHEL 8/9 のドキュメントには記載されていないのですが、パラメータとしては存在します。

[2] のドキュメントには、「自動 NUMA バランシングは、スレッドまたはプロセスである可能性のあるタスクを、アクセスしているメモリーの近くに移動します。また、アプリケーションデータを、それを参照するタスクに近いメモリーに移動します。」と記載されていますが、[3] を読むと "When this feature is enabled the kernel samples what task thread is accessing memory by periodically unmapping pages and later trapping a page fault. At the time of the page fault, it is determined if the data being accessed should be migrated to a local memory node." と記されており、プロセスのメモリが複数 NUMA ノードにまたがっているときにこの機能を有効化すると片 NUMA ノードに寄せられること(が多いの)が、実験でも確認済みです(実験については後ほど詳しく記します)。また、[3] の記載と合致して、有効化するだけで恒常的にページフォルトが発生することも確認済みです。なお、デフォルト設定値は 1=enabled です。

関連して以下の4つのパラメータでプロセスメモリをスキャンするレートとサイズを制御できます。頻度を上げる、またはサイズを大きくする、どちらでもリモートからローカルへのメモリ移動のスピードを上げられますが、オーバーヘッドも増大します。

numa_balancing_scan_period_min_ms numa_balancing_scan_delay_ms numa_balancing_scan_period_max_ms numa_balancing_scan_size_mb

自動 NUMA バランシングの有効化によりオーバーヘッドが発生しますので、手動で十分にチューニングされた環境では無効化した方が良好な性能が得られる可能性もあります。

sysctl パラメータ vm.zone_reclaim_mode

ゾーン内でのメモリ回収の積極性を制御するパラメータです。ゾーンという概念は NUMA と直接関連するものではありませんが、メモリ階層においてゾーンは NUMA ノードより下の階層、つまり、単一 NUMA ノードは複数ゾーンに分割されますが、単一ゾーンが複数 NUMA ノードにまたがることはありませんので、ゾーン内でのメモリ回収を積極的に行うということは、NUMA ノード内でも積極的にメモリ回収することを意味します。設定値と動作モードの関連については、[4][5] を参照ください ([5] の日本語版では RHEL 5 と 6 だけが対象として記載されていますが、RHEL 7 以降にも存在するパラメータで、意味も変更ありません)。なお、今回の実験ではこのパラメータの影響は見ておりません。

numactl

先ほどのハードウェア構成の確認にも使用したコマンドですが、プロセス起動時の CPU アフィニティやメモリポリシーを設定する機能があり、手動で CPU とメモリの配置を決めたい場合に有効です。今回の実験では numactl を使用することで、どういうポリシーでどの NUMA ノードからメモリをアロケートするかの指定を行っています。

numad

各プロセスのメモリ利用状況などをモニタリングし自律的に最適化するコマンドもしくはシステムサービスで、numa_balancing よりもかなり積極的に NUMA ノード間のメモリ利用率のバランシングを行いますが、その一方で手動で設定したアフィニティを上書きしてノード間のプロセス移動を行う挙動も確認されており、手動チューニングとの併用には注意が必要です。デフォルトの systemd ユニットファイルの設定では tuned との併用などは考慮されている様子がありません。

実験の動機

kernel.numa_balancing の項にも少し書きましたが、このパラメータがその名前の通りに NUMA ノード間の資源利用をバランスする効果があるのか、あるとしてどの程度なのか、を調べるために実験を開始しました。のですが、今回の実験では当初の動機を超えるような範囲の実験も行っています。

実験環境

実験環境は最新の RHEL 8.7 (kernel-4.18.0-425.19.2.el8_7.x86_64) を稼働中の DELL PowerEdge R640 で、NUMA の構成は前述の numactl -H 出力の通り 2 ノード、各ノードに 16 論理コアと 64GB メモリを搭載した環境となります。

実験手法

実験には手軽にメモリ負荷をかけることができる stress ユーティリティを利用しました。このツールは RHEL には同梱されていませんが EPEL [6] からインストール可能です。RHEL レポジトリからインストールできる stress-ng もありますが、やりたいのは容量を指定してローカル/リモートメモリをアロケートすること、アロケートしたメモリを定期的にアクセスさせること、ぐらいでしたので、より手軽に実行できる stress を選択しました。

なお、CPU アフィニティはプロセス起動後に taskset コマンドにプロセス ID を渡して再設定することもできますが、メモリポリシーの設定を行う set_mempolicy(2) システムコールは呼び出したスレッドのメモリポリシーを設定するインターフェイスとなっていますので、外から他のプロセスのメモリポリシーを操作することはできません。というわけで、numactl コマンド経由で stress コマンドを起動することで、メモリが割り当てられる NUMA ノードを指定するようにしています。

stress コマンドの起動時引数は以下のようにしています。

numactl --cpunodebind=<node> <mempolicy_opt> --cpustress -t <timeout> -m <nr_children> --vm-bytes <alloc_size> --vm-keep

ここで、各引数の意味は、node は実行 CPU コアを選択する NUMA ノード、mempolicy_opt はメモリポリシーを設定するオプション、timeout はテスト実行時間、nr_children は起動する子プロセスの数、alloc_size は子プロセスごとにアロケートするメモリ容量を、それぞれ指定します。最後の --vm-keep オプションは、アロケートしたメモリを解放せずに書き換え続けるという意味となります。

メモリポリシーを設定するオプションには以下があります (numactl(8) から抜粋)。

       --interleave=nodes, -i nodes
              指定された各ノードからラウンドロビンでまんべんなくメモリをアロケートします。--interleave と --membind には複数ノードが指定可能です。

       --membind=nodes, -m nodes
              指定されたノードからのみメモリをアロケートします。指定ノードのメモリが不足しているとアロケートは失敗します。

       --localalloc, -l
              プロセスが現在実行しているノードのメモリを優先してアロケートします。不足した場合は他のノードの空きメモリを使用します。デフォルトポリシーです。

       --preferred=node
              指定したノードのメモリを優先してアロケートしますが、不足している場合は他のノードの空きメモリを使用します。このオプションで指定できるノードはひとつだけです。

実験シナリオ

実験はいくつかの異なるシナリオに沿って実施しました。当初の動機としては kernel.numa_balancing の効果を見ることでしたので、基本的にはどのシナリオでも

  1. numa_balancing を無効化 (0 を書き込み)
  2. stress を起動
  3. 各プロセスの CPU および NUMA ノードごとのメモリアロケート状況を確認
  4. stress プロセスの CPU アフィニティを taskset コマンドで変更して、全ノードの CPU を利用可とする
  5. しばらくの間定期的に各プロセスの CPU および NUMA ノードごとのメモリアロケート状況を確認して経過を観察
  6. kernel.numa_balancing を有効化 (1 を書き込み)
  7. しばらくの間定期的に各プロセスの NUMA ノードごとのメモリアロケート状況の変化を観察

という流れになります。この共通部分に対して各シナリオで異なっているのは、ステップ 2 のプロセス数とメモリ容量をシナリオに合わせて調整している点が主となります。

なお、上記各ステップで使用しているコマンドは以下の通りです。

ステップ 1、6 (ステップ 6 で書き込む値は 1)

   sudo sysctl -w kernel.numa_balancing=0

ステップ 2

   numactl --cpunodebind=1 --localalloc --cpustress -t 7200s -m <nr_children> --vm-bytes <alloc_size> --vm-keep

ステップ 3、5、7

   ps --no-headers -C stress -o pid,ppid,psr,rss,args | sort -n
   numastat -p stress

ステップ 4

   taskset -apc 0-31 $stress_pid

シナリオ 1

このシナリオでは、ステップ 2 で --cpunodebind に片ノードだけ、メモリポリシーは --localalloc を指定して片ノードのメモリを優先的にアロケートするように指定しました。ただし、合計アロケートメモリ容量が片ノードの搭載メモリを超えるようにして、必ずリモートメモリが使用されるようにしています。また、ステップ 4 の後、プロセスが当初指定したのと逆ノードに移行するかどうかも観察のポイントです。numa_balancing を有効にしてからは、ノード移行とリモートからローカルへのメモリ移行がどのように行われるかを観察しました。

実験前の仮説として以下のような想定がありました。

  • メモリ移動はコストが高いので CPU 移行が主に使用され、プロセス内でメモリがより多くアロケートされているノードの CPU が割り当てられるのでは
  • 現在割り当てられている CPU のローカルアクセスが多くなるように、メモリ移行はアロケート量が少ないノードから多いノードへと起こるのでは

つまり、numa_balancing から想起される両ノードのメモリ利用率をバランスする動きではなく、名前とは裏腹に片ノードへメモリを片寄せするような動きが支配的になるのではないか、という予想です。

シナリオ 2

このシナリオでは、基本的にシナリオ 1 と同じ手順ですが、ステップ 1.5 としてあらかじめ stress を起動して片ノードのメモリの半分程度をある程度アロケートしてしまう追加をしています。ステップ 2 の段階で意図的にメモリ不足を発生させて、リモートメモリが多くアロケートされる stress プロセスを作れるのでは、そしてそのようなプロセスに対して numa_balancing がどのような挙動を見せるのかという点に興味があって考えたシナリオです。

1.5. stress #1 を起動
   numactl --cpunodebind=1 --localalloc --cpustress -t 7200s -m 6 --vm-bytes 4G --vm-keep
2. stress #2 を起動
   numactl --cpunodebind=1 --localalloc --cpustress -t 7200s -m 16 --vm-bytes 6G --vm-keep

シナリオ 3

このシナリオでは、シナリオ 2 の手順に加えて、ステップ 6.5 としてステップ 1.5 で起動した stress を先に終了させて故意にメモリに空きを作る手順を追加しました (実際には 1.5 の timeout を短かく指定することで早く終了させています)。大半のメモリをアロケートする片ノードにそれなりの空きができて、メモリ移動が比較的自由にできる状況になったときの numa_balancing の挙動を見たいと考えたものです。

シナリオ 4

最後のシナリオは、ここまでの実験をするうちに新たに考えついた仮説を実証するためのシナリオとなります。どういう仮説かというと、numa_balancing は現在実行中プロセスに対してリモートアクセスを削減してできるだけローカルアクセスさせようとメモリ移動するだけで、システム全体として NUMA ノード間のメモリ利用のバランスをとろうとしているわけではないのでは、というものです。片ノードの CPU 100% に近い状態を stress で作っておいてから、もう一方のノードのメモリを大量に割り当てて numa_balancing がどのような挙動を示すか見るものです。

と、だいぶ長くなってしまいました、というよりは、実はこの後の実験結果と考察が恐しく長くなりそうなので、実験結果と考察については次の記事に記したいと思います。次回までもう少しお待ちください。余力があれば numa_balancing だけではなく numad の挙動もお見せしたいと考えています(検証自体は実施済みです)。

参考文献

* 各記事は著者の見解によるものでありその所属組織を代表する公式なものではありません。その内容については非公式見解を含みます。