Linux kernel のバグを追おう: memcg 編(2)

前回の記事の続きです。 このシリーズでは、Linux kernel の memcg 関連のバグを追うとともに、コードリーディングをしていきます。前回は、バグの概要を説明しました。今回は、その mitigation(緩和策)として提案されたコードを読んでいきます。根本的な解決は次回以降に取り上げるので、そちらだけを見たい方は、今回はスキップでよろし、です。

では、見ていきましょう!

今回読むパッチは、memcg が持つ per-CPU の統計領域を、memcg 本体より先に解放するというものです。page cache の page などから参照されている memcg 本体は、すぐには解放できません。そこで、その memcg が持つ per-CPU の統計領域だけでも先に解放しよう、という緩和策が提案されました。

前回のおさらいですが、per-CPU は、kernel が CPU ごとに専用のデータ領域を持つ仕組みです。CPU ごとに統計を記録することでロックの競合を避けられる一方、CPU の数だけデータ領域が必要になります。つまり今回のパッチは、memcg 本体を残したまま、その中でもメモリ消費の大きい部分だけを先に片づけようとしているわけです。

読み始める前に、どの時点のコードを見ているのかをはっきりさせておきます。この記事で引用するコードは、パッチが投稿された 2022 年 4 月時点のもので、具体的には linux-next の next-20220421 という tag の tree です。linux-next は、次の merge window に向けて各サブシステムの変更をあらかじめ集めておく、kernel 開発の統合用 tree です。この tag の tree が、パッチの変更前の内容と一致することを確認しています。tree の Makefile 上のバージョンは Linux 5.18-rc3 でした。今の kernel では、この後に出てくる構造体のレイアウトは変わっているので、その点はご注意ください。

では、百聞は一見に如かず。まずはパッチの中から、実際に per-CPU の統計領域を解放していそうな場所を探します。あ、ありました。パッチが mm/memcontrol.c に追加している percpu_stats_free_rwork_fn() ですね。まず目に入るのが、次のコードですね。

/*
 * Flush and free the percpu stats
 */
static void percpu_stats_free_rwork_fn(struct work_struct *work)
{
	struct mem_cgroup *memcg = container_of(to_rcu_work(work),
						struct mem_cgroup,
						percpu_stats_rwork);
	int node;


	cgroup_rstat_flush_hold(memcg->css.cgroup);
	memcg->percpu_stats_disabled = MEMCG_PERCPU_STATS_FLUSHED;
	cgroup_rstat_flush_release();

	for_each_node(node) {
		struct mem_cgroup_per_node *pn = memcg->nodeinfo[node];

		if (pn)
			free_percpu(pn->lruvec_stats_percpu);
	}
	free_percpu(memcg->vmstats_percpu);
	memcg->percpu_stats_disabled = MEMCG_PERCPU_STATS_FREED;
	mem_cgroup_id_put(memcg);
}

ここで何やら free_percpu() で per-CPU の領域を free していそう。これで終わり??はや!と思いましたが、すんどこどっこい。

なぜか free_percpu() が二つあるではないですか。pn->lruvec_stats_percpumemcg->vmstats_percpu はどう違うのでしょうか。

その前に一つ断っておくと、関数の頭にある cgroup_rstat_flush_hold()percpu_stats_disabled への代入も気になるところですが、そこは次回以降に回します。今回はこの二つの free_percpu() に絞って見ていきます。

まず前者の pn を調べてみます。

struct mem_cgroup_per_node *pn = memcg->nodeinfo[node];

pn は、struct mem_cgroup_per_node へのポインタでした。struct mem_cgroup_per_nodeinclude/linux/memcontrol.h にあります。短いから全部載せちゃおっ。えいっ。

/*
 * per-node information in memory controller.
 */
struct mem_cgroup_per_node {
	struct lruvec		lruvec;

	struct lruvec_stats_percpu __percpu	*lruvec_stats_percpu;
	struct lruvec_stats			lruvec_stats;

	unsigned long		lru_zone_size[MAX_NR_ZONES][NR_LRU_LISTS];

	struct mem_cgroup_reclaim_iter	iter;

	struct shrinker_info __rcu	*shrinker_info;

	struct rb_node		tree_node;	/* RB tree node */
	unsigned long		usage_in_excess;/* Set to the value by which */
						/* the soft limit is exceeded*/
	bool			on_tree;
	struct mem_cgroup	*memcg;		/* Back pointer, we cannot */
						/* use container_of	   */
};

名前のとおり、これは memory controller が node ごとに持つ情報です。……node ってなんの node だ?この node は NUMA node の node です。では、NUMA node とは何でしょうか。NUMA は、CPU とメモリをいくつかのまとまりに分けて扱う仕組みです。そのまとまりを NUMA node と呼びます。

なぜ、わざわざまとまりに分けるのでしょうか。CPU が増えると、すべての CPU から一つのメモリへ向かう経路にアクセスが集中し、そこがボトルネックになります。そこで、それぞれの CPU の近くにアクセスしやすいメモリを配置し、メモリへの経路や帯域を分散させるわけです。同じ node に属するメモリには比較的速くアクセスでき、別の node のメモリへアクセスすると遅くなることがあります。NUMA node についてもう少し詳しく知りたい方には、「NUMAって、そもそもナニ?」が分かりやすいです。

Linux kernel はメモリの回収などを node ごとに行うため、memcg にも node ごとの情報が必要になります。 そしてコードに戻ると、今回は次の部分で pn->lruvec_stats_percpu が解放されています。

if (pn)
	free_percpu(pn->lruvec_stats_percpu);

なんか lruvec という単語が出てきましたね。lruvec は、複数の LRU リストをまとめた構造体です。

では、LRU とは何でしょうか。LRU は、メモリを回収するときに参照する page の管理リストです。最近使われたかどうかを基準に page を分類し、メモリが必要になったときに回収する page を選ぶために使われます。LRU の基本的な動きは、「LRU(Least Recently Used)とは」の図が分かりやすいです。Linux kernel の実装そのものを説明した図ではありませんが、長く参照されていない page から回収するイメージをつかめます。今回解放しようとしている lruvec_stats_percpu には、active や inactive といった各 LRU リストに属する page 数など、LRU に関する統計情報が記録されています。

ところで、lruvec_stats_percpu という名前を見ると、「CPU ごとに別々の lruvec を持っているの?」という疑問が出てきます。しかし、CPU ごとに用意されているのは lruvec 本体ではなく、その統計領域です。CPU ごとの統計は、後で集約先の lruvec_stats に反映されます。ここでも、前回説明した per-CPU の仕組みが使われています。

ここまでで、struct mem_cgroup_per_node は memcg が node ごとに持つ情報で、その中に lruvec もあると分かりました。memcg、NUMA node、LRU、lruvec……。こんがらがってきました。ここで一度、関係を整理してみましょう。まずは memcg、NUMA node、lruvec の三つです。概念上は、次のようになります。

                 NUMA node 0       NUMA node 1
memcg A          lruvec A-0        lruvec A-1
memcg B          lruvec B-0        lruvec B-1

lruvec は、memcg と NUMA node の組み合わせごとに用意されます。例えば lruvec A-0 は、memcg A に charge され、NUMA node 0 に置かれている page を管理する LRU リストをまとめた構造体です。

これをコード上の構造で表すと、次のようになります。

memcg A
├─ nodeinfo[0]
│  ├─ lruvec                 ← LRU リスト本体
│  └─ lruvec_stats_percpu    ← CPU ごとの統計(今回解放する領域)
│     ├─ CPU 0 が更新する統計
│     ├─ CPU 1 が更新する統計
│     └─ ...

└─ nodeinfo[1]
   ├─ lruvec
   └─ lruvec_stats_percpu
      ├─ CPU 0 が更新する統計
      ├─ CPU 1 が更新する統計
      └─ ...

nodeinfo[0] は、memcg A と NUMA node 0 の組み合わせに対応する情報です。その中に、複数の LRU リストをまとめた lruvec と、その統計を CPU ごとに記録する lruvec_stats_percpu が入っています。ここで、元のコードに戻ります。

for_each_node(node) {
	struct mem_cgroup_per_node *pn = memcg->nodeinfo[node];

	if (pn)
		free_percpu(pn->lruvec_stats_percpu);
}

ここで処理している memcg が memcg A だとします。for_each_node() は NUMA node を一つずつ回り、その node に対応する struct mem_cgroup_per_nodememcg->nodeinfo[node] から取り出します。

node 0 → lruvec A-0 の lruvec_stats_percpu を解放
node 1 → lruvec A-1 の lruvec_stats_percpu を解放
node 2 → lruvec A-2 の lruvec_stats_percpu を解放
  ...

このように、memcg A が各 NUMA node に持っている lruvec の統計領域を、順番に解放しています。

では、もう一つの memcg->vmstats_percpu は何でしょうか。memcg は、struct mem_cgroup へのポインタです。struct mem_cgroup も、同じく include/linux/memcontrol.h にあります。こちらは長いので、全部載せるのは控えます。

/*
 * The memory controller data structure. The memory controller controls both
 * page cache and RSS per cgroup. We would eventually like to provide
 * statistics based on the statistics developed by Rik Van Riel for clock-pro,
 * to help the administrator determine what knobs to tune.
 */
struct mem_cgroup {

struct mem_cgroup は、1 つの memory cgroup を表す構造体です。コメントには、cgroup ごとに page cache と RSS を制御すると書かれています。RSS ってなんや!!RSS は Resident Set Size の略で、プロセスが使用しているメモリのうち、実際に物理メモリ上に存在する量を表します。

今回注目するのは、struct mem_cgroup が持つ次のメンバーです。

struct memcg_vmstats_percpu __percpu *vmstats_percpu;

こちらは NUMA node ごとではなく、memcg 全体の page state や event を CPU ごとに記録する領域です。

ここまでを整理すると、今回 free_percpu() で解放される二つの統計領域の違いは、次のようになります。

  • lruvec_stats_percpu: memcg と NUMA node の組み合わせごとに持つ per-CPU 統計
  • vmstats_percpu: memcg ごとに持つ per-CPU 統計

そのため、lruvec_stats_percpu はすべての node を回って解放し、vmstats_percpu は最後に一度だけ解放していたんですね。図にすると、次のようになります。

memcg A
├─ nodeinfo[0] ─ lruvec_stats_percpu  ┐
├─ nodeinfo[1] ─ lruvec_stats_percpu  │ for_each_node() で
├─ ...                                │ node ごとに free_percpu()
├─ nodeinfo[n] ─ lruvec_stats_percpu  ┘

└─ vmstats_percpu                       最後に 1 回だけ free_percpu()

ここまでで、二つの free_percpu() がそれぞれ何を解放しているのかが分かりました。

ただ、lruvec_stats_percpuvmstats_percpu の中身、つまり統計そのものがどう記録されているのかは、まだ見ていません。次回は、この二つの構造体を開いて、state という配列の正体を追いかけます。

今回は以上になります。