このエントリーをはてなブックマークに追加
はてなブックマーク - Railsより100倍速いフレームワークをRubyで作ってみた
Bookmark this on Digg

Rails より 100 倍、Sinatra より 20 倍速いフレームワーク「Keight.rb」を作りました。Ruby 用です。

ただし、α版なのでまだいろいろ足りない点があります。ご了承ください。

ベンチマーク

“hello world” を返すだけのベンチマークをこちら に載せてます。なおこのベンチマークは、ab や siege や wrk を使ったのではなく、純粋に app.call(env) の部分のみを計測しています。これはサーバのオーバーヘッドを取り除いた、フレームワークのみのスピードを調べたいからです。

FW Request sec/1000req req/sec
Rails GET /api/hello 7.5188 133.0
Rails GET /api/hello/123 8.0030 125.0
Sinatra GET /api/hello 1.5034 665.2
Sinatra GET /api/hello/123 1.6328 612.4
Rack GET /api/hello 0.0789 12674.3
Keight GET /api/hello 0.0773 12936.6
Keight GET /api/hello/123 0.1385 7220.2
  • Ruby 2.2.3
  • Rails 4.2.4
  • Sinatra 1.4.6
  • Rack 1.6.4 (= Rack::Request + Rack::Response)
  • Keight 0.1.0

個別に見ていきましょう。Rails と比べると、Keight.rb は URL パスパラメータがない場合 (GET /api/hello) で約 100 倍、ある場合 (GET /api/hello/123) で約 60 倍高速です。

FW Request sec/1000req req/sec
Rails GET /api/hello 7.5188 133.0
Rails GET /api/hello/123 8.0030 125.0
Keight GET /api/hello 0.0773 12936.6
Keight GET /api/hello/123 0.1385 7220.2

Sinatra と比べると、Keight.rb は URL パスパラメータがない場合 (GET /api/hello) で約 20 倍、ある場合 (GET /api/hello/123) で約 10 倍高速です。

FW Request sec/1000req req/sec
Sinatra GET /api/hello 1.5034 665.2
Sinatra GET /api/hello/123 1.6328 612.4
Keight GET /api/hello 0.0773 12936.6
Keight GET /api/hello/123 0.1385 7220.2

注目すべきは Rack との比較です。素の Rack アプリではないのですが、Rack::Request と Rack::Response を使った Rack アプリよりも、Keight.rb のほうが若干ですが高速です。前者は routing 処理を一切行っていないため、GET /api/hello/123 の場合はありません。

FW Request sec/1000req req/sec
Rack GET /api/hello 0.0789 12674.3
Keight GET /api/hello 0.0773 12936.6
Keight GET /api/hello/123 0.1385 7220.2

とはいえ、先に述べたようにこれは app.call(env) だけを計測したものであり、アプリサーバを含めたベンチマークではありません。サーバを起動して ab や siege や wrk で計測するとここまでの差はなく、Rails との比較では約 8 倍、Sinatra との比較では約 2 倍高速になった程度でした。これについては後述します。

使い方

README から抜粋します。

$ ruby -v     # required Ruby >= 2.0
ruby 2.2.3p173 (2015-08-18 revision 51636) [x86_64-darwin14]
$ mkdir gems
$ export GEM_HOME=$PWD/gems
$ export PATH=$GEM_HOME/bin:$PATH

$ gem install keight rack
$ vi hello.rb
$ vi config.ru
$ rackup -p 8000 config.ru

hello.rb:

# -*- coding: utf-8 -*-
require 'keight'

class HelloAction < K8::Action

  mapping ''       , :GET=>:do_index, :POST=>:do_create
  mapping '/{id}'  , :GET=>:do_show, :PUT=>:do_update, :DELETE=>:do_delete

  def do_index
    #{"message"=>"Hello"}   # JSON
    "<h1>Hello</h1>"        # HTML
  end

  def do_show(id)
    ## 'id' or 'xxx_id' will be converted into integer.
    "<h1>Hello: id=#{id.inspect}</h1>"
  end

  def do_create    ; "<p>create</p>"; end
  def do_update(id); "<p>update</p>"; end
  def do_delete(id); "<p>delete</p>"; end

end

config.rb:

# -*- coding: utf-8 -*-
require 'keight'
require './hello'

app = K8::RackApplication.new()
app.mount '/hello', HelloAction

### or
#mapping = [
#  ['/api', [
#    ['/hello'         , "./hello:HelloAction"],
#  ]],
#]
#app = K8::RackApplication.new(mapping)

run app

rackup で起動したら、ブラウザで http://localhost:8000/hello や http://localhost:8000/hello/123 にアクセスして表示を確かめてください。

もっと本格的なものをお望みなら、k8rb init コマンドを実行してみてください。テンプレートエンジンや static ファイルの扱いなどが用意されています。

$ k8rb init myapp1
$ cd myapp1
$ export APP_ENV=dev    # 'dev', 'prod', or 'stg'
$ k8rb mapping
$ k8rb configs
$ rackup -p 8000 -E production config.ru
$ ab -n 1000 -c 10 http://127.0.0.1:8000/api/hello
## or:
$ gem install puma
$ rackup -p 8000 -E production -s puma config.ru
$ ab -n 10000 -c 100 http://localhost:8000/api/hello

なぜ Keight.rb は速いのか?

Keight.rb が速いのは、主に routing 処理を高速化したためです。概要だけ紹介すると:

  • URL パスパラメータを含まないエントリ (ex: ‘/api/hello’ ) は Hash に格納する。これで O(1) で探索できるだけでなく、URL パスパラメータを含むエントリの探索範囲を大きく減らせる。
  • Routing エントリを prefix (ex: ‘/api’, ‘/admin’) でグループ化する。これは高速化というよりは、エントリ数が多くなっても遅くならないようにするため。
  • 重い正規表現を避け、軽い正規表現で済ませる。Ruby 2.0 から Regexp の性能が落ちているので、正規表現のチューニングは重要。特に名前つきキャプチャは遅いので全力で避ける。

とはいっても、高度なアルゴリズムやデータ構造を使っているわけではありません。Computer Science の知識がなくても理解できるような方法しか採用していません1。それでもこれだけ高速化できましたし、逆にいうと他のフレームワークではこんなことすらしていないのか!とも言えます。

要約すると、Keight.rb が速いわけではなく、他のフレームワークが遅すぎるだけです。

なぜ実アプリだと100倍速くはならないのか?

先に説明したように、Keight.rb が Rails より 100 倍速いといっても、それは app.call(env) の部分だけです。アプリサーバを含めたベンチマークではせいぜい数倍の違いにしかなりません。これでデータベースへのアクセスが入れば、差はもっと少なくなるでしょう。

なぜこうなるかというと、フレームワーク以外の部分がボトルネックになるからです。

フレームワークを20高速化しても全体の速度がそこまで上がらない理由

この絵を見れば、フレームワークをこれ以上速くしても全体の速度には影響しないことがわかります2

逆にいえば、ab や siege や wrk でのベンチマークでは 10 パーセントしか違いがなくても、実際のコードでは 10 倍ぐらい速度差があるといえます。

なぜ Keight.rb を作ったのか?

Keight.rb を作った最大の目的は、Rails が遅いのは Rails 自身のせいであって Ruby のせいではないことを証明することです。

Rails というフレームワークは、たとえるならサイドブレーキをかけたまま運転している車のようなものです。そんな状態でいくらエンジンを強力にしても、まともなスピードが出るわけありません。しかし世の中は「Rails が遅いのは Ruby というエンジンが遅いからだ」「強力なエンジンを積めば Rails が速くなるはずだ」と思い込んでるバカがかなりいます。

たしかに、強力なエンジンを積めば Rails が速くなるでしょう。しかしそれで達成できる高速化はわずかです。それよりも先にすべきなのは、サイドブレーキを解除することです。それをせずにエンジンを高速化しても、車が速くなることはないでしょう。

Ruby の GC が遅いのではなく、Rails がオブジェクトを作りすぎているから GC が遅いように見えるのです。GC を高速化するのも大事ですが、先にすべきはオブジェクトの作りすぎをやめることです。

Ruby の文字列リテラルが Mutable なのが問題なのではなく、Rails が無駄に文字列を作りすぎているのが問題なのです。文字列リテラルを Immutable にするよりも、Hash#stringify_keys() のようなのをあちこちで使っている現状を改めるべきです。

 
 

 

 

こういった、Rails が遅いのを Ruby のせいにするバカや、Rails の速度と Ruby の速度の違いが分からないバカを一匹残らず駆逐したいです。

Rack-Multiplexer との比較

話しは変わりますが、Rack アプリケーションでの routing は、Rack::Multiplexer が高速なんだそうです。高速な理由は、Rack::Multiplexer が「O(1) Router」だから だそうです。

しかしベンチマークをとると、明らかに URL パスのエントリ数に応じて、つまり n に比例して遅くなっています。以下は Keight.rb 付属のベンチマーク結果です (単位: usec/req、小さいほど高速)。これをみると、次のことがわかります。

  • エントリ数に比例して、Rack::Multiplexer は明らかに遅くなる。Keight.rb も遅くなるが、わずかである。
  • URL パスパラメータがある場合 (ex: /api/books/:id )、どちらも遅くなるが、Rack::Multiplexer のほうが落ち方が激しい。
Request Multiplexer Keight
GET /api/books 6.7282 6.3584
GET /api/books/123 19.1501 11.5798
GET /api/support 13.3628 6.2821
GET /api/support/123 26.1931 11.8862
GET /admin/aaa01 14.3839 6.3615
GET /admin/aaa01/123 26.8434 11.9656
GET /admin/zzz26 36.0899 6.4987
GET /admin/zzz26/123 49.5345 12.4415

遅いのはまあいいんですが、これで O(1) だと説明するのはいかがなものかと思います。ベンチマーク結果を見ても、最初の要素 (/api/books) へのアクセスだけは速いですが、それ以降は後ろの要素ほど如実に遅くなっています。これのどこが O(1) なんだよ!要素数が増えても速度が一定なのが O(1) のはずなのに、これはよくない。ぶっちゃけ、1 つの正規表現にコンパイルしない古き良きやり方でも、Rack::Multiplexer より速い router を作れました。

Ruby のメソッド呼び出しが 1 回であることと、O(n) であることとは関係ないです。 arr.find(x) のようなコードを考えればすぐに分かると思いますが、たとえメソッド呼び出しが 1 回でも、中で O(n) のコードが実行されていればそれは O(n) です。メソッド呼び出しの回数が 1 回であることを強調したいならそう言えばよく、「O(1) Router」と名乗るのはおかしいでしょう。

また Rack::Multiplexer は、正規表現でのマッチングは 1 回だけで済んでますが、どの名前つきキャプチャにマッチしたかを調べる部分のコードが明らかに O(n) です。

      def find(path)
        if regexp === path
          @routes.size.times do |i|
            return @routes[i] if Regexp.last_match("_#{i}")
          end
        end
      end

こんなコードで O(1) と主張するのは、ナシでしょう。

加えて、405 Method Not Allowed にすべき場合でも 404 Not Found にしているのがよくないと思います。たとえば GET /api/books が登録されている場合に POST /api/books が呼ばれたら、これは 405 Method Not Allowed にすべきです。しかし今の Rack::Multiplexer では 404 Not Found になってしまいます。意図した仕様なのかはわかりませんが、router の仕様としてはよくないと思いました。

まあ Rack::Multiplexer は Router::Broom を参考にしているそうなので、もとの実装がよくなかったのでしょう。

Ruby は遅いのか、速いのか?

「Rails が遅いのは Rails 自身のせいであって Ruby のせいではない」と説明しましたが、これは必ずしも「Ruby が高速である」ことは意味しません。「Rails が遅いのは Ruby のせいではない」ことと、「Ruby が速い or 遅い」ことは別の話であることに注意してください (この区別がつかない人は中学校からやり直そうな)。

それで、Ruby が速いか遅いかですが、そんなのは「用途によって変わる」としか言いようがないです。とはいえ、そんな回答では納得しない人も多いでしょうから、ここではたいていの用途なら Ruby で充分間に合う (そのくらいには高速) だと答えておきましょう。

たとえば、新しい車を選ぶとしましょう。通勤が目的なら、普通の乗用車で充分です。買い物も、ちょっとした遠出も、乗用車で充分です。けど F1 レースに出場するなら、F1 カーを選ぶしかありません。

そして、Ruby は乗用車です。みなさんが普段使うぶんにはこれで充分です。Twitter や Netflix のような F1 レースの常連は F1 カーを選ぶべきですが、それを見て「これからは我が社も F1 カーだ!」と言いだすのはただのバカです。

ただし、今の Ruby には非同期 I/O やイベント駆動といった機能が弱いです。これは最近のトレンドに取り残されている感が否めません。たとえば簡単なベンチマークをとると Python3 は Ruby 2.2 より遅いのですが、Python 3.4 で yield from が、そして Python 3.5 で async/await がサポートされたため、「Python3 は Ruby より遅い」などと簡単に言えるものではないです。この点については、GIL とともに Ruby (CRuby) の弱点と見なしていいでしょう。文字列リテラルの Immutable 化よりも、こっちのほうを改善してほしいです。

おわりに

「Rails が遅いのは Rails 自身のせいであって Ruby のせいではない」ことを証明するために、Ruby で高速なフレームワークを作りました。何度も書きますけど、「Rails が遅いのは Ruby が遅いから」などと平気で言ってるようなバカをこの世から一匹残らず駆逐したいです。

Ruby が人気になったのは Rails のおかげであることは間違いないでしょう。しかし逆に、Rails の人気に陰りがでると Ruby の人気も落ちるという未来は充分あり得ます (し、すでに現実化しつつあります)。こんな未来は全力で回避せねばなりません。そのためにも「打倒 Rails」へ向けて行動していきたいです。


  1. べつに Computer Science を否定しているわけではないので注意してください。単に、高度なデータ構造やアルゴリズムを使わなくても改善できることがたくさんある、というそれだけの話です。 

  2. あるいは、全体の速度を上げるような仕組みをフレームワークが用意するとか、処理を多重化して見かけ上の速度を向上させることが重要と言えます。