SML#のDockerイメージが全種揃った
eldesh/smlsharp で公開しているSML#のdockerイメージがあります。
最近必要になって バージョン1 系列(1.0.0
, 1.0.1
, 1.0.2
, 1.0.3
, 1.1.0
)を追加しました。
今月初め頃 0.90
をビルドしてあったため、
これで現時点でのリリースを全種揃えたことになりました。
SML/NJのdockerイメージをマルチアーキテクチャ対応した
私が個人的に管理しているsml/njのdockerイメージを linux/amd64 と linux/386 のマルチアーキテクチャ対応しました。
以下のページでタグを確認すると、linux/amd64 と linux/386 という種類が表示されると思います。
使用方法
使用するにはそれぞれのアーキテクチャのLinuxホストから任意のイメージをpullするだけ、つまり普通に使うだけです。
amd64版の方は全て 32/64bit モード両方でビルドしてありますので、sml -32(or -64)
とすることで32bitモードでも使うことができますが、これは非ネイティブ動作(らしい)なので注意です*1。
仕組み
docker registry は、各イメージ名に manifest というメタデータを関連付けており、ここに対応アーキテクチャ(やプラットフォーム)も含まれています。
ですので docker cli から適切なアーキテクチャを指定すればそのレイヤーがダウンロード出来るというワケです。
以下のように確認出来ます。(manifest は実験的な機能なので明示的に有効にしてやる必要があります。)
$ DOCKER_CLI_EXPERIMENTAL=enabled docker manifest inspect eldesh/smlnj:latest { "schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json", "manifests": [ { "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "size": 1157, "digest": "sha256:86983a83d1ef042bd3dbc1e013dad7845a566dc2afc3e52c17582f993a626af7", "platform": { "architecture": "386", "os": "linux" } }, { "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "size": 1158, "digest": "sha256:6d93f94070bf7877595f143f7f2ae7b3488e3740b2da35db17362a614f310a05", "platform": { "architecture": "amd64", "os": "linux" } } ] }
*1:これに注意すべき人存在する気がしないけど
SMLUnitをPoly/MLに対応した
SMLUnitというSML用ユニットテストライブラリをPoly/ML5.8から使えるようにしました。
makeと上手く連携するMakefileが書けたので満足です。
ブランチはここ> https://github.com/eldesh/SMLUnit/tree/support/polyml *1
使い方はReadmeに書いたけど、Poly/MLの機能の解説もしつつここにもまとめておこう。
使い方
まずREPLでの使い方。
Poly/MLは PolyML.make: string -> unit
という関数にディレクトリ名を与えると、そのディレクトリ以下の ml_bind.ML
みたいな名前のファイルを探してきて読み込みます。
さらに ml_bind.ML
の中で structure, signature, functor
に言及すると、それらと同名のファイルを探してきて読み込みます。
ちゃんとmake
なので(?)2回目以降は前回読んだ時から更新されたファイルだけ読み込んでくれるので便利です。
SMLUnit$ poly --eval 'PolyML.suffixes := ".sig"::(!PolyML.suffixes)' > PolyML.make "src/main"; Making main Making SMLUnit .. structure SMLUnit: SMLUNIT val it = (): unit
--eval
には PolyML.make
が探すファイル名の拡張子を追加するコードを渡しています。
こうしておくと未知のシグネチャ名に言及したときに .sig
ファイルも探してくれます。
ビルド
SMLUnitはユニットテストライブラリなので当然他のライブラリからも参照したいですね。
でも実はPolyML.make
は、その時点でのカレントディレクトリからの相対パスでファイルを探すので*2、無関係なディレクトリから参照しづらいという問題があります。
そこでコンパイルした結果をモジュールエクスポートという機能を使って書き出しておきます。
SMLUnit$ make -f Makefile.polyml echo "" | poly -q --error-exit --eval 'PolyML.suffixes := ".sig"::(!PolyML.suffixes)' \ --eval 'PolyML.make "src/main"' \ --use export.sml \ --eval 'PolyML.SaveState.saveModule ("libsmlunit.poly", SMLUnit)' Making main Making SMLUnit .. Created structure SMLUnit
デフォルトターゲットをmake
すると、PolyML.SaveState.saveModule
を使ってSMLUnitの公開するモジュールが ./libsmlunit.poly
に書き出されます。
このファイルは普通にREPLからも読めますのでライブラリのデバッグなどに便利です*3。
> PolyML.loadModule "./libsmlunit.poly"; signature ASSERT = sig .. signature TESTRUNNER = sig type parameter val runTest: parameter -> Test.test -> unit end val it = (): unit
(他ライブラリからの)使い方
Poly/MLでプログラムの最終結果(実行ファイル)を得るには、まずPolyML.export: string * (unit -> 'a) -> unit
という関数に適切なオブジェクトファイル名とエントリーポイントになる関数(要はmain関数)を渡します。
その際あらかじめ依存しているライブラリ(libsmlunit.poly)をロードしておきます:
> PolyML.loadModule "/path/to/libsmlunit.poly"; (* 依存している SMLUnit を読み込む *) > PolyML.make "hoge/src"; (* 利用側のコードを読み込む *) > PolyML.export ("libhoge.o", Main.main); (* main: unit -> unit をエクスポート *)
そしてエクスポートしたオブジェクトファイルをpolyc
コマンドでリンクして完了です。
$ polyc -o hoge libhoge.o
./hoge
..
SML# 版 AObench をMassiveThreadsで高速化
前回*1はちょっとケチが付いてしまいましたが、今回めげずに AObenchSML の SML# 版に MassiveThreads による並列化版 を追加しました。
シングルスレッド版と並列化版を両方実行します。
並列forを実装して 並列ML: MaPLe で AObench を高速化 で作ったものと全く同じコードで動くようにしました。
パフォーマンス
私の環境で一番良かった並列化パラメータでの結果を載せます。
環境: Linux(amd64) VMWare 4core 8GB MEM
real | user | sys | |
---|---|---|---|
sml#(シングルスレッド) | 0m13.872s | 0m9.969s | 0m1.442s |
sml#(4プロセス並列化版) | 0m5.739s | 0m19.625s | 0m1.978s |
2.4倍に速くなりました。ちゃんと並列化できてるようですね。
並列化の粒度調整
MaPLeと同じくSML#の場合もプロセス数と粒度をそれぞれ指定する必要がありました。
よさそうな組み合わせを探したときのログを簡単に可視化したものを以下に示します*2。
x軸が MYTH_NUM_WORKERS
で y軸が粒度(1スレッド内で何行計算するか)、カラーバーは実行時間(秒)です。
並列度が高くても粒度が大きすぎるとパフォーマンスが劣化していることが分かりますね。今回のケースではコアと同数のプロセス、2行(512ピクセル)分の粒度がもっとも良かったようです。
終わり
- 今回のケースでは(エンジニア的センスで)適当に指定してもシングルスレッド版より遅くなることはあまり無さそう
- とりあえず粒度を小さくしておいても問題無さそうなので手軽に使えそう
- MaPLe版である程度調整済みだったので無駄な試行が少なかったのもある
- 実験中パフォーマンスのばらつきが結構あったのはparfor内でjoinしているのが遅いのかも知れない
*1:SML#のプログラムにMassiveThreadsのオプションを付けるとパフォーマンスが低下する
*2:各頂点間の平均を取ってる色らしいので本当はあんまり良くない
SML#のプログラムにMassiveThreadsのオプションを付けるとパフォーマンスが低下する
SML# には最初からユーザレベルの細粒度スレッドライブラリ MassiveThreads のラッパーが提供されています。
Myth
ストラクチャを使ってプログラムを書き、環境変数 MYTH_NUM_WORKERS
で仕様コア数を指定するようです。
> 11.2 MassiveThreadsを用いた細粒度スレッドプログラミング‣ Chapter 11 SML#の拡張機能:マルチスレッドプログラミング ‣ Part II チュートリアル ‣ プログラミング言語SML#解説
この環境変数を通常のシングルスレッド向けの AObenchSML で設定みたところ、パフォーマンスが 60% ほど低下しました。
$ time ./aobench-smlsharp aobench-smlsharp.ppm
real | 0m11.233s |
---|---|
user | 0m9.969s |
sys | 0m1.417s |
$ time MYTH_NUM_WORKERS=0 ./aobench-smlsharp aobench-smlsharp.ppm
real | 0m18.210s |
---|---|
user | 1m10.984s |
sys | 0m2.163s |
4コアの環境で実行しています。user
(CPU時間)ではちょうど 18.210s * 4 ≒ 1m10.984s
ですね。
ランタイムの何かが無駄に同期しているんでしょうか? 謎です。
並列ML: MaPLe で AObench を高速化
POPL2020 で新しい並列ML(MaPLe)(略称: MPL)の提案がありました。
正確に言うと言語の提案じゃ無くて、「並列プログラムの性質を表す(良い)性質 Disentanglement Property を提案したので、その良さ(= 並列プログラムの実行効率)を示すための処理系を作って公開しました」というモノ。
(珍しいことに)あっさり手元で動いたので AObench-SML に追加してベンチマークを取ってみました。
ただしこの処理系の持つ並列プリミティブは正しく使わないとクラッシュするので実用するものではありません*1。
AObench はピクセル毎に並列化しやすく、ループ毎に配列の互いに異なる領域に書き込むアルゴリズムなのでMPL(というかDisentanglement)の役立つ形の処理にかなりよくマッチしています(多分)。
ベンチマーク結果
実際のベンチマーク結果を以下に示します。
処理系 | real | user | sys |
---|---|---|---|
gcc | 0m0.732s | 0m0.718s | 0m0.012s |
mlton | 0m1.589s | 0m1.579s | 0m0.008s |
mpl | 0m0.398s | 0m1.523s | 0m0.016s |
MPLについてはユーザがプロセス数を指定する必要があるため、以下のようにコア数と同じ procs 4
を指定しています。
(後ろの 4 は後述する並列粒度指定。実験的に求めた値。)
./aobench-mpl @mpl procs 4 set-affinity -- aobench-mpl.ppm 4
この処理系は MLton を拡張して実装されているので並列化しない場合は素の MLton と同じパフォーマンスが出ます。
今回はちょうど(プロセス数と同じ)4倍速になってますね。
変更箇所
主な変更箇所を解説します。
まず以下の部分。
ライン毎のループを ForkJoin.parfor
に換えています。粒度とループ範囲を与えるだけで並列化完了です。
この粒度は上で示したコマンドライン引数から渡される 4
で、この範囲(つまり f n .. f (n+3)
を実行する範囲)では各スレッド内でシーケンシャル実行されます。
ここで渡す関数が並列実行されます。
< for {begin=0, limit=h, step=succ} (fn y=> --- > ForkJoin.parfor grain (0, h) (fn y => > let val drand48 = mk_rand (48271 + y) in
for
から parfor
への変更だけだと乱数生成の部分がスレッド間で共有されてしまい速度が出ませんでした*2。
そのため以下のようにクロージャを作る関数を定義し直して使用するようにしました。
< local < val rand = Random.rand (48271, Option.getOpt (Int.maxInt, 1073741823)) < in < fun drand48 () = Random.randReal rand < end --- > fun mk_rand seed = > let > val rand = Random.rand (seed, Option.getOpt (Int.maxInt, 1073741823)) > in > fn () => Random.randReal rand > end
まとめ
- 簡単に速くなったのでそこそこ感動しました
- 動くはずのモノが動くとやる気が出る
- スレッド間で共有している ref (今回の場合
rand
の中身) があっても遅くなるだけで動くのが良かった
Link
- 提案論文 Disentanglement in Nested-Parallel Programs
- MPLのリポジトリ MPLLang/mpl
- AObenchSMLの並列forを使った箇所 eldesh/aobench_sml/aobench-mpl.sml#L237
nfq_bind_pf の使い方
Linux にはネットワークフィルタリング機能と、そのラッパーライブラリである libnetfilter-queue があります。
そのライブラリの提供するAPIである nfq_bind_pf
と nfq_unbind_pf
は uint16_t pf
を要求します。
これに何を渡せばいいのかよく判らなかったという話。
- サンプルコード では
AF_INET
を使うように書いてある。 - nfq_bind_pf のコメントでは
PF_INET
(など) を使えと書いてある。 - どっちを使っても動いてる(ように見える)
どっちを使えばいいのか?というか AF_INET
と PF_INET
って何だっけ?
PF_INETとAF_INETの微妙な違い によると:
- PF_ はプロトコルファミリー、AF_ はアドレスファミリーを表す
- BSDソケットの設計ではプロトコルファミリーとアドレスファミリーは独立している
- 現状のインターネットではIPv4/v6とそれらの唯一のアドレッシング方法しか無い(から独立に指定出来るうれしみは無い)
(適当な)結論
PF
はプロトコルファミリーを表すAF
はアドレスファミリーを表す- 実装は同じ値なのでどっちでも動く(
PF_INET
もAF_INET
も2
) - ドキュメントには
bind a nfqueue handler to a given protocol family
と書いてあるのでPF_*
を指定するべき - サンプルコード書いた人が怪しい
リンク
- libnetfilter_queueのdoxygen (2020/01/29時点でのバージョンは1.0.3)