HN 日本語サマリー

← 一覧へ戻る
プログラミング

バイトコードからソースコードへのマッピング

Bytecode-to-Source Mapping (tidefield.dev)

38 pointsby evakhoury2 コメント

要約

この記事では、インタープリタにおいてバイトコードのオフセットをソースコードの行番号にマッピングする手法について解説しています。単純な配列から、ランレングスエンコーディング、そして二分探索やカーソルベースの走査が可能な「開始オフセット」方式まで、効率的なマッピング方法を比較検討しています。JVMやLuaなどの他の仮想マシンでの実装方法にも触れています。

全文翻訳

Robert Nystrom の著書『Crafting Interpreters』の第14章の課題に取り組んでいる際に、この問題に遭遇しました。注: 本番環境の VM はより洗練されていますが、この記事の最後で JVM や Lua のような他の VM との類似点について共有します。 背景 この本の前半では、字句解析と構文解析から始めて AST を構築し、それを解釈することで、トイ言語 jlox をトップダウンで実装しています。後半では、バイトコード構造から始めて、ボトムアップで同じ言語を再実装しています。 Source: Crafting Interpreters, chapter 14 バイトコードはチャンクに格納され、チャンクはバイトのシーケンスを含みます。各バイトは、オペコードであるか、オペコードのオペランドのいずれかです。したがって、命令は異なるバイト数を占める可能性があります。例えば、OP_RETURN は 1 バイトですが、OP_CONSTANT にはチャンクの定数プールへのインデックスを含むオペランドが続きます。 offset 0 1 2 byte OP_CONSTANT constant index OP_RETURN ___________________ | 1つの命令 別の命令 命令が実行時エラーを引き起こした場合、VM はバイトコードオフセットを生成したソース行に逆変換する方法が必要です。そのため、チャンクはソース行を格納する必要があります。簡単な解決策は、バイトコードと並列に lines という別の配列を格納し、すべてのバイトに対応するソース行を持たせることです。 offset: 0 1 2 3 4 5 6 7 code: 00 01 00 02 01 00 03 01 line: 1 1 1 1 1 2 2 2 この設計はシンプルで O(1) の検索が可能です。しかし、n バイトのバイトコードに対して O(n) のメモリを消費します。上記の lines 配列を見ると、いくつかの連続したバイトが同じソース行から来ているという事実を利用できます。 ランレングスエンコーディング ランレングスエンコーディングは、各行番号を、それに属する連続バイト数とともに一度だけ格納します。 line per byte: 1 1 1 1 1 | 2 2 2 encoded runs: (5, 1) | (3, 2) count,line n はバイトコードバイト数です。r は連続する行の実行数です。ここでは n = 8、r = 2 です。一般に、1 <= r <= n です。最良の場合は、チャンク全体が 1 つのソース行から生成され、r = 1 となります。最悪の場合は、バイトごとにソース行が変更され、r = n となります。 ランレングスエンコーディングは、行テーブルを O(n) から O(r) のメモリに削減します。 線形検索 任意のオフセットの行を見つけるには、実行をたどりながらそれらの長さを累積していくことができます。オフセット 6 の場合: (5, 1) -> オフセット 0..4 をカバー (3, 2) -> オフセット 5..7 をカバー <- オフセット 6 はここ したがって、ランダムな検索は最悪の場合 O(r) の時間を要します。チャンクを逆アセンブルする際に、バイトごとに新しい線形スキャンを実行すると、合計コストは O(nr) になります。r は n に等しくなる可能性があるため、最悪の場合は O(n²) です。 ワンパス走査 しかし、ランレングスエンコーディングは本質的に二次的ではありません。逆アセンブラがオフセットを増加順に訪問する場合、現在の実行を指すカーソルを保持できます。各バイトと各実行は一度だけ訪問されるため、O(n + r) となり、r <= n であるため O(n) に簡略化されます。このアプローチは逐次走査に最適ですが、任意の検索を改善するものではなく、O(r) のままです。エラーがチャンクの途中のオフセットを返した場合、より多くの情報を格納しない限り、実行を最初からスキャンする必要があります。 先行問題 実行長を記録する代わりに、実行の開始オフセットを記録できます。 offset: 0 1 2 | 3 4 | 5 line: 1 1 1 | 2 2 | 3 starting pairs: (0, 1) (3, 2) (5, 3) 各ペアは、このバイトコードオフセットから、このバイト数の後続バイトがこのソース行に属することを意味します。本質的に、これは静的先行問題1になります。 二分探索 ペアは開始オフセットでソートされているため、変更された二分探索によって静的先行問題を解決できます。考慮してください: starting pairs: (0, 1) (3, 2) (5, 3) ターゲットオフセット 4 が与えられた場合、4 以下で最大の開始オフセットである (3, 2) を見つけます。二分探索中、left と right は正確な一致が含まれる可能性のある領域を区切ります。正確な一致が存在しない場合、それらは最終的に交差します。 target 4 v starting offsets: 0 3 | 5 ^ ^ right left 正確な一致がない場合、pair[right] はターゲットより下の最大の開始オフセットを指します。正確な一致ケースと合わせて、ターゲット以下の最大の開始オフセットを見つけます。 fn get_line(chunk: &Chunk, offset: usize) -> usize { let mut left = 0; let mut right = chunk.line_starts.len() - 1; while left <= right { let mid = left + (right - left) / 2; let (mid_offset, mid_line) = chunk.line_starts[mid]; if offset < mid_offset { right = mid - 1; } else if offset > mid_offset { left = mid + 1; } else { return mid_line; } } let (_, line) = chunk.line_starts[right]; line } このコードは、以下の不変条件に依存しています: get_line は有効なバイトコードオフセットに対してのみ呼び出されます。 line_starts は、バイトコードが順序通りに追加されるため、ソートされたままです。 開始オフセットによるワンパス走査 二分探索は任意の検索に役立ちます。逐次逆アセンブル中、カーソルは現在の開始ペアを指すことができます。次のペアの開始オフセットが現在のバイトコードオフセット以下になるたびに、カーソルを進めます。カーソルは前方のみ移動するため、各ペアを最大 1 回訪問し、全体で O(n) の走査になります。これにより、開始オフセットデータ構造は 2 つの便利なアクセスパターンを提供します: 任意のオフセットには二分探索を使用: O(log r)。 順序走査にはカーソルを使用: 全体で O(n)。 美しさは、二分探索とカーソルアプローチのどちらかを選択する必要がないことです。シナリオに応じてどちらかを選択できます。 実行時分析 Approach | Memory | Random lookup | Full traversal One line per byte | O(n) | O(1) | O(n) Run lengths + fresh linear search | O(r) | O(r) | O(nr), worst-case O(n²) Run lengths + cursor | O(r) | O(r) | O(n) Starting offsets + binary search | O(r) | O(log r) | O(n log r) Starting offsets + cursor | O(r) | O(log r) when needed | O(n) 他の VM のアプローチ JVM JVM の LineNumberTable は、実質的に同じ開始オフセット表現を使用しています。これは各メソッドの Code のオプション属性であり、各 (start_pc, line_number) エントリはソース行が始まる場所を示します。1 つの違いは、仕様でペアがソートされていることを要求していないことです。HotSpot が行番号を見つける必要がある場合、line_number_from_bci は線形検索を実行して先行者を見つけます。 Lua Lua は、本の С реализацииに似た並列配列に行情報を格納します。ただし、すべての命令の正確な行番号を格納する代わりに、前の行番号からの 1 バイトの差分と、検索スキャンを制限するための時折の絶対チェックポイント (source) を格納します。 例えば、絶対チェックポイントが (pc 2, line 300) の場合: instruction: 0 1 2 3 4 source line: 10 10 300 310 314 (lines を格納する代わりに) lineinfo: 0 0 ABS +10 +4 (line delta を格納) ^ |-- checkpoint when delta exceeds one byte 命令 4 の行を見つけるために、Lua は行 300 から開始し、次の差分を加算します: 300 + 10 + 4 = 314。 脚注 この問題について調査している際に、Harvard の CS224: Advanced Algorithms コースの最初の講義で、静的先行問題として参照されていることを発見しました。この講義では、動的先行問題とワード RAM モデルも紹介されています。それらのトピックはざっと目を通しただけですが、バイトコード VM やその他の場所で実用的な用途に出会えることを願っています。↩