优化器
Solidity编译器使用两种不同的优化器模块。在操作码水平上操作的 "旧" 优化器 和在 Yul IR 代码上操作的 “新” 优化器。
基于操作码的优化器对操作码应用一套 简化规则。 它还结合了相等的代码集并删除了未使用的代码。
基于Yul的优化器要强大得多,因为它可以跨函数调用工作。 例如,任意跳转在Yul中是不可能的, 所以有可能计算每个函数的副作用。假设有两个函数调用, 其中第一个不修改存储,第二个修改存储。 如果它们的参数和返回值不相互依赖,我们就可以对函数调用重新排序。 同样地,如果一个函数是没有副作用的,而且其结果是乘以0的,就可以完全删除该函数调用。
目前,参数 --optimize
会为生成的字节码激活基于操作码的优化器,
并为内部生成的 Yul 代码激活 Yul 优化器,例如当使用 ABI coder v2 时。
您可以使用 solc --ir optimized --optimize
来为 Solidity 源码产生一个优化的 Yul IR。
同样地,您可以使用 solc --strict-assembly --optimize
来产生一个独立的 Yul 模式。
备注
窥视孔(peephole)优化器 和内联器总是默认启用的,只能通过 标准 JSON 文件配置 关闭。
您可以在下面找到关于这两个优化器模块及其优化步骤的更多细节。
优化Solidity代码的好处
总的来说,优化器试图简化复杂的表达式,从而减少代码大小和执行成本, 也就是说,它可以减少部署合约以及对合约进行外部调用所需的气体。 它还会对函数进行专业化或内联化优化。特别是当函数内联一个可能导致更大的代码操作时, 它经常这样做,因为这导致了更多简化的机会。
优化和非优化代码之间的差异
一般来说,最明显的区别是常量表达式在编译时被评估。
当涉及到ASM输出时,人们也可以注意到等价或重复的代码块的减少(比较 --asm
和 --asm --optimize
标志的输出)。
然而,当涉及到Yul/中间代表时,可能会有明显的差异,
例如,函数可能被内联,合并或重写以消除冗余等等(比较带有 --ir
和 --optimize --ir-optimized
标志的输出)。
优化器参数运行
运行次数( --optimize-runs
)大致规定了在合约有效期内,
所部署的代码的每个操作码被执行的频率。
这意味着它是代码大小(部署成本)和代码执行成本(部署后的成本)之间的一个折衷参数。
一个 “运行” 参数为 “1” 将产生简短的合约但昂贵的执行代码。相反,
一个较大的 “运行” 参数将产生较大的合约但更省气体的执行代码。
该参数的最大值为 2**32-1
。
备注
一个常见的误解是,这个参数指定了优化器的迭代次数。这是不正确的。 优化器将始终运行尽可能多的次数来改进代码。
基于操作码的优化器模块
基于操作码的优化器模块对汇编代码进行操作。
它在 JUMPs
和 JUMPDESTs
之间将指令序列分成基本块。
在这些块中,优化器分析指令,并将对堆栈、内存或存储的每一次修改记录为一个表达式,
该表达式由一条指令和一列参数组成,这些参数是指向其他表达式的指针。
此外,基于操作码的优化器使用了一个名为 “通用子表达式消除器” 的组件,
它除其他任务外,还能找到总是相等的表达式(在每个输入上),
并将它们合并为一个表达式类。它首先尝试在一个已经知道的表达式列表中找到每个新的表达式。
如果没有找到这样的匹配,
它就根据 constant + constant = sum_of_constants
或 X * 1 = X
这样的规则简化表达式。
由于这是一个递归过程,如果第二个因素是一个更复杂的表达式,并且知道这个表达式的值总是为1,我们也可以应用后一个规则。
某些优化器步骤象征性地跟踪存储和内存位置。例如, 这些信息被用来计算Keccak-256哈希值,可以在编译时进行评估。 考虑一下这个序列:
PUSH 32
PUSH 0
CALLDATALOAD
PUSH 100
DUP2
MSTORE
KECCAK256
或着等同于Yul为
let x := calldataload(0)
mstore(x, 100)
let value := keccak256(x, 32)
在这种情况下,优化器跟踪位于内存位置 calldataload(0)
的值,
然后意识到Keccak-256哈希值可以在编译时被评估。
这只有在 mstore
和 keccak256
之间没有其他指令修改内存时才有效。
因此,如果有一条指令写到内存(或存储),那么我们需要擦除对当前内存(或存储)的记忆。
然而,这种擦除有一个例外,当我们可以很容易地看到指令没有写到某个位置。
示例,
let x := calldataload(0)
mstore(x, 100)
// 已知当前内存位置x -> 100
let y := add(x, 32)
// 没有清除 x -> 100 的记忆,因为y并没有写到[x,x+32)。
mstore(y, 200)
// 现在可以对这个Keccak-256进行计算了
let value := keccak256(x, 32)
因此,对存储和内存位置的修改,比如说位置 l
,
必须擦除关于可能等于 l
的存储或内存位置的记忆。更具体地说,
对于存储,优化器必须删除所有可能等于 l
的符号位置的记忆,
对于内存,优化器必须删除所有可能不超过32字节的符号位置的记忆。
如果 m
表示一个任意的位置,那么这个擦除的决定是通过计算 sub(l, m)
的值来完成。
对于存储,如果这个值被评估为一个非零的值,那么关于 m
的记忆将被保留。
对于内存,如果这个值被评估为一个介于 32
和 2**256 - 32
之间的值,那么关于 m
的记忆将被保留。
在所有其他情况下,关于 m
的记忆将被删除。
并且有一个对内存和存储的修改列表。 这些信息与基本代码块一起存储并用来链接它们。此外, 关于堆栈、存储和内存配置的记忆被转发给下一个(几个)块。
如果我们知道所有 JUMP
和 JUMPI
指令的目标,
我们就可以构建一个完整的程序流程图。
如果只有一个我们不知道的目标(原则上可能发生,跳转目标可以基于输入来计算),
我们必须消除关于代码块输入状态的所有信息,因为它可能是未知的 JUMP
目标。
如果一个 JUMPI
的条件等于一个常量,它将被转换为无条件跳转。
作为最后一步,每个块中的代码都会被完全重新生成。 然后优化器会从代码块的结尾处在栈上的表达式开始创建依赖关系图, 且不是该图组成部分的每个操作都会被丢弃。 这样生成的代码将按照原始代码中的顺序对内存和存储进行修改(舍弃不需要的修改)。 最后,它生成了所有需要在堆栈中的正确位置的值。
这些步骤适用于每个基本代码块,如果代码块较小,则新生成的代码将用作替换。
如果一个基本代码块在 JUMPI
处被分割,且在分析过程中被评估为一个常数,
则会根据常量的值来替换 JUMPI
,因此,类似于
uint x = 7;
data[7] = 9;
if (data[x] != x + 2) // 这个条件永远不会是真的
return 2;
else
return 1;
简化为这样:
data[7] = 9;
return 1;
简单内联
从Solidity 0.8.2版本开始,有另一个优化步骤,
它用这些指令的拷贝来替换某些包含以 “跳转” 结束的 “简单” 指令的块的跳转。
这相当于对简单的、小的Solidity或Yul函数进行内联。特别是,
PUSHTAG(tag) JUMP
序列可以被替换,只要 JUMP
被标记为 "进入" 一个函数的跳转,
并且在 tag
后面有一个基本块(如上面描述的 “通用子表达式消除器”),
它以另一个 JUMP
结束,被标记为 “离开” 一个函数的跳转。
特别是,考虑以下为调用内部Solidity函数而生成的汇编的原型例子:
tag_return
tag_f
jump // 从此进入
tag_return:
...opcodes after call to f...
tag_f:
...body of function f...
jump // 从此退出
只要函数的主体是一个连续的基本块,“内联” 就可以用位于 tag_f
处的块来代替 tag_f jump
,结果是:
tag_return
...body of function f...
jump
tag_return:
...opcodes after call to f...
tag_f:
...body of function f...
jump // 从此退出
现在,理想情况下,上述的其他优化器步骤将导致返回标签的推送被移向剩余的跳转,从而导致:
...body of function f...
tag_return
jump
tag_return:
...opcodes after call to f...
tag_f:
...body of function f...
jump // 从此退出
在这种情况下,“窥视孔优化器(PeepholeOptimizer)” 将删除返回跳转。理想情况下,
所有对 tag_f
的引用都可以这样做,而不使用它,特别处理的话,它也可以被移除:
...body of function f...
...opcodes after call to f...
因此,对函数 f
的调用是内联的,可以删除 f
的原始定义。
无论何时,只要启发式算法表明,在合同的生命周期内,内联比不内联更便宜,就会尝试这样的内联。 这种启发式方法取决于函数体的大小、对其标记的其他引用的数量(近似于函数调用的数量) 以及合约的预期执行次数(全局优化器参数 "runs")。
基于Yul的优化器模块
基于Yul的优化器由几个阶段和组件组成,它们都以语义等效的方式转换AST。 我们的目标是,最终的代码要么更短,要么至少略长,但允许进一步的优化步骤。
警告
由于优化器正在进行大量开发,这里的信息可能已经过时。 如果您依赖某项功能,请直接联系团队。
优化器目前遵循的是一种纯粹的贪婪策略,不做任何回溯。
下面将解释基于Yul的优化器模块的所有组件。 以下的转换步骤是主要的组成部分:
SSA转换
通用子表达式消除器
表达式简化器
冗余赋值消除器
完全内联
优化器的步骤
这是按字母顺序排列的基于Yul的优化器的所有步骤的列表。 您可以在下面找到更多关于各个步骤和它们的顺序的信息。
选择优化方案
默认情况下,优化器对生成的程序集应用其预定义的优化步骤序列。
您可以使用 yul-optimizations
选项覆盖这个序列并提供您自己的序列:
solc --optimize --ir-optimized --yul-optimizations 'dhfoD[xarrscLMcCTU]uljmul'
[...]
里面的序列将被循环应用多次,直到Yul代码保持不变或达到最大轮数(目前为12)。
可用的缩写列在 Yul 优化器文档 中。
预处理
预处理组件进行转换,使程序变成某种更容易操作的正常形式。 这种正常形式在剩下的优化过程中被保留。
消歧器
消歧器获取AST并返回一个新拷贝,其中所有标识符在输入AST中都有唯一的名称。 这是所有其他优化器阶段的先决条件。 其中一个好处是,标识符查找不需要考虑作用域, 这简化了其他步骤所需的分析。
所有后续阶段都有一个属性,即所有的名字都保持唯一。 这意味着如果需要引入一个新的标识符,就会产生一个新的唯一名称。
函数提升器
函数提升器将所有的函数定义移到最上面的块的末尾。 只要在消歧义阶段之后进行,这就是一个语义上的等价转换。 原因是,将一个定义移到更高层次的块中不能降低其可见性, 而且不可能引用在不同函数中定义的变量。
这个阶段的好处是,可以更容易地查找函数定义, 并且可以孤立地优化函数,而不必完全遍历AST。
函数分组器
函数分组器必须在消歧义器和函数提升器之后应用。 它的作用是将所有不是函数定义的最上面的元素移到一个单一的块中, 这个块是根块的第一个语句。
在这一步之后,一个程序具有以下正常形式:
{ I F... }
其中 I
是一个(可能是空的)区块,不包含任何函数定义(甚至是递归的),
F
是一个函数定义的列表,使得没有一个函数包含函数定义。
这个阶段的好处是,我们总是知道功能列表的开始位置。
循环条件进入正文
这种转换将for循环的循环迭代条件移动到循环体中。
我们需要这种转换,因为 表达式拆分器 将不适用于迭代条件表达式(以下示例中的 C
)。
for { Init... } C { Post... } {
Body...
}
被转化为
for { Init... } 1 { Post... } {
if iszero(C) { break }
Body...
}
当与 循环不变代码模式
搭配时,这种转换也是有用的,因为循环不变条件中的不变量可以在循环之外进行。
循环初始重写器
这种转换将for-loop的初始化部分移到循环之前:
for { Init... } C { Post... } {
Body...
}
被转化为
Init...
for {} C { Post... } {
Body...
}
这简化了其余的优化过程, 因为我们可以忽略for循环初始化块的复杂范围规则。
初始化程序
这一步重写了变量声明,使所有的变量都被初始化。
像 let x, y
这样的声明被分割成多个声明语句。
目前只支持用零值初始化。
伪SSA转换
这个组件的目的是让程序变成一个较长的形式, 以便其他组件能够更容易地与之配合。 最终的表现形式将类似于静态单一赋值(SSA)的形式,不同的是, 它不使用明确的 "phi" 函数来合并来自控制流不同分支的值, 因为Yul语言中不存在这样的功能。相反,当控制流合并时, 如果一个变量在其中一个分支中被重新赋值,就会声明一个新的SSA变量来保持它的当前值, 这样,下面的表达式仍然只需要引用SSA变量。
下面是一个转换的例子:
{
let a := calldataload(0)
let b := calldataload(0x20)
if gt(a, 0) {
b := mul(b, 0x20)
}
a := add(a, 1)
sstore(a, add(b, 0x20))
}
应用以下所有转换步骤后,程序将如下所示:
{
let _1 := 0
let a_9 := calldataload(_1)
let a := a_9
let _2 := 0x20
let b_10 := calldataload(_2)
let b := b_10
let _3 := 0
let _4 := gt(a_9, _3)
if _4
{
let _5 := 0x20
let b_11 := mul(b_10, _5)
b := b_11
}
let b_12 := b
let _6 := 1
let a_13 := add(a_9, _6)
let _7 := 0x20
let _8 := add(b_12, _7)
sstore(a_13, _8)
}
请注意,此代码段中唯一重新分配的变量是 b
。
无法避免这种重新分配,因为根据控制流, b
具有不同的值。
所有其他变量在定义后都不会改变其值。
该属性的优点是,变量可以自由移动,
对它们的引用可以通过它们的初始值进行交换(反之亦然),
只要这些值在新上下文中仍然有效。
当然,这里的代码远远没有得到优化。相反,它要长得多。 我们希望这段代码更容易使用,此外,还有一些优化器步骤可以撤销这些更改, 并在最后使代码更加紧凑。
表达式拆分器
表达式拆分器将诸如 add(mload(0x123), mul(mload(0x456), 0x20))
这样的表达式变成一连串独特变量的声明,这些变量被分配给该表达式的子表达式,
这样每个函数调用只有变量作为参数。
上述内容将被转化为
{
let _1 := 0x20
let _2 := 0x456
let _3 := mload(_2)
let _4 := mul(_3, _1)
let _5 := 0x123
let _6 := mload(_5)
let z := add(_6, _4)
}
请注意,这种转换并不改变操作码或函数调用的顺序。
它不适用于循环迭代条件,因为循环控制流不允许在所有情况下 “概述” 内部表达式。 我们可以通过应用 循环条件进入正文 将迭代条件移动到循环体中,从而避开这个限制。
最后一个程序的形式应确保(循环条件除外)函数调用不会嵌套在表达式中, 所有函数调用参数都必须是变量。
这种形式的好处是,更容易重新排列操作码序列, 也更容易执行函数调用内联。此外, 也更简单地替换表达式的各个部分或重新组织 “表达式树”。 缺点是这样的代码对我们来说更难阅读。
SSA转换
这个阶段尽可能地用新变量的声明来取代对现有变量的重复赋值。 重新赋值仍然存在,但是所有对重新赋值的变量的引用都被新声明的变量所取代。
示例:
{
let a := 1
mstore(a, 2)
a := 3
}
被转化为
{
let a_1 := 1
let a := a_1
mstore(a_1, 2)
let a_3 := 3
a := a_3
}
精确语义:
对于任何在代码中被分配到某处的变量 a
(带值声明且从未重新分配的变量不被修改),执行以下转换:
将
let a := v
替换为let a_i := v let a := a_i
将
a := v
替换为let a_i := v a := a_i
, 其中i
是一个数字,使得a_i
尚未使用。
此外,总是记录用于 a
的 i
的当前值,并用 a_i
替换对 a
的每次引用。
变量 a
的当前值映射在每个分配给它的块结束时被清除,
如果它被分配在for循环体或post块内,则在for循环初始块结束时被清除。
如果一个变量的值根据上面的规则被清除,并且该变量被声明在块之外,
一个新的SSA变量将在控制流加入的位置被创建,这包括循环后/体块的开始和If/Switch/ForLoop/Block语句之后的位置。
在此阶段之后,建议使用冗余赋值消除器删除不必要的中间分配。
如果在这个阶段之前运行表达式拆分器和通用子表达式消除器, 那么这个阶段会提供最好的结果,因为这样就不会产生过多的变量。 另一方面,如果在SSA转换之后运行通用子表达式消除器,则效率更高。
冗余赋值消除器
SSA转换总是生成 a := a_i
形式的赋值,
尽管这些赋值在许多情况下可能是不必要的,比如下面的例子:
{
let a := 1
a := mload(a)
a := sload(a)
sstore(a, 1)
}
SSA转换将这个片段转换为以下内容:
{
let a_1 := 1
let a := a_1
let a_2 := mload(a_1)
a := a_2
let a_3 := sload(a_2)
a := a_3
sstore(a_3, 1)
}
冗余赋值消除器将删除对 a
的所有三个赋值,因为未使用 a
的值,
因此将此代码段转换为严格的SSA形式为:
{
let a_1 := 1
let a_2 := mload(a_1)
let a_3 := sload(a_2)
sstore(a_3, 1)
}
当然,确定分配是否多余的错综复杂的部分与加入控制流有关。
该组件的详细工作情况如下:
AST被遍历了两次:分别在在信息收集步骤和实际删除步骤中。 在信息收集过程中,我们维护了一个从赋值语句到 “未使用(unused)”,“未决定(undecided)” 和 “已使用(used)” 三种状态的映射, 这标志着分配的值是否会在以后被变量的引用使用。
当一个赋值被访问时,它被添加到处于 “未决定” 状态的映射中 (见下面关于for循环的注释),而其他每个仍处于 “未决定” 状态的对同一变量的赋值被改为 “未使用”。 当一个变量被引用时,任何对该变量的赋值仍处于 “未决定” 状态,其状态被改变为 “已使用”。
在控制流分叉的地方,映射的拷贝被移交给每个分支。 在控制流汇合的地方,来自两个分支的两个映射以下列方式合并: 只在一个映射中的语句或具有相同状态的语句不作改动地使用。 冲突的值以如下方式解决:
“未使用”, “未决定” -> “未决定”
“未使用”, “已使用” -> “已使用”
“未决定”, “已使用” -> “已使用”
对于For循环,考虑到条件下的连接控制流,将对条件、主体和后部进行两次访问。 换句话说,我们创建了三条控制流路径:循环的零次运行、一次运行和两次运行,然后在最后合并它们。
不需要模拟第三次甚至更多的运行,这可以如下所示:
迭代开始时的赋值状态将决定性地导致该赋值在迭代结束时的状态。
假如这个状态映射函数被称为 f
。如上所述,
三种不同状态 unused(未使用)
, undecided(未决定)
和 used(已使用)
的组合是 最多(max)
操作,
其中 unused = 0
, undecided = 1
, used = 2
。
正确的方法是计算
max(s, f(s), f(f(s)), f(f(f(s))), ...)
作为循环后的状态。因为 f
只是有三个不同的值的范围,
迭代它必须在最多三个迭代后达到一个循环,
因此 f(f(f(s)))
必须等于 s
, f(s)
或 f(f(s))
其中之一,
因此
max(s, f(s), f(f(s))) = max(s, f(s), f(f(s)), f(f(f(s))), ...).
总之,最多运行两次循环就足够了,因为只有三种不同的状态。
对于有 "默认" 情况的switch语句,没有跳过switch的控制流部分。
当一个变量超出范围时,所有仍处于 "未决定" 状态的语句都被改为 "未使用", 除非该变量是一个函数的返回参数--如何是这样,状态变为 "已使用"。
在第二次遍历中,所有处于 "未使用" 状态的赋值都被删除。
这一步通常是在SSA转换之后立即运行,以完成伪SSA的生成。
工具
可移动性
可移动性是表达式的一个属性。它大致上意味着表达式是没有副作用的, 它的评估只取决于变量的值和环境的调用常数状态。 大多数表达式都是可移动的。以下部分使表达式不可移动:
函数调用(如果函数中的所有语句都是可移动的,未来可能会放宽)
有副作用的操作码(如
call
或selfdestruct
)读取或写入内存, 存储或外部状态信息的操作码
取决于当前PC、内存大小或返回数据大小的操作码
数据流分析器
数据流分析器本身不是一个优化步骤,而是被其他组件作为工具使用。
在遍历AST时,它跟踪每个变量的当前值,
只要该值是一个可移动的表达式。
它记录了作为表达式一部分的变量,
这些表达式目前被分配给其他每个变量。在每次对变量 a
的赋值时,
a
的当前存储值被更新,只要 a
是 b
当前存储表达式的一部分,
变量 b
的所有存储值都被清除。
在控制流连接处,如果变量在任何控制流路径中已经或将要被分配, 那么关于这些变量的记忆就会被清除。例如,在进入for循环时,所有将在主体或后块中分配的变量都被清除。
表达式的简化
这些简化过程会改变表达式,并用等效的、希望更简单的表达式替换它们。
通用子表达式消除器
这一步使用数据流分析器,用对某一变量的引用来替换语法上与该变量当前值相匹配的子表达式。 这是一个等价转换,因为这种子表达式必须是可移动的。
如果值是一个标识符,所有本身是标识符的子表达式都被其当前值替换。
上述两条规则的结合允许计算出一个局部值的编号, 这意味着如果两个变量有相同的值,其中一个将永远是未使用的。 然后,未使用过的处理器或冗余赋值消除器将能够完全消除此类变量。
如果之前运行过表达式拆分器,则此步骤尤其有效。 如果代码是伪SSA形式,那么变量值的可用时间更长,因此我们有更高的机会替换表达式。
如果通用子表达式消除器在它之前运行, 表达式简化器将能够进行更好的替换。
表达式简化器
表达式简化器使用数据流分析器,
并利用表达式的等价变换列表,如 X + 0 -> X
来简化代码。
它试图在每个子表达式上匹配诸如 X + 0
的模式。
在匹配过程中,它将变量解析为当前分配的表达式,
以便能够匹配更深入的嵌套模式,
即使代码是伪SSA形式。
一些模式如 X - X -> 0
只能在表达式 X
是可移动的情况下应用,
否则会删除其潜在的副作用。
由于变量引用总是可移动的,即使它们的当前值可能不是,
表达式简化器在拆分或伪SSA形式下又更加强大。
字面意义上的再物质化器(LiteralRematerialiser)
有待记录。
负载解析器
优化阶段,分别将 sload(x)
和 mload(x)
类型的表达式替换为当前存储和内存中的值,如果已知的话。
如果代码是SSA形式的,效果最好。
先决条件:消歧器,循环初始重写器。
基于推理的简化器
这个优化器使用SMT求解器来检查 if
条件是否为常数。
如果
限制条件和条件
是不满足的(UNSAT),那么条件永远不会是真的,整个主体可以被删除。如果
限制条件和非限制条件
是不满足的(UNSAT),那么条件永远是真的,可以用1
代替。
只有在条件是可移动的情况下,上面的简化才能适用。
它只对EVM语言有效,但在其他语言上使用是安全的。
先决条件:消歧器,SSA转换。
声明规模的简化
循环引用程序
这个阶段删除了那些互相调用但既没有外部引用也没有从最外层上下文中引用的函数。
条件简化器
如果可以从控制流中确定数值,条件简化器就会插入对条件变量的赋值。
销毁SSA表格。
目前,这个工具是非常有限的,主要是因为我们还没有支持布尔类型。 由于条件只检查表达式是否为非零,我们不能指定一个特定的值。
当前的特性:
切换条件:插入 “<条件> := <条件标签>”
在带有终止控制流的if语句后,插入“<条件> : =0”
未来的特性:
允许用 "1" 替换
考虑到用户定义的终止函数
如果之前已经运行过死代码的删除,那么使用SSA表单效果最好。
先决条件:消歧器。
有条件的非对称性放大器
条件简化器的反面。
控制流简化器
简化了几个控制流结构:
用pop(条件)代替if,用空的程序体代替if
移除空的默认switch情况
如果不存在默认情况,则删除空的switch情况
用pop(表达式)代替没有条件的switch
把单例的switch变成if
用pop(表达式)和程序体代替switch,只用默认情况
用匹配的条件程序体的常量表达式替换switch
将
for
替换为终止控制流,在没有其他 break/continue 的情况下替换为if
移除函数末尾的
leave
这些操作都不依赖于数据流。然而结构简化器执行类似的任务,确实依赖于数据流。
控制流简化器在其遍历过程中确实记录了是否存在 break
和 continue
语句。
先决条件:消歧器,函数提升器, 循环初始重写器。 重要提示:引入了EVM操作代码,因此目前只能用于EVM代码。
死代码消除器
这个优化阶段删除了不可到达的代码。
无法访问代码可以是一个块中的任何代码, 其前面有leave,return,invalid,break,continue,selfdestruct 或 revert。
函数定义被保留下来,因为它们可能被早期的代码调用,因此被认为是可访问的。
因为在for循环的init块中声明的变量,其范围会扩展到循环体, 所以我们要求 循环初始重写器 在此步骤之前运行。
先决条件: 循环初始重写器, 函数提升器, 函数分组器
等价的存储清除器
如果之前有对 mstore(k, v)
/ sstore(k, v)
的调用,
但中间没有其他存储,并且 k
和 v
的值没有变化,
则该步骤将删除 mstore(k, v)
和 sstore(k, v)
的调用。
如果在SSA转换和通用子表达式消除器之后运行,这个简单的步骤是有效的, 因为SSA将确保变量不会改变,而通用子表达式消除器在已知值相同的情况下会重新使用完全相同的变量。
先决条件: 消歧器, 循环初始重写器
未使用过的处理器
这一步删除了所有从未被引用的函数的定义。
它还删除了从未被引用的变量的声明。如果声明指定了一个不可移动的值, 表达式将被保留,但其值将被丢弃。
所有可移动的表达式语句(未被赋值的表达式)都被删除。
结构简化器
这是一个一般的步骤,在结构层面上进行各种简化:
用
pop(条件)
代替 if 语句的空程序体。用其主体替换带有真实条件的if语句
删除带有错误条件的if语句
把单例的switch变成if
用
pop(表达式)
和程序体代替switch,只用默认情况通过匹配的条件程序体,用字面表达式替换switch
用其初始化部分取代带有错误条件的for循环
该组件使用数据流分析器。
块展平器
这个阶段通过在外部块的适当位置插入内部块的语句来消除嵌套块。 它依赖于函数分组器,并不对最外层的块进行展平,以保持函数分组器产生的形式。
{
{
let x := 2
{
let y := 3
mstore(x, y)
}
}
}
被转化为
{
{
let x := 2
let y := 3
mstore(x, y)
}
}
只要代码没有歧义,这就不会造成问题,因为变量的作用域只能增长。
循环不变代码模式
这种优化将可移动的SSA变量声明移到循环之外。
只有在循环体或后块中的最高级别的语句被考虑, 即条件分支内的变量声明不会被移出循环。
要求:
消歧器, 循环初始重写器和函数提升器必须提前运行。
表达式拆分器和SSA转换应在前期运行以获得更好的结果。
函数级的优化
函数特殊化器
这一步是用字面参数来实现函数的专业化。
如果一个函数,例如, function f(a, b) { sstore (a, b) }
,被调用时有字面参数,
例如, f(x, 5)
,其中 x
是一个标识符,可以通过创建一个新函数 f_1
来专门化,
该函数只需要一个参数,即:
function f_1(a_1) {
let b_1 := 5
sstore(a_1, b_1)
}
其他优化步骤将能够对函数进行更多的简化。 优化步骤主要对那些不会被内联的函数有用。
先决条件: 消歧器, 函数提升器
建议将字面意义上的再物质化器(LiteralRematerialiser)作为先决条件,尽管它不是正确性的必要条件。
未使用的函数参数管理器
这一步是删除一个函数中未使用的参数。
如果一个参数没有使用,
比如在 function f(a,b,c) -> x, y { x := div(a,b) }
中的 c
和 y
,
我们删除该参数并创建一个新的 "连接" 函数,如下所示:
function f(a,b) -> x { x := div(a,b) }
function f2(a,b,c) -> x, y { x := f(a,b) }
并将所有对 f
的引用替换为 f2
。
之后应该运行内联,以确保所有对 f2
的引用都被 f
替换。
先决条件: 消歧器, 函数提升器, 字面意义上的再物质化器
字面意义上的再物质化器这个步骤对于正确性来说不是必需的。
它有助于处理诸如以下情况:
function f(x) -> y { revert(y, y} }
其中字面意思 y
将被其值 0
取代,
使我们能够重写该函数。
等价函数组合器
如果两个函数在语法上是等价的, 同时允许变量重命名,但不允许任何重新排序, 那么对其中一个函数的任何引用都会被另一个函数取代。
实际删除的功能是由未使用过的处理器执行的。
函数内联
表达式内联
优化器的这个组件通过内联可以在函数表达式中内联的函数来执行限制性的函数内联,函数为:
返回一个单一的值。
有一个像
r := <函数表达式>
的主体。既没有提到自己,也没有提到右边的
r
。
此外,对于所有的参数,以下各项都需要为真:
参数是可移动的。
该参数在函数体中被引用不到两次,或者该参数相当便宜 ( "成本" 最多为1,就像一个0xff以下的常数)。
例如:要被内联的函数的形式是: function f(...) -> r { r := E }
其中 E
是一个不引用 r
的表达式,函数调用中的所有参数都是可移动表达式。
这种内联的结果总是一个单一的表达式。
该组件只能用于具有唯一名称的源码。
完全内联
完全内联用函数的主体取代了某些函数的调用。 这在大多数情况下是没有什么帮助的,因为它只是增加了代码的大小,但并没有什么好处。 此外,代码通常是非常昂贵的,我们往往宁愿要更短的代码而不是更有效的代码。 不过,在相同的情况下,内联一个函数可以对后续的优化步骤产生积极的影响。 例如,如果一个函数参数是一个常数,就会出现这种情况。
在内联过程中,一个启发式方法被用来判断函数调用是否应该被内联。 目前的启发式方法是不内联到 "大" 函数,除非被调用的函数很小。 只使用一次的函数以及中等大小的函数被内联,而带有常数参数的函数调用允许稍大的函数。
在未来,我们可能会加入一个回溯组件, 它不会立即对一个函数进行内联,而只是对其进行专业化处理, 这意味着会生成一个函数的拷贝,其中某个参数总是被一个常数取代。 之后,我们可以在这个专用函数上运行优化器。 如果结果有很大的收益,那么这个专门化的函数就被保留下来,否则就用原来的函数代替。
清理
清理工作是在优化器运行结束时进行的。 它试图将分割的表达式再次组合成深度嵌套的表达式, 并且通过尽可能地消除变量来提高堆栈机的 "可编译性"。
表达式连接器
这是与表达式分割器相反的操作。它把正好有一个引用的变量声明序列变成一个复杂的表达式。 这个阶段完全保留了函数调用和操作码执行的顺序。它不使用任何关于操作码的互换性的信息; 如果将一个变量的值移到它的使用位置会改变任何函数调用或操作码执行的顺序,则不执行转换。
注意,组件不会移动变量赋值或被多次引用的变量的赋值。
片段 let x := add(0, 2) let y := mul(x, mload(2))
不能转换,
因为它将导致调用操作码 add
和 mload
的顺序被调换--尽管这不会有什么影响,
因为 add
是可移动的。
当像这样重排操作码时,变量引用和字面意义被忽略了。
因此,片段 let x := add(0, 2) let y := mul(x, 3)
被转换为
let y := mul(add(0, 2), 3)
,尽管 add
操作码将在计算字面意义 3
后执行。
SSA反转器
这是一个微小的步骤,如果它与通用子表达式消除器和未使用过的处理器相结合, 则有助于扭转SSA转换的影响。
我们生成的SSA形式对EVM和WebAssembly的代码生成是不利的, 因为它生成了许多局部变量。最好的办法是用赋值重新使用现有的变量, 而不是用新的变量声明。
SSA转换改写
let a := calldataload(0)
mstore(a, 1)
为
let a_1 := calldataload(0)
let a := a_1
mstore(a_1, 1)
let a_2 := calldataload(0x20)
a := a_2
问题是在引用 a
时使用了变量 a_1
,而不是 a
。
SSA转换改变了这种形式的语句,只需将声明和赋值互换。
上面的片段被转化为
let a := calldataload(0)
let a_1 := a
mstore(a_1, 1)
a := calldataload(0x20)
let a_2 := a
这是一个非常简单的等价转换,但是当我们现在运行通用子表达式消除器时,
它将用 a
替换所有出现的 a_1
(直到 a
被重新赋值)。
然后,未使用过的处理器将完全消除变量 a_1
,从而完全逆转SSA的转换。
堆栈压缩器
让以太坊虚拟机的代码生成变得困难的一个问题是, 在表达式堆栈中,有16个插槽的硬性限制,可以向下延伸。 这或多或少转化为16个局部变量的限制。 堆栈压缩器采用Yul代码并将其编译为EVM字节码。 每当堆栈差异过大时,它就会记录发生在哪个函数中。
对于每一个造成这种问题的函数,再物质化都会被调用, 并提出特殊要求,以积极消除按其值的成本排序的特定变量。
一旦失败,这个程序会重复多次。
再物质化
再物质化阶段试图用最后分配给变量的表达式来替换变量引用。 当然,这只有在这个表达式的评估费用相对较低的情况下才是有益的。 此外,只有当表达式的值在赋值点和使用点之间没有变化时, 它才具有语义上的等同性。这个阶段的主要好处是, 如果它导致一个变量被完全消除,它可以节省堆栈槽(见下文), 但是如果表达式非常便宜,它也可以在EVM上节省一个DUP操作码。
再物质化使用数据流分析器来跟踪变量的当前值, 这些变量总是可移动的。 如果数值非常便宜或者变量被明确要求消除, 那么变量的引用就会被其当前值所取代。
体外循环条件
逆转体外循环条件的转换。
对于任何可移动的 c
,它转换
for { ... } 1 { ... } {
if iszero(c) { break }
...
}
为
for { ... } c { ... } {
...
}
而它又转换
for { ... } 1 { ... } {
if c { break }
...
}
为
for { ... } iszero(c) { ... } {
...
}
字面意义上的再物质化器应在此步骤之前运行。
特定的WebAssembly
主要功能
将最上面的块改变为一个具有特定名称(“main”)的函数,它没有输入和输出。
取决于函数分组器。