Slide 1

Slide 1 text

Witchcraft for Memory 関西Ruby会議08 2025-06-28

Slide 2

Slide 2 text

pp self • Masataka Pocke Kuwabara • 株式会社マネーフォワード所属 • Ruby / RBS コミッター • Rails アプリケーションエンジニア • 岡山在住 / 大阪, 京都勤務 • 最近の趣味: ピアノ, ランニング, AIにC#を書か せること

Slide 3

Slide 3 text

RBS / Steep とは

Slide 4

Slide 4 text

RBS / Steep とは • RBS • Rubyの静的型のための言語、ライブラリ • Steep • RBSを使った静的型検査ツール • CLIコマンド、LSPサーバーなどとして動作

Slide 5

Slide 5 text

RBS を使われるようにするために • rbs collection • Community-driven RBS repository - RubyKaigi 2024 • https://github.com/ruby/gem_rbs_collection • rbs subtract • Let's write RBS! - RubyKaigi 2023

Slide 6

Slide 6 text

テーマ: RBS のメモリ削減について

Slide 7

Slide 7 text

メモリ使用量が増加する背景 SteepのLSP Serverでメモリ使用量が多い • SteepのLSP Serverはマルチプロセス構成になっている • 管理用プロセス1台、ワーカープロセス * コア数 • コア数分だけ使用メモリも増えていく • LSP Serverはエディタに対して常駐する • 複数プロジェクトを開くと、その数だけLSP Serverも起動する

Slide 8

Slide 8 text

実際のメモリ使用量 中規模Railsアプリで、1 workerプロセスあたり1.5GBほど。 例えば8コア、5プロジェクトだと60GBほどのメモリを消費してしまう

Slide 9

Slide 9 text

今日のtopic • Majo • A memory profiler focusing on long-lived objects • Refork feature for Steep • A memory-efficient process management

Slide 10

Slide 10 text

Majo A memory profiler focusing on long-lived objects

Slide 11

Slide 11 text

memory_profiler gem Rubyには memory_profiler gemがある。 https://github.com/SamSaffron/memory_profiler

Slide 12

Slide 12 text

memory_profiler gem とは • Rubyのメモリ使用をプロファイルするためのgem • 集計対象 • プロファイル中に生成されたすべてのオブジェクト(allocated objects) • プロファイル中に生成され、プロファイル終了時に残っているオブジェクト (retained objects) • 出力内容 • ファイル、行、クラスごとなどで集計 • メモリ使用量、オブジェクト数などを集計

Slide 13

Slide 13 text

memory_profiler gemの出力例 Total allocated: 29742818 bytes (281359 objects) Total retained: 9905 bytes (188 objects) allocated memory by gem ----------------------------------- 27874713 rbs-3.8.0 1763800 pathname 86296 set (snip) allocated memory by file ----------------------------------- 15759032 /path/to/gems/rbs-3.8.0/lib/rbs/parser_aux.rb 3139712 /path/to/gems/rbs-3.8.0/lib/rbs/types.rb 2120553 /path/to/gems/rbs-3.8.0/lib/rbs/environment_loader.rb 1984256 /path/to/gems/rbs-3.8.0/lib/rbs/environment.rb (snip) allocated memory by location ----------------------------------- 15755712 /path/to/gems/rbs-3.8.0/lib/rbs/parser_aux.rb:20 2099025 /path/to/gems/rbs-3.8.0/lib/rbs/environment_loader.rb:164 855200 /path/to/gems/rbs-3.8.0/lib/rbs/types.rb:374 780960 /path/to/gems/rbs-3.8.0/lib/rbs/types.rb:1001 (snip)

Slide 14

Slide 14 text

memory_profiler gemの問題点 steep check コマンドのピークのメモリ使用量を知りたい、というケースに合わない

Slide 15

Slide 15 text

memory_profiler gemの問題点 memory_profilerが集計するもの • プロファイル中に生成されたすべてのオブジェクト • Pros: オブジェクト生成が速度のボトルネックになっているようなコードを探すのに 便利 • Cons: ピークのメモリを知るにはノイズが多すぎる • プロファイル中に生成され、プロファイル終了時に残っているオブジェクト • Pros: メモリリークなどを探すのに便利 • Cons: メモリ使用量がピークのときにプロファイルを止める必要があるが、コード中 のどこがピークかは自明ではない (今回のケースに合わなかっただけで、memory_profiler gemはめちゃくちゃ便利で愛 用しています)

Slide 16

Slide 16 text

Majo https://github.com/pocke/majo • プロファイル中に生成されたすべてのオブジェクトの内、長生きしたオブジェクトの みを集める • 長生きしているオブジェクト ≒ 長い期間メモリを占有しているオブジェクト • 長生きしているかの判定は、GCを生き残った回数で判定 • デフォルトは1回でもGCを生き残ったオブジェクトが対象

Slide 17

Slide 17 text

使い方 プロファイルしたいコードを Majo.run ブロックに渡すだけ result = Majo.run do code_to_profile end # 標準出力にメモリ使用量のレポートを出力 result.report

Slide 18

Slide 18 text

出力例 memory_profiler gem と同様の出力をする Total 15055372 bytes (159976 objects) Memory by file ----------------------------------- 10431760 /path/to/gems/rbs-3.5.1/lib/rbs/parser_aux.rb 1966348 /path/to/gems/rbs-3.5.1/lib/rbs/environment_loader.rb 980344 /path/to/gems/rbs-3.5.1/lib/rbs/types.rb (snip) Memory by location ----------------------------------- 10431760 /path/to/gems/rbs-3.5.1/lib/rbs/parser_aux.rb:20 1942812 /path/to/gems/rbs-3.5.1/lib/rbs/environment_loader.rb:159 249920 /path/to/gems/rbs-3.5.1/lib/rbs/types.rb:994 (snip)

Slide 19

Slide 19 text

CSV Format テキスト形式の出力とは別に、CSV形式での出力もサポート • 集計前のデータなので、柔軟な集計が可能 • 特定のメソッドで生成されたオブジェクトはどのクラスが多いのか • オブジェクトはどれくらいの期間生存していたのか https://docs.google.com/spreadsheets/d/1TnlnLXQTnuDfB3Bhw0sNp9y2iZObqp kKVeqE--eAdlk/edit?gid=331894152#gid=331894152

Slide 20

Slide 20 text

CSVの生データ

Slide 21

Slide 21 text

生成箇所でgroup byして、メモリ使用量でソート

Slide 22

Slide 22 text

特定のメソッドでフィルタ

Slide 23

Slide 23 text

Majo の実装 • オブジェクトの生成、解放にフック • 生成時にオブジェクトの生成情報を記録 • 生成されたファイル、行、メソッド、オブジェクトのクラスなど • rb_gc_count() も記録 • 解放時にそのオブジェクトが指定回数以上GCを生き残っていれば、配列に記録 • 生成時、解放時の rb_gc_count() を比較 • ObjectSpace.memsize_of でメモリサイズを取得、保存

Slide 24

Slide 24 text

実装の詳細 - オブジェクトの生成、解放にフック Cレベルの TracePoint APIを使って、オブジェクトの生成、解放にフックする • RUBY_INTERNAL_EVENT_NEWOBJ • RUBY_INTERNAL_EVENT_FREEOBJ arg->newobj_trace = rb_tracepoint_new(0, RUBY_INTERNAL_EVENT_NEWOBJ, newobj_i, (void *)res); arg->freeobj_trace = rb_tracepoint_new(0, RUBY_INTERNAL_EVENT_FREEOBJ, freeobj_i, arg);

Slide 25

Slide 25 text

RUBY_INTERNAL_EVENT_FREEOBJ GCが走っているとき、GCを走らせてはいけないのが大変 • オブジェクトを生成してはいけない • 配列を伸ばしてはいけない このハンドラでは、Rubyのオブジェクトと関係ないメモリを使って、オブジェクトの 生成情報を記録している • Cの構造体で生成情報を記録 • Cレベルの配列に結果を記録 • 最後に結果を表示する段階になって、初めてRubyのオブジェクトとして扱う

Slide 26

Slide 26 text

Majo を使った効果 空のHash, Arrayが大量に重複していることがわかったので、重複を排除 • https://github.com/ruby/rbs/pull/1950 Arrayの重複排除 • 約8%のメモリ使用量削減 • https://github.com/ruby/rbs/pull/2308 Hashの重複排除 • 約13%のメモリ使用量削減 Hashに関しては面白い問題にも遭遇しました 不要な処理が実行速度を速くする謎を追う - Money Forward Developers Blog

Slide 27

Slide 27 text

Majo - Summary • 長生きするオブジェクトに注目するメモリプロファイラを実装した • TracePointのC APIを使っている • 実際にRBSのメモリ使用量を削減することができた

Slide 28

Slide 28 text

Refork feature for Steep A memory-efficient process management

Slide 29

Slide 29 text

前提 • Majoを使ってある程度メモリ使用量を削減できた • とはいえまだ十分とは言えない • プロファイル結果を見る限り、大きくメモリ使用を削減できるオブジェクト生成が見 当たらない 個々のオブジェクトの削減ではなく、メモリの管理方法の改善に目を向けた

Slide 30

Slide 30 text

Steep のプロセス構成 管理プロセスが複数のworkerプロセスを管理する LSP Client (editor) Steep Master Steep Worker (1) Steep Worker (2) Steep Worker (3) Spawn fork fork fork

Slide 31

Slide 31 text

プロセス構成の問題点 • それぞれのworkerプロセスは別々のメモリを使用する • Workerプロセスの数だけメモリが乗算で増えてしまう

Slide 32

Slide 32 text

fork • *nix系OSで、プロセスを立ち上げる方法 • 自身のプロセスの複製を作成する worker_pids = [] Etc.nprocessors.times do # コア数だけループを回す worker_pids << fork do run_worker! # fork のブロックの中は、子プロセスで実行される end end # Handle workers

Slide 33

Slide 33 text

Copy on Write (CoW) • fork時にはメモリはすぐにコピーされず、同じメモリを参照する • 変更があったときに、初めて変更された部分だけコピーされる

Slide 34

Slide 34 text

Copy on Writeの例 x = [1, 2, 3] fork do # 子プロセスをfork p x # この時点では x は変更されていないので、親も子も同じメモリを参照 x << 4 # x が変更されたので、子プロセス用に新しいメモリが割り当てられる end CoWをうまく利用すれば、全体でのメモリ使用量を減らせるはず

Slide 35

Slide 35 text

SteepでのCoWの問題点 SteepでCoWをうまく利用できていなかった • すべてのworkerプロセスは、管理プロセスからforkされる • つまり、workerプロセスは管理プロセスとメモリを共有できる • 管理プロセスはコードの解析を行わない • 一方メモリの消費はコードの解析部分が多い • つまり、管理プロセスとworkerプロセス間で共有できるメモリはわずか

Slide 36

Slide 36 text

Refork workerプロセスから更にworkerプロセスをforkする Steep Master Steep Worker (1) Steep Worker (2) Steep Worker (3) fork fork fork 親workerプロセスと、その子workerプロセス間で同じメモリを共有できる HTTPサーバーであるPumaで実装されているテクニック

Slide 37

Slide 37 text

Refork - Steepの実装 • まず管理プロセスからすべてのworkerプロセスをforkする • 型検査を1度実行する • workerプロセスのメモリが暖気される • その後、1つのworkerプロセスから更にworkerプロセスをforkし、古いworkerプロ セスを終了する

Slide 38

Slide 38 text

プロセス間通信 今回の実装で難しかったポイントの1つ • Steepは管理プロセスとworkerプロセス間の通信に、 IO.pipe を使っている • IO.pipe はIOをforkによって親から子へ共有する • 孫プロセスとの通信に難しさがある

Slide 39

Slide 39 text

IO.pipe IO.pipe はIOをforkによって親から子へ共有する io_read, io_write = IO.pipe # pipe となるIO を生成 fork do # 子プロセスをfork # 子プロセスでもio_read 変数からIO を参照できる io_write.close io_read.each_line do |line| # IO からの入力を読み込む puts "Received: #{line}" end end io_read.close io_write.puts 'Hello, World!' # IO に書き込む つまり管理プロセスから孫workerプロセスにIOを渡すには、子workerをforkすると きに孫worker用の IO.pipe をあらかじめ作成しておく必要がある

Slide 40

Slide 40 text

解決法: UNIXSocket.pair • socketpair システムコールを呼び出すメソッド • IO.pipe と同様に、2つのIOオブジェクトを返す • Pipeと違い、ファイルディスクリプタ(つまりIO)を送受信できる • UNIXSocket#send_io , UNIXSocket#recv_io

Slide 41

Slide 41 text

FDの送受信 require 'socket' sock_parent, sock_child = UNIXSocket.pair # UNIX ドメインソケットを生成 fork do # 子プロセスをfork sock_parent.close r = sock_child.recv_io # パイプを受信 while line = r.readline # IO からの入力を読み込む puts "Child: #{line}" end end r, w = IO.pipe # 管理プロセスでパイプを生成 sock_parent.send_io(r) # パイプを子プロセスへ送信 r.close w.puts "Hello, world!" # IO に書き込む

Slide 42

Slide 42 text

SteepのSocket.pairの使い方 Steep Master Steep Worker (1) Steep Worker (2) Steep Worker (3) IO.pipe IO.pipe I IO.pipe Socket.pair

Slide 43

Slide 43 text

SteepのSocket.pairの使い方 Steep Master Steep Worker (1) Steep Worker (2) Steep Worker (3) IO.pipe IO.pipe I IO.pipe Socket.pair IO Steep Worker (2') Steep Worker (3') Killed Killed Fork with IO Fork with IO

Slide 44

Slide 44 text

Alternatives 通信のためにファイルシステムを利用する • mkfifo やUNIXドメインソケットは、ファイルシステム上にファイルを作成する • 例: /tmp/steep-worker-#{i}.sock を通じて通信 • Pros: • あらかじめ名前付けルールを決めておけば、FDをやり取りしなくても通信できる • Cons: • ファイルシステム上のファイルの権限管理に気を配る必要がある • プロセス終了時のファイル削除などの管理が必要

Slide 45

Slide 45 text

Other key points of Refork • 孫プロセスの終了を待つ方法 • Process.wait は直接の子プロセスしか待てない • workerプロセスが孫workerプロセスの管理をするように • Process.warmup • Process.warmup を呼ぶと、GCの実行、メモリのコンパクション、カーネルへのメ モリの返却などが行われる • forkする直前に呼ぶと不要なメモリの共有を抑えられる

Slide 46

Slide 46 text

Summary - Refork feature for Steep • forkの方法を変えることで、Copy on Writeをうまく利用してメモリ使用量を削減 • 10コアのマシンで、55%、4.1GBほどのメモリ使用量を削減

Slide 47

Slide 47 text

今回の実装で得たもの

Slide 48

Slide 48 text

Back to Matsuyama 今回の実装を通じて、RubyKaigiをもっと楽しめた • Performance Bugs and Low-level Ruby Observability APIs by Ivo • TracePointのC API • Postponed Job API • Bringing Linux pidfd to Ruby by Maciej • 孫プロセスのPID管理

Slide 49

Slide 49 text

No content

Slide 50

Slide 50 text

今後 • rbs collection • 趣味プロジェクトとか

Slide 51

Slide 51 text

本屋 いくつか私が推薦した本があります(全部入荷しているそうです!) • 誰のためのデザイン? • 自分の中のデザインについての考え方の基礎に なっている本です。 • @soutaro さんにおすすめされた記憶 • Linuxプログラミングインターフェース • Steepのrefork機能の実装で、めちゃくちゃ参考 にしました。 • シグナル、プロセス管理について • 20, 21, 22章 シグナル:基礎, シグナルハンドラ, 応用 • 26章 子プロセスの監視 • プロセス間通信について • 43章 プロセス間通信:概要, 44章 パイプとFIFO, 57章 ソケット:UNIXドメイン • 論理学 • 10年近く前に読んだのですが、とても面白く読 み進めた記憶があります。 • 私と同い年の本(16日違い) • CODE コードから見たコンピュータのから くり 第2版 • 論理回路のレベルからコンピュータの構成要素 をボトムアップに知れてとても良い本でした。 • 私が読んだのは第1版ですが、第2版も良い本と 聞いています。