首页
/
新闻
/
LoongArch LA664 上丢失的原子更新
LoongArch LA664 上丢失的原子更新
摘要
LoongArch LA664 处理器的原子加法指令存在一个 bug,在软件打包过程中会导致无限循环。该问题通过 AI 辅助得以识别,并由龙芯通过固件更新修复。
<p><a href="https://lobste.rs/s/duu1z0/lost_atomic_update_on_loongarch_la664">评论</a></p>
查看缓存全文
缓存时间:
2026/09/26 19:28
# 一条CPU原子指令,一个封装无限循环:LA664 上丢失更新事件始末 - 杰哥的{教学,运维,编程,调板子}小笔记 来源:https://jia.je/hardware/2026/09/24/loongson-cpu-erratum-en/ cpu (https://jia.je/tags/#tag:cpu)erratum (https://jia.je/tags/#tag:erratum)loongarch (https://jia.je/tags/#tag:loongarch)loongson (https://jia.je/tags/#tag:loongson)中文版本 (https://jia.je/hardware/2026/09/24/loongson-cpu-erratum/)
## 概要¶ (https://jia.je/hardware/2026/09/24/loongson-cpu-erratum-en/#tldr)
2026年2月,王淼 (https://github.com/shankerwangmiao) 在龙芯LoongArch服务器上为Debian打包normaliz时遇到了怪事:这款数学软件的内置测试一直超时,陷入了一个无法跳出的无限循环。追查代码后,问题指向了一个极其普通的操作:OpenMP的`#pragma omp atomic`在累加一个共享变量。循环的退出条件要求累加值等于某个数字,但累加结果总是小于该数字,导致了无限循环。由于程序庞大且代码复杂,我们始终未能将其简化为一个人能理解的最小例子,于是此事被搁置。
半年后的八月,王淼再次找到我,想重新拾起这个问题。这次我们采取了不同的方法:不再由人定位问题,而是让AI寻找最小复现路径,由人指导AI的调查。大约两天后,我们得到了一个稳定的复现程序,这时才发现根本原因:CPU的原子加法指令偶尔会未能保持原子性。这意味着我们发现了一个新的CPU勘误,龙芯在获知后仅两周就找到了一个几乎无性能损失的修复方案,并向我们提供了测试固件。我们确认测试固件解决了问题,龙芯告知该固件预计在国庆节(10月1日)前发布,届时读者即可升级固件修复问题。
现在让我们从头到尾讲述这个完整的故事。
## 起源¶ (https://jia.je/hardware/2026/09/24/loongson-cpu-erratum-en/#origins)
loong13 (https://loong13.debian.net/) 是社区维护的Debian 13稳定版对LoongArch的移植,王淼是其维护者之一。在构建和打包过程中,发现normaliz的内置测试会陷入无法退出的循环,导致打包超时。当时我们未立即找到根本原因,因此不得不跳过这个软件包。但由于有若干其他软件包依赖normaliz,我们无法一直跳过,因此在二月份开始集中调查此问题。
此前,在构建其他软件包时,我们曾在代码中发现隐藏的竞态条件或内存序问题,而这类问题在采用弱内存模型的LoongArch上更容易暴露。因此起初我们猜测,该软件可能存在类似问题。但调查一开始,惊喜来了。
## 第一轮调查¶ (https://jia.je/hardware/2026/09/24/loongson-cpu-erratum-en/#the-first-round-of-investigation)
第一轮调查从normaliz的源代码开始。normaliz使用OpenMP并行处理数据点。问题代码片段可概括如下:
```c
func (std::list> LatticePoints) {
size_t nr_to_match = LatticePoints.size(); // 输入大小
size_t nr_points_matched = 0; // 已处理的点数
while (true) {
size_t nr_points_done_in_this_round = 0; // 本轮处理的点数
#pragma omp parallel {
auto P = LatticePoints.begin(); // 线程私有的列表指针
size_t ppos = 0; // 线程私有的列表指针位置
#pragma omp for
for (ppp = 0...nr_to_match){
if (skip_remaining) {
// 在某些情况下设置 skip_remaining,跳过未处理的点
continue;
}
// 根据 ppos 和 ppp 之间的差值,将 P 移动到 ppp 指向的位置并维护 ppos
if ((*P)[0] == 0) { // 表示已被处理
continue;
}
#pragma omp atomic
nr_points_matched++;
#pragma omp atomic
nr_points_done_in_this_round++;
// 处理 P 指向的对象
(*P)[0] = 0;
}
}
// 这个 break 永远不会被执行
if (nr_points_matched == nr_to_match) break;
}
}
```
这段代码的要点是:对于给定的`LatticePoints`列表,程序并行处理每个点。在处理每个数据点时,某些点可能被暂时跳过,需要重复遍历,直到所有数据点都被处理完毕。
在此代码中,`nr_to_match`是数据点总数,`nr_points_matched`是已处理的点数,`nr_points_done_in_this_round`是本轮处理的点数。循环的终止条件是`nr_points_matched`等于`nr_to_match`,即所有数据点均已处理。
无限循环的直接原因是`nr_points_matched`从未达到`nr_to_match`,因此循环无法终止。使用gdb可以发现,此时整个`LatticePoints`列表中的每个点都已被标记为已处理,所以`nr_points_matched`停止增长,但循环的退出条件始终未满足,于是它一直循环下去。
那么问题就变成:为什么计数器`nr_points_matched`的值与实际处理的数据点数不符?根据代码,`nr_points_matched`的每轮增量应等于`nr_points_done_in_this_round`的增量,因为它们总是一起被原子递增。但实际输出并非如此:两个计数器的值略有差异,且差距不稳定,每次运行结果都不同。
首先排除的是内存序问题:这段代码并未依赖原子变量来同步其他变量;换言之,它始终只操作和读取原子变量本身,因此从代码逻辑上看是正确的。接下来的怀疑是OpenMP实现是否有问题:那些用`#pragma omp atomic`标注的原子操作是否真正保证了原子性?从反汇编可以看出,编译器为这些原子操作生成了LoongArch64的`amadd.d`指令,符合预期。
为了调查此问题,我们设置了两个额外的`std::atomic`计数器作为对照,与原有的一起使用,看结果是否一致。结果四个计数器的值(根据每轮增量计算)本应一致,但实际上却显示出随机的差异。这暗示原子加法指令在某些条件下会丢失更新。然而,用一个简单的原子加法程序测试原子加法指令的原子性,却无法复现丢失更新的问题。
为了找到最小复现程序,我们将上述normaliz处理逻辑简化为一个类似的测试程序,同样无法复现问题。因此我们不得不在normaliz实际运行的代码中不断注释掉计算步骤,试图找到触发丢失原子加法的最小条件。一个怪异的现象是,即使注释掉大部分计算步骤,问题依然存在。由于程序过于复杂,最终我们仍然未能找到一个可靠复现丢失原子加法的最小代码片段。
## 第二轮调查¶ (https://jia.je/hardware/2026/09/24/loongson-cpu-erratum-en/#the-second-round-of-investigation)
六个月后,问题仍未解决。随着LoongLeak/LoongBleed漏洞 (https://jia.je/hardware/2026/08/18/loongleak-loongbleed/) 的披露,normaliz中丢失的原子加法再次回到我们的视野。这次,我们尝试使用AI辅助调查。方法是:首先向AI指出上述normaliz代码存在无限循环问题,要求AI确认并复现它,然后寻找可能的原因。
在第一轮对话中,AI注意到了有问题的循环,但并未断定是原子加法指令的问题。之后,我们向AI提示该问题仅存在于LoongArch上,其他架构没有,但AI仍无法给出明确结论。最后,我们直接告诉AI我们已将问题定位到原子加法的事实,要求其复现并提供最小复现程序。在那轮对话中,AI最终将注意力转向了处理函数中的一个`memcpy`调用,而这正是第一轮调查中被忽略的部分:`memcpy`的实现位于glibc中,glibc会根据当前可用的硬件特性选择最优实现;如果硬件支持向量指令集(LoongArch上的LSX/LASX),glibc的`memcpy`将使用相应的向量指令加速内存复制。而正是这些向量化的内存复制,在LoongArch64上触发了丢失原子加法。
两天后,AI生成了一个可靠复现问题的最小程序。
## 扩展范围¶ (https://jia.je/hardware/2026/09/24/loongson-cpu-erratum-en/#expanding-the-scope)
发现原子加法指令会丢失更新后,我们有了新的疑问:首先,是只有原子加法受影响,还是其他原子指令也有相同问题;其次,其他内存操作是否也会触发类似问题。
对于第一个问题,我们首先调查了CAS指令,因为它可用于实现原子加法,容易判断是否出错。结果CAS在相同条件下也有问题。对于其他原子指令,如原子交换、原子最大值、原子最小值、原子按位与、原子按位或等,由于即使发生丢失更新也不容易从结果检测,我们起初并未验证它们。例如,在原子最大值操作中发生丢失更新,只要更新最大值的操作没有丢失,结果就是正确的。
经过深思熟虑,我们最终找到了一种验证方案:通过记录每次操作的结果并在事后验证来检查此类指令是否丢失更新。以原子最大值为例:如果并行地对数字1到n进行原子取最大值,最终结果应为n。每次原子最大值操作会修改内存并返回旧的最大值。对于一个输入k的操作,如果返回的旧值小于k,则该操作更新了最大值。原子性保证了所有更新最大值的操作的返回值不会重复。如果发生重复,则说明原子指令发生了丢失更新。
验证表明,这些原子指令在相同条件下都会丢失更新。
对于第二个问题,我们在测试中发现,只有LASX向量化内存读取(`xvld`)会触发丢失原子指令;普通的标量读取和LSX向量化内存读取(`vld`)不会触发问题。后来,Rong "Mantle" Bao (https://github.com/CSharperMantle) 独立发现,当原子变量的内存地址与读取的内存地址具有特定的位置关系时,普通的标量读取也可能触发丢失原子指令,这意味着即使没有LASX,问题也可能发生,只是概率较低。这些复杂的触发条件解释了为何此问题如此长时间未被发现。
## 具体结论¶ (https://jia.je/hardware/2026/09/24/loongson-cpu-erratum-en/#specific-conclusions)
在给出具体结论之前,让我们先介绍背景:龙芯的3C6000/S和3A6000使用LA664核心,其指令集是带有SIMD扩展的LoongArch64;其中,128位SIMD扩展称为LSX,256位称为LASX。更早的LA464核心(如3A5000)没有此问题。
LASX包含内存读取指令`xvld`,可一次读取32字节到向量寄存器。龙芯的原子指令可概括为`am[_db].<op>.<size>`,其中`<op>`是具体的原子操作,如`amadd`、`amcas`、`amswap`、`ammax`、`amxor`、`amand`、`amor`等;`[_db]`表示是否带有数据屏障(db);`<size>`是操作的数据宽度,例如`.d`表示64位,`.w`表示32位。
总结实验结果,复现一次丢失的原子操作需要同时满足以下三个条件:
- 线程运行在不同的物理核心上(即不是同一物理核心上的两个SMT逻辑核心);
- 这些线程在相同地址上执行不带数据屏障的原子操作;
- 至少有一个线程在原子操作之间穿插内存读取。
这里的内存读取操作可以是LASX向量化内存读取`xvld`;当读取的内存地址与原子操作的内存地址具有特定位置关系时,内存读取操作也可以是普通的标量读取。
## 复现与测量¶ (https://jia.je/hardware/2026/09/24/loongson-cpu-erratum-en/#reproduction-and-measurement)
最小复现程序来自AI对normaliz代码的简化。每个normaliz数据点为2208字节,即276个`uint64_t`。两个线程分片遍历这些点;对于每个点,它们首先进行向量化内存复制(`memcpy`),然后在三个共享计数器(对应不带数据屏障的amadd指令)上各进行一次宽松原子加法。每轮结束后,它们检查这三个计数器是否相等;如果不相等,则发生了丢失更新。
在3C6000/S上,使用两个不同的物理核心(例如CPU0和CPU2),每个点2208字节,每次试验200轮,共30次试验,我们得到以下结果:
| 原子操作 | 双方均做LASX复制 | 双方均做LASX读取 | 一方做LASX复制 |
| :--- | :--- | :--- | :--- |
| `amadd.d` | 67% | 100% | 53% |
| `amadd.w` | 73% | 100% | 67% |
| `amcas.d` | 77% | 97% | 17% |
| `amcas_db.d` | 0% | 0% | 0% |
| `ammax.d` | 43% | 100% | 50% |
| `amswap.d` | 53% | 100% | 53% |
表中的百分比是“30次试验中失败的比例”。可以看出,“双方均做LASX读取”最容易触发问题,概率接近100%;而在相同条件下,带db的`amcas_db.d`始终为0%。
## 旁白:为何AOSC后来无法复现了¶ (https://jia.je/hardware/2026/09/24/loongson-cpu-erratum-en/#an-aside-why-aosc-could-no-longer-reproduce-it-later)
在复现此问题的过程中,有一段小插曲,也是整个故事中最有趣的部分。二月份问题刚被发现时,AOSC OS和Debian都能复现normaliz无限循环。但当我们在八月重新调查时,AOSC无论怎样都无法复现,而Debian仍然可以。当时我们不知道为何会出现这种不一致,只在Debian上不断试验。后来,随着AI将问题定位到`memcpy`,我们明白了原因。
AOSC在二月后发布的Core 13版本中,错误地禁用了glibc中的`--enable-multi-arch`选项,导致系统的`memcpy`不再走LASX加速路径;而Debian的glibc正常启用了向量加速,因此其`memcpy`使用LASX。在二月,AOSC和Debian都使用LASX加速路径;到了八月,AOSC的`memcpy`不再使用LASX加速,自然无法触发。在Debian上,通过`GLIBC_TUNABLES=glibc.cpu.hwcaps=-LASX`禁用LASX加速也能阻止问题触发。
因此,一旦AOSC发布Core 14并重新启用`--enable-multi-arch`选项,问题就会重现。考虑到`memcpy`的使用如此普遍,受影响的程序数量可能比我们想象的要多,只是因为触发概率较低而……
相似文章
Reddit r/LocalLLaMA
一个 GitHub 补丁通过侧载 antirez 的 llama.cpp 分支,解决了结构布局漂移、解码拆分和代码签名问题,从而允许在 128GB Mac 上的 LM Studio 中运行 DeepSeek V4 Flash。
Lobsters Hottest
一个关于Windows x86仿真器团队的故事:他们遇到一个程序,其初始化循环完全展开了64KB(65,536条指令),于是添加了特殊优化,将其替换为一个紧凑循环。
Hacker News Top
本文讨论在 ARM 的弱序内存模型上模拟 x86-TSO 内存模型的挑战,涵盖原子指令、分裂锁和未缓存内存等问题,并探讨潜在解决方案。
OpenAI Blog
OpenAI工程师详细描述了Rockset的C++数据基础设施中看似不可能的崩溃的诊断过程,揭示了一个Azure上的静默硬件损坏bug以及GNU libunwind中存在18年的竞态条件,最终通过崩溃数据的流行病学分析得以解决。
Hacker News Top
This repository provides configuration, patches, and tuning to run the DeepSeek V4 Flash 304B checkpoint on a single AMD MI300X in production, achieving 168 tok/s decode without quantization. It includes correctness overlays for vLLM ROCm, AITER tuning tables, and a hybrid KV cache strategy.