2025-06-23 08:00:00
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:
但是很多细节是缺失的,因此下面结合微架构测试,进一步研究它的内部结构。
在之前的博客里,我们已经测试了各种处理器的 BTB,在这里也是一样的:按照一定的 stride 分布无条件(uncond)或总是跳转的有条件(cond)直接分支,构成一个链条,然后测量 CPI。在先前的 Neoverse N1 测试 里,我们只测试了无条件分支,但实际上,在 Neoverse N1 上用条件分支测出来的结果也是一样的,但在 Neoverse V1 上就不同了,所以在这里要分开讨论。
首先是 stride=4B uncond 的情况:
可以看到,图像上出现了如下比较显著的台阶:
那么 stride=4B uncond 的情况下就遗留了如下问题:
stride=4B cond 的情况:
可以看到,图像上出现了如下比较显著的台阶:
nano BTB 只表现出 48 的容量,刚好是 96 的一半;同时没有观察到 2 predicted branches per cycle。考虑这两点,可以认为 nano BTB 的组织方式和分支类型有关,当分支过于密集(stride=4B)或者用条件分支(cond)时,不能得到完整的 96-entry 的大小,此时也会回落到 CPI=1 的情况。
那么 stride=4B cond 的情况下就遗留了如下问题:
stride=8B uncond 的情况:
可以看到,图像上出现了如下比较显著的台阶:
此时 nano BTB 完整地表现出了它的 96-entry 容量,并且实现了 CPI=0.5 的效果。main BTB 也实现了 CPI=1,考虑到它的容量不太可能单周期给出一个分支的结果,大概率是两个周期预测两条分支指令。
那么 stride=8B uncond 的情况下就遗留了如下问题:
stride=8B cond 的情况:
可以看到,图像上出现了如下比较显著的台阶:
和之前一样,遇到 cond 分支,nano BTB 的容量只有一半,也观察不到 2 predicted branches per cycle。另一边,main BTB 的 CPI 也到了 2,意味着此时 main BTB 也只能两个周期预测一条分支指令,和之前的分析吻合。
那么为什么用条件分支,就不能预测两条分支指令了呢?猜测是,BTB 可以一次给出两条分支的信息,但是没有时间去同时预测这两条分支的方向。所以就回落到了普通的 2 cycle BTB 情况。
stride=16B uncond 的情况:
可以看到,图像上出现了如下比较显著的台阶:
那么 stride=16B uncond 的情况下就遗留了如下问题:
stride=16B cond 的情况:
可以看到,图像上出现了如下比较显著的台阶:
预测的效果和 stride=8B cond 完全相同。
那么 stride=16B cond 的情况下就遗留了如下问题:
stride=32B uncond 的情况:
可以看到,图像上出现了如下比较显著的台阶:
那么 stride=32B uncond 的情况下就遗留了如下问题:
stride=32B cond 的情况:
可以看到,图像上出现了如下比较显著的台阶:
基本符合预期,只是在 stride=16B cond 的基础上,引入了 64KB L1 ICache 导致的性能下降。
stride=64B uncond 的情况:
可以看到,图像上出现了如下比较显著的台阶:
stride=64B cond 的情况:
可以看到,图像上出现了如下比较显著的台阶:
测试到这里就差不多了,更大的 stride 得到的也是类似的结果,总结一下前面的发现:
也总结一下前面发现了各种没有解释的遗留问题:
接下来尝试解析一下这些遗留问题背后的原理。部分遗留问题,并没有被解释出来,欢迎读者提出猜想。
比较相同 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:
然后下一个周期从 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:
类似的情况,我们在分析 Neoverse N1 的时候就遇到了。Neoverse N1 的情况是,每对齐的 32B 块内,由于 6 路组相连,最多记录 6 条分支,而 stride=4B 时,有 8 条分支,所以出现了性能问题。
那么 Neoverse V1 是不是还是类似的情况呢?查阅 Neoverse V1 TRM,可以看到它的 L1 (main) BTB 的描述是:
回想之前 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:
当 uncond + uncond 或者 cond + uncond 时,可以实现每次预测两条分支;对于 cond + cond,每次只能预测一条分支。
2 predicted branches per cycle 通常也被称为 2 taken branches per cycle,简称 2 taken。
根据官方信息,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 V1 和 Neoverse N1 的设计:
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 是行优先的,也就是说:
0
width-1
width*(height-1)
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 内部,实际的效果大概是:
上图中几个点的坐标以及对应的 bitmap 数组的下标:
0
width-1
width*(height-1)
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 中:
vertex shader 只是简单地把这些信息传递到顶点的坐标和 fragment shader。fragment shader 做的事情是:
texture(text, texCoords).r
就代表了这个字体在这个位置的 alpha 值(1.0, 1.0, 1.0, alpha)
,即带有 alpha 的白色在绘制文字之前,先绘制好背景色,然后通过设置 blending function:
它使得 blending 采用如下的公式:
这里 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 这个顶点的坐标是 (xpos, ypos),然后矩形的宽度是 w,高度是 h,考虑到 OpenGL 的坐标系也是向右 X 正方向,向上 Y 正方向,那么这四个顶点的坐标:
接下来考虑这些顶点对应的 uv 坐标。首先,我们知道这些顶点对应的 bitmap 的下标在哪里;然后我们又知道这些 bitmap 的下标对应的 uv 坐标,那就每个顶点找一次对应关系:
width*(height-1)
,uv 坐标是 (0, 1)width*(height-1)+width-1
,uv 坐标是 (1, 1)0
,uv 坐标是 (0, 0)width-1
,uv 坐标是 (1, 0)为了绘制这个矩形,绘制两个三角形,分别是 3->1->2 和 3->2->4,一共六个顶点的 (x, y, u, v) 信息就是:
把这些数传递给 vertex shader,就可以画出来这个字符了。
最后还有一个小细节:上述的 xpos 和 ypos 说的是矩形左下角的坐标,但是我们画图的时候,实际上期望的是把字符都画到同一条线上。也就是说,我们指定 origin 的 xy 坐标,然后根据每个字符的 bearingX 和 bearingY 来算出它的矩形的左下角的坐标 xpos 和 ypos:
至此就实现了逐个字符绘制需要的所有内容。这也是 Text Rendering - Learn OpenGL 这篇文章所讲的内容。
上面这种逐字符绘制的方法比较简单,但是也有硬伤,比如每次绘制字符,都需要切换 texture,更新 buffer,再进行一次 glDrawArrays 进行绘制,效率比较低。所以一个想法是,把这些 bitmap 拼接起来,合成一个大的 texture,然后把每个字符在这个大的 texture 内的 uv 坐标保存下来。这样,可以一次性把所有字符的所有三角形都传递给 OpenGL,一次绘制完成,不涉及到 texture 的切换。这样效率会高很多。
具体到代码上,也就是分成两步:
此外,在前面的 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 方式,比如:
根据这个,就可以想到,设置 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 用的就是这种实现方法:
注:如果在 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 渲染都需要哪些事情:
首先,在 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:
创建 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:
在这之后就可以用 OpenGL 的各种函数了。OpenGL 绘制完成以后,更新到窗口上:
在 ResizeSurface 中,主要是更新 glViewport,让它按照新的 surface 大小来绘制。
2025-06-10 08:00:00
最近在研究鸿蒙电脑,群友 @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。
各内核版本启动情况:
echo simpledrm > /etc/modules-load.d/simpledrm.conf
后正常,否则系统可以启动但是图形界面起不来经过 @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。
2025-06-06 08:00:00
前段时间把 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,或许也是能实现的,但目前还没有去做进一步的尝试。
2025-06-06 08:00:00
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,这台鸿蒙电脑有风扇,有点小小的不习惯,但还算安静。
规格如下:
预装的版本是 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 日)应用商城有这些软件:
暂时还没有微信,可以通过操控手机来发微信,但是在消息栏里按回车是换行,没找到发送按钮对应的电脑按键,需要手动操。但是居然有企业微信。
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 性能测试:
详细数据: 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,显示支持 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.hap
的 hnp/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/SPECCPU2017Harmony 和 https://github.com/jiegec/NetworkToolsHarmony 都能正常在 MateBook Pro 上运行。
测试的过程中,发现用 hdc 传文件到电脑比传手机更快:Pura 70 Pro+ 是 24 MB/s,MateBook Pro 是 31 MB/s。
开源的鸿蒙应用也可以编译 + 运行:
目前还没找到怎么让鸿蒙电脑自己调试自己。
目前通过 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
2025-06-05 08:00:00
ARM Neoverse N1 是比较早的一代 ARM 服务器的处理器,它在很多地方都和 Cortex-A76 类似。它的 BTB 结构比较有意思,所以在这里对它的 BTB 做一些分析。
首先收集了一些 ARM Neoverse N1 的 BTB 结构的官方信息:
简单整理一下官方信息,大概有三级 BTB:
但是很多细节是缺失的,因此下面结合微架构测试,进一步研究它的内部结构。
在之前的博客里,我们已经测试了各种处理器的 BTB,在这里也是一样的:按照一定的 stride 分布无条件直接分支,构成一个链条,然后测量 CPI。
首先是 stride=4B 的情况:
可以看到,图像上出现了三个比较显著的台阶:
80=16+64
,多出来的部分对应了 64-entry 的 Micro BTB4B*16384=64KB
那么 stride=4B 的情况下就遗留了两个问题:为什么没有命中 Main BTB;8192 处为什么出现了性能下降。
接下来观察 stride=8B 的情况:
可以看到,图像上出现了三个比较显著的台阶:
80=16+64
,多出来的部分对应了 64-entry 的 Micro BTB,和之前一样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 的情况:
可以看到,图像上出现了四个比较显著的台阶:
80=16+64
,多出来的部分对应了 64-entry 的 Micro BTB,和之前一样4096*16B=64KB
相比 stride=8B,Nano BTB 和 Micro BTB 的行为没有变化;Main BTB 的 6144 容量开始显现,并且出现地比 64KB ICache 更晚。
那么 stride=16B 的情况下遗留了一个问题:为什么出现了 CPI=2.5 的平台?
继续观察 stride=32B 的情况:
可以看到,图像上出现了三个比较显著的台阶:
2048*32B=64KB
,但是这个时候已经超出了 Micro BTB 的容量,而 Main BTB 有 3 cycle 的 latency,为何还能保持 CPI=2 呢那么 stride=32B 的情况下遗留了一个问题:为什么在 Main BTB 的范围内出现了 CPI=2 的平台?
继续观察 stride=64B 的情况:
可以看到,图像上出现了三个比较显著的台阶:
1024*64B=64KB
,和之前一样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 的情况:
可以看到,图像上出现了三个比较显著的台阶:
512*128B=64KB
,和之前一样stride=128B 相比 stride=64B 的 Main BTB 容量进一步砍半,也是组相连的表现,意味着 PC[6] 也在组相连的 idnex 当中,只有四分之一的 set 可以被用到。
那么 stride=128B 的情况下遗留的问题和 stride=32B 一样:为什么在 Main BTB 的范围内出现了 CPI=2 的平台?
测试到这里就差不多了,更大的 stride 得到的也是类似的结果,总结一下前面的发现:
也总结一下前面发现了各种没有解释的遗留问题:
接下来尝试解析一下这些遗留问题背后的原理。部分遗留问题,并没有被解释出来,欢迎读者提出猜想。
对于这种类似 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 数量上产生的。这就导致了至少这样几种可能:
回顾前面的分析: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:
这暗示了给定一个 Index 和一个 Way,可以读出来 82 bit 的数据,这不太寻常:一个分支的信息,通常不需要这么多 bit 的数据。一个 BTB Entry,通常需要这些信息:
除非保存了完整的 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 的组织方式是:
所以 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 容量,实际上,用刚才分析的 Main BTB 结构,就可以分析出来。
首先,这个测试的构造方法是,给定分支数和 stride,按照这个 stride 在连续的一段虚拟地址上分布这些分支。以 stride=8B 为例,那么分支 i 的地址就是 8*i
(实际情况下高位不是 0,但是所有的分支的高位是相同的,例如 0x100000000+8*i
,但这不影响分析)。我们来观察一下前几个分支的信息:
可以看到从分支 5 开始,到了一个新的 set,第一个 set 内出现了 4 条分支,小于一个 set 内可以保存的最多 6 条分支。接下来看从分支 4096 开始的几个分支:
可以看到,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
,那么这些分支的地址是:
这个时候,每个 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 条分支。
前面提到,命中 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 的分支的信息。为了实现这个事情,硬件上应该:
这个逻辑是比较复杂的,首先要筛选出地址大于或等于输入的 VA 的分支,其次要找到其中 VA 最小的分支。一个思路是保证 BTB 里面的 VA 是排好序的,但是硬件上排序并不好做,而且即使排序了,也需要做类似二分搜索的事情。另一个思路就是不管顺序,用组合逻辑把所有可能性都考虑到,计算出要找的分支。
但是这个组合逻辑比较复杂,本质上就是一个 filter+min 操作,需要比较大的延迟。三个周期能做下来,但是两个周期内,就做不下这么复杂的组合逻辑了。那怎么办呢?
观察一下 CPI 比 3 小的情况:
可以看到,随着 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 生效的比例是:
此时你可能已经发现了一些规律:32/4=8
,然后 32/2=16
。也就是说,当对齐的 32B 块里,有四条分支的时候,平均只有一条分支可以走 fast path;有两条分支的时候,平均也是一条分支可以走 fast path;只有一条分支的时候,它总是可以走 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 的不同的组织方式:
那么猜测 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: