Zenn
Open6

RubyKaigi 2024 おさらい

dak2dak2

The grand strategy of Ruby Parser

https://rubykaigi.org/2024/

Parser in Ruby

CRuby's parser is LALR parser

https://ja.wikipedia.org/wiki/LALR法

入力されたスクリプトを受け取って後続のためにASTを作るのがパーサーの役割

去年までは Bison だが今は Lrama
https://gihyo.jp/article/2024/01/ruby3.3-lrama#ghbzLfIiOP

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 でレポートしてくれると嬉しい

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
        • 文法からパーサーを作ってるから、文法に関して議論すれば良い
  • hand written parser
    • grammer follows parser
      • パーサーがパースできるものが文法になる、パーサー側の問題なのか、文法側の問題なのかが区別つかない
      • when bug exists, discuss about grammer

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

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 で実装したパーサーを各言語で呼び出す
本当のユニバーサルパーサーは文法定義からパーサーを生成することなんじゃないか?

dak2dak2

Strings! Interpolation, Optimisation & Bugs

https://rubykaigi.org/2024/presentations/eightbitraptor.html#day1

Variable Width Allocation

ヒープ内のサイズプールに、オブジェクトのサイズに基づいて割り当てられる
最小プールは40バイト、最大は640バイト
オブジェクトがサイズプールに割り当てられると embedded object と呼ばれる
gsub! などでオブジェクトのバイトサイズが変更され、元のサイズプールに収まらない場合、ヒープ外のメモリ領域を割り当て、元のオブジェクトからその領域へのポインタを作成する。このようなオブジェクトは、extended object と呼ばれる
https://rubykaigi.org/2021-takeout/presentations/peterzhu2118.html

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}}

objspaceHello, 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 オブジェクトを期待してた

https://github.com/ruby/ruby/pull/6965

後で読む

https://kddnewton.com/2022/11/30/advent-of-yarv-part-0.html
https://joker1007.github.io/slides/toyama01/slides.html#1
https://qiita.com/south37/items/0eb05ebf31ba6cbf53c4
https://docs.ruby-lang.org/en/master/ruby/option_dump_md.html

dak2dak2

Namespace, What and Why

https://rubykaigi.org/2024/presentations/tagomoris.html#day1

Namespace

  • ある空間の中でアプリケーションやライブラリを読み込む
  • ある空間の中でアプリケーションやライブラリの変更を他の空間からは隠すこと
  • ある空間の中で定義されたメソッドを外側から呼び出せること
    • 隔離された空間の変更を使って呼び出せる

Before Namespace

Ruby の Process は一つの Global な空間しかない
App::Func というモジュールを作ったりするとその影響は Ruby プロセス全体に影響する
=> 名前衝突が起きて動かなくなる

Namespace

この中でライブラリやアプリケーションの変更や名前は Namespace の中に限定される
Namespaceの外で同じ名前のアプリケーションやライブラリを定義できる

Namespace "on read"

  • on write approach
    • このコードはこの Namespace で定義して書いていく
      • RubyGems の全部の Gem に Namespace を書いて回るのは厳しい
  • on read approach
    • ライブラリアプリケーションをロードするときにどういう Namespace でロードするのか指定する
    • Namespace の利点を活かしながらすでにあるライブラリを使える

How

  • class や module を namespace 配下に定義
    • namespace は module のサブクラス
      • Kernel#load を使って Ruby スクリプトを Namespace の下にロード
    • class_alloc(in class.c)
      • 現在のコンテキストが Namespace の中にあれば、Namespace の下でクラスを作成してアロケーションする
  • ローカルで拡張ライブラリを 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 でメソッド定義などを行える
dak2dak2

Vernier: A next generation profiler for CRuby

https://rubykaigi.org/2024/presentations/jhawthorn.html#day1

https://github.com/jhawthorn/vernier

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

https://github.com/tmm1/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 もプロファイリングできる

dak2dak2

Ractor Enhancements, 2024

https://rubykaigi.org/2024/presentations/ko1.html#day1

“Ractor” is

  • マルチコア上で並行プログラミングを可能に
  • オブジェクトの共有を制限しているため堅牢

オブジェクトの共有制限

sharable

  • Immutable Objects
  • Class/Module
  • Ractor Objects

Unshareable

  • Most of objects

Child Ractor から ConstantsGlobal variables はアクセスできないよう

Issues

Child Ractor 上で requiretimeout などが利用できない

  • 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をセット

ログインするとコメントできます