ダイナミックSpinel(2) DSLプラグイン編
(1) eval編では、Spinelでコンパイルしたプログラムの中にRubyのインタプリタを組み込むツール、Fluxpinel1を紹介しました。この記事は、ユーザがRubyで書いたプラグインを、コンパイル済みのプログラムが実行時に読み込む例を紹介します。
アクセスログを解析するCLI、logqを作りました。Fluxpinelのリポジトリのexamples/logqにあります。
ログの読み込み、絞り込み、集計といった重いループはSpinelがコンパイルします。そして「何を調べるか」は、ユーザがRubyでファイルに書いておき、CLIが実行時にそれを読み込みます。
ファイル構成
examples/logq/
├── app.rb # カーネル: Spinelがコンパイルする
├── dsl.rb # DSLの土台: 実行のたびにFluxpinelが読む
├── gen.rb # サンプルのログを作る
└── queries/
├── errors.rb # クエリ: ユーザが書く
└── per_row_block.rb # 同じ種類のクエリを、行ごとのブロックで書いたもの
build/logq/
└── logq # app.rbとFluxpinelをひとつにしたバイナリ
それぞれのファイルの役割は次のとおりです。ここで「ユーザ」とは、ログに対して具体的な調査をしたい人です。
| ファイル | 書く人 | 読まれるとき | 編集すると |
|---|---|---|---|
app.rb |
logqの作者 | ビルド時(Spinelがコンパイル) | 再ビルドが必要 |
dsl.rb |
logqの作者 | 実行のたびに(Fluxpinelが読む) | 再ビルドなしで、DSLの語彙を変えられる |
queries/*.rb |
logqのユーザ | 実行のたびに(Fluxpinelが読む) | 再ビルドなしで、調べる内容を変更できる |
app.rbだけがコンパイルされて、バイナリbuild/logq/logqになります。このバイナリには、Fluxpinel(インタプリタ)も入っています。dsl.rbとqueries/*.rbはふつうのRubyのファイルのまま、実行のたびにバイナリが読み込みます。
ビルドは一度だけです。
$ make host HOST=build/logq HOST_NAME=logq KERNEL=examples/logq/app.rb KERNEL_MAIN=Logq.main \
KERNEL_ENTRIES=Logq.main,Logq.all,Logq.where,Logq.slower,Logq.where_block,Logq.top,Logq.count_by
KERNEL_ENTRIESは、スクリプトから呼べるようにエクスポートするメソッドです。KERNEL_MAINは、バイナリの起動時に呼ぶメソッドです。
あとは何度でも実行できます。
$ ruby examples/logq/gen.rb 1000000 > access.log
$ build/logq/logq access.log examples/logq/queries/errors.rb
つまりユーザが触るのはqueries/の下だけです。クエリを書き換えて、すぐにもう一度実行できます。dsl.rbの場所は、コンパイル時の__dir__(examples/logq)が決めます。そのため、いまはFluxpinelのトップディレクトリから実行します。
ユーザが書くクエリ
queries/errors.rbはこんなファイルです。
# queries/errors.rb
report "slow 5xx" do
where_status(500..599).slower_than(1.0).top(5, by: :duration)
end
report "POST by path" do
where_verb("POST").count_by_path
end
report "API errors" do
where_path(%r{\A/api/}).where_status(400..).size
end
where_statusやcount_by_pathというメソッドは、どこにも定義されていません。method_missingが名前を見て、カラムに対する操作に読み替えます。そして初回の呼び出しでdefine_methodしてメソッドを生やし、2回目からはふつうのメソッド呼び出しになります。Rubyピーポーが好きなやつです。
DSLの土台
これもFluxpinelが実行時に読み込むRubyだから、再ビルドなしにlogqの語彙を増やせます。
# dsl.rb
class Query
def initialize(sel) = @sel = sel
def size = @sel.size
def slower_than(sec) = Query.new(Logq.slower(@sel, sec))
def where(&blk) = Query.new(Logq.where_block(@sel, blk))
def top(n, by:) = Logq.top(@sel, n, by.to_s)
def method_missing(name, *args)
case name
when /\Awhere_(\w+)\z/
field = $1
self.class.define_method(name) { |cond| Query.new(Logq.where(@sel, field, cond)) }
when /\Acount_by_(\w+)\z/
field = $1
self.class.define_method(name) { Logq.count_by(@sel, field) }
else
return super
end
send(name, *args)
end
def respond_to_missing?(name, include_all = false) = name.match?(/\A(where|count_by)_\w+\z/) || super
end
class Reports
def initialize = @list = []
def add(title, blk) = @list << [title, blk]
# the title and the result of each report; a Query answers its size
def run
@list.map do |title, blk|
result = Query.new(Logq.all).instance_eval(&blk)
[title, result.is_a?(Query) ? "#{result.size} rows" : result]
end
end
end
REPORTS = Reports.new
def report(title, &blk) = REPORTS.add(title, blk)
REPORTS
Queryが持っているのは、選んだ行の番号の配列(@sel)だけです。Logq.all、Logq.where、Logq.count_by、Logq.topは、コンパイル済みのコードのエントリです。100万行のループはCの速さで回ります。DSLの側では、次のことが起きます。
method_missingが受ける- 実行時に組み立てた名前のメソッドを、
define_methodが生やす sendが呼び直すinstance_evalが、ブロックのselfを差し替える
Queryはスクリプトで定義したクラスです。where_statusの呼び出しを解決し、見つからなければmethod_missingに回すのは、SpinelではなくFluxpinelです。
カーネルとスクリプトの境界
こちらはSpinelでコンパイルする側です。ログを列ごとの配列に読み込み、dsl.rbとユーザのクエリを読ませ、結果を表示します。抜粋です。
# app.rb(抜粋)
require_relative "../../kernel/fluxpinel"
module Logq
class Table
attr_reader :verbs, :paths, :statuses, :durations
# ...
end
TABLE = Table.new
# the rows of sel whose field takes cond with ===: a value, a Range, a
# Regexp or a class
#: (Array[Integer], String, untyped) -> Array[Integer]
def self.where(sel, field, cond)
out = []
case field
when "verb" then sel.each { |i| out << i if cond === TABLE.verbs[i] }
when "path" then sel.each { |i| out << i if cond === TABLE.paths[i] }
when "status" then sel.each { |i| out << i if cond === TABLE.statuses[i] }
when "duration" then sel.each { |i| out << i if cond === TABLE.durations[i] }
else raise ArgumentError, "no field #{field}"
end
out
end
def self.main(args)
n = load(args[0])
puts "#{n} rows"
dsl = Fluxpinel.load(File.join(__dir__, "dsl.rb"))
Fluxpinel.load(args[1])
Fluxpinel.call(dsl, "run", []).each do |title, result|
puts "== #{title}"
print_result(result)
end
end
end
#: (Array[Integer], String, untyped) -> Array[Integer]は、Spinelが読むインラインのRBSです。whereはスクリプトからしか呼ばれず、コンパイル済みのコードの中には呼び出し元がありません。呼び出し元がない引数は、Spinel上では型のない値になります。RBSを付けると、selは整数の配列として扱われ、ループが型付きのCになります。
エントリが返す整数の配列は、スクリプトの側でもそのまま持てます。それをまた次のエントリに渡すので、行の番号の配列はコピーされずに、カーネルとスクリプトの間を行き来します。
カーネルとスクリプトの間の行き来は、次のとおりです。
- カーネルがスクリプトを読み込む:
Fluxpinel.load - カーネルがスクリプトのメソッドを呼ぶ:
Fluxpinel.call(dsl, "run", []) - スクリプトがカーネルのエントリを呼ぶ:
Logq.where(...)など
Fluxpinelの効かせ方
ここで疑問が湧くと思います。Fluxpinelは素朴なインタプリタです。ログ100万行が毎回ユーザのクエリを呼んだら、全体はインタプリタの速さになってしまうのでは?
そのとおり。だからlogqは、行のループ内でスクリプトが走らないように作ってあります。
where_status(500..599).slower_than(1.0).top(5, by: :duration)
このクエリでFluxpinelがエントリを呼ぶのは、3回だけです。たとえばwhere_status(500..599)は、Logq.where(@sel, "status", 500..599)を1回呼びます。100万行のループはエントリの中、つまりコンパイル済みのコードで回ります。条件の500..599は値のままカーネルに渡って、カーネルが各行でcond === vを評価します。これはランタイムのRange#===だから、ネイティブの速さです。
queries/errors.rb(Fluxpinel) app.rb(AOT)
where_status(500..599) ---1回---> Logq.where: 100万行のループ
.slower_than(1.0) ---1回---> Logq.slower: 残りの行のループ
.top(5, by: :duration) ---1回---> Logq.top: ソート
| 処理 | 動く場所 | 回数 |
|---|---|---|
| ログの読み込みと分解 | AOT | 行の数 |
method_missing、define_method、send |
Fluxpinel | 操作の数(レポートごとに数回) |
| 絞り込み、集計、ソート | AOT | 行の数 |
スクリプトの仕事は「操作の数」に比例し、「行の数」には比例しません。動的な部分のコストは誤差になります。pandasやNumPyと似た形です。Pythonが操作を組み立てて、ループはCが回すんですよね?(あんまりわかってない)
逆に、行ごとのブロックを許すと遅くなります。dsl.rbのwhereはブロックを取り、こう書くこともできます。
# queries/per_row_block.rb これはダメな例です
report "API errors (block)" do
where { |row| row["path"].start_with?("/api/") && row["status"] >= 400 }.size
end
errors.rbの”API errors”と同じことを調べるクエリです。カーネルのLogq.where_blockは、各行でこのブロックを呼びます。
#: (Array[Integer], untyped) -> Array[Integer]
def self.where_block(sel, blk) = sel.select { |i| Fluxpinel.call(blk, "call", [TABLE.row(i)]) }
ログが100万行なら、インタプリタに100万回入ってしまいます。どこまでを動的にして、どこからをAOTにするか。その線引きが、Fluxpinelの使いどころです。
結果
100万行のログにerrors.rbを実行した結果:
$ build/logq/logq access.log examples/logq/queries/errors.rb
1000000 rows
== slow 5xx
{"verb" => "GET", "path" => "/api/users", "status" => 500, "duration" => 2.997}
{"verb" => "GET", "path" => "/", "status" => 503, "duration" => 2.997}
{"verb" => "DELETE", "path" => "/static/app.js", "status" => 502, "duration" => 2.997}
{"verb" => "GET", "path" => "/static/app.css", "status" => 502, "duration" => 2.997}
{"verb" => "GET", "path" => "/", "status" => 500, "duration" => 2.997}
== POST by path
/static/app.css: 12590
/static/app.js: 12581
/cart: 12509
/logout: 12496
/api/users: 12451
/api/orders: 12417
/api/items: 12388
/search: 12377
/: 12338
/login: 12294
== API errors
24843
kernel/fluxpinel_cruby.rbは、Fluxpinel.loadなどのCRuby版の実装です。ruby -rがこれを読み込めば、同じapp.rb、dsl.rb、クエリがCRubyの上でも動きます(Logq.main(ARGV)を呼ぶ小さなファイルも要ります。手順はREADMEにあります)。出力も一致します。
時間を比べます。100万行、7回ずつ実行した中央値です。揺れを減らすため、どれもCPUのコアを1つに固定しています。CRubyは4.0.9。YJITとZJITを有効にした場合も測りました。
| CRuby | CRuby + YJIT | CRuby + ZJIT | Spinel + Fluxpinel | |
|---|---|---|---|---|
| ログの読み込みだけ | 1.56秒 | 1.50秒 | 1.53秒 | 0.56秒 |
errors.rb(全体) |
2.21秒 | 2.02秒 | 2.07秒 | 0.79秒 |
per_row_block.rb(全体) |
2.43秒 | 2.26秒 | 2.35秒 | 2.14秒 |
操作を組み立てるDSL(errors.rb)なら、全体でCRubyのインタプリタの約2.8倍、YJITやZJITと比べても約2.6倍の速さです。method_missingとdefine_methodが動いているのに。
その一方、行ごとのブロック(per_row_block.rb)にすると、全体の時間はCRubyとほとんど変わりません。per_row_block.rbのクエリは、errors.rbの3つのレポートのうちの1つと同じなのに、全体で1.4秒ほど余計にかかっています。読み込みで稼いだぶんを、100万回インタプリタに入ることによって使い果たしてしまいました。
このようにlogqを作り直さずに、ユーザは好きなクエリを書けます。RakefileやGemfileと同じ、「ツールの設定や拡張をRubyのDSLで書く」という作法を、コンパイル済みのツールに持ち込めるわけです。
つぎはいよいよRails
AOTコンパイラだから、Rubyの動的なところはあきらめる。そんなふうに考えていた時期が筆者にもありました。でもlogqを作ってみると、問題は「動的か、速いか」ではなく「どこに線を引くか」でした。ホットパスはコンパイル済みのコードに置く。操作を組み立てるところには、method_missingやdefine_methodを書いていい。この線をうまく引ければ、速さはほとんど落ちません。
(3) Railsコンソール編では、動いているRailsバイナリにソケット接続するコンソールを作ります。