1. 理解ArchLab PartC的核心挑战当你第一次打开CSAPP的ArchLab PartC实验文档时可能会被CPECycles Per Element这个指标搞得一头雾水。简单来说CPE衡量的是处理器处理每个数组元素所需的平均时钟周期数。这个数字越小说明你的代码性能越好。在ncopy.ys这个实验中我们需要复制一个数组并统计其中正数的数量同时要尽可能降低CPE值。实验提供了两个关键文件ncopy.ysY86-64汇编代码和pipe-full.hcl处理器微架构描述。前者是我们的优化对象后者则决定了处理器如何执行这些指令。我刚开始做这个实验时最大的困惑是如何将书本上的流水线、数据转发这些概念转化为实际的性能提升。后来发现关键在于理解Y86-64模拟器的工作机制。2. 微架构层面的关键优化2.1 实现iaddq指令在原始的pipe-full.hcl文件中缺少对iaddq立即数加法指令的支持。这个指令可以直接将立即数与寄存器相加比传统的先用irmovq加载立即数再用addq相加要高效得多。添加这个指令需要修改hcl文件中的多个部分首先在# 指令取值部分添加iaddq的解码逻辑bool instr_valid icode in { INOP, IHALT, IRRMOVQ, IIRMOVQ, IRMMOVQ, IMRMOVQ, IOPQ, IJXX, ICALL, IRET, IPUSHQ, IPOPQ, IIADDQ };然后在## 执行阶段添加对应的执行逻辑## 选择ALU的输入 word aluA [ icode in { IRRMOVQ, IOPQ } : valA; icode in { IIRMOVQ, IRMMOVQ, IMRMOVQ, IIADDQ } : valC; icode in { ICALL, IPUSHQ } : -8; icode in { IRET, IPOPQ } : 8; # 其他情况不需要ALU ]; word aluB [ icode in { IRMMOVQ, IMRMOVQ, IOPQ, ICALL, IPUSHQ, IRET, IPOPQ, IIADDQ } : valB; icode in { IRRMOVQ, IIRMOVQ } : 0; # 其他情况不需要ALU ];最后在## 写回阶段添加结果写回逻辑## 确定写回的目标寄存器 word dstE [ icode in { IRRMOVQ, IIRMOVQ, IOPQ, IIADDQ } : rB; icode in { IPUSHQ, IPOPQ, ICALL, IRET } : RRSP; # 其他情况不需要写回 ];这个优化看似简单但实测可以带来约15%的性能提升因为它减少了指令数量和流水线停顿。2.2 加载转发机制另一个关键优化是加载转发Load Forwarding。在原始流水线中当一条加载指令mrmovq后面紧跟着使用该数据的指令时会导致流水线停顿。通过实现加载转发我们可以让数据直接从内存加载阶段转发到需要它的指令避免停顿。在pipe-full.hcl中添加以下转发逻辑## 转发源选择 word fwdE [ # 从执行阶段转发 E_icode in { IOPQ, IIADDQ } E_dstM ! RNONE : e_valE; # 从访存阶段转发加载转发 M_icode in { IMRMOVQ, IPOPQ } M_dstM ! RNONE : m_valM; # 其他情况不转发 1 : 0; ];这个优化对性能影响很大特别是在循环展开后的代码中可以避免大量由于数据依赖导致的停顿。我在测试中发现仅这一项优化就能降低CPE约0.5。3. 汇编代码层面的极致优化3.1 十路循环展开策略由于实验对代码长度有限制经过多次尝试我发现十路循环展开是最佳选择。展开太多会超出长度限制太少则无法充分利用流水线。展开的基本思路是将循环体复制十份每次迭代处理十个元素。核心循环结构如下Loop1: mrmovq (%rdi), %r8 # 加载第一个元素 rmmovq %r8, (%rsi) # 存储第一个元素 andq %r8, %r8 # 测试是否为正数 jle Loop2 # 如果不是正数跳过计数 iaddq $1, %rax # 计数器加1 Loop2: mrmovq 8(%rdi), %r8 # 第二个元素 rmmovq %r8, 8(%rsi) andq %r8, %r8 jle Loop3 iaddq $1, %rax ... # 继续到Loop10这种展开方式虽然增加了代码量但大大减少了循环控制的开销。在我的测试中十路展开比原始循环降低了约1.2的CPE。3.2 余数处理的智能分支策略循环展开后我们需要处理元素数量不是10的倍数的情况。这里采用了三叉搜索树的分支策略通过精心选择判断点3和7来最小化平均比较次数。分支判断的核心逻辑L0R9: iaddq $7,%rdx # 比较与3的关系 jl L0R2 # len 3 jg L4R9 # len 3 je Rem3 # len 3 L0R2: iaddq $2,%rdx # 比较与1的关系 je Rem1 # len 1 jg Rem2 # len 2 ret # len 0 L4R6: iaddq $2,%rdx # 比较与5的关系 jl Rem4 # len 4 je Rem5 # len 5 jg Rem6 # len 6这种分支策略考虑了两个因素一是区间越大发生概率越大的分支优先级越高二是余数越小优先级越高因为CPE是各长度成绩的平均值。经过实测这种策略比简单的线性判断能提高约5%的性能。4. 其他关键优化技巧4.1 寄存器初始化的优化在原始代码中常见用xorq来清零寄存器。但在Y86-64模拟器中寄存器默认就是零值所以可以安全地删除这类指令。虽然每条指令节省的周期不多但在循环中累积起来效果明显。4.2 指令顺序的精心安排在展开的循环体中我刻意将加载指令mrmovq提前存储指令rmmovq靠后。这是因为加载操作需要等待内存访问而存储操作可以与其他指令并行。通过这种安排可以减少数据依赖带来的停顿。4.3 分支预测的利用Y86-64模拟器采用静态分支预测策略默认预测分支不跳转。因此在编写条件跳转时应该把最可能执行的分支放在不跳转的位置。例如在统计正数时大多数情况下数值可能都是正数所以应该把是正数的情况放在不跳转路径上。5. 性能测试与调优经验在实际优化过程中我建立了一个系统化的测试方法每次修改后都运行完整的测试套件记录各长度的CPE值。特别关注以下几点长数组100元素的CPE这反映了核心循环的性能短数组1-10元素的CPE这检验了余数处理的效率边界情况空数组、全正数数组、全负数数组通过这种方法我发现最初的八路展开虽然对长数组表现不错但在短数组上表现欠佳。改为十路展开后整体性能更加均衡。另一个重要发现是看似微小的调整可能带来意想不到的效果。例如改变循环展开中各块指令的顺序有时能提高指令级并行度。这需要反复试验和耐心观察。