Techouse Developers Blog

テックハウス開発者ブログ|マルチプロダクト型スタートアップ|エンジニアによる技術情報を発信|SaaS、求人プラットフォーム、DX推進

RubyKaigi 2026 - Building the Next-Generation Garbage Collector in Ruby

ogp

こんにちは、株式会社 Techouse のジョブハウスでエンジニアをしている ReLU と申します。
本記事では、RubyKaigi 2026 の 2 日目に行われた Peter Zhu(@peterzhu2118)さんのセッション Building the Next-Generation Garbage Collector in Ruby を紹介します。

セッションの紹介へ入る前に、前提知識として Ruby のデフォルトの GC がどのような仕組みで動いているのか説明していきます。
ここを理解しておくことで、Peter さんがなぜ去年や今年のセッションのような GC の改善をしているのかが理解できるようになります。

Ruby のデフォルト GC

まずは Ruby のデフォルト GC がどのような仕組みなのかをおさらいしておきます。
セッションでも冒頭でこの部分を丁寧に説明されていました。

Mark-Sweep-Compact アルゴリズム

Ruby のデフォルト GC は Mark-Sweep-Compact と呼ばれるアルゴリズムを採用しており、次の 3 つのフェーズで構成されます。

  • Mark フェーズ
    • ルート(グローバル変数や定数など)から参照をたどれるオブジェクトをマークして生存状態を判定する
    • マークは「白(未マーク)→ グレー(マーク済み・未走査)→ ブラック(走査済み)」の 3 色で塗り分ける三色マーキングアルゴリズム
    • 白のまま残ったオブジェクトが死んでいると判定される
  • Sweep フェーズ
    • 白のまま残ったオブジェクト(死んでいるオブジェクト)を回収し、空きスロットとして再利用可能にする
  • Compaction フェーズ
    • 生きているオブジェクトを片側に寄せ、フラグメンテーションを解消する
    • フラグメンテーションとは、割り当てと回収を繰り返した結果、生きたオブジェクトの間に空きスロットがまばらに残ってしまう状態のこと
    • コストが高いためデフォルトではオフになっており、有効にすると実行される

Mark-Sweep-Compact のイメージは、Peter さんのセッションスライドの p.7 あたりからとてもわかりやすい図で説明されているので、そちらも合わせてご参照ください。

オブジェクト割り当て

オブジェクトの割り当て方式は、Ruby のデフォルト GC では フリーリスト方式のアロケータを採用しています。
具体的には、各空きスロットの中に「次の空きスロットの場所」を指すポインタを書き込んでおき、空きスロット同士を 1 本の連結リストにします。
新しいオブジェクトを割り当てるときは、リストの先頭の空きスロットにオブジェクトを書き込み、リストの先頭をその次のスロットへ進める、という流れで動きます。

シンプルですが、この「リストを進めるためにオブジェクトのメモリを読まなければならない」という点が、後ほど登場するバンプアロケータとの比較で重要なポイントとなります。

デフォルト GC の制約

ここまで紹介した仕組みは Ruby を長年支えてきた優れた設計ですが、現代的な GC の視点から見ると、いくつかの制約もあります。

アルゴリズムが固定されている

Ruby のデフォルト GC は Mark-Sweep-Compact 一択で、アプリケーションのワークロードに応じて別のアルゴリズム(Immix、LXR など)に差し替えることができません。
GC の研究は世界中で活発に進んでいるにもかかわらず、その成果を Ruby に取り込もうとすると gc.c を直接書き換える必要があり、ハードルが非常に高い状態です。

シングルスレッドでしか動作できない

現代のマシンはマルチコア構成が一般的ですが、デフォルト GC はシングルスレッドで動作するため、追加のコアを GC のために活用できません。
セッションで示された 16 コア環境のプロファイルでも、GC で動いているのは 1 コアだけで、残り 15 コアはアイドル状態のままでした。

オブジェクトサイズが限定される

Ruby のデフォルト GC は技術的な制約から、オブジェクトを 40 の倍数(40・80・160・320・640 バイト)の固定サイズスロットでしか割り当てられません。
このスロットサイズ制約自体、Ruby 3.2 の Variable Width Allocation で大きく改善された箇所ではあります。
それでもなお「40 バイト × 2 のべき乗(40・80・160・320・640)」という離散的なスロットサイズに縛られている状態です。

この固定サイズスロット制約には、次のような問題があります。

  • スロットにピッタリ収まらないオブジェクトはメモリを無駄にする
  • 巨大な配列や文字列の可変長バッファは malloc で別途確保され、GC ヒープ外(off-heap)となるためパフォーマンスに影響する

GC を差し替えられるようにしたい

これらの制約は Ruby のデフォルト GC がダメというわけではなく、30 年近く Mark-Sweep をベースに堅実に進化してきた結果だと考えています。
ただ、ここから先の改善(アルゴリズム選択・並列化・可変サイズ)を一気に取り込むには、gc.c を直接書き換えるのではなく GC のアルゴリズムや実装そのものを差し替えられる仕組みが必要になります。

これが、去年 Peter さんが発表された Modular GC へとつながっていきます。

Modular GC

ここからは去年の RubyKaigi 2025 で発表された Modular GC のおさらいです。

Ruby の GC は伝統的に gc.c という巨大ファイルに密結合で実装されていました。
Ruby 3.4 で Feature #20470 として導入された Modular GC は、GC のロジックを gc/gc_impl.h に定義された API 関数群の裏側に隠すフレームワークです。
これにより、実行時に GC 実装を差し替え可能になります。

Modular GC によって何が変わったのか整理すると次のようになります。

観点 Before(〜 Ruby 3.3) After(Ruby 3.4〜)
GC 実装の置き場所 すべて gc.c に直接実装されている gc.c は VM と GC 実装を仲介する薄いレイヤーになり、実装は gc/default/default.c に分離
ビルド形態 Ruby 本体に組み込まれて 1 バイナリ デフォルト GC を共有ライブラリとして別ビルドできる
VM と GC の連携 直接呼び出し gc.c で定義された API を介して呼び出す

MMTk(Memory Management Toolkit)

Modular GC が整備された一番の動機が、この MMTk(Memory Management Toolkit) を Ruby で動かせる形にすることです。

MMTk は、オーストラリア国立大学(ANU)が中心となって Rust で開発している言語非依存の GC フレームワークです。OpenJDK、Julia、V8、そして Ruby など、すでに複数の言語ランタイムで採用・検証されています。

セッションの中で MMTk が持つ利点を 3 つ挙げられていました。

  1. 複数の GC アルゴリズムから選べる
  2. 並列 GC ワーカーに対応している
  3. 可変サイズのオブジェクト割り当てが可能

ただし、MMTk が提供するすべてのアルゴリズムを Ruby で使えるわけではない、という制約もあります。
これは Ruby VM が GC に対して pinning(ピン留め) をサポートすることを要求しているためです。
pinning とは、GC に対して「このオブジェクトは絶対に動かさないでほしい」と印をつけておく機能のことです。

Ruby で pinning が必要になる代表例が C 拡張です。Nokogiri などのネイティブ拡張は、内部で Ruby オブジェクトの生のアドレス(ポインタ)を直接保持していることがあります。
もし GC が Compaction などでオブジェクトを別のアドレスに移動させてしまうと、C 拡張側で保持していたポインタの指し先がズレてしまいます。
最悪ケースでは Segmentation Fault が発生して Ruby プロセスごとクラッシュする可能性があります。
そのため Ruby VM は GC に対して「ネイティブ側から参照されているオブジェクトは移動してはいけない」と要求します。

ここで問題になるのが、MMTk のアルゴリズムの中にはすべてのオブジェクトを必ず移動させる前提で設計されているものも含まれていることです。
こうしたアルゴリズムは pinning と両立できないため、Ruby では採用できません。セッションで紹介されていた SemiSpace アルゴリズムはその一例です。

ここまでで「MMTk とは何か」「Ruby で使うときにはどんな制約があるか」までは整理できました。

RubyKaigi 2025

去年のセッション「Modular Garbage Collectors in Ruby」は、MMTk が Ruby 上で動き始めたが速度はデフォルト GC の半分程度にとどまる、という中間報告でした。
具体的には、次のような内容が語られていました。

  • Modular GC API(gc/gc_impl.h)の設計と CRuby への組み込み
  • RUBY_GC_LIBRARY=mmtk を指定することで MMTk を有効化できるビルドフロー
  • Ruby で動く MMTk のアルゴリズムは Mark-Sweep と Non-moving Immix までで、オブジェクトの移動はまだ未対応
  • 今後のロードマップとして「Moving への対応」「世代別 GC への対応」「並列性の改善」が宣言されていた

つまり、Modular GC という土台は完成し、MMTk が Non-moving のアルゴリズムまで動くものの、性能・機能の両面でまだ改善余地があるところで去年の発表は終わりました。

MMTk を速くする

ここからが今年のセッションの本題です。
昨年の MMTk はデフォルト GC の約 2 倍遅かったですが、今年はほぼ同等のところまで追いついた、という宣言からセッションは始まっています。

本記事では、その核となる Immix アルゴリズムと今年加えた大きな改善、そして今後のロードマップに焦点を当てて紹介します。

Immix とは

Immix は 2008 年に Stephen Blackburn と Kathryn McKinley が提案した mark-region 型 の GC です(原論文)。
このあと紹介する Moving Immix やバンプアロケータ、Sticky Immix の話はいずれも Immix の内部構造を前提にした話なので、先に Immix の仕組みを見ていきます。

既存アルゴリズム

Immix は、生きたオブジェクトをどの程度移動させるかという観点から整理すると把握しやすくなります。
本記事で紹介してきたアルゴリズムと並べてみると、Immix は移動量の点で中間に位置するアルゴリズムです。

  • Mark-Sweep(フリーリスト方式)
    • オブジェクトを一切動かさない方式
    • GC は安く済むがフラグメンテーションを解消できない
  • Mark-Sweep-Compact / Copying
    • GC のたびに生きているオブジェクトを別の場所に移す方式
    • Ruby のデフォルト GC の Compaction フェーズや MMTk の SemiSpace など
    • フラグメンテーションは毎回解消できるが、生きたオブジェクトを 1 つ残らずコピーするので GC コストが高い
  • Immix
    • ふだんはオブジェクトを動かさず、フラグメンテーションが進んだ Block のオブジェクトだけ別の Block に退避する方式
    • 動かす対象を最小限に絞ることで、回収コストを抑えながらフラグメンテーションも継続的に解消できる

整理すると次のようになります。

アルゴリズム GC 時のコピー量 フラグメンテーション 新規割り当ての速度
Mark-Sweep(フリーリスト方式) なし 対応できない 遅い
Mark-Sweep-Compact / Copying 全件 すべて解消 速い(バンプ方式)
Immix 一部のみ 継続的に解消 速い(バンプ方式)

すべてのオブジェクトを動かすのはコストが高すぎるが、まったく動かさないとフラグメンテーションが解消できない。というトレードオフを解消するため、Immix はヒープを 2 階層 に分けて管理します。

Block と Line

Immix のヒープは次の 2 つの単位で管理されます。

  • Block … 32 KB のメモリ領域で、GC の割り当てや回収の単位
  • Line … Block を 128 バイトごとに区切った小さなセルで、GC のマーキング単位

1 つの Block の中に Line が数百本並び、その Line の中にオブジェクトが詰められていきます。
オブジェクトの生死は Line という細かい粒度で追跡し、メモリの確保や解放は Block という大きな単位で扱います。この 2 階層構造こそが Immix の中核となる仕組みです。

Block と Line の関係を図1 に示します。1 つの Block(32 KB)が約 256 本の Line(128 B)に分割され、Line を最小単位としてオブジェクトが配置されていきます。

図1: Block と Line の階層構造

Line 単位のマーキング

通常の Mark-Sweep では生きているオブジェクトだけをマークします。
一方 Immix では、オブジェクトをマークするときに、そのオブジェクトが乗っている Line にもマークします。

GC が終わったあとの Block は、含まれる Line の状態によって次の 3 種類に分類されます。

Block の状態 Block 内の Line の状態 次の GC サイクルでの扱い
Free すべての Line が未マーク 丸ごとバンプアロケーションで再利用できる
Recyclable マーク済みと未マークの Line が混在 未マーク Line(hole)を見つけてバンプアロケーション
Full すべての Line がマーク済み アロケーション対象から外す

3 つの状態を視覚化したものが図2 です。Free はすべての Line が空いた Block、Full はすべての Line がマーク済みの Block を表しています。
中間状態の Recyclable は、マーク済み Line と未マーク Line(hole)が混在する Block を表します。

図2: GC 後の Block の 3 状態

表中の バンプアロケーション は、ヒープ上に「次に割り当てる位置」を指すカーソルを置く方式です。
新しいオブジェクトをカーソル位置に書き込んだあと、カーソルをオブジェクトサイズ分だけ進めるだけのシンプルな仕組みです。
フリーリストと違って空きスロットを 1 つずつたどる必要がなく、ポインタを進めるだけで割り当てが完了します(詳しい比較は次節で扱います)。 ポイントは、個々の空きスロットではなく連続した未マーク Line(hole)を再利用の単位にしているところです。
Line 内に生きたオブジェクトが 1 つでも残っていれば hole とは見なされず、Line 全体が未マークになって初めてその領域を再利用できます。

割り当ての高速化

hole を連続した空き領域として扱えることで、Immix の新規割り当てはポインタの加算だけで完了するようになります。
デフォルト GC のフリーリスト方式と比較すると違いが明確にわかります。

フリーリスト方式(デフォルト GC)

デフォルト GC のフリーリスト方式では、空きスロットの中に次の空きスロットの位置を書き込んでおき、1 個割り当てるたびに next ポインタをメモリから読み取って head を進めます。
割り当てそのものは単純ですが、ヒープへのアクセスが毎回発生する点で負担になります。

フリーリスト方式の割り当ての様子を図3 に示します。空きスロット同士は next ポインタで連結されており、新しいオブジェクト(obj E)を書き込んだあと、head は次の空きスロットへ進む必要があります。
このときスロット内の next ポインタをメモリから読み出すことになります。

図3: フリーリスト方式の割り当て

Immix のバンプアロケータ

Immix は hole に対して cursor(次に書き込む位置)limit(hole の終端) の 2 つのポインタを保持するだけです。
新しいオブジェクトは cursor 位置に書き込み、cursor をオブジェクトサイズ分だけ加算します。
空きが残っているかは cursor と limit の比較で判定できるので、ヒープのメモリを読む必要が一切ありません。

これを示したのが図4 です。obj E を cursor の位置に書き込み、cursor をオブジェクトサイズ分だけ進めるだけで割り当てが完了します。フリーリスト方式と違い、既存メモリを読む操作が一切登場しません。

図4: バンプアロケータの割り当て

決定的な違いは 「ヒープを読む必要があるかどうか」 です。
フリーリスト方式は next ポインタを取得するために割り当てのたびにヒープへアクセスしますが、バンプ方式はレジスタ上の cursor と limit の比較・加算だけで完結します。
割り当てそのものがアプリケーション本来の処理を邪魔しないので高速化につながります。

さらに Immix は、空の Free Block だけでなく Recyclable な Block(hole が混在する Block)でも同じくバンプ方式で割り当てができます。

Recyclable Block でのバンプアロケーションの動きを図5 に示します。hole 1 つを範囲として cursor と limit をセットし、その範囲内で順にオブジェクト(①〜③)を割り当てていきます。cursor が limit に到達したら次の hole にジャンプして、再度同じ要領でバンプアロケーションを続けます。Immix の割り当てパスにフリーリスト的な処理が登場することはありません。

図5: hole からのバンプアロケーション

選択的コンパクションと Moving / Non-moving

デフォルト GC の Compact は、GC のたびに生きたオブジェクトすべてをヒープの片側に寄せ集めます。
オブジェクトの移動はメモリのコピーとポインタの書き換えを伴う本来とても高価な処理で、ヒープが大きくなるほどコストがかさみます。

Immix は GC のたびに Block ごとの断片化度合い(hole の数や使用率)を統計的に集計しておきます。
次の GC では最も断片化が進んでいる Block だけを対象に、オブジェクトを別の Block へ退避(evacuate)します。退避元の Block は丸ごと Free 状態に戻ります。
動かす対象を必要最小限に絞ることで、移動コストを抑えつつ断片化を継続的に解消できる設計になっています。この方式は 選択的コンパクション(selective compaction) と呼ばれます。

そして、選択的コンパクションを実際に行うかどうかで Immix の実装は 2 つのモードに分かれます。

  • Non-moving Immix … マークと Line 単位の再利用までは行うが、オブジェクトは一切動かさない
  • Moving Immix … 上記に加えて、選択的コンパクションでオブジェクトを退避する

C 拡張との互換性(pinning が必要な状況)では Non-moving のほうが安全ですが、長時間動かすアプリケーションでは断片化が積み上がるため、いずれ Moving が必要になります。
昨年までの MMTk Ruby に入っていたのは Non-moving Immix だけでしたが、今年ついに Moving Immix として動くようになりました。

Moving Immix

今年セッションの目玉のひとつが、Implement moving Immix in MMTk (ruby/ruby#15744) による Moving Immix の追加です。
MMTk のヒープ上でもオブジェクトを動かせるようになり、選択的コンパクションが動くようになりました。

ただし PR 概要にも The performance of this implementation is not yet amazing と書かれている通り、この PR 単体での改善はわずかです。
真価は、次に紹介する Ruby Heap や Fast Path と組み合わさって初めて発揮されます。

アロケーションの Fast Path

先述の通り、Immix のバンプアロケータはオブジェクトのメモリへ触れずに済む点が大きな強みです。
ところが昨年の MMTk Ruby は、せっかくバンプアロケータを採用していても MMTk 側のアロケータ呼び出しのオーバーヘッドが大きく、その強みを十分に活かしきれていませんでした。

そこで今年追加されたのが Implement fast path for bump pointer allocator (ruby/mmtk#53) です。
C 側にバンプアロケータの高速パスを直接実装し、C → Rust の関数呼び出しを回避するという改善で、ホットパスがほぼ Ruby のデフォルト GC と同じシンプルさになりました。

なぜ Fast Path が必要だったのか

以下は去年の発表で示された MMTk Ruby のレイヤー構造です。

Ruby (Modular GC API)  ↔  C Bindings  ↔  Rust Bindings  ↔  MMTk core

オブジェクトをひとつ割り当てるたびに、この経路を伝って C → Rust の FFI 呼び出しが発生します。
Rust 側で実行されるバンプ自体は cursor を加算して比較するだけの数命令ですが、その数命令のために毎回 FFI のオーバーヘッドを払うことになります。

デフォルト GC の場合は GC 実装も C で書かれており、コンパイラのインライン展開も効くため、この問題はそもそも発生しません。
MMTk のバンプ方式自体は本来速いはずなのに、ベンチマークではそこまで速く見えなかった主因のひとつが、この FFI によるコストでした。

この問題に対するアイデアはシンプルで、割り当てが成功する大多数のケース(ホットパス)を C で完結させ、Rust 側で処理するのはバンプ領域を使い切ったときだけにするというものです。

C と Rust で BumpPointer を共有

Fast Path の核は、C 側と Rust 側が同じ BumpPointer 構造体を直接読み書きすることです。

mmtk-core の BumpPointer は cursor と limit のふたつのアドレスを持つだけのシンプルな構造体です。これと同じレイアウトを C 側にも宣言します(gc/mmtk/mmtk.h)。

typedef struct MMTk_BumpPointer {
    uintptr_t cursor;
    uintptr_t limit;
} MMTk_BumpPointer;

次に、ImmixAllocator が保持する bump_pointer フィールドへの生ポインタを C 側へ公開する関数を、Rust 側に新設します(gc/mmtk/src/api.rs)。

pub unsafe extern "C" fn mmtk_get_bump_pointer_allocator(m: *mut RubyMutator) -> *mut BumpPointer {
    match *crate::BINDING.get().unwrap().mmtk.get_options().plan {
        PlanSelector::Immix => {
            let mutator: &mut Mutator<Ruby> = unsafe { &mut *m };
            let allocator =
                unsafe { mutator.allocator_mut(mmtk::util::alloc::AllocatorSelector::Immix(0)) };

            if let Some(immix_allocator) = allocator.downcast_mut::<ImmixAllocator<Ruby>>() {
                &mut immix_allocator.bump_pointer as *mut BumpPointer
            } else {
                panic!("Failed to get bump pointer allocator");
            }
        }
        _ => std::ptr::null_mut(),
    }
}

ポイントは、新しい構造体を作って返しているわけではなく、ImmixAllocator がもともと持っている bump_pointer フィールドへの生ポインタを返している点です。
C 側で cursor を進めれば、Rust 側の ImmixAllocator から見ても同じ cursor が進んでいる、という状態になります。両者が同一のメモリを共有するので、C 側で値を進めても整合性が崩れません。

Immix 以外の Plan(Mark-Sweep など)が選択されているときは null_mut() を返すため、この仕組みが動くのは Plan が Immix のときだけです。

Fast Path 本体と Slow Path へのフォールバック

割り当てのたびに呼ばれる Fast Path 本体は、驚くほど短いコードに収まります。

static VALUE
rb_mmtk_alloc_fast_path(struct objspace *objspace, struct MMTk_ractor_cache *ractor_cache, size_t size)
{
    MMTk_BumpPointer *bump_pointer = ractor_cache->bump_pointer;
    if (bump_pointer == NULL) return 0;

    uintptr_t new_cursor = bump_pointer->cursor + size;

    if (new_cursor > bump_pointer->limit) {
        return 0;
    }
    else {
        VALUE obj = (VALUE)bump_pointer->cursor;
        bump_pointer->cursor = new_cursor;
        return obj;
    }
}

やっていることは、現在のバンプ領域に空きがあれば cursor を進めて新しいオブジェクトのアドレスを返し、空きがなければ 0(NULL)を返すだけです。
ただし、この関数だけではバンプ領域を使い切ったときの処理ができません。
そこでオブジェクト生成の入口にあたる rb_gc_impl_new_obj の中で、まず Fast Path を試します。NULL が返ったときだけ従来の mmtk_alloc(Rust 側)へフォールバックする構造です。

VALUE *alloc_obj = (VALUE *)rb_mmtk_alloc_fast_path(objspace, ractor_cache, alloc_size);
if (!alloc_obj) {
    alloc_obj = mmtk_alloc(ractor_cache->mutator, alloc_size, MMTk_MIN_OBJ_ALIGN, 0,
                           MMTK_ALLOCATION_SEMANTICS_DEFAULT);
}

役割分担を整理すると次のようになります。

  • Fast Path(バンプ領域に空きがある場合) … C コードのまま完結し、Rust や MMTk core の処理は発生しない
  • Slow Path(バンプ領域を使い切った場合) … 従来どおり mmtk_alloc で Rust 側の処理が走り、新しい Block の確保や必要に応じた GC のトリガを行う

Slow Path が呼ばれて新しいバンプ領域を確保すると、Rust 側で BumpPointer の cursor / limit が書き換わります。
前節で見たとおり C 側はその構造体を直接共有しているので、次に Fast Path に戻ってきたときには、明示的な同期処理を挟むことなく新しい領域から割り当てを再開できます。

ベンチマーク結果

ここまでの仕組みでどのくらい速くなったのか、PR の概要に 10_000_000.times { String.new }MMTK_HEAP_MIN=100MiB)のマイクロベンチ結果が掲載されています。

時間(平均 ± 標準偏差)
Before 810.7 ms ± 8.3 ms
After 777.9 ms ± 10.4 ms

全体で 33 ms の改善なので、割り当て 1 回あたりに換算すると 3.3 ns 程度の改善です。絶対値としてはわずかに見えますが、Ruby アプリケーションのあらゆる場所で発生するオブジェクト生成すべてに関わる差分なので、効果は非常に大きいと考えられます。

ただし、この PR で Fast Path 化されたのはあくまでアロケータ本体だけで、オブジェクト生成のホットパスにはまだ Rust 関数の呼び出しが残っています。
PR の差分には、それを示唆する TODO コメントがそのまま入っています。

// TODO: implement fast path for mmtk_post_alloc
mmtk_post_alloc(ractor_cache->mutator, (void*)alloc_obj, alloc_size, MMTK_ALLOCATION_SEMANTICS_DEFAULT);

mmtk_post_alloc はオブジェクト生成後にメタデータを更新する処理です。Fast Path で確保したオブジェクトに対しても毎回 Rust 側を呼ぶため、改善の余地はまだ残っています。

Ruby Heap

ここまで取り上げてきた改善は、GC アルゴリズムや 1 回ごとの割り当て速度に関わるものでした。
これとは別の軸として実運用上の挙動を大きく左右するのが、ヒープ全体の拡張・縮小をどのように制御するかという問題です。

MMTk ではこの責務が GC アルゴリズム本体(Plan)から分離されており、GCTriggerPolicy という独立したコンポーネントが受け持っています。
「どのアルゴリズムで GC を回すか」と「ヒープをいつ・どこまで拡張するか」を別々に差し替えられるのが MMTk の設計上の特徴です。

mmtk-core 側にはあらかじめ GCTriggerSelector enum で 3 つの選択肢が用意されています(mmtk-core/src/util/options.rs)。

  • FixedHeapSize(max):起動時に指定したサイズで固定
  • DynamicHeapSize(min, max):MemBalancer アルゴリズム(論文 OOPSLA 2022)でメモリ使用量を最小化
  • Delegated:判断を VM バインディング側に委譲するためのフック

このうち Fixed は、最大サイズが事前に分かっている前提のため、リクエストごとに使用量が変動する Rails アプリケーションには合いません。
Dynamic はメモリ使用量を最小化するのが目的のため、Ruby のように短命なオブジェクトを大量に生成するワークロードでは GC を頻繁にトリガーしてしまい、CPU 時間がかえって悪化します。
つまり、mmtk-core 側に汎用実装を持つ Fixed・Dynamic の 2 つは、いずれも Ruby のワークロードには適合しません。

そこで残るのが 3 つ目の Delegated です。
これは mmtk-core 側に具体的なポリシー実装を持たないオプションです。
Collection::create_gc_trigger() というトレイトメソッドを通じて、ヒープ伸縮の判定ロジックを VM バインディング側に委譲するためのインターフェースとして定義されています。

Peter さんはこの Delegated のフックを利用し、新しい Ruby Heap モードを追加しました(Implement Ruby heap (ruby/mmtk#54))。
Ruby のデフォルト GC が長年使ってきたヒープ成長アルゴリズムを、Ruby バインディング側で独自実装する形になっています。

Ruby Heap の中身

実体は gc/mmtk/src/heap/ruby_heap_trigger.rs にある RubyHeapTrigger という構造体です。
Config は以下のような構成になっています。

pub struct RubyHeapTriggerConfig {
    pub min_heap_pages: usize,        // ヒープの下限ページ数
    pub max_heap_pages: usize,        // ヒープの上限ページ数
    pub heap_pages_min_ratio: f64,    // この空き率を割ると拡張(既定 0.20)
    pub heap_pages_goal_ratio: f64,   // 拡張時の目標空き率(既定 0.40)
    pub heap_pages_max_ratio: f64,    // この空き率を超えると縮小(既定 0.65)
}

中核になるのは GC 完了時のフック on_gc_end() で、要約すると次のロジックを毎回走らせています。

let used_pages = mmtk.get_plan().get_used_pages();

let new_target = ((used_pages as f64
    * (1.0 + Self::get_config().heap_pages_goal_ratio)) as usize)
    .clamp(min_heap_pages, max_heap_pages);

self.target_heap_pages.store(new_target, Ordering::Relaxed);

GC が終わるたびに (使用ページ数) × (1 + goal_ratio) を新しいヒープ目標サイズとして再計算しています。goal_ratio の既定値は 0.4 なので、つねに使用量に対して 40% の余裕を残す形でヒープが伸縮します。
次の GC を発火させるかどうかは is_heap_full() 側で「予約済みページ数 > target_heap_pages」という条件で判定します。
ここでも target_heap_pages がヒープの伸縮を支配する中心の値になっています。

挙動を整理するとこうです。

  • GC 後、ヒープには少なくとも 20% の空きを確保する
  • 拡張時は 40% の空きを目指して大きくする
  • 空きが 65% を超えていればメモリをシステムへ返却する

環境変数でのチューニング

ここまで紹介した 3 つの ratio は、Ruby Heap モード内部にハードコードされているわけではありません。起動時に、Ruby のデフォルト GC と 同じ環境変数 から読み込まれる仕組みになっています。
api.rsMMTK_HEAP_MODE=ruby 分岐を見るとわかります。

"ruby" => {
    let min_ratio  = parse_float_env_var("RUBY_GC_HEAP_FREE_SLOTS_MIN_RATIO",  0.2,  0.0, 1.0);
    let goal_ratio = parse_float_env_var("RUBY_GC_HEAP_FREE_SLOTS_GOAL_RATIO", 0.4,  min_ratio, 1.0);
    let max_ratio  = parse_float_env_var("RUBY_GC_HEAP_FREE_SLOTS_MAX_RATIO",  0.65, goal_ratio, 1.0);
    // ... RubyHeapTriggerConfig にセット
    Some(GCTriggerSelector::Delegated)
}

parse_float_env_var の第 2 引数(0.2 / 0.4 / 0.65)は、対応する環境変数が指定されなかったときに使われる既定値です。
Ruby 本体側の gc/default/default.c でも、デフォルト GC のヒープ伸縮を制御するマクロ GC_HEAP_FREE_SLOTS_MIN_RATIO / _GOAL_RATIO / _MAX_RATIO が定義されています。
これらの既定値も同じく 0.20 / 0.40 / 0.65 で揃っています。

つまり Ruby Heap モードは、チューニング用の環境変数とその既定値という外向きのインターフェースまでデフォルト GC とそろえています。

ベンチマーク結果

スライドで紹介されていた代表的な結果は次の通りです。

ワークロード デフォルト GC MMTk(Dynamic Heap) MMTk(Ruby Heap) メモリ使用量
activerecord 148 ms 845 ms 233 ms(旧比 3.63×) デフォルト比 +26%
railsbench 1263 ms 3218 ms 1707 ms(旧比 1.89×) デフォルト比 +27%

また、並列化の効果を 16 ワーカー環境のプロファイルで比較した結果も示されました。

構成 合計時間
シングルスレッド(シーケンシャル)GC 38.5 ms
並列 GC(16 ワーカー) 11.9 ms(約 3 倍以上高速)

立ち位置としては、デフォルト GC にはまだ少し届かないものの、去年から大きく前進したという総括でした。Peter さんも still plenty of work と述べており、残された改善余地は次の項で紹介する 4 つのロードマップで詰めていく方針が示されました。

今後の改善アイデア

セッション終盤では、さらに性能を引き上げるための 4 つのアイデアが紹介されました。

1. Sticky Immix(世代別 GC)

デフォルト GC は 世代別 GC を実装していますが、MMTk Ruby にはまだ世代別 GC が入っていません。

  • Sticky Immix はマークの状態をそのまま保つ方式で、マイナー GC 後もオブジェクトがマーク済みのまま維持される
  • 後続のマイナー GC でスキャン対象が減るため、大きな性能向上が期待できる

弱い世代仮説(ほとんどのオブジェクトはすぐ死ぬ)は Rails アプリケーションに特によく当てはまるため、Sticky Immix の効果で大きく改善されることが期待できます。

2. 並列性の向上

16 ワーカー環境のプロファイルでは、割り当てられたタスクを処理し終えて、次のタスクが来るまでアイドル状態になっているワーカーが散見されました。
GC 処理をより細かい粒度のタスクへ分割し、すべてのワーカーが常に動き続ける状態を目指したい、というアイデアです。

3. 可変長バッファーの GC 管理

現状、Array などの可変長オブジェクトがスロットサイズに収まらなくなると、malloc 経由でシステムから追加メモリを確保しています。これが GC アロケーションより遅いため、GC 管理下の隠しオブジェクトとしてバッファを確保する実験をしたい、という話でした。

このテーマは Peter さんの過去記事 Reworking Memory Management in CRuby でも詳しく触れられており、併せて読むと理解が深まります。

4. マシンコード生成による関数呼び出し削減

Fast Path の改善で C → Rust の関数呼び出しは減りましたが、Ruby VM → GC 側の関数呼び出し はまだ残っており、依然としてオーバーヘッドになっています。GC 側で高速パス用のマシンコードを生成して関数呼び出しごと回避できれば、アロケーションがさらに速くなる、という展望でした。

まとめ

今年のセッションは、デフォルト GC の制約の整理から始まりました。
そこから去年の Modular GC による差し替え可能な API の整備、今年の Moving Immix を中心とした改善によってデフォルト GC との差をワークロード次第で大きく縮めるところまで、段階的な取り組みをまとめた内容でした。

去年と今年を表で並べると、進化の方向性がクリアに見えてきます。

観点 RubyKaigi 2025 RubyKaigi 2026
テーマ Modular GC の仕組み紹介 次世代 GC の実用化報告
MMTk の状態 実験的、Non-moving Immix まで Moving Immix が動作
主な成果物 Modular GC API の整備 Moving Immix の作り込み
ベンチマーク デフォルトの約 1/2 の速度 ワークロードに依るがデフォルトの 65〜90% 程度

残された改善余地としては、Sticky Immix による世代別 GC、並列性の向上、可変長バッファの GC 管理化、マシンコード生成の 4 点がロードマップに挙げられています。3 年がかりで積み上げられてきた取り組みは、来年以降も進んでいくはずです。

感想

RubyKaigi 2026 に参加して、Ruby の GC まわりが想像以上のスピードで進化していることを改めて実感しました。Ruby 4.0 では ZJIT がかなり注目されており、個人的にも今後の改善が気になっていたのですが、GC についても転換期が来たのではないかと感じています。

去年参加した RubyKaigi 2025 では、GC のセッションを全くと言っていいほど理解できずに心が折れていました。
ですが今年は、Immix の内部、アロケータの設計、ヒープ成長アルゴリズムといった、昨年までなら確実に置いていかれていた箇所まで理解できました。少しだけ成長を感じられた RubyKaigi でもありました。

普段業務で触っているジョブハウスも、Rails アプリケーションとして大量のリクエストごとにオブジェクトを生成しています。
これはまさに弱い世代仮説がよく効くワークロードです。将来 Sticky Immix が入って MMTk が世代別 GC を持ったときには、手元で RUBY_GC_LIBRARY=mmtk を指定してベンチマークを取りたいと思っています。

オフィシャルパーティで一緒に撮っていただいたツーショット写真も載せて、今回のブログを締めさせていただきます。

Peter Zhuさんとの2ショット

改めまして、素晴らしい発表をしてくださった Peter Zhu さんと、RubyKaigi 2026 の運営に関わったすべての方々に感謝申し上げます。本当にありがとうございました。

参考文献


Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。

jp.techouse.com