MoreRSS

site iconJiaJe | 杰哥修改

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

Inoreader Feedly Follow Feedbin Local Reader

JiaJe | 杰哥的 RSS 预览

记一次软 RAID1 坏盘的恢复过程

2026-01-21 08:00:00

记一次软 RAID1 坏盘的恢复过程

背景

最近遇到一个运维场景,两个 SATA 盘组了一个 RAID1,Linux 的根系统也在上面,启动时能进内核,但是内核一直在报错 link is too slow to respond, please be patient 以及 COMRESET failed (errno=-16)。下面记录一下故障排查以及恢复的过程。

恢复过程

考虑到 Linux 系统也在 RAID1 上面,所以找了另一台机器,接上两个 SATA 盘,然后观察到,其中一个盘直接无法识别,另一个盘可以正常访问,但它分区表里只有一个分区,参与到了 md 组的 RAID1 当中。遇到盘坏了又是 RAID,第一反应是买一个新盘,然后重建 RAID。但是一通询价,发现最近硬盘价格涨的比较多,所以先尝试如何单盘启动。由于是 UEFI 启动,推测 ESP 在已经坏的那个盘上面,好的盘上并没有 ESP,但它唯一的分区已经占满了整个空间,所以第一步是对 RAID 分区缩容,这就需要:

  1. 首先用 fsck -f /dev/md0 && resize2fs /dev/md0 newsize 对根分区进行缩容
  2. mdadm --grow --size=newsize /dev/md0 对 RAID 进行缩容
  3. 停止 RAID:mdadm --stop /dev/md0
  4. 重新分区,缩小 RAID 分区大小:cfdisk /dev/sda
  5. 重新启动 RAID,更新 device size:mdadm --assemble --update=devicesize /dev/md0 /dev/sda1

这些步骤完成以后,就可以在空余的空间里建 ESP 分区了:建分区,mkfs.vfat,挂载到 /mnt/boot/efi(假设 /dev/sda1 已经挂载到了 /mnt),接着 arch-chroot /mnt(或者手抄 Archlinux Wiki),进去 grub-install,修改 /etc/fstab,重新 update-grub

这个过程中,踩了一些小坑,比如:

  1. 重启以后直接进 grub shell,没有菜单显示出来,后来发现是 UEFI 启动项里有之前的旧残留,导致 grub 没有能够正确加载 ESP 里面的 grub.cfg,如果在 grub shell 里手动 source 一下是正常的
  2. 如果不更新 device size,那么 assemble 的时候会说 does not have a valid v1.2 superblock 报错,实际上就是它记录了旧的分区大小,和新的分区大小不匹配,此时要强制修改它
  3. 最后买了个新盘,但是不够大:960GB vs 1TB,导致如果要重组 RAID1 还得再缩小一次已有的 RAID1 分区,之前缩小的时候只给 ESP 预留了足够的空间,但分区还不够小到能够在新盘里建一个相同大小的分区

IBM POWER9 微架构评测

2026-01-17 08:00:00

IBM POWER9 微架构评测

背景

IBM POWER8 之后,也来评测一下后续的 IBM POWER9 微架构。IBM POWER9 有 SMT4 和 SMT8 两种版本,我只有 SMT4 版本的测试环境,下列所有评测都是针对 SMT4 版本进行测试。

官方信息

IBM 关于 POWER9 微架构有如下公开信息:

下面分各个模块分别记录官方提供的信息,以及实测的结果。官方信息与实测结果一致的数据会加粗。

Benchmark

IBM POWER9 的性能测试结果见 SPEC

前端

L1 ICache

官方信息:32KB(SMT4)/64KB(split into 2 regions, SMT8)

为了测试 L1 ICache 容量,构造一个具有巨大指令 footprint 的循环,由大量的 nop 和最后的分支指令组成。观察在不同 footprint 大小下的 IPC:

测试环境是 SMT4 Core,所以只有 32KB 的容量。超出 L1 ICache 容量后,IPC 从 6 降低到了 4.7。

测试过程详见测试代码

取指带宽

官方信息:32 bytes/cycle

为了测试实际的 Fetch 宽度,参考 如何测量真正的取指带宽(I-fetch width) - JamesAslan 构造了测试。

其原理是当 Fetch 要跨页的时候,由于两个相邻页可能映射到不同的物理地址,如果要支持单周期跨页取指,需要查询两次 ITLB,或者 ITLB 需要把相邻两个页的映射存在一起。这个场景一般比较少,处理器很少会针对这种特殊情况做优化,但也不是没有。经过测试,把循环放在两个页的边界上,发现 IBM POWER9 微架构遇到跨页的取指时确实会拆成两个周期来进行。

在此基础上,构造一个循环,循环的第一条指令放在第一个页的最后四个字节,其余指令放第二个页上,那么每次循环的取指时间,就是一个周期(读取第一个页内的指令)加上第二个页内指令需要 Fetch 的周期数,多的这一个周期就足以把 Fetch 宽度从后端限制中区分开,实验结果如下:

图中蓝线(cross-page)表示的就是上面所述的第一条指令放一个页,其余指令放第二个页的情况,横坐标是第二个页内的指令数,那么一次循环的指令数等于横坐标 +1。纵坐标是运行很多次循环的总 cycle 数除以循环次数,也就是平均每次循环耗费的周期数。可以看到每 8 条指令会多一个周期,因此 IBM POWER9 的前端取指宽度确实是 8 条指令即 32 字节。

为了确认这个瓶颈是由取指造成的,又构造了一组实验,把循环的所有指令都放到一个页中,这个时候 Fetch 不再成为瓶颈(图中 aligned),两个曲线的对比可以明确地得出上述结论。

随着指令数进一步增加,最终瓶颈在每周期执行的 NOP 指令数,因此两条线重合。

测试过程详见测试代码

L1 ITLB

为了测试 L1 ITLB 的容量,构造 b 序列,每个 b 在一个单独的页(64KB 的页大小)中,观察 b 的性能:

可以看到明显的 256 pages 的拐点,对应了 256 entry 的 L1 ITLB。CPI 从 3 升高到了 28。

测试过程详见测试代码

BTB (aka Branch Target Address Calculator, BTAC)

官方信息:1 cycle latency

Return Address Stack

构造不同深度的调用链,测试每次调用花费的时间,得到如下测试结果:

可以看到 64 的拐点,对应的就是 RAS 的大小。

测试过程详见测试代码

CBP (Conditional Branch Predictor)

官方信息:BHT(3 cycle redirect) + TAGE(4 components, 5 cycle redirect), 256-bit LGHB(long global history vector)

Dispatch

官方信息:6 instructions per SMT4, 12 instructions per SMT8

后端

ROB (aka ICT)

官方信息:256 entries per SMT4 core

把两个独立的 long latency pointer chasing load 放在循环的头和尾,中间用 NOP 填充,当 NOP 填满了 ROB,第二个 pointer chasing load 无法提前执行,导致性能下降。测试结果如下:

拐点在 256 附近。

测试过程详见测试代码

Issue Queue

官方信息:54 instructions per SMT4 core, 108 instructions per SMT8 core

L1 DCache

官方信息:32KB(SMT4)/64KB(SMT8, split into two regions)

L1 DTLB

用类似测 L1 DCache 的方法测试 L1 DTLB 容量,只不过这次 pointer chasing 链的指针分布在不同的 64KB page 上,使得 DTLB 成为瓶颈:

可以看到 256 Page 出现了明显的拐点,对应的就是 256 的 L1 DTLB 容量。没有超出 L1 DTLB 容量前,Load to use latency 是 4 cycle。

测试过程详见测试代码

L2 Cache

官方信息:8-way 512KB L2 cache

L3 Cache

官方信息:20-way 10MB eDRAM L3 cache per core

Prefetcher

参考 Battling the Prefetcher: Exploring Coffee Lake (Part 1) 的方式,研究预取器的行为:分配一片内存,把数据从缓存中 flush 掉,再按照特定的访存模式访问,触发预取器,最后测量访问每个缓存行的时间,从而得到预取器预取了哪些缓存行的信息。

首先是连续访问若干个 128B cacheline,观察哪些被预取了进来:

预取的行为相比 POWER8 更加激进:有更多的缓存行被预取到了更近的 L1(或者是 L2?)。

如果是访问了几个分立的缓存行,有时会表现出 Next 3 Line 的行为,但都是到 L3:

测试过程详见测试代码

IBM POWER8 微架构评测

2026-01-15 08:00:00

IBM POWER8 微架构评测

背景

之前评测了很多 AMD64 和 ARM64 指令集的处理器,这次也来评测一下 PPC64LE 指令集的 IBM POWER8 微架构。

官方信息

IBM 关于 POWER8 微架构有如下公开信息:

下面分各个模块分别记录官方提供的信息,以及实测的结果。官方信息与实测结果一致的数据会加粗。

Benchmark

IBM POWER8 的性能测试结果见 SPEC

前端

L1 ICache

官方信息:32 KB, 8-way set associative

为了测试 L1 ICache 容量,构造一个具有巨大指令 footprint 的循环,由大量的 nop 和最后的分支指令组成。观察在不同 footprint 大小下的 IPC:

超出 L1 ICache 容量后,IPC 从 6 降低到了 2.4。其中 6 IPC 来自于,IBM POWER8 在 ST 模式下每周期可以发射 8 条指令,但其中分支指令最多两条,非分支指令最多六条,所以执行 NOP 指令的 IPC 只能达到 6。

测试过程详见测试代码

L1 ITLB (aka Instruction Effective to Real Address translation Table, IERAT)

官方信息:64-entry, fully associative

为了测试 L1 ITLB 的容量,构造 b 序列,每个 b 在一个单独的页(64KB 的页大小)中,观察 b 的性能:

可以看到明显的 64 pages 的拐点,对应了 64 entry 的 L1 ITLB。

测试过程详见测试代码

BTB (Branch Target Buffer)

官方信息:无 BTB,总是通过 3 周期延迟的 Fetch + Decode(Branch Scan) 来得到分支指令的目的地址,靠 SMT 来填补流水线的气泡。

实测也是如此,对于连续执行多个 b 指令的情况,每条 b 指令都需要 3 周期。

Return Address Stack (aka Link Stack)

官方信息:32-entry(ST/SMT2)/16-entry(SMT4)/8-entry(SMT8) Link Stack per thread,也就是说总容量是 64,但每个线程只能用一部分

构造不同深度的调用链,测试每次调用花费的时间,得到如下测试结果:

可以看到 32 的拐点,对应的就是 ST 模式下 RAS 的大小。在同一个物理核上的其他三个逻辑核分别运行 stress,就测得 SMT4 模式下的 RAS 大小 16:

类似地,在其余七个逻辑核上分别运行 stress 负载,得到 SMT8 模式下的 RAS 大小为 8:

测试过程详见测试代码

CBP (Conditional Branch Predictor)

官方信息:16K-entry LBHT, 16K-entry GBHT, 16K-entry GSEL,使用 21-bit GHV 记录全局分支历史,GSEL 用来选择由 LBHT 还是 GBHT 提供预测(通过 2-bit 饱和计数器),LBHT 采用 PC 索引,GBHT 和 GSEL 采用 PC+GHV 的哈希索引;此外,还支持把 conditional branch to +8 也就是只跳过一条指令的分支指令改写为 predication

IBP (Indirect Branch Predictor)

官方信息:256-entry local count cache, 512-entry global count cache,前者采用 PC 索引,后者采用 PC+GHV 的哈希索引,entry 内容是 30-bit 预测的目的地址加 2-bit 的 confidence(local count cache 的 entry 还有额外的 2-bit 饱和计数器用于选择 local 还是 global)

Dispatch

官方信息:按 Group 来 Dispatch,ST 模式下每周期一个 Group,每个 Group 最多 8 条指令(最多 2 条分支,最多 6 条非分支,且第二条分支必须是最后一条指令);SMT 模式下,每周期从两个线程各 Dispatch 一个 Group,每个 Group 最多 4 条指令(最多 1 条分支,3 条非分支)

后端

ROB (aka Global Completion Table, GCT)

官方信息:28-entry,ST 模式下每个 entry 对应一个 Group;SMT 模式下每个 entry 对应两个来自同一个线程的 Group;所以最多容纳 28*8=224 条指令;Commit 的粒度是 Group,ST 模式下每周期 Commit 一个 Group,SMT 模式下每周期 Commit 两个 Group

把两个独立的 long latency pointer chasing load 放在循环的头和尾,中间用 NOP 填充,当 NOP 填满了 ROB,第二个 pointer chasing load 无法提前执行,导致性能下降。测试结果如下:

拐点大致在 168 附近,因为每 6 条 NOP 指令对应一个 Group,所以只能容纳 28*6=168 条指令。

测试过程详见测试代码

Register File

官方信息:一共可以有 106 个 Inflight 的 Rename,由 GPR(General Purpose Register)和 VSR(Vector and Scalar Register)共享;GPR 分为两组,每组 124-entry;VSR 分为两组,每组 144-entry;还有额外的两组 SAR(Software Architected Registers),一组用于 GPR,一组用于 VSR;CR(Condition Register)单独 Rename(32-entry mapper)到 64-entry Architected Register File;XER(fiXed-point Exception Register)Rename(30-entry mapper)到 32-entry Architected Register File;LR,CTR 和 TAR 单独 Rename(20-entry mapper)到 24-entry Architected Register File;FPSCR(Floating Point Status and Control Register)单独 Rename 到 28-entry buffer。

Issue Queue

官方信息:15-entry Branch Issue Queue,8-entry Condition Register Queue,64-entry UniQueue 用于其他指令;每周期最多 Issue 10 条指令:1x Branch, 1x Condition Register Logical, 2x Fixed Point, 2x Load/Store/Fixed Point to LSU, 2x Load/Fixed Point to LU, 2x Vector-Scalar to VSU/DFU(Decimal Floating point Unit)/Crypto

执行单元

官方信息:2 个定点计算流水线(FX),2 个 Load/Store 流水线(LS/FX),2 个 Load 流水线(L/FX),4 个双精度浮点流水线(或 8 个单精度浮点流水线),2 个向量流水线(VMX),1 个密码学流水线(Crypto),1 个分支流水线(Branch),1 个条件寄存器流水线(CR),1 个十进制浮点数流水线,共 16 个;其中 2 个 Load/Store 流水线和 2 个 Load 流水线还能执行简单的定点计算

Load Store Unit

官方信息:共有四个 Pipeline,L0/L1 仅 Load,LS0/LS1 可 Load/Store, 3 cycle load-to-use latency

Load/Store (Reorder) Queue

官方信息:40-entry(128 Virtual)Store Reorder queue,44-entry(128 Virtual)Load Reorder Queue

Load to use latency

官方信息:3-cycle latency

实测在下列的场景下可以达到 3 cycle:

  • ldr 4, 0(4): load 结果转发到基地址,无偏移
  • ldr 4, 8(4):load 结果转发到基地址,有立即数偏移
  • ldx 4, 4, 6:load 结果转发到基地址,有寄存器偏移
  • ldx 4, 6, 4:load 结果转发到寄存器偏移

如果访存跨越了 128B 边界,则退化到 16 cycle。

L1 DCache

官方信息:64KB, 8-way set associative, 128B cache line, 4 read port, 1 write port,3 cycle load to use latency, store-through(写入会同时写 L1 DCache 和 L2),所以 store miss 不分配 cache line, 16 MSHR(aka Load Miss Queue)

构造不同大小 footprint 的 pointer chasing 链,测试不同 footprint 下每条 load 指令耗费的时间:

可以看到 64KB 出现了明显的拐点,对应的就是 64KB 的 L1 DCache 容量。第二个拐点在 512KB,对应的是 L2 Cache 的容量。第三个拐点是 3MB,对应的是 L1 DTLB 的容量:48*64KB=3MB

测试过程详见测试代码

Banking

官方信息:L1 DCache 由 16 个 macro 组成,每个 macro 是 16 个 bank,一共是 256 个 bank;sram 用的是 2R 或 1W,所以每个 bank 可以支持每周期 2R 或 1W

L1 DTLB (aka primary Data Effective-to-Real Address Translation, DERAT)

官方信息:48-entry(ST)/96-entry(SMT), fully associative

用类似测 L1 DCache 的方法测试 L1 DTLB 容量,只不过这次 pointer chasing 链的指针分布在不同的 64KB page 上,使得 DTLB 成为瓶颈:

可以看到 48 Page 出现了明显的拐点,对应的就是 48 的 L1 DTLB 容量。没有超出 L1 DTLB 容量前,Load to use latency 是 3 cycle。最终出现一个 18.8 cycle 的平台。

测试过程详见测试代码

L2 DTLB (aka secondary Data Effective-to-Real Address Translation, DERAT)

官方信息:256-entry(ST 模式下全可见,SMT 模式下每个线程只有一半可见), fully associative

继续扩大 DTLB 测试规模,可以看到在 256 处出现了新的拐点,其中 256 的地方出现周期数的骤降,是触发了 Linux 的大页合并功能:

关掉 THP(Transparent Huge Page) 后,周期数的骤降消失,256 的拐点之后周期数增加而不是减少:

测试过程详见测试代码

L3 TLB

官方信息:2048-entry, 4-way set associative, 4 concurrent page table walk

继续扩大 DTLB 测试规模,在 2048 处出现了拐点,注意要关闭 THP,否则拐点会消失,因为实际上没有用到 2048 个页:

测试过程详见测试代码

Prefetcher

官方信息:16-entry Stream Prefetcher,可以跨 4KB/64KB 页边界,用虚拟地址预取,可以预取到 L1/L2/L3

参考 Battling the Prefetcher: Exploring Coffee Lake (Part 1) 的方式,研究预取器的行为:分配一片内存,把数据从缓存中 flush 掉,再按照特定的访存模式访问,触发预取器,最后测量访问每个缓存行的时间,从而得到预取器预取了哪些缓存行的信息。

首先是连续访问若干个 128B cacheline,观察哪些被预取了进来:

可以看到后面有 12 个 cacheline 都被预取了,但是预取到了不同的 cache 层次,猜测距离越近的 4 个 cacheline 预取到 L1,更远的 2 个到 L2,其余的 6 个到 L3。

如果是访问了几个分立的缓存行,行为变成了 Next 3 Line:

测试过程详见测试代码

2025 年我是怎么使用 AI 的

2025-12-25 08:00:00

2025 年我是怎么使用 AI 的

前言

经常看我博客的读者应该能看出来,我研究的主要是计算机系统结构方向,特别是处理器的微架构,几乎没有涉及到 AI 的内容,我也确实不喜欢 AI 研究,仅关注但不参与。但今年,因为各种 AI 技术尤其是 LLM 的发展,我确实成为了很多 AI 技术的用户,可以说 2025 年是我正经大规模用 AI 的元年,所以在年末做一个简单的总结。

我不想在这里给大模型厂商打广告,所以相关的名字我都会按照某 PDF 的方法进行打码,有需要的朋友可以自行查看实际的内容。

Vibe Coding

首先的一个冲击来自于 Vibe Coding。我写代码也有大概十五年了,一直都是坚持自己写代码,但今年从一些朋友那里了解到一些 Vibe Coding 的效果以后,也自己尝试了一下,确实能够感受到 Vibe Coding 对写代码的巨大冲击,我的心态也出现了一定的变化。Vibe Coding 并不复杂,其实就是用一些 Coding 客户端,配上 LLM 加一些 Tool Call,使得 LLM 可以自己编写、测试和运行代码。目前随着 LLM 能力的变强,Vibe Coding 逐渐成为了一个可以负担得起且效果不错的东西。结合实际的使用,以及受朋友们的一些启发,我目前已经用它进行了一些 Vibe Coding 尝试,例如:

  1. 写一些简单的 MCP 服务器,例如把 devdocs.io 的文档通过 MCP 暴露给 LLM,让它可以精确读取标准库的文档,避免幻觉,还有让 LLM 可以读取波形文件的 waveform-mcp
  2. 写一个 API 路由器,可以在多个 API 提供商之间自动 Fallback,类似于本地版的 OpenRouter,但在这里主要是为了解决 Rate Limit 问题;
  3. 对已有代码的一些改进,比如实现 TODO,修复代码 BUG 等等;
  4. 给定提示词,让 LLM 用 Typst 或者 SVG 绘图,相比直接 AI 绘图,我更希望是可编辑的矢量图;
  5. 给定一张图,让 LLM 用 Typst 或者 SVG 复刻出来,然后再用 Vision LLM 识别绘制出来的图,观察内容是否和输入足够相似;
  6. 对于闭源的软件,让 LLM 自动逆向工程,得到一份关于内部实现的代码,甚至让它实现一份开源的等价实现。

目前给我的感觉是,LLM 借助各种 MCP Tooling,在很多事情上可以做的很好,但也有一些前提条件。第一是 LLM 需要有针对这个事情的知识,但如果它的知识停留在几年前,又做一些比较新的东西(例如 Typst 语法很多 LLM 就不会写),它就比较难写对;第二是,一定要给 LLM 反馈的路径,能够让它自产自纠自查,不然幻觉是很难避免的,一次写对的情况很少,有反馈和无反馈完全是两个表现;第三是,目前 LLM 做复杂事情需要大量的 Token,这就意味着 API 调用时间和开销都是不可忽略的因素,即使我用了比较便宜的 DeepSeek 模型,让 LLM 在后台跑几个小时,价格一样受不了。

举一个数据,我这个月在 DeepSeek 上已经花费了 200 多元,而这个月之前的所有时间加起来,也就不过 10 元。如果相同的 Token 数用在 Claude 上,这个价格不可想象。所以我也终于能理解,那些几百美刀一个月的订阅服务为啥有市场了。也是因为这个原因,我才会降本增效,通过订阅 GLM Coding Plan 去解决一些低频的 Vibe Coding 需求,但它的用量限制和并发限制都比较容易触发,所以才去 Vibe Coding 了一个 API 路由器,对于 GLM Coding Plan 用量以外的需求,再 Fallback 到 DeepSeek 上。

在使用 Vibe Coding 的过程中,我也有一些感受,就感觉我并不是在 Vibe Coding,而是在指挥一个水平飘忽不定的人在写代码。它有时候能精准地找到问题并写出正确的代码,有时候又注意力涣散,必须要我及时地打断它,让它按照我指定的方法去做。对于一些简单的代码,可能可以让它在后台跑,我去做一些别的事情,然后隔一段时间再看看它做得怎么样,有问题了,再提供及时的纠正。然后我就在想,这其实就是当领导吧,给钱让手下的人干活,不一定干的对,所以还得时不时地去纠正一下。某种意义来说,LLM 让每个人都有了成为领导,领导一群 LLM 干活的能力。我目前的工作流就是在 tmux 里挂几个 Qwen Code,连上几个配好的 MCP 服务器以及 API 路由器,然后时不时地看看它做的咋样,做得好就验收,让它 Git Commit,做得不好就让它改,时不时还得翻翻代码看看怎么帮它修。某种意义上,这和课后布置作业,给学生答疑也没啥本质上的区别,甚至 LLM 还更爱说话一点。

既然提到了答疑,也来谈谈教学。这种 Vibe Coding 的能力对于计算机教育的冲击无疑是巨大的,本来很多上课教的内容,AI 可以比较容易地完成,那学生可能就更倾向于让 AI 去完成了,换位思考一下,如果让我在 2025 年成为大一不会编程的新生,我也很难抵御这个诱惑。但是,锻炼代码和工程能力就欠缺了。这就对应一个很重要的问题,就是 AI 它到底是不是一种类似编译器、调试器或者编程语言的工具?我们说学生可以从编程语言而不是汇编学起,是因为它是一个很成熟很可靠的工具,你学会了高层次的工具就是会了,就可以用它做很多事情。AI 就很奇怪,它确实可以做很多事情,但又不总是可以完成,它好像是概率性的图灵完全,全看是否出现幻觉,所以它不是一个可靠的工具,但又是一个好用的工具。那么紧接的问题是,计算机教育,是要教出来真的会写代码的人,还是会用 AI 写代码就行?我目前没有答案,也不知道未来会怎么发展,只能慢慢走一步看一步了。但抛开计算机专业的教育,如果是对于计算机的通识教育,我觉得用 AI 写代码完全没有问题,毕竟对于更多人来说,能解决问题就可以,可不可靠,其实很多时候并不在考虑范围内。

我知道上面这段话可能会让读者有一些焦虑,但我觉得,它都这样了,就共存吧,反正焦虑也没有用,不如拥抱它。至于是否担心自己会被替代,我确实是不担心,目前它还不够专业,就算它再专业,它也没有身份证是吧。希望早日实现生产力极大富足,实现共同富裕,那就不用思考人是不是会被 AI 替代了。另外,高级编程语言出现了,那些写汇编的人去写高级语言,现在 Vibe Coding 来了,只是同一拨人又跑去做 Vibe Coding 罢了。持续学习才是最重要的。今年开始尝试 Vibe Coding 也是让我意识到,随着年龄增大,确实是没有当年对新事物接受得那么快了,这也让我有了一些反思,以后还是要多多接触新技术,一些过去的思维可能也要重新审视。

目前我对 Vibe Coding 的态度是,它不能替代我的思考,相反,我可以更多地思考一些更高层次的东西,而可以适当地把一些细节交给 AI。我也持续在自己写代码,特别是一些关键的部分,我还是无法信任完全由 AI 编写,毕竟它比人还懂得偷懒,经常写出来一些没有测试效果的测例,一看测例都过,一测全是 BUG。

我还会继续尝试和 LLM 协作,尽量保持高质量的代码产出,我认为这是用 Vibe Coding 的底线:用 AI 并不是写出烂代码的理由。以前我们有所谓的中文羞耻,觉得写了很多中文的项目的代码可能不靠谱,现在是所谓的 AI 羞耻,看到 README 里一堆 AI 生成的辞藻就觉得不靠谱一样。我们作为业内人士,还是要把事情做得漂亮,而不是让 AI 生成一个勉强能用的组装拖拉机就完事。

写作和语音输入

另一方面 AI 影响比较大的,其实是写作,包括日常的各种文字,比较正式的文档、论文甚至教材,不得不承认,AI 在写作方面确实是比我这种语文是考试弱项的偏科生要做得更好。我通常会自己编写一遍,然后交给 DeepSeek 来润色一遍,再在润色的基础上修改,保证我要表达的意思能够完全地被保留下来。一些小的人情世故,比如微信上和各种人打交道的措辞,网络上发送的邮件或者是 GitHub Issue 等等场合的客套话,AI 确实也是做得比自己好。但是,更完整的内容,或者整体架构上的把握,还是不会让 AI 完全去完成,因为能感觉到 AI 训练所使用的语料和自己的思维方式或者写作的习惯还是不一样的,我还是希望我写的东西能更加得有我的思考和劳动在里面,AI 只是一个让文字看起来更加通顺的工具,帮我纠正一些语法错误之类的。例如,我平时可能更习惯一些口语化的表达,能够让我很快地通过打字或者语音输入把我的脑子里的想法变成文字,然后再让 AI 改写成更加严肃的文字,像教材或者论文,这时 AI 就沦为了纯粹的文字风格改写或者语言翻译器。

既然提到了语音输入法,就不得不提,今年我用语音输入的比例大大提升了。其实语音输入法历史已经很久了,但是以前每次体验,都觉得效果不行,每次输入的有错误还得改,自己改正的时间,还不如自己打字来得快。所以一直以来我都是坚持在所有设备上都是 26 键打字用拼音输入的,当然包括手机,经过多年的练习,确实速度还不错,包括我也不喜欢麻烦别人在微信上听语音,所以我尽量都是用文字的。但今年感受下来,确实是不一样,感觉语音输入的准确率有了质的飞跃,能看到它先识别出一个音对字不对的状态,再纠正成正确的表达,还会提示你,这里可能是另一个词,如果你要修改的话,就直接点一下就行。有这个功能以后,我在手机上真的很多时候就直接用语音输入了,尤其是在一些不太正式的场合,对方也能够对那些少数的识别错误脑补的时候,语音输入确确实实替代了手机上打字。在电脑上,还是打字通常更快一些,但最近也尝试了一下 智谱 AutoGLM 的输入法,感觉这种语音输入和 LLM 结合还挺有意思的,就是它们家的语音输入准确率还比不上 鸿蒙 6 上的小艺输入法,要是二者的优点能够结合在一起就更好了,相信这一天并不遥远。

小结

目前想到的就这么多,其实 AI 还有很多场景可以用到,比如生成图片、视频和音乐等等,目前还没有太多的尝试,相信明年开始会逐渐接触,到时候再在年底写一个 AI 使用总结。总的下来,就是感叹自己也到了感慨科技进步的年纪了,十几年前学技术,虽然也能感觉到科技进步,但因为自己是从零开始,学的就是最新的科技,所以没有啥感觉。但这几年,不断地把新的输入和已有的积累进行对比,就能感觉明显到技术潮流和技术栈的移动,也能感觉到自己对新技术的接受度开始有了略微的下降,这值得让我警醒。以前,我们总是嘲笑大人不追求潮流,不去学习手机等新技术,我们在这个时代长大的人,可也不能犯这样的错误呀。

条件分支预测器逆向工程(以 Apple M1 Firestorm 为例)

2025-10-28 08:00:00

条件分支预测器逆向工程(以 Apple M1 Firestorm 为例)

背景

去年我完成了针对 Apple 和 Qualcomm 条件分支预测器(Conditional Branch Predictor)的逆向工程研究,相关论文已发表在 arXiv 上,并公开了源代码。考虑到许多读者对处理器逆向工程感兴趣,但可能因其复杂性而望而却步,本文将以 Apple M1 Firestorm 为例,详细介绍条件分支预测器的逆向工程方法,作为对原论文的补充说明。

背景知识

首先介绍一些背景知识。要逆向工程条件分支预测器,需要先了解其工作原理。条件分支预测器的基本思路是:

  • 条件分支的跳转行为(跳转或不跳转)通常是高度可预测的
  • 预测器的输入包括条件分支的地址,以及近期执行的若干条分支的历史记录;输出则是预测该条件分支是否跳转

为了在硬件上实现这一算法,处理器会维护一个预测表,表中每一项包含一个 2 位饱和计数器,用于预测跳转方向。查表时,系统会对条件分支地址以及近期执行的分支历史进行哈希运算,使用哈希结果作为索引读取表项,然后根据计数器的值来预测分支的跳转方向。

(图源:CMU ECE740 Computer Architecture: Branch Prediction)

目前主流处理器普遍采用 TAGE 预测器,它在上述基础查表方法的基础上进行了重要改进:

  1. 观察到不同分支的预测所需的历史长度各不相同:有些分支无需历史信息即可准确预测,有些依赖近期分支的跳转结果,而有些则需要更久远的历史信息;
  2. 分支历史越长,可能的路径组合就越多,导致预测器训练过程变慢,训练期间的预测错误率较高,因此希望尽快收敛;
  3. 为满足不同分支对历史长度的需求,TAGE 设计了多个预测表,每个表使用不同长度的分支历史。多个表同时进行预测,当多个表都提供预测结果时(仅在 tag 匹配时提供预测),选择使用最长历史长度的预测结果。

(图源:Half&Half: Demystifying Intel's Directional Branch Predictors for Fast, Secure Partitioned Execution

因此,要逆向工程处理器的条件分支预测器,需要完成以下工作:

  1. 确定分支历史的记录方式:通常涉及分支地址和目的地址,通过一系列移位和异或操作,将结果存储在寄存器中;
  2. 确定 TAGE 算法的具体实现:包括表的数量、每个表的大小、索引方式以及使用的分支历史长度。

分支历史的逆向

第一步是逆向工程处理器记录分支历史的方式。传统教科书方法使用一个寄存器,每当遇到条件分支时记录其跳转方向(跳转记为 1,不跳转记为 0),每个分支占用 1 bit。然而,现代处理器(包括 Intel、Apple、Qualcomm、ARM 和部分 AMD)普遍采用 Path History Register 方法。这种方法设计一个长度为 \(n\) 的寄存器 \(\mathrm{PHR}\),每当遇到跳转分支(包括条件分支和无条件分支)时,将寄存器左移,然后将当前跳转分支的地址和目的地址通过哈希函数映射,将哈希结果异或到移位寄存器中。用数学公式表示为:

\(\mathrm{PHR}_{\mathrm{new}} = (\mathrm{PHR}_{\mathrm{old}} \ll \mathrm{shamt}) \oplus \mathrm{footprint}\)

其中 \(\mathrm{footprint}\) 是通过分支地址和目的地址计算得到的哈希值。接下来的任务是确定 \(\mathrm{PHR}\) 的位宽、每次左移的位数,以及 \(\mathrm{footprint}\) 的计算方法。

历史长度

首先分析这个更新公式:它将最近的 \(\lceil n / \mathrm{shamt} \rceil\) 条跳转分支的信息压缩存储在 \(n\) 位的 \(\mathrm{PHR}\) 寄存器中。随着移位操作的累积,更早的分支历史信息对 \(\mathrm{PHR}\) 的贡献最终会变为零。

第一个实验的目标是确定 \(\mathrm{PHR}\) 能够记录多少条最近分支的历史。具体方法是构建一个分支历史序列:

  1. 第一个条件分支:以 50% 的概率随机跳转或不跳转;
  2. 中间插入若干条无条件分支;
  3. 最后一个条件分支:跳转方向与第一个条件分支相同。

接下来分析两种情况:

  1. 如果在预测最后一个条件分支时,分支历史 \(\mathrm{PHR}\) 仍然包含第一个条件分支的信息,预测器应该能够准确预测最后一个条件分支的方向;
  2. 如果中间的无条件分支数量足够多,使得第一个条件分支的跳转信息对预测最后一个条件分支时的 \(\mathrm{PHR}\) 没有影响,预测器只能以 50% 的概率进行正确预测。

通过构造上述程序,调整中间无条件分支的数量,并使用性能计数器统计分支预测错误率,可以找到一个临界点。当无条件分支数量超过这个阈值时,第二个条件分支的错误预测率会从 0% 上升到 50%。这个临界点对应 \(\mathrm{PHR}\) 能够记录的分支历史数量,即 \(\lceil n / \mathrm{shamt} \rceil\)

经过测试

# 第一列:第二步插入的无条件分支数量加一 # 第二列到第四列:分支预测错误概率的 min/avg/max # 第五列:每次循环的周期数 size,min,avg,max,cycles 97,0.00,0.00,0.01,216.87 98,0.00,0.00,0.01,221.02 99,0.00,0.00,0.01,225.18 100,0.00,0.00,0.01,229.17 101,0.45,0.50,0.53,331.97 102,0.47,0.50,0.54,336.27 103,0.46,0.50,0.54,339.85 

测试结果表明阈值为 100:在 Apple M1 Firestorm 上,最多可以记录最近 100 条分支的历史信息。

分支预测错误率是怎么测量的?

处理器内置了性能计数器,会记录分支预测错误次数。在 Linux 上,可以用 perf 子系统来读取;在 macOS 上,可以用 kpep 私有 API 来获取。我开源的代码中对这些 API 进行了封装,可以实现跨平台的性能计数器读取。更进一步,我们还逆向了 Qualcomm Oryon 的针对条件分支的预测错误次数的隐藏性能计数器,用于后续的实验。

分支地址 B 的贡献

接下来需要推测 \(\mathrm{footprint}\) 的计算方法,即分支地址和目的地址如何参与 \(\mathrm{PHR}\) 的更新过程。约定分支地址记为 \(B\)(Branch 的首字母),目的地址记为 \(T\)(Target 的首字母),用 \(B[i]\) 表示分支地址从低到高第 \(i\) 位(下标从 0 开始)的值,\(T[i]\) 同理。假设 \(\mathrm{footprint}\) 的每一位都由若干个 \(B[i]\)\(T[i]\) 通过异或运算得到。

分支指令本身占用了多个字节,那么分支地址指的是哪一个字节的地址呢?

经过测试,AMD64 架构下,分支地址用的是分支指令最后一个字节的地址,而 ARM64 架构下,分支地址用的是分支指令第一个字节的地址。这大概是因为 AMD64 架构下分支指令是变长的,并且可以跨越页的边界;ARM64 则是定长的,并且不会跨越页的边界。

设计以下程序来推测某个 \(B[i]\) 如何参与 \(\mathrm{footprint}\) 的计算:

  1. 根据上面的分析,Apple M1 Firestorm 最多可以记录最近 100 条分支的历史信息,为了让 \(\mathrm{PHR}\) 进入一个稳定的初始值,执行 100 个无条件分支;
  2. 设计两条分支指令,第一条是条件分支,按 50% 的概率跳或不跳;第二条是无条件分支;这两条分支的分支地址只在 \(B[i]\) 上不同,其余的位都相同,目的地址相同;
  3. 执行若干条无条件分支,目的是把 \(B[i]\)\(\mathrm{PHR}\) 的贡献向前移动;
  4. 执行一条条件分支指令,其跳转方向与第二步中条件分支的方向一致。

对应的代码如下:

// step 1. // 100 jumps forward goto jump_0; jump_0: goto jump_1; // ... jump_98: goto jump_99; jump_99:  // step 2. int d = rand(); // the follow two branches differ in B[i] // first conditional branch, 50% taken or not taken if (d % 2 == 0) goto target; // second unconditional branch else goto target; target:  // step 3. // variable number of jumps forward goto varjump_0; varjump_0: goto varjump_1; // ... varjump_k: goto last;  // step 4. // conditional branch last: if (d % 2 == 0) goto end; end: 

第二步中条件分支跳转与否,会影响分支历史中 \(B[i]\) 一个位的变化,它会经过哈希函数,影响 \(\mathrm{footprint}\),进而异或到 \(\mathrm{PHR}\) 中。通过调整第三步执行的无条件分支个数,可以把 \(B[i]\)\(\mathrm{PHR}\) 的影响左移到不同的位置。如果 \(B[i]\)\(\mathrm{PHR}\) 造成了影响,就可以正确预测最后一条条件分支指令的方向。当左移次数足够多时,\(B[i]\)\(\mathrm{PHR}\) 的贡献会变为零,此时对最后一条条件分支指令的方向预测只有 50% 的正确率。在 Apple M1 Firestorm 上测试,得到如下结果:

横坐标 Dummy branches 指的是上面第三步插入的无条件分支的个数,纵坐标 Branch toggle bit 代表修改的是具体哪一个 \(B[i]\),颜色对应分支预测的错误率,浅色部分对应最后一条分支只能正确预测 50%,深色部分对应最后一条分支总是可以正确预测。

从这个图可以得到什么信息呢?首先观察 \(B[2]\) 对应的这一行,可以看到它确实参与到了 \(\mathrm{PHR}\) 的计算中,但是仅仅经过 28 次移位,这个贡献就被移出了 \(\mathrm{PHR}\),为了保留在 \(\mathrm{PHR}\) 内,最多移动 27 次。类似地,在移出 \(\mathrm{PHR}\) 之前,\(B[3]\) 最多移动 26 次,\(B[4]\) 最多移动 25 次,\(B[5]\) 最多移动 24 次。

但实际上,这些 \(B\) 是同时进入 \(\mathrm{PHR}\) 的:这暗示它们对应 \(\mathrm{footprint}\) 的不同位置。如果某个 \(B[i]\) 出现在 \(\mathrm{footprint}\) 更高位的地方,它也会进入 \(\mathrm{PHR}\) 更高位,经过更少的移位次数就会被移出 \(\mathrm{PHR}\);反之,如果 \(B[i]\) 出现在 \(\mathrm{footprint}\) 更低位的地方,它能够在 \(\mathrm{PHR}\) 中停留更长的时间。

根据上面的实验,可见 \(B[5], B[4], B[3], B[2]\) 参与到了 \(\mathrm{footprint}\) 计算中,而 \(B\) 的其他位则没有。但比较奇怪的是,\(\mathrm{PHR}\) 理应可以记录最近 100 条分支的信息,但实际上只观察到了 28。所以一定还有其他的信息。

目的地址 T 的贡献

刚刚测试了 \(B\),接下来测试 \(T\) 各位对 \(\mathrm{PHR}\) 的贡献,方法类似:

  1. 为了让 \(\mathrm{PHR}\) 进入一个稳定的初始值,执行 100 个无条件分支;
  2. 设计一个间接分支,根据随机数,随机跳转到两个不同的目的地址,这两个目的地址只在 \(T[i]\) 上不同,其余的位都相同,分支地址相同;
  3. 执行若干条无条件分支,目的是把 \(T[i]\)\(\mathrm{PHR}\) 的贡献向前移动;
  4. 执行一条条件分支指令,其跳转方向取决于第二步中间接分支所使用的随机数。

对应的代码如下:

// step 1. // 100 jumps forward goto jump_0; jump_0: goto jump_1; // ... jump_98: goto jump_99; jump_99:  // step 2. int d = rand(); // indirect branch // the follow two targets differ in T[i] auto targets[2] = {target0, target1}; goto targets[d % 2]; target0: // add many nops target1:  // step 3. // variable number of jumps forward goto varjump_0; varjump_0: goto varjump_1; // ... varjump_k: goto last;  // step 4. // conditional branch last: if (d % 2 == 0) goto end; end: 

在 Apple M1 Firestorm 上测试,得到如下结果:

为了测试 T[31],岂不是要插入很多个 NOP,一方面二进制很大,其次还要执行很长时间?

是的,所以这里在测试的时候,采用的是类似 JIT 的方法,通过 mmap MAP_FIXED 在内存中特定位置分配并写入代码,避免了用汇编器生成一个巨大的 ELF。同时,为了避免执行大量的 NOP,考虑到前面已经发现 \(B[6]\) 或更高的位没有参与到 \(\mathrm{PHR}\) 计算中,所以可以添加额外的一组无条件分支来跳过大量的 NOP,它们的目的地址相同,分支地址低位相同,因此对 PHR 不会产生影响。对应的代码如下:

// step 1. // 100 jumps forward goto jump_0; jump_0: goto jump_1; // ... jump_98: goto jump_99; jump_99:  // step 2. int d = rand(); // indirect branch // the follow two targets differ in T[i] auto targets[2] = {target0, target1}; goto targets[d % 2]; target0: // skip over nops, while keeping B[5:2]=0 goto target2; // add many nops target1: goto target2;  target2:  // step 3. // variable number of jumps forward goto varjump_0; varjump_0: goto varjump_1; // ... varjump_k: goto last;  // step 4. // conditional branch last: if (d % 2 == 0) goto end; end: 

由此我们终于找到了分支历史最长记录 100 条分支的来源:\(T[2]\) 会经过 \(\mathrm{footprint}\) 被异或到 \(\mathrm{PHR}\) 的最低位,然后每次执行一个跳转分支左移一次,直到移动 100 次才被移出 \(\mathrm{PHR}\)。类似地,\(T[3]\) 只需要 99 次就能移出 \(\mathrm{PHR}\),说明 \(T[3]\) 被异或到了 \(\mathrm{PHR}[1]\)。依此类推,可以知道涉及 \(T\)\(\mathrm{footprint} = T[31:2]\),其中 \(T[31:2]\) 代表一个 30 位的数,每一位从高到低分别对应 \(T[31], T[30], \cdots, T[2]\)

小结

那么问题来了,前面测试 \(B\) 的时候,移位次数那么少,明显少于 \(T\) 的移位次数。这有两种可能:

  1. 硬件上只有一个 \(\mathrm{PHR}\) 寄存器,\(T[31:2]\) 被异或到 \(\mathrm{PHR}\) 的低位,而 \(B[5:2]\) 被异或到 \(\mathrm{PHR}\) 的中间位置;
  2. 硬件上有两个 \(\mathrm{PHR}\) 寄存器,其中一个是 100 位,它的 \(\mathrm{footprint} = T[31:2]\),记为 \(\mathrm{PHRT}\);另一个是 28 位,它的 \(\mathrm{footprint} = B[5:2]\),记为 \(\mathrm{PHRB}\)

经过后续的测试,基本确认硬件实现的是第二种。用数学公式表达:

\(\mathrm{PHRT}_{\mathrm{new}} = (\mathrm{PHRT}_{\mathrm{old}} \ll 1) \oplus \mathrm{T}[31:2]\)

\(\mathrm{PHRB}_{\mathrm{new}} = (\mathrm{PHRB}_{\mathrm{old}} \ll 1) \oplus \mathrm{B}[5:2]\)

有意思的是,在我的论文发表后不久,Apple 公开的专利 Managing table accesses for tagged geometric length (TAGE) load value prediction 中就出现了相关表述,证明了逆向结果的正确性。

按照这个方法,我还逆向工程了 Apple、Qualcomm、ARM 和 Intel 的多代处理器的分支历史记录方法,并进行了公开,供感兴趣的读者阅读,也欢迎读者将测试代码移植到更多处理器上,并贡献逆向工程的结果。

TAGE 表的逆向

接下来,我们将目光转向 TAGE 表的逆向工程。TAGE 表与缓存结构类似,也是一个多路组相连的结构,通过 index 访问若干路,然后对每一路进行 tag 匹配,匹配正确的那一路提供预测。TAGE 在预测时,输入是历史寄存器,即上面逆向得到的 \(\mathrm{PHRT}\)\(\mathrm{PHRB}\),以及分支地址,目前这两个输入都是可控的。为了避免多个表同时提供预测,首先逆向工程使用分支历史最长的表的参数:它的容量是多少,index 如何计算,tag 如何计算,以及几路组相连。

如何确保使用分支历史最长的表提供预测呢?其实还是利用分支历史的特性,将随机数注入到 \(PHRT\) 中,例如前面的间接分支,让两个目的地址只在 \(T[2]\) 上不同:

// add some unconditional jumps to reset phr to some constant value // 100 jumps forward goto jump_0; jump_0: goto jump_1; // ... jump_98: goto jump_99; jump_99:  // inject int d = rand(); // indirect branch // the follow two targets differ in T[2] auto targets[2] = {target0, target1}; goto targets[d % 2]; target0: // add nop here target1:  // add some unconditional jumps to shift the injected bit left goto varjump_0; varjump_0: goto varjump_1; // ... varjump_k: goto last; last: 

根据前面的分析,\(T[2]\) 会被异或到 \(\mathrm{PHRT}\) 的最低位上,每执行一次无条件分支,就左移一位。因此,通过若干个无条件分支,可以把 d % 2 这个随机数注入到 \(\mathrm{PHRT}\) 的任意一位上。之后我们还会很多次地进行这种随机数的注入。

把随机数注入到 \(\mathrm{PHRT}\) 高位以后,再预测一个根据随机数跳转或不跳转的分支,就可以保证它只能由使用分支历史最长的表来进行预测。

逆向工程 PC 输入

首先,我们希望推断 PC 如何参与到 index 或 tag 计算中。通常,TAGE 只会采用一部分 PC 位参与 index 或 tag 计算。换句话说,如果两个分支在 PC 上不同的部分没有参与 index 或 tag 计算,那么 TAGE 无法区分这两条分支。如果这两个分支跳转方向相反,并且用相同的 PHR 进行预测,那么一定会出现错误的预测。思路如下:

  1. 用 100 个无条件分支,保证 PHR 变成一个确定的值;
  2. 注入随机数 d % 2 到 PHRT,并移动到高位(例如 \(PHRT[99]\)),使用前面所述的方法;
  3. 执行两个条件分支,它们在分支地址上只有一位 \(PC[i]\) 不同,它们的跳转条件相反,当第一个条件分支不跳转的时候,会执行第二个条件分支,它总是会跳转。

对应代码类似于:

// step 1. inject phrt int d = rand(); inject_phrt(d % 2, 99);  // step 2. a pair of conditional branches with different direction // their PC differs in one bit if (d % 2 == 0) goto end; if (d % 2 == 1) goto end;  end: 

经过测试,PC 的输入是 \(PC[18:2]\),其余的没有。

逆向工程相连度和 index 函数的 PC 输入

接下来是比较复杂的一步,同时逆向工程表的相连度和 index 函数的 PC 输入。这是因为这两部分是紧密耦合的:只有知道相连度,才能知道预测出来的分支数对应几个 set;但不知道 index 函数,又无法控制分支被分配到几个 set 中。首先,为了避免 PHR 的干扰,还是只注入一个随机数到 \(PHRT[99]\) 上(事实上,\(PHRT[99]\) 不是随便选择的,而是需要在 index 函数中,但通过测试可以找到满足要求的位)。其次,构造一系列分支,它们的地址满足:第 i 条分支(i 从 0 开始)的分支地址是 \(i2^k\),其中 \(k\) 是接下来要遍历的参数。当 \(k=3\) 时,分支会被放到 0x0, 0x8, 0x10, 0x18, 0x20 等地址,涉及的 PC 位数随着分支数的增加而增加。接下来,我们分类讨论:

  • 假如涉及的 PC 位都在 tag 中,没有出现在 index 中:那么这些分支都会被映射到同一个 set 内,一旦分支数量超出相连度,就会出现预测错误。
  • 假如涉及的 PC 位有一部分出现在 index 中:那么每有一个 PC 位出现在 index 中,这些分支可以被分配到的 set 数量就翻倍,直到这些 set 都满了以后,才会出现预测错误。
  • 假如涉及的 PC 位有一部分超出 PC 输入的范围(如前面逆向工程得到的 \(PC[18:2]\)):那么超出输入的部分地址会被忽略,使得 set 内出现冲突。

实验结果如下图:

纵坐标就是上面的 \(k\),横坐标是测试的条件分支数,颜色表示预测的错误率。当颜色从深色变浅,就说明出现了预测错误。观察:

  • \(PC[3]\) 的情况下,只能预测 4 个分支,而 \(PC[4]\)\(PC[5]\) 可以预测 8 个分支,暗示了四路组相连,然后 \(PC[4]\)\(PC[5]\) 对应到了两个 set,所以能够正确预测 8 个分支。
  • \(PC[6]\) 的情况下,可以预测 16 个分支,对应 4 个 set;后续 \(PC[7]\)\(PC[8]\) 又可以预测 8 个分支,对应 2 个 set;意味着 \(PC[6]\) 在 index 中,给 \(PC[4]\)\(PC[5]\) 提供了两倍的 set;\(PC[9]\) 在 index 中,给 \(PC[6]\)\(PC[7]\)\(PC[8]\) 提供了两倍的 set。
  • 后续更高的 PC 位,没有受到 index 函数的影响,因此都是 4,直到最后超出 PC 输入范围。

这就说明它是四路组相连,PC[6] 和 PC[9] 参与到了 index 函数中。

下面给读者一个小练习,下面是在 Qualcomm Oryon 上测得的结果,可以看到噪声比较大,你能推断出它是几路组相连,有哪些 PC 参与到了 index 计算吗?

揭晓答案

四路组相连,\(PC[6]\)\(PC[7]\) 参与到了 index 函数。

那么,这种测试是怎么构造的呢?即需要用相同的 PHR 去预测 \(PC=i2^k\) 的多条分支。思路比较复杂:

  1. 首先执行一条间接分支,目的地址是 \(i2^{k-1}\),那么它对 PHRT 的贡献是 \(\mathrm{PHRT}_1 = (\mathrm{PHRT}_0 \ll 1) \oplus (i2^{k-3})\)
  2. 接下来,在 \(i2^{k-1}\) 的位置,再执行一条直接分支,目的地址是 \(i2^k\),那么它对 PHRT 的贡献是 \(\mathrm{PHRT}_2 = (\mathrm{PHRT}_1 \ll 1) \oplus (i2^{k-2}) = (((\mathrm{PHRT}_0 \ll 1) \oplus (i2^{k-3})) \ll 1) \oplus (i2^{k-2}) = \mathrm{PHRT}_0 \ll 2\)

可见经过两步以后,PHRT 是保持不变的。针对 PHRB,只要 \(i2^{k-1}\) 没有涉及 \(PC[5:2]\),就能保证相同。那么如果 \(k\) 足够小,也有办法:

  1. 首先执行一条间接分支,目的地址是 \(i2^{k-1}\)
  2. 接下来执行大量的 NOP,使得 \(B\) 的低位等于 0,然后再执行一条间接分支,目的地址是 \(i2^k\)

因此我们总是可以通过两次分支,实现用相同的 PHR 预测不同 PC 上的多条分支。

逆向工程 tag 函数

接下来,进行 tag 函数的逆向工程。为了逆向工程 tag 函数,我们希望找到两个位在 tag 函数中有异或关系,那么如果这两个位同时设为 0,或者同时设为 1,其异或结果都等于 0,使得计算出来的 tag 函数相同,如果此时 index 还相同,那么预测器就无法区分这两种情况。

为了利用这一点,生成两个 0 到 1 的随机数 \(k\)\(l\),分别把它们注入到 PC、PHRB 或者 PHRT 中,去预测一个条件分支,其跳转与否取决于 \(k\) 的值(论文中有个小 typo)。如果 \(k\)\(l\) 在 tag 函数中有异或关系,那么预测器总会预测错误。

实验结果大致如下,横纵坐标表示注入哪一个位,颜色代表预测错误率,深色意味着预测错误,也就是找到了一组异或关系:

其中有一些异或关系,因为对应的位在 index 中出现的缘故,导致没有显现出来。根据已知的异或关系外推,可以得到如下的 tag 计算公式:

  • PC[7] xor PHRT[0,12,...,96] xor PHRB[8,21]
  • PC[8] xor PHRT[1,13,...,97] xor PHRB[9,22]
  • PC[9] xor PHRT[2,14,...,98] xor PHRB[10,23,24]
  • PC[10] xor PHRT[3,15,...,87,99] xor PHRB[11,12,25]
  • PC[11] xor PHRT[4,16,...,88] xor PHRB[0,13,26]
  • PC[12] xor PHRT[5,17,...,89] xor PHRB[1,14,27]
  • PC[13] xor PHRT[6,18,...,90] xor PHRB[2,15]
  • PC[14] xor PHRT[7,19,...,91] xor PHRB[3,16]
  • PC[15] xor PHRT[8,20,...,92] xor PHRB[4,17]
  • PC[16] xor PHRT[9,21,...,93] xor PHRB[5,18]
  • PC[17] xor PHRT[10,22,...,94] xor PHRB[6,19]
  • PC[18] xor PHRT[11,23,...,95] xor PHRB[7,20]
  • PC[2:5]: 单独出现,不和其他位异或

那么,这里是怎么实现针对 PC、PHRT 和 PHRB 的注入的呢?针对 PHRT 的注入前面已经提到过,只需要一个间接分支:

// target differs in T[2] auto targets[2] = {target0, target1}; goto targets[d % 2]; target0: // add nop target1: 

PHRB 的注入就比较复杂了,例如要注入 \(B[2]=k\),我们需要进行三次分支的跳转:

  1. 第一次跳转,\(B\) 相同,\(T[2]=k\)
  2. 第二次跳转,\(B[2]=k\)\(T[3]=k\)
  3. 第三次跳转,\(B[2]=B[3]=k\), \(T\) 相同。

经过计算,可以发现前两次跳转对 PHRT 的抵消,第二次跳转的 \(B[2]\) 与第三次跳转的 \(B[3]\) 抵消,最后相当于只有最后一次跳转的 \(B[2]=k\) 对 PHRB 产生了贡献。

最复杂的是 PC 的注入,这次需要分情况讨论:

第一种情况是,要注入的 PC 位比较高,具体来说,是 \(PC[7]\) 或者更高的位数,此时我们可以很容易地避免引入对 PHRB 的贡献,因为它只考虑 \(B[5:2]\)

  1. 第一次跳转,\(B\) 相同,\(T[6]=k\)
  2. 第二次跳转,\(B[6]=k\) 但对 PHRB 没有贡献,\(T[7]=k\)

那么两次跳转完以后,PHRB 不变,PHRT 的贡献被抵消掉,同时实现了 \(PC[7]=k\) 的注入。

第二种情况是,要注入的正好是 \(PC[6]\),继续用上面的方法会发现 PHRB 无法抵消,这时候,需要引入第三次跳转:

  1. 第一次跳转,\(B\) 相同,\(T[3]=k\)
  2. 第二次跳转,\(B[3]=k\)\(T[5]=T[4]=k\)
  3. 第三次跳转,\(B[4]=k\)\(T[6]=k\)

验算可以发现,PHRB 和 PHRT 的贡献全都被抵消,成功注入 \(PC[6]=k\)

最后来看要注入的 PC 更低的情况,例如要注入 \(PC[3]=k\),还是用三次跳转:

  1. 第一次跳转,\(B\) 相同,\(T[2]=k\)
  2. 第二次跳转,\(B[2]=k\)\(T[2]=T[3]=k\)
  3. 第三次跳转,\(B[3]=k\)\(T[3]=k\)

这样就成功注入 \(PC[3]=k\)

那么 PC 的注入就完成了,只有 \(PC[2]\) 没有找到合适的方法来注入。

逆向工程 index 函数

相连度和 tag 函数已知,接下来,让我们逆向工程最后的 index 函数。逆向工程的思路如下:

  1. 通过前面的逆向工程,发现 \(PC[5:2]\) 是独立出现在 tag 函数中,并且没有出现在 index 中,所以我们可以构造出多个分支,它们的 tag 不同;
  2. 进一步,构造两组条件分支,每组都有四个条件分支,因为是四路组相连,如果这两组分别映射到两个 set 中,就可以正确预测;反之,如果被映射到同一个 set 中,就会预测错误;
  3. 和之前类似,向 PC、PHRB 或 PHRT 注入两个随机数 \(k\)\(l\),然后预测两组共八个条件分支,这些条件分支的跳转方向都是 k xor l
  4. 如果 \(k\)\(l\) 注入的位同时出现在 index 函数中,但是没有异或关系,那么这八个条件分支可以正确地被预测;
  5. 如果 \(k\)\(l\) 注入的位同时出现在 index 函数中,并且有异或关系,那么这八个条件分支会被映射到同一个 set 内,最多只能正确预测其中四个分支;
  6. 如果 \(k\)\(l\) 注入的位至少出现一个在 tag 函数中,那么一个分支会在同一个 set 内占用两项,导致最多只能正确预测其中两个分支。

注入 \(PC[9]\)\(PHRT[i]\)实验结果如下:

结合上面的讨论,可以知道:

  • PC[9] 和 PHRT[38] 和 PHRT[88] 有异或关系
  • PHRT[2]、PHRT[12]、PHRT[17]、PHRT[22]、PHRT[27]、PHRT[33]、PHRT[43]、PHRT[53]、PHRT[58]、PHRT[63]、PHRT[68]、PHRT[73]、PHRT[78]、PHRT[83]、PHRT[93] 在 index 中,但是和 PC[9] 没有异或关系

实际上还有 PHRT[7] 和 PHRT[48] 也在 index 中,但实际上测试的时候为了保证历史最长的表提供预测,还额外注入了 PHRT[99],它与 PHRT[7] 和 PHRT[48] 有异或关系,所以在上图没有显现出来。

用类似的方法,去测试 PHRT 与 PHRT、PHRT 与 PHRB、PHRB 与 PHRB 之间的异或关系,就可以找到最终的 index 函数:

  • PHRT[2] xor PHRT[43] xor PHRT[93]
  • PHRT[7] xor PHRT[48] xor PHRT[99]
  • PHRT[12] xor PHRT[63] xor PHRB[5]
  • PHRT[17] xor PHRT[68] xor PHRB[10]
  • PHRT[22] xor PHRT[73] xor PHRB[15]
  • PHRT[27] xor PHRT[78] xor PHRB[20]
  • PHRT[33] xor PHRT[83] xor PHRB[25]
  • PHRT[38] xor PHRT[88] xor PC[9]
  • PHRT[53] xor PHRT[58] xor PHRB[0]
  • PC[6]

至此使用分支历史最长的表的逆向就完成了。接下来讨论一下,如何逆向工程分支历史更短的表。

逆向工程使用分支历史更短的 TAGE 表

前面提到,TAGE 在预测时,会选取提供预测的多个表中使用历史最长的那个表。那么,如果要测试使用历史第二长的表,应该怎么办呢?我们尝试了以下方法:

  • 在使用历史更长的表里,预先插入一些表项,再去测试历史较短的表的情况,由于 TAGE 会利用它的 useful 计数器来进行新表项的分配,当历史更长的表里的表项的 useful 不为零,可以防止它被覆盖成新的内容,逼迫 TAGE 用历史更短的表来进行预测;
  • 把多个表当成一个整体来考虑,比如在测试能够正常预测的分支数量的时候,得到的是多个表叠加的结果,再减去已知数量,可以得到历史比较短的表的信息;
  • 在超出要测试的表的历史部分,注入大量随机数,例如要测试的一个表,只用到了历史的低 57 位,那就在更高的部分注入大量的随机数,那么历史最长的表能够提供预测的概率会非常小,从而逼迫 TAGE 用当前被测试的表来做预测。

通过这些方法,我们成功地逆向出了 Firestorm 的剩下的 TAGE 表的信息:

有兴趣的读者可以试着自己复现一下,看看能不能得到对应的实验结果,然后从结果中分析出硬件的参数。有意思的是,我们逆向出来 Qualcomm Oryon 的分支预测器的大小(6 个表一共 80KB 的空间),与官方在 Hot Chips 上公开的是一致的。

总结

我们通过一系列方法,实现了对 Apple M1 Firestorm 的条件分支预测器的逆向,并且成功地把它应用到了设计基本一样的 Qualcomm Oryon 处理器上,为后续的研究提供了基础。

引用文献

本博客近三个月来的访问数据观察

2025-10-09 08:00:00

本博客近三个月来的访问数据观察

写在前面

这个博客自 2014 年更新至今,已走过近十一个年头,累计发布了四百多篇文章。出于好奇,我一直想了解哪些内容更受读者欢迎。五年前,我曾配置过 Google Analytics,但使用体验并不理想,于是转而自行部署了 rybbit 实例来收集访问数据。如今三个月过去,是时候与大家分享一些有趣的发现。

P.S. 如果你对数据收集有所顾虑,可以屏蔽对应的 analytics 脚本。

数据总览与趋势

首先来看这三个月内的整体访问情况与趋势:

访问量比我的预期要高一些。虽然这些年写了不少内容,但并没有刻意宣传,主要依赖搜索引擎推荐和读者的订阅与转发。从时间趋势上可以看出两个明显特点:

  1. 工作日访问量显著高于周末,通常为周末的两到三倍;
  2. 开学后工作日的访问量比暑假期间又高出一倍,但周末依然低迷。

由此推测,学生读者占比较高。结合国庆假期访问量的下降来看,国内读者仍是主力。下面是各国家与地区的访问分布:

值得一提的是,也有不少海外读者访问,看来近年来有意识地撰写英文内容确实产生了效果。

以 UTC+8 时区为基准,从访问时间分布中可以看出大家的作息习惯:

尽管大家可能习惯熬夜,但深夜阅读博客的并不多,多数访问集中在工作时间。

接下来是基于 User Agent 的统计。首先是浏览器分布:

不出所料,Chrome 及基于 Chromium 内核的浏览器占据主流,Firefox 和 Safari 占比不高。我本人目前也在使用 Firefox,希望它能持续发展,避免 Chrome 一家独大。

操作系统分布如下:

Windows 占比最高,macOS 次之。考虑到博客内容主要涉及计算机技术,这也大致反映了相关从业人员的偏好。

以下是访问次数较多的几篇文章:

这个排名有些出乎我的意料。这几篇文章在撰写时并未特别考虑入门读者的理解难度或内容的丰富性。或许是因为它们涉及的领域资料较少,因此在相关关键词搜索中容易被找到。这一点在 Google Search Console 中也得到了印证:

相比之下,一些精心准备的文章阅读量并不高,可能是因为所在领域已有较多优质内容,新文章难以脱颖而出。这也说明文章质量与阅读量之间并非简单的正比关系。

我还注意到一些重度用户,他们不仅阅读多篇文章,且停留时间较长:

  • 未知用户一:阅读了分支预测、乱序执行、CPU 微架构分析等文章,推测可能是刚开始研究 CPU 微架构的同行;
  • 未知用户二:浏览了 wishbone、乱序执行相关内容,猜测是学习清华计算机组成原理课程的学生;
  • 未知用户三:从华为相关文章进入,随后浏览了其他内容,可能是搜索华为内容进入博客后,被其他文章吸引的读者;
  • 未知用户四:阅读了 ARM/Samsung/Intel 等处理器微架构分析文章,又一位同行,但有一定基础,更加关注业界。

类似的例子还有不少,在此不一一列举。尽管 CPU 相关文章的阅读量不及软件类内容,但能得到这么多同行的关注,确实令人欣慰——毕竟这本身就是一个小众领域,不能奢求过高的阅读量。

通过这次数据分析,我收获了不少有趣的观察。未来可能会不定期更新类似内容,看看随着时间推移,是否会有新的发现。

最后,如果你对访问数据的收集感到不适,可以直接屏蔽 analytics 脚本(或许你的浏览器插件已经这样做了)。根据 rybbit 的官方说明,其信息收集方法较为尊重用户隐私,我也没有对代码进行任何修改,不放心的读者可以阅读 rybbit 的源码来审计。

P.S. 你能看出这篇文章是,我先写了一遍,然后让大模型润色的结果吗?