
最近一周我把全部业余时间砸进了一件事在没有一块 RISC-V 开发板、也没用过目标架构编译器的情况下把自己写的周期级模型从最初 1.13 的基准成绩一路调到逼近玄铁910 的水平。这里先解释一下“1.13”不是 IPC——五级顺序单发射核的 IPC 理论上限撑死 1.0我这里是 CoreMark/MHz 口径的计分。也正是因为起初成绩难看得像块单片机才有后面这么多故事可写。这个项目适合三类人看想搞清楚乱序 CPU 到底比顺序核强在哪的手里有 FPGA 但没有 RISC-V 工具链又不想只会点灯的人以及纯粹想在不依赖现成 EDA 与开发板的情况下用软件模型探索微架构的同学。我不会把整个过程包装成“手写一个芯片”它就是一次用周期级模拟器做微架构调优的实验记录。1. 为什么在“没板子也没工具链”的情况下还要把周期级模拟当主线1.1 没有开发板的真实痛点大多数人学 RISC-V 都是从一块开发板开始的烧进去一个 RTOS、点亮几个 LED、跑个 CoreMark然后看频率和分数。这套流程没错但它对理解微架构的帮助很有限你看到的只是整体得分芯片内部到底为什么慢、流水线在哪一条路径上冒泡开发板不会告诉你。我这次的环境约束更苛刻手头没有 RISC-V 开发板也没有可用的 RISC-V 交叉编译器。这意味着我不能愉快地riscv64-unknown-elf-gcc编一个程序丢进 QEMU 去验证。我唯一能依靠的是自己写的模拟器、自制的微型汇编器以及一套按 RISC-V 原始编码规则生成的二进制。听起来简陋但反而逼我把每个周期都算清楚没有处理器帮你兜底没有缓存一致性模型帮你掩盖设计失误所有性能问题都赤裸裸地摆在周期计数面前。这不是退而求其次。对微架构研究来说周期级模型的价值本来就比开发板高一个量级。开发板只能告诉你“现在的成绩是多少”周期级模型能告诉你“这 1% 的周期到底去了哪里”。1.2 玄铁910作为参考基线怎么选以及口径界定选玄铁910当对比目标首先是因为它足够“透明”。平头哥公开过玄铁910的微架构特点和不少性能资料网上能搜到的 CoreMark 参考曲线也很多用它做参照比拿某个闭源商业核强得多。其次玄铁910是一个乱序多发射的高性能核和“顺序单发射”的初始模型之间有巨大的性能空间正好可以观察每一步优化带来的增量。但我必须先把口径限制死我做的不是把整颗玄铁910一比一复刻。我的模拟器没有实现它的 MMU、缓存一致性协议、中断控制器和完整存储模型也没有做工艺相关的时序分析。所谓“逼近”是指在我的测试二进制和固定内存映射条件下CoreMark/MHz 从 1.13 提高到接近公开资料里玄铁910的水平。关于玄铁910的参考值我不打算把它当成一个精确到小数点后两位的官方成绩不同编译选项、不同主频档位下会差出好几个点。我取“大致 5 分上下”这个印象值目标是别差太远。口径清晰之后赛道才成立。否则用我这么个软件模型去跟成熟 IP 比跑分本身就是耍流氓。2. 第一版顺序五级流水CoreMark/MHz1.13 到底是怎么算出来的2.1 最小模型里我塞了哪些硬件部件第一版模型没有一上来就仿玄铁910我先做了个教科书级 RISC-V 顺序五级流水取指、译码、执行、访存、写回。指令集支持到 RV32IM也就是整数指令加上乘除法扩展。没有缓存没有分支预测没有乱序执行啥优化都没加。这个模型最重要的地方在于它有一个独立的“指令功能层”和“周期计数层”。功能层负责把每一条指令的执行结果算对周期计数层只负责看着流水线哪里卡了多久。两层分开的好处是调性能时如果发现结果不对能快速判断是流水线碰撞走了岔路还是指令语义本身写错了。模型里还内置了一个最简 CoreMark 子集。准确点说我在没有完整工具链的情况下没法直接编译原版 CoreMark于是提取了它最典型的几段核心循环链表遍历、位操作、矩阵乘法并按照 CoreMark 的计分方式换算成每 MHz 分数。这样做有个副作用它夸大了某些典型路径的收益但也正因为如此优化方向的对比变得非常灵敏。2.2 初始成绩的气泡和命门第一版跑下来CoreMark/MHz 是 1.13。这个数字在顺序单发射核里不算离谱因为 CoreMark 本身依赖大量访存和分支而我这模型里每个 load 固定占用两拍所有分支无条件冲刷流水线分支惩罚固定 2 个周期。简单算一笔账如果程序里每 5 条指令就有 1 条分支每条分支平均浪费 2 个周期那理想 CPI 就已经从 1.0 变成了大约 1.4。再加上 load-use 停顿CPI 往 1.5-2.0 走都很正常。CoreMark/MHz 只有 1.13说明我这模拟器里访存系统还算克制但控制流开销已经压不住了。当时我统计了周期去向数据冲突造成的气泡只占 23%分支冲刷占 41%结构性冲突占 18%其余是取指带宽和写回阻塞。分支这一项比我想象的严重得多。很多教材里把乱序执行讲得神乎其神但在这个起步阶段我最大的敌人不是数据依赖而是“不知道下一步去哪”。3. 性能数据面前先别动手我是如何把瓶颈逐条钉死的3.1 按“分支/访存/结构冲突/数据冲突”四类做周期归因拿到 1.13 这个数字之后我没有急着加宽流水线或做乱序那是典型的“我觉得这里慢”。做性能调优最忌讳拍脑袋我给自己定了个规矩每一轮优化之前必须有周期归因数据。具体的做法是在模拟器里维护一组硬件计分器。不是那种统计指令总数的计数器而是专门记录每个周期流水线处于什么状态正常发射、等待取指、等待数据、等待写回端口、分支刷新、访存冲突、指令缓存未命中。每一类状态会单独计数最后按总周期做归一化。第一轮数据非常清晰取指停顿占 12%数据冒险占 23%分支清洗占 41%访存结构冲突占 14%真正好好发射指令的周期只有 37%。换句话说五级流水里有接近三分之二的周期在做无用功。这时候谁再说“顺序核也能做到接近 1 IPC”我建议拿这个数据给他看。3.2 真正让我意外的一处瓶颈我原本以为 load 指令会是大头结果拆开细看发现排在第一的不是 load-use 停顿而是“取指分支惩罚”。因为我没有做任何分支预测每条分支在译码阶段被识别之后取指阶段已经拉进来了后续两条指令这些全部作废。CoreMark 的链表遍历里到处都是循环和跳转循环体又短几乎每十来个周期就来一次冲刷。另一个意外的点是写回端口冲突。乘除法指令占的周期长而在顺序模型里算数逻辑单元和乘法器共享一个写回端口导致乘法指令执行完还要排队等写回。这不影响功能正确性但让周期账白白多了一截。这个小问题后来在我决定做多发射时成了非常重要的教训发射宽度不是唯一瓶颈后端提交/写回端口不够照样卡脖子。4. 顺序核上的两次“低成本”优化分支预测与指令预取4.1 BTB BHT RAS 的配置和收益第一轮改的是分支预测。我没有直接上 TAGE 那种复杂预测器而是按工程性价比来一个 256 项 BTB每项保存跳转目标一个 2-bit 饱和计数器组成的 BHT再加一个返回地址栈 RAS。这套组合几乎能覆盖所有函数调用和循环分支。配置上我刻意做成可参数化后续几轮测试会逐一打开。最初只开 BTB误惩罚从固定 2 个周期降到平均 1.2 个周期加上 BHT 之后循环分支准确率立刻冲到 96% 左右。RAS 看着不起眼但对 CoreMark 这类大量函数调用的负载非常有效返回地址预测错误基本清零。改完分支后我又给取指前端加了一个 16 项指令缓存。没有缓存的时候每条指令都被当成一次内存访问遇到连续取指时还能靠顺序预取稍微掩盖一下。加了指令缓存之后取指命中率很快到了 97% 以上。这一轮做完CoreMark/MHz 从 1.13 拉到 1.72收益主要来自控制流溢出变少。我特别想强调一点这轮优化里我没有改动执行核心只动了前端。很多做 CPU 模拟的人一上来就堆寄存器重命名和 ROB反而忽略了“前端取指能不能跟上后端执行”这个最基础的问题。4.2 为“没有编译器”准备的极简链接脚本与内存映射因为没有现成的 RISC-V 工具链我需要自己负责产物生成的最后一公里。这里最实用的工具是一段自写的链接脚本功能上等同常见开发板工程里的link.ld把代码段固定放在地址 0x1000数据段放在 0x20000栈顶放在 0x40000。有人会问模拟器里反正没有 MMU地址随便定不就行了还真不是。没有固定内存映射周期模拟器里就无法模拟访存延迟差异也无法模拟指令缓存和数据缓存的行为。我做了一个极简内存模型代码区始终命中指令缓存数据区访问如果落在连续地址内则命中数据旁路跨页访问多付 1 个周期。这一步让所有访存优化的结果都可复现、可解释。这也侧面回答了为什么“没有编译器”还做得下去只要你能控制指令编码和内存布局一个最小汇编器加一个链接脚本完全可以把测试程序喂给周期级模型。开发板能帮你做的事模型用更透明的方式也能做。5. 从顺序到乱序的代价ROB、保留站和访存消除冲突5.1 三条改造主线跑完前两轮优化之后顺序内核的得分已经上来了但天花板也很明显单发射宽度决定了每个周期最多只能发射一条指令数据冒险再少也只能空等。下一步只能做乱序执行我给自己定了一个星期内能完成的改造范围三条主线。第一条是寄存器重命名。用物理寄存器池消除 WAW/WAR 冒险让指令在译码后就可以把名字改成物理寄存器的编号。第二条是保留站和 ROB。保留站为每类功能单元维护一个等待队列只要源操作数准备好了就发射不一定按程序顺序ROB 负责最终按程序顺序提交确保异常和分支误预测时的精确状态。第三条是访存队列。乱序执行后最头疼的不是算术指令而是 load/store 的次序关系我加了一个存储队列让 load 可以越过尚未提交的 store只要地址确定不冲突。这里我贴一段模型里保留站分配的伪代码核心逻辑大概是for (auto inst : dispatchList) { if (rs.hasFreeEntry(inst.opType) rob.hasFreeEntry()) { rob.allocate(inst); rs.allocate(inst); dispatchList.pop_front(); } else { stall_dispatch(); break; } }看起来简单真实坑全在stall_dispatch()的后台保留站满了得停译码ROB 满了必须停发射源操作数还没算出来时得让保留站里的指令干等。每停一个周期计数器都要有对应记录否则调了半天都不知道时间去哪了。5.2 乱序改完后的回归问题乱序改造最折磨人的不是功能跑不通而是“功能对了但周期账对不上”。我记得第一版乱序模型跑 CoreMark 子集时指令结果完全正确但 IPC 比顺序核还低。原因出在 ROB 只开了 16 项保留站也只有 8 项一遇到长延迟的 load 和乘法资源迅速耗尽后面的指令根本进不来乱序执行的窗口小到等于没开。把 ROB 扩到 64 项、保留站扩到 24 项之后成绩才上来。这里我得到了一个重要教训乱序执行不是“改成乱序”就自动变快窗口太小等于没有乱序寄存器重命名只是浪费面积。调度窗口和访问执行单元的带宽必须匹配单发射 64 项 ROB 是浪费3 发射 8 项 ROB 又不够用。另一个回归问题出在分支误预测恢复。乱序执行中的分支预测失败不能像顺序核那样把流水线从头冲刷而要回溯 ROB 中被误预测及之后的每一条指令还需恢复寄存器映射关系。我第一版恢复逻辑写得太粗暴误预测惩罚从顺序核的 2 周期变成了 12 周期分支预测率再高也扛不住这种惩罚。后来把恢复逻辑改成“检查点 部分冲刷”误预测惩罚才稳定在 4-6 个周期。6. 一周第七天我拿什么证明它“逼近玄铁910”6.1 对数量表和差距读法一周结束时我在固定负载下跑出了 4.87 的 CoreMark/MHz。对比公开资料里对玄铁910 大体“5 分上下”的参考印象差距已经缩小到 5% 以内。下面这组数据是这一周每一轮变更的累计结果阶段配置要点CoreMark/MHz相对初始倍率初始基线顺序单发射无预测无缓存1.131.00x分支前端BTBBHTRAS16项指令缓存1.721.52x发射宽度2发射顺序支持转发网络2.642.34x乱序执行3发射ROB 64项保留站24项4.513.99x存储队列优化load/store 消歧加上cache命中优化4.874.31x请注意这个对比不是严格的官方评测我的测试子集比完整 CoreMark 更偏向局部热点。但性能提升趋势仍然有参考价值顺序核再怎么优化到 2.6 左右已经是极限再往上走必须靠乱序执行带来的多个流水线程并行。这正好解释了为什么任何一家处理器做高性能核最终都要投入乱序执行的怀抱。6.2 周期级模型不是实战替代品但它是性能实验的显微镜这一周下来我带走的不仅仅是那个 4.87。相比开发板周期级模型给出的是一整套“性能归因能力”每个周期为什么空转、每条分支误预测浪费多少、哪个资源在排队全都有据可查。这比在板子上拿性能计数器猜要直接得多。当然我也不会说没有编译器、没有开发板反而比有板子更好。玄铁910 是经过完整验证的工业级 IP它有存储一致性、页表转换、中断响应等一系列我完全没碰的复杂度。我的模型更像一台“性能显微镜”能看到微架构层面的流水线行为但无法验证物理实现级的问题。能在没有硬件的约束下逼近参照核是因为测试负载足够集中、模拟边界足够窄这是一次有价值的学习实验不是产品级对标。6.3 一点个人体会如果让我给下一周的自己一句话作为忠告我会说先把计数器和事件分类做对了再去调架构参数。这一周最大的返工不是乱序执行逻辑而是早期的总周期计数逻辑没算准。Branch 误预测、load 延迟、结构冲突三件事的周期可能重叠按照“每个阶段各加一拍”的幼稚算法统计出来的数据会互相重叠最后归因怎么都解释不通。另外收藏一个小技巧给模拟器加一个“周期轨迹导出”接口每个周期把当前发射/提交/等待状态输出成文本流。没有仿真波形时这就是你的逻辑分析仪。我在调试 ROB 提交顺序时全靠这个文本轨迹把乱序执行的每一条指令历史对齐到程序原始序列。最后想说的是玄铁910 只是个参照不是终点。一个自写的周期级模型最大的价值是你能在里面看见“为什么慢”而这个视角是开发板和编译器给不了你的。