本記事はこちらのブログを参考にしています。
翻訳にはアリババクラウドのModelStudio(Qwen)を使用しております。
WebAssemblyのパフォーマンスプロファイリングについて
最近、WebAssemblyコードのパフォーマンスプロファイリングに関するいくつかの実践を通じて、そのアイデアや手法が他のジャストインタイムコンパイル(JIT)コードにも共通していることがわかりました。この記事では、WebAssemblyを例としてパフォーマンスプロファイリングの方法を説明します。
WebAssemblyとWAVM
まず、WebAssembly(以下、WASM)について簡単に紹介します。WASMはバイトコード形式の低レベルアセンブリ言語であり、C/C++やRustなどの高級言語からコンパイル可能です。当初はWeb上でネイティブに近い実行効率を達成することを目的として設計されました(現在、主要なブラウザで利用可能)。応用例としては、Web版のUnity、TensorFlow、AutoCAD、Google Earthなどがあります(事例はこちら)。
WASMの移植性の高さから、多くのプロジェクトが非ブラウザ環境でもWASMを使用し始めています。これは、WASMをVMまたはランタイムを通じて実行することを意味します。本記事では、VMを介して実行されるWASMのパフォーマンスプロファイリングについて議論します。WASMのVMおよびランタイムには多数の選択肢があり、近年では一部がパフォーマンスプロファイリングのサポートを提供しています。そこで、今回は現時点でプロファイリングサポートが不足しているWAVMを実験対象としました。WASMはWAVMによってJITモードで実行されます。以下の図に示すように、開発者は異なる高級言語をWASMにコンパイルでき、その後、WAVMはLLVMを通じてWASMをLLVM IRにコンパイルし、最終的に異なるプラットフォーム向けのマシンコードにコンパイルします。
パフォーマンスプロファイリング - WASMインスツルメンテーション
さて、最初の目的に戻ります。WASMに対してどのようにパフォーマンスプロファイリングを行うのでしょうか?最も基本的な方法に戻りましょう。つまり、関数に入る際にタイムスタンプを記録し、出る際に別のタイムスタンプを記録して、両者の差を計算します。あるいは、関数呼び出し前後でタイムスタンプを記録し、その差を計算してオーバーヘッドを求めます。以下の図に示すように、これらの2つの解決策には大きな違いがないため、便宜上、最初の解決策のみを選択します。
インスツルメンテーション関数の設計
ハッシュテーブルに基づく実装
同じ関数が異なる関数によって呼び出される可能性があるため、perf_start
と perf_end
で実行時間とコールスタック構造の両方を記録する必要があります。これに対する最も直感的な設計は、記録中にコールスタックを維持することです。具体的には次のようになります:
-
perf_start
時点で、現在の関数名とエントリ時刻をスタックにプッシュします。
-
perf_end
時点で、スタックをポップし、トップ関数のオーバーヘッドを記録します。同時に、コールスタックを一度トラバースしてトップ関数の実行パスを取得し、これらをハッシュテーブルに保存します。
しかし、テストの結果、この解決策は直感的であるものの、スタックをトラバースし、インスツルメンテーション関数内で文字列連結を行う必要があるため、大きなオーバーヘッドが発生することがわかりました。これが観察者効果を引き起こし、信頼できないプロファイリング結果につながります。以下の図に示すように、複雑な関数のオーバーヘッドが80、単純な関数のオーバーヘッドが20の場合、各関数呼び出しにおけるインスツルメンテーションのオーバーヘッドが20で、両方の関数が同じ回数呼び出された場合、最終的に観測される結果は「複雑」が100、「単純」が40となります。
最適化:ツリー構造に基づく実装
オーバーヘッドを減らすために、コールグラフを記録するためにツリーを使用できます。具体的には次のようになります:
-
perf_start
時点で、既存の子ノードをクエリするか、新しい子ノードを作成し、エントリ時間を更新します。グローバルノードポインタを子ノードに移動します。
-
perf_end
時点で、関数の時間オーバーヘッドを更新します。グローバルノードポインタを親ノードに移動します。
この方法により、複数回呼び出される関数の場合、インスツルメンテーション関数の時間オーバーヘッドは基本的にタイムスタンプの取得コストにまで削減されます。一連の最適化後、最終的にオーバーヘッドは約3%に減少し、結果としての誤差も許容範囲内に収まりました。
void perf_start(int32_t func_id) {
PerfNode* cur_node = perf_data->perf_node();
if (!cur_node) {
return;
}
// 子ノードを取得または作成
PerfNode* child_node = cur_node->GetChildNode(perf_data->buffer(), func_id);
if (!child_node){
perf_data->UpdatePerfNode(NULL);
return;
}
// エントリ時間を記録
child_node->RecordEntry();
// g_cur_node を child_node に設定
perf_data->UpdatePerfNode(child_node);
}
void perf_end() {
PerfNode* cur_node = perf_data->perf_node();
if (!cur_node) {
return;
}
// オーバーヘッドを記録
cur_node->RecordExit();
// g_cur_node を親ノードに設定
perf_data->UpdatePerfNode(cur_node->parent());
}
その他の最適化
- 新しい子ノード作成時のスペース割り当てにはプールを使用します。
- サンプリングレコード:通常の時間関数に加えて、より高性能な
rdtsc
命令を使用してレジスタから直接値を取得できます。<a href=https://zsummer.github.io/2021/02/19/2021-04-02-perf-clock/
以下は、WASMインスツルメンテーションのコアコードであり、前述のステップ2および3を含んでいます。WASMセクションへのその他の変更についてはここでは詳細に説明しません。
// perf_start インスツルメンテーション
func_builder.instruction(&Instruction::I32Const((current_func_index+2) as i32));
func_builder.instruction(&Instruction::Call(perf_start));
// ブロックの深さは、関数が終了したかどうかを判断するために使用されます。
let mut block_depth = 0;
for op in operator_reader {
let op = op? ;
match op {
// 呼び出されるターゲットインデックスが2増加します。
Operator::Call { function_index } => {
handle_in_function_call(&mut func_builder, entry_func_index, exit_func_index, function_index)? ;
},
// Return の perf_end インスツルメンテーション
Operator::Return => {
func_builder.instruction(&Instruction::Call(exit_func_index));
func_builder.instruction(&Instruction::Return);
},
// ブロックの深さをカウントします。
Operator::Block { .. } | Operator::Loop { .. } | Operator::If { .. } | Operator::Try { .. } => {
block_depth += 1;
func_builder.instruction(&DefaultTranslator.translate_op(&op)? );
},
// 戻り値のない関数の場合、perf_end インスツルメンテーションを挿入します。
Operator::End => {
if block_depth == 0 {
func_builder.instruction(&Instruction::Call(exit_func_index));
}
func_builder.instruction(&DefaultTranslator.translate_op(&op)? );
block_depth -= 1;
},
_ =>{
func_builder.instruction(&DefaultTranslator.translate_op(&op)? );
},
}
}
code_section_builder.function(&func_builder);
LLVM IR インスツルメンテーション
前述の通り、WAVMはJITモードでWASMを実行し、バックエンドでLLVMを使用します。これは、WASMが最初にLLVM IRにコンパイルされ、その後各プラットフォーム向けのマシンコードがIRから生成されることを意味します。WASMレベルでのインスツルメンテーションに加えて、LLVM IRレベルでもインスツルメンテーションを行うことができます。インスツルメンテーションの原理はほぼ同じですが、異なるVMはバックエンドの実装が異なります。この変更は作業量と複雑さを増すだけでなく、ソリューションの移植性も低下させるため、推奨されません。
フレームグラフ
フレームグラフは、パフォーマンスプロファイリングを視覚化するための有用なスクリプトであり、関数オーバーヘッドの割合を表示できます。一般的な処理手順は次のとおりです。
$FG_DIR/stackcollapse-perf.pl perf.unfold > perf.folded
$FG_DIR/flamegraph.pl perf.folded > perf.svg
これで、インスツルメンテーションを通じて呼び出しオーバーヘッドグラフを取得しましたが、この呼び出しオーバーヘッドグラフをフレームグラフに変換するにはどうすればよいでしょうか? perf.unfold
と perf.folded
の形式を見てみましょう。
perf.unfold
には、perf
ツールによって採取された各サンプルのコールスタックとサンプリング期間が含まれており、ファイルサイズが大きくなります。
明らかに、perf.folded
はよりシンプルで、コールスタックは本質的にリーフノードへのパスであるため、コールツリーの深さ優先探索を通じて直接出力できます。以下はコアコードです:
std::string call_stack = wavm;
while (!node_stack.empty()) {
PerfNode* cur_node = node_stack.top();
if (!cur_node->visited_) {
// 子ノードをスタックにプッシュ
// 現在のコールスタックコストを計算
uint64_t cur_cost = cur_node->time_cost_;
for (size_t i = 0; i < cur_node->children_size_; i++) {
node_stack.push(cur_node->children_[i]);
cur_cost -= cur_node->children_[i]->time_cost_;
}
// コールスタックを更新して出力
call_stack.append(,:);
call_stack.append(wasm_func_names.functions[cur_node->func_id_].name);
call_length_stack.push(call_stack.size());
fprintf(fp, "%s %ld\n", call_stack.c_str(), cur_cost);
cur_node->visited_ = true;
visited_node.push_back(cur_node);
}
else {
node_stack.pop();
call_length_stack.pop();
call_stack.resize(call_length_stack.top());
}
}
上記の実装後、PerfOutput
インターフェースをパッケージ化して、WASM実行後にフレームグラフに必要な .folded
ファイルを出力するだけです。
サンプル
次に、フィボナッチ計算の簡単なサンプルを書きます。
#include <stdio.h>
#include <stdlib.h>
int fibonacci(int n) {
if (n <= 0)
return 0;
if (n == 1)
return 1;
return fibonacci(n - 1) + fibonacci(n - 2);
}
int main(int argc, char **argv) {
int n = atoi(argv[1]);
printf("fibonacci(%d)=%d\n", n, fibonacci(n));
return 0;
}
次に、以下のコマンドを使用してWASMにコンパイルし、バイトコード形式から読みやすいテキスト形式(WAT)に変換します。
# emcc は Emscripten SDK からのもので、WASM用のコンパイラです。
# -O0 最適化を無効にしてソースコード構造を保持します。-g 最適化を無効にしてWASMにデバッグ情報を追加します。
# wasm2wat は WebAssembly Binary Toolkit からのものです。
wasm2wat fib.wasm > fib.wat
fib.wat
でフィボナッチ関数を見つけることができます:
(func $fibonacci (type 3) (param i32) (result i32)
(local i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)
global.get $__stack_pointer
...
global.set $__stack_pointer
local.get 25
return)
func
は、この括弧内に関数があることを示しており、その後に関数シグネチャとローカル変数が続きます。関数の内容は3行目から始まり、最後の return
まで続きます。
wasm-instrument instrument fib.wasm -o fib_i.wasm
wasm2wat fib_i.wasm > fib_i.wat
fib_i.wat
の内容は次のようになり、インスツルメンテーションが完了したことがわかります。
(func $fibonacci (type 3) (param i32) (result i32)
(local i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)
i32.const 7
call $perf_start
global.get $__stack_pointer
...
global.set $__stack_pointer
local.get 25
call $perf_end
return)
実行して出力します。
# 実行して fib.folded ファイルを生成します。
wavm run -o fib.folded fib.wasm