MoreRSS

site iconJiaJe | 杰哥修改

清华大学计算机系博士生。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

JiaJe | 杰哥的 RSS 预览

用 Files API 减少图片体积导致的 TTFT 膨胀

2026-10-12 08:00:00

用 Files API 减少图片体积导致的 TTFT 膨胀

背景

最近我经常用 DeepSeek-V4.1-Flash 模型读图。由于每次请求都要传完整上下文,随着上下文里的图片越来越多,上传图片所花的时间很快就成了 TTFT 的主要瓶颈;如果走的是 OpenCode Go,甚至动辄要几分钟。为了解决这个问题,可以用 Files API 提前把图片上传到 DeepSeek,然后在上下文里直接用 id 引用文件。为此,我随手 Vibe 了一个 pi-files-api 插件,给 Pi Agent 加上这个能力。

具体现象

我最近发现一个现象:在一些会话里,TTFT 稳定地偏高,而且越来越高,10s、12s 一路上涨,有时甚至到几分钟的量级,而这些会话都包含大量的图片读取。一开始我以为是服务端的问题,后来通过交流和分析才发现,图片在每次请求时都会被重新发送给后端;随着读图不断累积,TTFT 里的大部分时间都耗在了上传图片上。

一开始,我还寄希望于有状态的 OpenAI Responses API:每次只发送相对上一次请求的增量,从而避免图片的重复上传。然而 DeepSeek 目前还没有实现这个功能,所以只能用 Files API。

Files API

可以把 Files API 理解为一个临时的网盘,它支持上传、列举、删除和查询,有 OpenAI 和 Anthropic 两个版本,不过功能大同小异。在这个临时网盘里,每个文件都有唯一的 id,可以出现在 chat completions/responses/anthropic messages 请求体中,用来代替实际的图片。

因此,如果 Agent 懂得使用 Files API,先上传图片,再用 file id 填充请求体,就可以解决上述 TTFT 暴增的问题。DeepSeek Harness 是支持的,但前提是使用第一方 API;像我这样通过 AxonHub 统一管理 API key 的情况,它就不会用这个 Files API 了。

另外,我日常使用的 Pi Agent 也没有做这个优化。不过,在 AI 时代,做这样一个优化,也就是写几句 Prompt、写个插件的事情。

pi-files-api

最终,我快速 Vibe 出了一个插件 pi-files-api:它从 models.json 读取配置,替换上下文里的图片 base64,上传到指定的 Files API Endpoint,然后用 file id 替代图片内容;插件内部还维护了一份 sha256 到 file id 的映射。

当然,目前 AxonHub 还不支持代理 Files API,所以我只能被迫在本地给 Pi 配置 DeepSeek 的 API key:先通过 api.deepseek.com 上传图片,再通过 AxonHub 发送实际的请求体,非常不优雅。希望 AxonHub 后续能增加对 Files API 的支持,不过这也会带来一个问题:切换 API 上游时,Files 不会跟着迁移,换了一个供应商以后,原来的 file id 就失效了。这个问题我还没想好怎么办,只能先曲线救国一下。

替换自制服务器上的 48V 直流电源

2026-10-09 08:00:00

替换自制服务器上的 48V 直流电源

背景

又到了新学期,计算机组成原理的实验又轰轰烈烈地开始了,但实验平台背后的 FPGA 集群,却出现了一台自制服务器不工作的问题,通过替换 48V 直流电源解决了。

自制服务器

说是服务器,但实际上,这个服务器是借用了机架式服务器的外壳,把里面改造成了一个 FPGA 集群。它通过一个电源把 220V 市电转成 48V 直流电源,然后给三个 10 口 PoE 交换机供电,然后每个 PoE 交换机再通过 PoE 给 8 个 FPGA 开发板供电。此外,再通过一个 48V 到 12V 的 DCDC 直流电源,给几个风扇供电。这样,一个自制服务器里,就可以承载 24 个 FPGA 开发板,再通过多个自制服务器组成 FPGA 集群。

交换机之间,采用菊花链的方式连接,最后连一根网线到机柜上的交换机。

问题定位

问题出在,其中一台自制服务器通电后没有反应。把自制服务器从机柜上拆下来,用万用表测试,最终的结论是:48V 电源坏了,输出的两个直流端子之间的电压为零,电源上的灯也没有亮。后续把电源拆出来看了看,没找到特别明显的出问题的部件。

修复

修复也很简单:找到了一个同型号的备用电源,一通拆卸,再按照原来的方式装回来就可以。由于这个 48V 电源的输出就是两个带有螺丝的端子,所以要把各路电源(三个交换机、一路 DCDC)的电源线的正和负分别压紧到对应的端子上,防止虚接发热。

探究一个 USB Type-C 拓展坞的硬件实现

2026-10-02 08:00:00

探究一个 USB Type-C 拓展坞的硬件实现

背景

最近在高强度使用 HDMI 和 DP 做视频输出,但在使用的时候遇到了各种细节问题,所以就研究整个链路上到底发生了哪些事情,在这个过程中,研究了一下手上的 Biaze KZ11 这款 Type-C 拓展坞,看看它内部有哪些芯片,又是怎样实现拓展坞的功能的。

拓展坞基本信息

首先介绍一下这款拓展坞的基本信息,从购买页面可以看到:

  • 接口上,有三个 USB 3.0(5 Gbps),一个 TF/SD 读卡槽,一个 HDMI,一个 VGA,一个 3.5mm TRS 音频输出,一个千兆以太网口,还有一个 Type-C PD 供电
  • 采用了 RTL8153、GL3224、VL103、VL817、AG6320 芯片
  • HDMI 最高支持 4K 30Hz,VGA 最高支持 1080P 60Hz

当然,本博客并不是购物推荐,只是因为恰好手上有这么一个拓展坞,并且把它用的芯片给了出来,所以才方便了我做分析。

初步分析

既然给出了芯片型号,我就去调查了一下,这些芯片都是做什么的:

  • RTL8153:USB 3.0 的千兆以太网控制器
  • GL3224:USB 3.2 Gen 1 的读卡器,用于 TF/SD 卡读取
  • VL103:Type-C DP Alt-Mode 和 PD 3.0 控制器
  • VL817:USB 3.1 Gen 1 (5Gbps) 的 Hub,最多接四个下游设备
  • AG6320:把 DP 转化为 HDMI 或者 VGA 信号,同时音频通过 I2S 接口输出,DP 运行在 5.40 Gbps HBR2 速率下,两个 lane 带宽一共是 10.80 Gbps,考虑编码损失还有 10.80*8/10=8.64 Gbps,所以最高 4K 30Hz 4:4:4 8bpc,它需要的带宽是 3840*2160*30*24=5.97 Gbps,算上消隐区就是 4400*2250*30*24=7.13 Gbps

于是我就想,既然 Type-C 拓展坞连电脑只有一个 Type-C,这个 Type-C 只能连一个设备,而下游有这么多设备:RTL8153,GL3224、VL103,都需要接到 USB 总线上,需要 VL817 来拓展,此外还有三个额外的 USB 口,那就至少有六个设备了,一个 VL817 不够,那就得级联一下:

flowchart LR
    Host -->|Type-C| VL817_1[VL817 #1]
    VL817_1 --> RTL8153 --> ETH
    VL817_1 --> GL3224 --> TF/SD
    VL817_1 --> VL103 --> DP --> AG6320 --> HDMI
    AG6320 --> VGA
    AG6320 --> I2S
    VL103 --> PD[Type-C PD 3.0]
    VL817_1 --> VL817_2[VL817 #2]
    VL817_2 --> Port1[USB 3.0 Port #1]
    VL817_2 --> Port2[USB 3.0 Port #2]
    VL817_2 --> Port3[USB 3.0 Port #3]

这里我觉得,既然 AG6320 输入是 DP,然后 VL103 是 DP Alt-mode 芯片,那就应该 AG6320 的输入来自于 VL103,就画出了上面的拓扑。看起来很合理,但实际上有问题:

  • VL817 是数据的 Hub,它能支持 PD 3.0 的高功率吗?
  • VL817 #2 只有三个下游设备,理论上可以再来一个 USB 3.0 Port,为啥只用了三个?

所以这个拓扑肯定不对,PD 芯片应该是直接接到电脑侧的,其他都是传数据的芯片,不需要也不应该接那么高功率的电源。这就给出了第二版拓扑:

flowchart LR
    Host -->|Type-C| VL103
    VL103 --> USB --> VL817_1[VL817 #1]
    VL817_1 --> RTL8153 --> ETH
    VL817_1 --> GL3224 --> TF/SD
    VL103 --> DP --> AG6320 --> HDMI
    AG6320 --> VGA
    AG6320 --> I2S
    VL103 --> PD[Type-C PD 3.0]
    VL817_1 --> VL817_2[VL817 #2]
    VL817_2 --> Port1[USB 3.0 Port #1]
    VL817_2 --> Port2[USB 3.0 Port #2]
    VL817_2 --> Port3[USB 3.0 Port #3]

但这依然很奇怪:VL817 #1 和 VL817 #2 各有一个 Port 是空的,这不是浪费了吗?说明这个拓扑依然不对。

USB 拓扑分析

上面都是从芯片的功能来硬推,推不出来合理的拓扑,那就说明我对芯片的功能的理解还是有所欠缺。那就从电脑这边来看,USB 总线上是个什么拓扑:

首先,我能看到有一个 USB 3.0 的 Hub(2109:0817),下面出现的设备是:

  • RTL8153 网卡
  • USB 3.0 端口 #1
  • USB 3.0 端口 #2
  • USB 3.0 端口 #3

这意味着,RTL8153 网卡是和三个 USB 3.0 端口挂在同一个 Hub 下的,猜测就是 VL817。此外,还有一个 USB 2.0 的 Hub(2109:2817),下面出现的设备是:

  • 另一个 USB 2.0 的 Hub(Terminus,1a40:0801,推测是 FE8.1 芯片)
  • USB Billboard Device(2109:8888)
  • USB 3.0 端口 #1 的 USB 2.0
  • USB 3.0 端口 #2 的 USB 2.0
  • USB 3.0 端口 #3 的 USB 2.0

这个 Terminus 的 USB 2.0 Hub 下面,又挂载了两个设备:

  • GL3224 读卡器
  • USB C Video Adaptor(9636:9300),另一个 USB Billboard Device

把这个 USB 总线拓扑画出来,就是:

flowchart LR
    Host --> USB3[USB 3.0 Hub 2109:0817]
    USB3 --> RTL8153 --> ETH
    USB3 --> Port1[USB 3.0 Port #1]
    USB3 --> Port2[USB 3.0 Port #2]
    USB3 --> Port3[USB 3.0 Port #3]

    Host --> USB2[USB 2.0 Hub 2109:2817]
    USB2 --> Terminus[Terminus USB 2.0 Hub 1a40:0801]
    USB2 --> Billboard[USB Billboard Device 2109:8888]
    USB2 --> Port1_USB2[USB 3.0 Port #1 USB 2.0]
    USB2 --> Port2_USB2[USB 3.0 Port #2 USB 2.0]
    USB2 --> Port3_USB2[USB 3.0 Port #3 USB 2.0]
    Terminus --> GL3224 --> TFSD[TF/SD Card]
    Terminus --> Adaptor[USB C Video Adaptor 9636:9300]

这个信息量很大,推翻了很多之前的分析:VL817 下游就是三个 USB 3.0,外加一个 RTL8153。然后 GL3224 是通过一个额外的 Terminus USB 2.0 Hub 接进来的,即使 GL3224 本身是一个 USB 3.2 Gen 1 的读卡器。同时为啥这个 USB 2.0 Hub 下面出现了五个设备?这个 USB Billboard Device 和 USB C Video Adaptor 又是哪来的?为啥要多加一个 Terminus USB 2.0 Hub?

推倒重来

这说明一开始认为的“Type-C 只能连到一个芯片上,再分到不同的芯片”的假设是错误的。实际上,Type-C 是可以同时连到多个芯片上的:

  • USB 2.0 的 D+/D-,用于 USB 2.0 总线
  • USB 3.0 Type-C 的四个差分对,可以两个差分对用于 USB 3.0 总线,另外两个差分对用于 DP
  • SBU 信号用于 DP 的 AUX
  • 电源和 CC,用于 USB PD

也就是说,实际上,Host 通过一个 Type-C 端口,同时接到了 VL103、VL817 和 AG6320 上!也就是说,AG6320 并不是接到 VL103,而是直接从 Type-C 取走了两个差分对,来做 DP 输入,只是 SBU(AUX) 信号是先进入 VL103,再接到 AG6320,此外为了实现热插拔,AG6320 给出了 HPD 信号给 VL103;VL103 也不负责 USB 3.0,它只是负责电源、CC 协商和 DP Alt-mode,同时还透传了 USB 2.0;VL817 虽然是 USB 3.0 Hub,但它其实就是一个 USB 3.0 Hub 和一个 USB 2.0 Hub,输出是四个 USB 3.0 和四个 USB 2.0。这已经基本解释了大部分问题,还差最后一点:

VL817 只有四个端口,那么,它 USB 3.0 Hub 下四个设备、USB 2.0 Hub 下五个设备都是怎么来的呢?

注意到外接的 USB 3.0 端口,它也是 3.0 和 2.0 兼具的,而且得是同一个 Port,所以 VL817 前三个 Port 就是 USB 3.0 和 2.0 同时连到外接 USB 端口上。那只剩下一个 Port 了,这个 Port 上又需要接 RTL8153,又需要接 Terminus USB 2.0 Hub,此外还有一个 USB 2.0 的 Billboard 2109:8888 设备,这哪接的下来?

答案是:最后一个 Port,把 3.0 部分和 2.0 部分分开:3.0 接 RTL8153,2.0 接 Terminus USB 2.0 Hub。实际上,从 USB 总线枚举上来看,这两个设备对应的 Port ID 是一样的。而那个 2109:8888 Billboard 设备,只是 VL817 自己枚举出来的设备,并没有实体。

至于 USB C Video Adaptor 9636:9300 是怎么来的,其实就是 DP Alt-mode 的要求:VL103 同时也是一个 USB 2.0 Device,这个 Device 就是这个 USB C Video Adaptor,给 Host 汇报 Alt-mode 的一些信息。因为有这个 USB C Video Adaptor 和 GL3224 都需要接到 VL817 最后一个 USB 2.0 端口上,放不下,才又引入了一个 Terminus USB 2.0 Hub。

至此,完整的硬件拓扑就比较清楚了:

flowchart LR
    Host --> PD --> VL103
    Host --> SBU --> VL103 -->|AUX| AG6320
    AG6320 -->|HPD| VL103
    Host --> USB2[USB 2.0] --> VL103 -->|USB 2.0| VL817
    Host --> USB3[USB 3.0 2 lanes] --> VL817
    Host --> DP[DP 2 lanes] --> AG6320 --> HDMI
    AG6320 --> VGA
    AG6320 --> I2S

    VL817 --> Port1[USB 3.0 Port 1, USB 3.0 + USB 2.0]
    VL817 --> Port2[USB 3.0 Port 2, USB 3.0 + USB 2.0]
    VL817 --> Port3[USB 3.0 Port 3, USB 3.0 + USB 2.0]
    VL817 --> Port4[Port 4]
    Port4 -->|USB 3.0| RTL8153 --> ETH
    Port4 -->|USB 2.0| Terminus[Terminus USB 2.0 Hub]
    VL817 --> Billboard[Virtual Billboard Device 2109:8888]
    Terminus --> GL3224[GL3224 Card Reader 05e3:0749] --> TFSD[TF/SD Card]
    Terminus --> Adaptor[USB C Video Adaptor 9636:9300 (Billboard Device of VL103)]

这样,VL817 的四个端口,无论是 USB 3.0 还是 USB 2.0,都物尽其用了,刚好把三个 USB 3.0 端口都用满了。多出来的 Terminus USB 2.0 Hub 的用途也解释清楚了。

附录:分析绿联 CM478-15495

顺带也分析了一下 绿联 CM478-15495 拓展坞的配置,它的参数如下:

  • 接口上,支持 USB3.0+USB2.0*2+PD+HDMI
  • HDMI: 4K 30Hz, 1080P 120Hz
  • PD 3.0 100W

分析了一下 USB 拓扑,发现它的 USB 3.0 设备是直接通到电脑上的,没有经过 Hub;而它有一个 Genesys Logic 05e3:0608 的 4 口 USB 2.0 Hub(根据网上信息,猜测是 GL850G),下面连了四个设备:

  • DP Alt-mode 的 Billboard Device 343c:0000
  • USB 2.0 端口 #1
  • USB 2.0 端口 #2
  • USB 3.0 端口的 USB 2.0

那么 DP 则又是直接从 Type-C 拿走了两个差分对,然后 USB 3.0 端口是接了另外两个差分对。可见这又是一个物尽其用的例子。推测 CM478-15495 一种可能的拓扑:

flowchart LR
    Host --> PD --> Unknown
    Host --> USB2[USB 2.0] --> GL850G[GL850G USB 2.0 Hub 05e3:0608]
    GL850G --> Port2[USB 2.0 Port #1]
    GL850G --> Port3[USB 2.0 Port #2]
    GL850G -->|USB 2.0| Port1
    GL850G --> Port5[USB Billboard Device 343c:0000]
    Host --> USB3[USB 3.0 2 lanes] -->|USB 3.0| Port1[USB 3.0 Port]
    Host --> DP[DP 2 lanes] --> Unknown --> HDMI

实际上,因为 CM478-15495 的 USB 3.0 端口是直接接到了电脑上,没有像 KZ11 那样过了一个 USB 3.1 Gen 1 的 VL817 Hub,在 macOS 实测速率可以协商到 10 Gbps,也就是 USB 3.1 Gen 2。同一个设备,插到 KZ11 的 USB 3.0 口上,就只有 5 Gbps 的速率了。

附录:分析飞利浦 SWR1607L/93

参数:

  • 3xUSB 3.0 5Gbps,其中两个是 Type-C,一个是 Type-A
  • USB PD 100W
  • HDMI 4K60Hz

在系统里看到的是两个 Hub:

  • USB 3.1 Hub 05e3:0626 5Gbps
  • USB 2.1 Hub 05e3:0610 480Mbps

目测又是 Genesys Logic 的 4 口 Hub,根据网上信息,猜测是 GL3523,四口 USB 3.1 Gen 1 的 Hub。

考虑到它只有 5Gbps,那么应该又是 Type-C 的两个 lane 通过 4 口 Hub 接出了三个 USB 3.0,剩下一个口有两种可能:一是留给 DP Alt-mode 的 Billboard Device,但实际上无论是否插 HDMI,都没有这个设备出现,或许是因为 DP Alt-mode 协商正常工作,所以 Billboard Device 没有出现,但实际上还是占用了一个口;二是它就是没有接任何设备。Hub 的 Port 4 对应 USB 3.0 Type-C 口,Port 3 对应 Type-C 旁边的 USB 3.0 Type-A 口,Port 2 则是最后一个离 Type-C 口最远的 USB 3.0 Type-C 口。

另外两个 lane 则是给了 DP Alt-mode,这里的 HDMI 能跑到 4K 60Hz 4:4:4 8bpc,说明它的 DP 跑在了更高的速率:在 macOS 里看,它的 DP 运行在 8.10 Gbps (HBR3) 速率上,这样两个 lane 的总带宽就是 16.20 Gbps,考虑编码损失还有 16.2*8/10=12.96 Gbps,足够 4K 60Hz 4:4:4 的 3840*2160*60*24=11.94 Gbps 带宽,算上消隐区(需要消隐区比较少的时序),也就是 4000*2205*60*24=12.70 Gbps。

有意思的是,如果我直接用 Type-C 连显示器,那么它就可以用四个 lane 传 5.40 Gbps (HBR2) 速率,这样总速率达到了 21.60 Gbps,考虑编码损失还有 21.60*8/10=17.28 Gbps,甚至可以 4K 75 Hz 4:4:4 8bpc,毕竟它只需要 3840*2160*75*24=14.9 Gbps 带宽,算上消隐区,也就是 4000*2205*75*24=15.88 Gbps。

所以对于各种 Type-C 拓展坞来说,因为要保留 Type-C Hub 功能,所以最多有两个 lane 用于 DP,此时能跑到 4K 30Hz,还是 4K 60Hz,就取决于能跑到 HBR2 还是 HBR3 上。HBR2 就对应 4K 30Hz 4:4:4 8bpc 或者 4K 60Hz 4:2:0 8bpc,HBR3 就对应 4K 60Hz 4:4:4 8bpc(但需要消隐区比较少的时序,带宽特别紧张)。如果不走拓展坞,直连显示器,四个 lane 都可以用于 DP,此时即使是 HBR2,也能跑到 4K 75Hz 4:4:4 8bpc 上。

也做了一个小实验,如果把显示器通过 Type-C 接到飞利浦 SWR1607L/93 的 USB 3.0 Type-C 口上,显示输出就不工作了,因为这个口过了一个 Hub,这个 Hub 不支持 DP Alt-mode。

附录:分析绿联 CM136-70495

也分析一下 绿联 CM136-70495 拓展坞的配置,它的参数如下:

  • 接口上,支持 USB3.0*3+PD+HDMI
  • USB 3.0 5Gbps
  • HDMI: 4K 60Hz
  • PD 3.0 100W

它的接口和上面的飞利浦 SWR1607L/93 一模一样,唯一的不同就是,绿联 CM136-70495 三个 USB 3.0 都是 Type-A,而飞利浦 SWR1607L/93 把其中一个换成了 Type-C。

在系统里看到的是两个 Hub:

  • USB 3.0 Hub 2109:0817 5Gbps
  • USB 2.0 Hub 2109:2817 480Mbps

看到这个熟悉的 VID/PID,不出意外,它用的和 Biaze KZ11 一样,也是 VL817,USB 3.1 Gen 1 5Gbps 的四口 Hub。然后三个 USB 3.0 Type-A 口都是挂在这个 VL817 四口 Hub 下。离 HDMI 最近的口是 Port 4,次近的是 Port 1,最远的是 Port 2。

与飞利浦 SWR1607L/93 一样,它也能跑到 4K 60Hz 4:4:4 8bpc,在 macOS 里看,果然是 2 lane 的 HBR3。这次也没有 Billboard Device 出现。

附录:同一个显示器,一个拓展坞只能 4K 30Hz,另一个拓展坞可以 4K 60Hz

不过在实践中,也遇到过特殊的情况:同一个 Crestron 420 显示器,连上述两个拓展坞出现了不同的现象:

  • 通过飞利浦 SWR1607L/93 接 Crestron 420,只能输出 4K 30Hz,没有 4K 60Hz 的选项
  • 通过绿联 CM136-70495 接 Crestron 420,则可以输出 4K 60Hz

猜测是 EDID 的问题,由于 Crestron 420 的限制,HDMI 版本只有 1.4b,它的 4K 60Hz 是 4:2:0 的,但实际能否达到,还要看拓展坞这边能否提供正确的 EDID。首先是只能跑到 4K 30Hz 来自飞利浦 SWR1607L/93 的 EDID:

00ffffffffffff000ca9e10700000000041b010380502d800eee91a3544c99260f50542000000101010101010101010101010101010140d10080f0705a8020188a0020c23100001a023a801871382d40582c450020c23100001e000000fd00174c1e4b1e000a202020202020000000fc004372657374726f6e203432300a01fb02032172465f5e5d9004022309070783010000e2004f67030c001000003ce20e61023a801871382d40582c450020c23100001e04740030f2705a80b0588a0020c23100001e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e5

接着是能跑到 4K 60Hz 来自绿联 CM136-70495 的 EDID:

00ffffffffffff000ca9e10700000000041b010380502d800eee91a3544c99260f50542000000101010101010101010101010101010104740030f2705a80b0588a0020c23100001e023a801871382d40582c450020c23100001e000000fc004372657374726f6e203432300a000000fd00174c1e4b1e000a202020202020010e02032970475f5e5d1005040223090707830100006d030c001000003c200060010203e30e6160e2004f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f7

附录:分析 Biaze ZH395

参数:

  • PD 100W
  • HDMI 4K 30Hz
  • 5Gbps USB 3.0 Type-A 口
  • 一个 USB 2.0 Type-A 口
  • 一个 USB 2.0 Type-C 口

看过前面这么多分析,基本可以推断出,它又是 Type-C 的两个 lane 直通 USB 3.0 Type-A 口,另外两个 lane 用于 DP Alt-mode,再来个 USB 2.0 的 Hub 214b:7260。虽然宣传的是 HDMI 4K 30Hz,但实际上能跑到 4K 60Hz,由于是 2 lane 的 HBR2,应该是 4:2:2 或者 4:2:0 而非 4:4:4,看起来有略微的模糊感。

附录:分析绿联 CM511-45031

CM511-45031 参数:

  • PD 3.0 100W
  • HDMI 2.0 4K 60Hz
  • 3x USB 3.2 10 Gbps Type-A 口
  • 1x USB 3.2 10 Gbps Type-C 口

实测 DP 能跑到 2 lane HBR3,因此可以轻松 4K 60Hz 4:4:4。USB 方面,有一个 USB 3.2 Hub GenesysLogic 05e3:0625 和一个 USB 2.1 Hub GenesysLogic 05e3:0610。这里的 USB 3.2 代表的其实是 USB 3.2 Gen 2,所以每个口都是 10Gbps。

附录:同一个显示器,不同拓展坞显示的频率选项不同

同一个 HKC 2755U 显示器,连上述拓展坞出现了不同的现象:

  • 通过飞利浦 SWR1607L/93 接 HKC T2755U,可以输出 4K 60Hz,无 50Hz 选项,同时还有 HDR 选项
  • 通过绿联 CM136-70495 接 HKC T2755U,可以输出 4K 60Hz/50Hz,同时还有 HDR 选项
  • 通过 Biaze ZH395 接 HKC T2755U,可以输出 4K 60Hz,无 50Hz 选项,同时还有 HDR 选项,但界面比较模糊,疑似 4:2:2 或 4:2:0,macOS 设置里显示的是分辨率列表,而不是像其他几个拓展坞那样,显示几种文字大小
  • 通过绿联 CM511-45031 接 HKC T2755U,可以输出 4K 60Hz/50Hz,同时还有 HDR 选项

附录:其他常用拓展坞芯片

  • AX88179: USB 3.0 to 1000M Ethernet Controller
  • VL822: USB 3.1 Gen 2 10Gbps 4-Port Hub Controller
  • SW2505: USB PD Controller

把博客生成器从 Mkdocs 迁移到 Zensical

2026-09-30 08:00:00

把博客生成器从 Mkdocs 迁移到 Zensical

距离上一次 从 Mkdocs 迁移到 Zensical 已经过去了三年,这次 Zensical 终于是补齐了原来 Mkdocs 用到的大部分插件,所以就当小白鼠,把博客从 Mkdocs + Mkdocs-Material 迁移到了 Zensical。

这次迁移的背景,就是 Mkdocs-Material 团队对 Mkdocs 后续的更新计划不满,自己独立出来,RIIR 了一个尽量兼容 Mkdocs 生态的 Zensical。当然这个兼容不是 100% 的,所以我也在逐渐地做一些替换,从 ctf-writeups 开始,到 cpu,再到博客主站,未来等 i18n 插件到位了,再迁移 kb 等等。其实用起来也没啥区别,连配置文件都不用改,然后就是构建速度确实快了很多,之前 mkdocs build 一次要好几十秒,zensical 明显更快。插件方面,我也自己移植了一个 zensical-wavedrom-plugin 用来渲染 wavedrom 波形图。

回顾历史,本博客已经做了好几次迁移:

迁移的基本动力,要么是原来的生成器太慢了,要么就是有一些硬伤。不知道几年后,会不会再迁移到新的技术栈呢。

一颗 CPU 的原子指令,一个打包死循环:LA664 丢失更新事件始末

2026-09-24 08:00:00

一颗 CPU 的原子指令,一个打包死循环:LA664 丢失更新事件始末

本文同步发布到本人的知乎。

English version

太长不看版本

2026 年 2 月,王邈在龙架构服务器上给 Debian 打包 normaliz 时遇到一件怪事:这个数学软件的自带测试总是超时,现象是卡在死循环里出不来。顺着代码调查,问题指向一个很常规的操作:OpenMP 的 #pragma omp atomic 对共享变量进行累加。循环的退出条件要求累加后的值等于某个数,而累加的结果总是少于这个数,就导致了死循环。由于程序太大、代码又很复杂,始终没能把问题缩减成一个人类能看懂的最小例子,这件事就被搁置了。

半年后的 8 月,王邈再次找到我,想把它重新捡起来。这回我们换了个做法:不再由人来定位问题,而是让 AI 去找最小复现,人在这个过程中负责指挥 AI 调查的方向。大概两天后,我们拿到一个稳定的复现程序,才发现事情的根源是:CPU 的原子加法指令,居然偶尔会不原子。这意味着我们找到了 CPU 的一个新 erratum,而龙芯得知这件事后,仅仅过了两周,就找到了几乎没有性能损失的修复方法,并给我们提供了测试固件。我们确认了测试固件可以解决问题,并且龙芯告诉我们,该测试固件预计在国庆(10 月 1 日)之前发布,届时读者将可以升级固件以修复该问题。

下面,让我们把整个事件的来龙去脉娓娓道来。

缘起

loong13 是社区维护的一个 Debian 13 稳定版在龙架构上的移植,王邈是维护者之一。在编译打包过程中,发现 normaliz 的自带测试会卡在循环里无法退出,导致打包超时。当时并没有立即找到问题的根源,只好先跳过这个软件包。但由于有一些其它软件包依赖 normaliz,不能一直跳过,2 月份的时候,我们开始集中精力排查这个问题。此前在构建其它软件包的过程中,我们发现过代码中暗藏的竞态条件或内存序问题,这类问题更容易在采用弱内存序的龙架构上暴露出来。因此,一开始我们推测,原因可能是该软件也存在类似的问题。但是在排查开始后,不出意外就出了意外。

第一轮排查

第一轮排查从 normaliz 的源码开始。normaliz 使用 OpenMP 并行处理数据点,出问题的代码片段概括起来如下所示:

func (std::list<std::vector<int>> 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(); // 线程私有的 List 指针
      size_t ppos = 0; // 线程私有的 List 指针位置
      #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 实际运行的代码中,不断注释掉其中的运算步骤,试图找出触发原子加丢失的最小条件。一个诡异的现象是,在注释掉大部分的运算步骤后,问题依然存在。由于程序太复杂,最终还是没能找到一个能够稳定复现原子加丢失的最小代码片段。

第二轮排查

6 个月后,问题依然没有得到解决。随着 LoongLeak/LoongBleed 漏洞的公开,normaliz 中原子加丢失的问题也重新回到我们的视线。这次,我们试图使用 AI 来协助排查。具体使用 AI 的方法是:首先向 AI 指出 normaliz 的上述代码存在死循环问题,要求 AI 确认并复现,然后找出可能原因。在第一轮对话中,AI 注意到了出问题的循环,但并没有得出原子加指令存在问题的结论。此后,我们又向 AI 提示该问题只在 LoongArch 上存在、在其它架构上不存在,AI 依然没能给出明确的结论。最后,我们直接告诉 AI 已经定位到原子加存在问题这一事实,并要求它复现并给出最小复现样例。这轮对话中,AI 最终把目光投向处理函数中的一段 memcpy 调用,而这正是第一轮排查中被忽视的部分:memcpy 的实现位于 glibc 中,glibc 会根据当前可用的硬件特性选择最优实现;如果硬件支持向量指令集(龙架构上是 LSX/LASX),glibc 的 memcpy 就会使用相应的向量指令加速内存搬运。而正是这些向量化的内存搬运,触发了 LoongArch64 上原子加指令丢失的问题。两天后,AI 给出了一个稳定复现问题的最小程序。

问题面的扩大

发现原子加指令会出现丢失更新的问题后,我们产生了新的疑问:其一,只有原子加存在问题,还是其它原子指令也存在问题;其二,其它的内存操作是否也会触发类似的问题。

对于问题一,我们首先排查了 CAS 指令,因为它可用于实现原子加法,很容易判断是否出现问题。结果发现,CAS 指令在相同条件下也存在问题。对于其它原子指令,例如原子交换、原子最大、原子最小、原子按位与、原子按位或等,由于即使发生丢失,也不容易从结果上判断出来,因此一开始并没有验证。例如,原子取最大值的过程中如果发生丢失,那么只要更新最大值的那次操作没有丢失,结果就是正确的。

经过深入思考,我们最终找到了验证方案:为了检查这类指令是否发生丢失,改为把每次操作的结果都记录下来,事后再做校验。以原子取最大值为例,如果并行地对 1 到 n 这 n 个数进行原子取最大值操作,那么最终的结果应该是 n。每次原子取最大值时,修改内存的同时,还会返回旧的最大值。对于输入为 k 的操作,如果返回的旧值小于 k,则说明此次操作更新了最大值。原子性保证了:所有更新了最大值的操作,其返回值不会出现重复。如果出现了重复,则说明发生了原子指令更新的丢失。经过验证发现,这些原子指令在相同条件下,都会发生丢失。

对于问题二,我们测试发现,只有 LASX 的向量化读内存操作(xvld)会触发原子指令丢失的问题;而正常的标量读取和 LSX 的向量化读内存操作(vld)则不会触发该问题。后来,Rong "Mantle" Bao 独立发现,当原子变量所在的内存地址与被读取的内存地址存在特定的位置关系时,正常的标量读取也可能触发原子指令丢失,意味着即使没有使用 LASX 也可能出现问题,只是概率更小。这些复杂的触发条件,解释了为什么这个问题一直没有被找到。

具体结论

在介绍具体结论之前,首先介绍关于此问题的背景知识:龙芯的 3C6000/S 和 3A6000 用的是 LA664 核心,其指令集是 LoongArch64,带有 SIMD 扩展;其中,128 位的 SIMD 扩展叫 LSX,256 位的叫 LASX。较早的 LA464 核心(如 3A5000)没有这个问题。LASX 中存在读取内存指令 xvld,一次可以读取 32 字节到向量寄存器中。龙芯的原子指令可以概括为:am<OP>[_db].<width>,其中 <OP> 表示具体的原子操作,如 amadd、amcas、amswap、ammax、amxor、amand、amor 等;[_db] 表示是否带有 data barrier(db)即数据屏障;而 <width> 表示操作的数据宽度,如 .d 表示 64 位,.w 表示 32 位。

总结实验结果,复现原子操作丢失需要同时满足以下三个条件:

  • 线程运行在不同的物理核上(即不能是同一个物理核上的两个 SMT 逻辑核);
  • 这些线程对同一地址进行不带数据屏障的原子操作;
  • 其中至少一个线程在原子操作之间穿插内存读取操作。

其中,内存读取操作可以是 LASX 的向量化读内存操作 xvld;当被读取的内存地址与原子操作的内存地址满足特定的位置关系时,内存读取操作也可以是普通的标量读取。

复现和测量

最小的复现方法,来自于 AI 对 normaliz 代码的简化。normaliz 每个数据点大小是 2208 字节,即是 276 个 uint64_t。两条线程各自分片遍历这些点,对每个点先做一次向量化的内存搬运(memcpy),再对三个共享计数器各做一次 relaxed 原子加(对应不带数据屏障的 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 后来复现不了了

在复现这个问题的过程中,有一个小插曲,也是整件事里最有意思的地方。在 2 月刚发现这个问题时,AOSC OS 和 Debian 都能复现 normaliz 的死循环问题。但是到了 8 月重新排查时,AOSC 那边却怎么都复现不出来,而 Debian 照旧。当时我们并不知道为什么会出现这种不一致,只是继续在 Debian 上做实验。

后来,随着 AI 定位到问题出在 memcpy,我们才明白了其中的原因。AOSC 在 2 月份之后发布的 Core 13 版本在 glibc 里错误地关掉了 --enable-multi-arch 选项,于是系统 memcpy 不再走 LASX 加速路径;而 Debian 的 glibc 正常启用向量加速,因此 memcpy 会使用 LASX。2 月份的时候,AOSC 和 Debian 都使用 LASX 加速路径;而到了 8 月,AOSC 的 memcpy 不再使用 LASX 加速,自然就触发不了。在 Debian 上,用 GLIBC_TUNABLES=glibc.cpu.hwcaps=-LASX 关掉 LASX 加速,问题也不再触发。

所以等 AOSC 发布 Core 14 版本,重新打开 --enable-multi-arch 选项后,问题也会重新复现。考虑到 memcpy 很常用,受影响的程序可能比我们想象的还要多,只是因为触发概率较小,难以发现。

影响面分析

存在问题的原子指令中,ammax、ammin 等指令由于目前 C 标准中没有对应的原子操作接口,所以很难被编译器生成出来;而 amcas 属于 LoongArch64 v1.1 的新增指令,因此在默认情况下也不会被编译器生成。可能存在较大影响的是 amadd,即原子加指令。该指令通常会被用于引用计数:如果引用计数的增加操作丢失,计数就会小于实际引用数,可能导致对象被提前释放,从而引发 use-after-free 或双重释放等内存安全问题。而触发的另一个条件,即向量化内存读取,则很容易被 memcpy 之类的函数触发。我们发现,在 Rust 标准库中,std::sync::Arc 的引用计数、std::sync::mpsc 的 Sender 克隆也都采用了不带数据屏障的 amadd 指令。我们构造了 safe Rust 程序,使用 Arc 或者 mpsc 都能让程序崩溃,表现为 SIGABRT 或 glibc 报告堆被破坏,这意味着出现了 use-after-free。

但是,该问题很难被用于安全攻击。因为要触发该问题,两个线程必须在同一个对象的同一个计数器上并发执行 relaxed 原子操作,而且至少有一方在执行向量化内存读取。这种触发条件意味着潜在攻击者与受害者必须处于同一进程内,无法跨越进程隔离,因此很难被单方面利用。

规避方法

一般而言,由于在高级语言代码中,开发者对编译器产生的原子指令并无控制能力,因此对应用软件的开发者而言,并无直接的规避手段。在软件层面的规避方法,主要通过编译器实现,即编译器不再生成不带数据屏障的原子指令(am<OP>.*),而是生成带有数据屏障的原子指令(am<OP>_db.*),或者使用 LL/SC 循环来实现原子操作。但对于目前已经存在的二进制程序而言,则需要完全重新编译才能应用这些规避方法。因此,从软件层面上规避的代价很高昂。

修复

向龙芯反馈后,修复来得很快:2026 年 8 月 26 日我们发邮件把问题反馈给龙芯,仅仅两周后的 2026 年 9 月 9 日就拿到了测试固件,3A6000 和 3C6000/S 实测都恢复了正常。龙芯告诉我们,这个固件预计会在国庆节(10 月 1 日)之前发布。

修复方式是将 MCSR24 的 bit 13 置为 1。MCSR24 是一个内部的 CSR,手册中并未说明其功能。设置这个 bit 之后,丢失更新的问题不再出现。经测试,性能损失很小:单核性能没有受到影响,多核性能只是略微下降。

如果受影响用户暂时不能更新固件,也可以选择在 Linux 内核里直接写入该 bit,这样等效于完成固件修复,不需要等待主板固件更新。

总结

回过头来看,这个故事起源于一个纯软件的问题:打包时出现的死循环、偶尔算错的计数器。到最后却发现是 CPU 里的一条原子加指令,它并不原子。从发现问题到找出原因前后跨越了半年时间,其中真正有效的推进只在两天里完成,AI 和人都有不可或缺的作用。剩下的时间就是确认问题、找到触发条件、扩大测试面,等待固件修复。

事实上,类似的 erratum 在各家厂商的 CPU 中都很常见。感兴趣的读者可以翻阅 ARM 公版核的 Software Developer Errata Notice,其中不少涉及访存指令或原子指令,个别严重的甚至会导致 CPU 死锁;但真正影响到用户使用体验的其实非常少。这类问题,与其让它在未来的某一天于某个复杂系统中以一次不稳定的报错突然冒出来,不如尽早定位到具体原因并加以修复。

致谢

这项工作由王邈发起并主导,我负责复现和报告整理。在我们告知 amcas 也存在问题之后,Rong "Mantle" Bao 又发现了类似的 CPU 问题,并一并得到了修复。感谢龙芯芯片研发部和开发者社区运营部等部门在报告和修复过程中高效而专业的表现!

One CPU Atomic Instruction, One Packaging Infinite Loop: The Story of the Lost Update on LA664

2026-09-24 08:00:00

One CPU Atomic Instruction, One Packaging Infinite Loop: The Story of the Lost Update on LA664

中文版本

TL;DR

In February 2026, Wang Miao ran into something strange while packaging normaliz for Debian on a LoongArch server: the math software's built-in test kept timing out, stuck in an infinite loop that it could not escape. Following the code, the problem pointed to a very ordinary operation: OpenMP's #pragma omp atomic accumulating into a shared variable. The loop's exit condition required the accumulated value to equal a certain number, but the accumulated result was always less than that number, causing the infinite loop. Because the program was large and the code complex, we never managed to reduce it to a minimal example a human could understand, so the matter was shelved.

Half a year later, in August, Wang Miao came to me again, wanting to pick it back up. This time we took a different approach: instead of having a human locate the problem, we let AI find a minimal reproduction, with the human directing the AI's investigation. About two days later, we had a stable reproducer, and only then discovered the root cause: the CPU's atomic add instruction occasionally fails to be atomic. This meant we had found a new CPU erratum, and after Loongson learned of it, only two weeks passed before they found a fix with almost no performance loss and provided us with test firmware. We confirmed that the test firmware resolves the issue, and Loongson told us the firmware is expected to be released before National Day (October 1), at which point readers will be able to upgrade their firmware to fix the problem.

Now let us tell the whole story from beginning to end.

Origins

loong13 is a community-maintained port of Debian 13 stable to LoongArch, and Wang Miao is one of its maintainers. During the build and packaging process, normaliz's built-in test was found to get stuck in a loop that it could not exit, causing the packaging to time out. At the time we did not immediately find the root cause, so we had no choice but to skip this package. But since several other packages depend on normaliz, we could not keep skipping it forever, so in February we began to focus on investigating the problem. Previously, while building other packages, we had found hidden race conditions or memory-ordering issues in the code, and such problems are more likely to surface on LoongArch, which uses a weak memory model. So at first we guessed the cause might be a similar issue in this software. But once the investigation began, surprise, surprise, there was a surprise.

The First Round of Investigation

The first round started from normaliz's source code. normaliz uses OpenMP to process data points in parallel. The problematic code snippet can be summarized as follows:

func (std::list<std::vector<int>> LatticePoints) {

  size_t nr_to_match = LatticePoints.size(); // input size
  size_t nr_points_matched = 0; // number of points already processed

  while (true) {
    size_t nr_points_done_in_this_round = 0; // number of points processed this round

    #pragma omp parallel
    {
      auto P = LatticePoints.begin(); // thread-private List pointer
      size_t ppos = 0; // thread-private List pointer position
      #pragma omp for
      for (ppp = 0...nr_to_match){

        if (skip_remaining) { // in certain cases skip_remaining is set, skipping unprocessed points
          continue;
        }

        // Based on the difference between ppos and ppp, move P to the position
        // pointed to by ppp and maintain ppos

        if ((*P)[0] == 0) { // means it has been processed
          continue;
        }

        #pragma omp atomic
        nr_points_matched++;
        #pragma omp atomic
        nr_points_done_in_this_round++;

        // process the object pointed to by P
        (*P)[0] = 0;
      }
    }

    // this break never gets executed
    if (nr_points_matched == nr_to_match)
      break;
  }

}

The gist of this code is: for a given LatticePoints list, the program processes each point in parallel. While processing each data point, some points may be temporarily skipped, requiring repeated passes until all data points have been processed. In this code, nr_to_match is the total number of data points, nr_points_matched is the number of points already processed, and nr_points_done_in_this_round is the number of points processed this round. The loop's termination condition is nr_points_matched equaling nr_to_match, i.e. all data points processed. The direct cause of the infinite loop is that nr_points_matched never reaches nr_to_match, so the loop cannot terminate. Using gdb, one can find that when this happens, every point in the entire LatticePoints list has been marked as processed, so nr_points_matched stops increasing, yet the loop's exit condition is never satisfied, so it just keeps looping. The question then becomes: why does the value of the counter nr_points_matched not match the actual number of processed data points. According to the code, the per-round increment of nr_points_matched should equal that of nr_points_done_in_this_round, because they are always atomically incremented together. But the actual output was not so: the two counters' values differ slightly, and the gap is unstable, with the result varying from run to run.

The first thing ruled out was a memory-ordering issue: this code does not rely on atomic variables to synchronize other variables; in other words, it operates on and reads only the atomic variables themselves the whole time, so from the code's perspective it is logically correct. The next suspicion was whether the OpenMP implementation was at fault: whether the atomic operations annotated with #pragma omp atomic really guarantee atomicity. From the disassembly, one can see the compiler generated the LoongArch64 amadd.d instruction for these atomic operations, as expected. To investigate this, we set up two additional std::atomic counters as controls, used alongside the original two, to see whether the results agreed. It turned out that the four counters' values (computed from the per-round increments) should have agreed, but in fact they showed random discrepancies. This hinted that the atomic add instruction loses updates under certain conditions.

However, testing the atomicity of the atomic add instruction with a simple atomic add program could not reproduce the lost update. To find a minimal reproducer, we simplified the aforementioned normaliz processing logic into a similar test program, which also could not reproduce the problem. So we had to keep commenting out computation steps in normaliz's actually-running code, trying to find the minimal condition that triggers the lost atomic add. One bizarre phenomenon was that even after commenting out most of the computation steps, the problem persisted. Because the program was too complex, in the end we still could not find a minimal code snippet that reliably reproduced the lost atomic add.

The Second Round of Investigation

Six months later, the problem remained unsolved. With the disclosure of the LoongLeak/LoongBleed vulnerabilities, the lost atomic add in normaliz came back into our view. This time, we tried to use AI to assist the investigation. The method was: first point out to the AI that the above normaliz code has an infinite-loop problem, ask the AI to confirm and reproduce it, and then find the possible cause. In the first round of conversation, the AI noticed the problematic loop but did not conclude that the atomic add instruction was at fault. After that, we hinted to the AI that the problem exists only on LoongArch and not on other architectures, but the AI still could not give a definite conclusion. Finally, we directly told the AI the fact that we had already localized the problem to the atomic add, and asked it to reproduce it and provide a minimal reproducer. In that round of conversation, the AI eventually turned its attention to a memcpy call in the processing function, which was exactly the part overlooked in the first round: memcpy's implementation lives in glibc, and glibc chooses the optimal implementation based on currently available hardware features; if the hardware supports a vector instruction set (LSX/LASX on LoongArch), glibc's memcpy will use the corresponding vector instructions to accelerate memory copying. And it was precisely these vectorized memory copies that triggered the lost atomic add on LoongArch64. Two days later, the AI produced a minimal program that reliably reproduces the problem.

Expanding the Scope

After discovering that the atomic add instruction can lose updates, we had new questions: first, is only atomic add affected, or do other atomic instructions have the same problem; second, do other memory operations also trigger similar problems.

For the first question, we first investigated the CAS instruction, because it can be used to implement atomic addition, making it easy to tell whether something goes wrong. It turned out that CAS also has the problem under the same conditions. For other atomic instructions, such as atomic swap, atomic max, atomic min, atomic bitwise AND, atomic bitwise OR, and so on, since even a lost update is not easy to detect from the result, we did not verify them at first. For example, if a lost update happens during an atomic max, then as long as the operation that updated the maximum was not lost, the result is correct.

After much thought, we finally found a verification scheme: to check whether such instructions lose updates, we recorded the result of every operation and verified afterward. Take atomic max as an example: if you atomically take the max over the numbers 1 to n in parallel, the final result should be n. Each atomic max modifies the memory and also returns the old maximum. For an operation with input k, if the returned old value is less than k, then this operation updated the maximum. Atomicity guarantees that the return values of all operations that updated the maximum will not repeat. If a repeat occurs, then a lost update of the atomic instruction occurred. Verification showed that these atomic instructions all lose updates under the same conditions.

For the second question, we found in testing that only LASX vectorized memory reads (xvld) trigger the lost atomic instruction; normal scalar reads and LSX vectorized memory reads (vld) do not trigger the problem. Later, Rong "Mantle" Bao independently discovered that when the memory address of the atomic variable and the read memory address have a particular positional relationship, normal scalar reads can also trigger the lost atomic instruction, meaning the problem can occur even without LASX, just with lower probability. These complex triggering conditions explain why this problem went undiscovered for so long.

Specific Conclusions

Before presenting the specific conclusions, let us first introduce the background: Loongson's 3C6000/S and 3A6000 use the LA664 core, whose instruction set is LoongArch64 with SIMD extensions; of these, the 128-bit SIMD extension is called LSX and the 256-bit one is called LASX. The earlier LA464 core (such as the 3A5000) does not have this problem. LASX includes the memory-read instruction xvld, which can read 32 bytes at a time into a vector register. Loongson's atomic instructions can be summarized as am<OP>[_db].<width>, where <OP> is the specific atomic operation, such as amadd, amcas, amswap, ammax, amxor, amand, amor, etc.; [_db] indicates whether it carries a data barrier (db); and <width> is the data width of the operation, e.g. .d for 64-bit and .w for 32-bit.

Summarizing the experimental results, reproducing a lost atomic operation requires all three of the following conditions at once:

  • The threads run on different physical cores (i.e. not two SMT logical cores on the same physical core);
  • These threads perform atomic operations without a data barrier on the same address;
  • At least one of the threads interleaves memory reads between atomic operations.

Here, the memory-read operation can be the LASX vectorized memory read xvld; when the read memory address and the atomic operation's memory address have a particular positional relationship, the memory-read operation can also be an ordinary scalar read.

Reproduction and Measurement

The minimal reproducer came from the AI's simplification of the normaliz code. Each normaliz data point is 2208 bytes, i.e. 276 uint64_ts. Two threads traverse these points in shards; for each point they first do a vectorized memory copy (memcpy), then do one relaxed atomic add on each of three shared counters (corresponding to the amadd instruction without a data barrier). After each round, they check whether the three counters are equal; if not, a lost update occurred.

On a 3C6000/S, using two different physical cores (e.g. CPU0 and CPU2), 2208 bytes per point, 200 rounds per trial, and 30 trials in total, we obtained the following results:

Atomic Op Both do LASX copy Both do LASX read One side LASX copy
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%

The percentages in the table are "the proportion of trials that failed out of 30 trials". One can see that "both do LASX read" most easily triggers the problem, with a probability of almost 100%; amcas_db.d with db is always 0% under the same conditions.

An Aside: Why AOSC Could No Longer Reproduce It Later

During the process of reproducing this problem, there was a small aside, and it is also the most interesting part of the whole story. When the problem was first discovered in February, both AOSC OS and Debian could reproduce the normaliz infinite loop. But when we re-investigated in August, AOSC could not reproduce it no matter what, while Debian still could. At the time we did not know why this inconsistency occurred, and just kept experimenting on Debian.

Later, as the AI localized the problem to memcpy, we understood the reason. The Core 13 release that AOSC shipped after February had mistakenly disabled the --enable-multi-arch option in glibc, so the system memcpy no longer took the LASX acceleration path; whereas Debian's glibc enables vector acceleration normally, so its memcpy uses LASX. In February, both AOSC and Debian used the LASX acceleration path; by August, AOSC's memcpy no longer used LASX acceleration, so naturally it could not be triggered. On Debian, disabling LASX acceleration with GLIBC_TUNABLES=glibc.cpu.hwcaps=-LASX also stopped the problem from triggering.

So once AOSC releases Core 14 and re-enables the --enable-multi-arch option, the problem will reappear. Considering how commonly memcpy is used, the number of affected programs may be larger than we imagine, only because the trigger probability is low that it is hard to notice.

Impact Analysis

Among the problematic atomic instructions, ammax, ammin, and the like are hard for compilers to generate because the C standard currently has no corresponding atomic operation interface; and amcas is an instruction newly added in LoongArch64 v1.1, so it is also not generated by compilers by default. The one that may have the largest impact is amadd, the atomic add instruction. This instruction is typically used for reference counting: if an increment of the reference count is lost, the count becomes smaller than the actual number of references, which may cause an object to be freed prematurely, leading to memory-safety problems such as use-after-free or double free. And the other triggering condition, vectorized memory reads, is easily triggered by functions like memcpy. We found that in the Rust standard library, the reference count of std::sync::Arc and the cloning of std::sync::mpsc's Sender also use the amadd instruction without a data barrier. We constructed a safe Rust program; using either Arc or mpsc could make the program crash, manifesting as SIGABRT or glibc reporting heap corruption, which means a use-after-free occurred.

However, this problem is hard to use for a security attack. To trigger it, two threads must concurrently perform relaxed atomic operations on the same counter of the same object, and at least one of them must be doing a vectorized memory read. This triggering condition means the potential attacker and victim must be in the same process, and cannot be separated by process isolation, so it is hard to exploit unilaterally.

Workarounds

In general, since in high-level language code developers have no control over the atomic instructions the compiler produces, application developers have no direct workaround. Software-level workarounds are mainly implemented through the compiler, i.e. the compiler no longer generates atomic instructions without a data barrier (am<OP>.*) but instead generates atomic instructions with a data barrier (am<OP>_db.*), or implements atomic operations using LL/SC loops. But for already-existing binaries, applying these workarounds requires a full recompile. Therefore, the cost of avoiding the problem at the software level is high.

The Fix

After we reported it to Loongson, the fix came quickly: we emailed the problem to Loongson on August 26, 2026, and just two weeks later, on September 9, 2026, we received test firmware; both the 3A6000 and 3C6000/S returned to normal in our tests. Loongson told us this firmware is expected to be released before National Day (October 1).

The fix is to set bit 13 of MCSR24 to 1. MCSR24 is an internal CSR whose function is not described in the manual. After setting this bit, the lost update no longer occurs. Testing showed the performance loss is very small: single-core performance is unaffected, and multi-core performance drops only slightly.

If affected users cannot update the firmware for the time being, they can also choose to write this bit directly in the Linux kernel, which is equivalent to applying the firmware fix and avoids waiting for a motherboard firmware update.

Conclusion

Looking back, this story began with a purely software problem: an infinite loop during packaging, a counter that occasionally miscounts. In the end it turned out to be an atomic add instruction in the CPU that is not atomic. From discovering the problem to finding the cause spanned half a year, yet the truly effective progress took only two days, with both AI and humans playing indispensable roles. The rest of the time went into confirming the problem, finding the triggering conditions, broadening the testing scope, and waiting for the firmware fix.

In fact, similar errata are very common in CPUs from all vendors. Interested readers can browse ARM's Software Developer Errata Notice for its cores, many of which involve memory-access or atomic instructions, with a few severe ones even causing the CPU to deadlock; but those that genuinely affect the user experience are actually very few. For such problems, rather than letting one suddenly surface someday in some complex system as an unstable error report, it is better to localize the specific cause and fix it as early as possible.

Acknowledgements

This work was initiated and led by Wang Miao; I was responsible for reproduction and writing up the report. After we informed them that amcas also had the problem, Rong "Mantle" Bao discovered a similar CPU problem, which was fixed together. Thanks to Loongson's Chip R&D Department and Developer Community Operations Department, among others, for their efficient and professional work throughout the reporting and fixing process!