MoreRSS

site iconJiaJe | 杰哥修改

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

Inoreader Feedly Follow Feedbin Local Reader

JiaJe | 杰哥的 RSS 预览

ARM Neoverse V1 (代号 Zeus) 的 BTB 结构分析

2025-06-23 08:00:00

ARM Neoverse V1 (代号 Zeus) 的 BTB 结构分析

背景

ARM Neoverse V1 是 ARM Neoverse N1 的下一代服务器 CPU,之前我们分析过 Neoverse N1 的 BTB 设计。而 ARM Neoverse V1 在很多地方都和 Cortex-X1 类似,相比 Neoverse N1/Cortex-A76 有了一些改进,在这里对它的 BTB 做一些分析。

官方信息

首先收集了一些 ARM Neoverse V1 的 BTB 结构的官方信息:

简单整理一下官方信息,大概有两级 BTB:

  • 96-entry nano BTB, 1 cycle latency (0 cycle bubble)
  • 8K-entry main BTB
  • 2 predicted branches per cycle

但是很多细节是缺失的,因此下面结合微架构测试,进一步研究它的内部结构。

微架构测试

在之前的博客里,我们已经测试了各种处理器的 BTB,在这里也是一样的:按照一定的 stride 分布无条件(uncond)或总是跳转的有条件(cond)直接分支,构成一个链条,然后测量 CPI。在先前的 Neoverse N1 测试 里,我们只测试了无条件分支,但实际上,在 Neoverse N1 上用条件分支测出来的结果也是一样的,但在 Neoverse V1 上就不同了,所以在这里要分开讨论。

stride=4B uncond

首先是 stride=4B uncond 的情况:

可以看到,图像上出现了如下比较显著的台阶:

  • 第一个台阶到接近 64 条分支,CPI=1,对应了 96-entry 的 nano BTB,但是没有体现出完整的 96 的容量
  • 第二个台阶到 16384 条分支,CPI 在 5 到 6 之间,大于 main BTB 的 2 cycle latency,说明此时没有命中 main BTB,而是要等到取指和译码后,计算出正确的目的地址再回滚,导致了 5+ cycle latency;16384 对应 64KB L1 ICache 容量

那么 stride=4B uncond 的情况下就遗留了如下问题:

  1. nano BTB 没表现出 96 的容量,只表现出接近 64 的容量
  2. 没有观察到 2 predicted branches per cycle
  3. 没有命中 main BTB

stride=4B cond

stride=4B cond 的情况:

可以看到,图像上出现了如下比较显著的台阶:

  • 第一个台阶到 48 条分支,CPI=1,对应了 96-entry 的 nano BTB,但是没有体现出完整的 96 的容量
  • 之后没有明显的分界点,性能波动剧烈,没有观察到 main BTB 的台阶

nano BTB 只表现出 48 的容量,刚好是 96 的一半;同时没有观察到 2 predicted branches per cycle。考虑这两点,可以认为 nano BTB 的组织方式和分支类型有关,当分支过于密集(stride=4B)或者用条件分支(cond)时,不能得到完整的 96-entry 的大小,此时也会回落到 CPI=1 的情况。

那么 stride=4B cond 的情况下就遗留了如下问题:

  1. 没有命中 main BTB

stride=8B uncond

stride=8B uncond 的情况:

可以看到,图像上出现了如下比较显著的台阶:

  • 第一个台阶到 96 条分支,CPI=0.5,对应了 96-entry 的 nano BTB,体现了 2 predicted branches per cycle
  • 第二个台阶到 8192 条分支,CPI=1,对应 main BTB,此时也对应了 64KB L1 ICache;此外,从 4096 开始有略微的上升

此时 nano BTB 完整地表现出了它的 96-entry 容量,并且实现了 CPI=0.5 的效果。main BTB 也实现了 CPI=1,考虑到它的容量不太可能单周期给出一个分支的结果,大概率是两个周期预测两条分支指令。

那么 stride=8B uncond 的情况下就遗留了如下问题:

  1. 从 4096 条分支开始性能有略微的下降

stride=8B cond

stride=8B cond 的情况:

可以看到,图像上出现了如下比较显著的台阶:

  • 第一个台阶到 48 条分支,CPI=1,对应了 96-entry 的 nano BTB,没有 2 predicted branches per cycle,容量也只有 96 的一半
  • 第二个台阶到 8192 条分支,CPI=2,对应 main BTB,此时也对应了 64KB L1 ICache

和之前一样,遇到 cond 分支,nano BTB 的容量只有一半,也观察不到 2 predicted branches per cycle。另一边,main BTB 的 CPI 也到了 2,意味着此时 main BTB 也只能两个周期预测一条分支指令,和之前的分析吻合。

那么为什么用条件分支,就不能预测两条分支指令了呢?猜测是,BTB 可以一次给出两条分支的信息,但是没有时间去同时预测这两条分支的方向。所以就回落到了普通的 2 cycle BTB 情况。

stride=16B uncond

stride=16B uncond 的情况:

可以看到,图像上出现了如下比较显著的台阶:

  • 第一个台阶到 96 条分支,CPI=0.5,对应了 96-entry 的 nano BTB,体现了 2 predicted branches per cycle
  • 第二个台阶到 2048 条分支,CPI=1;略微上升到 4096,此时是 64KB L1 ICache 的容量;到 8192 出现明显突变,对应 main BTB 容量

那么 stride=16B uncond 的情况下就遗留了如下问题:

  1. 从 2048 条分支开始性能有略微的下降

stride=16B cond

stride=16B cond 的情况:

可以看到,图像上出现了如下比较显著的台阶:

  • 第一个台阶到 48 条分支,CPI=1,对应了 96-entry 的 nano BTB,没有 2 predicted branches per cycle,容量也只有 96 的一半
  • 第二个台阶到 8192 条分支,CPI=2,对应 main BTB

预测的效果和 stride=8B cond 完全相同。

那么 stride=16B cond 的情况下就遗留了如下问题:

  1. 64KB ICache 应该在 4096 条分支导致瓶颈,但是实际没有观察到

stride=32B uncond

stride=32B uncond 的情况:

可以看到,图像上出现了如下比较显著的台阶:

  • 第一个台阶到 96 条分支,CPI=0.5,对应了 96-entry 的 nano BTB,体现了 2 predicted branches per cycle
  • 第二个台阶到 1024 条分支,CPI=1;略微上升到 2048,此时是 64KB L1 ICache 的容量;到 8192 右侧出现明显突变

那么 stride=32B uncond 的情况下就遗留了如下问题:

  1. 从 1024 条分支开始性能有略微的下降
  2. 性能明显下降的点在 8192 右侧,而不是 8192

stride=32B cond

stride=32B cond 的情况:

可以看到,图像上出现了如下比较显著的台阶:

  • 第一个台阶到 48 条分支,CPI=1,对应了 96-entry 的 nano BTB,没有 2 predicted branches per cycle,容量也只有 96 的一半
  • 第二个台阶到 2048 条分支,CPI=2,对应 64KB L1 ICache 容量,之后缓慢上升,到 8192 出现性能突变,对应 main BTB 容量

基本符合预期,只是在 stride=16B cond 的基础上,引入了 64KB L1 ICache 导致的性能下降。

stride=64B uncond

stride=64B uncond 的情况:

可以看到,图像上出现了如下比较显著的台阶:

  • 第一个台阶到 96 条分支,CPI=0.5,对应了 96-entry 的 nano BTB,体现了 2 predicted branches per cycle
  • 第二个台阶到 1024 条分支,CPI=1,对应 64KB L1 ICache 的容量
  • 第三个台阶到 4096,CPI=3,对应 main BTB 的容量;main BTB 容量减半,意味着 main BTB 应当是个组相连结构

stride=64B cond

stride=64B cond 的情况:

可以看到,图像上出现了如下比较显著的台阶:

  • 第一个台阶到 48 条分支,CPI=1,对应了 96-entry 的 nano BTB,没有 2 predicted branches per cycle,容量也只有 96 的一半
  • 第二个台阶到 1024 条分支,CPI=2,对应 64KB L1 ICache 容量,之后缓慢上升,到 4096 出现性能突变,对应 main BTB 容量;main BTB 容量只有 8192 的一半,意味着它是组相连结构

小结

测试到这里就差不多了,更大的 stride 得到的也是类似的结果,总结一下前面的发现:

  • nano BTB 是 96-entry,1 cycle latency,对于 uncond 分支可以做到一次预测两条分支,大小不随着 stride 变化,对应全相连结构
  • main BTB 是 8K-entry,2 cycle latency,对于 uncond 分支可以做到一次预测两条分支,此时可以达到 CPI=1;容量随着 stride 变化,对应组相连结构
  • 64KB ICache 很多时候会比 main BTB 更早成为瓶颈

也总结一下前面发现了各种没有解释的遗留问题:

  • cond 分支情况下,没有 2 predicted branches per cycle,此时两级 BTB 分别可以做到 CPI=1 和 CPI=2,同时 nano BTB 容量减半到 48:解释见后
  • stride=4B uncond/cond 的情况下,main BTB 没有像预期那样工作:解释见后
  • stride=8B/16B/32B uncond 的情况下,4096/2048/1024 条分支处出现了性能下降:暂无解释
  • stride=32B uncond 的情况下,main BTB 导致的拐点应该在 8192,但实际上在 8192 右侧:暂无解释
  • stride=16B cond 的情况下,64KB ICache 应该在 4096 条分支导致瓶颈,但是实际没有观察到:暂无解释

接下来尝试解析一下这些遗留问题背后的原理。部分遗留问题,并没有被解释出来,欢迎读者提出猜想。

解析遗留问题

cond 分支情况下,没有 2 predicted branches per cycle,同时 nano BTB 只有 48 的容量

比较相同 stride 下,cond 和 uncond 的情况,可以看到,cond 情况下两级 BTB 的 CPI 都翻倍,这意味着,当遇到全是 cond 分支时,大概是因为条件分支预测器的带宽问题,不能一次性预测两个 cond 分支的方向,而只能预测第一条 cond 分支,那么第二条 cond 分支的信息,即使通过 BTB 读取出来,也不能立即使用,还得等下一次的预测。

为了验证这个猜想,额外做了一组实验:把分支按照 cond, uncond, cond, uncond, ... 的顺序排列,也就是每个 cond 分支的目的地址有一条 uncond 分支。此时测出来的结果,和 uncond 相同,也就是可以做到 2 predicted branches per cycle。此时,BTB 依然一次提供了两条分支的信息,只不过条件分支预测器只预测了第一个 cond 的方向。如果它预测为不跳转,那么下一个 PC 就是 cond 分支的下一条指令;如果它预测为跳转,那么下一个 PC 就是 uncond 分支的目的地址。

nano BTB 的容量减半,意味着 nano BTB 的 96 的容量,实际上是 48 个 entry,每个 entry 最多记录两条分支。考虑到 nano BTB 的容量不随 stride 变化,大概率是全相连,并且是根据第一条分支的地址进行全相连匹配,这样,在 cond + cond 这种情况下,就只能表现出 48 的容量。但是 stride=4B uncond 的情况下表现出介于 48 和 64 之间的容量,还不知道是什么原因。

main BTB 的容量不变,意味着它在 cond + cond 的情况下,会退化为普通的 BTB,此时所有容量都可以用来保存 cond 分支,并且都能匹配到。

那么,具体是怎么做到 2 predicted branches per cycle 呢?猜测在执行的时候,检测这种一个分支的目的地址后,跟着一条 uncond 分支的情况:如果有的话,就把第二条分支的信息,放在第一条分支的信息后面(这在 Branch Target Buffer Organizations 中被称为 MB-BTB 结构),单个周期直接从 SRAM 读取出来,然后组成两个 fetch bundle:

  • prediction pc -- first branch pc
  • first branch target -- second branch pc

然后下一个周期从 second branch target 开始继续预测。根据官方信息,Neoverse V1 的 L1 ICache 支持 2x32B 的带宽,这个 2x 代表了可以从两个不同的地方读取指令,也就是 L1 ICache 至少是双 bank 甚至双端口的 SRAM。考虑到前面的测试中,CPI=0.5 的范围跨越了各种 stride,认为 L1 ICache 是双 bank 的可能写比较小,不然应该会观测到 bank conflict,大概率就是双端口了。

此外,考虑到 fetch bundle 的长度限制,first branch target 到 second branch pc 不能太远。在上面的测试中,这个距离总是 0;读者如果感兴趣,可以尝试把距离拉长,看看超过 32B 以后,是不是会让 2 predicted branches per cycle 失效。类似的表述,在 AMD Zen 4 Software Optimization Guide 中也有出现:

The branch target buffer (BTB) is a two-level structure accessed using the fetch address of the previous fetch block.Each BTB entry includes information for branches and their targets.Each BTB entry can hold up to two branches, and two pair cases are supported:• A conditional branch followed by another branch with both branches having their last byte in the same 64 byte aligned cacheline.• A direct branch (excluding CALLs) followed by a branch ending within the 64 byte aligned cacheline containing the target of the first branch.Predicting with BTB pairs allows two fetches to be predicted in one prediction cycle.

上面的第二种情况,对应了第二条分支的 pc,在第一条分支的 target 的同一个 64 字节 cacheline 内的要求。可见,ARM 和 AMD 在 BTB 的设计上是趋同的。

小结,Neoverse V1 在满足如下条件时,可以做到 2 predicted branches per cycle:

  • uncond + uncond
  • cond + uncond

stride=4B uncond/cond 的情况下,main BTB 没有像预期那样工作

类似的情况,我们在分析 Neoverse N1 的时候就遇到了。Neoverse N1 的情况是,每对齐的 32B 块内,由于 6 路组相连,最多记录 6 条分支,而 stride=4B 时,有 8 条分支,所以出现了性能问题。

那么 Neoverse V1 是不是还是类似的情况呢?查阅 Neoverse V1 TRM,可以看到它的 L1 (main) BTB 的描述是:

  • Index: [15:4]
  • Data: [91:0]

回想之前 Neoverse N1 的 main BTB 容量:Index 是 [14:5],意味着有 1024 个 set;3 个 Way,每个 Way 里面是 82 bit 的数据,每个分支占用 41 bit,所以一共可以存 1024*3*2=6K 条分支。

类比一下,Neoverse V1 的 main BTB 容量也就可以计算得出:Index 是 [15:4],意味着有 4096 个 set;没有 Way,说明就是直接映射;92 bit 的数据,大概率也是每个分支占用一半也就是 46 bit,所以一共可以存 4096*2=8K 条分支,和官方数据吻合。在需要 2 predicted branches 的时候,就把这两个分支放到同一个 92-bit entry 内即可。

那么,在 stride=4B 的情况下,对齐的 16B 块内的分支会被放到同一个 set 内,而每个 set 只能放两条分支,而 stride=4B 时需要放四条分支,这就导致了 main BTB 出现性能问题。

但比较奇怪的是,main BTB 的容量,在 stride=32B 时是 8192,而 stride=64B 时是 4096,这和 Index 是 PC[15:4] 不符,这成为了新的遗留问题。有一种可能,就是 TRM 写的不准确,Index 并非 PC[15:4]。另外还有一个佐证:Neoverse N2 的 BTB 设计和 Neoverse V1 基本相同,但是它的 TRM 写的 Index 就是 [11:0],这就肯定不是 PC[11:0] 了。

抛开 TRM,根据 JamesAslan 在 偷懒的 BTB?ARM Cortex X1 初探 中的测试,Main BTB 是四路组相连。如果按照四路组相连来考虑,那么 8K 条分支,实际上应该是 2048 个 set,2 个 way,一共是 4K 个 entry,每个 entry 最多保存两条分支。此时 Index 应该有 11 个 bit。在 2 way 每 way 两条分支等效为 4 way 的情况下,stride=4B 出现分支数比 way 数量更多的情况,stride=8B 则不会,意味着参与到 Index 的最低的 PC 应该是 PC[5],即每个对齐的 32B 块内,最多放四条分支(Neoverse N1 上是每个对齐的 32B 块内最多放六条分支)。这样的话,Index 可能实际上是 PC[15:5]。

总结

最后总结一下 Neoverse V1 的 BTB:

  • 48-entry(96 branches) nano BTB, at most 2 branches per entry, 1 cycle latency, at most 2 predicted branches every 1 cycle, fully associative
  • 4K-entry(8K branches) main BTB, at most 2 branches per entry, 2 cycle latency, at most 2 predicted branches every 2 cycles, 2-way(4-branch-way) set-associative, index PC[15:5]

当 uncond + uncond 或者 cond + uncond 时,可以实现每次预测两条分支;对于 cond + cond,每次只能预测一条分支。

2 predicted branches per cycle 通常也被称为 2 taken branches per cycle,简称 2 taken。

附录

Neoverse N2(代号 Perseus)的 BTB 结构分析

根据官方信息,Neoverse N2 和 Neoverse V1 的 BTB 配置十分类似,从数字来看只有 nano BTB 缩小到了 32-entry(64 branches),其余是相同的,例如 main BTB 容量也是 8K branches。实测下来,BTB 测试图像和 Neoverse V1 基本一样,只有 nano BTB 容量的区别。因此本文也可以认为是对 Neoverse N2 的 BTB 结构分析。考虑到 Neoverse N2 和 Neoverse V1 的发布时间相同,可以认为它们用的就是相同的前端设计,只是改了一下参数。

各代 Neoverse 处理器的 BTB 结构对比

比较一下 Neoverse V1 和 Neoverse N1 的设计:

  • Neoverse N1 设计了三级 BTB(16+64+6K),分别对应 1-3 的周期的延迟,特别地,main BTB 设计了 fastpath 来实现一定情况下的 2 周期延迟
  • Neoverse V1 设计了两级 BTB(96+8K),分别对应 1-2 的周期的延迟,并且都支持 2 taken

Neoverse V1 相比 Neoverse N1,在容量和延迟上都有比较明显的提升,还额外给两级 BTB 都引入了 2 taken 的支持,进一步提升了吞吐。

下面是一个对比表格:

uArch Neoverse N1 Neoverse V1 Neoverse N2
Nano BTB size 16 branches 48*2 branches 32*2 branches
Nano BTB latency 1 cycle 1 cycle 1 cycle
Nano BTB throughput 1 branch 1-2 branches 1-2 branches
Micro BTB size 64 branches N/A N/A
Micro BTB latency 2 cycles N/A N/A
Main BTB size 3K*2 branches 4K*2 branches 4K*2 branches
Main BTB latency 2-3 cycles 2 cycle 2 cycle
Main BTB throughput 1 branch 1-2 branches 1-2 branches

终端模拟器的文字绘制

2025-06-10 08:00:00

终端模拟器的文字绘制

背景

最近在造鸿蒙电脑上的终端模拟器 Termony,一开始用 ArkTS 的 Text + Span 空间来绘制终端,后来发现这样性能和可定制性比较差,就选择了自己用 OpenGL 实现,顺带学习了一下终端模拟器的文字绘制是什么样的一个过程。

读取字形

文本绘制,首先就要从字体文件中读取字形,提取出 Bitmap 来,然后把 Bitmap 绘制到该去的地方。为了提取这些信息,首先用 FreeType 库,它可以解析字体文件,然后计算出给定大小的给定字符的 Bitmap。但是,这个 Bitmap 它只记录字体非空白的部分(准确的说,是 Bounding Box),如下图的 width * height 部分:

(图源:Managing Glyphs - FreeType Tutorial II

其中 x 轴,应该是同一行的字体对齐的,这样才会看到有高有低的字符出现在同一行,而不是全部上对齐或者下对齐。得到的 Bitmap 是行优先的,也就是说:

  • 图中左上角,坐标 (xMin, yMax) 对应 Bitmap 数组的下标是 0
  • 图中右上角,坐标 (xMax, yMax) 对应 Bitmap 数组的下标是 width-1
  • 图中左下角,坐标 (xMin, yMin) 对应 Bitmap 数组的下标是 width*(height-1)
  • 图中右下角,坐标 (xMax, yMax) 对应 Bitmap 数组的下标是 width*(height-1)+width-1

得到这个 Bitmap 后,如果我们不用 OpenGL,而是直接生成 PNG,那就直接进行一次 copy 甚至 blend 就可以把文字绘制上去了。但是,我们要用 OpenGL 的 shader,就需要把 bitmap 放到 texture 里面。由于目前我们用的就是单色的字体,所以它对应只有一个 channel 的 texture。

OpenGL 的 texture,里面也是保存的 bitmap,但它的坐标系统的命名方式不太一样:它的水平向右方向是 U 轴,竖直向上方向是 V 轴,然后它的 bitmap 保存个数也是行优先,但是从 (0, 0) 坐标开始保存像素,然后 U 和 V 的范围都是 0 到 1。

所以,如果我们创建一个 width*height 的单通道 texture,直接把上面的 bitmap 拷贝到 texture 内部,实际的效果大概是:

 V ^ | C D | | A------B--->U

上图中几个点的坐标以及对应的 bitmap 数组的下标:

  • A 点:U = 0,V = 0,对应 bitmap 数组下标 0
  • B 点:U = 1,V = 0,对应 bitmap 数组下标 width-1
  • C 点:U = 0,V = 1,对应 bitmap 数组下标 width*(height-1)
  • D 点:U = 1,V = 1,对应 bitmap 数组下标 width*(height-1)+width-1

所以在向 OpenGL 的 texture 保存 bitmap 的时候,相当于做了一个上下翻转,不过这没有关系,后续在指定三角形顶点的 U V 坐标的时候,保证对应关系即可。

逐个字符绘制

有了这个基础以后,就可以实现逐个字符绘制:提前把所有要用到的字符,从字体提取出对应的 Bitmap,每个字符对应到一个 Texture。然后要绘制文字的时候,逐个字符,用对应的 Texture,在想要绘制的位置上,绘制一个字符。为了实现这个目的,写一个简单的 Shader:

// vertex shader#version 320 esin vec4 vertex; // xy is position, zw is its texture coordinatesout vec2 texCoors; // output texture coordinatesvoid main() { gl_Position.xy = vertex.xy; gl_Position.z = 0.0; // we don't care about depth now gl_Position.w = 1.0; // (x, y, z, w) corresponds to (x/w, y/w, z/w), so we set w = 1.0 texCoords = vertex.zw;}// fragment shader#version 320 esprecision lowp float;in vec2 texCoords;out vec4 color;uniform sampler2D text;void main() { float alpha = texture(text, texCoords).r; color = vec4(1.0, 1.0, 1.0, alpha);}

在这里,我们给每个顶点设置四个属性,包在一个 vec4 中:

  • xy:记录了这个顶点的坐标,x 和 y 范围都是 -1 到 1
  • zw:记录了这个顶点的 texture 坐标 u 和 v,范围都是 0 到 1

vertex shader 只是简单地把这些信息传递到顶点的坐标和 fragment shader。fragment shader 做的事情是:

  • 根据当前点经过插值出来的 u v 坐标,在 texture 中进行采样
  • 由于这个 texture 只有单通道,所以它的第一个 channel 也就是 texture(text, texCoords).r 就代表了这个字体在这个位置的 alpha 值
  • 然后把 alpha 值输出:(1.0, 1.0, 1.0, alpha),即带有 alpha 的白色

在绘制文字之前,先绘制好背景色,然后通过设置 blending function:

glEnable(GL_BLEND);glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); 

它使得 blending 采用如下的公式:

final = src * src.alpha + dest * (1 - src.alpha);

这里 dest 就是绘制文本前的颜色,src 就是 fragment shader 输出的颜色,也就是 (1.0, 1.0, 1.0, alpha)。代入公式,就知道最终的结果是:

final.r = 1 * alpha + dest.r * (1 - alpha);final.g = 1 * alpha + dest.g * (1 - alpha);final.b = 1 * alpha + dest.b * (1 - alpha);

也就是以 alpha 为不透明度,把白色和背景颜色进行了一次 blend。

如果要设置字体颜色,只需要修改一下 fragment shader:

#version 320 esprecision lowp float;in vec2 texCoords;out vec4 color;uniform sampler2D text;uniform vec3 textColor;void main() { float alpha = texture(text, texCoords).r; color = vec4(textColor, alpha);}

此时 src 等于 (textColor.r, textColor.g, textColor.b, alpha),经过融合后的结果为:

final.r = textColor.r * alpha + dest.r * (1 - alpha);final.g = textColor.g * alpha + dest.g * (1 - alpha);final.b = textColor.b * alpha + dest.b * (1 - alpha);

即最终颜色,等于字体颜色和原来背景颜色,基于 bitmap 的 alpha 值的融合。

解决了颜色,接下来考虑如何设置顶点的信息。前面提到,得到的 bitmap 是一个矩形,而 OpenGL 绘图的基本元素是三角形,因此我们需要拆分成两个三角形来绘图,假如说要绘制一个矩形,它个四个顶点如下:

3-4| |1-2

如果确定左下角 3 这个顶点的坐标是 (xpos, ypos),然后矩形的宽度是 w,高度是 h,考虑到 OpenGL 的坐标系也是向右 X 正方向,向上 Y 正方向,那么这四个顶点的坐标:

  • 顶点 1:(xpos, ypos)
  • 顶点 2:(xpos + w, ypos)
  • 顶点 3:(xpos, ypos + h)
  • 顶点 4:(xpos + w, ypos + h)

接下来考虑这些顶点对应的 uv 坐标。首先,我们知道这些顶点对应的 bitmap 的下标在哪里;然后我们又知道这些 bitmap 的下标对应的 uv 坐标,那就每个顶点找一次对应关系:

  • 顶点 1:(xpos, ypos),下标是 width*(height-1),uv 坐标是 (0, 1)
  • 顶点 2:(xpos + w, ypos),下标是 width*(height-1)+width-1,uv 坐标是 (1, 1)
  • 顶点 3:(xpos, ypos + h),下标是 0,uv 坐标是 (0, 0)
  • 顶点 4:(xpos + w, ypos + h),下标是 width-1,uv 坐标是 (1, 0)

为了绘制这个矩形,绘制两个三角形,分别是 3->1->2 和 3->2->4,一共六个顶点的 (x, y, u, v) 信息就是:

  • 3: (xpos , ypos + h, 0, 0)
  • 1: (xpos , ypos , 0, 1)
  • 2: (xpos + w, ypos , 1, 1)
  • 3: (xpos , ypos + h, 0, 0)
  • 2: (xpos + w, ypos , 1, 1)
  • 4: (xpos + w, ypos + h, 1, 0)

把这些数传递给 vertex shader,就可以画出来这个字符了。

最后还有一个小细节:上述的 xpos 和 ypos 说的是矩形左下角的坐标,但是我们画图的时候,实际上期望的是把字符都画到同一条线上。也就是说,我们指定 origin 的 xy 坐标,然后根据每个字符的 bearingX 和 bearingY 来算出它的矩形的左下角的坐标 xpos 和 ypos:

  • xpos = originX + bearingX
  • ypos = originY + bearingY - height

至此就实现了逐个字符绘制需要的所有内容。这也是 Text Rendering - Learn OpenGL 这篇文章所讲的内容。

Texture Atlas

上面这种逐字符绘制的方法比较简单,但是也有硬伤,比如每次绘制字符,都需要切换 texture,更新 buffer,再进行一次 glDrawArrays 进行绘制,效率比较低。所以一个想法是,把这些 bitmap 拼接起来,合成一个大的 texture,然后把每个字符在这个大的 texture 内的 uv 坐标保存下来。这样,可以一次性把所有字符的所有三角形都传递给 OpenGL,一次绘制完成,不涉及到 texture 的切换。这样效率会高很多。

具体到代码上,也就是分成两步:

  • bitmap 的拼接,这一步比较灵活,理想情况下是构造一个比较紧密的排布,但也可以留一些空间,直接对齐到最大宽度/高度的整数倍网格上,然后进行 uv 坐标的计算
  • 剩下的,就是在计算顶点信息的时候,用计算好的 uv 坐标,其中 left/right 对应 bitmap 左右两侧的 u 坐标,top/bottom 对应 bitmap 上下两侧的 v 坐标(注意 top 比 bottom 小,因为竖直方向是反的):
    • 3: (xpos , ypos + h, left , top )
    • 1: (xpos , ypos , left , bottom)
    • 2: (xpos + w, ypos , right, bottom)
    • 3: (xpos , ypos + h, left , top )
    • 2: (xpos + w, ypos , right, bottom)
    • 4: (xpos + w, ypos + h, right, top )

此外,在前面的 shader 代码里,字体颜色用的是 uniform,也就是每次调用只能用同一种颜色。修改的方法,就是把它也变成顶点的属性,从 vertex shader 直接传给 fragment shader,替代 uniform 变量。不过由于 vec4 已经放不下更多的维度了,所以需要另外开一个 attribute:

// vertex shader#version 320 esin vec4 vertex; // xy is position, zw is its texture coordinatesin vec3 textColor;out vec2 texCoors; // output texture coordinatesout vec3 fragTextColor; // send to fragment shadervoid main() { gl_Position.xy = vertex.xy; gl_Position.z = 0.0; // we don't care about depth now gl_Position.w = 1.0; // (x, y, z, w) corresponds to (x/w, y/w, z/w), so we set w = 1.0 texCoords = vertex.zw; fragTextColor = textColor;}// fragment shader#version 320 esprecision lowp float;in vec2 texCoords;in vec3 fragTextColor;out vec4 color;uniform sampler2D text;void main() { float alpha = texture(text, texCoords).r; color = vec4(fragTextColor, alpha);}

背景和光标绘制

接下来回到终端模拟器,它除了绘制字符,还需要绘制背景颜色和光标。前面在绘制字符的时候,只把 bounding box 绘制了出来,那么剩下的空白部分是没有绘制的。但是终端里,每一个位置的背景颜色都可能不同,所以还需要给每个位置绘制对应的背景颜色。这里有两种做法:

第一种做法是,把前面每个字符的 bitmap 扩展到终端里一个固定的位置的大小,这样每次绘制的矩形,就是完整的一个位置的区域,这个时候再去绘制背景颜色,就比较容易了:修改 vertex shader 和 fragment shader,在内部进行一次 blend:color = vec4(fragTextColor.rgb * alpha + fragBackgroundColor.rgb * (1.0 - alpha), 1.0),相当于是丢掉了 OpenGL 的 blend function,自己完成了前景和后景的绘制。

但这个方法有个问题:并非所有的字符的 bitmap 都可以放到一个固定大小的矩形里的。有一些特殊字符,要么长的太高,要么在很下面的位置。后续可能还有更复杂的需求,比如 CJK 和 Emoji,那么字符的宽度又不一样了。所以这个时候导出了第二种做法:

  • 第一轮,先绘制出终端每个位置的背景颜色
  • 第二轮,再绘制出每个位置的字符,和背景进行融合

这时候 shader 没法自己做 blend,所以这考虑怎么用 blend function 来实现这个 blend 的计算。首先,要考虑我们最终需要的结果是:

final.r = textColor.r * alpha + dest.r * (1 - alpha);final.g = textColor.g * alpha + dest.g * (1 - alpha);final.b = textColor.b * alpha + dest.b * (1 - alpha);final.a = 1.0;

由于是 OpenGL 做的 blending,我们需要用 OpenGL 自带的 blending mode 来实现上述公式。OpenGL 可以指定 RGB 的 source 和 dest 的 blending 方式,比如:

  • GL_ONE:乘以 1 的系数
  • GL_ONE_MINUS_SRC_ALPHA:乘以 (1 - source.a) 的系数

根据这个,就可以想到,设置 source = vec4(textColor.rgb * alpha, alpha),设置 source 采用 GL_ONE 方式,dest 采用 GL_ONE_MINUS_SRC_ALPHA 模式,那么 OpenGL 负责剩下的 blending 工作 final = source * 1 + dest * (1 - source.a)(要求 dest.a = 1.0):

final.r = source.r * 1.0 + dest.r * (1 - source.a) = textColor.r * alpha + dest.r * (1 - alpha);final.g = source.g * 1.0 + dest.g * (1 - source.a) = textColor.g * alpha + dest.g * (1 - alpha);final.b = source.b * 1.0 + dest.b * (1 - source.a) = textColor.b * alpha + dest.b * (1 - alpha);final.a = source.a * 1.0 + dest.a * (1 - source.a) = alpha + 1.0 * (1 - alpha) = 1.0;

正好实现了想要的计算公式。这个方法来自于 Text Rendering - WebRender。有了这个推导后,就可以分两轮,完成终端里前后景的绘制了。

目前 Termony 用的就是这种实现方法:

  • 首先把不同字重的各种字符的 bitmap 拼在一起,放在一个 texture 内部
  • 使用两阶段绘制,第一阶段

注:如果在 source 使用 GL_SRC_ALPHA,设置 source = vec4(textColor.rgb, alpha),这样 final.r = source.r * source.a + dest.r * (1 - source.a) = textColor.r * alpha + dest.r * (1 - alpha),结果是上面是一样的,不过这个时候 final 的 alpha 值等于 source.a * source.a + dest.a * (1 - source.a) 是 alpha 和 dest.a 经过 blend 以后的结果,不再是 1.0,如果不用它就无所谓。上面这种 vec4(textColor.rgb * alpha, alpha) 的计算方法,叫做 premultiplied alpha,也就是预先把 alpha 乘到颜色项里,可以方便后续的计算。

在鸿蒙上使用 OpenGL 渲染

最后再简单列举一下,在鸿蒙上用 OpenGL 渲染都需要哪些事情:

首先,在 ArkTS 中,插入一个 XComponent,然后在 XComponentController 的回调函数中,通知 native api:

import testNapi from 'libentry.so';class MyXComponentController extends XComponentController { onSurfaceCreated(surfaceId: string): void { hilog.info(DOMAIN, 'testTag', 'onSurfaceCreated surfaceId: %{public}s', surfaceId); testNapi.createSurface(BigInt(surfaceId)); } onSurfaceChanged(surfaceId: string, rect: SurfaceRect): void { hilog.info(DOMAIN, 'testTag', 'onSurfaceChanged surfaceId: %{public}s rect: %{public}s', surfaceId, JSON.stringify(rect)); testNapi.resizeSurface(BigInt(surfaceId), rect.surfaceWidth, rect.surfaceHeight); } onSurfaceDestroyed(surfaceId: string): void { hilog.info(DOMAIN, 'testTag', 'onSurfaceDestroyed surfaceId: %{public}s', surfaceId); testNapi.destroySurface(BigInt(surfaceId)) }}@Componentstruct Index { xComponentController: XComponentController = new MyXComponentController(); build() { // ... XComponent({ type: XComponentType.SURFACE, controller: this.xComponentController }) }}

native 部分需要实现至少两个函数:createSurface 和 resizeSurface。其中主要的工作在 CreateSurface 中完成,ResizeSurface 会在窗口大小变化的时候被调用。

CreateSurface 要做的事情:

读取 surface id:

size_t argc = 1;napi_value args[1] = {nullptr};napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);int64_t surface_id = 0;bool lossless = true;napi_status res = napi_get_value_bigint_int64(env, args[0], &surface_id, &lossless);assert(res == napi_ok);

创建 OHNativeWindow:

OHNativeWindow *native_window;OH_NativeWindow_CreateNativeWindowFromSurfaceId(surface_id, &native_window);assert(native_window);

创建 EGLDisplay:

EGLNativeWindowType egl_window = (EGLNativeWindowType)native_window;EGLDisplay egl_display = eglGetDisplay(EGL_DEFAULT_DISPLAY);assert(egl_display != EGL_NO_DISPLAY);

初始化 EGL:

EGLint major_version;EGLint minor_version;EGLBoolean egl_res = eglInitialize(egl_display, &major_version, &minor_version);assert(egl_res == EGL_TRUE);

选择 EGL 配置:

const EGLint attrib[] = {EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, 24, EGL_STENCIL_SIZE, 8, EGL_SAMPLE_BUFFERS, 1, EGL_SAMPLES, 4, // Request 4 samples for multisampling EGL_NONE};const EGLint max_config_size = 1;EGLint num_configs;EGLConfig egl_config;egl_res = eglChooseConfig(egl_display, attrib, &egl_config, max_config_size, &num_configs);assert(egl_res == EGL_TRUE);

创建 EGLSurface:

EGLSurface egl_surface = eglCreateWindowSurface(egl_display, egl_config, egl_window, NULL);

创建 EGLContext:

EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};EGLContext egl_context = eglCreateContext(egl_display, egl_config, EGL_NO_CONTEXT, context_attributes);

在当前线程启用 EGL:

eglMakeCurrent(egl_display, egl_surface, egl_surface, egl_context);

在这之后就可以用 OpenGL 的各种函数了。OpenGL 绘制完成以后,更新到窗口上:

eglSwapBuffers(egl_display, egl_surface);

在 ResizeSurface 中,主要是更新 glViewport,让它按照新的 surface 大小来绘制。

参考

在鸿蒙电脑上的虚拟机内启动 Linux

2025-06-10 08:00:00

在鸿蒙电脑上的虚拟机内启动 Linux

背景

最近在研究鸿蒙电脑,群友 @Fearyncess 摸索出了,如何在鸿蒙电脑上的虚拟机内启动 Linux,而不是 Windows。在此做个复现并记录。

方法

目前鸿蒙的应用市场上有两家虚拟机,我用 Oseasy 虚拟机,但是理论上铠大师也是可以的。(P.S. @driver1998 反馈:“铠大师测试也能启动,但键盘左右方向键的处理有点问题,虚拟机内收不到按键松开的信号,EFI 和 Linux里 面都是这样。目前建议用 OSEasy。”)

首先需要在 U 盘上,把一个 UEFI arm64 的 Linux 安装盘写进去。我用的是 Ventoy + Debian Installer,理论上直接写例如 Debian 发行版的安装 ISO 也是可以的。

然后把 U 盘插到鸿蒙电脑上,打开 Windows 虚拟机,直通到虚拟机里面,保证虚拟机里面可以看到 U 盘。

接着,进入 Windows 磁盘管理,缩小 Windows 的 NTFS 分区,留出空间。注意 Windows 启动的时候会自动 growpart,所以装 Debian 前,不要回到 Windows。装好以后,可以继续用 Windows。

接着,重启 Windows,同时按住 Escape,进入 OVMF 的界面,然后选择 Boot Manager,从 U 盘启动,然后就进入 Ventoy 的界面了。(注:根据 @quiccat 群友提醒,在 Windows 内,通过设置->系统->恢复->高级启动->UEFI 固件设置也可以进入 OVMF 的设置界面)

剩下的就是正常的 Linux 安装过程了,分区的时候,注意保留 Windows 已有的 NTFS,可以和 Windows 用同一个 ESP 分区。网络的话,配置静态 IP 是 172.16.100.2,默认网关是 172.16.100.1 即可。重启以后,在 grub 界面,修改 linux 配置,在 cmdline 一栏添加 modprobe.blacklist=vmwgfx,这样就能启动了。内核版本是 Debian Bookworm 的 6.1。

各内核版本启动情况:

  • 5.10 from debian:正常
  • 6.1 from debian:正常
  • 6.2/6.3/6.4 from kernel.ubuntu.com: echo simpledrm > /etc/modules-load.d/simpledrm.conf 后正常,否则系统可以启动但是图形界面起不来
  • 6.5 from kernel.ubuntu.com:起不来,需要强制关机
  • 6.12 from debian:起不来,需要强制关机

经过 @Fearyncess 的二分,找到了导致问题的 commit

最终效果:

主要的遗憾是分辨率:屏幕两侧有黑边,并且由于宽度不同,触摸屏的位置映射会偏中间。

附录

Geekbench 6 测试结果:

如果没有 blacklist 的话,vmwgfx 驱动的报错:

vmwgfx 0000:00:04.0: [drm] FIFO at 0x0000000020000000 size is 2048 kiBvmwgfx 0000:00:04.0: [drm] VRAM at 0x0000000010000000 size is 262144 kiBvmwgfx 0000:00:04.0: [drm] *ERROR* Unsupported SVGA ID 0xffffffff on chipset 0x405vmwgfx: probe of 0000:00:04.0 failed with error -38

blacklist vmwgfx 后用的是 efifb:

[ 0.465898] pci 0000:00:04.0: BAR 1: assigned to efifb[ 1.197638] efifb: probing for efifb[ 1.197705] efifb: framebuffer at 0x10000000, using 7500k, total 7500k[ 1.197708] efifb: mode is 1600x1200x32, linelength=6400, pages=1[ 1.197711] efifb: scrolling: redraw[ 1.197712] efifb: Truecolor: size=8:8:8:8, shift=24:16:8:0

虚拟机的 IP 地址,从宿主机也可以直接访问,通过 WVMBr 访问,目测是直接 Tap 接出来,然后建了个 Bridge,外加 NAT,只是没有 DHCP。

在 HarmonyOS 5 上运行 Fortran 程序

2025-06-06 08:00:00

在 HarmonyOS 5 上运行 Fortran 程序

背景

前段时间把 SPEC CPU 2017 移植到了鸿蒙 5 上:https://github.com/jiegec/SPECCPU2017Harmony,由于 SPEC CPU 2017 里有不少 Fortran 程序,所以就研究了一下怎么编译 Fortran 代码,最终搞成了,在这里记录一下。

过程

HarmonyOS 5 的工具链用的是 LLVM 15,自带的编译器是 clang,那个时候还没有 LLVM flang。但是,经过实际测试,使用新版本的 flang,也是可以的,只是需要做一些额外的操作。例如 flang 有自己的 runtime(类比 libgcc 和 LLVM 的 compiler-rt),需要交叉编译一个 arm64 的版本,下面是仓库中 build-flang.sh 的内容:

#!/bin/sh# build missing libraries for aarch64-linux-ohos target# assume llvm-project is cloned at $HOME/llvm-projectset -x -emkdir -p flangexport PATH=~/command-line-tools/sdk/default/openharmony/native/llvm/bin:$PATHDST=$PWD/flangcd $HOME/llvm-projectgit checkout main# match hash in flang-new-20 --versiongit reset 7cf14539b644 --hardcd libunwindrm -rf buildmkdir -p buildcd buildcmake .. -G Ninja \ -DCMAKE_C_FLAGS="-target aarch64-linux-ohos -fuse-ld=lld" \ -DCMAKE_C_COMPILER="clang" \ -DCMAKE_CXX_FLAGS="-target aarch64-linux-ohos -fuse-ld=lld" \ -DCMAKE_CXX_COMPILER="clang++"ninjacp lib/libunwind.a $DST/cd ../../cd flang/lib/Decimalrm -rf buildmkdir -p buildcd buildcmake .. -G Ninja \ -DCMAKE_C_FLAGS="-target aarch64-linux-ohos -fuse-ld=lld -fPIC" \ -DCMAKE_C_COMPILER="clang" \ -DCMAKE_CXX_FLAGS="-target aarch64-linux-ohos -fuse-ld=lld -fPIC" \ -DCMAKE_CXX_COMPILER="clang++"ninjacp libFortranDecimal.a $DST/cd ../../../../cd flang/runtimerm -rf buildmkdir -p buildcd buildcmake .. -G Ninja \ -DCMAKE_C_FLAGS="-target aarch64-linux-ohos -fuse-ld=lld -fPIC" \ -DCMAKE_C_COMPILER="clang" \ -DCMAKE_CXX_FLAGS="-target aarch64-linux-ohos -fuse-ld=lld -fPIC" \ -DCMAKE_CXX_COMPILER="clang++"ninjacp libFortranRuntime.a $DST/cd ../../../ls -al $DST

核心就是以 aarch64-linux-ohos 为 target,编译出三个 .a 文件,之后再链接上就可以了。需要注意的是,runtime 版本和 flang 版本需要一致。为了偷懒,直接用的是 LLVM APT 提供的 flang-new-20 的 binary,那么它是会随着 apt upgrade 而更新的,这个时候就需要重新编译一次 flang runtime,然后链接到程序里。如果版本不对上,可能遇到一些问题:

fatal Fortran runtime error(/home/jiegec/llvm-project/flang/runtime/descriptor.cpp:74): not yet implemented: type category(6)

参考 [flang] fatal Fortran runtime error,就知道是编译器版本和 runtime 不兼容的问题了。

编译好了 fortran runtime 之后,就可以用 flang-new-20 编译 fortran 代码了。这里给出 CMake 的配置方式,主要涉及到需要用的编译选项:

set(CMAKE_Fortran_COMPILER_FORCED TRUE)set(CMAKE_Fortran_COMPILER "flang-new-20")set(CMAKE_Fortran_FLAGS "-target aarch64-linux-ohos -fuse-ld=lld -L ${CMAKE_CURRENT_SOURCE_DIR}/../../../../flang -nostdlib -L ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../command-line-tools/sdk/default/openharmony/native/sysroot/usr/lib/aarch64-linux-ohos -lc -lm -L ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../command-line-tools/sdk/default/openharmony/native/llvm/lib/clang/15.0.4/lib/aarch64-linux-ohos/ -lclang_rt.builtins -lFortranRuntime -lFortranDecimal")enable_language(Fortran)

这里的相对路径,其实就是要找到新编译出来的 flang runtime,以及 HarmonyOS command line tools 里面的一些库,具体路径需要根据实际情况来调整,这里只是一个样例。

到这里,就可以在 HarmonyOS 5 上运行 Fortran 程序了。其实还可以考虑研究一下 GFortran,或许也是能实现的,但目前还没有去做进一步的尝试。

鸿蒙电脑 MateBook Pro 开箱体验

2025-06-06 08:00:00

鸿蒙电脑 MateBook Pro 开箱体验

购买

2025.6.6 号正式开卖,当华为线上商城显示没货的时候,果断去线下门店买了一台回来。购买的是 32GB 内存,1TB SSD 存储,加柔光屏的版本,型号 HAD-W32,原价 9999,国补后 7999。

开箱

由于用了国补,需要当面激活,就在店里直接激活了,所以没有体验到鸿蒙系统的扫码激活功能,有点可惜。激活前的第一次开机需要插电,直接按电源键是没有反应的。激活过程也很简单,联网,创建用户,登录华为账号,输入指纹,就可以了。包装盒里还有 140W 单口 Type-C 电源适配器,体积挺小的。此外附赠了一条 Type-C to Type-C 的线,还有一个 Type-C 有线耳机,外加一个 Type-A 母口加 Type-C 公口的线,可以用来接 Type-A 公口的外设。此外还有快速指南和一个超纤抛光布。店家还贴心地提供了一个虚拟机的安装教程。

外形上,就是 MateBook X Pro 加了一个 HarmonyOS 的标识,上手很轻,不愧是不到一公斤的笔记本,对于习惯用 MacBookAir 轻薄本的我来说,是很大的一个亮点。不像 MacBookAir,这台鸿蒙电脑有风扇,有点小小的不习惯,但还算安静。

规格如下:

  • 970g 重量
  • 14.2 寸显示器
  • 3120x2080 分辨率,120 Hz 刷新率
  • 1.8mm 键程键盘
  • 70 Wh 电池

系统体验

预装的版本是 HarmonyOS 5.0.1.305,有更新 5.0.1.310 SP9(SP9C00E301R9P2patch02,内核 1.9.5 2025-05-26),首先更新了一下系统。这是我的第一台支持触屏的笔记本,所以用起来还有点新奇。这个柔光屏用起来触感不错,和之前买的柔光屏 MatePad 的触感类似。

底部状态栏的颜色会随着情况变化,比如在桌面的时候,默认壁纸是黑色的,状态栏也就是黑色的。如果打开了设置,设置是白色的,状态栏也就是白色的。之后可以多测试一下它具体的变色逻辑。

系统里预装了 WPS Office,迅雷,亿图,中望 CAD,剪映,好压,抖音等,面向的客户群体很显然了。虽然预装,但都可以卸载。

内置了控制手机屏幕的功能,有略微的不跟手,但由于电脑本身也是触屏,所以体验还是和手机很接近的。下方是经典的三个按钮。这个协同,可以电脑和手机同时操作,还是挺好的,不会说电脑控制了手机,手机就不能用的情况。手机界面左上角会提示协同中。键鼠共享功能不错,可以把手机当屏幕,然后用电脑的键盘和触摸板控制,外接的鼠标也可以。

触摸板手势方面,可以在设置里修改,比如菜单弹出改成双指点按或轻点。触摸板的手感比苹果还是有一定的差距,但是屏幕触摸弥补了这个问题。没有找到三指拖拽的手势,它用的是类似 Windows 的轻点两次,第二次不抬起的做法。

屏幕分辨率 2080 x 3120,14.2 英寸。

2025-06-17 推送了 5.0.1.310 SP9,SP9C00E301R9P2patch05,内核 1.9.5 2025-05-26。

应用体验

目前(2025 年 6 月 6 日)应用商城有这些软件:

  • Bilibili
  • 飞书
  • 钉钉
  • 腾讯会议
  • QQ(在应用尝鲜内)
  • CodeArts IDE(在应用尝鲜内,需要开发者模式)

暂时还没有微信,可以通过操控手机来发微信,但是在消息栏里按回车是换行,没找到发送按钮对应的电脑按键,需要手动操。但是居然有企业微信。

UPDATE: 2025-06-13 收到了微信的测试短信,可以体验了,版本是 4.0.1.30。2025-06-14 推送了 4.0.1.31 测试版本。2025-06-19 推送了 4.0.1.32 版本。

支持应用接续,在手机上播放的 B 站视频,可以在电脑上接续继续看。

期待一个功能,当电脑上出现需要扫的二维码的时候,可以通过协同功能,不用操作手机,让手机直接扫电脑的屏幕。不过反过来,如果电脑上有需要输入手机短信验证码的场景,就已经很方便了。

试了一下腾讯会议,声音,视频,共享屏幕都是正常工作的。但是共享的屏幕只有笔记本自己的屏幕,还不能选取共享哪个屏幕的内容,也不能选取共享哪个窗口。

开发者模式

开发者模式的打开方式和手机上一样,在设置里狂点软件版本。自带了一个 Terminal App,会提示你如何打开开发者模式。

打开以后就可以访问终端了。shell 是用的 toybox。df 如下:

$ df -hFilesystem Size Used Avail Use% Mounted ontmpfs 16G 52K 16G 1% /tmpfs 16G 0 16G 0% /storage/hmdfs/dev/block/dm-4 5.7M 5.7M 0 100% /cust/dev/block/dm-6 3.1G 3.1G 0 100% /preload/dev/block/dm-0 3.0G 3.0G 0 100% /system/variant/dev/block/dm-5 8.0K 8.0K 0 100% /version/dev/block/platform/b0000000.hi_pcie/by-name/userdata 928G 59G 869G 7% /data/service/hnptmpfs 16G 0 16G 0% /module_update/dev/block/dm-2 9.3G 8.1G 1.1G 88% /sys_proddevfs 15G 104M 15G 1% /dev/data/service/el2/100/hmdfs/non_account 928G 59G 869G 7% /mnt/hmdfs/100/non_account/dev/block/loop0 114M 112M 0 100% /module_update/ArkWebCoretmpfs 1.0G 608K 0.9G 1% /dev/shm

查看 /proc/cpuinfo。四个 0xd42(2.0 GHz),八个 0xd43(2.0 GHz),八个 0xd03(2.3 GHz),共 20 个逻辑核。从 part id 来看,0xd03 和 0xd42 对应麒麟 9010 的大核和中核,但 0xd43 是新的 part id。

使用 https://github.com/jiegec/SPECCPU2017Harmony 性能测试:

  • X90 P-Core 2.3 GHz 0xd03 Full: INT 4.87 FP 7.42
  • X90 E-Core 2.0 GHz 0xd43 Full: INT 4.28 FP 6.52
  • X90 LPE-Core 2.0 GHz 0xd42 Full: INT 3.25 FP TODO
  • 9010 P-Core 2.3 GHz 0xd03 Best: INT 4.18 FP 6.22
  • 9010 P-Core 2.3 GHz 0xd03 Full: INT 3.96 FP 5.86
  • 9010 E-Core 2.2 GHz 0xd42 Full: INT 3.21 FP 4.72

详细数据: https://github.com/jiegec/SPECCPU2017Harmony/tree/master/results。Best 代表每一项单独跑,散热条件好,Full 代表顺着跑一遍,散热条件差。由于编译器和编译选项不同,不能和在其他平台上跑的 SPEC CPU 2017 成绩直接对比,仅供参考。

大概性能排序:X90 P-Core > X90 E-Core > 9010 P-Core > X90 LPE-Core > 9010 E-Core > 9010 LPE-Core。

即使是同样的 2.3 GHz 0xd03 的核,X90 比 9010 快上 20%:可能是散热问题,或者缓存大小和内存带宽的问题,或许连微架构都是不一样的,这些都需要后续进一步测试。而 X90 的中核也比 9010 的大核要快。

CodeArts IDE

试了一下从应用商城安装的 CodeArts IDE,显示支持 Java 和 Python 开发,UI 上有点像 JetBrains,但应该是基于 VSCode 做的二次开发。实际测了一下,用它创建 Python 项目后,可以在 CodeArts IDE 的命令行里用 Python3:

$ pwd/storage/Users/currentUser/IDEProjects/pythonProject$ python3 main.pyHello World!$ which python3/storage/Users/currentUser/IDEProjects/pythonProject/venv/bin/python3o$ /data/app/bin/python --versionPython 3.12.5

这里的 /storage/Users/currentUser/ 就是 HOME 目录,对应文件管理器的个人目录。

看了看 /data/app/bin 目录,下面有 git,python,unzip, vi,rg,java(bisheng jdk 8/17),ssh,electron(用来跑 LSP!)等等。

试了试 pip,也是工作的:

(venv) $ python3 -m pip install requests(venv) $ python3Python 3.12.5 (main, Aug 28 2024, 01:18:17) [Clang 15.0.4 (llvm-project 81cdec3cd117b1e6e3a9f1ebc4695d790c978463)] on linuxType "help", "copyright", "credits" or "license" for more information.>>> import requests>>> requests.get("https://github.com").status_code200>>> 

需要 native 编译的库,比如 numpy 还不行,会提示找不到 make。

终端里的 ssh 是可以用的,实测 ssh 到远程的 linux 上是没问题的。

终端里的括号补全有一些问题,等待修复。CodeArts IDE 的 Python 单步调试功能也是工作的。

似乎没有安装 Remote 开发的插件,也没有安装插件的菜单。

既然可以跑 shell,意味着可以 execve 了,意味着可以做 termux 的类似物了。期待鸿蒙 5 上早日有 Termux 用,直接跑 Linux 发行版。实际测了一下,Popen 确实是工作的。

UPDATE: 开了个坑:https://github.com/jiegec/Termony,目前已经能跑很多命令了,包括在鸿蒙电脑上编译 C/C++ 代码。

试了一下 HOME 目录,发现它里面不能有可执行的文件,所以可能还是得打包到一个 App 里面,通过 /data/app/bin 类似的路径来访问。

在 CodeArts IDE 里,可以访问 /data/storage/el1/bundle 目录,里面有一个 pc_entry.hap 文件,可以通过 cat /data/storage/el1/bundle/pc_entry.hap | ssh hostname "cat - > pc_entry.hap" 拷贝到其他机器上。这个文件有 1.9GB,可以看到在 /data/app 下面的各种文件,其实是来自于这个 pc_entry.haphnp/arm64-v8a 下面的一系列文件,例如 git.hnp 就是一个 zip 压缩包,里面就是 /data/app/git.org/git_1.2 目录的内容,这个东西叫做 应用包内 Native 包(.hnp)。这些文件在 module.json 里声明,对应 hnpPackages 标签

{ "module": { "hnpPackages": [ { "package": "electron.hnp", "type": "private" }, { "package": "huaweicloud-smartassist-java-ls.hnp", "type": "private" }, { "package": "bishengjdk8.hnp", "type": "private" }, { "package": "rg.hnp", "type": "private" }, { "package": "unzip.hnp", "type": "private" }, { "package": "git.hnp", "type": "private" }, { "package": "bishengjdk17.hnp", "type": "private" }, { "package": "python.hnp", "type": "private" } ], "name": "pc_entry", "packageName": "pc_entry" }}

解压 git.hnp 后,里面的文件会被复制到 /data/app/git.org/git_1.2 目录下,然后有一个 hnp.json 指定了在 /data/app/bin 创建哪些文件的软连接,比如:

{ "install": { "links": [ { "source": "bin/expr", "target": "expr" }, { "source": "bin/git", "target": "git" } ] }, "name": "git", "type": "hnp-config", "version": "1.2"}

在 HarmonyOS SDK 里,有一个 hnpcli,可以用来生成 .hnp 文件。

除此之外,就是 VSCode 加各种插件了。

鸿蒙电脑上,可以访问各个 App 的内部目录了,无论是自带的文件浏览器,还是通过 DevEco Studio。这给调试带来了很多便利。

UPDATE: 2025-06-21 推送了 1.0.3 版本。实测在 shell 里面输入括号,不会出现括号补全跑到错误的位置的问题了。

虚拟机

目前应用商城有两家虚拟机:Oseasy 和铠大师。两者都是提示安装 ARM64 版本的 Windows,尝试了一下给它一个 Debian 的安装 ISO,它不认。用的 unattended install,不需要进行什么操作。Oseasy 和铠大师的虚拟机不能同时开,但是可以一边安装完,再去安装另一边的 Windows。

试了试在虚拟机里装 WSL,说没有硬件虚拟化,大概是没有打开嵌套虚拟化的功能。

在 6 核 Oseasy 虚拟机里运行 ARM64 Geekbench 6:Single-Core 1436, Multi-Core 5296。Oseasy 8 核:Single-Core 1462, Multi-Core 7043。算上剩下的 12 个逻辑核,考虑虚拟化的开销,多核分数达到网传的 11640 分,感觉是可能的。

Oseasy 虚拟机只允许开到 8 个核心,实测下来,会优先调度到 0xD03 的八个逻辑核中其中四个逻辑核(不同时用一个物理核的两个逻辑核),之后再调度到 0xD43 的八个逻辑核中的四个逻辑核(也不同时用同一个物理核的两个逻辑核)。在 Oseasy 虚拟机里看到的 CPU 信息是 Cortex-A53,没有正确暴露外面的处理器信息,从 cpuinfo 来看,也没有暴露 SVE。

UPDATE: 能跑 Linux 了,见 在鸿蒙电脑上的虚拟机内启动 Linux

外设

把 Type-C Hub 接到 MateBook Pro 上,显示器,键盘鼠标都正常工作了。

侧载

打开开发者模式后,在设置里,可以打开 USB 调试:把电脑右边的 USB Type-C 接到另一台电脑上,就可以用 hdc 连接了。

然后给自己的项目加上 2in1 的 device type:

diff --git a/entry/build-profile.json5 b/entry/build-profile.json5index 38bdcc9..ad6fd45 100644--- a/entry/build-profile.json5+++ b/entry/build-profile.json5@@ -30,7 +30,13 @@ ], "targets": [ {- "name": "default"+ "name": "default",+ "config": {+ "deviceType": [+ "default",+ "2in1"+ ]+ } }, { "name": "ohosTest",diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5index 7b8532f..76c009c 100644--- a/entry/src/main/module.json5+++ b/entry/src/main/module.json5@@ -5,7 +5,8 @@ "description": "$string:module_desc", "mainElement": "EntryAbility", "deviceTypes": [- "default"+ "default",+ "2in1" ], "requestPermissions": [ {

就可以在鸿蒙电脑上跑了。我编写的两个鸿蒙上的应用:https://github.com/jiegec/SPECCPU2017Harmonyhttps://github.com/jiegec/NetworkToolsHarmony 都能正常在 MateBook Pro 上运行。

测试的过程中,发现用 hdc 传文件到电脑比传手机更快:Pura 70 Pro+ 是 24 MB/s,MateBook Pro 是 31 MB/s。

开源的鸿蒙应用也可以编译 + 运行:

目前还没找到怎么让鸿蒙电脑自己调试自己。

移植问题

  • ioctl(fd, TCSETS) 会失败,ioctl(fd, TCSETSW) 则成功
  • libc 缺少一些函数,比如 getspnam,有一些函数不可用,例如 getpwuid
  • openssl 的 hwcap 检测有问题,可能会导致 sigill
  • 无法访问 /proc/stat

Termony

目前通过 https://github.com/jiegec/Termony 运行了一些 benchmark:

$ vkpeak 0device = Maleoon 916fp32-scalar = 718.54 GFLOPSfp32-vec4 = 1038.34 GFLOPSfp16-scalar = 1083.84 GFLOPSfp16-vec4 = 1791.44 GFLOPSfp16-matrix = 0.00 GFLOPSfp64-scalar = 0.00 GFLOPSfp64-vec4 = 0.00 GFLOPSint32-scalar = 303.34 GIOPSint32-vec4 = 316.56 GIOPSint16-scalar = 709.12 GIOPSint16-vec4 = 830.55 GIOPSint8-dotprod = 0.00 GIOPSint8-matrix = 0.00 GIOPSbf16-dotprod = 0.00 GFLOPSbf16-matrix = 0.00 GFLOPS

未完待续

ARM Neoverse N1 (代号 Ares) 的 BTB 结构分析

2025-06-05 08:00:00

ARM Neoverse N1 (代号 Ares) 的 BTB 结构分析

背景

ARM Neoverse N1 是比较早的一代 ARM 服务器的处理器,它在很多地方都和 Cortex-A76 类似。它的 BTB 结构比较有意思,所以在这里对它的 BTB 做一些分析。

官方信息

首先收集了一些 ARM Neoverse N1 的 BTB 结构的官方信息:

简单整理一下官方信息,大概有三级 BTB:

  • 16-entry Nano BTB, 1 cycle latency (0 cycle bubble)
  • 64-entry Micro BTB
  • 6K-entry Main BTB, 3 cycle latency

但是很多细节是缺失的,因此下面结合微架构测试,进一步研究它的内部结构。

微架构测试

在之前的博客里,我们已经测试了各种处理器的 BTB,在这里也是一样的:按照一定的 stride 分布无条件直接分支,构成一个链条,然后测量 CPI。

stride=4B

首先是 stride=4B 的情况:

可以看到,图像上出现了三个比较显著的台阶:

  • 第一个台阶到 16 条分支,CPI=1,对应了 16-entry 的 Nano BTB
  • 第二个台阶到 80 条分支,CPI=2,其中 80=16+64,多出来的部分对应了 64-entry 的 Micro BTB
  • 第三个台阶到 8192 条分支,CPI=5,大于 Main BTB 的 3 cycle latency,说明此时没有命中 Main BTB,而是要等到取指和译码后,计算出正确的目的地址再回滚,导致了 5 cycle latency;8192 的性能下降原因还需要进一步研究,16384 的性能下降对应了 64KB 的 ICache,因为 4B*16384=64KB

那么 stride=4B 的情况下就遗留了两个问题:为什么没有命中 Main BTB;8192 处为什么出现了性能下降。

stride=8B

接下来观察 stride=8B 的情况:

可以看到,图像上出现了三个比较显著的台阶:

  • 第一个台阶到 16 条分支,CPI=1,对应了 16-entry 的 Nano BTB,和之前一样
  • 第二个台阶到 80 条分支,CPI=2,其中 80=16+64,多出来的部分对应了 64-entry 的 Micro BTB,和之前一样
  • 第三个台阶到 4096 条分支,CPI=2.75,约等于 Main BTB 的 3 cycle latency,说明此时命中的是 Main BTB,但是它并没有达到宣称的 6144-entry 的 Main BTB 容量;8192 还有一个性能下降,这对应了 64KB 的 ICache:8B*8192=64KB

相比 stride=4B,Nano BTB 和 Micro BTB 的行为没有变化;而 Main BTB 开始能够命中,这代表 Main BTB 在分支特别密集的情况下,会出现性能问题。

那么 stride=8B 的情况下遗留了两个问题:为什么 CPI=2.75 而不是 3?为什么只观察到了 4K 的 Main BTB 容量,而不是 6K?

stride=16B

继续观察 stride=16B 的情况:

可以看到,图像上出现了四个比较显著的台阶:

  • 第一个台阶到 16 条分支,CPI=1,对应了 16-entry 的 Nano BTB,和之前一样
  • 第二个台阶到 80 条分支,CPI=2,其中 80=16+64,多出来的部分对应了 64-entry 的 Micro BTB,和之前一样
  • 第三个台阶到 4096 条分支,CPI=2.5,比 Main BTB 的 3 cycle latency 略小,但是大于 2,说明此时命中的是 Main BTB,此时遇到了 64KB ICache 的瓶颈:4096*16B=64KB
  • 第四个台阶到 6144 条分支,CPI=3.5,比 Main BTB 的 3 cycle latency 略大,是因为 64KB ICache 出现了缺失,但这个时候终于显现了宣传的 Main BTB 的 6144 的容量

相比 stride=8B,Nano BTB 和 Micro BTB 的行为没有变化;Main BTB 的 6144 容量开始显现,并且出现地比 64KB ICache 更晚。

那么 stride=16B 的情况下遗留了一个问题:为什么出现了 CPI=2.5 的平台?

stride=32B

继续观察 stride=32B 的情况:

可以看到,图像上出现了三个比较显著的台阶:

  • 第一个台阶到 16 条分支,CPI=1,对应了 16-entry 的 Nano BTB,和之前一样
  • 第二个台阶到 2048 条分支,CPI=2,此时遇到了 64KB ICache 的瓶颈:2048*32B=64KB,但是这个时候已经超出了 Micro BTB 的容量,而 Main BTB 有 3 cycle 的 latency,为何还能保持 CPI=2 呢
  • 第三个台阶到 6144 条分支,CPI=4,比 Main BTB 的 3 cycle latency 略大,是因为 64KB ICache 出现了缺失,显现的是宣传的 Main BTB 的 6144 的容量,更加说明第二个台阶内 Main BTB 是命中的

那么 stride=32B 的情况下遗留了一个问题:为什么在 Main BTB 的范围内出现了 CPI=2 的平台?

stride=64B

继续观察 stride=64B 的情况:

可以看到,图像上出现了三个比较显著的台阶:

  • 第一个台阶到 16 条分支,CPI=1,对应了 16-entry 的 Nano BTB,和之前一样
  • 第二个台阶到 1024 条分支,CPI=2,此时遇到了 64KB ICache 的瓶颈:1024*64B=64KB,和之前一样
  • 第三个台阶到 3122 条分支,CPI=6,比 Main BTB 的 3 cycle latency 大,是因为 64KB ICache 出现了缺失,此时 Main BTB 的容量砍半

stride=64B 相比 stride=32B 的 Main BTB 容量砍半,这是组相连的表现:如果 PC[5] 在组相连的 index 当中,那么当 stride=64B 时,PC[5] 恒等于 0,意味着只有一半的 set 可以被用到,那也就只有一半的容量了。

Nano BTB 和 Micro BTB 容量没有变小,意味着它们大概率是全相连的:这也和它们的大小相吻合。

那么 stride=64B 的情况下遗留的问题和 stride=32B 一样:为什么在 Main BTB 的范围内出现了 CPI=2 的平台?

stride=128B

继续观察 stride=128B 的情况:

可以看到,图像上出现了三个比较显著的台阶:

  • 第一个台阶到 16 条分支,CPI=1,对应了 16-entry 的 Nano BTB,和之前一样
  • 第二个台阶到 512 条分支,CPI=2,此时遇到了 64KB ICache 的瓶颈:512*128B=64KB,和之前一样
  • 第三个台阶到 1536 条分支,CPI=6.x,比 Main BTB 的 3 cycle latency 大,是因为 64KB ICache 出现了缺失,此时 Main BTB 的容量进一步砍半

stride=128B 相比 stride=64B 的 Main BTB 容量进一步砍半,也是组相连的表现,意味着 PC[6] 也在组相连的 idnex 当中,只有四分之一的 set 可以被用到。

那么 stride=128B 的情况下遗留的问题和 stride=32B 一样:为什么在 Main BTB 的范围内出现了 CPI=2 的平台?

小结

测试到这里就差不多了,更大的 stride 得到的也是类似的结果,总结一下前面的发现:

  • Nano BTB 是 16-entry,1 cycle latency,不随着 stride 变化
  • Micro BTB 是 64-entry,2 cycle latency,也不随着 stride 变化
  • Main BTB 是 6K-entry,3 cycle latency,容量随着 stride 变化,大概率是 PC[n:5] 这一段被用于 index,使得 stride=64B 开始容量不断减半
  • 64KB ICache 很多时候会比 Main BTB 更早成为瓶颈

也总结一下前面发现了各种没有解释的遗留问题:

  • stride=4B 的情况下,Main BTB 没有像预期那样工作:解释见后
  • stride=4B 的情况下,8192 条分支处出现了性能下降:暂无解释
  • stride=8B 的情况下,只观察到 4096 的 Main BTB 容量,而不是 6144:解释见后
  • stride=8B 的情况下,在 Main BTB 命中的范围内,CPI=2.75:解释见后
  • stride=16B 的情况下,在 Main BTB 命中的范围内,CPI=2.5:解释见后
  • stride=32B 或 64B 或 128B 的情况下,在 Main BTB 命中的范围内,CPI=2:解释见后

接下来尝试解析一下这些遗留问题背后的原理。部分遗留问题,并没有被解释出来,欢迎读者提出猜想。

解析遗留问题

stride=4B 的情况下,Main BTB 没有像预期那样工作

对于这种类似 Cache 的结构,当它看起来总是没有命中的时候,其实就是每一个 Set 内要访问的数据超出了 Way,导致每次新访问的都会缺失。上面分析到,Main BTB 的 Index 大概是 PC[n:5] 这一段,那么一个对齐的 32B 范围内,分支指令都会被映射到同一个 set 内。当 stride=4B 的时候,对齐的 32B 范围内有 8 条指令;而 stride=8B 的时候,只有 4 条指令。8 条指令不行,4 条指令可以,暗示了中间跨越了 Way 的数量。

首先来回顾一下 Main BTB 的 6144-entry 是怎么来的:虽然它没说是几路组相连,但因为 6144 有一个 3 的因子,它不是二的幂次,所以一定是在 Way 数量上产生的。这就导致了至少这样几种可能:

  • 3-way set associative, 2048 sets
  • 6-way set associative, 1024 sets
  • 12-way set associative, 512 sets

回顾前面的分析:4 条指令没有超过 Way 数量,8 条指令超过了,那么只能是上述可能里的 6-way set associative,1024 sets 的情况。

翻阅 Arm® Neoverse™ N1 Core Technical Reference Manual,它是这么说的:

L1 BTB data location encoding:

  • [31:24]: RAMID = 0x02
  • [23:20]: Reserved
  • [19:18]: Way
  • [17:15]: Reserved
  • [14:5]: Index [14:5]
  • [4:0]: Reserved

它暗示了 L1 BTB(也就是 Main BTB)的 Index 是 PC[14:5],这和我们之前的观察一致。这样算出来有 2^(14-5+1)=1024 个 set,和我们前面的 6-way set associative,1024 sets 的猜测是一致的。

但是,这时候又出现一个问题:[19:18] 只能记录两位的 Way 编号,也就是说不能超过 4 个 Way,但实际上有 6 个 Way。这似乎又出现了矛盾。

继续去阅读文档里对从 BTB 读出来的数据的描述:

L1 BTB cache format:

  • Instruction Register 0 [63:0]: Data [63:0]
  • Instruction Register 1 [17:0]: Data [81:64]

这暗示了给定一个 Index 和一个 Way,可以读出来 82 bit 的数据,这不太寻常:一个分支的信息,通常不需要这么多 bit 的数据。一个 BTB Entry,通常需要这些信息:

  • valid
  • branch type: conditional or unconditional, direct or indirect, call or return, etc.
  • tag
  • replacement policy
  • part of target address

除非保存了完整的 target address 和 tag,是达不到 82 bit 这么多的。但是这样又显得很浪费,可能还有其他的可能写。

考虑到上面出现的两位的 Way 编号,并且有 3 的素数因子,只能是 3-way 组相连了。如果按 3-way 组相连,1024 个 set 来算,只有 3072 个 entry,距离 Main BTB 的容量 6144 个 entry 刚好只有一半。一个想法诞生了:如果一个 BTB entry 可以保存两个分支的信息呢?82 bit 正好是 2 的倍数,除以二是 41 bit,每个分支存 41 bit 的数据是比较合理的数据。这样,就可以推导出来,它 Main BTB 的组织方式是:

  • Index: PC[14:5],有 1024 个 set
  • 3-Way 组相连
  • 每个 Entry 是 82 bit,可以记录两条分支的信息

所以 BTB 的 Entry 怎么计算其实会比较复杂,到底是按实际的 Entry 数,还是按分支数,需要深入分析才能理解。

那么,为什么要把两条分支保存在一个 BTB Entry 里呢?Neoverse N1 并没有实现 two taken,似乎并没有放在一起的必要。而且虽然是 3-Way 组相连,匹配的时候还是 6-Way 的,那么这样做的好处是什么呢?

这时候就要提到很多处理器实现的一个优化了:大多数分支,它的目的地址距离它自己是很短的,即使考虑指令支持的最大范围,比如 AArch64 指令里面,B 指令的立即数是 26 位,B.cond 和 CBNZ 的立即数是 19 位,也比完整的虚拟地址空间小很多。针对多数的跳转距离比较短的分支,可以用一个更压缩的表示来保存,使得 BTB 可以保存更多的分支;同时,也保留针对跳转距离比较长的分支的支持。这和前面的这个设计就对上了:对于跳转距离短的分支,每 41 bit 可以保存一条分支的信息;对于跳转距离远的分支,再用 82 bit 来保存一条分支的信息。从另外一个角度来说,41 bit 也确实保存不下完整的虚拟地址,所以需要有一个方案给跳转距离远的分支兜底。

那么如果跳转距离比较远,Main BTB 的容量将会只有一半。感兴趣的读者可以设计实验来验证这一点。

小结:Main BTB 是 1024 set,3 way set associative 的结构,一共 3072 个 entry,每个 entry 可以保存两条分支,Index 是 PC[14:5]。stride=4B 的情况下,会出现一个 set 内 8 条分支的情况,无法在 3 个 entry 内放下,所以总是会出现缺失。

stride=8B 的情况下,只观察到 4096 的 Main BTB 容量,而不是 6144

在 stride=8B 的情况下,只观察到 4096 的 Main BTB 容量,实际上,用刚才分析的 Main BTB 结构,就可以分析出来。

首先,这个测试的构造方法是,给定分支数和 stride,按照这个 stride 在连续的一段虚拟地址上分布这些分支。以 stride=8B 为例,那么分支 i 的地址就是 8*i(实际情况下高位不是 0,但是所有的分支的高位是相同的,例如 0x100000000+8*i,但这不影响分析)。我们来观察一下前几个分支的信息:

  • Branch 0: addr=0x00, index=0
  • Branch 1: addr=0x08, index=0
  • Branch 2: addr=0x10, index=0
  • Branch 3: addr=0x18, index=0
  • Branch 4: addr=0x20, index=1

可以看到从分支 5 开始,到了一个新的 set,第一个 set 内出现了 4 条分支,小于一个 set 内可以保存的最多 6 条分支。接下来看从分支 4096 开始的几个分支:

  • Branch 4096: addr=0x8000, index=0
  • Branch 4097: addr=0x8008, index=0
  • Branch 4098: addr=0x8010, index=0
  • Branch 4099: addr=0x8018, index=0
  • Branch 4100: addr=0x8020, index=1

可以看到,index=0 这个 set 出现了 8 个 Branch:Branch 0-3 和 Branch 4096-4099,已经大于一个 set 内可以保存的最多 6 条分支。虽然 Main BTB 容量是 6144,但由于分支的排布方式,会首先在一个 set 里出现溢出。然后随着分支继续增加,产生溢出的 set 的比例逐渐上升,直到 8192 条分支的时候,每个 set 都完全溢出了。此时也恰好遇到了 64KB ICache 的瓶颈,如果 ICache 更大,应该会在 8192 的地方观察到一个平台,此时 Main BTB 完全缺失。

继续增加 stride,就没有了这个问题。以 stride=16B 为例子,Branch i 地址是 i*16,那么这些分支的地址是:

  • Branch 0: addr=0x00, index=0
  • Branch 1: addr=0x10, index=0
  • Branch 2: addr=0x20, index=1
  • Branch 3: addr=0x30, index=1
  • ...
  • Branch 2048: addr=0x8000, index=0
  • Branch 2049: addr=0x8010, index=0
  • Branch 2050: addr=0x8020, index=1
  • Branch 2051: addr=0x8030, index=1
  • ...
  • Branch 4096: addr=0x10000, index=0
  • Branch 4097: addr=0x10010, index=0
  • Branch 4098: addr=0x10020, index=1
  • Branch 4099: addr=0x10030, index=1
  • ...
  • Branch 6144: addr=0x18000, index=0
  • Branch 6145: addr=0x18010, index=0
  • Branch 6146: addr=0x18020, index=1
  • Branch 6147: addr=0x18030, index=1

这个时候,每个 set 会以两个 branch 的粒度来增加,由于 6 是 2 的倍数,所以从 4096 开始,set 逐渐被填满,会等到 6144 条分支才会产生溢出。

小结:由于 Main BTB 的 Index 是 PC[14:5],所以在 stride=8B 的情况下,每个 set 内以 4 个 branch 的粒度来增加,会有部分 set 已经出现溢出(只能存 6 个分支,但需要存 8 个分支),而另一部分 set 还没有满的情况(能存 6 个分支,但只存了 4 个分支)。这个拐点就是 4096 条分支。

stride=8B 的情况下,在 Main BTB 命中的范围内,CPI=2.75;stride=16B 的情况下,在 Main BTB 命中的范围内,CPI=2.5;stride=32B 或 64B 或 128B 的情况下,在 Main BTB 命中的范围内,CPI=2

前面提到,命中 Main BTB 的时候,其实 CPI 并不是 3,而是 2 到 3 之间的一个数,这似乎意味着 Main BTB 并非总是 3 周期提供一个预测。考虑到 Main BTB 容量比较大,很难单周期提供一个预测,猜测 Main BTB 可以两周期或者三周期提供一个预测。那么为什么会在不同的延迟下给出预测结果呢?

首先来分析一下 Main BTB 是如何做预测的:它首先会用传入的 VA 访问 SRAM,得到 3 个 82-bit 的数据,里面最多可以存 6 条分支指令的信息。得到这些数据以后,进行 tag 比较,筛选出其中匹配的部分。如果没有匹配的,或者只有一个匹配的分支,那都好说。但是,如果有多条匹配的分支呢?

例如,这是一个对齐的 32B 块,里面有 8 条 4 字节的指令:

0 4 8 12 16 20 24 28 +-----+-----+-----+-----+-----+-----+-----+-----+| NOP | NOP | Br | NOP | NOP | NOP | Br | NOP |+-----+-----+-----+-----+-----+-----+-----+-----+

假如要从地址 4 处开始执行,那么 Main BTB 应该要得到的是位于地址 8 的分支的信息;假如要从地址 16 处开始执行,那么 Main BTB 应该要得到的是位于地址 24 的分支的信息。为了实现这个事情,硬件上应该:

  • 首先找到在同一个 32B 块内的所有分支:通过 tag 比对,找到这个 set 内的在该 32B 内的所有分支
  • 接着,找到比输入的 VA 大于或者等于第一个分支

这个逻辑是比较复杂的,首先要筛选出地址大于或等于输入的 VA 的分支,其次要找到其中 VA 最小的分支。一个思路是保证 BTB 里面的 VA 是排好序的,但是硬件上排序并不好做,而且即使排序了,也需要做类似二分搜索的事情。另一个思路就是不管顺序,用组合逻辑把所有可能性都考虑到,计算出要找的分支。

但是这个组合逻辑比较复杂,本质上就是一个 filter+min 操作,需要比较大的延迟。三个周期能做下来,但是两个周期内,就做不下这么复杂的组合逻辑了。那怎么办呢?

观察一下 CPI 比 3 小的情况:

  • stride=8B 的 CPI=2.75
  • stride=16B 的 CPI=2.5
  • stride=32B 或 64B 或 128B 的 CPI=2

可以看到,随着 stride 增加,CPI 逐渐减少,到 stride=32B 的时候,能够稳定地达到 CPI=2 的情况。设想 Main BTB 有一个 2 周期出结果的 fast path,那么它此时可以稳定地触发;而 stride=16B 只有一半的时候可以触发 fast path:0.5*2+0.5*3=2.5;stride=8B 只有四分之一的时候可以触发 fast path:0.25*2+0.75*3=2.75。这样这些 CPI 都说得通了,其实就是有多大的概率能够触发 fast path。那么 fast path 生效的比例是:

  • stride=8B 有四分之一的概率走 fast path
  • stride=16B 有二分之一的概率走 fast path
  • stride=32B 或 64B 或 128B 一定可以走 fast path

此时你可能已经发现了一些规律:32/4=8,然后 32/2=16。也就是说,当对齐的 32B 块里,有四条分支的时候,平均只有一条分支可以走 fast path;有两条分支的时候,平均也是一条分支可以走 fast path;只有一条分支的时候,它总是可以走 fast path。

再回想一下前面的匹配逻辑:

  1. 找到该 32B 块内所有的分支:这一步免不了
  2. 找到大于或等于输入 VA 地址的所有分支:这一步也免不了
  3. 找到第一个满足要求的分支:如果只有一条分支,那就不用寻找最小值了,这就是 fast path 的条件

这就解释了前面的现象:当 stride=8B 的时候,对齐的 32B 块内部是:

0 4 8 12 16 20 24 28 +-----+-----+-----+-----+-----+-----+-----+-----+| Br | NOP | Br | NOP | Br | NOP | Br | NOP |+-----+-----+-----+-----+-----+-----+-----+-----+

分支预测的时候,用的地址分别是 0、8、16 和 24。当用 0、8 和 16 的输入 VA 查询的时候,分别能找到 4、3 和 2 条 VA 大于或等于输入 VA 的分支。只有在用 24 的输入 VA 查询的时候,只能找到一条分支,不需要再求 min。

stride=16B 的情况类似,在预测第二条分支的时候,只有一条分支满足要求,可以走 fast path。

stride=32B 或更大的时候,对齐的 32B 块内都只有一条分支,满足走 fast path 的条件。

这就解释了前面看到的各种奇怪的 CPI 现象。

那么,为什么只有 Main BTB 会出现这种现象呢,理论上来说,Nano BTB 和 Micro BTB 也可以做类似的优化?这就涉及到了 BTB 的不同的组织方式:

  1. 一种是 Main BTB 这种,每条分支只保存一份,那么为了找到某个 VA 开始的第一条分支,就需要把满足要求的分支都找出来,再寻找地址最小的那一个;具体实现上,也有两种情况:
    1. 对于每个可能出现分支指令的地址,都进行一次 BTB 查询(这种结构叫 Instruction BTB)
    2. 对于每个对齐的块,记录这个块内的有限条分支的信息(这种结构叫 Region BTB),Main BTB 采用的就是这种,每个对齐的 32B 块内最多保存六条分支
  2. 另一种结构,则是直接记录从某个 VA 开始的第一条分支,即给定 VA,查询 BTB 后,匹配到的 entry 里记录的就是从这个 VA 开始的第一条分支(这种结构叫做 Block BTB);这样一条分支可能会出现在多个 entry 内,此时就不会涉及到上面所述的 fast path 优化

那么猜测 Nano BTB 和 Micro BTB 采用了 Block BTB 的方法,或者因为延迟本身就足够低,即使可以做 fast path,也没有引入 fast path 优化。

详细的 BTB 设计分析,可以参考 浅谈乱序执行 CPU(三:前端) 的相关内容。

小结:Main BTB 可以在 2 或 3 周期提供预测,其中 2 周期预测的条件是,只找到一条 VA 大于或等于输入 VA 的分支,此时可以跳过求 min 的组合逻辑,在第二个周期给出预测。

模拟

既然已经知道了它的 BTB 结构,就写了一段程序来模拟它的工作过程:

// Cortex-A76/Neoverse-N1 BTB model// 16-entry Nano BTB, fully associative, 1 cycle latency.// 64-entry Micro BTB, fully associative, 2 cycle latency.// 3072-entry Main BTB, 3-way set associative, 2-3 cycle latency, each entry at// most 2 branches, index PC[14:5].#include <cassert>#include <set>#include <stdint.h>#include <stdio.h>#include <utility>#include <vector>struct BTBEntry { bool valid; uint64_t pc; uint64_t target;};typedef BTBEntry NanoBTBEntry;typedef BTBEntry MicroBTBEntry;typedef BTBEntry MainBTBEntry;struct BTB { NanoBTBEntry nanoBTB[16]; MicroBTBEntry microBTB[64]; // pretend as 6-way MainBTBEntry mainBTB[1024][6]; // return latency // use pc to predict a branch at pc, i.e. pva = pc int match(uint64_t pc, uint64_t target) { int result = 5; // miss penalty // Nano BTB at P1 for (int i = 0; i < 16; i++) { if (nanoBTB[i].pc == pc && nanoBTB[i].target == target) { // Nano BTB hit // LRU: move it to head for (int j = i; j > 0; j--) { nanoBTB[j] = nanoBTB[j - 1]; } nanoBTB[0].pc = pc; nanoBTB[0].target = target; result = 1; goto main_btb; } } // Nano BTB miss, check Micro BTB at P1 // like victim cache for (int i = 0; i < 64; i++) { if (microBTB[i].pc == pc && microBTB[i].target == target) { // Micro BTB hit // Move to Nano BTB for (int j = i; j > 0; j--) { microBTB[j] = microBTB[j - 1]; } microBTB[0].pc = nanoBTB[16 - 1].pc; microBTB[0].target = nanoBTB[16 - 1].target; for (int j = 16 - 1; j > 0; j--) { nanoBTB[j] = nanoBTB[j - 1]; } nanoBTB[0].pc = pc; nanoBTB[0].target = target; result = 2; goto main_btb; } } // Micro BTB miss for (int j = 64 - 1; j > 0; j--) { microBTB[j] = microBTB[j - 1]; } microBTB[0].pc = nanoBTB[16 - 1].pc; microBTB[0].target = nanoBTB[16 - 1].target; for (int j = 16 - 1; j > 0; j--) { nanoBTB[j] = nanoBTB[j - 1]; } nanoBTB[0].pc = pc; nanoBTB[0].target = target; main_btb: // check Main BTB // PC[4:2] uint64_t offset = pc & 0x1c; // PC[14:5] uint64_t index = (pc & 0x7fe0) >> 5; assert(index < 1024); // PC[n:15] uint64_t tag = pc >> 15; uint64_t min_offset = -1; int min_i = -1; int matches = 0; for (int i = 0; i < 6; i++) { // find matches if ((mainBTB[index][i].pc >> 15) == tag && mainBTB[index][i].valid) { // check offset if ((mainBTB[index][i].pc & 0x1c) >= offset) { if (min_i == -1) { min_i = i; min_offset = mainBTB[index][i].pc & 0x1c; } else if ((mainBTB[index][i].pc & 0x1c) < min_offset) { min_i = i; min_offset = mainBTB[index][i].pc & 0x1c; } matches++; } } } // hit if (min_offset == offset) { if (matches != 0) { // LRU MainBTBEntry temp = mainBTB[index][min_i]; for (int i = min_i; i > 0; i--) { mainBTB[index][i] = mainBTB[index][i - 1]; } mainBTB[index][0] = temp; } if (matches == 1) { // fast path if (result == 5) { result = 2; goto end; } } else if (matches > 1) { // slow path if (result == 5) { result = 3; goto end; } } } // miss for (int i = 6 - 1; i > 0; i--) { mainBTB[index][i] = mainBTB[index][i - 1]; } mainBTB[index][0].pc = pc; mainBTB[index][0].target = target; mainBTB[index][0].valid = true; end: // BTB miss return result; }};int main() { FILE *fp = fopen("btb_size.csv", "w"); int min_size = 2; int max_size = 8192; int max_product = 1048576; std::vector<int> mults = {1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 31, 33, 35, 37, 39}; fprintf(fp, "pattern,size,stride,min,avg,max\n"); for (int stride = 4; stride <= 128; stride *= 2) { std::set<int> sizes; for (uint64_t size_base = min_size; size_base <= max_product / stride; size_base *= 2) { for (uint64_t mult : mults) { for (uint64_t size = size_base * mult - 1; size <= size_base * mult + 1 && size * stride <= max_product && size <= max_size; size++) { sizes.insert(size); } } } for (int size : sizes) { BTB btb; memset(&btb, 0, sizeof(btb)); int cycles = 0; int branch_count = 1000 * size; // warmup for (int i = 0; i < branch_count; i++) { uint64_t pc = ((i % size) * stride); uint64_t target = (((i + 1) % size) * stride); btb.match(pc, target); } // test for (int i = 0; i < branch_count; i++) { uint64_t pc = ((i % size) * stride); uint64_t target = (((i + 1) % size) * stride); cycles += btb.match(pc, target); } float cpi = (float)cycles / branch_count; fprintf(fp, "0,%d,%d,%.2f,%.2f,%.2f\n", size, stride, cpi, cpi, cpi); fflush(fp); } } return 0;}

这个模型只评估了 BTB 的性能影响,没有考虑 ICache。下面是模拟和实际的对比图,左边是模拟,右边是实际:

stride=4B:

stride=8B:

stride=16B:

stride=32B:

stride=64B:

stride=128B:

可以看到模型和实际的表现是非常一致的。

总结

最后总结一下 Neoverse N1 的 BTB:

  • 16-entry Nano BTB, fully associative, 1 cycle latency
  • 64-entry Micro BTB, fully associative, 2 cycle latency
  • 3072-entry(6144 branches) Main BTB, 3-way(6-branch-way) set associative, 2-3 cycle latency, each entry at most 2 branches, index PC[14:5]