跳到主要内容

CPU 调节器决定了你的 Agent 基准测试结果:那个被忽视的 CI 宿主机因素

· 阅读需 11 分钟
Tian Pan
Software Engineer

我曾合作过的一个团队花了三天时间寻找他们智能体(agent)循环中 22% 的延迟回归原因。他们归咎于新的工具路由(tool router)。他们归咎于切换了模型版本。他们归咎于前一周悄悄升级的 JSON schema 验证器。他们最终在代码下游两层的地方找到了元凶:一个运行器镜像(runner image)进行了更新,新镜像将 cpufreq 调节器(governor)的默认值从 performance 改为了 schedutil,而智能体工具调用循环的突发性使得 schedutil 的升频延迟在 p95 指标中变得显而易见。模型没问题。智能体也没问题。仅仅是内核在微突发任务之间改变了 CPU 频率的调节策略,导致整个基准测试结果发生了偏移。

这是大多数智能体团队从未见过的故障模式,因为他们从不观察。你的 CI 基准测试数字并不是对模型或智能体的测量。它们是对一个技术栈的测量,这个栈恰好包含了模型、网络、共享虚拟机、虚拟机监控程序(hypervisor)调度器、具有未知邻居的缓存层级,以及——最隐蔽的——频率缩放策略,它决定了给定的每一毫秒计算是以 1.0 GHz 还是 3.6 GHz 运行。

这种情况之所以隐藏得这么深,是因为智能体延迟看起来像是一个受网络限制的问题。模型是远程的。工具是远程的。累计延迟主要由 token 生成占据。你可能根本不会预料到宿主机的 CPU 频率会有什么影响。但智能体循环本身——JSON 解析、schema 验证、工具分发、追踪序列化、嵌入查找、本地缓存命中——都运行在运行器上。而且它是在远程调用之间的短暂、突发间隔内运行的,这恰恰是让动态频率调节器失效的工作负载模式。

调节器到底对突发工作负载做了什么

CPU 频率调节器是一个内核策略,它根据最近的利用率决定核心应该以什么时钟频率运行。大多数 Linux 发行版的默认设置在过去几年发生了变化:许多服务器镜像在基准测试工作负载中使用 performance(固定在最大频率),而通用镜像转向了 schedutil,它与内核调度器的每个实体的负载跟踪(PELT)集成。Ubuntu 24.04 LTS ARM 默认使用 schedutil。一些云镜像也是如此。这种初衷是合理的——让时钟频率与负载匹配,这样空闲的宿主机就不会浪费电力——但对突发性工作的实际影响是可测量的升频延迟。

机制如下。当一个核心大部分时间处于空闲状态时,schedutil 会降低其频率。当任务到达时,调节器需要观察负载,然后才决定重新提高频率。这个观察窗口很小——毫秒级——但并不是零。对于具有间歇性 CPU 使用率的工作负载,这意味着默认调节器会让 CPU 保持在较低频率,因为它看到进程大部分时间在休眠,然后在执行密集型函数时必须进行缩放,这导致记录的基准测试产生波动。智能体循环几乎完全符合这种特征:几毫秒的本地工作,然后是数百或数千毫秒等待远程 token 流,接着又是另一波短暂的本地工作。

每次突发的最初几毫秒都以较低的频率运行。对于一个工具调用密集的智能体来说,这些“每次突发的最初几毫秒”在几十次工具轮转中累积起来。单次基准测试运行会承担这个成本。运行相同场景 50 次的回归测试会承担 50 次成本,现在你的 p50 比上一个运行器镜像慢了几个百分点——而代码没有任何改动。

云端 CI 让情况变得更糟,而非更好

一种常见的反应是“我们使用云端 CI,所以这无关紧要——我们又不在自己可以配置的硬件上运行”。这种反应是错误的,而且云端的情况实际上更糟。

云端 CI 运行器不保证硬件的一致性。GitHub Actions 标准运行器历史上使用 Standard_DS2_v2 Azure 实例,其跨越了多个具有不同 L2 和 L3 缓存大小的 Intel CPU 代次。你为一个作业获得的运行器可能与你一小时前获得的运行器在物理上是不同的。CPU 受限的基准测试往往比内存或磁盘受限的基准测试更稳定,但云端 CI 环境中报告的可变性通常达到 50%,一些研究引用同一作业运行之间的平均性能差异高达 3 倍。

除了 CPU 型号差异外,你还会遇到“嘈杂邻居”(noisy neighbors)。云端虚拟机共享物理硬件。缓存和内存带宽是共享资源。对云端虚拟机 L3 缓存行为的实证测量显示了随时间变化的嘈杂模式。虚拟机监控程序调度和资源超量交付加剧了这一点。一项关于虚拟化性能下降的调查报告显示,在最坏的情况下,相对于裸机,性能下降因子高达 16 倍。

将调节器效应叠加上述所有因素,你就得到了一个基准测试,其结果由以下因素决定:(1) 你恰好落在哪台物理 CPU 上,(2) 你的嘈杂邻居这一个小时在做什么,(3) 运行器镜像本周恰好搭载了哪个调节器,(4) 虚拟机监控程序级别是否启用了加速(boost),(5) 以及列表中某处的——你的代码更改。在这种环境下,捕捉真实的智能体循环回归的信噪比确实非常糟糕。

那些令真实团队崩溃的近例

这并非理论。过去几年中出现的几种模式直接映射到了智能体(agent)基准测试上。

glibc-on-Actions 案例:CodSpeed 记录了一个基准测试回退(regression),结果发现是 GLIBC 2.33+ 根据底层 CPU 加载了不同的优化库变体。相同代码,相同依赖版本,不同的运行器(runner)CPU 型号,导致了 C 库中不同的热路径(hot path)。在容器中运行智能体的团队通常认为容器抽象了硬件;但它并没有抽象掉 glibc 的 CPU 特性调度 IFUNC 选择器。

GCP boost-clock 开关:Google Cloud 为 Emerald Rapids 实例增加了一个明确的选项来禁用加速时钟(boost clock),专门为客户提供“一致的性能”。加速功能本身就是不一致性的源头,以至于客户要求能够将其关闭。在默认配置的云端 VM 上运行基准测试的团队是在开启加速的情况下进行测量的,这意味着运行结果会根据邻居负载和散热余量而波动。

运行器镜像滚动更新:GitHub 托管的运行器镜像会按已知节奏更新。在已记录的案例中,镜像更新改变了默认内核参数、调节器(governor)默认值,甚至是微代码(microcode)版本。团队的基准测试数据可能会在镜像滚动的那个早晨发生可察觉的百分比变化 —— 没有任何 commit,没有任何 PR,也没有模型更改。责任却落在了最近的代码更改上,团队花费数天时间撤回那些无辜的 commit。

一个真实的智能体基准测试应该是怎样的

如果你认真对待捕获真实的智能体循环回退,主机配置(host-config)问题会迫使你进行一些规范上的转变。

  • 明确锁定调节器(governor)。 如果你对运行器有任何控制权 —— 无论是自托管、专用 VM 还是裸机池 —— 请设置为 performance(性能模式)并记录下来。如果没有控制权,至少在每次基准测试运行开始时记录 /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor,这样事后你就能将结果的波动与调节器的变化联系起来。

  • 记录主机 CPU 型号。 在基准测试输出的最上方运行 lscpu | grep "Model name"。当出现回退时,第一个问题应该是“CPU 变了吗”,而不是“我们提交了什么”。特别是对于云端 CI,还要记录 /proc/cpuinfo 的缓存大小,因为即使是同一种实例类型,这些参数也可能有所不同。

  • 停止对比单次运行的数值。 运行每个基准测试场景的时间要足够长,以平摊启动阶段的影响 —— 根据从业者的报告,10 到 30 分钟是能够显现加速衰减(boost decay)和嘈杂邻居(noisy-neighbor)模式的有效范围。报告中位值、p95 以及重复运行间的离散指标(如变异系数,CV)。20% CV 范围内的 5% 中位值变化是噪声;1% CV 范围内的 5% 中位值变化则是信号。

  • 将智能体循环(agent-loop)基准测试与模型延迟(model-latency)基准测试分开。 大部分对主机敏感的工作都在本地循环中:JSON 处理、schema 校验、分发(dispatch)、追踪序列化、嵌入缓存。而大部分模型延迟发生在提供商一端,且对主机不敏感。分别测试它们,这样你才能正确归因变化。本地循环基准测试出现回退而模型调用基准测试没有回退,除非证明有误,否则这就是一个主机配置问题。

  • 跨天、跨时段运行。 合并后仅运行一次的基准测试,只是从一个带有特定邻居的运行器中获取了一个样本。通过 cron 计划在几天内运行相同的基准测试,并观察分布情况。真正的回退表现为分布偏移;主机噪声则表现为一天中的时间性规律。

  • 对本地循环部分使用确定性测量工具。 像 Cachegrind 这种计数指令而非计时的插桩(instrumentation)工具,对于代码中确定性的部分,完全消除了主机差异。虽然你无法得到墙上时钟(wall-clock)时间,但你会获得一个稳定的信号,表明你的本地循环在所执行的工作量上是否变得更沉重了。

观念的转变

根本性的错误在于将“延迟”视为代码的一种属性。在 CI 环境中,它不是。延迟是测量瞬间整个技术栈的属性,而在云端 CI 上,这个技术栈的大部分都在你的代码仓库之外。你的基准测试仪表板显示了一个数字,而这个数字有很多“父母”:模型的分词(tokenization)速度、网络的尾部行为、运行器的 CPU 型号、嘈杂邻居的缓存压力,以及 —— 悄无声息地 —— 调节器对于刚才执行工具分发函数的那个毫秒该给 1.0 GHz 还是 3.6 GHz 的决定。

当出现回退时,规范的第一步是询问自上次绿色运行(green run)以来,哪些“父母”发生了变化。模型?大概率没有。智能体循环代码?可能。运行器镜像?经常。运行器镜像中的调节器默认值?几乎没人问过,但有时这就是答案。

那些能够交付可靠智能体的团队,都已将基准测试栈拆解为对主机配置敏感度明确的多个部分,并将主机配置视为已记录、受版本控制的产物,而非静默的输入。那些没做到的团队,仍在不断撤回 commit,试图追逐一个隐藏在代码下方两层的性能回退。

如果你的智能体基准测试无法告诉你它运行在哪个 CPU 上、哪个调节器处于活动状态,以及结果分布与过去一周的运行相比如何,那么这个基准测试测量的就不是你的智能体,而是运行器。而运行器对于你的代码应该跑多快,有着它自己的看法。

References:Let's stay in touch and Follow me for more thoughts and updates