1. 理解ArchLab PartC的性能挑战
当你第一次打开CSAPP的ArchLab PartC实验文档时,可能会被ncopy.ys这个看似简单的Y86-64汇编任务所迷惑。任务要求我们复制一个整数数组,并统计其中正数的个数,但真正的挑战在于如何让这个操作跑得足够快。我刚开始做这个实验时,看着基准CPE(每元素周期数)成绩直摇头,直到后来通过系统性的优化,才最终实现了满分表现。
这个实验的精妙之处在于它模拟了真实CPU设计中的各种限制。我们需要同时修改汇编代码(ncopy.ys)和流水线硬件控制逻辑(pipe-full.hcl),就像真正的CPU架构师一样思考问题。硬件层面需要实现iaddq指令和加载转发机制,软件层面则要运用循环展开、分支预测等技巧。这种软硬件协同优化的思路,正是现代计算机体系结构的核心思想。
实验评分标准主要看CPE值,也就是处理每个数组元素所需的平均时钟周期数。初始版本的CPE可能高达15以上,而经过充分优化的版本可以降到4以下。要达到这个目标,我们需要深入理解流水线冒险、分支预测错误、指令级并行等概念对性能的影响。
2. 硬件优化:从iaddq指令到加载转发
2.1 实现iaddq指令
在pipe-full.hcl中添加iaddq指令是我优化之路的第一步。这个立即数加法指令可以显著减少循环计数等常见操作的开销。记得我第一次尝试实现时,漏掉了访存阶段的处理,导致程序运行结果完全错误。正确的实现需要在取指、译码、执行、访存和写回五个阶段都做相应修改。
具体来说,在取指阶段需要识别iaddq的指令编码(0xC);译码阶段要正确设置srcA和dstE为rA;执行阶段要进行加法运算;访存阶段虽然不需要操作,但要保留通路;最后写回阶段要将结果写入寄存器。实现后,原本需要两条指令的立即数加法(irmovq和addq)现在一条指令就能搞定,这对循环展开后的代码特别有用。
2.2 加载转发机制
数据冒险是影响流水线性能的主要因素之一。在原始流水线中,当一条加载指令后面紧跟着使用该数据的指令时,会导致3个周期的停顿。通过实现加载转发,我们可以把这个停顿降到1个周期。
这个优化的关键在于修改pipe-full.hcl中的冒险检测逻辑。当检测到内存读取操作时,需要将数据从内存阶段转发到执行阶段的ALU输入。我花了整整一个下午调试这个功能,因为最初没考虑到转发条件判断的优先级问题。正确的实现应该先判断是否需要转发,再决定转发源是内存阶段还是写回阶段。
3. 软件优化:十路循环展开的艺术
3.1 为什么选择十路展开
循环展开是减少循环控制开销的最有效方法之一。在ncopy.ys中,我尝试过从二路到十二路的各种展开方式,最终选择十路展开是经过仔细权衡的。一方面,代码长度限制让我们不能无限制展开;另一方面,十路展开能在控制开销和代码体积间取得良好平衡。
展开后的核心循环处理10个元素,每次迭代指针前进80字节(10个8字节整数)。这里有个小技巧:使用iaddq $-10, %rdx来更新剩余元素计数,比传统的decq指令更高效。循环条件判断也改用jge而不是jl,这样可以更好地利用处理器的分支预测机制。
3.2 三叉搜索树处理余数
循环展开后,我们需要处理元素数量不是10的倍数的情况。传统的方法是使用线性判断,但这会产生大量分支指令。我采用了更聪明的三叉搜索树方法,通过两次比较就能覆盖0-9的所有余数情况。
具体实现中,先与3比较将范围分为0-2、3、4-9;然后在4-9中再与7比较。这种分层判断结构大幅减少了平均比较次数。选择3和7作为分割点不是随意的,而是基于余数分布概率和代码长度的综合考虑。例如,余数为1和2的情况比8和9更常见,因此应该放在更浅的判断层级。
4. 高级优化技巧与实战细节
4.1 分支预测策略优化
现代处理器都有复杂的分支预测机制,但我们可以通过代码布局来帮助它做出更好的预测。在ncopy.ys中,我采用了"总是选择"策略,将最可能执行的分支放在fall-through路径上。例如,在余数处理部分,将余数为1、2的情况放在不跳转的路径上,因为它们在统计上更常见。
另一个技巧是减少条件跳转的总数。在十路展开的主循环中,每个元素拷贝后都跟着一个条件判断,这会产生大量分支点。通过合理安排代码顺序,我们可以让多个元素共享同一个条件判断,从而减少分支预测错误的惩罚。
4.2 寄存器使用与指令选择
Y86-64的寄存器资源有限,如何高效利用它们对性能至关重要。在优化过程中,我仔细规划了每个寄存器的用途:%rdi和%rsi始终作为源和目标指针,%rdx作为计数器,%rax保存结果,%r8作为临时操作数寄存器。
指令选择也有讲究。比如使用andq指令来测试数值正负,它比testq更紧凑;删除多余的xorq %rax, %rax初始化指令,因为寄存器默认初始化为零;合理安排iaddq指令的位置以减少数据依赖。这些看似微小的优化,累积起来能带来显著的性能提升。
4.3 内存访问模式优化
虽然ncopy.ys的内存访问模式相对简单,但仍有优化空间。在循环展开版本中,内存访问是完全顺序的,这有利于处理器的预取机制。我特意将相邻元素的偏移量设置为连续值(0x0, 0x8, 0x10等),这样硬件可以更好地预测接下来的访问模式。
另一个细节是内存操作的配对。在Y86-64中,内存读写指令可能会引起流水线停顿。通过交错安排读取和写入操作,可以减少这种停顿。例如,先读取下一个元素的值,再写入当前元素的结果,这样两个内存操作不会产生冲突。