OpenAI工程师借用流行病学思维,通过系统收集和分析核心转储数据,将看似不可能的崩溃拆解为两个独立问题:一个硬件故障和GNU libunwind中隐藏18年的竞态条件。最终修复了这个影响ChatGPT数据基础设施的棘手bug,展示了数据驱动调试的力量。

OpenAI 的模型和智能体越来越依赖可扩展的数据基础设施,以便在推理时搜索相关信息。部分服务用 C++ 编写,其底层系统控制能最大化性能、最小化内存占用,但 C++ 缺乏内存安全,bug 可能导致写入错误地址而崩溃。
几个月前,我们在 Rockset 服务(ChatGPT 数据基础设施的关键组件,负责搜索对话等)中观察到一些崩溃。每次崩溃中,一个正常的 C++ 函数似乎执行完毕,然后返回到一个错误地址,导致内核停止程序。有时栈帧中的返回地址为 NULL,有时栈指针寄存器 %rsp 看起来偏移了 8 字节。这些都在返回时发生。
这不是应用代码的常规故障模式。我们想到了各种可能(包括内核 bug、编译器问题等),但每条假设都有反证。最终,我们意识到这其实是两个不相关的 bug 恰好同时出现:一个 Azure 主机上的静默硬件损坏(CPU 计算错误),以及一个存在了 18 年的竞态条件——GNU libunwind 库中的 bug。
我们最初尝试像常规调试那样,重点分析少数核心转储。大多数崩溃出现在 DocumentTree::updateDocument 方法中:它调用了某个未知函数 X,栈在 X 执行期间被破坏,X 返回了一个不是可执行代码的地址。但我们不知道栈何时被破坏,搜索空间巨大。
我们手动检查了更多核心转储,但过程太费力,无法获得可靠数据集。我们错误地排除了硬件 bug,因为崩溃跨多个区域和硬件类型出现,所以还在寻找纯软件原因。
Rockset 以 -fno-omit-frame-pointer 编译,活动栈帧始终可通过 %rbp 访问。Linux x86_64 在 %rsp 下方保留 128 字节的红色区域(red zone),内核在信号交付时不会覆盖它。这给调试提供了线索。
我们找到一个 %rsp 错位的崩溃,其中所有涉及函数都很小,发现 %rsp 在一个简单函数执行期间就错位了,后续调用却成功进行,直到活动函数尝试返回时才崩溃。代码路径中未使用异常、内联汇编等,因此不是用户空间代码问题,这让我们怀疑内核。
Rockset 比大多数程序更积极使用信号。我们使用 coarse_thread_cputime_clock 近似测量线程 CPU 时间,通过 timer_create 每几毫秒 CPU 时间发送一次 SIGUSR2。这样频繁的信号使得罕见的内核 bug 变得可能。我们阅读了内核源码、Azure 补丁等,但没有发现相关线索。
调试问题有两种方式:一种是像医生一样专注一个病例,深入检查;另一种是像流行病学家一样观察整个群体,寻找模式。我们之前一直在“医生模式”,关键转变是决定收集高质量的人群数据。
我们请 ChatGPT 编写了脚本,自动下载核心文件前缀、提取寄存器、过滤已知误报并标记崩溃类型(返回 NULL、栈错位、其他)。然后并行运行在之前一年所有生产 Rockset 核心转储上。
这是转折点。数据一出现,相关性立刻显现:我们一直当作一个奇怪 bug 的,实际上是两个独立的崩溃群体。
返回 NULL 的崩溃分布在各集群和区域,近期频率增加,但没有明确的开始日期。栈错位的崩溃完全不同:全部来自同一区域,有明确开始日期,从未出现在运行已久的节点上。模式指向一台物理主机上的硬件问题。
我们意识到自己一直在混淆两个 bug。
根据干净的 Kubernetes 节点和时间戳列表,我们将栈错位崩溃追踪到单个物理主机,轻松将其加入黑名单。移除该主机后,栈错位崩溃消失。
我们改进了信号处理程序以包含寄存器状态,以便仅从日志检测同类问题;修改控制平面以便尽可能复用 VM 而非回收,从而更容易检测坏节点。
将硬件崩溃分离后,剩下的返回 NULL 崩溃变得更容易推理。我们之前排除了异常展开的可能性,因为以为有反例(代码路径中确实没使用异常),但那些反例全部来自硬件错误集群。现在重新检查发现结论正好相反:所有崩溃都发生在异常展开期间。
C++ 抛出异常时,运行时需要发现哪个 catch 块应接收它,以及沿途运行的析构函数。编译器发出元数据,但实际匹配在运行时动态进行。
异常展开由辅助函数执行,这些函数检查栈、获取元数据、动态查找清理处理器和 catch 块,然后转移控制。转移控制包括展开所有中间栈帧(包括辅助函数本身的)。这与 longjmp 或协程切换相似,需要恢复被调用者保存的寄存器以及 %rbp 和 %rsp。
我们的二进制链接了两个包含异常展开实现的库:libgcc 和 GNU libunwind。动态链接器选择了 GNU libunwind 的版本,这出乎我们意料。
我们原先假设看到的是普通函数返回 NULL,现在假设:我们看到的可能是一次展开转移——类似 setcontext 的寄存器恢复——其中目标指令指针在控制转移前变成了 NULL。也就是说,展开库给出了错误的目标状态,或者正确状态但在应用前被损坏了。
我们阅读 GNU libunwind 源码,发现它在栈上合成一个 ucontext_t,填入所需寄存器状态,然后交给内部汇编例程 _Ux86_64_setcontext。
合成的 ucontext_t 位于某个被 _Ux86_64_setcontext 展开的栈帧中。_Ux86_64_setcontext 是否在改变 %rsp 后从该结构读取数据?此时结构地址已不再属于活动栈,因此易受信号交付影响——例如我们频繁的 SIGUSR2。
答案是肯定的。以下是 _Ux86_64_setcontext 的最后六条指令(版本相关):
%rdi 指向栈上分配的 ucontext_t。第一条指令更新 %rsp 指向新活动栈底。这一瞬间,%rdi 指向的结构不再是活动栈(或红色区域)的一部分,内核可以覆盖它。
通常这不会造成问题,但如果信号恰好在此时到达,内核会在 %rsp-128 处构建信号帧,可能覆盖 %rdi 指向的内存。如果发生在下一条指令读取 UC_MCONTEXT_GREGS_RIP(%rdi) 之前,恢复的指令指针可能被损坏(在我们的崩溃中变成 NULL)。
这就是那个 bug。
setcontext 在控制转移的最后时刻无法使用 %rdi 寄存器读取目标地址,因为它要恢复所有寄存器(包括 %rdi)。因此它提前读取目标地址并保存到栈上,恢复几个寄存器后,用 retq 读取保存的值并转移控制。
核心转储中看起来像“函数返回 NULL”,实际上是“展开器在栈上合成了目标返回地址,但在转移完成前被损坏”。我们之前假设返回地址槽的损坏必须发生在原地,因为我们不知道有地方会故意向返回地址槽写入可损坏的数据。
这个竞态窗口只有一条指令!信号必须在 %rsp 改变后、下一条指令加载 %rip 前交付。现代 CPU 上这类简单指令每个周期可以执行多条,竞态窗口大约 100 皮秒。
我们最初认为这太罕见,不足以解释观察到的崩溃率。但通过费米估算:如果脆弱窗口约 10⁻¹⁰ 秒,SIGUSR2 每 10⁻² 秒 CPU 时间到达一次,那么每个异常清理处理器或 catch 块输掉竞态的概率约 10⁻⁸。Rockset 使用异常作为内部摄入反压力机制,一台过载主机每秒可能抛出 10⁴ 次异常,因此平均故障间隔时间约 10⁴ 秒(几小时),足以解释舰队规模的崩溃频率。
GNU libunwind 的 bug 已有 18 年历史。崩溃率与异常抛出次数和信号次数成正比,还取决于信号处理程序消耗多少栈。Rockset 在这三方面都不同寻常:正常过载控制中高频抛出异常;因 coarse_thread_cputime_clock 频繁发送 SIGUSR2;今年早些时候我们增加了信号处理程序的栈使用(调用 timer_getoverrun 以处理合并信号)。
最后一个改动很重要:如果处理程序栈使用较少,可能不会触及并覆盖过时的 ucontext_t 内存。在此之前我们完全未观察到这些崩溃。之后崩溃率保持低位,直到我们为某些用例增加负载,压垮了反压力机制。
换句话说,libunwind 的 bug 一直存在,但异常率、信号率和处理程序栈使用的乘积直到最近才跨越操作可见的阈值。
我们的即时缓解措施:从 GNU libunwind 切换到 libgcc 的展开器。我们还向上游提交了可复现用例和修复,并验证其他展开器没有类似问题。
这次调试经历教会我们很多具体细节,但最重要的经验是:构建一个高质量的数据集。没有这个数据集,我们始终将两个不同现象混为一谈。有了准确、完整的人群数据,问题的结构就变得清晰。
对于基础设施系统,这一点至关重要。这次调查加强了我们对深度监控、自动调查和运维工具持续改进的承诺。可靠性不仅在于 bug 发生后的修复,更在于构建数据、工作流和技能,将不可能的问题转变成可诊断、可解决的。
原文链接:OpenAI Blog
本文由前途科技编辑整理
免费获取企业 AI 成熟度诊断报告,发现转型机会
关注公众号

扫码关注,获取最新 AI 资讯
3 步完成企业诊断,获取专属转型建议
已有 200+ 企业完成诊断