未来を形作るテクノロジーの深掘り記事。

jemallocが重要な理由:大規模環境のメモリ割り当て

jemallocは世界最大級のシステムでメモリ割り当てを担う。断片化を抑える仕組みと、Metaが投資を続ける理由を解説。

倉庫でデータブロックをサイズ別のメモリビンに振り分けるロボットアーム

プログラムが malloc() を呼ぶたびに、仮想メモリのどの塊を返すかを決める処理が走ります。小さなプログラムでは些細な判断ですが、規模が大きくなると巨大なエンジニアリング課題になります。出来の悪いアロケータはメモリを断片化させ、RAMを無駄にし、スレッド間でロック競合を起こし、OSがページを回収するときにレイテンシのスパイクを生みます。良いアロケータはこのどれも起こしません。jemallocは良いアロケータであり、Metaは今回改めてそれへの投資を表明しました。Metaの規模では、良いアロケータと平凡なアロケータの差がハードウェアコストで数十億ドルに相当するからです。

ほとんどの開発者はメモリ割り当てのことなど考えません。new や malloc を呼べばポインタが返ってきます。しかしMetaの規模、つまり1日あたり数十億のリクエスト、数百万台のサーバー、ペタバイト級のRAMになると、アロケータの振る舞いがハードウェア効率、テールレイテンシ、運用コストに直接的かつ測定可能な影響を与えます。

メモリアロケータの実際の役割

メモリアロケータはプログラムとOSの間に位置します。OSはメモリを大きな塊(通常は4KBや2MBのページ)単位で提供します。一方、プログラムが必要とするのは小さくサイズもばらばらなメモリです(ここに24バイトの文字列、そこに4096バイトのバッファ)。アロケータの仕事は、OSから受け取ったページを必要な大きさに切り分け、解放された塊を次の割り当てのために再利用することです。

素朴なアプローチ、つまり割り当てのたびにOSへ新しいページを要求し、解放時に返すやり方は、壊滅的に遅くなります。システムコールにはオーバーヘッドがあり、ページはほとんどの割り当てサイズよりはるかに大きいからです。24バイトの文字列のために4KBを使うことになってしまいます。

実用的なアロケータはメモリのプールを保持し、その中から小分けにして割り当てます。課題は三つあります。断片化(小さすぎて使えない割り当て間の隙間)の最小化、ロック競合(複数スレッドの同時割り当て)の最小化、そしてオーバーヘッド(割り当てごとのメタデータを本体に対して小さく保つこと)の最小化です。

jemallocの仕組み

jemalloc(開発者はJason Evans、名前の「je」はここから)は、もともとFreeBSD向けに開発され、その後Meta(当時はFacebook)がC/C++サービスの標準アロケータとして採用しました。その設計は、三つの主要な課題それぞれに具体的な手法で対処しています。

スレッドキャッシュで競合を排除。 各スレッドは小さな割り当て用に自分専用のキャッシュを持っています。スレッドが小さなオブジェクトに対して malloc() を呼ぶと、その割り当てはスレッドのローカルキャッシュだけで処理されます。ロックもアトミック操作も競合もありません。キャッシュが枯渇したときだけ、共有アリーナから補充を受けます。

jemalloc allocation flow:
Thread calls malloc(64)
↓
Check thread cache for 64-byte size class
→ Hit: return cached chunk (no lock, no contention)
→ Miss: refill from arena
↓
Arena (shared, but per-thread affinity)
→ Find a partially-full slab for 64-byte objects
→ Carve out a chunk
→ Return to thread cache
↓
Return chunk to caller
Thread caches handle 95%+ of allocations without any locking.

サイズクラスで断片化を抑える。 jemallocは要求されたバイト数ちょうどを割り当てるのではなく、直近のサイズクラスに切り上げます。サイズクラスは慎重に選ばれており、8、16、32、48、64、80、96、112、128、160、192、224、256……と続き、サイズが大きくなるほど間隔も広がります。つまり50バイトの割り当てには64バイトの塊が渡されます(23%の無駄)。一見悪く見えますが、実は良い設計です。同じサイズクラス内の64バイトの塊はすべて互換性があるため、クラス内での外部断片化はゼロになります。

スラブで同サイズの割り当てをまとめる。 各スラブは、同じサイズクラスの塊に分割された連続したメモリ領域です。64バイトのオブジェクト用のスラブには64バイトの塊しか入っていません。これにより、最も厄介な種類の断片化、つまり解放されたメモリが異なるサイズのアクティブな割り当てに挟まれて再利用できなくなる状況を排除します。

断片化の問題

メモリの断片化は、長時間稼働するサービスをじわじわと蝕む存在です。起動直後のサーバーはメモリを効率よく使っています。割り当てが詰まって並んでいるからです。しかし数日から数週間にわたって割り当てと解放が混在すると、ヒープはスイスチーズのようになります。アクティブな割り当ての間に小さな空きがたくさん生まれるのです。空きメモリの合計が2GBあっても、最大の連続した空き領域は64KBしかない、という事態が起こります。

外部断片化(割り当て間で使えない隙間)と内部断片化(切り上げによって割り当て内部に生じる無駄)はどちらも重要ですが、外部断片化のほうがたちが悪いです。内部断片化はサイズクラスの間隔によって上限が決まり、最悪でも割り当てごとに約25%程度の無駄で済みます。一方、外部断片化には上限がなく、時間とともに増え続けます。

jemallocのスラブ方式は、小さな割り当て(圧倒的多数を占めます)についての外部断片化をほぼ解消します。スラブ内のオブジェクトはすべて同じサイズなので、一つ解放すると、同じサイズクラスの次の割り当てにちょうど合う穴ができます。使えない隙間は生じません。

大きな割り当て(一般に14KB超)については、jemallocは別の戦略を取ります。大きな割り当てには専用のページが与えられ、解放されたページはOSに返却されるか、別のサイズクラスに再利用されます。ここでは依然として断片化が起こり得ますが、大きな割り当て自体は比較的まれです。

jemalloc対glibc malloc対tcmalloc

Linuxエコシステムの主要な三つのアロケータは、それぞれ異なるトレードオフを選んでいます。

  • glibc malloc(ptmalloc2) は多くのLinuxシステムでデフォルトです。スレッドのスケーラビリティのためにアリーナを使いますが、jemallocよりサイズクラスが少なく、長時間稼働するサービスでは断片化が増えがちです。最大の利点は、デフォルトであるため設定が不要なことです。
  • tcmalloc(Google) は、もともとGoogleのC++サービス向けに開発されたスレッドキャッシング型のmallocです。スレッドローカルのキャッシュに優れ、オーバーヘッドも小さいです。短命な割り当てが多いワークロードに向いていますが、断片化が問題になるような高いメモリ負荷のワークロードにはあまり向きません。
  • jemalloc は、持続的な負荷の下で断片化を抑え、振る舞いを予測可能にすることに最適化されています。他の二つより洗練されたサイズクラスとスラブ管理を使います。トレードオフは、割り当てごとのオーバーヘッドがわずかに大きい(メタデータが多い)ことです。その代わりに、時間が経っても高いメモリ効率を保てます。

最適な選択はワークロードによります。短命なプロセスなら三つともほぼ同じ性能です。Webサーバー、データベース、キャッシュのように割り当てパターンが混在する長時間稼働のサーバーでは、jemallocの断片化耐性が通常は勝ります。同じワークロードでglibc mallocより10〜30%少ないRAMで済むことが多く、規模が大きくなればそのままハードウェアコストの削減につながります。

Metaがこだわる理由

Metaの規模では、C++サービス全体でメモリ使用量が10%減るだけで、ハードウェアコストが数百万ドル削減されます。しかも年単位ではなく、月単位です。数百万台のサーバーを運用し、それぞれが1日に数十億回もメモリを割り当てて解放するサービスを動かしていると、アロケータの効率は予算の一項目になるのです。

Metaが改めて投資しているjemallocの領域はいくつかあります。大容量メモリのシステムでTLBミスを減らすためのHuge Pageサポートの改善、負荷が下がったときにOSへメモリを返却する仕組みの改善(常駐メモリの削減)、そしてメモリがどこで使われ、どこで無駄になっているかを理解するためのプロファイリングツールの強化です。

特に興味深いのはプロファイリングです。jemallocには組み込みのヒーププロファイラがあり、割り当てをサンプリングして、どこでメモリが割り当てられたか、アクティブと解放済みがどれくらいか、ヒープがどの程度断片化しているかを示すプロファイルを出力させられます。このプロファイリングは本番環境でのオーバーヘッドがほぼゼロなので、本番サーバーで継続的に動かすことが現実的になります。

自分のプロジェクトでjemallocを使う

C/C++のプログラムであれば、jemallocへの切り替えは通常とても簡単です。Linuxでは、直接リンクするか、LD_PRELOAD を使って実行時に注入できます。コードの変更は不要です。

# Install jemalloc
sudo apt install libjemalloc-dev  # Debian/Ubuntu
brew install jemalloc             # macOS
# Use LD_PRELOAD — works with any program, no recompilation
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so ./my_server
# Or link at compile time
gcc -o my_server my_server.c -ljemalloc
# Enable profiling
export MALLOC_CONF="prof:true,prof_prefix:jeprof"
./my_server
# Then analyze: jeprof --svg ./my_server jeprof.*.heap > heap.svg

有名なプロジェクトもいくつかデフォルトでjemallocを使っています。Redis、Rust(一部のプラットフォームではデフォルトのアロケータとして使用)、Firefox(jemallocはFirefoxの対象だったFreeBSD向けに開発されました)、そして多くのゲームエンジンです。

マネージドメモリの言語(Python、Java、Go)では、割り当てはランタイムが処理するため、jemallocを直接使うことはできません。ただし、スレッドローカルキャッシュ、サイズクラス、スラブ割り当てといった考え方は、現代のあらゆるランタイムのガベージコレクタやメモリアロケータに現れています。たとえばGoのランタイムアロケータは、tcmallocに着想を得た設計で、P(プロセッサ)ごとのキャッシュとサイズクラスを持っています。

目に見えないインフラ

メモリ割り当ては、うまく動いているときには見えず、うまくいかないと壊滅的になるインフラです。断片化によって徐々にメモリリークを起こしたサーバーは、最終的にOOM Killerで強制終了されます。しかもその原因はアプリケーションのログには現れません。アプリケーション層より下で起きているからです。

多くのアプリケーションではデフォルトのアロケータで十分です。しかし長時間稼働するサーバーを運用している場合、原因不明のメモリ増加に悩まされている場合、あるいはハードウェア効率が重要になる規模で運用している場合には、アロケータを理解し、必要に応じてより良いものへ切り替えることが、最もレバレッジの効くインフラ変更の一つになります。jemallocは魔法ではありません。慎重なデータ構造設計、納得のいくトレードオフ、そして規模が大きくなると重要になる細部へのたゆまぬこだわり、つまりエンジニアリングそのものです。