Upgrade to Pro — share decks privately, control downloads, hide ads and more …

graphql-ruby を Spinel で速くしようとした話

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest. →

graphql-ruby を Spinel で速くしようとした話

Avatar for Fumiaki MATSUSHIMA

Fumiaki MATSUSHIMA

October 22, 2026

More Decks by Fumiaki MATSUSHIMA

Other Decks in Programming

Transcript

  1. 03 Spinel • matz 作の AOT コンパイラ • Ruby のプログラム全体を読み、呼び出し先と型をコンパイル時に全部決めて、C

    に変換す る Ruby のプログラム → 型を する → C に する → ネイティブのバイナリ
  2. 04 足し算 Ruby Spinel が出力した C def add(a, b) a

    + b end sp_int sp_add(sp_int lv_a, sp_int lv_b) { return sp_int_add(lv_a, lv_b); } add(1, 2) sp_add(1LL, 2LL); • add(1, 2) と整数で呼ばれているので、a と b は整数だと決める。sp_int は C の整数 • C の名前の sp_ は Spinel が付ける接頭辞、lv_ はローカル変数
  3. 05 クラス Ruby Spinel が出力した C class User def initialize(name)

    struct sp_User_s { sp_int cls_id; @name = name const char * iv_name; end }; def name @name end const char * sp_User_name(sp_User self) { return self.iv_name; } end sp_User_name(lv_user); user = User.new("Alice") user.name
  4. 07 マクロ的な module_eval Ruby class User [:name, :email].each do |n|

    module_eval <<-RUBY def #{n}? = !#{n}.empty? RUBY end end Spinel が出力した C sp_bool sp_User_email_p(sp_User *self) { return !sp_str_empty_p(sp_User_email(self)); } • べたっと書かれた module_eval は、コンパイル時に展開されて、普通のメソッドと同じ関 数になる
  5. 08 graphql-ruby の黒魔術 # language/nodes.rb class AbstractNode class << self

    def children_methods(children_of_type) ... children_of_type.each do |method_name, node_type| module_eval <<-RUBY, __FILE__, __LINE__ attr_reader :#{method_name} RUBY ... end end end end class Directive < AbstractNode children_methods(arguments: GraphQL::Language::Nodes::Argument) end • メソッドの中の module_eval なので、今の Spinel は拒否する(inherited フックの中も同じ) • 引数はソースに書いてあるので、原理的には展開できる
  6. 09 名前が実行時のデータで決まる呼び出し Ruby class User def name = "Alice" def

    email = "[email protected]" end Spinel が出力した C if (sym == 0) sp_User_name(user); else if (sym == 1) sp_User_email(user); else sp_raise_nomethod(...); FIELDS = { "name" => :name, "email" => :email } User.new.public_send(FIELDS[gets.chomp]) • どのメソッドを呼ぶかは入力を読むまで分からない • Spinel は呼ばれうるメソッドを全部並べて、名前(:name は 0、:email は 1)を比べる 分岐にする。通るが、直接の呼び出しにはならない
  7. 10 原理的に無理なもの # eval(gets) class User define_method(gets) { "Alice" }

    end 否される # 否される class User def method_missing(name, *args) = "Alice" end User.new.name # ⾏すると NoMethodError • コードやメソッドそのものが、実行時の入力で作られる • 対応するには、バイナリの中に Ruby の処理系をもう一度持つことになる。それでは速くなりようがない
  8. 11 黒魔術を Spinel がどこまで扱えるか 名前の決まり方 例 Spinel ソースに書い てある 名前を書いた

    public_send、マクロ的な module_eval 直接の関数呼び出しになる。対応し ていない書き方も、改善で展開で きる 実行時の データで決ま る データから名前を取り出す public_send 候補を全部並べた分岐になる 実行時に作 られる eval(gets)、de ne_method(gets)、 method_missing 原理的に無理
  9. 12 スキーマから Ruby コード生成 • AI は graphql-ruby に 50

    個以上のパッチを積み上げて、ようやく { hello } が動 いた • graphql-ruby 本体をそのまま Spinel に通すのは無理筋 • そこで、graphql-ruby と同じ動きをする、スキーマに特化した Ruby コードを生成する ことにした スキーマ(graphql-ruby の DSL で書いた Ruby) → CRuby で み む → このスキーマ専⽤の、平たい Ruby を書き す(graphql-ruby を使わない) → Spinel でコンパイルする
  10. 13 最小のスキーマ class UserType < GraphQL::Schema::Object field :name, String, null:

    false end このスキーマから、次のページのコードを生成する
  11. 14 実行の流れ にあたる り⼝ ここから下を、スキーマから⽣成する アプリが書いたリゾルバを呼ぶ ユーザーの だけ呼ぶ gql_execute("{ users

    { name } }") Schema.execute → exec_Query(nil, plan) → QueryType#users → exec_User(user, plan) → { "data" => { "users" => [{ "name" => "Alice" }] } } • 型ごとに exec_型名 のメソッドを生成する。オブジェクト型のフィールドに当たると、その 型の exec_ を呼んで下へ進む
  12. 15 生成されたコード def exec_User(obj, plan) res = {} e =

    plan.first until e.nil? case e.fid when 1 # name v8 = obj.name # if v8.nil? # String! leaf_null_error("User", "name", e.field, e.key) return nil end res[e.key] = v8.to_s # String end e = e.next_entry end res end メソッドで り す なので なので
  13. 16 ベンチマーク graphql-ruby に付属する benchmark/run.rb の pro le_large_result { foos

    { id int1 int2 string1 string2 boolean1 boolean2 stringArray intArray booleanArray } } • Foo を 1,000 件返す。1 件に 10 フィールドで、うち 3 つは要素 10 個の配列。値は全 部で約 4 万個 • 同じスキーマと同じクエリを、graphql-ruby と生成したコードで実行して、時間を比べる (5 回のうちの最小)
  14. 18 本当に Spinel のおかげ? YJIT graphql-ruby スキーマから Ruby コード生成 Spinel

    74.5 〜 3.58 4.05 3.68 〜4.06 • 同じ生成コードを YJIT で動かしても、ほとんど変わらない • 速くしたのは Spinel ではなかった。では、何が消えたのか
  15. 19 ボトルネック 1: フィールドの一覧づくり { users { name ... on

    Admin { role } } } users.each do |user| fields = collect_fields(query, type_of(user)) ... end # ユーザーごとに作る • クエリが求めるフィールドを、目の前のオブジェクトについて実際に埋める一覧にする • ... on があるので、オブジェクトの型を見ないと一覧が決まらない • ユーザー 1 人 1 人に対して、どのフィールドを返すかを計算している
  16. 20 resolve と complete クエリ { user { name createdAt

    } } # resolve: オブジェクトから、Ruby の値を り す # user.name user.created_at # complete: Ruby "Alice" Time # => "Alice" # => 2026-10-22 00:30:00 +0900 Time ( ) の値を、スキーマの型どおりの ⼒にする # String! → null でないか かめて "Alice" # ISO8601DateTime → "2026-10-22T00:30:00+09:00" • フィールド 1 つにつき、必ずこの 2 段を通る • resolve で Ruby のオブジェクトをたどり、complete で JSON にできる値にする
  17. 21 ボトルネック 2: 取り出し方の呼び分け class UserType < GraphQL::Schema::Object field :name,

    String field :display_name, String def display_name = object.name.capitalize end def resolve(field, user) wrapper = UserType.new(user) # if wrapper.respond_to?(field.name) then wrapper.public_send(field.name) elsif user.is_a?(Hash) then user[field.name] else user.public_send(field.name) end end ユーザーごとに む • name は User のメソッド、display_name は UserType で定義したメソッドだと、スキーマを見れば決まっている。 それでもユーザーごと、フィールドごとに包んで探し直している
  18. 22 ボトルネック 3: JSON への変換 def complete(type, value) if type.non_null?

    raise "null " if value.nil? complete(type.of_type, value) elsif value.nil? then nil は せない elsif type.list? elsif type.enum? then value.map { |v| complete(type.of_type, v) } then type.name_for(value) # :active → "ACTIVE" elsif type.scalar? then type.coerce_result(value) else end execute(type, value, ...) # Time → # オブジェクトなら中へ end • どんな型が来ても正しい出力にするために、値ごとに型を 1 段ずつたどって判断する • [Int!]! なら、本当に必要な仕事は to_i だけ。それを値の数(このベンチマークでは約 4 万)だけ繰り返す
  19. 23 実は graphql-ruby も気づいていた • graphql-ruby は next という新しい実行エンジンで、このボトルネックの改善を進めて いた

    • 2.6 から使える(GraphQL::Execution::Next)。v3.0 ではこれだけになる。従来のも のを、ここでは legacy と呼ぶ
  20. 24 legacy は深さ優先 # interpreter/runtime.rb def evaluate_selections(gathered_selections, ...) gathered_selections.each do

    |result_name, ast_node| evaluate_selection(result_name, ...) # → continue_field end end def continue_field(value, ..., current_type, ...) case current_type.kind.name when "OBJECT" evaluate_selections(selections, ...) # 1 when "LIST" value.each do |inner_value| resolve_list_item(inner_value, ...) の中へ # 1 ずつ → continue_field end end end • 1 人を受け取り、その 1 人の中身を最後まで埋めてから、次の 1 人へ進む再帰
  21. 25 next は幅優先 # execution/selections_step.rb def initialize(parent_type:, selections:, objects:, ...)

    # def call @runner.gather_selections(@parent_type, @selections, ...) continue_selections.each { |frs| @runner.add_step(frs) } # → FieldResolveStep end 員 の配 # execution/field_resolve_step.rb def execute_field objects = @selections_step.objects @field_results = resolve_batch(authorized_objects, ctx, @arguments) # ... @runner.add_step(SelectionsStep.new(objects: @all_next_objects, ...)) # end • 全員を受け取り、フィールドごとに全員分を埋めてから、次の段へ進む • 判断はフィールドごとに 1 回で済む 員 まとめて の も 員
  22. 26 判断を置く場所 ループの中で する # legacy: users.map do |user| case

    field.how when :method then user.name when :hash then user[:name] end end ループの で、 回だけ する # next: 1 case field.how when :method then users.map { |user| user.name } when :hash then users.map { |user| user[:name] } end スキーマから コード⽣成 # Ruby : users.map { |user| user.name } は、コードを書き すときに んでいる
  23. 27 legacy が何をしているか ユーザー 1 人、フィールド 1 つごとに、主にこれだけのことをしている • ユーザーを型のクラスのインスタンスで包み、権限を確かめる(authorized?)

    • 型のクラスにメソッドがあるかを respond_to? で調べる • フィールドの引数を、型に合わせて変換する • @skip や @include の条件を確かめる • 遅延した値が返ってきていないかを調べる • トレースの仕組みに知らせる
  24. 28 結果 pro le_large_result(ms)。✓ 解消した、✗ 実行時に繰り返している、✂ 省いた legacy next スキーマから

    Ruby コード生成 一覧づくり 取り出し方 JSON への変換 その他の処理 YJIT ✗ ✓ ✗ ✓ ✗ ✗ ✗ ✗ 74.5 ✂ 3.58 4.05 ✓ ✓ ✓ Spinel 13.7 〜 〜 3.68 4.06 • next はユーザーごとの判断を消して、legacy の 5.5 倍速い • スキーマから Ruby コード生成は next の約 3.8 倍速い。値ごとの判断で約 2.7 倍、そ の他の処理を省いて約 1.5 倍
  25. 30 余談: 実アプリでは Decidim と Canvas LMS を手元で動かして、GraphQL のリクエストの時間の内訳を 測った

    Decidim の一覧クエリ(提案 50 件、81 ms、YJIT なし) Canvas のネストしたクエリ(約 1.2 万値、2.2 s、YJIT) graphql-ruby 11% graphql-ruby 20% ActiveRecord 41% ActiveRecord 16% Rails 30% Rails 25% その他 18% その他 39% • 実行の部分を生成コードに置き換えても、全体は 1.15〜1.7 倍。残りはアプリ側なので、 ここを頑張っても大して速くならない