GraphQL Asia 2019 で登壇しました

こんにちは。メディアプロダクト開発部の我妻謙樹(@itiskj)です。 ソフトウェアエンジニアとして、広告配信システムの開発・運用を担当しています。好きな言語は Go と TypeScript です。

先日、GraphQL に関してアジア圏初の大型カンファレンス、GraphQL Asia がバンガロールで開催されました。元 Facebook で GraphQL の策定者の一人である Lee Byron を始めとし、Twitter, PayPal, Airbnb, Atlassian などのエンジニアが登壇し、GraphQL の導入事例やベストプラクティスの紹介が行なわれました。私も CFP が通過し発表してきたので、他の登壇者の内容と合わせて紹介いたします。

GraphQL Asia 2019 での発表

全発表資料は後ほど https://www.graphql-asia.org/ にて公開されるとのことですが、先にいくつかピックアップしてご紹介します。

BrikL - A GraphQL native

まず、GraphQL Asia 2019 の主催者の一社である BrikL から、GraphQL を用いた自社製品の開発の変遷について紹介がありました。

個人的には、S3/DynamoDB/Elasticsearch などの外部サービスを Directives を用いて宣言している点がユニークで学びでした。

f:id:itiskj:20190422134337p:plain
graphql asia brikl slide capture

APIS.GURU - GraphQL Tools are easy or how to write one in less than 100 lines

https://github.com/graphql/graphql-js のコミッタや https://github.com/APIs-guru/graphql-voyager のメンテナをしている OSS 開発者からの発表でした。

GraphQL tool を書くことは思ったより簡単であることを、実際に demo で GraphQL coverage tool を書きながら伝える内容でした。

GraphQL 界隈は、基本的なものに関しては出揃ってきた感がありますが、エコシステム全体的にはツールも production ready でないものが多かったり、作りかけのものが多かったりと、成熟期には達していません。GraphQL community に対して、ツールを書くことに敷居を下げ、ヒントや手法を与えた、という意味でこの発表はとても有意義なものでした。

Airbnb - GraphQL @ Airbnb

Airbnb における GraphQL 活用事例の紹介でした。

Airbnb では、monolithic な Rails アプリ、いわゆる "Monorail" が 10 年以上稼働していました。2 年前から Airbnb SOA というアーキテクチャを導入し、徐々に service oriented な構成へと移行しているとのこと。その中でも、presentation layer に GraphQL Gateway を導入しているとのことでした。

この発表の最大の価値は、RPC フレームワークであるFacebook's branch of Apache Thrift と GraphQL を併用した場合のアンチパターン及びエッジケースについて紹介されていることでした。おそらくこの組み合わせを大規模なサービスで用いているのは Airbnb が初めてとのことで、彼らならではのチャレンジから生まれる知見が世に出たことは、今後の GraphQL community の資産となることでしょう。

発表資料

メディアプロダクト開発部のうち、動画領域を担当するプロダクト開発グループでは AWS AppSync を利用しており、GraphQL および AppSync の活用事例として、以下のような資料が公開されています。

一方、私が所属する広告領域を担当するマーケティングサービス開発グループでも、社内の広告管理システムにおいて GraphQL を導入しています。今回は、こちらのシステムに GraphQL を入れるにあたって行った技術選定の過程や、実際に GraphQL を利用して得た知見を共有する、という内容で発表を行いました。

GraphQL の導入事例は他にも Tokopedia, Intuit や Phillips, Adobe からもいくつかありましたが、どの会社も違った課題を抱えており、それぞれのユースケースの講演も参考になりました。

感想

国際カンファレンスに参加したのは初めてだったのですが、Speaker として参加したからこそ得られた知見や経験が非常に貴重でした。というのも、Speaker 同士の交流会や市内観光などもカンファレンススタッフによって予定されていたのですが、それらに参加する中で、リアリティに富んだ新鮮度の高い情報(GraphQL の欧米圏における浸透具合や、今必要とされているツールや技術)のみならず、決して SNS では得られることのできないような情報についても聞くことができたのは、非常に有意義な点でした。また、彼らと接点を持てたことも大きいです。

発表資料は後からオンラインに公開されるので世界中どこに住んでいても同じ情報にアクセスできますが、やはり直接会うからこその経験を得ることができるのも、カンファレンス(国内・国外問わず)に参加することの意義だということを再認識しました。

また、国外ではカンファレンス渡航費を会社が出してくれないという Speaker もそれなりにいました。今回、渡航費をサポートしてくれたり、出張中に業務をカバーしてくれたりした同僚や上司の方々には感謝しかありません。ありがとうございました。

まとめ

今までは、"Header Bidding 導入によるネットワーク広告改善の開発事情""cookpad storeTV の広告配信を支えるリアルタイムログ集計基盤" など、まさに広告領域を代表するような技術およびシステムについて紹介してきました。

しかし、広告領域ではネットワーク広告や配信サーバーのみならず、社内入稿システムも開発しています。そして、その入稿システムを利用する業務推進チームや制作チームなどが、会社の広告事業の売上を支えてくださっています。そのメンバーの業務効率を上げるために、社内入稿システムの開発及び改善・保守にも力を注いでいます。今回の記事をきっかけに、また違った観点からの技術的チャレンジもお伝えできれば嬉しいです。

広告領域は、技術的にチャレンジングな課題も多く、かつ事業の売上貢献に直結することが多いだけでなく、入稿から配信まで様々なシステムが複雑に絡み合う、非常にエキサイティングな領域です。ぜひ、興味を持っていただけたら、Twitter からご連絡ください。

また、メディアプロダクト開発部では、一緒に働いてくれるメンバーを募集しています。少しでも興味を持っていただけたら、以下をご覧ください。

Bitrise & Cookpad Developer Meetupを開催しました

モバイル基盤部の@hiragramです。先日try! Swiftに合わせて来日したBitriseチームを恵比寿オフィスに招いてミートアップを開催しました。

Bitriseはモバイルアプリ開発に特化したクラウドCIサービスで、最近日本での採用もスタートされたそうです。

cookpad.connpass.com

東京へのカウントダウン! - Bitrise Blog

ミートアップでは、モバイルCIをテーマにBitrise社のサービス紹介やクックパッド社内のモバイルアプリ向けCI環境やその上で行なっている取り組みについてのトークがありました。

まず、BitriseCTOのViktorさんから、Bitriseのサービス紹介とジョブの最適化についての発表をしていただきました。

モバイル基盤部長の@slightairが、社内のモバイルCI環境で動いているタスクや、社内向けベータ配信の仕組みについて発表しました。

クックパッドのモバイル向けCI環境では、Pull Requestごとに実行されるユニットテストの他に、日次で実行されるUIテストや、社内向けにベータ版を配信するサービスが動いています。品質を担保するために必要な各種テストやベータ版の配布を自動化することで、開発者は繰り返し発生するそれらの作業に追われる事無く、手元の開発に集中することができます。

また、毎週金曜日に実行されるAppStore Connectへの自動サブミットによって「機械に人間が合わせる」というリリースフローが確立されており、部署をまたいだスケジュールの調整や、コードフリーズ日のすり合わせなどをする必要が無くなりました。

過去のテックブログや前回のiOSDCなどでも社内向け配信やサブミットの自動化などについて紹介しているので、ぜひそちらも合わせて御覧ください。

クックパッドアプリはみんなが寝ている間にサブミットされる | クックパッド開発者ブログ

続いて、モバイル基盤部の@vinsentisambartが、モバイル向けCI環境の具体的な構成などについて発表しました。

クックパッドのモバイル向けCI環境は、Jenkinsの上に構築されており、 iOSは4台のMac mini、Androidは3組の EC2 Linux instance + Genymotion Cloud Instance という Slave 構成になっています。iOS/AndroidそれぞれのCIを実現するに当たって、それらの環境の良いところ/悪いところを紹介しています。周辺ツールの更新などが自由度高く行える一方で、マシンの追加や環境構築のコストなどが課題として指摘されています。

おわりに

クックパッドモバイル基盤部では、アプリ開発のフローを効率化/自動化することで、サービス開発者の生産性を高めるための取り組みをしています。開発者の生産性向上に興味がある方はぜひ一度クックパッドオフィスに遊びに来てください。

クックパッドメンバーに直接カジュアルにお声がけいただいてもいただいてもいいですし、以下のページからご連絡いただいても大丈夫です!お待ちしております!

クックパッド株式会社 採用サイト

RubyKaigi 2019: Write a Ruby interpreter in Ruby for Ruby 3

技術部の笹田です。フルタイム Ruby コミッタとして働いているので、明日から始まる RubyKaigi 2019 は仕事で行きます。あまり日のあたることが少ない我々の晴れの舞台です。

宣伝もかねて、RubyKaigi 中に自分がどんな仕事があるか並べてみました(クックパッド全般の話は、「クックパッド一同は、RubyKaigi 2019でみなさんにお会いできることを楽しみにしています!」 をご覧下さい)。

  • 毎朝、クックパッドブースで「Cookpad Daily Ruby Puzzles」を紙で配付しますので、興味がある方はお持ち下さい。
  • 1日目
    • 11:20-「Ruby 3 Progress Report」まつもとさんの keynote 後、Ruby 3 の進捗みたいなことをご紹介します。
    • 14:20-「Write a Ruby interpreter in Ruby for Ruby 3」私の accept された発表です。
    • 15:00- 休憩時間、クックパッドのブースにおりますので、ご質問がある方はお運びいただければ幸いです。
  • 3日目
    • 10:00- 「Ruby Committers vs the World」恒例のコミッタを壇上に並べる出し物です。Q&A になるかと思います。https://forms.gle/f7zZt1pKCA5HTABe9 から、まつもとさんやコミッタに質問をお寄せ下さい。
    • 15:00- 休憩時間、クックパッドのブースにて、「Cookpad Daily Ruby Puzzles」の解説が遠藤さんからあるのを眺める予定です。
    • 終了後、RubyKaigi 子供会という、子供連れが集まる宴会を企画しています(保護者会だったかもしれない)。

あれ意外と少ない。京都でやったときは、一日中並列化の議論をしていた気がする。開催の前日に Developer's meeting と、翌日に after hackathon があるので、まぁやはり大変かも知れません。

さて、本稿では、私の発表、「Write a Ruby interpreter in Ruby for Ruby 3」についてご紹介します。下手な英語で発表する予定なので、こちらでは日本語で記事として残しとこうという意図になっています。

この発表は?

f:id:koichi-sasada:20190417011701p:plain
title_image

発表タイトルを直訳すると、「Ruby 3 にむけて、Ruby でインタプリタを書いていこうぜ」という感じになるでしょうか。

今、MRI (Matz Ruby Interpreter) は、ほぼすべて C で書かれています。タイトルを読むと、これを Ruby に全部置き換えよう、と見えるかも知れませんが、意図としては、「Ruby で書いた方がよいところは Ruby で書けるようにしよう」というものです。Ruby で Ruby をすべて self-host しよう、みたいな RubyでつくるRuby のような話ではありません。

現実的に、良い感じの仕組みを導入して、Ruby 3 をよりよくしましょう、という提案になります。

発表資料は http://www.atdot.net/~ko1/activities/2019_rubykaigi2019.pdf からダウンロード頂けます(修正等、随時更新が入ります)。

背景:現状と問題点

MRI での組込クラス・メソッドの定義の方法

現在、Ruby の組込クラス・メソッドのほとんどは、C で記述されています。String だとこんな感じ。

void
Init_String(void)
{
    rb_cString  = rb_define_class("String", rb_cObject);
    ...
    rb_define_method(rb_cString, "<=>", rb_str_cmp_m, 1);
    rb_define_method(rb_cString, "==", rb_str_equal, 1);
    rb_define_method(rb_cString, "===", rb_str_equal, 1);
    rb_define_method(rb_cString, "eql?", rb_str_eql, 1);
    ...
    rb_define_method(rb_cString, "length", rb_str_length, 0);
    rb_define_method(rb_cString, "size", rb_str_length, 0);
    ...
}

基本的に、rb_define_class というクラスでクラスを定義して、そのクラスに rb_define_method でメソッドを追加していく、というものです。rb_define_method では、名前と実装している関数、それから arity (引数の数)を指定します。String#lengthの場合は、rb_str_length という関数で実装されているようです。

VALUE
rb_str_length(VALUE str)
{
    return LONG2NUM(str_strlen(str, NULL));
}

こんな感じで、C で Ruby のメソッドが記述できます。この場合、String#length メソッドが呼ばれると、最終的には rb_str_length() が呼ばれる、というものです。C プログラマなら、見ればわかるような構造になっていて、わかりやすいです。

(実は、prelude.rb という、Ruby で定義を書く方法もあったりしますが、あまり使われていません)

なお、このように C で定義されたメソッドを C メソッド、Ruby で定義されたメソッドを Ruby メソッドと呼ぶことにしましょう。

現状の問題点

さて、このわかりやすい構造ですが、現在はいくつか問題があります。4つにまとめてみました。

  • (1) アノテーション(メタデータ)の問題
  • (2) 性能の問題
  • (3) 生産性の問題
  • (4) API に context を追加したい問題

これらの問題を解説します。

(1) アノテーション(メタデータ)の問題

C メソッドには、いくつかの意味で情報が足りていません。

(a) Ruby メソッドに比べて情報が足りません。

例えば、Method#parameters という、パラメータ名を取得刷るメソッドを利用すると、

def hello(msg) puts "Hello #{msg}"; end
p method(:hello).parameters
#=> [[:req, :msg]]

このように、Ruby メソッドの引数の名前 msg を取得することができます。他にも、バックトレース情報など、こんな感じで Ruby メソッドに比べて情報が落ちているところがあります(時々聞く、stack-prof で C メソッドが出てこなくて困る、というのは、これが理由です)。

これらは、Ruby で定義すれば、持っていたはずの情報になります。

(b) 最適化のために必要な情報が足りません。

とくに、メソッドをまたぐ最適化を行おうとすると、あるメソッドがどのような性質を持つか、例えば「副作用を持つ・持たない」という情報はとても重要になります。しかし、C で実装されたメソッドの性質を調べようとすれば、C のソースコードの解析が必要になり、現実的ではありません。

たとば、str.gsub("goodby", "hello") というプログラムでは gsub に渡した引数を弄るかも知れないので、呼び出す度に2つの文字列を生成します。しかし、gsub は引数を弄らないので、本来であれば、frozen な文字列を(毎回生成せずに)渡すだけで良いはずです。frozen-string-literal pragma を使えば、プログラマがそのように指定することができますが、煩雑です。gsub がこのようなメソッドである、という情報を付加できれば、MRI が自動的に判断できそうです(がんばれば)。

これらは、MRI 開発者ががんばって付けていく情報になります。

(c) どれくらいメソッドが定義されるか、事前にわかりません。

rb_define_method で定義すると、起動が終わらないと、あるクラスに、どれくらいのメソッドが定義されるかわかりません。わかっていれば、先にメソッドテーブルをそのサイズで確保する、みたいなことができます。が、現在そういうのができません。

定義が事前に解析出来る形で書いてあれば、得られる情報です。

(2) 性能の問題

多くの場面で、C は Ruby よりも速いです。いろんな理由がありますが(最近、なぜrubyは他の言語と比べて遅いのでしょうか? という Quora の質問にこたえてみましたので、よかったら参考にして下さい)、まぁ適材適所、向いてる言語を使うべきでしょう。Ruby の主要部分を C で書くのは、そこそこ妥当だと思います(現代では、Rust などのより安全な言語を視野にいれるべきだとは思います)。

ですが、いくつかの場面で、実は Ruby は C で書くよりも速いことがあります。典型的な例は、キーワード引数の処理です。

# Ruby
def dummy_func_kw(k1: 1, k2: 2)
  dummy_func2(k1, k2)
end

こういう処理を C で書こうとすると、結構面倒ですがこんな感じになります。

static VALUE
tdummy_func_kw(int argc, VALUE *argv, VALUE self)
{
    VALUE h;
    ID ids[2] = {rb_intern("k1"), rb_intern("k2")};
    VALUE vals[2];

    rb_scan_args(argc, argv, "0:", &h);
    rb_get_kwargs(h, ids, 0, 2, vals);
    return tdummy_func2(self,
                        vals[0] == Qundef ? INT2FIX(1) : vals[0],
                        vals[1] == Qundef ? INT2FIX(2) : vals[1]);
}

これらのメソッドの速度を比較してみましょう。

f:id:koichi-sasada:20190417011812p:plain
キーワード引数のあるメソッドの呼び出しの速度比較

キーワード引数がないときは、C の方が速いです。というのも、Ruby での dummy_func2() 呼び出しは、C での tdummy_func2)() 関数呼び出しよりも圧倒的に遅いからです。

しかし、キーワードを与えると、圧倒的に Ruby で書いた方が速いです。というのも、キーワード引数のあるメソッドに、Ruby でキーワード引数を渡すときは、ハッシュオブジェクトを生成しない、特別な最適化が施されているからです。

例外処理も、同じような理由で Ruby で書いた方が速いです。

# in Ruby
def em_dummy_func_rescue
  nil
rescue
  nil
end
static VALUE
dummy_body(VALUE self)
{
    return Qnil;
}
static VALUE
dummy_rescue(VALUE self)
{
    return Qnil;
}
static VALUE
tdummy_func_rescue(VALUE self)
{
    return rb_rescue(dummy_body, self,
             dummy_rescue, self);
}

f:id:koichi-sasada:20190417011909p:plain
例外処理の速度比較

このように、たまにある「Ruby で書いた方がいい場合も、C で書いちゃう」という問題があります。

(3) 生産性の問題

(2) で例を出したように、Ruby だと数行のものが、C で書くと何十行、複数関数にまたがる、みたいなことがよく起きます。 C で表現するためにしょうがない部分なんですが、大変です。

例外処理やイテレータ、キーワード引数の処理なんかが該当しそうです。

また、あまり呼ばれないメソッドの場合、ささっと Ruby で定義しちゃってもいいかもしれませんね。今だと gem でやれって言われるかもしれませんが...。

余談ですが、私は C でキーワード引数の処理を書きたくなさ過ぎて、prelude.rb で Ruby 2.6 で導入した TracePoint#enable(target:) を実装しました。楽だったー。

(4) API に context を追加したい問題

rb_deifne_method() で登録する関数の引数は、基本的に self とパラメータ情報になります。しかし、我々が進めている並列処理機構である Guild では、現在の「コンテキスト」情報を渡す必要があります。mruby における mrb_state * です。

# mruby String#length
static mrb_value
mrb_str_size(mrb_state *mrb, mrb_value self)
{
  mrb_int len = RSTRING_CHAR_LEN(self);
  return mrb_fixnum_value(len);
}

Thread-local-storage (TLS) に保存する、と言う方法もありますが、とくに shared library 経由で利用すると、とても遅いことが知られています(詳細は、笹田等:Ruby 用マルチ仮想マシンによる並列処理の実現 (2012))。そこで、第一引数に、mruby みたいに情報を引き渡したすために API の変更が必要です。

問題の最後 (4) に持ってきましたが、個人的にはこれが一番なんとかしたい問題です。ただ単に API を変更しても、なかなか追従してもらえないんですが、いろんな特典がついたほうが移行しやすいよね、という戦略です。

問題のまとめ

4つの問題をまとめました。

  • (1) アノテーション(メタデータ)の問題 -> DSL が必要
  • (2) 性能の問題 -> 時々 Ruby のほうが速い
  • (3) 生産性の問題 -> Ruby で十分のときがある
  • (4) API に context を追加したい問題

(1) は、新たにメソッド定義のための DSL があれば解決しそうです。あれ、そういえば、DSL を構築しやすい言語に心当たりがあったような?

解決案:Ruby をつかおう!

問題を解決するために、Ruby で定義もしくは宣言を行うことを考えました。すべてを Ruby で置き換えるわけではなく、C で書いた方がよいところは C で書いて、Ruby の定義から簡単に呼び出せるようにすれば(FFIの導入)、既存の資産も有効活用でき、C の圧倒的な性能も利用できて良さそうです。

Ruby で書いておけば、後から解析することで、いろいろなことがわかります。また、内部DSL的にメソッドにアノテーションを付けることも可能でしょう。

問題点はこのように解決できます。

  • (1) アノテーション(メタデータ)の問題 -> Ruby で DSL を書いて解決
  • (2) 性能の問題 -> 素直に Ruby が得意なところで Ruby を書けば解決
  • (3) 生産性の問題 -> Ruby で簡単に済むところは Ruby で済ますことで解決
  • (4) API に context を追加したい問題 -> FFI で context を渡すようにすれば解決

新しい書き方

では、具体的にどんなふうに書いていくでしょうか。

文字列のメソッドを定義する string.rb を新設し、length メソッドを定義することを考えます。

# string.rb
class String
  def length
    __ATTR__.pure
    __C__.str_length    
  end
end
# String#length impl. with new FFI
static VALUE
str_length(rb_ec_t *ec, VALUE str)
{
    return LONG2NUM(
      str_strlen(str, NULL));
}

こんな感じで、__C__.str_length と書くと、str_length() が呼ばれる、という仕組みです。

なお、__C__ は適当です。多分、変わると思います。また、特別な実行モードでのみ利用可能になると思います。普段はローカル変数(もしくはメソッド名)ですね。

__ATTR__.pure も適当にでっちあげてるだけですが、こんな感じで、String#length の属性を人間が書けるようにしていければなと思っています。

これを使うと、プログラマはこんな感じになると思います。

  • Ruby の機能を使うことで、簡単に書けるところは簡単に書けるようになる。
  • C の関数を簡単に呼べるので、性能を落とさずにちゃんと書けるようになる。
  • いくつかの点に気を付けなければならない
    • GVL リリースや、GC タイミングなどが変わるので、気にする人はきにしないといけません。
    • 従来通りにしたければ、単に C の関数を呼び出す、というようになります。

疑問

さて、どうでしょうか。書きやすく、良さそうな感じがしないでしょうか。

ただ、きっと、パフォーマンスについて気にする人(私とか)は、次の点が気にならないでしょうか。

  • ランタイムオーバヘッド:FFI で C 関数呼び出しって遅いんじゃないの?
  • スタートアップ時間:Ruby スクリプトを読み込むから、スタートアップ時間が長くなってしまうんじゃないの?

この二つの疑問に答えるために、本発表では、次の二つの技術的成果についてご紹介します。

  • 高速な FFI を実現するための VM 命令の追加
  • ロード時間削減のためのコンパイルバイナリフォーマットの改善

ざっくり結論を申しますと、この二つの技術的成果を用いることで、C で全部書くよりは、若干遅いけど、でも十分速くなるので、多分問題ないんじゃないかな? という感じです。

ここまできて、やっと本題にたどり着きました。

高速な FFI を実現するための VM 命令の追加

長くなったので、手短に行きます。

__C__.func(a, b) のように関数を呼び出せるようにするために、invokecfunc という命令を VM に追加しました。fiddle などのミドルウェアを用いずに C の関数を呼び出すので高速です。

# string.rb
class String
  def length
    __C__.str_length    
  end
end

こういうプログラムは、

== disasm: #<ISeq:length@string.rb:10>
0000 invokecfunc                     
0002 leave

こんな感じでコンパイルされます。

ただ、invokecfunc を用いる関数呼び出しは、従来の C メソッドよりもオーバヘッドがあります。

  • (1) 引数を VM スタックに push するので遅い
  • (2) leave 命令でフレームを抜けるので、1命令実行が余分にかかり遅い

そこで、(1) の問題のために、__C__.func(a, b) に渡す実引数が、そのメソッドの仮引数 def foo(a, b) とまったく等しいとき、VM スタックにプッシュするのではなく、関数の引数にメソッドの引数をそのまま利用する invokecfuncwparam 命令を追加することにしました。

def dummy_func2 a, b
  __C__.dummy_func2(a, b)
end
0000 invokecfuncwparam<dummy_func2/2>
0002 leave

これで、「(1) 引数を VM スタックに push するので遅い」の問題が解決します。組み込み関数は、だいたい C で書いてある関数をそのまま呼ぶことになるんじゃないかと思うので(つまり、C 関数への delegator のような実装になるんじゃないかと思うので)、この命令を作る価値はあるのではないかと判断しました。

そして、leave をわざわざ次命令でやるのは無駄じゃないかと言うことで、invokecfuncwparam 命令の次の命令が leave の場合、その命令内でフレームを終了させる invokecfuncwparamandleave 命令を用意しました。

つまり、上記 dummy_func2 関数は、次のようにコンパイルされます。

0000 invokecfuncwparamandleave …
0002 leave

TracePointreturn イベントに対応するために、leave イベントは残す必要がありますが、基本的には invokecfuncwparamandleave 命令のみ実行するメソッドになります。

評価

さて、結果はどうなったでしょうか。

def dummy_func0
  __C__.dummy_func0
end
def dummy_func1 a
  __C__.dummy_func1(a)
end
def dummy_func2 a, b
  __C__.dummy_func2(a, b)
end

このように定義したメソッドと、これに対応する C メソッドの実装の実行時間を比べてみたのが次のグラフです。

f:id:koichi-sasada:20190417011942p:plain
FFIの高速化の評価結果

invokecfunc を用いるのみが baseline ですが、それだと C メソッドよりも遅かったのが、最適化を組み合わせることで、Cメソッドよりも高速に実行できることがわかります。

発表資料には、もう少しいろいろな評価があるので、そちらもご参照下さい。

まとめと今後の課題

まとめると、「FFI を用いると、ランタイムオーバヘッドは高いのでは?」という疑念に対し、「なんでもやる強い気持ちをもって最適化を行うと、問題ない(ことが多い)よ」ということです。性能を気にせず、Ruby で書けそうです。

今後の課題として、オプショナル引数などはまだ遅いので、オーバーローディングの仕組みを入れるなどして、典型的な例は速い、みたいなことを目指せればと思っています。引数の数によってメソッド実装を選ぶようなことを想定していますが、インラインキャッシュが使えるので、そこそこ feasible なのではないかと思っています。

関連研究に、私が10年前にやっていた「Ricsin: RubyにCを埋め込むシステム (2009.3)」という研究があります。これは、Ruby の中に、直接 C のプログラム片を埋め込めるようにする、という研究です。

# Writing C in Ruby code
def open_fd(path)
  fd = __C__(%q{ // passing string literals to __C__ methods
    /* C implementation */
    return INT2FIX(open(RSTRING_PTR(path), O_RDONLY));
  })
  raise 'open error' if fd == -1
  yield fd
ensure
  raise 'close error' if -1 == __C__(%q{
    /* C implmentation */
    return INT2FIX(close(FIX2INT(fd)));
  })
end

C の中から、Ruby の変数にアクセスできるのがキモ面白いところだと思っています。将来的には、こういう拡張ができるようにしても面白いかも知れないと思っています。

なお、本稿では、FFI の実装に必要になる関数テーブルの作成部分は、ちょっと面倒なので省略しました。正直、ここがブートストラップで一番難しいところなんですよね。

ロード時間削減のためのコンパイルバイナリフォーマットの改善

ランタイムオーバヘッドの懸念が解消されたら、次はスタートアップタイムが伸びてしまうんじゃないかという懸念についての返答です。Ruby でメソッドを定義するようにしたら、複数の .rb ファイルを起動時に読むから遅そうなんじゃないの、という話です。

Ruby では 2.3 から、バイトコード(MRI では ISeq という用語を使います)をバイナリにダンプする仕組みを持っています。

# dump
bin = RubyVM::InstructionSequence#to_binary

# load
RubyVM::InstructionSequence.load_from_binary(bin)

AOT コンパイルみたいな用語を使ってもいいと思います。bootsnap でも使っていますね。事前にコンパイルすることで、コンパイルのコストを抑えられるんじゃないか、という期待で作ったものです。

で、そのバイナリデータを、例えば C の配列表現にして MRI と一緒にコンパイルすれば、MRI のバイナリに統合することができます。ちなみに、起動後に mmap しても、だいたい同じような感じにすることができます。実験では、前者を使いましたが、正直最初から mmap でやればよかったな。

で、それだけだとなんなので、二つの仕組みをさらに有効にすることで、より効率的に出来るんじゃないかと思います。

Lazy ローディング

ISeq は、ツリー構造になっています。トップレベル iseq が、クラス定義 iseq をもち、それがメソッド iseq を持つ、という感じです。メソッドを起動されない限り、メソッド iseq は使われません。つまり、iseq のロードを、実際に使われるまで、遅延することができるということです。これを lazy ローディングと言います。

実は、この lazy ローディング、Ruby 2.3 の段階で入っていたんですが(vm_core.h の USE_LAZY_LOAD マクロ)、イマイチ使わないかなーと思ってたんですが、起動時に全部の iseq を作るよりも、実際に使うメソッドやブロックだけロードするほうが圧倒的に速いので、これを有効にしちゃおうかなあと思っています。

たいてい、あるプログラムで呼ばれるメソッドなんて、定義されたメソッドのごく一部でしょうから、そこそこ納得感ある話なんじゃないかと思います。

ロード済みかイチイチチェックが入るので、若干遅くなるんですが、分岐予測で十分カバー出来る範囲かな、と思っています。

なお、この仕組みについては、RubyKaigi 2015 の私の発表や、「笹田等: Ruby処理系のコンパイル済みコードの設計 (2015)」に詳しいです。もう3~4年前なんだな。

複数のファイルをサポート

現在の compiled binary は、一つのファイルが一つのバイナリを出すようにしかなっていません。しかし、複数のファイルをまとめて一つのバイナリにすれば、共有部分が増えて、リソースが若干節約できます(多分)。

そこで、複数ファイルを一つの compiled binary にまとめることができるようにしました。現在は数値インデックスでしかアクセス出来ませんが、ファイル名でアクセスできるように拡張する予定です。

bin = RubyVM::InstructionSequence.to_binary(iseq1, iseq2, ...) と複数の iseq を読み込み、

loader = RubyVM::InstructionSequence::Loader.new(bin)
iseq0 = loader.laod(0)

のように取り出すことができるようにしてみました。

評価

評価のために、3000個のクラス C0~C2999 を作り、各クラスが 1~20個のメソッドを持つ(def m0; m; endのような単純なメソッド。全合計3万メソッドくらい)、というサンプルを作って実験してみました。

  • 1ファイルに詰め込む場合
    • .rb が 582KB
    • compiled binary が 16MB
    • それを C の配列表現にすると 79MB(!)
  • 各クラスごとにファイルを作る
    • .rb が 3000 個
    • まとめた compiled binary が 17MB
    • それを C の配列表現にすると 86MB
  • 従来の C メソッドでの定義の仕方を用いると、4.2MB の .c

この3通りを用いて、ロードして Ruby が起動する時間をはかってみました。なお、--disable-gems で rubygems などのライブラリはロードしないようになっています。

結果は次のようになりました。

f:id:koichi-sasada:20190417012010p:plain
ロード時間の評価結果

結果を見ると、従来の C での定義が最も速く 27.5 秒で、lazy loading を用いることで、だいたい 2 倍程度の性能低下で済む、という具合です。

単なる compiled binary のロードだと 3 倍遅い。普通に .rb としてロードするよりも6~16倍程度遅い、という結果になりました(一番下の結果ひどいな)。というわけで、従来手法に比べると、やはり速いのだけれど、まだ C メソッド定義に及ばず、というところです。

まとめと今後の課題

工夫によって、スタートアップタイムについては、従来の C メソッドのロード時間より大幅に遅い、ということはなかったのですが、まだ若干遅いです。もう少しなんとかならないでしょうか。

.rb を書いておくと、事前にどのクラスにどんなメソッドが定義される、というのがわかるので、先にテーブルだけ作っておいて、メソッド問い合わせが来たときに初めて iseq のロードを始めるような、より lazy なやり方なんかが効くんじゃ無いかと思います。そこまでやれば、C メソッドのロードよりも速くなるんじゃないかな?

あと、単純にコンパイル済みバイナリがむっちゃでかいんですよね。わざと小さくしないようにしたんですが、さすがに大きすぎなのでなんとかしたい。多分、簡単に 1/5 くらいにはなると思います。誰かやってくれません?

本稿のまとめ

本稿では、私の RubyKaigi 2019 の発表である「Write a Ruby interpreter in Ruby for Ruby 3」について述べました。

現在 MRI では、ほぼすべて C で記述されていますが、それを良い感じに Ruby と混ぜるために、特別な FFI 記法の導入を提案しました。

そして、そこで懸念される「ランタイムオーバヘッド」および「スタートアップタイムの増加」について、いくつかのテクニックをご紹介し、そこそこ feasible な結果を出すことで、懸念をそこそこ払拭できたんじゃないかと思います。

現在の組込クラス・メソッドの定義を書き換えるとなると、多くの人手が必要になります。まだ、この方針で Ruby 3 向け(Ruby 2.7 向けかな?)に書き換えるという合意は取れていませんが、取れたらばばばーと書き換える作業が発生します。ある程度機械的な作業になるんですが、良い機会なので興味がある方、一緒にやりませんか?

われわれは Ruby Hack Challenge というイベントを開催しており、次回は Ruby Hack Challenge Holiday #3 が 5/11 (土) に行います。こういう場で、Ruby (MRI) 開発に参加してくれる方がいらっしゃいましたら、お声かけ頂けましたら幸いです。

というわけで、RubyKaigi 2019 でお会いできることを楽しみにしております。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/