Common Lisp 用の libnuma の binding を書きました
Linux で NUMA 環境をユーザプログラムから扱うための C ライブラリである libnuma の Common Lisp 向け binding として、 cl-libnuma というものを書きました。
レポジトリはこちらです : https://github.com/y2q-actionman/cl-libnuma
これを書いた動機や用途などについて書こうと思います。
これは何か?
NUMA とは Non-Unified Memory Access の略で、全メモリが一箇所には存在していないようなシステムです。 高速なネットワークで結ばれた CPU とメモリの組み合わせを ノード とを呼びます。一個のシステムは、この ノード が複数個あつまったものとして構成されます。 全てのCPUは、それぞれ全てのメモリにアクセスできるのですが、自身のノードや近傍のノードにあるメモリの方がより早くアクセスでき、遠くのノードにあるメモリへのアクセスは遅くなってしまいます。 *1
libnuma は、NUMAポリシを C プログラムから使うための API である numa(3) の実装であり、 C の 共有ライブラリ (.so) として提供されています。 これを使うことで、例えば、プログラムを特定のCPUや特定のNUMAノード上で動かす、といったことが出来るようになります。
この libnuma を、 Common Lisp の FFI から呼べるようにしたのが cl-libnuma です。 FFI には CFFI を使用しています。
何が出来るのか?
Lisp 上から、現在の NUMA 環境を取得する。
NUMA ノード数や、 CPU と NUMA ノードとの関連、 NUMA距離などを Lisp から取得できます。
外部ツールを使ったり、 /proc
以下を読みに行くといったことをしなくてもよくなりました。
Lisp 上から NUMA ポリシーを変更する。
Lisp の起動後でも、適切な API コールを行うことで、 NUMA ポリシーを変更できるようになりました。
Lisp の起動時には、 numactl
コマンドを使用して Lisp 処理系を起動することにより、 NUMA ポリシーを指定することが可能です。 今回、libnuma を呼べるようにすることで、 Lisp の起動後も NUMAポリシーを変更可能になりました。
Lisp で SMP をする場合、 各スレッドそれぞれで libnuma を呼ぶことで、それぞれ別の NUMA ノードで動かす、なんてことも可能です。
NUMA ポリシーが指定されたメモリを取得する。
「ある NUMA ノード上に確保されたメモリ」とか、「全ノードで interleave されたメモリ」といったように、NUMAポリシーが指定されたメモリを取得できます。
ただ、得られるのは Lisp ヒープ上のメモリではなく、C言語で扱うような 生のメモリ となります。そのため、確保したメモリの扱いは単純ではありません。
ここで確保したメモリは、生メモリを触るためのインターフェイスで操作する必要があります。 (cffi の cffi:mem-aref
や、 Allegro CL の system:memref
など)
Lisp ヒープ外のメモリに NUMA ポリシーを指定する。
Lisp 処理系によっては、Lisp ヒープ外の生メモリを確保するコール (Allegro CL の excl::malloc
) や、 Lisp ヒープ外のメモリを使って vector を作るような拡張 (Allegro CL の cl:make-array
の :allocation :static
) があります。
それらで得られたメモリのアドレスを取得し、そこに cl-libnuma で NUMA ポリシーを指定することで、透過的にNUMAポリシーを指定したメモリを使用できます。
どんなプログラムで使えるのか?
普通の Lisp オブジェクトは恩恵を受けられるのか?
色々と可能性を挙げましたが、肝心の、普通の Lisp オブジェクト (Lisp ヒープに確保され、 GC の対象となるオブジェクト) は、このコールの対象には出来ません。これらのオブジェクトは GC によって管理されており、生アドレスを得るなどといったことは出来ないためです。
現状、 Lisp ヒープ上にあるオブジェクトに対してできることは、せいぜい、プロセス全体の NUMA ポリシーを変更する程度のことだけです。
とはいえ、プロセスを分けてしまえば、メモリ空間、Lispヒープ、そしてNUMAポリシを分けることが出来ます。なので、 fork を通じて擬似的に NUMA ポリシ毎に Lispヒープを確保することは可能ではあります。
例えば:
- Lisp 処理系を起動する。
- fork() して、 NUMA ノードの数だけプロセスを作る。
numa_set_preffered()
などを呼んで、各プロセスをそれぞれ別ノードに割りあてる。
などすれば、 NUMA を活かした並列計算が可能かもしれません。
NUMA でない環境では価値があるのか?
残念ながら、ほとんど意味はないでしょう。意味があるとすれば:
- システムのメモリ量やCPU数を取るコールで情報収集。
- プロセスやスレッドに CPU affinity を指定できるので、 CPU 間での プロセスの migration を抑えたいとかいう用途があれば使えるかも。
ぐらいでしょうか。
なぜ書いたのか?
Common Lisp で書かれた、 配列をいじくり回して何らかの数値を計算するようなプログラムがありました。そいつは並列化されていて、小規模なマシンではスレッドを立てれば立てた分だけ性能が上がっており、十分にスケールしているように見えていました。
色々あって、CPUソケットが複数あって、CPU数もメモリ量もたくさんあるようなマシンで走らせてみることになりました。「これは爆速だろうな」とか思っていたのですが、やってみたら、あるスレッド数から妙に性能向上が衰えたり、並列化する前より遅くなったりしたのです。
いろいろと測定しましたが直接的な原因は分かりませんでした。しかし、 Lispの起動時に numactl
コマンドで NUMA ポリシを指定すると、結果がかなり改善することが分かりました。
というわけで、NUMA環境が性能に影響を及ぼしそうだと考え、 numactl
コマンドで Lisp 処理系を起動し直しては測定をする、とやっていました。しかし、この方法はどうにも鈍重なので、 NUMA 関連の C API を Lisp から叩けないかと考え、 cl-libnuma を書き始めました。
しかし、そのプログラムのチューニングはスケジュール上で時間切れになってしまい、とりあえず測定して分かった「なんだかよく分からないけど、一番早い感じの設定」を採用して終わってしまいました。速度低下を引き起こす真の原因は、分からず終いでした。
そんなわけで cl-libnuma は活躍する機会がなかったんですが、まあ世に出してみるか、ということでここに至ります。
派生記事の予定
今回、CFFI binding を書いてみて分かった知見を、今後書く予定です:
- cffi binding の書き方のスタイル
cffi-grovel:wrapper-file
という謎の機能について- wrapper-file に新しい文法を追加する。
*1:この段落は http://oss.sgi.com/projects/libnuma/ の第一段落の適当翻訳です。
*2:余談: 本質的には、処理系のフタを開けて、NUMAの知識を前提としたGCがあればいいという話になるので・・ それを願って止みません。 Allegro CL 10 では、並列GCが入るそうです( http://franz.com/ps/newsletter-edt.lhtml#article1 )が、そういうことはやってくれるのでしょうか?