OpenResty® は LuaJIT を主要な計算エンジンとして使用しており、ユーザーも主に Lua 言語を使用してアプリケーションを作成しています。非常に複雑なアプリケーションでさえもそうです。64 ビットシステム(x86_64 を含む)では、LuaJIT のガベージコレクターが管理できるメモリの最大値が 2GB に制限されていることが、長年コミュニティから批判されてきました。幸いなことに、LuaJIT1 の公式版が 2016 年に “GC64” モードを導入し、この上限を 128 TB(つまり下位 47 ビットのアドレス空間)まで引き上げることができるようになりました。これにより、現在の主流のパーソナルコンピューターやサーバー上で制限なく実行できるようになりました。過去 2 年間で GC64 モードは十分に成熟したため、私たちは ARM64(または AArch64)アーキテクチャと同様に、x86_64 アーキテクチャでもデフォルトで GC64 モードを有効にすることを決定しました。この記事では、過去の古いメモリ制限の理由と、新しい GC64 モードについて簡単に説明します。

旧メモリ制限

公式の LuaJIT は x86_64 アーキテクチャ上でデフォルトで “x64” モード2を使用しており、OpenResty 1.13.6.2 以前のバージョンも x86_64 アーキテクチャ上でこのモードをデフォルトで使用していました。このモードでは、LuaJIT のガベージコレクターは下位 31 ビットのアドレス空間しか使用できず、これは最大で 2 GB のメモリしか管理できないことを意味します。

このメモリ制限に遭遇する時期

では、この 2 GB のメモリ制限にいつ遭遇するのでしょうか。小さな Lua スクリプトを使用して簡単に再現することができます。

-- File grow.lua

local tb = {}
local i = 0
local s = string.rep("a", 1024 * 1024)
while true do
    i = i + 1
    tb[i] = s .. i
    print(collectgarbage("count"), " KB")
end

このスクリプトには while の無限ループがあり、新しい Lua 文字列を継続的に作成し、Lua テーブルに挿入しています(Lua のガベージコレクタによる回収を防ぐため)。各ループの反復で、1MB 以上の Lua 文字列が作成され、Lua の標準 API 関数 collectgarbage を使用して、現在 Lua ガベージコレクタ(GC)が管理している総メモリサイズを出力します。また、トップレベルスコープの Lua テーブル変数 tb も継続的に成長し、Lua 文字列よりはるかに少ないものの、より多くのメモリを消費し続けることも注目に値します。

OpenResty が提供する resty コマンドを使用して、このスクリプトを簡単に実行できます。例えば:

$ resty grow.lua
4181.08984375 KB
5205.6767578125 KB
6229.869140625 KB
6229.66796875 KB
8277.4013671875 KB
9301.5546875 KB
10325.741210938 KB
...
2003241.1367188 KB
2004265.3320313 KB
2005289.5273438 KB
2006313.7226563 KB
2007337.9179688 KB

$

今回は、OpenResty を “x64” モードでコンパイルしています。明らかに、Lua ガベージコレクタが管理するメモリが 2GB に近づくと、resty ツールが終了します。実際には、プロセスがクラッシュしています。shell の戻り値を確認できます:

$ echo $?
134

luajit コマンドを使用してこのスクリプトを実行すると、より詳細なクラッシュエラーメッセージを見ることができます:

$ /usr/local/openresty/luajit/bin/luajit grow.lua
4181.08984375 KB
5205.6767578125 KB
6229.869140625 KB
6229.66796875 KB
8277.4013671875 KB
...
2053220.5429688	 KB
2054244.5634766	 KB
2055268.5839844	 KB
2056292.6044922	 KB
2057316.625	 KB
PANIC: unprotected error in call to Lua API (not enough memory)

これにより、メモリ制限に確かに達したことが確認されました。

メモリ制限はプロセスごと

OpenResty は、単一マシンの複数の CPU コアを最大限に活用するために、Nginx のマルチプロセスモデルを継承しています。そのため、各 Nginx ワーカープロセスには独自の独立したアドレス空間があります。したがって、この 2GB の制限は、個々の独立した Nginx ワーカープロセスレベルにのみ適用されます。OpenResty/Nginx サービスに 12 のワーカープロセスがある場合、総メモリ制限は 2 * 12 = 24 GB になります。これが、長年にわたってこの制限が大規模な OpenResty アプリケーションにあまり問題を引き起こさなかった理由であり、ほとんどの OpenResty ユーザーがこの制限を知らなかった理由でもあります。

しかし、このメモリ制限は LuaJIT 仮想マシン(VM)レベルではありません。例えば、同じ Nginx プロセス内で、ngx_stream_lua_modulengx_http_lua_module がそれぞれ独自の LuaJIT VM インスタンスを作成しますが、これらの 2 つの LuaJIT VM がそれぞれ最大 2GB のメモリを管理できるわけではありません。むしろ、これら 2 つの LuaJIT VM を合わせて最大 2GB のメモリを管理できます。これは、メモリ制限がアドレス空間によって制限されているためで、LuaJIT の GC マネージャーは下位 31 ビットのアドレス空間しか使用できません。このアドレス空間はプロセスレベルのものです。

GC が管理するメモリ

LuaJIT のガベージコレクタ(GC)が管理する Lua レベルのオブジェクトの大半(例えば、string、table、function、userdata、cdata、thread、trace、upvalue、prototype など)があります。upvalue と prototype オブジェクトは通常、function オブジェクトによって参照されます。これらは総称して「GC オブジェクト」と呼ばれます。

number、boolean、light userdata などの他のプリミティブ値は GC によって管理されません。これらは実際の値を直接エンコードして使用し、LuaJIT 内部では「TValue」(または tagged values)と呼ばれます。LuaJIT 内部では、TValue は常に 64 ビットであり、単精度浮動小数点数でさえも 64 ビットです(LuaJIT は「NAN tagging」技術を使用して、これを非常に効率的に実現しています)。これが、通常、同じアプリケーションを LuaJIT で実行すると、標準の Lua 5.1 インタプリタで実行するよりもメモリ使用量が大幅に少なくなる理由です3

GC が管理しないメモリ

LuaJIT の cdata データ型は特殊です。標準の LuaJIT API 関数 ffi.new() を使用して作成された cdata オブジェクトは GC によって管理されます。しかし、malloc()mmap() などの C レベルの関数、または他の C ライブラリ関数を使用してメモリを割り当てた場合、そのメモリは GC によって管理され ません。したがって、2 GB の制限の対象にもなりません。以下の Lua スクリプトでテストできます:

-- File big-malloc.lua

local ffi = require "ffi"
ffi.cdef[[
    void *malloc(size_t size);
]]
local ptr = ffi.C.malloc(5 * 1024 * 1024 * 1024)
print(collectgarbage("count"), " KB")

ここでは、ffi を使用して標準 C ライブラリ関数 malloc() を呼び出し、5 GB のメモリブロックを割り当てています。「x64」モードの OpenResty または LuaJIT でこのスクリプトを実行しても、クラッシュしません。

$ resty big-malloc.lua
73.1298828125 KB

GC が管理するメモリサイズはわずか 73 KB であり、システムから直接割り当てられた 5 GB のメモリブロックが含まれていないことは明らかです。

しかし、GC が管理しないメモリも LuaJIT のメモリ制限に悪影響を与える可能性があります。なぜでしょうか?それは、これらのメモリも下位 31 ビットの空間内に存在する可能性があるからです。

Linux x86_64 システムで mmap() システムコールを実行する場合、アドレスパラメータ(またはメモリ割り当てアドレスに影響を与える他のパラメータ)を指定しなければ、通常、下位 31 ビットのアドレス空間は使用されません。しかし、sbrk() のような呼び出しを使用すると、ほぼ常に低アドレス空間が優先的に使用されます。後者は、LuaJIT の GC メモリアロケータが使用できるメモリ空間をさらに縮小させます。これは Linux などのオペレーティングシステムのメモリレイアウト方式によるものです:「ヒープ」は常に低位から高位に向かって成長します。同様に、Linux などのシステムでは、プログラムのデータセグメントは常に低アドレス空間の開始位置付近を使用するため、データセグメント内の静的定数データ(例えば、定数 C 文字列の値)も LuaJIT が使用可能な低アドレス空間をさらに圧迫します。

上記の理由により、x86_64 アーキテクチャでは、LuaJIT が実際に使用できるアドレス空間は 2 GB よりもかなり小さくなる可能性があります。実際に使用可能な空間のサイズは、アプリケーション自体が具体的にどのアドレス位置で、どれだけの量のメモリ空間を要求しているかによって異なります。コミュニティのユーザーからも、FreeBSD 上で Nginx が要求する共有メモリ領域4が LuaJIT の使用可能な低アドレス空間を圧迫するという問題が報告されています。また、ngx_http_slice_module のようなサードパーティモジュールを使用した場合、LuaJIT がメモリ不足の例外をより簡単に投げるようになるという報告もあります。

x64 モードのメモリ上限を 4 GB に引き上げる

理論上、LuaJIT の x64 モードでの上限は 4 GB(つまり、下位 32 ビットのアドレス空間)であるべきで、2 GB ではありません。実際、i386 システムでは LuaJIT は下位アドレスの 4 GB 空間を十分に活用できています。LuaJIT 内部の手書きアセンブリコードでは、32 ビットのアドレスポインタ値を 64 ビットに変換する必要がある度に、「符号拡張」(sign extension)の問題を正しく処理する必要があり、これが上限を 2 GB に制限する原因となっています。一方、i386 アーキテクチャではこの問題は存在しません。なぜなら、word 値は常に 32 ビットだからです。

4 GB は 2 GB の 2 倍ですが、それでも上記の問題が発生する可能性があります。そのため、LuaJIT の開発者は はるかに大きな アドレス空間を使用できる新しい VM モードを開発することを決定し、GC64 モードが誕生しました。注目すべきは、この GC64 モードが ARM64(Aarch64)アーキテクチャでの唯一の選択肢でもあることです。なぜなら、そこでは下位アドレス空間の確保が非常に困難だからです。

新しい GC64 モード

GC64 モードは 2016 年に始まり、最初に Peter Cawley によって実装され、その後 Mike Pall によって統合されました。過去 2 年以上にわたり、多くのバグが修正され、広範なテストを経て、本番環境で使用するのに十分安定していることが証明されました。そのため、OpenResty も x86_64 アーキテクチャでこの新しい GC64 モードに切り替える予定です(ARM64 ではすでに GC64 モードが強制的に使用されています)。

GC64 モードでは、元の Lua 変数(上記で言及した TValue)は引き続き 64 ビットのサイズを維持するため、新しいモードでメモリ使用量が大幅に増加することを心配する必要はありません。ただし、MRefGCRef など、LuaJIT 内部で一般的な C データ型のいくつかは大きくなります(32 ビットから 64 ビットに)。したがって、GC64 モードではメモリ使用量が大幅に増加することはありませんが、確実に若干増加します。

GC64 モードでは、ガベージコレクションマネージャーは下位 47 ビットのアドレス空間、つまり 128 TB を使用できるようになりました。これは、現在の高性能 PC の物理メモリ(通常、64 GB でも大容量メモリと見なされ、AWS EC2 インスタンスの最大メモリでさえ 12 TB にすぎない)をはるかに超えています。つまり、GC64 モードは現実世界では事実上メモリ制限がないと言えます。

GC64 モードの有効化方法

LuaJIT ソースコードからコンパイルする場合、以下のように有効化できます5

make XCFLAGS='-DLUAJIT_ENABLE_GC64'

OpenResty のソースコードを 1.13.6.2 バージョン 以前 からインストールする場合、./configure スクリプトに以下のオプションを追加して有効化できます:

-with-luajit-xcflags='-DLUAJIT_ENABLE_GC64'

OpenResty 1.15.8.1 以降、x86_64 システムでは GC64 がデフォルトで有効になっています。これには OpenResty が公式に提供するバイナリパッケージも含まれます。

パフォーマンスへの影響

新しい GC64 モードがどの程度の影響を与えるか、いくつかの大規模な Lua プログラムでテストしてみましょう。

Edge 言語コンパイラ

まず、Edge 言語(「edgelang」とも呼ばれる)コンパイラを使用して、いくつかの大規模な WAF モジュールをコンパイルします。x64 モードでは:

$ PATH=/opt/openresty-x64/bin:$PATH /bin/time ./bin/edgelang waf.edge >
/dev/null
0.73user 0.03system 0:00.77elapsed 99%CPU (0avgtext+0avgdata 119660maxresident)k
0inputs+0outputs (0major+33465minor)pagefaults 0swaps

edgelang コンパイラを使用して waf.edge を Lua コードにコンパイルするのに、ユーザー時間で 0.73 秒かかり、最大メモリ使用量は 119660 KB、つまり 116.9MB でした。次に GC64 モードで試してみます:

$ PATH=/opt/openresty-plus-gc64/bin:$PATH /bin/time ./bin/edgelang waf.edge
> /dev/null
0.70user 0.03system 0:00.74elapsed 99%CPU (0avgtext+0avgdata 133748maxresident)k
0inputs+0outputs (0major+35396minor)pagefaults 0swaps

最大メモリ使用量は 133748 KB、つまり 130.6MB で、わずか 11.1% 増加しました。CPU 使用時間はほぼ同じで、この程度の差はテストの誤差と見なせます。

Edge 言語コンパイラは OpenResty 上の純粋な Lua 実装で、空行とコメントを含めて合計 83,315 行のコードがあります。両モードで対応する LuaJIT バイトコードはともに 1.8MB です(x64 と GC64 のバイトコードは互換性がありませんが)。

Y 言語コンパイラ

次に、Y 言語(「ylang」とも呼ばれる)コンパイラを試してみましょう。これも OpenResty ベースの純粋な Lua コマンドラインプログラムです。

ylang コンパイラは edgelang コンパイラよりも少し大きく、対応する LuaJIT バイトコードは 2.1 MB です(両モードとも)。 まず x64 モードで、ljfrace.y ツールを systemtap+ スクリプトにコンパイルします:

$ PATH=/opt/openresty-x64/bin:$PATH /bin/time ./bin/ylang --stap --symtab
luajit.jl lftrace.y > /dev/null
1.30user 0.12system 0:01.42elapsed 99%CPU (0avgtext+0avgdata 401184maxresident)k
0inputs+240outputs (0major+116438minor)pagefaults 0swaps

ユーザー時間で 1.3 秒かかり、最大で 401184 KB のメモリを使用しました。次に GC64 モードを試してみます:

$ PATH=/opt/openresty-gc64/bin:$PATH /bin/time ./bin/ylang --stap --symtab
luajit.jl lftrace.y > /dev/null
1.30user 0.11system 0:01.42elapsed 99%CPU (0avgtext+0avgdata 433948maxresident)k
0inputs+240outputs (0major+125591minor)pagefaults 0swaps

同じく 1.3 秒かかり、433948 KB のメモリを使用しました。今回は時間の差はなく、メモリ使用量もわずか 8.2% 増加しただけです。

デバッグ分析ツールチェーン

現在、オープンソースの動的分析ツール(openresty-systemtap-toolkitstap++openresty-gdb-toolkit を含む)のほとんどが新しい GC64 モードをサポートしていません。私たちはこれらの systemtap や gdb 向けのオープンソースツールのメンテナンスを終了しました。 私たちの焦点は OpenResty XRay プラットフォームとその Y 言語コンパイラに移っています。標準 C 言語のスーパーセット(Y 言語)を使用してツールを書いています。Y 言語コンパイラはこれを Python コードにコンパイルして gdb で実行したり、stap+ スクリプトにコンパイルして systemtap+6 で実行したりできます(将来的には他のバックエンドもサポートし、より多くのデバッグおよび動的トレースプラットフォームで実行できるようになる予定です)。これらの Y 言語で書かれたツールをほとんど変更せずに、GC64 モードを直接サポートできます。これは、インテリジェントなデバッグ情報処理と、Y 言語が C 言語レベルの表現を使用していることによるものです。

以下は GC64 モードでの OpenResty Lua レベルのフレームグラフで、私たちの ylang ツールと systemtap+ を使用しています。

Lua-land CPU Flame Graph for GC64 LuaJIT

また、ylang コンパイラが生成する gdb ツールは、core dump ファイルの分析にも使用できます。例えば:

(gdb) lbt 0 full
[builtin#128]
exit
test.lua:16
    c = 3
    d = 3.140000
    e = true
    k = nil
    null = light userdata (void *) 0x0
test.lua:baz
test.lua:19
    ffi = table: (GCtab *)\E0x[0-9a-fA-F]+\Q
    cjson = table: (GCtab *)\E0x[0-9a-fA-F]+\Q
test.lua:0
    ffi = table: (GCtab *)\E0x[0-9a-fA-F]+\Q
    cjson = table: (GCtab *)\E0x[0-9a-fA-F]+
C:pmain

(gdb)

フレームグラフの関数フレームには、Lua 関数フレームと C 関数フレームが含まれています。

OpenResty XRay では、ylang で書かれた既製の動的トレース分析ツールを提供するだけでなく、オンラインコンパイラも提供しています。Y 言語を使用して、新しい分析ツールを簡単に実装できます。

LuaJIT 組み込みのプロファイラ

バージョン 2.1 以降、LuaJIT 公式には仮想マシンレベルのプロファイラが組み込まれています。これは当然、GC64 モードでも引き続き使用できます。ただし、systemtap+ のようなシステムレベルの動的トレースツールとは異なり、JIT コンパイル済みのすべての Lua コード(LuaJIT の用語では「traces」)をクリアする必要があり、特別なプロファイリングモードで JIT コンパイルを再度行う必要があります。したがって、プロファイラを開始または停止するたびに、関連するすべての Lua コードの JIT コンパイルが再開されます。これは必然的に現在のプロセス内の多くの状態を変更することになり(予期せぬ副作用や極端なバグが発生しやすくなります)、また分析サンプリング期間中にもかなりの性能オーバーヘッドが発生します7。さらに、対象の Lua プログラムは、このようなプロファイリングモードをトリガーするための特別な API またはフックを提供する必要があるため、組み込みプロファイラを正しく機能させるには、アプリケーションの特別な協力が必要です。しかし、動的トレース技術に基づくパフォーマンス分析では、Lua アプリケーションの協力は一切必要なく、再起動や特別なコンパイルオプションの使用も必要ありません。

結論

本文では、LuaJIT の新しい GC64 モードについて紹介しました。これにより、従来のプロセスあたり 2GB の GC 管理メモリ制限を効果的に解除できます。より多くのメモリを使用する能力は、OpenResty アプリケーション自体がメモリ使用量の過剰や、メモリリークなどの問題により注意を払う必要があることも意味します。幸いなことに、OpenResty XRay を使用すれば、任意の OpenResty アプリケーションのメモリ使用を迅速に分析および最適化できます。この話題については、新しいシリーズ記事で詳しく説明する予定です。

関連記事

OpenResty XRay について

OpenResty XRay動的トレーシング製品であり、実行中のアプリケーションを自動的に分析して、パフォーマンスの問題、動作の問題、セキュリティの脆弱性を解決し、実行可能な提案を提供いたします。基盤となる実装において、OpenResty XRay は弊社の Y 言語によって駆動され、Stap+、eBPF+、GDB、ODB など、様々な環境下で複数の異なるランタイムをサポートしております。

著者について

章亦春(Zhang Yichun)は、オープンソースの OpenResty® プロジェクトの創始者であり、OpenResty Inc. の CEO および創業者です。

章亦春(GitHub ID: agentzh)は中国江蘇省生まれで、現在は米国ベイエリアに在住しております。彼は中国における初期のオープンソース技術と文化の提唱者およびリーダーの一人であり、Cloudflare、Yahoo!、Alibaba など、国際的に有名なハイテク企業に勤務した経験があります。「エッジコンピューティング」、「動的トレーシング」、「機械プログラミング」 の先駆者であり、22 年以上のプログラミング経験と 16 年以上のオープンソース経験を持っております。世界中で 4000 万以上のドメイン名を持つユーザーを抱えるオープンソースプロジェクトのリーダーとして、彼は OpenResty® オープンソースプロジェクトをベースに、米国シリコンバレーの中心部にハイテク企業 OpenResty Inc. を設立いたしました。同社の主力製品である OpenResty XRay動的トレーシング技術を利用した非侵襲的な障害分析および排除ツール)と OpenResty XRay(マイクロサービスおよび分散トラフィックに最適化された多機能ゲートウェイソフトウェア)は、世界中の多くの上場企業および大企業から高い評価を得ております。OpenResty 以外にも、章亦春は Linux カーネル、Nginx、LuaJITGDBSystemTapLLVM、Perl など、複数のオープンソースプロジェクトに累計 100 万行以上のコードを寄与し、60 以上のオープンソースソフトウェアライブラリを執筆しております。

翻訳

英語版の原文と日本語訳版(本文)をご用意しております。読者の皆様による他の言語への翻訳版も歓迎いたします。全文翻訳で省略がなければ、採用を検討させていただきます。心より感謝申し上げます!


  1. OpenResty は独自のブランチを維持しており、このブランチには高度な機能と OpenResty 向けの特別な最適化が含まれています。このブランチは定期的に 公式 LuaJIT から同期されます。 ↩︎

  2. 2019 年 12 月 8 日以降、公式の LuaJIT も x86_64 システム上でデフォルトで GC モードを使用するようになりました。 ↩︎

  3. 実際の本番環境において、LuaJIT と標準 Lua 5.1 インタプリタの間でメモリ使用量に数倍の差があることを観察しました。 ↩︎

  4. これらの共有メモリ領域は実際には mmap システムコールを通じて割り当てられています。 ↩︎

  5. 2019 年 12 月 8 日以降、公式 LuaJIT は x86_64 上でデフォルトで GC64 モードを有効にしています。 ↩︎

  6. systemtap+ は OpenResty Inc. によって大幅に拡張および最適化された systemtap です。 ↩︎

  7. Java の世界の BTrace ツールにも同様の問題と制限があります。 ↩︎