メモリ割り当て 基礎

あらゆる new と malloc は同じ場所に行き着く —— カーネルが渡した領域を刻むデータ構造だ。8 節で示すのは 3 つ。free はバイトをアロケータへ返し、カーネルへは返さない。空きの合計と最大の連続領域は別の数で、要求に答えるのは後者だけ。そして動かせる回収器は、注意ではなくメモリを払って両方を避ける。

01

2 つの領域と、片方が伸びる仕組み

どのプロセスも両方を持つ。スタックは命令 1 つで済み、作った呼び出しより長く生きられない。ヒープはデータ構造 1 式を要求する —— このページはその話だ。

スタックは誰も管理していない。呼び出しは rsp を引き、戻るときに同じ数を足す。空きリストも探索もメタデータもない。下ではすでに積まれたフレームの上にいまの呼び出しが積んだフレームが載っている。スライダーで呼び出しの深さを辿ってほしい:

0 フレーム

何も探索されないことに注目。ポインタがフレーム自身の大きさだけ動けば割り当ては終わり、解放は同じ命令の符号を変えただけだ。戻ったフレームを指すポインタが「古い」より悪いのもこれが理由で、バイトはまだそこにあり、次の呼び出しがその上に書く。

代価はこの領域が固定であること。Linux は既定で 8 MiB を与え、その下に誰もマップできない1 MiB のガード間隙を置く。フレームの大きさを選び、再帰を上限の先へ押してほしい:

スタック上限の 40%

どこで死ぬかはフレームの大きさで決まる。64 バイトならスタックは 131,072 回、ローカルが 8 KiB なら 1,024 回。同じ再帰が、ある関数では安全で別の関数では致命的になる。へ押しても失敗は大きな音だ。ガード間隙にマッピングはなく、最初に触れた命令でそのまま SIGSEGV になる。

呼び出しより長く生きるものはヒープへ行く。ヒープとは、要求すればカーネルが伸ばしてくれる領域にすぎない。break を引いて、brk 1 回が何を買うのかを見てほしい:

ヒープは起点より 128 KiB 上。break を上下に引く。矢印キーで 16 KiB ずつ、Home で元の位置へ
ヒープは起点より 128 KiB 上

5 回の呼び出しで全部が買える。glibc は成長のたびに必要より 128 KiB 多く要求する ——M_TOP_PAD だ。だから小さな断片で を割り当てるプログラムのbrk は 5 回であって 5000 回ではない。システムコールは薄まり、残るのは領域の内側の帳簿 —— それがアロケータだ。

だが break は 1 つの数なので、返せるのは一番上の使用中の割り当てより上にある分だけだ。まだ使われている割り当てを動かし、残りが返せなくなるのを見てほしい:

8 個中 1 番目がまだ使用中

break は水位なので、がその下の592 KiBを釘付けにする —— アロケータから見れば空き、カーネルから見れば常駐だ。健康なプロセスがリークして見える一番よくある理由がこれで、free をいくら呼んでも直らない。free はページを返す役ではなかったからだ。

02

malloc 1 回の本当の代価

返ってくるポインタはアロケータが取った領域の先頭ではなく、要求したバイトは支払ったバイトではない。この 2 つのずれは、どちらも人をつまずかせる。

glibc は渡すポインタのすぐ手前に 8 バイトの語を 1 つ書く。chunk 自身の大きさだ。free(p) が何も教わらずに p の大きさを知れるのはこれによる。要求の先はすべて整列のためのものだ。要求を引いて、丸めが足す分を見てほしい:

malloc(1)

算術は正確だ —— (n + 8 + 15) & ~15、下限は 32。 は 32 バイト、 は 48 バイト。要求 1 バイトが chunk 16 バイトを買う。 17 バイトの要求に malloc_usable_size が 24 を返すのは、次の chunk のサイズ語がこの chunk の空き時にしか使われないからだ。

固定の 1 語と 16 バイトの格子は、小さなオブジェクトには比例課税、大きなオブジェクトには丸め誤差でしかない。要求を 4 桁ぶん歩かせ、オーバーヘッドが落ちるのを見てほしい:

malloc(1)

左端を見てほしい。malloc(1) は 1 バイトのために 32 バイトを取る —— 3,100% だ。の連結リストは 32 バイトの chunk に住むので、リストの 4 分の 1 は誰も読まないサイズ欄になる。プールやアリーナの本当の理由はこれで、malloc が遅いからではなく、オブジェクトが小さいとオブジェクト単位の帳簿が薄まらないからだ。

ある閾値を越えると arena は正解でなくなり、glibc は直接カーネルへ行く。要求を 128 KiB の向こうへ滑らせ、経路が変わるのを見てほしい:

malloc(1024)

で変わるのは経路で、大きさではない。 chunk は自分専用のマッピングになってページ単位に切り上げられ、割り当てと解放の 1 組は 0 回だったシステムコールが 2 回になる。 1 つ free すると閾値はその大きさまで上がる —— 上限 32 MiB —— ので、1 MiB のバッファを使い回すプログラムは一度払えば以後払わない。

要求と実物のあいだのこのずれは、最も静かなヒープバグの住処でもある。書き込みを 17 バイトの割り当ての末尾より先へ引き、どこまで届くかを見てほしい:

末尾から 0 バイト先。書き込みカーソルを引く。矢印キーで 1 バイトずつ、Home で元の位置へ
末尾から 0 バイト先

その割り当ては実際には 24 バイトを持つ。だから末尾からまでは誰も読まない詰め物に落ちて何も変えない。テストは通り、レビューも通り、バグは出荷される。8 バイト目が次の chunk のサイズ欄を上書きし、クラッシュはずっと後の無関係な free で届く。大きな音の失敗は、運が良いほうだ。

03

chunk を見つけ、そして戻す

空きリストは割り当てを探索に変える。どのアロケータも同じ問いへの別々の答えだ —— 決める前に、リストをどこまで読む気があるか。

リストは解放順であって大きさ順ではないので、最初に見つかる十分大きい chunkと最小の十分大きい chunkはたいてい別物だ。要求を決め、規則を切り替え、リストがどこまで読まれるかを見てほしい:

malloc(40)

での取引に注目。先着適合は chunk を 2 つ読み、112 バイトのために 512 バイトの塊を切り刻む。最良適合は 8 つ全部を読み、64 バイトを残す。先着適合は安いが大きな塊を壊し、最良適合は守るが O(n) だ。サイズクラスは索引することで両方を買う。

chunk を戻すのがもう半分で、リストが際限なく伸びるのを止めるのはこちらだ。順序を選び、free を 1 歩ずつ進めてほしい:

0 回の free のあと

読むべき数は合計ではなく最大連続領域だ。どの free も隣 2 つを調べ、何と併合しようと併合は高々 2 回だ。だからヒープ全体を 1 つの chunk に潰す free の連続でも、 1 回あたりは定数時間のままになる。真ん中の chunk を生かしておけば、 304 バイトの空きは 224 より大きな連続領域には決してならない。

chunk がどのリストへ戻るかは大きさだけで決まり、glibc の答えは 4 つある。chunk の大きさをその上で歩かせてほしい:

32 バイトの chunk

最初の 3 つは索引なので、free も再利用する malloc も、ポインタ 1 つの書き込みと pop で終わる。走査されるのは largebin だけだ。アロケータの最悪ケースがより上に住み、よくあるケースには最悪ケースがない理由がこれだ。 tcache は 2.26 で追加、bin あたり 7 chunk、ロックなし。

04

サイズクラスと、それが買う無駄

サイズクラスは「もう考えない」という約束だ。要求を決まった大きさへ切り上げれば、割り当ては探索から配列の添字になる。請求書は使えないバイトで届く。

glibc のクラスは前節の 16 バイト格子で、各オブジェクトの前にサイズ語が付く。jemalloc は倍ごとに 4 クラス —— 8、16、32、48、64、80、96 —— でヘッダはない。メタデータが slab 側にあるからだ。表を切り替え、要求を動かしてほしい:

malloc(8)

2 つの表は正反対の場所で負ける。では glibc が 32、 jemalloc が 16 を取る。固定の 1 語が小さなオブジェクトを倍にするからだ。では glibc が 528、 jemalloc が 640 を取る。倍ごとに 4 クラスとは、次のクラスが前より 4 分の 1 上にあるということだからだ。

小さい範囲全体を描くと、2 つの表の交点はちょうど 1 つだ。要求をそこへ歩かせ、両方の無駄を同時に読んでほしい:

malloc(8)

交点は 129 バイト。その下では glibc の格子が細かく、ヘッダが費用のすべてだ。上では jemalloc のクラス間隔が支配し、境界の 1 バイト先で 25% に達する ——で jemalloc は 23.7%、glibc は 0.1%。 65 バイトの構造体は 80 を要し、64 に詰めれば 5 分の 1 が戻る。

クラスは設計の半分だ。もう半分は切り出し元のslab で、slab はページ単位なので、ページ数は端材が小さくなるように選ばれる。クラスを選び、slab がそれに従うのを見てほしい:

16 バイトのクラス

扱いにくいクラスは 1 ページで済まない。端材が最小になるよう slab が選ばれるからだ。は 1 ページなら 64 バイト、2 ページなら 16 バイトを取り残す。大事なのは答えの形 —— オブジェクトごとのヘッダではなく slab ごとのビットマップだ。どちらの表も別の分布に値付けされているだけで、ホットな割り当てが 9 KiB のバッファならクラスに寄せればいい。

05

スレッドと、真ん中のロック

単スレッドの割り当ては何十年も前に解かれた。変わったのは前 2 節の bin とリストが共有されることで、共有されたデータ構造にはロックが要る。

arena 1 つ、mutex 1 つ。どの malloc もそこに並ぶ。下のモデルの臨界区間は 8 ns —— bin を索引し chunk を外す —— で、外側の仕事が 40 ns だ。スレッドを足し、曲線が直線から離れるのを見てほしい:

1 スレッド

どこで止まるかに注目。ロックは何スレッドが待とうと 8 ns に 1 回しか割り当てを出せないので、スループットはで飽和し、続く 26 スレッドは何も買わない。定数はモデルの選択だが、形はそうではない。固定長の臨界区間はどれも平らな線を与え、本当の解決策はロックを取らないことだけだ。

そのためにスレッド単位のキャッシュがある。glibc の tcache はサイズクラスごとに 7 個の chunk を持ち、誰の許可も求めない。割り当てを挟まずに free してみてほしい:

0 回 free、0 個をキャッシュ

8 個目に注目。7 個までは free はスレッドローカルな単方向リストへの push だ —— アトミックもロックもなく、命令は十数個 —— 対になる malloc は pop になる。はarena に溢れ、ロックの代金を払う。 64 個の bin が 1,032 使用可能バイトまでを覆う。つまりほとんどすべてだ。

glibc の答えのもう半分は、単にロックを増やすことだ。主 arena が混んでいたスレッドには専用の arenaが与えられる。コアあたり最大 8 つまで。スレッド数を上げ、何が予約されるかを見てほしい:

1 スレッド

その arena はどれも 64 MiB のマッピング 1 つだ。16 コアの機械ではが何も割り当てないうちに 4 GiB のアドレス空間を予約する。仮想であって常駐ではないから触るまで費用はかからない —— が、これはコンテナの上限がリークとして報告する数でもある。MALLOC_ARENA_MAX=2 が 1 行の対処で、代わりに競合を買い戻すことになる。

これらすべてを破る型がひとつある。あるスレッドで割り当て、別のスレッドで free される chunk だ。別スレッド率を上げ、 2 通りの扱い方を比べてほしい:

0% を別スレッドが free

chunk は生まれた arena のものなので、返すにはその arena のロックが要り、 free 側は持ち主と直列化する。なら、モデルの 16 スレッドは毎秒 10.81 億回から 2.34 億回へ落ちる。 mimalloc は chunk を持ち主ページの原子的な空きリストへ積む。 CAS 1 回で、並ぶものはない。

06

断片化と、RSS のラチェット

アロケータがカーネルから取ったバイトは必ず 3 状態のどれかにある ——使用中の割り当ての中、空きリストの上、丸めに食われた分 —— そしてfree は 1 番目から 2 番目へ移すことしかしない。

だから需要が増えなくてもヒープは伸びうる。下では毎巡、使用中の割り当ての 3 分の 1 を散らして解放し、同じバイト数をすぐ割り当て直す。使用中の合計は 1 バイトも変わらない。巡回を回し、ヒープに何かを要求してほしい:

0 巡

どの数が要求に答えるのかに注目。のあと空きは 768 バイトで、最大の連続領域は 352 だ。だからはどこにも入らず、ヒープは伸びるしかない —— 使用中集合は最初の 3,360 バイトのままなのに。空きの合計は統計値で、実際に持っているのは穴のほうだ。

この成長は、運用が気にする唯一の意味で永久だ。波のある負荷の 2 分間を引いて、常駐と使用中を比べてほしい:

開始から 0 秒。曲線に沿って引く。矢印キーで 1 秒ずつ、Home で最初へ
開始から 0 秒

2 本の曲線が最初の山で離れ、二度と交わらないのを見てほしい。ピークが常駐の床になる。free はバイトを bin に返しただけで、ページはマップされたまま、汚れたままだ。もカーネルは 440 MiB を請求し続け、プログラムは 180 しか使っていない。誰かが意図的にページを返さない限り、RSS は増える一方の最大値だ。

返せる者はいる。jemalloc は減衰パージャを走らせ、しばらく触られていないページを madvise する。窓を決め、天井が下がるのを見てほしい:

返却しない

窓は山と山の間隔より短くなければならない。なら常駐は使用中に最後まで付いていく。30 秒では一度も返さない。窓が閉じる前にページがまた汚れるからだ。しかもパージは無料ではない —— 260 MiB の再フォールトはマイナーフォールトで約 67 ms かかり、次にそのページを触った者が払う。

最後の 1 点だけ読むなら、3 つの違う仕組みが同じグラフを描く。1 つ選び、曲線を歩いてほしい:

開始から 0 秒

2 分の時点で 2 本は 5 MiB 以内に着くが、同じバグではない。断片化は上りながら鈍って 410 MiB に達する。穴はいずれ再利用されるからだ。リークは平坦部のない直線で 415 に達し、自分のコードに直しがあるのはこれだけだ。丸めは 229 で平らになって自ら正体を明かす。診断は 1 時間ぶんの形にあり、末尾の数字にはない。

07

動かせる回収器が買うもの

ガベージコレクタは遅い malloc ではない。別の取引だ —— オブジェクトを個別に返すことを諦め、動かす能力を買う。

ある領域の中身が単独では決して解放されないなら、割り当てにデータ構造はもう要らない ——一方向にしか進まないポインタ、上限との比較、分岐。新世代を埋め、2 つの代価を並べてほしい:

新世代の 0% を使用

比がすべてだ。と、 48 バイトのオブジェクトで 174,762 回の割り当てになる。バンプポインタで 0.35 ms、tcache ヒットで 2.1 ms —— しかも tcache ヒットはもともと速い経路だ。空きリストもサイズクラスもない。ランタイムはオブジェクトヘッダを書くが、それは型システムのためであってアロケータのためではない。

請求は回収の時に届き、その形こそが要点だ。どれだけ生き残るかを決め、何が触られるかを見てほしい:

2% のオブジェクトが生き残る

触られないものを見てほしい。複写式回収器は生存者だけを辿って外へ動かし、残りはポインタを先頭へ戻すだけで回収する —— だからゴミは無料で、代価は生きているものに比例する。なら、割り当ての 0.35 ms に複写の 0.10 ms が乗るだけで、同じオブジェクトを malloc と free でさばくよりなお 4.6 倍安い。

動かすことは malloc が真似できない部分で、努力の問題ではない。chunk を横に引き、プログラムがまだ握っているものを見てほしい:

動かしていない。chunk を横に引く。矢印キーで 8 バイトずつ、Home で元の位置へ
動かしていない

C のポインタはアドレスそのものなので、とポインタは旧アドレスに今住んでいる何かを指す。回収器が動かせるのは、すべての根とすべての参照を知っていて書き換えられるからだ。malloc は自分のポインタがどこへ行ったか教わっていない。 C のヒープの断片化がプロセス再起動でしか解消しないのはこのためだ。

こうして回収器は断片化そのものを回避し —— そのために要る余地の代金を請求する。ヒープが使用中集合の何倍かを決めてほしい:

使用中集合の 2.0 倍

Hertz と Berger は、この曲線が補間する 3 点を測った。で世代別回収器は明示的な malloc と free に並ぶ。では17% 遅く、2 倍では 70% 遅い。取引を 1 つの数字にするとこれだ。選ぶのは速いか遅いかではなく、メモリを使うか、寿命に自分の注意を使うかである。

08

クイックリファレンス

そらで答えられるべき 3 つの問い。うち 2 つにはスライダーが付いている。

どのアロケータを使うべきか。

独立した 2 つのこと次第だ —— スレッドがどう free するか、負荷にどれだけ波があるか —— そして両者の答えは一致しない。負荷を選び、スレッドを足し、スループットと常駐量が食い違うのを見てほしい:

1 スレッド

差が付くのは 4 つのうち 2 つだけだ。単スレッドのツールでは 3 つとも並ぶ。 —— 生産側で割り当て、消費側で free —— では glibc はmimalloc の 5 分の 1。波のある負荷では常駐が 432 MiB 対 191 MiB だ。

レビューで指摘する 3 点。どれも上の節の帰結だ:

  • 64 バイト未満へのホットループ内 malloc。ヘッダが 4 分の 1 を占める。外へ出すか arena へ。
  • クラス境界のすぐ先の構造体。65 バイトは 80 を要する。sizeof して 64 に詰める。
  • VSZ でのアラート。触られていない arena まで数える。 RSS の傾きで出すこと。

最悪の malloc 1 回とは。

平均のそれではない —— tcache を外すもので、 bin も外し、arena を伸ばす。システムコール 1 回と、各ページ初回タッチのマイナーフォールト。このページが値付けした代価を 1 本の軸に並べる:

6 段中 1 段目

効くのはだ。新しい 1 MiB のマッピングは 1 バイト読む前に 256 µs かかる ——バンプ割り当ての 128,000 倍。 2 巡目が薄めるのでベンチマークには出ない。p99 のグラフはこれでできている。

リークを直したのに RSS が下がらない。

free がページを返さないからだ。brk は一番上の使用中 chunk より下へ下がれず、128 KiB 未満の chunk は単独 unmap もできない。実際に返すのはjemalloc の減衰パージャで、しかも窓の後だ。stats.allocated と stats.resident を比べること。

/* the chunk one malloc(n) really takes */
size_t c = (n + 8 + 15) & ~(size_t)15;
if (c < 32) c = 32;      /* MINSIZE   */
/* usable = c - 8                     */
/* own mmap when c >= 128 * 1024      */