JITコンパイラの欠点: NYIを避けるべき理由

API7.ai

September 30, 2022

OpenResty (NGINX + Lua)

前回の記事では、LuaJITのFFIについて見てきました。もしあなたのプロジェクトがOpenRestyが提供するAPIのみを使用し、C関数を呼び出す必要がない場合、FFIはそれほど重要ではありません。lua-resty-coreが有効になっていることを確認するだけで十分です。

しかし、今日話すLuaJITのNYIは、OpenRestyを使用するすべてのエンジニアが避けられない重要な問題であり、パフォーマンスに大きな影響を与えます。

OpenRestyを使用すれば、論理的に正しいコードを素早く書くことができますが、NYIを理解していなければ、効率的なコードを書くことはできず、OpenRestyの力を引き出すこともできません。両者のパフォーマンス差は少なくとも一桁以上です。

NYIとは何か?

まず、以前に述べた点を思い出してみましょう。

LuaJITのランタイムは、Luaインタプリタのアセンブリ実装に加えて、直接マシンコードを生成できるJITコンパイラを持っています。

LuaJITのJITコンパイラの実装はまだ完全ではありません。いくつかの関数をコンパイルできないのは、それらが実装が難しいためであり、またLuaJITの作者が現在半引退状態であるためです。これには、一般的なpairs()関数、unpack()関数、Lua CFunction実装に基づくLua Cモジュールなどが含まれます。これにより、JITコンパイラは現在のコードパスでサポートしていない操作に遭遇した場合、インタプリタモードにフォールバックすることができます。

LuaJITの公式ウェブサイトにはこれらのNYIの完全なリストがありますので、ぜひ目を通してください。この記事の目的は、このリストを暗記することではなく、コードを書く際に意識的に思い出すことです。

以下に、NYIリストから文字列ライブラリのいくつかの関数を取り上げました。

string library

string.byteのコンパイル状態はyesで、JITで最適化できることを意味し、コードで使用しても問題ありません。

string.charのコンパイル状態は2.1で、LuaJIT 2.1以降でサポートされていることを意味します。OpenRestyのLuaJITはLuaJIT 2.1に基づいているため、安心して使用できます。

string.dumpのコンパイル状態はneverで、JITで最適化されず、インタプリタモードにフォールバックします。現時点では、将来的にサポートする予定はありません。

string.findのコンパイル状態は2.1 partialで、LuaJIT 2.1以降で部分的にサポートされており、その後の注記には固定文字列の検索のみサポートし、パターンマッチングはサポートしていないとあります。したがって、固定文字列の検索にはstring.findをJITで最適化できます。

当然、NYIを使用しないようにして、より多くのコードをJITコンパイルし、パフォーマンスを保証するべきです。しかし、実際の環境では、いくつかのNYI関数を使用せざるを得ない場合があります。その場合、どうすればよいでしょうか?

NYIの代替手段

心配しないでください。ほとんどのNYI関数は敬意を払って置いておき、他の方法でその機能を実装できます。次に、いくつかの典型的なNYIを選んで説明し、さまざまなタイプのNYI代替手段を紹介します。これにより、他のNYIについても学ぶことができます。

string.gsub()

まず、string.gsub()関数を見てみましょう。これはLuaの組み込み文字列操作関数で、グローバルな文字列置換を行います。例えば、以下の例です。

$ resty -e 'local new = string.gsub("banana", "a", "A"); print(new)'
bAnAnA

この関数はNYI関数であり、JITでコンパイルできません。

OpenRestyのAPIで代替関数を探すことができますが、ほとんどの人にとって、すべてのAPIとその使用方法を覚えるのは現実的ではありません。そのため、私は開発作業中に常にlua-nginx-moduleのGitHubドキュメントページを開いています。

例えば、gsubをキーワードとしてドキュメントページを検索すると、ngx.re.gsubが思い浮かびます。

また、以前に推奨したrestydocツールを使用してOpenResty APIを検索することもできます。gsubを検索してみてください。

$ restydoc -s gsub

ご覧の通り、期待していたngx.re.gsubではなく、Luaの関数が表示されます。実際、この段階では、restydocは正確な一意の一致を返すため、API名を明確に知っている場合に使用するのに適しています。曖昧な検索については、ドキュメントで手動で行う必要があります。

検索結果に戻ると、ngx.re.gsubの関数定義は以下の通りです。

newstr, n, err = ngx.re.gsub(subject, regex, replace, options?)

ここで、関数のパラメータと戻り値は特定の意味を持つ名前が付けられています。実際、OpenRestyでは、多くのコメントを書くことをお勧めしません。ほとんどの場合、良い名前は数行のコメントよりも優れています。

OpenRestyの正規表現システムに慣れていないエンジニアにとって、最後の変数optionsを見ると混乱するかもしれません。しかし、この変数の説明はこの関数ではなく、ngx.re.match関数のドキュメントにあります。

optionsのドキュメントを見ると、joに設定するとPCRE JITが有効になり、ngx.re.gsubを使用するコードがLuaJITだけでなくPCRE JITでもJITコンパイルされることがわかります。

ドキュメントの詳細には触れません。OpenRestyのドキュメントは非常に優れているので、しっかりと読めばほとんどの問題を解決できます。

string.find()

string.gsubとは異なり、string.findはプレーンモード(つまり文字列検索)ではJIT可能ですが、正規表現を使用した文字列検索ではJITできません。これはOpenRestyのAPI ngx.re.findを使用して行います。

したがって、OpenRestyで文字列検索を行う場合、まず固定文字列を検索するのか正規表現を検索するのかを明確に区別する必要があります。前者の場合、string.findを使用し、最後にplaintrueに設定することを忘れないでください。

string.find("foo bar", "foo", 1, true)

後者の場合、OpenRestyのAPIを使用し、PCREのJITオプションを有効にするべきです。

ngx.re.find("foo bar", "^foo", "jo")

ここで、最適化オプションをデフォルトで有効にするためのラッピング層を作成し、エンドユーザーに多くの詳細を知らせないようにするのが適切です。そうすれば、外部に対して統一された文字列検索関数になります。ご存知の通り、時には多くのオプションと柔軟性は良いことではありません。

unpack()

次に見るのはunpack()関数です。unpack()も避けるべき関数で、特にループ本体では使用しないでください。代わりに、配列のインデックス番号を使用してアクセスできます。以下のコード例をご覧ください。

$ resty -e '
 local a = {100, 200, 300, 400}
 for i = 1, 2 do
    print(unpack(a))
 end'

$ resty -e 'local a = {100, 200, 300, 400}
 for i = 1, 2 do
    print(a[1], a[2], a[3], a[4])
 end'

unpackについてもう少し深く掘り下げてみましょう。今回はrestydocを使用して検索できます。

$ restydoc -s unpack

unpackのドキュメントから、unpack(list [, i [, j]])return list[i], list[i+1], list[j]と同等であることがわかります。unpackをシンタックスシュガーと考えることができます。これにより、LuaJITのJITコンパイルを壊すことなく、配列インデックスとして正確にアクセスできます。

pairs()

最後に、ハッシュテーブルをトラバースするpairs()関数を見てみましょう。これもJITでコンパイルできません。

しかし、残念ながら、これに相当する代替手段はありません。できるだけ避けるか、数値インデックスでアクセスする配列を使用するしかありません。特に、ホットコードパス上でハッシュテーブルをトラバースしないでください。ここでホットコードパスとは、コードが何度も実行されることを意味します。例えば、巨大なループ内などです。

これら4つの例を説明した後、NYI関数の使用を回避するためには、以下の2点に注意する必要があることをまとめます。

  • Luaの標準ライブラリ関数よりもOpenRestyが提供するAPIを優先して使用してください。Luaは組み込み言語であり、私たちはLuaではなくOpenRestyでプログラミングしていることを忘れないでください。
  • どうしてもNYI言語を使用する必要がある場合は、コードのホットパス上にないことを確認してください。

NYIを検出する方法

NYIの回避についてこれまで話してきたのは、何をすべきかを教えるためです。しかし、ここで突然終わってしまうと、OpenRestyが提唱する哲学の一つに反することになります。

機械が自動的にできることは人間を関与させない。

人間は機械ではなく、常にミスがあります。コードで使用されているNYIを自動的に検出することは、エンジニアの価値を示す重要な反映です。

ここでは、LuaJITに付属しているjit.dumpjit.vモジュールを推奨します。これらはどちらもJITコンパイラの動作プロセスを出力します。前者は詳細な情報を出力し、LuaJIT自体をデバッグするために使用できます。より深く理解するためにそのソースコードを参照してください。後者の出力はよりシンプルで、各行がトレースに対応し、通常はJIT可能かどうかをチェックするために使用されます。

どのように行うべきでしょうか?まず、init_by_luaに以下の2行のコードを追加します。

local v = require "jit.v"
v.on("/tmp/jit.log")

次に、ストレステストツールまたは数百のユニットテストセットを実行して、LuaJITが十分に熱くなり、JITコンパイルがトリガーされるようにします。それが完了したら、/tmp/jit.logの結果を確認します。

もちろん、この方法は比較的煩雑ですので、簡単に済ませたい場合はrestyで十分です。OpenResty CLIには以下のオプションがあります。

$resty -j v -e 'for i=1, 1000 do
      local newstr, n, err = ngx.re.gsub("hello, world", "([a-z])[a-z]+", "[$0,$1]", "i")
 end'
 [TRACE   1 (command line -e):1 stitch C:107bc91fd]
 [TRACE   2 (1/stitch) (command line -e):2 -> 1]

resty-jはLuaJIT関連のオプションで、dumpとvの値が続き、jit.dumpjit.vモードを有効にします。

jit.vモジュールの出力では、各行が正常にコンパイルされたトレースオブジェクトです。先ほどはJIT可能なトレースの例でしたが、NYI関数に遭遇した場合、出力にはNYIであることが指定されます。以下のpairsの例をご覧ください。

$resty -j v -e 'local t = {}
 for i=1,100 do
     t[i] = i
 end

 for i=1, 1000 do
     for j=1,1000 do
         for k,v in pairs(t) do
             --
         end
     end
 end'

JITできないため、結果には8行目にNYI関数があることが示されています。

 [TRACE   1 (command line -e):2 loop]
 [TRACE --- (command line -e):7 -- NYI: bytecode 72 at (command line -e):8]

最後に

これがOpenRestyのパフォーマンス問題について初めて詳しく話したことです。NYIに関するこれらの最適化を読んで、どう思いましたか?コメント欄にあなたの意見を残してください。

最後に、string.find()関数の代替手段について議論する際に、ラッピング層を作成し、最適化オプションをデフォルトで有効にする方が良いと述べました。そこで、そのタスクをあなたに任せて、少し試してもらいます。

コメント欄にあなたの回答を自由に書いてください。この記事を同僚や友人と共有して、コミュニケーションと進歩を共有してください。