原文はこちら。
https://blogs.oracle.com/linuxkernel/ktask%3a-a-generic-framework-for-parallelizing-cpu-intensive-work
コアカーネルチームのDaniel Jordanがktaskフレームワークのレビューを書きました。ktaskフレームワークは、Linux Kernelメーリングリストに提出され、現在レビュー中です。
直近ではLinuxカーネルに大きなタスクを並列化させるktaskというプロジェクトに取り組んできました。これらのタスクは常にカーネル・コンテキストで実行されますが、カーネル内で直接開始することも、アプリケーションからシステムコールを通じて間接的に開始することもできます。
このエントリでは、このフレームワークが必要になった課題、フレームワークの背後にあるハイレベルの思想、これまでに実装したユースケース、およびプロジェクトに関するその他の詳細について説明します。最後に、各タスクが効率的に並列化されていることを確認するために使用したLinuxパフォーマンスツールを説明します。
Motivation
コア数やメモリサイズが増えてもアプリケーションとカーネル自体が引き続きうまく稼働するようにするためには、カーネルのスケーリングを行う必要があります。例えば、システムコールがシステムリソースの特定の部分を呼び出す場合、カーネルは同様の割合のシステムリソースを使用して要求に対応するようにして対応する必要があります。 しかし、いくつかの場所では、カーネルがまだ動作していない場合があります。
たとえば、データベースやインメモリ・キャッシュなどの大きなアプリケーションがシャットダウンしてページをシステムに返した場合、そのアプリケーションはマシンのメモリの半分を簡単に使用していた可能性があります。しかし現在のところ、解放されるメモリのサイズにかかわらず、カーネルはこの作業にたった1スレッドを割り当てています。この部分がボトルネックになって、大規模なアプリケーションがすべてのリソースを返すために数分かかってしまい、その間に他のアプリケーションがそれらのリソースを使用することができなくなる可能性があります。
Concept
ktaskとは、これらのタイプの重い要求を処理するために使用されるカーネルスレッドの数をスケーリングするように設計されたものです。
コンセプトはかなりシンプルですが、用語を説明しておきます。タスク(task)とは、実行すべき総作業であり、チャンク(chunk)はスレッドに与えられた作業の単位です。
ktaskフレームワークを使ってタスクを完了するために、(システムコールなどの)カーネル・クライアントが、1つのチャンクを完了させるスレッド関数を提供します。スレッド関数は、クライアントがタスクに固有のデータを渡すために使用する引数だけでなく、チャンクを区切るstart引数とend引数を標準的な方法で定義されます。
さらに、クライアントは、タスクの開始を表すオブジェクトと、タスクのいくつかのユニットを進めて新しいタスク位置を表す別のオブジェクトを生成する方法を知っているイテレータ関数を提供します。ktaskフレームワークは、開始オブジェクトとイテレータを内部的に使用して、タスクをチャンクに分割します。
最後に、クライアントは、1チャンクで処理するのに適した最小作業量を示すために、タスクの合計サイズとチャンクの最小サイズを渡します。サイズはタスク固有の単位(ページ、inode、バイトなど)で与えられます。ktaskフレームワークは、オンラインのCPU個数と内部の最大スレッド数とともに、これらのサイズを使用して、何個のスレッドを開始すべきか、タスクを何個のチャンクに分割すべきかを決定します。
たとえば、巨大なページをクリアするタスクを考えてみると、このタスクは構成要素の各ベースページのページクリア関数を呼び出す 'for'ループを伴う単一のスレッドで行われていました。ktaskを使って並列化するために、クライアントはまずforループをスレッド関数に移動し、関数に渡された範囲で動作するようにします。この単純なケースでは、スレッド関数の開始引数と終了引数は、huge pageの一部をクリアするためのアドレスに過ぎません。次に、 forループが使用されていた箇所で、クライアントは、huge pageの開始アドレス、huge pageの合計サイズ、およびスレッド関数を使用してktaskを呼び出します。内部的には、ktaskはアドレス範囲を適切な数のチャンクに分割し、適切な数のスレッドを開始してそれらを完了します。
インターフェイスの詳細については、最新のアップストリームパッチセットを参照してください。
[RFC PATCH v2 2/7] ktask: multithread cpu-intensive kernel work
https://lkml.org/lkml/2017/8/24/801
Use Cases and Performance Results
これまでのところ、ktaskは、システムが匿名ページを解放するパスのunmap (2) やexit (2) 、ブート時にページの初期化を構成し、最大のhuge pageサイズをゼロにするといった箇所で使用することが予定されています。huge pageをクリアする簡単な例で、ktaskフレームワークのパフォーマンスを説明します。
以下の結果は下表のスペックを持つOracle X5-8 serverで計測しました。
CPU type: Intel(R) Xeon(R) CPU E7-8895 v3 @ 2.60GHz
CPU count: 144 cores (288 threads); 8 nodes @ 18 cores/node
Memory: 1T
以下はテスト結果です。1つのNUMAノード内と、大規模なマルチノードシステム上のすべてのノードにわたるスケーラビリティを示すために、4つのレンジサイズを使用しました。最初のサイズ(100GB)は、完全に同じノードのページから構成されています。サイズが最初のサイズを超えて増加するにつれて、ゼロ初期化されたメモリは増大し、次第にシステムのノードの多くを含んでいきます。
nthread speedup size (GiB) min time (s) stdev
1 100 41.13 0.03
2 2.03x 100 20.26 0.14
4 4.28x 100 9.62 0.09
8 8.39x 100 4.90 0.05
16 10.44x 100 3.94 0.03
1 200 89.68 0.35
2 2.21x 200 40.64 0.18
4 4.64x 200 19.33 0.32
8 8.99x 200 9.98 0.04
16 11.27x 200 7.96 0.04
1 400 188.20 1.57
2 2.30x 400 81.84 0.09
4 4.63x 400 40.62 0.26
8 8.92x 400 21.09 0.50
16 11.78x 400 15.97 0.25
1 800 434.91 1.81
2 2.54x 800 170.97 1.46
4 4.98x 800 87.38 1.91
8 10.15x 800 42.86 2.59
16 12.99x 800 33.48 0.83
このテストでは最大8スレッドまでスケールしていますが、16スレッドでは壁に当たっています。これはチップのメモリ帯域幅の上限に達したためです。実際には、より多くのスレッドがより多くのチップのキャッシュを使用できるため、2〜8スレッドまでは非常にリニアに高速化していることがわかります。
ここで強調しているループは
clear_page_erms という、タイトループ内でわずかな命令を使うプラットフォーム固有のページクリア関数で、1つのスレッドで最高2550 MiB/sの帯域幅に達します。 2スレッド、4スレッド、または8スレッド利用時に各スレッドで同じ帯域幅を得られますが、16スレッドではスレッドごとの帯域幅が1420 MiB/sに低下します。
しかし、ktaskがNUMAを認識することでのパフォーマンスも向上します(ktaskは、実行されている作業のローカルノード上でワーカースレッドを開始します)。このことは性能向上のより大きな要素になります。それは、ゼロ初期化するページの量が複数のノードからのメモリを含んで増大するにつれ、メモリサイズが増加するにつれて実際にスピードアップするという、優れたスケーラビリティの利点を得られからです。
Tools Used to Build this Framework
このフレームワークは、huge pageの消去やmunmap(2)のような、パス最適化時の2つのステップのうちの最初のものに過ぎません。ホットロック、キャッシュラインバウンシング、スレッド間の冗長な作業といった効率的な並列化の障害を取り除くために、カーネルコードをさらにチューニングする必要があります。幸いにもLinuxカーネルには、これらの問題を診断する上で役立つ多くのツールが付属しています。
最も使用したツールはlock_statです。
lock statistics (lock_stat)
https://www.kernel.org/doc/Documentation/locking/lockstat.txt
これは競合カウント、ロックの獲得数、待ち時間といった、さまざまなカーネルのロックに関するメトリックを収集する、ユーザ空間にアクセスできるツールです。lock_statを使用するには、CONFIG_LOCK_STAT=y を指定してカーネルをビルドする必要があります。カーネルからエクスポートされた多くのユーティリティと同様に、lock_statはprocファイルによって制御されます。まず
/proc/lock_stat は収集されたデータを表示し、続いて
/proc/sys/kernel/lock_stat はツールを有効化または無効化します。このエントリに添付されている単純なラッパースクリプトは、こうした作業をすべて自動化するものです。そのため、スクリプトを実行するだけでよいのです。
lstat cmd...
コマンドが戻ると、 lock_stat が無効化され、/proc/lock_stat をのんびりご覧頂くことができます。将来のシステム動作によってデータが汚れることを心配する必要はありません。-fオプションを使用すると、データをファイルに書き出すことができます。
もう1つの便利なツールは、perf probeで、これはコードを変更せずに動的なトレースポイントを実行中のカーネルに追加できるperfサブコマンドです。
perf-probe(1)
https://raw.githubusercontent.com/torvalds/linux/master/tools/perf/Documentation/perf-probe.txt
今回の場合、workqueueのスレッド待ち時間を調べるために使用しましたが、トレースする特定の関数をトレースしたい場合、一般にperf probeは有用です。どのCPUで関数が実行されたのか、関数(entry、return、さらには特定の命令)内のどの時点でいつ実行されたのかを表示することができます。利用にあたっては、カーネルを以下の設定で構成する必要があります。
CONFIG_KPROBE_EVENTS=y
CONFIG_KPROBES=y
CONFIG_PERF_EVENTS=y
以下は、あらかじめ定義されたイベントと動的プローブを組み合わせて、前述のスレッド待ち時間を測定するperf probeのサンプルです。この実験の目的は、ある状況下で、ktaskクライアントが
ktask_runを呼び出し、ktaskスレッド関数(ここでは
dispose_list_task)を実行するまでの間に、workqueueスレッドが原因で大きな遅延になっていないことを検証することでした。
dispose_list_task関数は
evict2と呼ばれる別の関数を多く呼び出すため、並列化に適しています。perf probeは、このプロセスの各ステップがいつどこで起こったかを示すのに役立ちます。
まず、動的なプローブを冗長に (-v) 追加 (-a) します。
# perf probe -v -a evict_inodes
# perf probe -v -a ktask_run
# perf probe -v -a ktask_task
# perf probe -v -a 'dispose_list_task start:x64 end:x64' # fourth probe
# perf probe -v -a 'evict2 inode:x64'
# perf probe -v -a 'evict2_ret=evict2+309 inode:x64' # sixth probe
# perf probe -v -a 'dispose_list_task_ret=dispose_list_task%return'
# perf probe -v -a 'ktask_task_ret=ktask_task%return'
# perf probe -v -a 'ktask_run_ret=ktask_run%return'
# perf probe -v -a 'evict_inodes_ret=evict_inodes%return'
最初の3つは、カーネルの関数名を使用して、プローブを関数呼び出し時に呼び出すようにします。4番目のプローブdispose_list_taskは、関数呼び出し時にも起動しますが、関数の引数(startおよびend)を64ビットの16進値として出力します。6番目は
evict2の特定の命令で呼び出されます。残りのプローブはそれぞれの関数から戻ったタイミングで呼び出されます。
今回は設定したダイナミックプローブを使用して、実際に関心のあるコマンドを記録します。'probe:'という接頭辞が付いていないプローブ(ここでは例えば 'workqueue:' )は、最近のすべてのカーネルに表示されるあらかじめ定義されたperfイベントです。
# perf record -aR \
-e probe:evict_inodes \
-e probe:ktask_run \
-e workqueue:workqueue_queue_work \
-e workqueue:workqueue_activate_work \
-e workqueue:workqueue_execute_start \
-e probe:ktask_task \
-e probe:dispose_list_task \
-e probe:evict2 \
-e probe:evict2_ret \
-e probe:dispose_list_task_ret \
-e probe:ktask_task_ret \
-e workqueue:workqueue_execute_end \
-e probe:ktask_run_ret \
-e probe:evict_inodes_ret \
cmd...
-aフラグを指定すると、システム内のすべてのCPU上のイベントが記録されます。 通常、perfは与えられたコマンドのイベントだけを記録しますが、この場合はカーネル内のworkqueueスレッドもトレースします。
# chown user:group perf.record.out
root権限で不必要に実行されないようにファイルのパーミッションを変更した後、以下のようにperfレコードから生成されたraw出力を後から処理できます。
$ perf script -F cpu,event,time,trace > perf.script.out
$ cat perf.script.out
[006] 0.000000: probe:evict_inodes: (ffffffff811f6c20)
[006] 0.014580: probe:ktask_run: (ffffffff8107e210)
[006] 0.014584: workqueue:workqueue_queue_work: work struct=0xffff8818634b6058 function=ktask_task workqueue=0xffff883fef931a00 req_cpu=1 cpu=1
[006] 0.014585: workqueue:workqueue_activate_work: work struct 0xffff8818634b6058
...snip...
[001] 0.014645: workqueue:workqueue_execute_start: work struct 0xffff8818634b6058: function ktask_task
[001] 0.014667: probe:ktask_task: (ffffffff8107e0c0)
[001] 0.014671: probe:dispose_list_task: (ffffffff811f5a50) start_x64=0x0 end_x64=0x1
[001] 0.014673: probe:evict2: (ffffffff811f5890) inode_x64=0xffff8818a15fbb50
[001] 0.016089: probe:evict2_ret: (ffffffff811f59c5) inode_x64=0xffff8818a15fbb50
[001] 0.016090: probe:evict2: (ffffffff811f5890) inode_x64=0xffff881fc9fceb10
[001] 0.017483: probe:evict2_ret: (ffffffff811f59c5) inode_x64=0xffff881fc9fceb10
...snip...
[001] 0.193898: probe:evict2: (ffffffff811f5890) inode_x64=0xffff8816939c6b10
[001] 0.195335: probe:evict2_ret: (ffffffff811f59c5) inode_x64=0xffff8816939c6b10
[001] 0.195339: probe:dispose_list_task_ret: (ffffffff811f5a50 <- ffffffff8107e134)
[001] 0.195345: probe:dispose_list_task: (ffffffff811f5a50) start_x64=0x15 end_x64=0x16
[001] 0.195347: probe:evict2: (ffffffff811f5890) inode_x64=0xffff8819a55f2350
[001] 0.196753: probe:evict2_ret: (ffffffff811f59c5) inode_x64=0xffff8819a55f2350
...snip...
[001] 2.701235: probe:evict2: (ffffffff811f5890) inode_x64=0xffff8816fdb52290
[001] 2.702268: probe:evict2_ret: (ffffffff811f59c5) inode_x64=0xffff8816fdb52290
[001] 2.702269: probe:dispose_list_task_ret: (ffffffff811f5a50 <- ffffffff8107e134)
[001] 2.702273: probe:ktask_task_ret: (ffffffff8107e0c0 <- ffffffff81072c69)
[001] 2.702275: workqueue:workqueue_execute_end: work struct 0xffff8818634b6058
...snip...
[006] 2.706126: probe:ktask_run_ret: (ffffffff8107e210 <- ffffffff811f6e0a)
[006] 2.706129: probe:evict_inodes_ret: (ffffffff811f6c20 <- ffffffff811db4b4)
左から、括弧内のCPU番号、秒単位のイベントのタイムスタンプ、プローブ名が並んでいます。リクエストした追加のデータは、行末に表示されます。この例では、ktask_runの呼び出しから1スレッドの最初のチャンクの開始時間(probe : dispose_list_task)が100マイクロ秒(0.014671 - 0.014580)未満であったことがわかりました。これから、workqueueスレッドの待ち時間が問題ではないことがわかります。
最後に、システムから動的トレースポイントを削除します。
# perf probe -d '*'
Conclusion
このエントリで、ktaskのモチベーションとなったスケーラビリティに関する問題、フレームワークのコンセプト、ユースケースを説明し、最後にフレームワーク作成時に使用したパフォーマンスツールについてまとめました。
これからは、ktaskに対するより多くのフィードバックをいただき、より多くのCallerを追加し、コアフレームワークを強化し続けることを計画しています。ktaskが近い将来、アップストリームに進出し、すべての人がカーネルスケーラビリティの向上を享受できるようになることを願っています。