RubyVM::InstructionSequenceで覗く!Rubyがコードを解釈する仕組み
Ruby Magicの新しい記事へようこそ!今回は、Rubyが私たちのコードをどのように解釈しているのか、そしてその知識をどう活かせるかに迫ります。この記事を通じて、コードが解釈される仕組みを理解し、より高速なコードを書くためのヒントを得られるでしょう。
記号の微妙な違い
以前のRuby Magic記事「Rubyでのエスケープ文字」では、改行のエスケープに関する例を紹介しました。
以下の例では、複数行にわたって2つの文字列を1つのStringに結合する方法として、プラス記号+を使う場合とバックスラッシュ\を使う場合を示しています。
"foo" +
"bar"
=> "foobar"
# versus
"foo" \
"bar"
=> "foobar"この2つの例はよく似ていますが、実際の動作はまったく異なります。両者の読み込みや解釈の違いを知るには、通常ならRubyインタプリタの細部まで深い知識が必要です。しかし、Ruby自身にその違いを尋ねてみるという手もあります。
InstructionSequence
RubyVM::InstructionSequenceクラスを使えば、渡したコードをRubyがどのように解釈するのかを直接問い合わせることができます。このクラスは、Rubyの内部構造を覗き見るためのツールセットを提供してくれます。
以下の例で返されるのは、YARVインタプリタが理解した形のRubyコードです。
YARVインタプリタ
YARV(Yet Another Ruby VM)は、Ruby 1.9で導入されたインタプリタで、元のインタプリタであるMRI(Matz's Ruby Interpreter)を置き換えるものです。
インタプリタ型言語は、中間的なコンパイル段階を経ずにコードを直接実行します。つまり、C、Rust、Goといったコンパイル型言語とは異なり、Rubyはプログラムを最適化された機械語に事前にコンパイルしません。
Rubyでは、プログラムはまずRuby VM向けの命令セットに変換され、その直後に実行されます。これらの命令は、あなたのRubyコードとRuby VM上で実行されるコードの間の中間ステップにあたります。
この命令セットのおかげで、Ruby VMは構文解析を意識することなくRubyコードを理解できます。構文固有の解釈は命令生成の段階で処理済みだからです。命令シーケンスは、解釈済みのコードを表す最適化された操作群なのです。
通常の実行ではこれらの命令を目にすることはありませんが、内容を確認すれば、Rubyがコードを正しく解釈したかどうかを見直せます。InstructionSequenceを使えば、YARVが実行前に生成する命令の種類を確認できるのです。
Rubyインタプリタを構成するすべてのYARV命令を理解する必要はありません。ほとんどのコマンドは名前から意味が推測できるでしょう。
"foo" +
"bar"
RubyVM::InstructionSequence.compile('"foo" + "bar"').to_a
# ... [:putstring, "foo"], [:putstring, "bar"] ...
# versus
"foo" \
"bar"
RubyVM::InstructionSequence.compile('"foo" "bar"').to_a
# ... [:putstring, "foobar"] ...実際の出力には後述するセットアップ系のコマンドも含まれますが、ここでは"foo" + "bar"と"foo" "bar"の本質的な違いがはっきりと分かります。
前者は2つの文字列を生成してから結合します。一方、後者は最初から1つの文字列を生成します。つまり、"foo" "bar"なら文字列オブジェクトは1つだけで済むのに対し、"foo" + "bar"では3つのオブジェクトが生成されるのです。
1 2 3
↓ ↓ ↓
"foo" + "bar" # => "foobar"
もちろん、これはごく基本的な例にすぎませんが、言語仕様の小さなディテールが大きな影響を持ちうることを示す好例です。
- 割り当ての増加:Stringオブジェクトはそれぞれ個別にメモリ割り当てされます。
- メモリ使用量の増加:割り当てられたStringオブジェクトはそれぞれメモリを消費します。
- GC時間の長期化:短命なオブジェクトであっても、ガベージコレクタによる回収には時間がかかります。割り当てが増えればGCにかかる時間も長くなります。
ディスアセンブルでロジックを検証する
もうひとつの活用例は、ロジックの不具合のデバッグです。次の例は誰でも犯しがちなミスですが、重大な結果を招くこともあります。違いに気づけますか?
1 + 2 * 3
# versus
(1 + 2) * 3少し複雑になったこの例でも、Rubyの力を借りれば違いを突き止められます。
コードをディスアセンブルすると、Rubyが実行するコマンドを読みやすい表形式で出力してくれます。
1 + 2 * 3
# => 7
puts RubyVM::InstructionSequence.compile("1 + 2 * 3").disasm
# == disasm: <RubyVM::InstructionSequence:<compiled>@<compiled>>==========
# 0000 trace 1 ( 1)
# 0002 putobject_OP_INT2FIX_O_1_C_
# 0003 putobject 2
# 0005 putobject 3
# 0007 opt_mult <callinfo!mid:*, argc:1, ARGS_SIMPLE>
# 0009 opt_plus <callinfo!mid:+, argc:1, ARGS_SIMPLE>
# 0011 leave
# versus
(1 + 2) * 3
# => 9
puts RubyVM::InstructionSequence.compile("(1 + 2) * 3").disasm
# == disasm: <RubyVM::InstructionSequence:<compiled>@<compiled>>==========
# 0000 trace 1 ( 1)
# 0002 putobject_OP_INT2FIX_O_1_C_
# 0003 putobject 2
# 0005 opt_plus <callinfo!mid:+, argc:1, ARGS_SIMPLE>
# 0007 putobject 3
# 0009 opt_mult <callinfo!mid:*, argc:1, ARGS_SIMPLE>
# 0011 leave先ほどの例よりYARV命令の数は増えていますが、出力と実行の順序だけを見ても、括弧ひとつがもたらす違いがよく分かります。
1 + 2を括弧で囲むことで、数学における演算順序が変わり、加算が先に実行されることが保証されます。
なお、ディスアセンブリの出力自体に括弧は現れません。見えるのは、括弧が他のコードに与えた影響だけです。
ディスアセンブリ出力の読み方
ディスアセンブリの出力には、一見すると理解しにくい情報が多く含まれています。
出力される表形式では、各行はまず操作番号で始まり、続いて操作名、最後にその操作への引数が表示されます。
これまで登場した操作の一部をご紹介しましょう。
trace:トレースを開始します。詳細はTracePointのドキュメントを参照してください。putobject:オブジェクトをスタックに積みます。putobject_OP_INT2FIX_O_1_C_:整数1をスタックに積みます。最適化された操作です(0と1は最適化対象)。putstring:文字列をスタックに積みます。opt_plus:加算操作(内部で最適化されています)。opt_mult:乗算操作(内部で最適化されています)。leave:現在のコードコンテキストを抜けます。
Rubyインタプリタが、開発者にとって読みやすいRubyコードをどのようにYARV命令へ変換するのかが分かったところで、これをアプリケーションの最適化に活かせます。
RubyVM::InstructionSequenceには、メソッド単位はもちろん、ファイル全体を渡すことも可能です。
puts RubyVM::InstructionSequence.disasm(method(:foo))
puts RubyVM::InstructionSequence.compile_file("/tmp/hello.rb").disasmなぜあるコードは動いて別のコードは動かないのか、特定の記号によってコードの挙動が変わるのはなぜなのか――その理由を探ってみましょう。細部にこそ魔物が潜んでいます。自分のRubyコードがアプリ内でどのように振る舞っているのか、そして最適化の余地があるのかを知っておくことは非常に有益です。
さらなる最適化
インタプリタレベルでコードを観察し最適化できるだけでなく、InstructionSequenceを使えばコードをさらに最適化することもできます。
InstructionSequenceでは、Rubyに組み込まれたパフォーマンス最適化機能を使って、特定の命令を最適化できます。利用可能な最適化の一覧は、RubyVM::InstructionSequence.compile_option =メソッドのドキュメントで確認できます。
そのひとつが末尾呼び出し最適化(Tail Call Optimization)です。
RubyVM::InstructionSequence.compileメソッドにオプションを渡すことで、この最適化を有効にできます。
some_code = <<-EOS
def fact(n, acc=1)
return acc if n <= 1
fact(n-1, n*acc)
end
EOS
puts RubyVM::InstructionSequence.compile(some_code, nil, nil, nil, tailcall_optimization: true, trace_instruction: false).disasm
RubyVM::InstructionSequence.compile(some_code, nil, nil, nil, tailcall_optimization: true, trace_instruction: false).evalさらに、RubyVM::InstructionSequence.compile_option =を使えば、すべてのコードに対してこの最適化を有効にすることもできます。ただし、必ず他のコードより先に読み込んでください。
RubyVM::InstructionSequence.compile_option = {
tailcall_optimization: true,
trace_instruction: false
}Rubyにおける末尾呼び出し最適化の仕組みについて詳しくは、「Tail Call Optimization in Ruby」および「Tail Call Optimization in Ruby: Background」の各記事を参照してください。
まとめ
RubyVM::InstructionSequenceを使えば、Rubyがあなたのコードをどのように解釈しているのかを詳しく調べられます。コードが実際に何をしているのかを把握し、パフォーマンス向上につなげましょう。
今回のInstructionSequence入門は、Rubyの内部動作を楽しく学ぶきっかけにもなるはずです。もしかしたら、Ruby本体のコード開発に興味を持つかもしれませんよ。
Rubyにおけるコードコンパイルについての簡単な入門は以上です。この記事の感想や疑問点、次に読みたいテーマなどがあれば、ぜひ@AppSignalまでお知らせください。
-
Rubyメソッドをスパイする方法!TracePointで実行をトレースしよう
Rubyには、TracePointクラスを使ってアクセスできる組み込みのトレーシングシステムが備わっています。このシステムを利用すると、メソッド呼び出し、新しいスレッドの開始、例外の発生などをトレースできます。 なぜこれを使いたくなるのでしょうか? たとえば、特定のメソッドの実行過程を追跡したい場合に非常に便利です。その処理の中で他にどのメソッドが呼ばれているのか、どんな戻り値が返されているのかを、目で確認できるようになります。 それでは、いくつか具体例を見ていきましょう! メソッド呼び出しをトレースする 多くの場合、TracePointでトレースしたいのはアプリケーション側のコードであり、
-
Rubyプログラムのデバッグと修正方法を徹底解説|スタックトレースの読み方からPry・Byebugまで
書いたプログラムが、初回実行で思い通りに動くことってどれくらいありますか? 多くの場合、プログラムは期待どおりには動いてくれないもの。そんなときに頼りになるのがRubyのデバッグという技術です。原因を突き止めるための頼れる相棒といえるでしょう。 次のようなエラーメッセージを見たことはありませんか? undefined method some_method for nil:NilClass これは、nil値がコードの中に紛れ込んでしまったことを意味します。 本記事で紹介するテクニックを身につければ、この問題や似たようなトラブルにも自信を持って対処できるようになりますよ! エラーとスタックトレース