2025-07-08 08:00:01
在之前,我们分析了 AMD Zen 1 和 AMD Zen 2 的 BTB,接下来分析它的再下一代微架构:2020 年发布的 AMD Zen 3 的 BTB,看看 AMD 的 Zen 系列的 BTB 是如何演进的。
AMD 在 Software Optimization Guide for AMD EPYC™ 7003 Processors (Publication No. 56665) 中有如下的表述:
The branch target buffer (BTB) is a two-level structure accessed using the fetch address of the previous fetch block.
Zen 3 的 BTB 有两级,相比 Zen 1 和 Zen 2 少了一级。BTB 是用之前 fetch block 的地址去查询,而不再是当前 fetch block 的地址。用当前 fetch block 的地址查询 BTB 很好理解,要寻找某个地址开始的第一个分支,就用这个地址去查询 BTB,Zen 1 和 Zen 2 都是如此;用之前 fetch block 的地址,则是用更早的信息,去获取当前 fetch block 的信息,例如:
在查询从 entrypoint2 开始的第一条分支指令的时候,如果使用当前 fetch block,就是用 entrypoint2 的地址去查询,那就必须等到前面 jmp entrypoint2
指令的目的地址被计算得出;如果使用之前 fetch block,就是用 entrypoint1 的地址去查询,不用等到 jmp entrypoint2
指令的目的地址被计算得出。因此,如果用之前 fetch block,可以更早地进行 BTB 的访问,从而减少 BTB 的延迟,或者在相同延迟下获得更大的容量。但是,代价是:
Each BTB entry can hold up to two branches if the last bytes of the branches reside in the same 64-byte aligned cache line and the first branch is a conditional branch.
Zen 3 的 BTB entry 有一定的压缩能力,一个 entry 最多保存两条分支,前提是两条分支在同一个 64B 缓存行中,并且第一条分支是条件分支。这样,如果第二条分支是无条件分支,分支预测的时候,可以根据第一条分支的方向预测的结果,决定要用哪条分支的目的地址作为下一个 fetch block 的地址。虽然有压缩能力,但是没有提到单个周期预测两条分支,所以只是扩大了等效 BTB 容量。和 Zen 1、Zen 2 一样。
L1BTB has 1024 entries and predicts with zero bubbles for conditional and unconditional direct branches, and one cycle for calls, returns and indirect branches.
Zen 3 的第一级 BTB 可以保存 1024 个 entry,但不确定这个 entry 是否可以保存两条分支,也不确定这个 entry 数量代表了实际的 entry 数量还是分支数量,后续会做实验证实;针对条件和无条件直接分支的预测不产生气泡,意味着它的延迟是一个周期。相比 Zen 2 容量翻倍,且延迟降低一个周期,猜测和使用 previous fetch block 有关。
L2BTB has 6656 entries and creates three bubbles if its prediction differs from L1BTB.
Zen 3 的第二级 BTB 可以保存 6656 个 entry,但不确定这个 entry 是否可以保存两条分支,也不确定这个 entry 数量代表了实际的 entry 数量还是分支数量,后续会做实验证实;预测会产生三个气泡,意味着它的延迟是四个周期。
简单整理一下官方信息,大概有两级 BTB:
相比 Zen 1 和 Zen 2 有比较大的不同:去掉了原来很小的 L0 BTB,扩大了 L1 BTB,同时延迟缩短了一个周期;虽然 L2 BTB 有所缩小,但是延迟也变短了一个周期。
下面结合微架构测试,进一步研究它的内部结构。
在之前的博客里,我们已经测试了各种处理器的 BTB,在这里也是一样的:按照一定的 stride 分布无条件直接分支,构成一个链条,然后测量 CPI。
考虑到 Zen 3 的 BTB 可能出现一个 entry 保存两条分支的情况,并且还对分支的类型有要求,因此下面的测试都会进行四组,分别对应四种分支模式:
虽然 Zen 3 使用 previous fetch block 来访问 BTB,但在这几种分支模式中,使用 previous fetch block 还是访问 current fetch block,结果都是唯一的,所以并不会对结果带来影响。
首先是 stride=4B 的情况:
可以看到,图像上出现了三个比较显著的拐点:
Zen 3 在 stride=4B 的情况下 L1 BTB 表现比较一般,应该是牺牲了高密度分支下的性能;而主要命中的是 L2 BTB,在不同的分支模式下,测出来差不多的结果。为了验证这一点,统计了如下的性能计数器(来源:Processor Programming Reference (PPR) for AMD Family 19h Model 21h, Revision B0 Processors):
PMCx08B [L2 Branch Prediction Overrides Existing Prediction (speculative)] (Core::X86::Pmc::Core::BpL2BTBCorrect)
它代表了 L2 BTB 提供预测(准确地说,L2 BTB 提供了预测且和 L1 BTB 提供的预测结果不同,覆盖了 L1 BTB 的预测结果)的次数,当分支数不大于 4 的时候,这个计数器的值约等于零;此后快速上升,说明后续都是 L2 BTB 在提供预测。
更进一步观察,发现 2048 到 4096 的 CPI 上升,来自于 L1 BTB 完全失效:2048 条分支时,L1 BTB 还能提供约 10% 的预测,所以 CPI=0.1*1+0.9*4=3.7
,但到 4096 条分支的时候,完全由 L2 BTB 提供分支,此时 CPI=4。
超过 4096 以后,则 L2 BTB 也开始缺失,出现了译码时才能发现的分支,如果这是一条 uncond 分支,那么会在译码时回滚,这一点可以通过如下性能计数器的提升来证明(来源:Processor Programming Reference (PPR) for AMD Family 19h Model 21h, Revision B0 Processors):
PMCx091 [Decode Redirects] (Core::X86::Pmc::Core::BpDeReDirect): The number of times the instruction decoder overrides the predicted target.
但在 L2 BTB 缺失后,如果译码器发现了 cond 分支,会把它预测为不跳转,所以要等到执行才能发现分支预测错误。这就导致了 cond 模式下 L2 BTB 溢出时 CPI=16,而 uncond 模式下 L2 BTB 溢出时 CPI=12,提前在译码阶段发现了 uncond 分支并纠正。
但译码器的纠正能力不是万能的:假如它首先发现了一条 cond 分支,在它其后又发现了一条 uncond 分支,它会用 uncond 分支去纠正,但实际上前面的 cond 分支会跳转,所以此时译码器纠正也无法提升性能,即使 BpDeReDirect 计数器的值看起来很大。
接下来观察 stride=8B 的情况:
L2 BTB 的容量不太确定,超过 4096 后需要一个 entry 保存两条分支才能获得更多容量,但也带来了一定的额外的延迟。与此同时 4096 也对应了 32KB ICache 的容量,这会对分析带来干扰。
从 BpDeReDirect 计数器来看,uncond 分支模式下,当分支数量超过 4096 后,L2 BTB 从 4096 时无缺失,之后缺失快速提升,说明此时 L2 BTB 容量确实是 4096。在 mix (cond + uncond) 模式下,分支数超过 4096 时,BpDeReDirect 计数器略微上升,直到 6144 条分支后才有明显的上升。
继续观察 stride=16B 的情况:
相比 stride=8B,L1 BTB 的行为没有变化。4096 对应的 CPI 有所下降,从 BpL2BTBCorrect 性能计数器可以发现是 L1 BTB 起了一定的作用。在 mix (cond + uncond) 模式下,直到 5632 条分支还维持了 CPI=3.25,之后 CPI 缓慢上升,到 6656 条分支时 CPI=3.75,到 6912 条分支时 CPI=4。
CPI=3.25 可能是来自于 1 和 4 的加权平均:25% 的时候是 1 周期,75% 的时候是 4 周期,平均下来就是 1*0.25+4*0.75=3.25
。这意味着 L1 BTB 还要保持 25% 的命中率。观察 BpL2BTBCorrect 性能计数器,发现它的取值等于 75% 的分支执行次数,意味着 L1 BTB 确实提供了 25% 的预测,L2 BTB 提供了剩下 75% 的预测。这一点是挺有意思的,意味着 L1 BTB 可能采用了一些对这种循环访问模式友好的替换策略:朴素的 LRU(或类 LRU)替换策略会导致 L1 BTB 出现 100% 缺失。
继续观察 stride=32B 的情况:
相比 stride=16B,L1 BTB 的行为没有变化,但是出现了一些性能波动。所有分支模式下,L2 BTB 的拐点都出现在 5120,但性能波动比较大,mix (cond + uncond) 模式下的 CPI 达到了 4.6。通过 BpDeReDirect 性能计数器的变化,可以确认这个拐点确实是来自于 L2 BTB 的缺失。
前面提到,译码器的纠正能力可能会给出错误的答案,在 stride=32B 时,就会出现一个很有意思的现象:
解释起来也并不复杂:stride=32B 的情况下,一个 64B cacheline 只有两条分支,那么:
顺带一提,uncond 模式下的 BpDeReDirect 占分支数量的接近 100%,cond 模式下的 BpDeReDirect 占分支数量的 0%,都是符合预期的。
继续观察 stride=64B 的情况:
相比 stride=32B,L1 BTB 的容量减半,达到了 512。之后出现了比较明显的性能波动,但四种分支模式下,拐点依然都是出现在 5120 条分支的位置。通过 BpDeReDirect 性能计数器的变化,可以确认这个拐点确实是来自于 L2 BTB 的缺失。由于 uncond 模式下,BTB sharing 不会工作,意味着 L2 BTB 至少有 5120 个 entry。
继续观察 stride=128B 的情况:
相比 stride=64B,L1 BTB 的容量进一步减小,达到了 256;L2 BTB 的性能依然波动剧烈,但四种分支模式下,拐点依然都是出现在 5120 条分支的位置。
考虑到 5120 这个拐点频繁出现,认为 L2 BTB 在不考虑 BTB entry sharing 的情况下,实际容量应该是 5120。那么剩下的 1536 个分支就是来自于压缩。
测试到这里就差不多了,更大的 stride 得到的也是类似的结果,总结一下前面的发现:
下面是对比表格:
uArch | AMD Zen 1 | AMD Zen 2 | AMD Zen 3 |
---|---|---|---|
L0 BTB size | 4+4 branches | 8+8 branches | N/A |
L0 BTB latency | 1 cycle | 1 cycle | N/A |
L1 BTB size | 256 branches | 512 branches | 1024 branches |
L1 BTB latency | 2 cycles | 2 cycles | 1 cycle |
L2 BTB size w/o sharing | 2K branches | 4K branches | 5K branches |
L2 BTB size w/ sharing | 4K branches | 7K branches | 6.5K branches |
L2 BTB latency | 5 cycles | 5 cycles | 4 cycles |
Technology Node | 14nm | 7nm | 7nm |
Release Year | 2017 | 2019 | 2020 |
Zen 3 在 Zen 2 的基础上,没有更换制程,而是通过 previous fetch block 的方式,减少 L1 BTB 的延迟到 1 cycle,顺带去掉了 L0 BTB。L2 BTB 的大小进行了调整,减少了共享的部分,而增加了不限制分支类型的 BTB entry 数量,同时减少了一个周期的延迟,不确定这个延迟是单纯通过优化容量实现的,还是说也依赖了 previous fetch block 的方法来减少周期,更倾向于是后者,因为 L1 和 L2 BTB 都减少了一个周期的延迟。
如果按照 Intel 的 tick-tock 说法,那么 Zen 2 相比 Zen 1 是一次 tick,更换制程,微架构上做少量改动;Zen 3 相比 Zen 2 是一次 tock,不更换制程,但是在微架构上做较多改动。Zen 4 是 2022 年发布的,使用的是 5nm 制程;Zen 5 是 2024 年发布的,使用的是 4nm 制程。总结一下规律,AMD 会花费两年的时间来升级制程,并且实际上,Zen 4 和 Zen 5 不仅更新了制程,还在前端微架构上有较大的改动。
AMD Zen 3 和 ARM Neoverse V1 都是在 2020 发布的处理器,下面对它们进行一个对比:
uArch | AMD Zen 3 | ARM Neoverse V1 |
---|---|---|
L1/Nano BTB size | 1024 branches | 48*2 branches |
L1/Nano BTB latency | 1 cycle | 1 cycle |
L1/Nano BTB throughput | 1 branch | 1-2 branches |
L2/Main BTB size w/o sharing | 5K branches | 4K*2 branches |
L2/Main BTB size w/ sharing | 6.5K branches | 4K*2 branches |
L2/Main BTB latency | 4 cycles | 2 cycles |
L2/Main BTB throughput | 1 branch | 1-2 branches |
Technology Node | 7nm | 5nm |
虽然 AMD Zen 3 通过 previous fetch block 优化,实现了 1 cycle 下更大的 L1 BTB,但这一点在 2022 年发布的 ARM Neoverse V2 上被追赶:ARM Neoverse V2 的 L1/Nano BTB 也做到了 1024 的容量。
在 L2 BTB 方面,ARM Neoverse V1 占据了领先,无论是延迟还是容量;当然了,ARM Neoverse V1 的制程也要更加领先,ARM 采用的 5nm 对比 AMD 采用的 7nm。
更进一步,ARM Neoverse V1 实现了一个周期预测两条分支,即 two taken(ARM 的说法是 two predicted branches per cycle),在 2 cycle 的 Main BTB 上可以实现接近 AMD Zen 3 的 L1 BTB 的预测吞吐。AMD 也不甘示弱,在 2022 年发布的 AMD Zen 4 处理器上,实现了 two taken。
2025-07-08 08:00:00
在之前,我们分析了 AMD Zen 1 的 BTB,接下来分析它的下一代微架构:2019 年发布的 AMD Zen 2 的 BTB,看看 AMD 的 Zen 系列的 BTB 是如何演进的。
AMD 在 Software Optimization Guide for AMD EPYC™ 7002 Processors (Publication No. 56305) 中有如下的表述:
The branch target buffer (BTB) is a three-level structure accessed using the fetch address of the current fetch block.
Zen 2 的 BTB 有三级,是用当前 fetch block 的地址去查询,和 Zen 1 一样。
Each BTB entry includes information for branches and their targets. Each BTBentry can hold up to two branches if the branches reside in the same 64-byte aligned cache line and the first branch is a conditional branch.
Zen 2 的 BTB entry 有一定的压缩能力,一个 entry 最多保存两条分支,前提是两条分支在同一个 64B 缓存行中,并且第一条分支是条件分支。这样,如果第二条分支是无条件分支,分支预测的时候,可以根据第一条分支的方向预测的结果,决定要用哪条分支的目的地址作为下一个 fetch block 的地址。虽然有压缩能力,但是没有提到单个周期预测两条分支,所以只是扩大了等效 BTB 容量。和 Zen 1 一样。
L0BTB holds 8 forward taken branches and 8 backward taken branches, and predicts with zero bubbles
Zen 2 的第一级 BTB 可以保存 8 条前向分支和 8 条后向分支,预测不会带来流水线气泡,也就是说每个周期都可以预测一次。相比 Zen 1 容量翻倍。
L1BTB has 512 entries and creates one bubble if prediction differs from L0BTB
Zen 2 的第二级 BTB 可以保存 512 个 entry,但不确定这个 entry 是否可以保存两条分支,也不确定这个 entry 数量代表了实际的 entry 数量还是分支数量,后续会做实验证实;预测会产生单个气泡,意味着它的延迟是两个周期。相比 Zen 1 容量翻倍。
L2BTB has 7168 entries and creates four bubbles if its prediction differs from L1BTB.
Zen 2 的第三级 BTB 可以保存 7168 个 entry,但不确定这个 entry 是否可以保存两条分支,也不确定这个 entry 数量代表了实际的 entry 数量还是分支数量,后续会做实验证实;预测会产生四个气泡,意味着它的延迟是五个周期。
简单整理一下官方信息,大概有三级 BTB:
从表述来看,除了容量以外基本和 Zen 1 一致,猜测是在 Zen 1 的基础上扩大了容量。
下面结合微架构测试,进一步研究它的内部结构。
在之前的博客里,我们已经测试了各种处理器的 BTB,在这里也是一样的:按照一定的 stride 分布无条件直接分支,构成一个链条,然后测量 CPI。
考虑到 Zen 2 的 BTB 可能出现一个 entry 保存两条分支的情况,并且还对分支的类型有要求,因此下面的测试都会进行四组,分别对应四种分支模式:
首先是 stride=4B 的情况:
可以看到,图像上出现了三个比较显著的台阶:
和 Zen 1 不同,Zen 2 的 L1 BTB 出现了不同模式下容量不同的情况,原因未知,后续还会看到类似的情况。
Zen 2 的 L2 BTB 依然是带有压缩的,只有在 mix (cond + uncond) 模式下才可以尽可能地用上所有的容量,而其余的三种模式都有容量上的损失。
接下来观察 stride=8B 的情况:
现象和 stride=4B 基本相同,L1 BTB 从 256 到 512 部分的变化斜率有所不同,其余部分一致。
继续观察 stride=16B 的情况:
相比 stride=4B/8B,L0 BTB 和 L2 BTB 的行为没有变化;除了 cond 模式以外,L1 BTB 的容量减半到了 128,意味着 L1 BTB 采用了组相连,此时有一半的 set 不能被用上。此外,比较特别的是,从 stride=16B 开始,CPI=5 的平台出现了波动,uncond 模式下 CPI 从 5 变到 4 再变到了 5,猜测此时 L1 BTB 也有一定的比例会介入。
继续观察 stride=32B 的情况:
相比 stride=16B,L0 BTB 的行为没有变化;除了 cond 模式以外,L1 BTB 的容量进一步减到了 64,符合组相连的预期;L2 BTB 在 mix (uncond + cond) 模式下不再能体现出 5120 的容量,而是 4096:此时在一个 64B cacheline 中只有两条分支,第一条分支是 uncond,第二条分支是 cond,不满足 entry 共享的条件(必须 cond + uncond,不能是 uncond + cond),此时 uncond 和 cond 分别保存在两个 entry 中,每个 entry 只保存一条分支,因此 L2 BTB 只能体现出 4096 的容量。而 mix (cond + uncond) 模式依然满足 entry 共享的条件,所以依然体现出 7168 的容量。特别地,在 mix (cond + uncond) 模式下出现了非常剧烈的 CPI 抖动,可能出现了一些预期之外的性能问题。
继续观察 stride=64B 的情况:
相比 stride=32B,L0 BTB 的行为没有变化;除了 cond 模式以外,L1 BTB 的容量进一步减到了 32,符合组相连的预期,但 cond 模式下依然保持了 512 的容量;L2 BTB 在 mix (cond + uncond) 模式下只能体现出 4096 的容量,此时每个 64B cacheline 都只有一条分支,不满足两条分支共享一个 entry 的条件。
继续观察 stride=128B 的情况:
相比 stride=64B,L0 BTB 的行为没有变化;除了 cond 模式以外,L1 BTB 的容量进一步减到了 16,符合组相连的预期,而 cond 模式下 L1 BTB 容量也减少到了 256;L2 BTB 的容量减半到了 2048,意味着 L2 BTB 也是组相连结构。
测试到这里就差不多了,更大的 stride 得到的也是类似的结果,总结一下前面的发现:
下面是对比表格:
uArch | AMD Zen 1 | AMD Zen 2 |
---|---|---|
L0 BTB size | 4+4 branches | 8+8 branches |
L0 BTB latency | 1 cycle | 1 cycle |
L1 BTB size | 256 branches | 512 branches |
L1 BTB latency | 2 cycles | 2 cycles |
L2 BTB size w/o sharing | 2K branches | 4K branches |
L2 BTB size w/ sharing | 4K branches | 7K branches |
L2 BTB latency | 5 cycles | 5 cycles |
Technology Node | 14nm | 7nm |
Release Year | 2017 | 2019 |
可见 Zen 2 在容量上做了一定的扩展,但机制上比较类似;特别地,可能是观察到 cond + uncond 的压缩能够生效的比例没有那么高,所以只允许其中一部分 entry 被压缩,例如 4 路组相连,只有前 3 个 way 是可以保存两条分支;剩下的一个 way 只能保存一条分支。
AMD Zen 2 和 ARM Neoverse N1 都是在 2019 发布的处理器,下面对它们进行一个对比:
uArch | AMD Zen 2 | ARM Neoverse N1 |
---|---|---|
L0/Nano BTB size | 8+8 branches | 16 branches |
L0/Nano BTB latency | 1 cycle | 1 cycle |
L1/Micro BTB size | 512 branches | 64 branches |
L1/Micro BTB latency | 2 cycles | 2 cycles |
L2/Main BTB size w/o sharing | 4K branches | 3K*2 branches |
L2/Main BTB size w/ sharing | 7K branches | 3K*2 branches |
L2/Main BTB latency | 5 cycles | 2-3 cycles |
Technology Node | 7nm | 7nm |
可见 AMD Zen 2 在 BTB 容量上有优势,但是延迟要更长;两者都在最后一级 BTB 上做了压缩,但是压缩的方法和目的不同:
二者都没有实现一个周期预测两条分支,即 two taken(ARM 的说法是 two predicted branches per cycle)。这要等到 2020 年的 ARM Neoverse N2/V1,或者 2022 年的 AMD Zen 4 才被实现。
注意到 AMD 的 Software Optimization Guide for AMD EPYC™ 7002 Processors (Publication No. 56305) 文档里,有这么一段表述:
Branches whose target crosses a half-megabyte aligned boundary are unable to be installed in the L0 BTB or to share BTB entries with other branches.
也就是说,如果两个分支要共享一个 BTB entry,那么它们的目的地址不能跨越 512KB 边界,也就是和分支地址的偏移量不超过 19 位。按 48 位虚拟地址计算,如果 BTB entry 只记录一条分支,最多需要记录目的地址的完整 48 位地址;如果现在 BTB entry 要存两条分支,这两条分支的目的地址都只需要记录 19 位,加起来也就 38 位,还可以空余 10 位的信息用来维护 BTB sharing 所需的额外信息。
所以说到底,无论是 AMD 还是 ARM,做的事情都是对一个固定长度的 entry 设置了不同的格式,一个格式保存的地址位数多,但是只能保存一个分支;另一个格式保存的地址位数少,但是可以保存两个分支。区别就是 AMD 对两个分支的类型和位置有要求,而 ARM 允许这两个分支毫无关系。这就是不同厂商的取舍了。
2025-07-07 08:00:00
AMD Zen 1 是 AMD 在 2017 年发布的 Zen 系列第一代微架构。在之前,我们分析了 ARM Neoverse N1 和 V1 的 BTB,那么现在也把视线转到 AMD 上,看看 AMD 的 Zen 系列的 BTB 是如何演进的。
AMD 在 Software Optimization Guide for AMD Family 17h Processors (Publication No. 55723) 中有如下的表述:
The branch target buffer (BTB) is a three-level structure accessed using the fetch address of the current fetch block.
Zen 1 的 BTB 有三级,是用当前 fetch block 的地址去查询。
Each BTB entry includes information for branches and their targets. Each BTB entry can hold up to two branches if the branches reside in the same 64-byte aligned cache line and the first branch is a conditional branch.
Zen 1 的 BTB entry 有一定的压缩能力,一个 entry 最多保存两条分支,前提是两条分支在同一个 64B 缓存行中,并且第一条分支是条件分支。这样,如果第二条分支是无条件分支,分支预测的时候,可以根据第一条分支的方向预测的结果,决定要用哪条分支的目的地址作为下一个 fetch block 的地址。虽然有压缩能力,但是没有提到单个周期预测两条分支,所以只是扩大了等效 BTB 容量。
例如,有这么一段代码:
那么 jnz 和 jmp 指令可以放到同一个 entry 当中,一次读出来,然后对 jnz 指令进行分支方向预测:
L0BTB holds 4 forward taken branches and 4 backward taken branches, and predicts with zero bubbles.
Zen 1 的第一级 BTB 可以保存 4 条前向分支和 4 条后向分支,预测不会带来流水线气泡,也就是说每个周期都可以预测一次。
L1BTB has 256 entries and creates one bubble if prediction differs from L0BTB.
Zen 1 的第二级 BTB 可以保存 256 个 entry,但不确定这个 entry 是否可以保存两条分支,也不确定这个 entry 数量代表了实际的 entry 数量还是分支数量,后续会做实验证实;预测会产生单个气泡,意味着它的延迟是两个周期。
L2BTB has 4096 entries and creates four bubbles if its prediction differs from L1BTB.
Zen 1 的第三级 BTB 可以保存 4096 个 entry,但不确定这个 entry 是否可以保存两条分支,也不确定这个 entry 数量代表了实际的 entry 数量还是分支数量,后续会做实验证实;预测会产生四个气泡,意味着它的延迟是五个周期。
简单整理一下官方信息,大概有三级 BTB:
下面结合微架构测试,进一步研究它的内部结构。
在之前的博客里,我们已经测试了各种处理器的 BTB,在这里也是一样的:按照一定的 stride 分布无条件直接分支,构成一个链条,然后测量 CPI。
考虑到 Zen 1 的 BTB 可能出现一个 entry 保存两条分支的情况,并且还对分支的类型有要求,因此下面的测试都会进行四组,分别对应四种分支模式:
首先是 stride=4B 的情况:
可以看到,图像上出现了三个比较显著的台阶:
可以观察到,过了 L2 BTB 容量以后,性能骤降到十多个 cycle,此时还没有超出 L1 ICache 容量,这么长的延迟,即使是在 uncond 模式下,可以在译码的时候发现 uncond 分支并 redirect,也要 16+ 个周期,可见其流水线之长。
接下来观察 stride=8B 的情况:
现象和 stride=4B 基本相同,各级 BTB 显现出来的大小没有变化。
继续观察 stride=16B 的情况:
相比 stride=4B/8B,L0 BTB 的行为没有变化;L1 BTB 的容量减半到了 128,意味着 L1 BTB 采用了组相连,此时有一半的 set 不能被用上。此外,比较特别的是,从 stride=16B 开始,CPI=5 的平台出现了波动,CPI 从 5 变到 4 再变到了 5,猜测此时 L1 BTB 也有一定的比例会介入。L2 BTB 在 mix (uncond + cond) 模式下,拐点从 3072 前移到 2560。
继续观察 stride=32B 的情况:
相比 stride=16B,L0 BTB 的行为没有变化;L1 BTB 的容量进一步减到了 64,符合组相连的预期;L2 BTB 在 mix (uncond + cond) 模式下不再能体现出 3072 的容量,而是 2048:此时在一个 64B cacheline 中只有两条分支,第一条分支是 uncond,第二条分支是 cond,不满足 entry 共享的条件(必须 cond + uncond,不能是 uncond + cond),此时 uncond 和 cond 分别保存在两个 entry 中,每个 entry 只保存一条分支,因此 L2 BTB 只能体现出 2048 的容量。而 mix (cond + uncond) 模式依然满足 entry 共享的条件,所以依然体现出 4096 的容量。
继续观察 stride=64B 的情况:
相比 stride=32B,L0 BTB 的行为没有变化;L1 BTB 的容量进一步减到了 32,符合组相连的预期;L2 BTB 在 mix (cond + uncond) 模式下只能体现出 2048 的容量,此时每个 64B cacheline 都只有一条分支,不满足两条分支共享一个 entry 的条件。
继续观察 stride=128B 的情况:
相比 stride=64B,L0 BTB 的行为没有变化;L1 BTB 的容量进一步减到了 16,符合组相连的预期;L2 BTB 的容量减半到了 1024,意味着 L2 BTB 也是组相连结构。
测试到这里就差不多了,更大的 stride 得到的也是类似的结果,总结一下前面的发现:
也总结一下前面发现了各种没有解释的遗留问题:
接下来尝试解析一下这些遗留问题背后的原理。部分遗留问题,并没有被解释出来,欢迎读者提出猜想。
前面测试出来,观察到两个奇怪的容量:3072 和 2560,分别有 3 和 5 的因子。下面通过进一步的实验,观察它的来源。
首先针对这个 2560 的拐点,做了一系列测试,在 stride=16B 的情况下,测试不同的 uncond/cond 分支的组合,下面是 64B cacheline 内四条分支的类型的不同组合(U 代表 Uncond,C 代表 Cond),以及该组合对应的容量:
可以观察到,如果没有出现连续的 CU(CCCC/UCCC/UUCC/UUUC/UUUU),容量是 2048;如果出现了一组 CU(CCCU/CCUC/CCUU/CUCC/CUUC/CUUU/UCCU/UCUC/UCUU/UUCU),容量是 2560;出现了两组 CU(CUCU),就是 mix (cond + uncond) 模式,容量是 4096。
一种可能的猜想:
2048+512=2560
个 branch但是也遗留了一个问题,就是只有一组 CU 的情况下,为啥剩下的 512 个 entry 只放 512 个 branch,而不能放 1024 个 branch,按理说是可能再次出现 cond + uncond 合并?这个问题暂时还没有解释。
由此可以看出,2560 的来源是 4 路组相连,然后其中一路发生了 cond + uncond 的合并,所以最终是 5 个分支保存到 4 路当中,再来一条分支就会放不下。
带着上面的分析,再去观察 stride=4B/8B 时的 3072:3072 有 3 的因子,所以大概率是从 2 路组相连得来,其中一路出现了 cond + uncond 的合并,所以出现了 3 个 branch 占用 2 个 entry 的情况,最后体现出来就是 3072 的 L2 BTB 容量。
似乎到这里,3072 和 2560 分别的 3 和 5 的因子都能解释了,剩下的就是解析具体的组相连的结构。
那么到底是 2 路组相连,还是 4 路组相连呢,另外这个组相连的 set 是怎么构成的呢?
首先回忆一下,在 ARM Neoverse N1 中,连续的 32B 内能放 6 个分支,但是 stride=8B 的时候,一次就会往同一个 set 里增加 4 个分支,于是一个 set 内的分支数从 0 变到 4 再变到 8,拐点出现在 4 个分支,而不是 6 个分支。因此为了达到前面出现的 3072 和 2560 的拐点,新增的分支也得均匀地分到各个 set 当中。
前面根据 L2 BTB 的容量分析到,L2 BTB 的 Index 可能是 PC[n:6],但肯定不是简单的这么取,否则也会出现 ARM Neoverse N1 类似的问题。只能说明 PC[6] 往上有若干个 bit 是单独出现在 L2 BTB 的 Index 当中的,而 PC[5] 以下的 bit,可能以某种哈希函数的形式,参与到 Index 当中。
所以,L2 BTB 可能是以 PC[n:6] 作为 Index 去访问,然后内部有多个 bank,每个 bank 内部是 2 路组相连。bank index 是通过 PC 经过哈希计算得来,使得在 stride=4B/8B 的时候,体现出 2 路组相连,而在 stride=16B 的时候,体现出 4 路组相连。同时,分支还能够均匀地分布到各个 bank 当中,避免了和 ARM Neoverse N1 类似的情况的发生。
2025-06-23 08:00:00
ARM Neoverse V1 是 ARM Neoverse N1 的下一代服务器 CPU,在 2020 年发布。此前我们分析过 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 内即可。一共占用 4096*92=376832
bit 也就是 46 KB 的空间。
那么,在 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 |
Main BTB area (bits) | 3K*82=251904 | 4K*92=376832 | 4K*92=376832 |
Main BTB area (KiB) | 30.75 | 46 | 46 |
Technology Node | 7nm | 5nm | 5nm |
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。