在答应来合肥站帮忙的那天,我怎么都没有想到,赛后自己居然要花好几天时间分析 Linux 内核的问题。

Disclaimer:

  1. 以下内容均为个人分析的结果;
  2. 关于 Kernel 的部分可能存在错误,欢迎评论指正。

遇到的问题 tl;dr

因为签到题太简单但测试点又太多,评测队列塞满了。后来删掉了一些测试点之后发现,开始出现很多提交解法没问题,但是被判定超时,并且特征全部都是 judgehost 上报的 Wall time(墙上时钟时间)远高于 CPU time。之后进行了大批量的重测,但是评测非常非常慢,直到蔡队 (@cubercsl) 把 Wall time 上限开到了 10s,评测结果才正确(虽然还是非常非常慢,但是至少能有一份正确的最终榜单)。

「评测机」是一台 2 CPU(CPU 为 Intel(R) Xeon(R) Gold 6133),每个 CPU 20 核心的服务器(包括 Web 服务器在内合计两台服务器,都是从几周前的南京站运过来的),Ubuntu 22.04 + Linux kernel 5.15。最开始开启了 21 个评测实例,后来发现队列阻塞,所以加到了 30 个。故障期间,评测机的 load 为评测实例的两倍,并且有很多 runguard(DOMjudge Judgehost 的评测程序,用于设置隔离等)处于 D 状态。

因为一些巧合,最开始以为是网口/网卡/网卡驱动的问题,但是周一拿到数据库分析之后发现,即使在以为插网线的 hack 之后,问题还是完全没有解决。我找了一台校内的 server,开了一个 32 核心的 KVM 虚拟机(宿主机 CPU 为 Intel(R) Xeon(R) Silver 4314,略差一些),按照文档装上 judgehost 开了 30 个 daemon,ssh -R 12345:localhost:12345 让它能连上我的笔记本的 DOMjudge,然后连着交了 114514 发:

# fish
# 正式比赛中这么做很可能导致你的队伍被 DQ (disqualified)
for i in $(seq 1 114514); ~/Downloads/domjudge-8.2.2/submit/submit --url http://localhost:12345/ --contest hefei -y G.cpp; end

于是惊讶地发现问题可以复现(时间单位为秒):

复现问题

能复现就简单多了,至少不是硬件问题——然后因为提交实在太多了,还好我打了一个数据库的 btrfs 快照,不然我就得重新下数据库打包文件了。

Runguard

Runguard(https://github.com/DOMjudge/domjudge/blob/main/judge/runguard.c)是一个 C 程序,用于设置隔离环境,然后执行选手提交的程序,程序结束之后统计时间、程序输出等信息。它的一些技术选型:

  • Chroot 用来隔离文件系统(以及允许我们直接用选手机的 rootfs 来执行程序,一定程度确保一致性)
  • Cgroup 用来绑核(cpuset)、限制内存(memory)、统计 CPU 时间(cpuacct)
  • Unshare 用来隔离一些网络之类别的东西:

      unshare(CLONE_FILES|CLONE_FS|CLONE_NEWIPC|CLONE_NEWNET|CLONE_NEWNS|CLONE_NEWUTS|CLONE_SYSVSEM);
    

它的 “Wall time” 是怎么计算的呢:

  • 建好 cgroup 之后 fork()
  • 父进程 fork() 之后 gettimeofday() 掐表开始,子进程 setrestrictions() 之后处理一下标准输入输出错误,然后就 execve。

两者在记时开始之前没有同步,所以 Wall time 是大概率包含 setrestrictions() 的时间的,如果 setrestrictions() 太慢的话,甚至 CPU time 也可能把它算进去。既然选手程序本体没问题,那么问题很可能在 setrestrictions() 上。

在现场 /proc/<pid>/stack 看 D 的 runguard 栈的时候 @iBug 发现很多 runguard 都在 openat/rmdir 里面,以及还有一些 cgroup 相关的东西,那么首先怀疑是 cgroup 的问题。我把 runguard 里面 cgroup 禁用了之后重新编译,再交了 250 发,然后发现:

好了。

尽管没有绑核、没有 CPU time(但是可以直接 user + sys 所以问题不大)、没有内存限制,但是 Wall - CPU 的问题神秘消失了。

之后在开着 runguard cgroup 支持的情况下,我测试了 Ubuntu 22.04 仓库内的 Linux 6.2 内核——问题仍然存在,6.5 OEM 内核——问题消失了。

Bisect

事已至此,只能动手编译内核 bisect 了。花了大半天时间编译了十几份不同版本的内核,最后发现首个解决问题的 commit 是 da27f796a832122ee533c7685438dad1c4e338dd

ipc,namespace: batch free ipc_namespace structures

Instead of waiting for an RCU grace period between each ipc_namespace
structure that is being freed, wait an RCU grace period for every batch
of ipc_namespace structures.

Thanks to Al Viro for the suggestion of the helper function.

This speeds up the run time of the test case that allocates ipc_namespaces
in a loop from 6 minutes, to a little over 1 second:

real	0m1.192s
user	0m0.038s
sys	0m1.152s

Signed-off-by: Rik van Riel <[email protected]>
Reported-by: Chris Mason <[email protected]>
Tested-by: Giuseppe Scrivano <[email protected]>
Suggested-by: Al Viro <[email protected]>
Signed-off-by: Al Viro <[email protected]>

挺好的,除了看起来和 cgroup 毫无关系以外。但是似乎和 IPC namespace 有关系,联想到 runguard unshare() 给自己搞了个新的 IPC namespace,我把 CLONE_NEWIPCCLONE_SYSVSEM 去掉之后(后者是否有关联不太清楚,但是既然都是 IPC 不如一起删了):

也好了。

所以这个问题:

  • Linux 6.3「没问题」,Linux 6.2 以及 5.15 存在;
  • 去掉 runguard 里的 cgroup 可以「解决」问题;
  • 或者去掉 runguard 里的 IPC namespace 隔离也可以「解决」问题。

Ha?

我真的很想做这个表情

free_ipc()

看似我们已经获得了解决评测问题的方法,但是原因却还完全没有弄清楚。在 elixir 上翻找一通,以及向 GPT-4 先生请教补习了一些 kernel 知识,然后终于可以解释这个 commit 做了什么了。

(以下代码均以 vanilla 5.15.129 为例)

首先 Linux kernel 有「工作队列」(workqueue) 的机制,允许异步执行一些任务(提交的任务会被对应的 kworker 执行)。free_ipc() 是一个工作任务,从注释看,是因为 RCU 同步的开销比较大,阻塞住不太好:

/*
 * The work queue is used to avoid the cost of synchronize_rcu in kern_unmount.
 */
static DECLARE_WORK(free_ipc_work, free_ipc);

其中 RCU 是一种针对读多写少的同步机制,每次同步 synchronize_rcu() 会等待已经开始的 rcu read lock 都 unlock 之后再返回(也被称为 RCU grace period)。

这个工作任务会被 put_ipc_ns() 调度:

/*
 * put_ipc_ns - drop a reference to an ipc namespace.
 * @ns: the namespace to put
 *
 * If this is the last task in the namespace exiting, and
 * it is dropping the refcount to 0, then it can race with
 * a task in another ipc namespace but in a mounts namespace
 * which has this ipcns's mqueuefs mounted, doing some action
 * with one of the mqueuefs files.  That can raise the refcount.
 * So dropping the refcount, and raising the refcount when
 * accessing it through the VFS, are protected with mq_lock.
 *
 * (Clearly, a task raising the refcount on its own ipc_ns
 * needn't take mq_lock since it can't race with the last task
 * in the ipcns exiting).
 */
void put_ipc_ns(struct ipc_namespace *ns)
{
	if (refcount_dec_and_lock(&ns->ns.count, &mq_lock)) {
		mq_clear_sbinfo(ns);
		spin_unlock(&mq_lock);

		if (llist_add(&ns->mnt_llist, &free_ipc_list))
			schedule_work(&free_ipc_work);
	}
}

free_ipc() 实现如下:

static LLIST_HEAD(free_ipc_list);
static void free_ipc(struct work_struct *unused)
{
	struct llist_node *node = llist_del_all(&free_ipc_list);
	struct ipc_namespace *n, *t;
	llist_for_each_entry_safe(n, t, node, mnt_llist)
		free_ipc_ns(n);
}

free_ipc_list 是一个无锁链表。llist_del_all() 会把链表头置空,然后 node 就是原来的链表头。llist_for_each_entry_safe() 会遍历链表,由 free_ipc_ns() 实际处理对应的 IPC namespace。

static void free_ipc_ns(struct ipc_namespace *ns)
{
	/* mq_put_mnt() waits for a grace period as kern_unmount()
	 * uses synchronize_rcu().
	 */
	mq_put_mnt(ns);
	sem_exit_ns(ns);
	msg_exit_ns(ns);
	shm_exit_ns(ns);

	dec_ipc_namespaces(ns->ucounts);
	put_user_ns(ns->user_ns);
	ns_free_inum(&ns->ns);
	kfree(ns);
}

这个 commit 处理部分与 mq_put_mnt() 这一部分有关,之前其调用:

// ipc/mqueue.c
void mq_put_mnt(struct ipc_namespace *ns)
{
	kern_unmount(ns->mq_mnt);
}

// fs/namespace.c
void kern_unmount(struct vfsmount *mnt)
{
	/* release long term mount so mount point can be released */
	if (!IS_ERR_OR_NULL(mnt)) {
		real_mount(mnt)->mnt_ns = NULL;
		synchronize_rcu();	/* yecchhh... */
		mntput(mnt);
	}
}
EXPORT_SYMBOL(kern_unmount);

因此可以发现,这里每个 IPC namespace 都需要:

  1. 置空 mnt_ns;
  2. 等待 RCU grace period;
  3. mntput。

这个 commit 做的事情就是不要 kern_unmount(),而是在 free_ipc() 里面先一次性全部置空 mnt_ns,然后只需要等待一次 RCU grace period,最后再过一遍 free_ipc_ns()free_ipc_ns() 内部调用也从 mq_put_mnt() 改成直接 mntput()

听起来是个很棒的性能优化,但是这和我们的问题有什么关系呢?

free_ipc() 的性能提升 benchmark

先让我们看看这个 commit 给 free_ipc() 带来了什么性能提升吧。幸运的是,著名的系统性能分析专家 Brendan Gregg 写过一个分析 workqueue 中任务执行时间的 bpftrace 脚本,因此不用麻烦我去翻 bpftrace 的文档了。

在 5.15 与 6.3 上运行,free_ipc() 结果如下:

# 5.15
@us[free_ipc]: 
[32K, 64K)             1 |@@@@@@@@@@@@@@@@@@@@@@@@@@                          |
[64K, 128K)            0 |                                                    |
[128K, 256K)           0 |                                                    |
[256K, 512K)           0 |                                                    |
[512K, 1M)             1 |@@@@@@@@@@@@@@@@@@@@@@@@@@                          |
[1M, 2M)               1 |@@@@@@@@@@@@@@@@@@@@@@@@@@                          |
[2M, 4M)               0 |                                                    |
[4M, 8M)               1 |@@@@@@@@@@@@@@@@@@@@@@@@@@                          |
[8M, 16M)              2 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[16M, 32M)             1 |@@@@@@@@@@@@@@@@@@@@@@@@@@                          |
[32M, 64M)             0 |                                                    |
[64M, 128M)            1 |@@@@@@@@@@@@@@@@@@@@@@@@@@                          |
[128M, 256M)           0 |                                                    |
[256M, 512M)           1 |@@@@@@@@@@@@@@@@@@@@@@@@@@                          |

# 6.3.0
@us[free_ipc]: 
[8K, 16K)              2 |                                                    |
[16K, 32K)           115 |@                                                   |
[32K, 64K)          5109 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[64K, 128K)         1209 |@@@@@@@@@@@@                                        |
[128K, 256K)           1 |                                                    |

可以发现,5.15 上 free_ipc() 调用虽然不多,但是令人震惊地缓慢,最慢的竟然需要 256 到 512 秒才能执行完成(内核代码对放置在默认 system_wq 队列的要求是 “There are users which expect relatively short queue flush time. Don’t queue works which can run for too long.”)!6.3.0 上的就正常多了。除此之外,css_release_work_fn(), css_killed_work_fn(), cgroup_bpf_release() 这几个和 cgroup 有关的函数也出现了少量执行超过 1s 的任务(css = cgroup subsystem state)。

至少这说明了在大批量评测的场合,修复之前的 free_ipc() 本身确实存在一些性能问题(考虑到有大量创建了自己的 IPC namespace 的 runguard 进程不停被创建销毁)。But then? 这个工作队列照理是异步执行,为什么会影响到 runguard 子进程的 cgroup 呢?

Poor man’s stack profiler

runguard 分析的一个难点是,它是每次评测都要运行的,但是它的执行由 judgedaemon 这个 PHP 程序决定,评测本身又不长,不太容易 attach。@iBug 帮我写了个简单的 shell:

for pid in $(ps aux | awk '$8 == "D" && $11 = "/home/taoky/domjudge/judgehost/bin/runguard" { print $2 }'); do echo -e "\nStack for $pid"; cat /proc/$pid/stack; done

然后微调一下,每两秒输出到文件:

watch -n 2 -d 'DATE=$(date +%s); for pid in $(ps aux | awk '\''$8 == "D" && $11 = "/home/taoky/domjudge/judgehost/bin/runguard" { print $2 }'\''); do echo -e "\nStack for $pid" >> stack/$DATE; sudo cat /proc/$pid/stack >> stack/$DATE; done'

然后就能够看到 D 住的 runguard 到底卡哪里了,虽然不太完美,但是够用了。

拿到有问题和没问题的 stacks 信息,做一下简单的统计:

# 修复前的最后一个 commit 的内核
~/t/d/stack_lastbug> rg cgroup | wc
   1781    3562   83949
~/t/d/stack_lastbug> rg free_ipc | wc
    522    1044   19314
~/t/d/stack_lastbug> rg css | wc
   1207    2414   56696
~/t/d/stack_lastbug> rg openat | wc
   1113    2226   47827
~/t/d/stack_lastbug> rg rmdir | wc
    553    1106   22354
# 修复后的第一个 commit 的内核
~/t/d/stack-firstfix> rg cgroup | wc
      3       6     137
~/t/d/stack-firstfix> rg free_ipc | wc
    165     330    6105
~/t/d/stack-firstfix> rg css | wc
      1       2      46
~/t/d/stack-firstfix> rg openat | wc
      0       0       0
~/t/d/stack-firstfix> rg rmdir | wc
      0       0       0

似乎还真有点关系。肉眼翻了一些输出,发现有另一个单词出现很频繁:

~/t/d/stack_lastbug> rg shrinker | wc
    451     902   21106
~/t/d/stack-firstfix> rg shrinker | wc
      0       0       0

相关的函数包括 prealloc_shrinker(), reparent_shrinker_deferred(), unregister_shrinker()。第一、三个在 IPC namespace 创建/销毁的时候出现,第二个会被 css_killed_work_fn() 调用。看一下代码吧!

int prealloc_shrinker(struct shrinker *shrinker)
{
	unsigned int size;
	int err;

	if (shrinker->flags & SHRINKER_MEMCG_AWARE) {
		err = prealloc_memcg_shrinker(shrinker);
		if (err != -ENOSYS)
			return err;

		shrinker->flags &= ~SHRINKER_MEMCG_AWARE;
	}

	size = sizeof(*shrinker->nr_deferred);
	if (shrinker->flags & SHRINKER_NUMA_AWARE)
		size *= nr_node_ids;

	shrinker->nr_deferred = kzalloc(size, GFP_KERNEL);
	if (!shrinker->nr_deferred)
		return -ENOMEM;

	return 0;
}

void reparent_shrinker_deferred(struct mem_cgroup *memcg)
{
	int i, nid;
	long nr;
	struct mem_cgroup *parent;
	struct shrinker_info *child_info, *parent_info;

	parent = parent_mem_cgroup(memcg);
	if (!parent)
		parent = root_mem_cgroup;

	/* Prevent from concurrent shrinker_info expand */
	down_read(&shrinker_rwsem);
	for_each_node(nid) {
		child_info = shrinker_info_protected(memcg, nid);
		parent_info = shrinker_info_protected(parent, nid);
		for (i = 0; i < shrinker_nr_max; i++) {
			nr = atomic_long_read(&child_info->nr_deferred[i]);
			atomic_long_add(nr, &parent_info->nr_deferred[i]);
		}
	}
	up_read(&shrinker_rwsem);
}

void unregister_shrinker(struct shrinker *shrinker)
{
	if (!(shrinker->flags & SHRINKER_REGISTERED))
		return;

	down_write(&shrinker_rwsem);
	list_del(&shrinker->list);
	shrinker->flags &= ~SHRINKER_REGISTERED;
	if (shrinker->flags & SHRINKER_MEMCG_AWARE)
		unregister_memcg_shrinker(shrinker);
	up_write(&shrinker_rwsem);

	kfree(shrinker->nr_deferred);
	shrinker->nr_deferred = NULL;
}
EXPORT_SYMBOL(unregister_shrinker);

有一些端倪:找到了一个全局的读写锁(信号量)shrinker_rwsem。而 prealloc_shrinker() 调用的 prealloc_memcg_shrinker() 也用到了这个信号量:

static int prealloc_memcg_shrinker(struct shrinker *shrinker)
{
	int id, ret = -ENOMEM;

	if (mem_cgroup_disabled())
		return -ENOSYS;

	down_write(&shrinker_rwsem);
	/* This may call shrinker, so it must use down_read_trylock() */
	id = idr_alloc(&shrinker_idr, shrinker, 0, 0, GFP_KERNEL);
	if (id < 0)
		goto unlock;

	if (id >= shrinker_nr_max) {
		if (expand_shrinker_info(id)) {
			idr_remove(&shrinker_idr, id);
			goto unlock;
		}
	}
	shrinker->id = id;
	ret = 0;
unlock:
	up_write(&shrinker_rwsem);
	return ret;
}

搞不好时间都花在了锁上头,而且这个锁还是全局的。

在 cgroup 这里,也有一些有趣的东西,不少 cgroup 函数都需要获取 cgroup_mutex,比如说上面我们提到的 css_killed_work_fn()

/*
 * This is called when the refcnt of a css is confirmed to be killed.
 * css_tryget_online() is now guaranteed to fail.  Tell the subsystem to
 * initiate destruction and put the css ref from kill_css().
 */
static void css_killed_work_fn(struct work_struct *work)
{
	struct cgroup_subsys_state *css =
		container_of(work, struct cgroup_subsys_state, destroy_work);

	mutex_lock(&cgroup_mutex);

	do {
		offline_css(css);
		css_put(css);
		/* @css can't go away while we're holding cgroup_mutex */
		css = css->parent;
	} while (css && atomic_dec_and_test(&css->online_cnt));

	mutex_unlock(&cgroup_mutex);
}

这把 mutex 锁同样也是全局的。难道是锁导致的问题?

Lock stat

好消息:Kernel 支持统计每个锁的信息,包括等待锁的时间、持有锁的时间等等;

坏消息:要重新编译内核。

不过反正我都编译了十几次了,再来几次也无妨。然后和上面一样,每两秒读一下 /proc/lock_stat 的内容记录下来。

选一个高峰期的统计数据:

class name    con-bounces    contentions   waittime-min   waittime-max waittime-total   waittime-avg    acq-bounces   acquisitions   holdtime-min   holdtime-max holdtime-total   holdtime-avg

shrinker_rwsem-W:          3995           5539           0.12      826880.50   474904067.23       85738.23         100759         156706           0.00      827284.22    73644524.74         469.95
shrinker_rwsem-R:           222            227           0.36      604864.05    47812995.44      210629.94          39633          42040           0.57         544.72     3316088.16          78.88
----------------
    shrinker_rwsem           1753          [<00000000c82e9511>] __prealloc_shrinker+0x74/0x360
    shrinker_rwsem            341          [<000000009fa4c964>] unregister_shrinker+0x20/0x90
    shrinker_rwsem           3383          [<00000000c42b3549>] register_shrinker_prepared+0x1c/0x70
    shrinker_rwsem             62          [<0000000032cb78dd>] alloc_shrinker_info+0x1a/0x110
----------------
    shrinker_rwsem           3852          [<00000000c82e9511>] __prealloc_shrinker+0x74/0x360
    shrinker_rwsem           1604          [<00000000fbba4669>] reparent_shrinker_deferred+0x28/0x100
    shrinker_rwsem            239          [<0000000032cb78dd>] alloc_shrinker_info+0x1a/0x110
    shrinker_rwsem             18          [<000000009fa4c964>] unregister_shrinker+0x20/0x90

cgroup_mutex:        346315         355424           0.40     1017647.63 40646408006.90      114360.34         922305        1130556           0.00      685445.13  2132487536.75        1886.23
------------
cgroup_mutex         116559          [<000000000a638515>] cgroup_kn_lock_live+0x4d/0x140
cgroup_mutex          29751          [<00000000c1655a73>] proc_cgroup_show+0x4a/0x2d0
cgroup_mutex          54064          [<00000000fe36961a>] css_release_work_fn+0x24/0x260
cgroup_mutex          98304          [<00000000f076cd76>] cgroup_bpf_release+0x4d/0x330
------------
cgroup_mutex         191675          [<000000000a638515>] cgroup_kn_lock_live+0x4d/0x140
cgroup_mutex         160964          [<00000000fa2cc618>] css_killed_work_fn+0x19/0x150
cgroup_mutex           2401          [<00000000fe36961a>] css_release_work_fn+0x24/0x260
cgroup_mutex            326          [<00000000c1655a73>] proc_cgroup_show+0x4a/0x2d0

得到:

  • shrinker_rwsem
    • 写锁最大等待时间 826880us,最大持有时间 827284us;
    • 读锁最大等待时间 604864us,最大持有时间 545us;
  • cgroup_mutex
    • 最大等待时间 1017648us,最大持有时间 685445us。

因此可以确定是锁(特别是 shrinker_rwsem 写锁)导致的问题了。

Debugging with… printk() + ktime_get()?

用 BPF 应该可以实现任意内核代码的高效时间统计,但是因为调试符号打 deb 实在太慢了,所以我没打,然后就也不知道 kprobe 应该放哪里,所以最终还是采用了最原始的调试方案:printk(),然后用 ktime_get() 获取时间。可能有一些 overhead 吧——大概会有几 us?

mm/vmscan.c 中需要写锁的有这么几个函数:prealloc_memcg_shrinker(), alloc_shrinker_info(), free_prealloced_shrinker(), register_shrinker_prepared()unregister_shrinker()。看一下 stack 记录里面的几位出场嘉宾:

> rg shrinker | cut --delimiter=" " -f2 | cut --delimiter="+" -f1 | sort | uniq -c
      3 alloc_shrinker_info
    183 __prealloc_shrinker
    183 prealloc_shrinker
     34 reparent_shrinker_deferred
     48 unregister_shrinker

alloc_shrinker_info() 出镜率实在太低了,所以就先不管了。reparent_shrinker_deferred() 拿的是读锁,而 unregister_shrinker() 确实是写锁,但是观察实现发现唯一可能花一点点时间的是它调用的 unregister_memcg_shrinker()

static void unregister_memcg_shrinker(struct shrinker *shrinker)
{
	int id = shrinker->id;

	BUG_ON(id < 0);

	lockdep_assert_held(&shrinker_rwsem);

	idr_remove(&shrinker_idr, id);
}

但是 IDR 好歹是个 radix tree 变种,要是删个元素就要几百毫秒的话也实在说不过去(当然,我加过,然后发现它的执行时间根本不可能达到百毫秒的量级,以及高峰期 shrinker_idr 看起来数量级在上万左右)。

问题只可能在 prealloc_memcg_shrinker() 上面。IDR 分配和删除也不太可能占用那么多时间,因此给 expand_shrinker_info() 加了个 printk()

@@ -379,10 +385,14 @@ static int prealloc_memcg_shrinker(struct shrinker *shrinker)
                goto unlock;
 
        if (id >= shrinker_nr_max) {
+               ktime_t start, end;
+               start = ktime_get();
                if (expand_shrinker_info(id)) {
                        idr_remove(&shrinker_idr, id);
                        goto unlock;
                }
+               end = ktime_get();
+               printk(KERN_INFO "expand_shrinker_info taken %lld ns\n", ktime_to_ns(ktime_sub(end, start)));
        }
        shrinker->id = id;
        ret = 0;

goto unlock 在测试中没有出现,所以偷懒了)

然后编译,重启,交一堆评测,结束之后收 kern.log,确实有慢到离谱的:

Nov 29 18:35:12 cactus kernel: [ 1494.674610] expand_shrinker_info taken 476731026 ns
static int expand_shrinker_info(int new_id)
{
	int ret = 0;
	int new_nr_max = new_id + 1;
	int map_size, defer_size = 0;
	int old_map_size, old_defer_size = 0;
	struct mem_cgroup *memcg;

	if (!need_expand(new_nr_max))
		goto out;

	if (!root_mem_cgroup)
		goto out;

	lockdep_assert_held(&shrinker_rwsem);

	map_size = shrinker_map_size(new_nr_max);
	defer_size = shrinker_defer_size(new_nr_max);
	old_map_size = shrinker_map_size(shrinker_nr_max);
	old_defer_size = shrinker_defer_size(shrinker_nr_max);

	memcg = mem_cgroup_iter(NULL, NULL, NULL);
	do {
		ret = expand_one_shrinker_info(memcg, map_size, defer_size,
					       old_map_size, old_defer_size);
		if (ret) {
			mem_cgroup_iter_break(NULL, memcg);
			goto out;
		}
	} while ((memcg = mem_cgroup_iter(NULL, memcg, NULL)) != NULL);
out:
	if (!ret)
		shrinker_nr_max = new_nr_max;

	return ret;
}

嫌疑最大的就是 do-while 了。如法炮制对每次迭代输出,然后写个脚本统计一下时间 (ns) 和前十名:

19520: 702339350, max: 3940353, cnt: 9026, avg: 77812.91269665411
18368: 639621126, max: 744031, cnt: 8916, avg: 71738.57402422611
20544: 635646321, max: 802928, cnt: 9128, avg: 69636.97644609991
20416: 631049831, max: 7987415, cnt: 9032, avg: 69868.22752435783
19328: 628072921, max: 11967237, cnt: 8870, avg: 70808.67204058624
18496: 614502461, max: 839398, cnt: 8792, avg: 69893.36453594177
19904: 611960697, max: 7229538, cnt: 9041, avg: 67687.2798363013
18112: 608502773, max: 1009415, cnt: 8535, avg: 71294.99390743996
19456: 599951989, max: 12143250, cnt: 8965, avg: 66921.582710541
19264: 592053029, max: 12948429, cnt: 8803, avg: 67255.8251732364

对比 commit 之后的内核的数据:

64: 206006, max: 9337, cnt: 113, avg: 1823.0619469026549

(只在最开始有一次,后面就没有再 expand 过了)

可以发现:

  • 循环次数多了一个数量级(因为 cgroup 被锁卡着很难清理);
  • 每次循环时间平均也多了一个数量级(可能是高负载导致的)。

推论

合计用了两天半时间,不停看代码、测试,最后我的推论是:

  • 大量的评测导致大量的 runguard 进程被创建和销毁;
  • 销毁的 runguard 进程的 IPC namespace 也要销毁,但是因为内核的问题,每一个都要全局同步 RCU,销毁速度非常慢;每个销毁都需要获取 shrinker_rwsem 写锁 (unregister_shrinker);
  • runguard 进程的 cgroup 也需要清理,对应的 cgroup subsystem state 的销毁工作的一部分给了 css_killed_work_fn(),它同时需要 cgroup_mutex 锁以及 shrinker_rwsem 读锁 (reparent_shrinker_deferred);
  • 因为销毁很慢,因此 IDR 里面分不出老的不再使用的 ID,只能分出新的,因此 expand_shrinker_info() 会被频繁执行,它的执行速度和当前拥有的(还没有销毁的)memory cgroup 数量正相关;
  • 随着时间推移,负载打满,expand_shrinker_info() 的执行时间会越来越长(在有很多核心的服务器上可能更显著,因为 expand_one_shrinker_info() 的执行时间和系统核心数量是线性关系),最终导致 shrinker_rwsem 写锁释放缓慢,而 cgroup_mutex 也因此释放缓慢(拿不到 shrinker_rwsem 的读锁),memory cgroup 也随之增多(但是不会无限增长,因为创建也需要 cgroup_mutex),推动了这个恶性循环;
    • 现场的配置可能也有一些问题:最开始开启的评测 instance 是 0 到 20,但是 NUMA 的情况下绝大部分的评测 worker 都会挤在一个 CPU 上面。但是即使 NUMA 分配正确,也最多只会推迟问题,在高负载 45min 以上的情况下很可能还会发生。
  • cgroup 操作迟迟无法结束,如果发生在 Wall time 记时外,那么不会在 DOMjudge 页面上表现出来,但是评测时间会显著增长;如果发生在 Wall time 记时内,表现即为 Wall time » CPU time。如果设置的 Wall time 上限比较小,那么就会直接 TLE,即使上限比较大,也会导致评测时间显著增长。
  • 进入到了无法挽回的局面(即使重启机器,在这段时间的评测堆积也肯定会再次导致这个问题)。
  • 现场唯一能做的事情就是忍受慢速评测,提高 Wall time 限制(或者我穿越回当天,说服所有人升级内核或者关闭 IPC 隔离)。当场发现问题根源是不可能的(加服务器也不可能,因为需要保证型号一致,否则题目测试点的时间限制就不准确了)。
    • 即使现场发现了 cgroup 可能有问题,因为需要 cgroup 限制内存使用,所以从 runguard 中去掉 cgroup 的使用也是无法接受的。
  • 如果这么看,这个 commit 可能没有解决本质问题,在这个问题上卡顿的可能性仍然存在(只是概率低得多、过程更加缓慢因此可以接受了而已)。
    • 我自己测试是偶尔还会出现一批进程 D 的情况,但是出现频率很低,跑比较长的时候 load 也只是略高于 worker 的数量。
    • 以及,其他的评测系统,只要每个评测点都创建一个新环境,并且用到 cgroup 和 unshare IPC 的(包括容器),都可能有这个问题。
  • 如何彻底在内核中修复这个问题?我不知道。
    • 字节跳动的 Qi Zheng 在今年初 [PATCH v2 0/7] make slab shrink lockless 中提及了 expand_shrinker_info() 可能导致 shrinker_rwsem 写锁被占用时间太长的问题,但是对应的 patch 合入之后因为新的性能问题又被 revert之后的新补丁从我阅读代码来看,似乎锁仍然存在,上面提到的两个地方仍然有可能要抢锁(shrinker_rwsem 变成了 shrinker_mutex,因为补丁修改后 reparent_shrinker_deferred() 是唯一需要读锁的地方,而它又已知有 cgroup_mutex,所以变成了 shrinker_mutex)。

如何测试自己的部署?

我修改出了一个独立的版本,不需要部署 DOMjudge web 即可测试:https://github.com/taoky/judgescale。同时它会将 Wall - CPU 的时间差和总测评时间记录,方便在 DOMjudge 中预估、设置合理的 Wall time limit。

在本次的评测机上测试不间断同时进行 21 个评测,在全速评测不到 25 分钟内(从 uptime 2h 开始测试),就出现了此次问题的症状(评测监管进程 D,load 大于等于开启任务数量的两倍):

Result 1

在安装 Linux 6.3.0 内核包后,相同条件运行接近 1h,1 分钟最高 load 在 30:

Result 2

(在压测中途安装了 netdata):

Result 2 load shown in netdata

此外,测试时发现该服务器的内核(5.15 和 6.3)仅会在系统负载高的时候不停输出 “enoX speed is unknown, defaulting to 1000” 的信息(所以赛前也没人发现网卡的问题),从源代码看相关信息是 ib_core 输出的(i40e -> irdma -> ib_core),但是调试条件有限,因此其具体原因仍然不明。手动禁用 irdma 重启后就不会再出现这个问题了。处理后再测试了两个小时,Linux 6.3.0 内核下 21 个评测 worker load 稳定在 20 到 25,Wall - CPU 时间的值不超过 0.25s,考虑到在不超过 Wall time limit 情况下判断 TLE 是按照 CPU 时间为标准的,内核更新到 6.3 或以上之后应当就足以应对比赛环境了。

Update 1 (2023/12/05): 如果只修改 runguard,让父子进程同步保证 setrestrictions() 不计入 Wall time 能解决问题吗?修改了一下 runguard 代码之后测试,发现问题仍然可能存在:

Test 2 on Cactus

从上面字节最开始的 patch 的内容来看(因为最近没有足够的精力,我没有去抓现场,因此这一段的内容都是推测),问题应该出在缺页处理(handle_mm_fault)的时候需要更新内存 cgroup 的统计数据(可以看我在一月份的 post,恰巧提到了这个事情),而更新数据需要 shrink_slab,从而需要 shrinker_rwsem 的读锁导致的。所以可能只能靠大版本更新内核来缓解问题(或者想办法把上面的 commit backport 到老的内核上)。

这里感谢坏人 (@shankerwangmiao) 对此的提问与建议。

结论

Q: Do you judgehosts scale?

A: With cgroup_mutex and shrinker_rwsem, NO (in Linux < 6.3).