编译器优化记录(Mem2Reg+SSA Destruction)

编译器,优化,记录,mem2reg,ssa,destruction · 浏览次数 : 133

小编点评

**Mem2Reg简介** Mem2Reg是一种对内存访问控制的指令,用于控制访问内存的指令。它允许用户设置访问内存的指令,并根据这些指令,控制内存访问的顺序。 **Mem2Reg指令** Mem2Reg指令用于设置对内存访问控制的指令,并根据这些指令,控制内存访问的顺序。常用的Mem2Reg指令包括: * **Mem2Reg**:设置对内存访问控制的指令。 * **Reg**:设置要访问的内存的指令。 * **Mem**:设置要访问的内存的指令。 **Mem2Reg指令示例** ```assembly Mem2Reg, Reg, Mem ``` 此指令设置对内存地址 Reg 的访问控制指令为 Mem。 **Mem2Reg指令应用** Mem2Reg指令可以用于控制内存访问的顺序,例如: ```assembly Mem2Reg, Reg, Mem Mem2Reg, Reg, Reg ``` 此指令设置对内存地址 Reg 的访问控制指令为 Mem,并根据这些指令,控制内存访问的顺序为 Reg。 **Mem2Reg指令的影响** Mem2Reg指令的影响范围取决于指令的类型。例如: * **Mem2Reg**指令影响内存地址。 * **Reg**指令影响内存指令。 * **Mem**指令影响内存指令。 **Mem2Reg指令的注意事项** * Mem2Reg指令只能设置对内存访问控制的指令,但不能设置对内存访问控制的指令。 * Mem2Reg指令的顺序对指令的影响顺序。 * Mem2Reg指令不能用于设置对内存访问控制的指令。

正文

编译器优化记录(2) Mem2Reg+SSA Destruction

写的时候忽然想起来,这部分的内容恰好是在我十八岁生日的前一天完成的。算是自己给自己的一份成长的纪念吧。

0. 哪些东西可以Mem2Reg

顾名思义,Mem2Reg的意思是我们可以通过维护每个函数中局部变量被赋值之后产生的副本来消除对其alloca,而后进行一系列load/store的过程(众所周知这一类操作是需要更多时间的)。

一般来说,所有的alloca都可以被消除,但是对于某个函数超过\(8\)个参数而言,情况会不太一样。具体而言,它们是从栈上传过来的,因而我们需要将其load到一个新的寄存器上。

另外,对于函数的\(0\to7\)号参数,在进行完Mem2Reg之后也有后续操作。我们需要在函数刚开始的时候把这几个参数从物理寄存器\(a_0,...,a_7\)转移到到我们给他们分配的虚拟寄存器上。

1. 确定插入phi的位置

上一份博客中,我们已经获得了插入phi的前置内容(即支配树、支配边界),那么接下来我们就可以插入phi指令。

注意,下面的内容都是以函数为单位进行的,Mem2Reg的本质应该是一个Function Pass

根据上文所述的规则,我们可以找到每个可以被消除的局部变量,只需要遍历enterBlock中的各个alloca。接下来,我们对于每个可以消除的局部变量\(alloca\),记录它在每个块中的最后一次\(def\),这样的目的很明显,是为了记录在支配边界,该变量应当被赋值为什么。

我们采用HashMap<IRRegister, HashMap<BasicBlock, entity>> all来记录对于每个局部变量,它在每个块中的最后一次定值。它肯定会发生改变,因为我们在某个块中插入phi指令的时候,就又创造了一次定值。

然后,我们采用工作表算法来解决。

工作表算法(WorkList Algorithm)就是说,我们用一个集合\(W\)来作为工作表,每次选取其中的一个节点,进行一个操作,然后加入这个操作会影响的其他节点(假使它们不在其中),直到工作表为空。我们大部分的优化都会用到它。

我们使用工作表\(W\),它的初始值为所有对\(alloca\)进行赋值(即store)的集合。我们同时用HashSet<BasicBlock> F来记录所有已经出现过关于alloca的接下来,我们选取其中的一个块bb1,如果!all.get(alloca).containsKey(bb1),那就说明bb1中对这个变量的最后一个定值一定是phi指令,我们要对all进行更新。接下来,我们就需要枚举bb1的支配边界来插入phi指令了。对于它的支配边界中的一个块Y,如果!F.contains(Y),那么我们就需要插入phi指令啦。同时我们也要把Y分别加入FW中。

我对于phi指令的设计是这样的:

public class IRPhi extends IRBaseInst {
    public HashMap<BasicBlock, entity> block_value = new HashMap<>();
    public IRRegister dest;
    public IRRegister origin;
}

同时,我在BasicBlock中加入了HashMap<IRRegister, IRPhi> phiMap来记录一个块中的所有phi,在执行toString()的时候优先输出。

经过这个过程,我们可以获得所有需要插入phi的地方。接下来,就是考虑从每条路径来的时候,这个变量应该被赋值为什么了。

2. 变量重命名

一个直观的想法是这样的。对于每个局部变量\(alloca\)和它出现的某个块,如果这个块中,在这条指令上面有对它的定值,那么就直接使用这个定值定出来的东西。如果啥定值都没有,那么这肯定说明连phi都没有,说明从控制流的角度来说,上一次对它定值一定是在它的直接支配节点或更上面。

那么,我们为了寻找这个“最后一次定值”,就需要在支配树上进行DFS。

接下来,我们考虑对于每个个块的操作,也即visitBlock(BasicBlock block, Function func)

2.1 需要使用的数据结构

我们使用HashMap<IRRegister, entity> last_def来记录当前每个变量最后\(def\)使用的值。例如%add = add i32 %0, 1; store i32 %add, ptr @s就可以被理解为,当前对变量s的最后一次\(def\)使用的是%add

我们使用HashMap<IRRegister, entity cur_name>来表示我们需要修改的虚拟寄存器。例如在上面两条指令之后,%1 = load i32, ptr @s; %add1 = add i32 %1, 1中的%1就可以被替代为%add

需要注意的是,在进入到同一个块的不同支配树后继时,last_def, cur_name都应该维持一致。这就需要我们每个块内给它们开一个副本,在一个后继访问完后,把它们的副本重新赋回来,然后再访问下一个后继。

2.2 操作流程

首先自然是开副本,注意java的引用赋值特性

接下来,我们考察每个基本块的所有指令(这里指令包含所有的phi)。如果这条指令是一个store或者一个phi,那么我们就修改last_def。如果这条指令是一个load,那么我们就修改cur_name

然后我们调整后继节点中phi的使用值。伪代码如下

 for (block的每个后继succ) {
            for (succ 的每一个phi) {
                记element为这个phi原本的局部变量
                if (last_def.get(element) != null) {
                    phi加入(block, last_def.get(element))的这个entry
                }
            }
        }

最后,我们访问block的每一个支配后继,并把副本赋回来,并删掉和我们消除的这些局部变量有关的load/store/alloca。

2.3 加入默认分支

考虑这样一个情况,有基本块\(BB_1, BB_2, BB_3\)满足\(pred(BB_3) = \{BB_1, BB_2\}, pred(BB_1)=pred(BB_2)=enterBlock\)。如果我们一开始定义了一个局部变量\(x\),并只在\(BB_1\)中对其定值,且在\(BB_3\)中使用之。那么根据上面的操作,\(BB_3\)中关于\(x\)的phi指令只有一个源头。然而,如果你尝试用clang编译它,会报一长串的错误。这是因为对于\(BB_3\)的前驱还包括\(BB_2\)。而规范应该是对于每一个前驱,都有一个赋值。于是我们需要对那些没出现的前驱补充值,这里姑且赋成初始值吧(i32, i8赋值为0ptr赋值为null)。

进一步思考,这其实算是对于源代码的语义精化。换言之,在这里我们可能改变了源代码的意义(尽管它可能是不安全的)。

3. SSA Destruction

注意到,刚刚的插入phi的过程仍然保持了Single Static Assignment的性质。但是在之后指令选择(指令选择(instruction selection)是将中间语言转换成汇编或机器代码的过程。在LLVM后端中具体表现为模式匹配)的阶段,我们并没有对phi指令的对应翻译方法。那就需要我们在IR过渡到汇编的过程中把phi转化成多次分别的赋值,这显然会打破每个虚拟寄存器只能被定值一次的准则。

一个可以想见的转化方法如下:

enter_main_0:
br label %for.cond_0

for.cond_0:
%i_phi_0 = phi i32 [ %inc_0, %for.inc_0 ], [ 0, %enter_main_0 ]
%x_phi_0 = phi i32 [ %add_0, %for.inc_0 ], [ 1, %enter_main_0 ]
%slt_0 = icmp slt i32 %i_phi_0, 10
br i1 %slt_0, label %for.body_0, label %for.end_0

for.inc_0:
%inc_0 = add i32 %i_phi_0, 1
br label %for.cond_0

for.body_0:
%add_0 = add i32 %x_phi_0, 1
br label %for.inc_0

for.end_0:
br label %exit_main_0

exit_main_0:
ret i32 %x_phi_0
}
enter_main_0:
%i_phi_tmp_0 = 0
%x_phi_tmp_0 = 1
br label %for.cond_0

for.cond_0:
%x_phi_0 = %x_phi_tmp_0
%i_phi_0 = %i_phi_tmp_0
%slt_0 = icmp slt i32 %i_phi_0, 10
br i1 %slt_0, label %for.body_0, label %for.end_0

for.inc_0:
%inc_0 = add i32 %i_phi_0, 1
%i_phi_tmp_0 = %inc_0
%x_phi_tmp_0 = %add_0
br label %for.cond_0

for.body_0:
%add_0 = add i32 %x_phi_0, 1
br label %for.inc_0

for.end_0:
br label %exit_main_0

exit_main_0:
ret i32 %x_phi_0
}

考虑%phi = phi i32 [0, bb1], [1, bb2]我们就在bb1/bb2的末端插入一个对%phi的赋值。这里我用了一个并不存在的llvm-ir指令IRMove,并在指令选择阶段直接将其变成了Move。

当然,如果你仔细看了上面的这段代码,你会发现我是先把所有值赋给一个%tmp,再在出现phi的那个基本块中将其赋值给%phi。这是为什么呢?

我们可以回顾支配边界的定义。可以想象,存在一种控制流图使得某个节点的支配边界有它自己。那这就会导致上面的方法对这两种代码会执行不同的结果:

BB1:
%i_phi_0 = phi i32 [ %x_phi_0, %for.inc_0 ], [ 0, %enter_main_0 ]
%x_phi_0 = phi i32 [ %i_phi_0, %for.inc_0 ], [ 1, %enter_main_0 ]
...
BB1:
%x_phi_0 = phi i32 [ %i_phi_0, %for.inc_0 ], [ 1, %enter_main_0 ]
%i_phi_0 = phi i32 [ %x_phi_0, %for.inc_0 ], [ 0, %enter_main_0 ]
...

这两个phi变量互相赋值,这样它们的先后顺序会影响它们在%for.inc_0这个块中的赋值结果。为了解决之,我们采用了新增虚拟寄存器的策略。这某种程度上和时序逻辑有些相似。毕竟每个块中的所有phi都应该是严格在同一时间完成的。

p.s.如果你读过编译器指导手册的话,你会发现,我们的这个操作也省掉了增加空块以避免数据竞争的操作。

修改完上述内容之后,你的Mem2Reg应该就能重新通过asm的所有测试点了。

4. 参考资料

[1] SSA Book

[2] 编译器指导手册(预览8.1)

与编译器优化记录(Mem2Reg+SSA Destruction)相似的内容:

编译器优化记录(Mem2Reg+SSA Destruction)

编译器优化记录(2) Mem2Reg+SSA Destruction 写的时候忽然想起来,这部分的内容恰好是在我十八岁生日的前一天完成的。算是自己给自己的一份成长的纪念吧。 0. 哪些东西可以Mem2Reg 顾名思义,Mem2Reg的意思是我们可以通过维护每个函数中局部变量被赋值之后产生的副本来消除

编译器优化记录(死代码消除+“激进的”死代码消除)

编译器优化记录(3)——死代码消除+”激进的“死代码消除 0. 什么是死代码消除 相信大家在写C++的时候,如果你定义了一个变量但是没有对其使用,大部分IDE都会对这个变量进行灰色的染色。又或者说,当你开了一个空的循环,在里面定义并使用了一堆和输出值/返回值没有关系的变量,这个时候IDE也会提示你这

编译器优化记录(控制流图,支配树)

编译器优化记录(1) 0. 为啥要写这个记录 我感觉自己平时整理自己想法的机会实在是太少了。即便是对于自己花了很多时间想、或是花了很多时间学的东西,同样如此。 写编译器优化的阶段学了很多方法,也看到了很多人类智慧,我希望能从头梳理一下认识它们的过程,来更好地体悟。 我身边有几位好朋友一直保持着记录(

[转帖]Linux 性能优化和内核观测 - 文件系统与磁盘I/O篇(一)

文件系统索引节点和目录项为了方便管理,Linux 文件系统为每个文件都分配了两个数据结构,即​​索引节点(index node)​​​和​​目录项(directory entry)​​。它们主要用来记录文件的元信息和目录结构。索引节点(简称 inode):用于记录文件的元数据,比如 inode 编号

[转帖]linux中内核的一个不错的参数somaxconn

最近发现很多内核优化参数都记不住了,写下文章来备记,方便以后查看. 编辑 /etc/sysctl.conf 文件,在里面加入如下内容:(有注释) #设置系统对最大跟踪的TCP连接数的限制(CentOS 5.6无此参数) net.ipv4.ip_conntrack_max = 25000000 #最大

[转帖]编译器优化那些事儿(7):Cache优化

https://bbs.huaweicloud.com/forum/thread-02101103793043210063-1-1.html 引言 软件开发人员往往期望计算机硬件拥有无限容量、零访问延迟、无限带宽以及便宜的内存,但是现实却是内存容量越大,相应的访问时间越长;内存访问速度越快,价格也更

[转帖]编译器优化那些事儿(6):别名分析概述

https://bbs.huaweicloud.com/forum/thread-0211985213969460007-1-1.html 应用性能调优 发表于 2022-09-14 15:03:17298查看 1.简介 别名分析是编译器理论中的一种技术,用于确定存储位置是否可以以多种方式访问。如果

编译器优化丨Cache优化

摘要:本文重点介绍几种通过优化Cache使用提高程序性能的方法。 本文分享自华为云社区《编译器优化那些事儿(7):Cache优化》,作者:毕昇小助手。 引言 软件开发人员往往期望计算机硬件拥有无限容量、零访问延迟、无限带宽以及便宜的内存,但是现实却是内存容量越大,相应的访问时间越长;内存访问速度越快

[转帖]Linux性能优化(十二)——CPU性能调优

Linux性能优化(十二)——CPU性能调优 https://blog.51cto.com/u_9291927/2594259 一、应用程序优化 (1)编译器优化。适当开启编译器优化选项,在编译阶段提升性能。gcc提供优化选项-On会自动对应用程序的代码进行优化。(2)算法优化。使用复杂度更低的算法

XCode汇编调试

汇编调试的意义 了解常用的汇编指令和知识,可以知道经过编译器优化后,最终的代码调用,有可能和源码并不相同,如:设置faster,smallest 代码会更短,最终的汇编执行指令与源码不一样。 可以研究代码在二进制层面的执行流程是否和源码的流程一致,从二进制层面研究方法调用的传参,内部调用,方法返回值