一个月前伊洪分享了一个 How is GNU/yes so fat: https://www.reddit.com/r/unix/comments/6gxduc/how_is_gnu_yes_so_fast/ ,很有意思,今天我来演进式学习一下(安全声明:螃蟹人慎入😀)
baseline: GNU/yes 9.4: yes | pv >/dev/null 结果是 6.23 GiB/s.
第一版是 libc put:
void main() {
while(puts("y"));
}
pv 指标 239M/s,用 perf stat -e cycles:u,cycles:k 发现 95% 的时间花在用户态,想想就不对劲,你一个理论上 IO bound 的进程怎么大量时间在用户态玩耍 buffer,扔掉 libc 吃我 syscall。
第二版 write(..., 2)
void main() {
while(write(1, "y\n", 2));
}
pv 8.13MiB/s,比 put 整整下降了 96%。这次 user:sys 大概是 16%:84%,好像也不太对劲,用户态一共就十条指令占了 16% 的 cycle,内核态是在吃屎吗。这一版 IPC 是 1.42,固然比第一版 put 的 IPC 3.77 低了三倍,但是就算想办法微操让 IPC 乘以 3,IO 也追不上 put。问题不在 IPC 而在过多低效 syscall。
第三版 bulk write(..., 65536)
int main(void)
{
static char buf[BUF_SZ];
for (size_t i = 0; i < BUF_SZ; i += 2) {
buf[i] = 'y';
buf[i + 1] = '\n';
}
while(write(STDOUT_FILENO, buf, BUF_SZ));
}
这一版 IO 已经飞起来了,直接到达了 5.4 GiB/s,但仍然不及 baseline。用 perf stat -d 发现
724.68 msec task-clock # 0.724 CPUs utilized
2,000,509,009 cpu_core/instructions/ # 0.91 insn per cycle (78.20%)
TopdownL1 (cpu_core) # 77.0 % tma_backend_bound
我专门贴出来这个结果就是想说,可能有人起手就 IPC 低啦,77% 的 tma_backend_bound 啦,但我始终强调,优先关注 on-cpu 状态。这个版本的 yes 只有 0.724 的 CPU 利用率,大量 off cpu context switch,解决这个先。
bpftrace oneliner 发现是 1 状态 (S)切出极多,直接 offwaketime --state 1 走一波:
waker: pv 71935
__x64_sys_splice
__splice_from_pipe
__wake_up
-- --
schedule
pipe_write
__x64_sys_write
target: write65536 71934
我把结果里最重要的信息裁剪出来如上,发现此版 yes 的海量 off cpu 切出都是由于 pipe 阻塞。熟读 TLPI 的工程师已经意识到方向了:扩大 pipe buf size。
第四版 fcntl 扩大 PIPE_SZ
fcntl(STDOUT_FILENO, F_SETPIPE_SZ, 1048576);
只需一行即可,1048576 是 sysctl fs.pipe-max-size 告诉我的。这一版结果已经略微超越了 GNU/yes,高达 6.35 G/s,baseline 6.23 G/s。这时候再 perf stat -d 发现 0.997 on cpu,心满意足。然后再去解决 0.71 IPC 和 80.9 % tma_backend_bound。
两次 topdown (perf stat -M tma_backend_bound_group, perf stat -M tma_memory_bound_group) 发现 24.8 % tma_store_bound 是 IPC 瓶颈。懂了,用户态到内核态的内存拷贝。
第五版,零拷贝。
int main()
{
fcntl(STDOUT_FILENO, F_SETPIPE_SZ, 1048576);
static char page[4096];
for (size_t i = 0; i < (size_t)4096; i += 2) {
page[i] = 'y';
page[i + 1] = '\n';
}
const int iovcnt = 65536 / 4096;
struct iovec iov[iovcnt];
for (int i = 0; i < iovcnt; ++i) {
iov[i].iov_base = page;
iov[i].iov_len = 4096;
}
while (vmsplice(STDOUT_FILENO, iov, iovcnt, 0));
}
这一版的结果达到了 baseline 的四倍 28G/s,IPC 2.51。这一版 IPC 是上一版的约四倍,IO 也达到了约四倍。这时候再做一次 context switch 统计,和上一版相比,发现 offcpu 切出变多了;其实不需要这么细节,直接看 perf stat -d 发现 cpu 利用率微微下降了,上一版的 0.997 到这一版的 0.993,说明更多时间阻塞在 pipe,瓶颈已经开始转移到 pipe 读端 pv 进程了。
第六版,io_uring
开始对 pv 做 perf topdown,发现 64.2 % 的 tma_serializing_operation。pv 已经使用了 splice syscall,我试着写了一个 io_uring + splice 的版本发现并不能更快,目前就卡在这里。虽然我觉得还有优化的空间,先 brain dump 在这里,我要先去忙一段时间其他主题。
bonus:
整个话题的起源其实是 https://github.com/jedisct1/yes-rs/ 这个 rust 版本的 yes 被发到 hacker news。我知道项目是在开玩笑,但我本着严肃对待一切自吹自擂、以防不明真相的路人把玩笑当真,说一点得罪螃蟹人的话:
这个 rust 版本的 yes 慢爆了,甚至只有上面版本二 write(..., 2) 的一半,慢到了 4 M/s。而就算如此它居然还敢说
⚠️ WARNING: This code is so BLAZINGLY FAST it might cause
temporal paradoxes. Use responsibly.
它但凡能达到 GNU/yes 的性能我都敬它是个有技术的项目,结果就这?写出超快的 yes 确实是技术活,但,😀忍着不开地图炮。
baseline: GNU/yes 9.4: yes | pv >/dev/null 结果是 6.23 GiB/s.
第一版是 libc put:
void main() {
while(puts("y"));
}
pv 指标 239M/s,用 perf stat -e cycles:u,cycles:k 发现 95% 的时间花在用户态,想想就不对劲,你一个理论上 IO bound 的进程怎么大量时间在用户态玩耍 buffer,扔掉 libc 吃我 syscall。
第二版 write(..., 2)
void main() {
while(write(1, "y\n", 2));
}
pv 8.13MiB/s,比 put 整整下降了 96%。这次 user:sys 大概是 16%:84%,好像也不太对劲,用户态一共就十条指令占了 16% 的 cycle,内核态是在吃屎吗。这一版 IPC 是 1.42,固然比第一版 put 的 IPC 3.77 低了三倍,但是就算想办法微操让 IPC 乘以 3,IO 也追不上 put。问题不在 IPC 而在过多低效 syscall。
第三版 bulk write(..., 65536)
int main(void)
{
static char buf[BUF_SZ];
for (size_t i = 0; i < BUF_SZ; i += 2) {
buf[i] = 'y';
buf[i + 1] = '\n';
}
while(write(STDOUT_FILENO, buf, BUF_SZ));
}
这一版 IO 已经飞起来了,直接到达了 5.4 GiB/s,但仍然不及 baseline。用 perf stat -d 发现
724.68 msec task-clock # 0.724 CPUs utilized
2,000,509,009 cpu_core/instructions/ # 0.91 insn per cycle (78.20%)
TopdownL1 (cpu_core) # 77.0 % tma_backend_bound
我专门贴出来这个结果就是想说,可能有人起手就 IPC 低啦,77% 的 tma_backend_bound 啦,但我始终强调,优先关注 on-cpu 状态。这个版本的 yes 只有 0.724 的 CPU 利用率,大量 off cpu context switch,解决这个先。
bpftrace oneliner 发现是 1 状态 (S)切出极多,直接 offwaketime --state 1 走一波:
waker: pv 71935
__x64_sys_splice
__splice_from_pipe
__wake_up
-- --
schedule
pipe_write
__x64_sys_write
target: write65536 71934
我把结果里最重要的信息裁剪出来如上,发现此版 yes 的海量 off cpu 切出都是由于 pipe 阻塞。熟读 TLPI 的工程师已经意识到方向了:扩大 pipe buf size。
第四版 fcntl 扩大 PIPE_SZ
fcntl(STDOUT_FILENO, F_SETPIPE_SZ, 1048576);
只需一行即可,1048576 是 sysctl fs.pipe-max-size 告诉我的。这一版结果已经略微超越了 GNU/yes,高达 6.35 G/s,baseline 6.23 G/s。这时候再 perf stat -d 发现 0.997 on cpu,心满意足。然后再去解决 0.71 IPC 和 80.9 % tma_backend_bound。
两次 topdown (perf stat -M tma_backend_bound_group, perf stat -M tma_memory_bound_group) 发现 24.8 % tma_store_bound 是 IPC 瓶颈。懂了,用户态到内核态的内存拷贝。
第五版,零拷贝。
int main()
{
fcntl(STDOUT_FILENO, F_SETPIPE_SZ, 1048576);
static char page[4096];
for (size_t i = 0; i < (size_t)4096; i += 2) {
page[i] = 'y';
page[i + 1] = '\n';
}
const int iovcnt = 65536 / 4096;
struct iovec iov[iovcnt];
for (int i = 0; i < iovcnt; ++i) {
iov[i].iov_base = page;
iov[i].iov_len = 4096;
}
while (vmsplice(STDOUT_FILENO, iov, iovcnt, 0));
}
这一版的结果达到了 baseline 的四倍 28G/s,IPC 2.51。这一版 IPC 是上一版的约四倍,IO 也达到了约四倍。这时候再做一次 context switch 统计,和上一版相比,发现 offcpu 切出变多了;其实不需要这么细节,直接看 perf stat -d 发现 cpu 利用率微微下降了,上一版的 0.997 到这一版的 0.993,说明更多时间阻塞在 pipe,瓶颈已经开始转移到 pipe 读端 pv 进程了。
第六版,io_uring
开始对 pv 做 perf topdown,发现 64.2 % 的 tma_serializing_operation。pv 已经使用了 splice syscall,我试着写了一个 io_uring + splice 的版本发现并不能更快,目前就卡在这里。虽然我觉得还有优化的空间,先 brain dump 在这里,我要先去忙一段时间其他主题。
bonus:
整个话题的起源其实是 https://github.com/jedisct1/yes-rs/ 这个 rust 版本的 yes 被发到 hacker news。我知道项目是在开玩笑,但我本着严肃对待一切自吹自擂、以防不明真相的路人把玩笑当真,说一点得罪螃蟹人的话:
这个 rust 版本的 yes 慢爆了,甚至只有上面版本二 write(..., 2) 的一半,慢到了 4 M/s。而就算如此它居然还敢说
⚠️ WARNING: This code is so BLAZINGLY FAST it might cause
temporal paradoxes. Use responsibly.
它但凡能达到 GNU/yes 的性能我都敬它是个有技术的项目,结果就这?写出超快的 yes 确实是技术活,但,😀忍着不开地图炮。