RubyKaigi 2024 おさらい

Large Hall の分だけ見る

The grand strategy of Ruby Parser
Parser in Ruby
CRuby's parser is LALR parser
入力されたスクリプトを受け取って後続のためにASTを作るのがパーサーの役割
去年までは Bison だが今は Lrama
parser generator で parser を生成 or 手書きで parser 作る
ruby は前者
BNF などを書いて parser を作るのが generator
Goals
- LSP などのツールに基盤提供
- ユニバーサルパーサーの提供
- Ruby の文法とパーサーがメンテナンス可能な状態を維持すること
solution: LR Parser と Parser Generator を使う
=> LR Parser を使いこなす
カバーできる言語の範囲が広く、メジャーなアルゴリズム
- LL parser
- LR より非力
- PEG
- エラートレラントなパーサーを作るのがむずい
Why LR parser generator is the best?
- 文法のデザインに対して適切に Feedback をくれる
- parser generator は既存文法との衝突をコンフリクトという形でレポートしてくれる
- 無限の組み合わせや省略可能な記法など人間が判別するのは無理
- 手書きパーサーはこの辺自力で対応しないといけない
- Ruby の文法は柔軟に変わりうるので parser generator でレポートしてくれると嬉しい
- parser generator は既存文法との衝突をコンフリクトという形でレポートしてくれる
BNF is very declarative
- parse.y
- BNF と C のコードが書かれていて、構文解析とそうでないロジックが分かれているので見通しが良い
- prism.c
- 構文解析の処理と Node 作る処理とかロジックが混じっているので見通しが悪い
Gap between grammer and parser
- parser generator
- parser follows grammer
- when bug exists, discuss about grammer
- 文法からパーサーを作ってるから、文法に関して議論すれば良い
- when bug exists, discuss about grammer
- parser follows grammer
- hand written parser
- grammer follows parser
- パーサーがパースできるものが文法になる、パーサー側の問題なのか、文法側の問題なのかが区別つかない
- when bug exists, discuss about grammer
- grammer follows parser
What is Lrama?
- Lrama is LR parser generator written by
Ruby
- Yacc や Bison の系譜を受け継いでる
- Ruby 3.3 から Lrama を使ってパーサーを作ってる
Why Lrama is needed?
- Bison は Ruby の文法扱うには機能が足りない
- Ruby をビルドする環境に依存した Bison を使っている
- 新しい機能を作ってもすぐ使えなかったり、環境依存があるのでテストが壊れたり
- => Bison の弱点を補うための Lrama
- Ruby をビルドする環境に依存した Bison を使っている
What's the challenge
- LSP
- parse.y for Undergraduate
- Universal Parser
LSP
- TypeProf や Rubocop などの Ruby コードを解析するツール群が扱いやすい AST の提供
- AST は内部的な構造なのでツールから使われることを想定していないので直感的でない
- 昔の CRuby は AST を直接トラバースして実行していたので、AST レベルでの最適化が施されていて、AST を取り出してくると元のテキストと構造が違うことがある
- Union を使っていたので Cast して使うのが難しかったり
- 入力中の文法的に正しくなくてもパースしてほしい => Error Tolerant Parser
- LSP とか考えると補完など
- CPCT+ アルゴリズム
- Syntax エラーになった時に何か他の構文をいくつか補うと、正しい文法になるような組み合わせ
parse.y for Undergraduate
parse.y が扱っている知識は広範で LR parser はその一部
=> parse.y を宣言的に記述し、表現力を高める
lex_state
Ruby の lexer には state があって13個くらいの bit
多くの場合、lexer 長いトークンを判定するようにチェック
a || b
a | | b
# 例外
obj.m do ||
end
=> 2つのバーで1個のトークンではなく、1つのバーが1個のトークン。最長マッチしてしまうとうまくいかない
lex_state が状態を見て | が2つなのに1こつず切り出すようなロジック
parser と lexer とのコミュニケーションなんだから
parser は do まで読んだのわかり、do の後には2本のバーのトークンが来ないの知ってる(グラマー知ってるから)から、これをうまく lexer に伝えてあげれば developer はやんなくて良くないか?
PRLR
parser と lexer を疎結合で組み合わせていた結果、複雑になってしまっている
Universal Parser
mruby とかの他の Ruby 実装から Ruby のパーサーを使いたい
Ruby のパーサー自体は Ruby の機能を使ってる、GC や String, Array など
こういったものへの依存を消さないと CRuby を丸々使わないといけなくなる
Ruby の AST 構造体をGCの管理から外す
C で実装したパーサーを各言語で呼び出す
本当のユニバーサルパーサーは文法定義からパーサーを生成することなんじゃないか?

Strings! Interpolation, Optimisation & Bugs
Variable Width Allocation
ヒープ内のサイズプールに、オブジェクトのサイズに基づいて割り当てられる
最小プールは40バイト、最大は640バイト
オブジェクトがサイズプールに割り当てられると embedded object と呼ばれる
gsub! などでオブジェクトのバイトサイズが変更され、元のサイズプールに収まらない場合、ヒープ外のメモリ領域を割り当て、元のオブジェクトからその領域へのポインタを作成する。このようなオブジェクトは、extended object と呼ばれる
objspace
irb(main):015> my_str
=> "Hello, Friends. How are we all today?"
irb(main):016> JSON.parse(ObjectSpace.dump(my_str))
=>
{"address" => "0x12c8ff890",
"type" => "STRING",
"shape_id" => 0,
"slot_size" => 80,
"class" => "0x1293fa460",
"embedded" => true,
"bytesize" => 37,
"value" => "Hello, Friends. How are we all today?",
"encoding" => "UTF-8",
"coderange" => "7bit",
"memsize" => 80,
"flags" => {"wb_protected" => true, "old" => true, "uncollectible" => true, "marked" => true}}
objspace
で Hello, Friends. How are we all today?
の文字列の stats を取ってみる
bytesize は37(資料では36)だが、Ruby のオブジェクトは16 bytes の metadata を持っており、Ruby 文字列はヌル終端文字列なので、それ用に 1 bytes を追加して 37 + 16 + 1 = 54 bytes になる
=> 40 bytes を超えるので次の 80 bytes の bucket にアロケートされる
ref. https://ja.wikipedia.org/wiki/ヌル終端文字列
string concatenation
文字列連結したオブジェクトの slot_size が 40 bytes だった
=> なぜ 80 bytes の bucket ではないのか?(手元で確認すると 80 bytes だった)バグだ
short_str = "hello"
# {"slot_size":40, "embedded":true, "bytesize":5,
"value":"hello"}
long_str = "Friends. How are we all today?"
# {"slot_size":80, "embedded":true, "bytesize":29,
"value":"Friends. How are we all today?"}
new_string = "#{short_str}, #{long_str}"
# {"slot_size":40, "bytesize":36,
"value":"hello, Friends. How are we all today?"}
$ ruby --dump=insns test.rb
dump=insns で VM の命令を dump できる
古い最適化のために最終的な文字列長が48 bytes 未満の場合、新しいメモリを割り当てるのではなく、最初の文字列を resurrect させて再利用しようとしてた
すでに short_str は 40 bytes の bucket に割り当てられているため、後続の文字列が追加されると40 bytes のbucketを超過して80 bytes のメモリが別の場所に割り当てられ、オブジェクトは40 bytes のextended オブジェクトとして作成されていた。本来は 80 bytes の embedded オブジェクトを期待してた
後で読む

Namespace, What and Why
Namespace
- ある空間の中でアプリケーションやライブラリを読み込む
- ある空間の中でアプリケーションやライブラリの変更を他の空間からは隠すこと
- ある空間の中で定義されたメソッドを外側から呼び出せること
- 隔離された空間の変更を使って呼び出せる
Before Namespace
Ruby の Process は一つの Global な空間しかない
App::Func
というモジュールを作ったりするとその影響は Ruby プロセス全体に影響する
=> 名前衝突が起きて動かなくなる
Namespace
この中でライブラリやアプリケーションの変更や名前は Namespace の中に限定される
Namespaceの外で同じ名前のアプリケーションやライブラリを定義できる
Namespace "on read"
-
on write
approach- このコードはこの Namespace で定義して書いていく
- RubyGems の全部の Gem に Namespace を書いて回るのは厳しい
- このコードはこの Namespace で定義して書いていく
-
on read
approach- ライブラリアプリケーションをロードするときにどういう Namespace でロードするのか指定する
- Namespace の利点を活かしながらすでにあるライブラリを使える
How
- class や module を namespace 配下に定義
- namespace は module のサブクラス
-
Kernel#load
を使って Ruby スクリプトを Namespace の下にロード
-
- class_alloc(in class.c)
- 現在のコンテキストが Namespace の中にあれば、Namespace の下でクラスを作成してアロケーションする
- namespace は module のサブクラス
- ローカルで拡張ライブラリを load
- Linux などで使用される dlopen システムコールは、同じファイルの 2 回目以降のロードを無視する
- ただ、同じライブラリの異なるバージョンや、同じライブラリそのものを異なるネームスペース内で複数回読み込む必要がある
- 拡張ライブラリを一時ディレクトリにコピーする
- ファイル名にはプロセスIDとNamespaceのオブジェクトIDをprefixとして付与することで、異なるNamespaceからの読み込みであることを区別
- これによって dlopen が二回目以降のロードを無視するのを回避できる
- 違うファイル名として認識されるため、
- 同じネイティブ拡張ライブラリを複数回ロードするとシンボルが衝突する
- dlopen の際に RTLD_LOCAL フラグを使用して拡張ライブラリ間での C のシンボルの衝突回避
- 拡張ライブラリを一時ディレクトリにコピーする
- クラス/モジュールの定義をNamespaceごとに保持
- モンキーパッチの影響を Namespace 内部にとどめたい
- 組み込みクラス、String、Object、Kernelなど
- Object クラスに blank? というメソッドを定義したとして、Namespace 外に出たら定義されていない状態にしたい
-
rb_classext_t
: クラスやモジュールの定義- これを複数持ってしまえば良い
- Namespace ごとにコピーしてもつ
- クラスが持っているメソッドや継承チェーンが切り替わる
- Namespace ごとに CoW でメソッド定義などを行える
- 組み込みクラス、String、Object、Kernelなど
- モンキーパッチの影響を Namespace 内部にとどめたい

Vernier: A next generation profiler for CRuby
Problems
Pure Ruby で実装された Profiler は GVL の影響を受ける
例えばループの処理で busy の時、Pure Ruby で実装された Profiler は GVL を取得できずに正しいサンプルを計測できない可能性がある
Thread GVL states
Vernier では、GVL の state を3つの状態で記録する
- Running
- GVL を取得して Ruby プログラムを実行している状態
- Stalled
- GVL の取得待ち
- Suspended
- I/O や network などの処理のために GVL を開放している状態
特に Suspended の状態は back trace に記録するので、I/O処理などがプログラムのどの部分で発生しているかを把握できる
Ruby 3.2 から入った GVL instrumentation API を利用して
ref. https://bugs.ruby-lang.org/issues/18339
Stackprof
UNIX Signal をベースにプロファイルしている
ただ、signal safe ではなく、メモリ確保などができない
VM が安全な状態(メモリ確保やコード実行などできる safe point)になったら、postpone job API を利用してプロファイルを実行
具体的には、RUBY_VM_CHECK_INTS
を呼び出して safe point に到達したら、rb_profile_frames
をを呼び出して現在のスタックを取得し、サンプルを保存
Wrong sampling
def empty_method
end
def slow_method
x = "h" + "e" + "l" + "l" + "o" + ","
x += "w" + "o" + "r" + "l" + "d"
empty_method
end
1000.times do
slow_method
end
この実装では empty_method と slow_method の終わりが safe point
empty_method の方に時間を使ってると report が返る
=> slow_method の後に empty_method が実行されているので、slow_method の処理に引っ張られている
Prevent YJIT optimization
大きなサイズのループでは postpone job が YJIT の最適化を妨げ、プログラムの実行速度を低下させる可能性
def test(n)
while n > 0
n -=1
end
end
test(100_000_000)
Missed sample
C 拡張の関数が長い時間を要して呼び出されてる場合、safe point に到達するまで時間かかり、その間に発生した signal による sample が欠落する可能性がある
Stackprof はバッファに一つの profile しか保持しないため、このような課題が顕著になる
Solutions
- 専用の C スレッドを用意
- C スレッドから Ruby スレッドに signal を送信
-
rb_profile_frames
を実行してスタックを取得して保存
=> Cスレッドなので GVL の制約を受けず、サンプルの欠落を防げるのでより正確に
=> メインスレッドでサンプルの保存処理をしないのでオーバーヘッドも少ない
メインスレッドだけではなく Ractor もプロファイリングできる

Ractor Enhancements, 2024
“Ractor” is
- マルチコア上で並行プログラミングを可能に
- オブジェクトの共有を制限しているため堅牢
オブジェクトの共有制限
sharable
- Immutable Objects
- Class/Module
- Ractor Objects
Unshareable
- Most of objects
Child Ractor から Constants
や Global variables
はアクセスできないよう
Issues
Child Ractor 上で require
や timeout
などが利用できない
-
require
利用時に$LOAD_PATH
などの global state にアクセスすること -
require
でロードされたコードは unshareable objects として定義される
Ractor は、オブジェクトの共有を制限することで、並行処理における安全性を高めているため、Child Ractor 上から上記は実現できない
=> Child Ractor で pp
等ができない
Solutions
Ractor#interrupt_exec{ expr }
expr は Ractor のメインスレッド上において非同期で扱われる
ex. Ractor.main.interrupt_exec{ $g=1 }
Ractor.main => Ractor の self を返す
interrupt_exec => Ractor のメインスレッドで $g に1をセット