【翻译_by_gpt】通过一个奇怪的技巧让你的 QEMU 快 10 倍(软件开发中的一个典型的debug例子)

标题:通过一个奇怪的技巧让你的 QEMU 快 10 倍

URL 来源:https://linus.schreibt.jetzt/posts/qemu-9p-performance.html

Markdown 内容:
这篇关于 QEMU 的工作和文章由 Determinate Systems 资助,并在 Determinate Systems 博客 上共同发布。

背景

NixOS 广泛使用基于 QEMU 的虚拟机来运行其测试套件。为了避免为每个测试生成磁盘镜像,测试驱动程序通常使用 Plan 9 文件协议 (9p) 共享(由 QEMU 实现的服务器)来引导 Nix 存储,其中包含测试所需的所有程序和配置。

我正在进行一个虚拟机测试,该测试从 9p 挂载的 Nix 存储中复制了相当大量的数据(约 278k 个文件,总计约 5.3GiB),并对复制这些数据所需的时间感到惊讶。在 NVMe 设备上,我预计这需要几秒钟或几分钟,但实际上测试花费了超过 2 小时,其中大部分时间用于从 9p 复制文件。由于这对于增量工作来说是不可接受的,我决定深入研究一下,并将测试时间减少到仅 7 分钟。在这篇文章中,我将描述整个过程。

分析 QEMU

作为前言:我在调试性能问题方面没有太多经验!我使用的大多数工具对我来说都是新鲜的。我首先想要找出大量时间花费在哪里。我猜测是在 QEMU 而不是在客户机中,尽管这个猜测的正确性纯属运气。这个 Stack Overflow 问题 描述了一个与我的问题大致相似但不完全相同的问题。这引导我尝试使用 穷人的分析器,这是一个由 gdb、一些 shell 和 awk 组成的小黑客。

惊讶和失败的故事:穷人的分析器

我立即在这种方法上遇到了一个小障碍。gdb 说:

警告:目标和调试器在不同的 PID 命名空间中;线程列表和其他数据可能不可靠。连接到容器内的 gdbserver。

Nix 使用 Linux 命名空间为构建提供一些与运行构建的系统隔离,以减少特定机器环境对构建结果的影响(“纯度”)。这包括 PID 命名空间,它们阻止命名空间内的进程接触命名空间外的任何进程。gdb 对于在与其目标进程不同的 PID 命名空间中感到不满!我首先尝试使用 nsenter 将我的 gdb 放入沙箱中。我在这里遇到的第一个惊讶是,进入 PID 命名空间并不会导致 procps 的实用程序(如 pspgreptop)仅报告新命名空间内的进程:

[root@oak:~]# pgrep -a qemu
1678991 /nix/store/6shk4z9ip57p6vffm5n9imnkwiks9fsa-qemu-host-cpu-only-for-vm-tests-7.0.0/bin/qemu-kvm [...]

[root@oak:~]# nsenter --target 1678991 --pid

🗣 这在构建的 PID 命名空间内生成了一个新 shell
[root@oak:~]# ps -f 1
UID          PID    PPID  C STIME TTY      STAT   TIME CMD
root           1       0  0 Sep02 ?        Ss     1:24 /run/current-system/systemd/lib/systemd/systemd

什么!?这不是我期望的 PID1!而且我肯定没有从 9 月 2 日开始运行这个构建。

我将省略随后的一个小时的挫败感的细节,但事实证明,ps 和朋友们读取的 /proc 仍然是根 PID 命名空间的,即使它们不再在其中运行!这可能会在使用 pkill 等工具时导致一些有趣的意外行为……但这是另一天的问题。

有了我的新知识,我能够通过创建一个新的挂载命名空间并将所需的 proc 文件系统挂载到我们从根命名空间继承的 /proc 上来解决这个问题。

[root@oak:~]# nsenter -t 1684606 -p -- unshare -m

🗣 现在在构建的 PID 命名空间内(通过 nsenter)和一个新的挂载命名空间内(由 unshare 创建)
[root@oak:~]# ps -f 1
UID          PID    PPID  C STIME TTY      STAT   TIME CMD
root           1       0  0 Sep02 ?        Ss     1:24 /run/current-system/systemd/lib/systemd/systemd

[root@oak:~]# mount -t proc proc /proc

[root@oak:~]# ps -f 1
UID          PID    PPID  C STIME TTY      STAT   TIME CMD
nixbld1        1       0  0 12:27 ?        Ss     0:00 bash -e /nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh

[root@oak:~]# ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
nixbld1        1       0  0 12:27 ?        00:00:00 bash -e /nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh
nixbld1        6       1  0 12:27 ?        00:00:00 /nix/store/pn7863n7s2p66b0gazcylm6cccdwpzaf-python3-3.9.13/bin/python3.9 /nix/store/kdi82vgfixayxaql77j3nj7
nixbld1        7       6 99 12:27 ?        00:04:00 /nix/store/6shk4z9ip57p6vffm5n9imnkwiks9fsa-qemu-host-cpu-only-for-vm-tests-7.0.0/bin/qemu-kvm -cpu max -na
root          46       0  0 12:29 pts/5    00:00:00 -bash
root          79      46  0 12:30 pts/5    00:00:00 ps -ef
🗣 这就好多了!

[root@oak:~]# pid=7 ./pprof
   1500 __futex_abstimed_wait_common,__new_sem_wait_slow64.constprop.1,qemu_sem_timedwait,worker_thread,qemu_thread_start,start_thread,clone3
    743 __lll_lock_wait,pthread_mutex_lock@@GLIBC_2.2.5,qemu_mutex_lock_impl,qemu_mutex_lock_iothread_impl,flatview_read_continue,flatview_read,address_space_rw,kvm_cpu_exec,kvm_vcpu_thread_fn,qemu_thread_start,start_thread,clone3
    100 syscall,qemu_event_wait,call_rcu_thread,qemu_thread_start,start_thread,clone3
     53 ioctl,kvm_vcpu_ioctl,kvm_cpu_exec,kvm_vcpu_thread_fn,qemu_thread_start,start_thread,clone3
     45 get_fid,v9fs_read,coroutine_trampoline,__correctly_grouped_prefixwc,??
     15 get_fid,v9fs_walk,coroutine_trampoline,__correctly_grouped_prefixwc,??
     11 alloc_fid,v9fs_walk,coroutine_trampoline,__correctly_grouped_prefixwc,??
      8 get_fid,v9fs_getattr,coroutine_trampoline,__correctly_grouped_prefixwc,??
      5 clunk_fid,v9fs_xattrwalk,coroutine_trampoline,__correctly_grouped_prefixwc,??
      5 alloc_fid,v9fs_xattrwalk,coroutine_trampoline,__correctly_grouped_prefixwc,??
      4 __lll_lock_wait,pthread_mutex_lock@@GLIBC_2.2.5,qemu_mutex_lock_impl,qemu_mutex_lock_iothread_impl,flatview_write_continue,flatview_write,address_space_rw,kvm_cpu_exec,kvm_vcpu_thread_fn,qemu_thread_start,start_thread,clone3
      3 get_fid,v9fs_xattrwalk,coroutine_trampoline,__correctly_grouped_prefixwc,??
      3 get_fid,v9fs_open,coroutine_trampoline,__correctly_grouped_prefixwc,??
      2 clunk_fid,v9fs_clunk,coroutine_trampoline,__correctly_grouped_prefixwc,??
      1 get_fid,v9fs_readlink,coroutine_trampoline,__correctly_grouped_prefixwc,??
      1 get_fid,v9fs_readdir,coroutine_trampoline,__correctly_grouped_prefixwc,??
      1 address_space_translate_internal,flatview_do_translate.isra,address_space_map,virtqueue_map_desc,virtqueue_split_pop,virtio_blk_handle_vq,virtio_queue_notify_vq.part,aio_dispatch_handler,aio_dispatch,aio_ctx_dispatch,g_main_context_dispatch,main_loop_wait,qemu_main_loop,main
      1

代码中最常见的点是:

  1. 锁定资源,在 1500 和 743 个线程样本中。由于最高结果在工作线程上,我猜测这并不有趣,它们只是等待工作可用。
  2. 在虚拟机内部等待事情发生,在 100 和 53 个线程样本中。不过这似乎不是一个相关的问题——我预计虚拟机监视器大部分时间都在等待其客户机需要某些东西。

其余的数字小到我(错误地!)认为它们也不有趣。此时,我对这条调查路线感到足够沮丧,以至于放弃了它。

我转向 quickstack打包它,并观察到它无法为我提供任何线程信息,然后回到 Stack Overflow 问题,跟随答案中提供的另一个链接

使用 perf 的火焰图

这才真正让我有所进展。在构建运行时使用 perf record -F max -a -g -- sleep 20 记录性能数据后,我能够生成一个火焰图,使性能问题的来源变得非常明显。以下命令:

# 取自上面链接的火焰图页面
perf script | stackcollapse-perf.pl |
  # 我们只对 qemu 进程感兴趣
  grep ^qemu |
  # 通过折叠多个连续的未知堆栈帧使图形不那么高
  awk '/unknown/ { gsub("(\\[unknown];){1,}", "[unknown...];", $0) } { print }' |
  flamegraph.pl > flamegraph.svg # 并生成火焰图

生成了这个漂亮的交互式 SVG 图:

火焰图中“fid”相关函数的普遍性非常明显。因此,我深入研究了 9p 文档和 QEMU 源代码,发现 fid 是指在 9p 连接中打开文件的编号,类似于 POSIX 文件 API 中的文件描述符——因此,每个客户机打开的文件都有一个 fid。

让我们看看 QEMU 的 9p 服务器在 stat(获取文件元数据)、open(打开文件)和 read(从打开的文件读取数据)等实现中使用的 get_fid 的先前实现:

static V9fsFidState *coroutine_fn get_fid(V9fsPDU *pdu, int32_t fid)
{
    int err;
    V9fsFidState *f;
    V9fsState *s = pdu->s;

    // 博客注:我省略了一些与性能无关的部分。
    QSIMPLEQ_FOREACH(f, &s->fid_list, next) {
        if (f->fid == fid) {
            return f;
        }
    }
    return NULL;
}

QEMU 通过迭代 fid_list 来查找所需的 fid 数据。通过迭代查找列表中的条目具有 O(n) 的时间复杂度,其中 n 是列表的大小——在这种情况下,是客户机打开的所有文件的列表——因此这很昂贵!此外,对 QSIMPLEQ(QEMU 简单队列)的一些检查显示,它是作为链表实现的,这种数据结构往往表现出较差的缓存局部性。

由于我的测试从 9p 文件系统复制了许多文件,并且从中引导,因此这种查找非常频繁地发生:

  • 一个 stat 用于获取文件的元数据(特别是权限)
  • 一个 open 用于获取文件的句柄
  • 一个 read 用于小文件,或多个用于大文件,以获取文件的内容

这使得每个 278000 个文件至少有 3 次操作执行查找,将低效的查找带入热代码路径。这就是缓慢的原因。

修复它

我们真正需要的是一种可以更便宜地按 fid 查找条目的结构。我们不能仅仅使用基于数组的向量,它会始终为我们提供 O(1) 的查找,因为 fid 是由客户端选择的:我们不能依赖每个新分配的 fid 只是最小的未占用的一个,并且需要支持任意的 32 位整数。我选择了哈希表,方便地由 glib 实现,QEMU 已经依赖于它。它为我们提供了 O(1) 的最佳情况复杂度,同时保持最坏情况在 O(n)。确切的实际性能特征比链表复杂得多,可能还有稍微更合适的数据结构(或哈希表实现),但我们在这里寻找的是一个大的简单胜利,而不是微优化。

重写相关代码出奇地简单和平静,以至于一旦我让它编译,它就能正常工作(这是我以前用 C 语言从未有过的经历!)。结果非常惊人:我之前超过 2 小时的测试在 7 分钟内完成。它还将 NixOS 的 ZFS AWS 镜像的构建时间从 19 分钟减少到 1 分钟。对我来说,很明显这需要上游。

贡献修复

QEMU 使用 基于电子邮件的工作流程,补丁作为电子邮件发送到维护者订阅的邮件列表。我以前没有做过这个,过程有点混乱,你可以通过查看邮件列表中的线程(v1 v3 v1a v5 v6)看到这一点。电子邮件补丁提交有很多出错的空间,我犯了几个错误:

  • 忘记在回复评论时回复所有人,以便回复对所有感兴趣的人可见,而不仅仅是评论者
  • 在重新提交时遗漏了版本标签(在我的情况下,这是因为误导性的文档
  • 将重新提交作为对先前线程的回复发送(那只是我没有阅读文档的失败)
  • 在使用 git-publish 时丢失了一堆补丁文档工作,因为 git send-email 失败了(nixpkgs 在默认包中没有启用 Git 的电子邮件功能,而 git-publish 无条件地删除了工作文件一旦发送命令运行)。

但评论者,特别是 Christian Schoenebeck(谢谢!)很有帮助和耐心,最终补丁通过了(尽管发现了一个显然无关的错误),并且很快会在你附近的 QEMU 版本中找到它!如果你等不及并且使用 Nix,你可以通过这个覆盖拉取补丁:

final: prev: {
  qemu = prev.qemu.overrideAttrs (o: {
    patches = o.patches ++ [ (prev.fetchpatch {
      name = "qemu-9p-performance-fix.patch";
      url = "https://gitlab.com/lheckemann/qemu/-/commit/8ab70b8958a8f9cb9bd316eecd3ccbcf05c06614.patch";
      sha256 = "sha256-PSOv0dhiEq9g6B1uIbs6vbhGr7BQWCtAoLHnk4vnvVg=";
    }) ];
  });
}

结果
虽然我所做的核心改动并不复杂,但这次冒险为我带来了许多附带的(积极的!)成果:

  • 我为 quickstack 和 git-publish 编写了 Nix 包(包括一个使构建更通用的上游 PR)。
  • 我第一次使用 perf 来解决性能问题,学习了它的有用性和基本用法。
  • 我了解到不同的分析方法可能会产生截然不同的结果——perf 的结果与简易分析器的结果大相径庭;我还不明白为什么,但我会花更多时间来学习它们。
  • 我向 QEMU 提交了我的第一个补丁,开始了解一些代码。
  • 我向 QEMU 提交了我的第二个补丁(尝试修复补丁提交文档)。
  • 我学会了如何通过电子邮件提交补丁,以及需要避免的错误。
  • 我了解了 9p 协议。
  • 我的测试现在运行得更快了!
  • 我感受到了做出有价值贡献的温暖和满足感。

我的收获?深入挖掘一个令人沮丧的问题——即使我对涉及的代码和使用的技术完全不熟悉——也可能是非常有回报的!

不仅通过减少周转时间使我自己的测试工作变得更加愉快,而且这将惠及所有 QEMU 用户,并显著减少 NixOS 构建农场生成的安装程序测试负载。这就是我热爱开源软件工作的原因:如果有问题,我有能力去解决它,并将好处传递给所有使用它的人。

虽然并不总是像这次一样顺利,但这种经历可以使许多不太成功的冒险变得值得。

posted @   ffl  阅读(73)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
点击右上角即可分享
微信分享提示