编译器后端多架构适配:指令选择、寄存器分配与调度实践
1. 后端适配的本质:把机器无关翻译成机器有关
从事编译器后端工作多年,最常被问的一句话是:前端都做完了,后端不就是“翻译成汇编”吗,能有多难。每次听到这话我都想笑。目标代码生成和优化真正难的地方,恰恰不在“翻译”,而在“适配”——同一段中间表示,落到不同硬件架构上,寄存器数量不同、指令格式不同、寻址模式不同、流水线深度不同、向量宽度不同,后端要做的是一整套带约束的搜索与取舍。这篇文章就把我这些年做多架构后端适配的经验拆开讲,从机器描述怎么建模、指令选择怎么选策略、寄存器分配怎么控溢出,到调度怎么贴流水线、工程上怎么保证多个后端不互相拖后腿,都会给出可复现的做法和参数推演。
先给个定位:这篇内容适合已经写过简单编译器、或者正在给某个自研芯片做工具链的同行,也适合想搞明白“为什么同一份源码在不同芯片上跑出来的机器码差距能有三倍”的读者。不要求你手上有现成的后端框架,思路和参数模型是通用的。我会尽量用生活化的类比把概念说透,同时保留必要的工程细节,避免看完只剩“好像懂了”的感觉。
1.1 中间表示往下走,究竟丢了什么信息
编译器前端把源码变成中间表示时,做的是一次“抽象升级”:变量名、作用域、类型这些语义信息被保留,而寄存器、栈帧布局、指令编码这些硬件细节被刻意抹掉了。中间表示是机器无关的,这是它最大的优点,也是后端全部麻烦的来源。因为一旦往下走,你就必须把这些被抹掉的信息一件件补回来,而且补的方式还不唯一。
举个最直观的例子。中间表示里有这么一句:t3 = t1 + t2,其中t1是从内存里读出来的。这句话在三种架构上可以长出三种完全不同的机器码:三地址、定长指令、寄存器充裕的架构上,就是一条load加一条add;两地址、变长指令、支持内存操作数的架构上,可以直接折叠成一条add reg, [mem],省掉一次寄存器占用;而如果目标架构有地址生成单元,甚至能把“基址加变址加偏移”整个折叠进那条加法里,连地址计算都省了。三种结果功能完全等价,性能差别可能是 30%。
所以后端的第一个核心问题就是:中间表示里那条“加法”,到底要展开成几条机器指令、按什么顺序展开、展开时占用哪些资源。这个决定不是在某个单一环节做出的,而是贯穿指令选择、寄存器分配、指令调度三个阶段,每个阶段都在做局部的重写和取舍,任何一个阶段做糙了,前面的努力都会白费。
1.2 机器描述文件是整个多架构适配的地基
真正做过多架构后端的人都有一个共识:能不能快速接入一颗新芯片,取决于机器描述层的设计质量,而不是后面那些优化算法写得多花哨。我的经验是把机器描述拆成四块,边界划清楚,后面加新架构就是填空而不是改代码。
第一块是指令信息:每条指令的操作码、操作数个数、操作数类型约束、隐含的寄存器读写、是否有副作用、编码长度、延迟周期。第二块是寄存器信息:有哪些寄存器类、每类多少个、位宽多少、哪些被调用约定固定用途、哪些是调用者保存、哪些是被调用者保存、能不能和别的寄存器类重叠。第三块是寻址模式:支持哪几种地址形式,各自的立即数范围是多少,能不能带移位,能不能带自增自减。第四块是栈帧与调用约定:参数怎么传、返回值放哪、栈向哪个方向生长、栈对齐要求是多少字节、结构体怎么按值传递。
这四块信息一旦结构化,上层的算法就都变成“查表加搜索”,而不是到处写if (arch == A) ... else if (arch == B) ...。我见过太多项目一开始图快,直接在优化流程里散落架构判断,等到接第三颗芯片时,代码里几百处条件分支,改一处崩两处。这种债一旦欠下,后面几乎只能推倒重来。
提示:机器描述里的每个字段都要有明确的“谁来消费它”。如果一个字段写进去了却没有任何优化流程读取,那它要么是冗余的,要么说明你的某条优化路径还没接上,这两种情况都值得停下来查清楚。
1.3 三条绕不开的硬约束:寄存器、指令集、内存模型
多架构适配的复杂度,说到底是被三条硬约束撑起来的。把这三条想清楚,后面所有策略选择都有了判断依据。
寄存器约束是最直观的。三十二个通用寄存器听起来不少,但真正能自由支配的往往只有十来个——栈指针占一个、帧指针占一个、返回地址占一个、几个保留给汇编器和操作系统,剩下的还要应付调用约定。寄存器越少,溢出越频繁,而溢出意味着访存,访存意味着延迟。这是所有后端优化里最贵的那个代价项。
指令集约束体现在“正交性”上。正交性好的架构,任何指令都能用任何寻址模式、操作任何寄存器,写起来很舒服;正交性差的架构,某些指令只能用特定寄存器(比如乘除法固定用某两个寄存器对),长整型运算要跨寄存器对,这时候指令选择就得插入额外的搬运指令,而这些搬运会吃掉寄存器也吃掉周期,必须在模式匹配阶段就考虑进去。
内存模型约束是最容易被忽视的。是否要求对齐访问、有没有非对齐访问陷阱、大小端怎么排、原子操作有没有原生支持、需不需要显式内存屏障,这些细节一旦搞错,生成的代码不是慢,而是直接崩。我在一个项目里就遇到过:某架构对非对齐的四字节访问会触发异常,而前端生成的中间表示里有个结构体字段刚好落在偏移 6 的位置,压力测试跑了一周才发现,排查成本极高。
2. 指令选择:四种打法与背后的取舍
2.1 树覆盖与DAG覆盖的差别在哪
指令选择最经典的模型是把中间表示看成树,每条机器指令对应树上的一个模式,问题就转化成“用最少的模式覆盖整棵树”。这个模型好理解、好实现,早期后端基本都是这么干的。但它有个天生的缺陷:树模型假设每个值只被使用一次,而真实的中间表示里,一个计算结果经常被多个地方引用,这时它其实是一个有向无环图而不是树。
举个典型场景:t = a + b算出来之后,t被用在三个地方。树模型会把这三次使用各自复制一份子树,于是a + b被算三遍。如果a + b本身是条加法,损失还小;但如果它是“读内存再加法”,那你就白白多读了两次内存。所以成熟的后端都会在指令选择前做一次“共享节点识别”,把重复的公共子表达式合并成 DAG 节点,覆盖的时候一个节点只生成一次代码,后续使用者直接引用结果。
DAG 覆盖的代价是算法复杂度上去了。树覆盖可以用简单的递归加贪心,DAG 覆盖要考虑节点之间的依赖顺序,还得处理“某个节点被覆盖后,它的使用者还能不能折叠”的问题。我的经验是:如果目标架构指令集比较简单、模式数量在几百条以内,DAG 覆盖带来的收益是值得的;如果模式数量上千、还有大量复杂寻址折叠,不如老老实实用树覆盖加局部窥孔优化,工程上更可控。
2.2 自顶向下贪心与自底向上动态规划
确定了覆盖模型,接下来是搜索策略。自顶向下贪心是最省事的:从根节点开始,尽量挑一条能覆盖最大子树的模式,覆盖完再递归处理剩下的叶子。它的优点是编译速度快、实现简单、调试容易;缺点是局部最优不等于全局最优,有时候根节点贪心选了一条“大模式”,反而逼得下面某个子树只能用一堆零碎指令拼,总代价更高。
自底向上动态规划则是求真正的最优解:对每个节点算出“以它为根的最小代价”,自底向上一层层递推,最后回溯出选择方案。代价模型可以很细,比如一条指令记 1 分,一次访存记 3 分,一次寄存器搬运记 2 分,这样算出来的方案在给定代价模型下确实最优。但它有两个现实问题:一是编译时间随模式数量和节点数量增长,大函数上开销明显;二是代价模型的参数很难调准,尤其是带流水线效应的架构,静态代价和真实周期数差得远。
我在实际项目里用的是混合策略:先用贪心快速跑一遍拿到可行解,再对“代价明显偏高”的基本块做局部动态规划优化。判断标准很简单——如果一个基本块生成的指令条数超过该基本块中间表示语句数的三倍,就判定为“选择质量差”,触发二次优化。这个阈值是我在几个项目里反复试出来的经验值,太松了没效果,太紧了编译时间涨得厉害。
| 策略 | 编译速度 | 代码质量 | 实现难度 | 适用场景 |
|---|---|---|---|---|
| 自顶向下贪心 | 快 | 中等,局部最优 | 低 | 模式少、对编译时间敏感 |
| 自底向上动态规划 | 慢 | 高,代价模型内最优 | 高 | 嵌入式、代码尺寸敏感 |
| 贪心加局部动态规划 | 中 | 接近最优 | 中 | 通用场景,推荐默认 |
| 窥孔优化兜底 | 很快 | 补漏为主 | 很低 | 所有场景都该有 |
2.3 一条模式规则该怎么写
模式规则写得好不好,直接决定后端能不能把架构特性吃干榨净。我习惯把规则写成“匹配条件 + 生成指令 + 代价 + 约束”四段式,缺一段就容易出问题。下面是一个示意写法,语法是自定义的,重点是结构而不是具体符号:
pattern ADD_LOAD(reg: GPR, addr: MEM) { match: (add reg, (load addr)) emit: ADD reg, [addr.base + addr.index * addr.scale + addr.disp] cost: 1 constraint: addr.scale in {1, 2, 4, 8} addr.disp fits_signed(12) reg.class == GPR side_effect: sets_flags }这段规则表达的意思是:当中间表示里出现“把一个内存读出来的值加到寄存器上”,并且地址形式满足基址加变址乘比例加偏移、偏移在 12 位有符号范围内时,就折叠成一条带内存操作数的加法指令。约束段是灵魂,少了它,编译器就会在偏移超范围时也硬套这条规则,生成的汇编过不了汇编器,或者更糟——静默截断偏移,跑出错误结果。
代价段也不能随便填。带内存操作数的指令看起来只占一条,但它可能有额外的地址计算周期,也可能不能在流水线的所有发射槽上执行。我的做法是给每条模式标注一个“基础代价”和一个“资源占用掩码”,调度阶段再根据资源冲突做二次调整,这样静态代价和动态调度能衔接上。
2.4 指令选择里最容易翻车的三个细节
第一个是立即数范围。三地址架构上add reg, reg, imm的立即数往往只有 12 位,超过就得先把常量装进寄存器再算。如果你的模式规则没写“立即数超范围时降级为装载加寄存器加法”这条备选路径,编译器就会直接报错或者生成非法指令。更隐蔽的是“常量池”路径——有些架构允许把大常量放在离当前指令一定距离内的常量池里,通过相对偏移加载,这条路径需要链接期配合,写规则时必须把重定位信息一并带上。
第二个是条件码复用。变长指令集架构上,比较指令会隐式设置标志位,后续的条件跳转直接读标志位,省掉一次显式比较。但标志位是个隐式全局资源,跨越基本块就不能复用(中间可能被别的指令改掉),函数调用之后更不能复用。我见过有后端为了省一条比较指令,把标志位跨基本块复用,结果在包含内联汇编的代码里稳定出错,排查了两周。安全做法是:标志位活躍范围严格限制在基本块内,跨块一律重新比较。
第三个是乘除法与移位扩展。宽乘法在很多架构上要求操作数放在固定的寄存器对里,结果也落在固定的寄存器对里。如果你的寄存器分配器不知道这个约束,它很可能把别的活跃变量分配到那半个寄存器对上,然后在乘除法指令处插入一堆搬运。正确做法是在机器描述里把这类指令的“固定寄存器约束”显式声明出来,让分配器提前避开。
注意:指令选择阶段生成的每一处折叠,都要能在寄存器分配阶段被“拆回去”。也就是说,折叠不是不可逆的,当寄存器压力过高时,把内存操作数折叠拆开、把常量重新装载,往往比溢出别的变量更划算。这个可逆性是后面调优的关键抓手。
3. 寄存器分配:后端优化的主战场
3.1 图着色与线性扫描的适用边界
寄存器分配的本质是一个图着色问题:把每个虚拟寄存器看成图上的一个点,两个同时活跃的虚拟寄存器之间连一条边,然后用有限的颜色(物理寄存器)去染色,染不上的就溢出到栈上。这个模型非常优雅,但它是 NP 完全问题,实际实现都是启发式。
图着色派的做法是先构建干涉图,反复“简化—染色—溢出”三轮迭代,溢出的时候选代价最小的点。它的优点是代码质量高,能充分利用寄存器;缺点是实现复杂、编译时间随函数规模增长明显,超大函数上容易退化成几十秒。线性扫描派的做法是按活跃区间排序,从左到右扫一遍,分配不出就溢出。它实现简单、速度快、内存占用小,代码质量比图着色差几个百分点。
我的判断标准是看目标场景。如果是桌面或服务器端,函数体普遍不大,图着色的质量优势值得那点编译时间;如果是嵌入式工具链,动辄遇到几万行的大函数,线性扫描加局部优化更现实。还有一种折中方案是“分区域分配”——把大函数按循环边界切成若干区域,每个区域内部做图着色,区域之间按边界活跃变量做统一协调,效果不错,实现复杂度居中。
| 算法 | 代码质量 | 编译时间 | 实现难度 | 大函数表现 |
|---|---|---|---|---|
| 图着色(简化-染色-溢出) | 高 | 高 | 高 | 明显退化 |
| 线性扫描 | 中 | 低 | 低 | 可预期 |
| 分区域图着色 | 较高 | 中 | 中 | 良好 |
| SSA 上分配后退出 SSA | 高 | 中 | 高 | 良好 |
3.2 溢出代价模型与一次具体的算术推演
溢出谁不溢出谁,是寄存器分配里最影响性能的决定。最朴素的代价模型是“用得多的不溢出”,但这忽略了循环结构——在深度为 3 的内层循环里用 2 次,远比在外层用 50 次更值钱。所以工业级实现几乎都带循环深度加权。
常见的加权方式是每个基本块给一个权重,循环深度每深一层乘以 10:第 0 层权重 1,第 1 层 10,第 2 层 100,第 3 层 1000。这个 10 倍系数是经验值,不是理论最优,但它足够粗粒度地反映“内外层差了数量级”的直觉。溢出代价就是变量所有使用点和定义点的权重之和。
举个具体的例子。变量甲在深度 3 的循环里被使用 5 次、定义 1 次,代价是(5 + 1) × 1000 = 6000。变量乙在深度 0 的函数入口块里被使用 50 次,代价是50 × 1 = 50。两者相差 120 倍,所以选择溢出乙。假如不做深度加权,按原始计数溢出甲,那 6 次访存全部落在最内层循环里;而溢出乙,50 次访存里有 49 次其实是编译器能通过重算消除的(比如乙是个常量或者简单的地址计算),实际真正的访存可能只有 1 次。这就是代价模型的威力——它不是让编译器的选择更“公平”,而是让选择更贴近真实的执行成本。
再算一笔更细的账。假设内层循环被执行 100 万次,每次多一条溢出装载指令,按该架构访存命中一级缓存的 4 周期延迟算,就是 400 万周期;如果处理器主频是 2.5 GHz,约合 1.6 毫秒。听起来不多,但如果这个循环在一个被调用上千次的函数里,累计起来就是秒级的差距。所以内层循环的溢出,一条都不能忍。
3.3 两地址架构带来的额外麻烦
三地址架构上add r1, r2, r3三个操作数互不干扰,分配起来很舒展。但变长指令集的很多架构是两地址格式:add r1, r2意思是r1 = r1 + r2,目标寄存器同时是源操作数。这就带来一个额外约束:如果r1在加法之后还活着(它的旧值别处要用),那就必须先复制一份到新寄存器,否则旧值被覆盖。
这种“复制”看着不起眼,实际影响很大。一个表达式链上如果有五六处这种约束,就会多出五六条搬运指令,还要多占用几个寄存器,进而推高寄存器压力,形成连锁反应。解决办法是在分配阶段做“复制合并”:当发现一处“复制后立刻使用”的模式时,检查源和目标的活跃区间是否重叠,不重叠就合并成同一个寄存器,把复制消掉。
复制合并的坑在于“过度合并”。如果两个虚拟寄存器合并后,导致干涉图上多出一批新的边,那后面的染色会更困难,可能反而增加溢出。所以工程上通常做两轮:第一轮激进合并,第二轮染色失败时回退部分合并。这个“合并—回退”的循环次数要设上限,不然编译时间不可控。
3.4 寄存器压力实测与调优心得
寄存器压力不是一个静态指标,它随代码位置变化。我习惯在编译器里加一个诊断开关,输出每个基本块的“峰值活跃虚拟寄存器数”和“可用物理寄存器数”的比值,比值超过 1 就标红。实践中发现,绝大多数溢出集中在少数几个基本块里,往往是深层嵌套的表达式或者长参数列表的函数调用。
对长参数列表这种场景,有个很实用的技巧:调用前的参数准备顺序。如果按参数从左到右依次准备好放进寄存器,然后再调用,那所有参数值必须在调用指令处同时活跃,压力是参数个数。但如果调整成“先把靠后的参数准备好、压到栈上,再准备靠前的寄存器参数”,同一时刻的活跃值就能压到最少。这个调整不改变语义,纯靠指令重排就能把压力降下来,我在好几个项目里靠它消掉了整片溢出。
另一个心得是重算优于溢出。有些变量的值是可以重新计算出来的,比如一个常量、一个全局变量的地址、一个简单的线性表达式,与其把它溢出到栈上再读回来,不如直接在使用点重算一遍。重算通常是一两条算术指令,比一次访存加一次装载便宜,而且不占用栈空间,还能减少栈帧大小。代价是要做“可重算性”分析,判断重算的表达式有没有副作用、依赖的值是否还在寄存器里,实现上有点工作量,但收益是实打实的。
4. 指令调度与流水线适配
4.1 依赖图与列表调度的优先级设计
指令调度的目标很朴素:把互不依赖的指令交错开,让流水线别闲着。做法是把基本块内所有指令按数据依赖和资源冲突建一张有向图,然后用列表调度:每轮从“所有前驱都已完成”的候选集里挑一条发射,直到发完。
关键在于挑哪一条。最常用的优先级是“到汇点的最长路径长度”,也叫关键路径长度——这条路径决定了整个基本块的最短完成时间,优先发射关键路径上的指令,能让后续指令尽早解锁。当优先级相同时,我一般再加一条打破平局的规则:优先发射“后继节点多”的指令,因为它解锁的候选更多,调度自由度更大。
priority(node) = longest_path_to_sink(node) tie_break(node) = 后继数量降序,再按原程序顺序升序 候选集 = { n | 所有数据前驱已发射 且 发射槽资源可用 } 选 priority 最高者发射,更新资源占用,重复这段伪代码简单,但有两个容易忽略的细节。一是“发射槽资源”要按架构建模:双发射架构每个周期有两个槽,但两个槽能执行的指令类型可能不同;VLIW 架构一个周期要打包一整个指令字,槽之间还有对齐约束。二是重排会改变寄存器压力,原本顺序执行时短暂活跃的值,重排后可能变成长期活跃,所以调度必须和寄存器分配交替进行,不能先把调度做完再分配。
4.2 不同硬件特征的调度策略映射
不同架构的调度重点完全不一样,把所有优化都打开反而可能变慢。我整理过一张对照表,基本上照着填就能定出调度策略:
| 硬件特征 | 调度重点 | 需要避免的做法 |
|---|---|---|
| 带延迟槽 | 延迟槽填入有用指令或安全空操作 | 往延迟槽塞有副作用的分支 |
| 顺序双发射 | 配对可并行的相邻指令,注意发射槽类型限制 | 配对两条争用同一功能单元的指令 |
| 乱序执行 | 侧重减小寄存器压力和缓存局部性 | 过度重排破坏访存局部性 |
| VLIW 打包 | 同周期内填满所有功能单元,跨周期考虑寄存器 | 打包后寄存器压力暴涨 |
| 带向量单元 | 向量化循环,处理尾部余数和对齐 | 非对齐向量访存未做保护 |
延迟槽这一条值得多说两句。延迟槽是分支指令之后那条“无论分支是否跳转都执行”的位置,编译器必须往里填东西:填有用的指令是最理想的,但它必须和分支指令无依赖;填不安全就填空操作,等于浪费一个周期。很多后端为了省事直接填空操作,性能白白掉 5% 到 15%。我的做法是在分支指令前预留调度窗口,主动搜索可以安全移动进延迟槽的指令,优先搬移那些“不改变标志位、不影响分支条件、不访存”的纯计算指令。
向量化则是另一套逻辑。向量宽度从 128 位到 512 位不等,编译器要判断循环能不能向量化、能不能对齐、尾部余数怎么处理。尾部的处理有个经典选择:生成“向量主循环 + 标量尾循环”两段代码,或者生成“带掩码的向量循环”。前者代码体积大但执行快,后者代码体积小但每个迭代都有掩码开销。我一般让编译器按循环的预估迭代次数做静态判断:预估次数远大于向量宽度时用两段式,次数少且不确定时用掩码式。
4.3 调度窗口开多大才合适
调度窗口指的是“一次调度能看多远”,通常以基本块为界,超基本块的话要处理跨块依赖。窗口开得大,调度自由度大,代码质量好;但寄存器压力和编译时间都会涨。
我实测过的经验值是:窗口内指令数在 40 到 80 条之间比较平衡。低于 40 条,调度器经常找不到可配对的指令,收益不明显;高于 200 条,寄存器压力上升带来的溢出损失会抵消调度收益,而且编译时间呈超线性增长。这个数字不是绝对的,跟架构有关——寄存器多的架构可以开到 150 条,寄存器少的架构超过 60 条就开始负收益。
提示:调度器一定要有“回退开关”。同一个函数,开启调度和关闭调度各编译一遍,比较生成的指令数和预估周期数,如果开启后反而更差,就保留关闭版本。这个双版本比对机制在多架构适配里价值极高,因为不同架构的最优策略真的不一样。
5. 多架构工程的落地与质量保障
5.1 分层抽象与特性开关
多架构后端要活得久,必须做分层。我的划分是四层:最底下是目标机描述层,纯粹的数据和查询接口,不含任何优化逻辑;往上是指令信息层,负责编码、解码、操作数约束、寄存器类转换;再往上是公共优化层,指令选择、寄存器分配、调度这些算法都写在这一层,通过接口查询目标机信息,绝不出现架构名字;最上面是目标相关的窥孔与特例层,放那些实在没法通用化的架构专属优化。
这个划分的关键在于“公共优化层零架构判断”。做得到的话,接新架构基本就是填描述文件加几个窥孔规则;做不到的话,迟早变成一堆条件分支的泥潭。我见过一个项目,公共层里有七十多处架构判断,接第三颗芯片的时候,每加一个功能都要在七十处里找该改哪几处,最后整个团队三个月没出过新版本。
特性开关是另一个必需品。同一颗芯片可能有多个变体(带不带向量单元、缓存大小不同、有没有某条扩展指令),这些都该建模成“子目标特性”,用特性开关控制优化路径。开关的粒度要细到单条指令级别,比如“是否支持融合乘加”就是一个开关,因为融合乘加会改变浮点结果的精度,某些严格精度的场景需要能关掉。
5.2 测试矩阵怎么搭
多架构后端最怕的不是性能差,是“某个架构上生成的代码悄悄算错了”。我的测试矩阵分四层,缺一层都不行。
第一层是单元测试,测机器描述的每个字段、每条指令的编码解码、每个寻址模式的范围判断。这层最容易写也最容易漏,但它是后面所有测试的地基。
第二层是模式覆盖测试,针对每条指令选择规则写一个最小用例,验证“这个模式确实被选中了”。这层的价值在于防止“规则写了但永远不触发”——这种 bug 最隐蔽,因为代码完全正确,只是慢。
第三层是差分测试,同一份源码分别用“开启优化”和“关闭优化”两套流程编译,跑同样的输入,比较输出。这一层能抓到绝大多数优化引入的错误。再加一层更狠的:把同一份源码编译到两个不同架构,在模拟器上跑,比较结果。这一步能抓到架构相关的错误,比如大小端、非对齐访问、浮点精度。
第四层是随机程序生成,用生成器批量产出结构合法但内容随机的程序,涵盖所有语言特性组合,然后跑差分比对。这一层我一开始觉得是学术界的玩具,实际用上之后发现它是唯一能在有限人力下覆盖长尾组合的手段。我们曾经靠它抓到一个 bug:某个组合下,一条带副作用的指令被错误地提到了条件分支前面,只在特定的嵌套深度下触发,手写用例根本想不到。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查手段 | 解决方向 |
|---|---|---|---|
| 某架构上程序崩溃,其他架构正常 | 非对齐访问、栈对齐不足、ABI 参数传递错误 | 反汇编对比、检查栈帧布局、打印调用约定 | 补对齐处理,修正调用约定描述 |
| 生成的代码正确但明显慢 | 溢出过多、模式未折叠、调度未生效 | 统计溢出条数、统计折叠命中率、开调度诊断 | 调代价模型,补模式规则,调窗口大小 |
| 浮点结果与其他架构不一致 | 融合乘加改变了精度、舍入模式不同 | 关闭融合乘加后重测 | 加特性开关,严格场景禁用融合 |
| 大函数编译时间爆炸 | 图着色迭代过多、调度窗口过大、内存分配频繁 | 分阶段计时,定位热点 | 分区域分配,限制迭代次数,加编译缓存 |
| 某条模式规则永不触发 | 约束写错、优先级被别的规则抢走 | 打印匹配日志,检查约束求值 | 修正约束,调整规则代价 |
| 内联汇编处结果异常 | 标志位跨块复用、寄存器约定未声明 | 检查内联汇编前后的标志位活跃性 | 强制内联汇编边界处刷新状态 |
5.4 几处踩过的坑
先说一个印象最深的。有次接一颗新芯片,所有测试都过,性能也不错,但上线后收到反馈说“某些场景下程序会卡死”。查了三天,最后定位到内存屏障:那颗芯片的缓存一致性协议要求在某些跨核通信点插入显式屏障,而我们的后端把“屏障”当成普通指令参与调度,被重排到了数据写入之前。语义上看起来没事——屏障操作的是内存,数据写入也是内存,顺序什么时候被改了?问题在于调度器把屏障和它后面的写入判定为“无数据依赖”,于是交换了位置,屏障失去了意义。
这件事给我的教训是:带副作用的指令必须在依赖图里显式建模成“看不见的边”。屏障、原子操作、内联汇编、可能抛异常的访存,这些都不能让调度器随便移动。后来我们在指令信息里加了一个“重排屏障级别”字段,调度器遇到带级别的指令就禁止跨越,问题解决。这类坑不踩一次很难想到,但只要踩过,就会明白机器描述里那些看似冗余的字段,每一条都是拿时间换来的。
第二个坑是关于浮点常量的。有些架构对浮点立即数支持很弱,甚至完全不支持,必须走常量池。常量池的位置是链接期决定的,编译器生成时只能放一个“待填”的符号。如果这条路径没走通,编译器会退而求其次生成“从内存加载再运算”,性能掉得不多,但代码尺寸涨了不少,后来在嵌入式项目上撞了墙——代码尺寸直接超了 flash 预算。最后的解法是给常量池单独做一遍合并去重,把重复的浮点常量合并成一个,尺寸才勉强压回预算内。
第三个坑比较隐蔽,关于递归函数的栈帧。某些架构的栈对齐要求是 16 字节,而我们的栈帧计算只按 8 字节对齐,多数情况下没事,但一旦函数里有需要 16 字节对齐的向量类型局部变量,就会出错。更麻烦的是这个错误不会在函数入口暴露,而是在向量访存时触发异常,报错位置离真正的原因很远。
注意:栈帧对齐这件事,最稳的做法是把对齐要求算成“向上取整到最大对齐要求”,而不是逐个类型判断。算一次,全局统一,比到处打补丁可靠得多。
最后分享一个我一直在用的小技巧:给后端加一个“代码尺寸与预估周期”的双指标输出,每次提交都跑一遍基准集,两个指标同时回归就报警。只盯性能会忽略代码尺寸,只盯尺寸会忽略性能,两个一起看,很多问题在合入前就能拦住。这个习惯养成之后,后端代码的质量曲线明显平缓了很多。
我在实际项目里最深的体会是,硬件架构的差异远不止指令集本身。真正决定适配成本的是那些没有被写进手册的隐性约定:某个寄存器在异常返回后值不确定、某条指令在跨缓存行时性能骤降、某种寻址模式在页边界上有额外惩罚。这些东西只能靠实测、靠计数器、靠一行行比对汇编慢慢积累。把每一次踩坑都变成机器描述里的一个字段或者一条约束规则,后端才会随着接的架构越来越多而变得更强,而不是越来越乱。