优化器

Solidity 编译器在三个不同的级别(按执行顺序)进行优化。

  • 在代码生成期间基于对 Solidity 代码的直接分析进行优化。

  • 对 Yul IR 代码进行优化转换。

  • 在操作码级别进行优化。

基于操作码的优化器对操作码应用一组简化规则。它还合并了相同的代码集并删除了未使用的代码。

基于 Yul 的优化器功能更强大,因为它可以跨越函数调用进行操作。例如,Yul 中不允许进行任意跳转,因此可以计算每个函数的副作用。考虑两个函数调用,第一个函数不修改存储,而第二个函数修改存储。如果它们的实参和返回值不互相依赖,我们可以重新排序函数调用。类似地,如果一个函数没有副作用,并且它的结果乘以零,则可以完全删除函数调用。

基于代码生成的优化器会影响从 Solidity 输入生成的初始低级代码。在传统管道中,字节码是立即生成的,并且大多数这种类型的优化是隐式的且不可配置的,唯一的例外是改变二进制操作数中字面量顺序的优化。基于 IR 的管道采用了不同的方法,它生成与 Solidity 代码结构非常接近的 Yul IR,并且几乎所有优化都推迟到 Yul 优化器模块中。在这种情况下,代码生成级别的优化仅在 Yul IR 中难以处理但使用分析阶段的高级信息很容易处理的极少数情况下进行。这种优化的一个示例是在某些惯用的for循环中,在递增计数器时绕过检查算术。

目前,参数--optimize会为生成的字节码激活基于操作码的优化器,并为内部生成的 Yul 代码(例如,用于 ABI 编码器 v2)激活 Yul 优化器。可以使用solc --ir-optimized --optimize为 Solidity 源代码生成优化的 Yul IR。类似地,可以使用solc --strict-assembly --optimize用于独立的 Yul 模式。

注意

一些优化步骤,例如,窥孔优化器未检查的循环增量优化器始终默认启用,只能通过标准 JSON关闭。

注意

即使没有--optimize,也接受空优化器序列,以便完全禁用用户提供的 Yul 优化器序列,因为默认情况下,即使优化器没有打开,未用修剪器步骤也会运行。

您可以在下面找到有关两个优化器模块及其优化步骤的更多详细信息。

优化 Solidity 代码的好处

总的来说,优化器试图简化复杂的表达式,这会减少代码大小和执行成本,即它可以减少合约部署所需的 gas 以及对合约进行外部调用所需的 gas。它还会专门化或内联函数。特别是函数内联是一个可能导致代码量大幅增加的操作,但它通常会执行,因为它会带来更多简化机会。

优化后的代码与非优化代码的区别

通常,最明显的区别是常量表达式在编译时被求值。在 ASM 输出方面,还可以注意到等效或重复代码块的减少(比较标志--asm--asm --optimize的输出)。但是,在 Yul/中间表示方面,可能会出现重大差异,例如,函数可能会被内联、合并或重写以消除冗余等(比较标志--ir--optimize --ir-optimized之间的输出)。

优化器参数运行

运行次数 (--optimize-runs) 大致指定在合约的整个生命周期中,部署代码的每个操作码将被执行的次数。这意味着它是在代码大小(部署成本)和代码执行成本(部署后的成本)之间进行权衡的参数。“运行”参数为“1”将生成简短但昂贵的代码。相反,较大的“运行”参数将生成较长但更节约 gas 的代码。参数的最大值为2**32-1

注意

一个常见的误解是,这个参数指定了优化器的迭代次数。事实并非如此:优化器将始终运行尽可能多次,直到它可以继续改进代码。

基于操作码的优化器模块

基于操作码的优化器模块在汇编代码上操作。它将指令序列拆分为在JUMPsJUMPDESTs处的基本块。在这些块中,优化器会分析指令,并将对栈、内存或存储的每次修改记录为一个表达式,该表达式由一个指令和一个参数列表组成,这些参数是指向其他表达式的指针。

此外,基于操作码的优化器使用了一个名为“公共子表达式消除器”的组件,该组件在其他任务中会找到始终相等的表达式(在每个输入上),并将它们合并到一个表达式类中。它首先尝试在一个已经知道的表达式列表中找到每个新表达式。如果找不到这样的匹配,它会根据规则简化表达式,例如constant + constant = sum_of_constantsX * 1 = X。由于这是一个递归过程,如果第二个因子是一个我们知道始终计算为一的更复杂表达式,我们也可以应用后一个规则。

某些优化步骤会象征性地跟踪存储和内存位置。例如,此信息用于计算可以在编译时求值的 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 哈希值可以在编译时求值。这只在mstorekeccak256之间没有其他修改内存(或存储)的指令时才有效。因此,如果存在写入内存(或存储)的指令,则我们需要擦除当前内存(或存储)的知识。但是,当我们可以轻松地看到指令没有写入特定位置时,有一个例外。

例如,

let x := calldataload(0)
mstore(x, 100)
// Current knowledge memory location x -> 100
let y := add(x, 32)
// Does not clear the knowledge that x -> 100, since y does not write to [x, x + 32)
mstore(y, 200)
// This Keccak-256 can now be evaluated
let value := keccak256(x, 32)

因此,对存储和内存位置的修改,例如位置 l,必须擦除有关存储或内存位置的知识,这些位置可能等于 l。更具体地说,对于存储,优化器必须擦除所有关于符号位置的知识,这些符号位置可能等于 l;对于内存,优化器必须擦除所有关于符号位置的知识,这些位置可能不至少距离 32 字节。如果 m 表示任意位置,则通过计算值 sub(l, m) 来决定是否擦除。对于存储,如果此值计算为非零的字面量,则将保留有关 m 的知识。对于内存,如果此值计算为介于 322**256 - 32 之间的字面量,则将保留有关 m 的知识。在所有其他情况下,有关 m 的知识将被擦除。

在此过程之后,我们知道哪些表达式必须在最后位于堆栈上,并且有一份对内存和存储的修改列表。此信息与基本块一起存储,并用于链接它们。此外,有关堆栈、存储和内存配置的知识将转发到下一个块。

如果我们知道所有 JUMPJUMPI 指令的目标,则可以构建程序的完整控制流图。如果只有一个目标我们不知道(这种情况可能会发生,因为原则上,跳转目标可以从输入计算),我们必须擦除所有关于块的输入状态的知识,因为它可能是未知的 JUMP 的目标。如果基于操作码的优化器模块找到一个 JUMPI,其条件计算为常量,则将其转换为无条件跳转。

作为最后一步,每个块中的代码将被重新生成。优化器从堆栈末尾的表达式创建依赖关系图,并删除图中不存在的每个操作。它生成代码,按照它们在原始代码中的执行顺序对内存和存储进行修改(删除发现不需要的修改)。最后,它生成所有需要位于堆栈上的正确位置的值。

这些步骤将应用于每个基本块,如果新生成的代码更小,则将其用作替换。如果基本块在 JUMPI 处分割,并且在分析过程中条件计算为常量,则根据常量的值替换 JUMPI。因此,类似以下的代码

uint x = 7;
data[7] = 9;
if (data[x] != x + 2) // this condition is never true
  return 2;
else
  return 1;

将简化为:

data[7] = 9;
return 1;

简单内联

从 Solidity 版本 0.8.2 开始,还有一个优化器步骤可以将某些跳转替换为包含以“跳转”结尾的“简单”指令的块,这些指令的副本。这对应于对简单的、小的 Solidity 或 Yul 函数的内联。特别是,序列 PUSHTAG(tag) JUMP 可能会被替换,只要 JUMP 被标记为跳入函数,并且在 tag 后面有一个基本块(如上所述,用于“CommonSubexpressionEliminator”),该块以另一个 JUMP 结尾,该 JUMP 被标记为跳出函数。

特别是,考虑以下为调用内部 Solidity 函数生成的程序集的典型示例

  tag_return
  tag_f
  jump      // in
tag_return:
  ...opcodes after call to f...

tag_f:
  ...body of function f...
  jump      // out

只要函数的主体是连续的基本块,“Inliner”就可以用 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      // out

现在理想情况下,上面描述的其他优化器步骤将导致返回标签的推送移动到剩余的跳转,从而得到

  ...body of function f...
  tag_return
  jump
tag_return:
  ...opcodes after call to f...

tag_f:
  ...body of function f...
  jump      // out

在这种情况下,“PeepholeOptimizer”将删除返回跳转。理想情况下,所有这些都可以对对 tag_f 的所有引用进行,使其保持未使用,以便可以将其删除,从而得到

...body of function f...
...opcodes after call to f...

因此,对函数 f 的调用被内联,并且可以删除 f 的原始定义。

每当启发式算法表明在合同的整个生命周期内,内联比不内联更便宜时,就会尝试进行这种内联。这种启发式算法取决于函数主体的规模、对它的标签的其他引用的数量(近似于对函数的调用次数)以及合同的预期执行次数(全局优化器参数“runs”)。

基于 Yul 的优化器模块

基于 Yul 的优化器由几个阶段和组件组成,它们都以语义等效的方式转换 AST。目标是最终得到代码,这些代码要么更短,要么至少只是略微更长,但将允许进一步的优化步骤。

警告

由于优化器正在积极开发中,因此此处的信息可能已过时。如果您依赖某个特定功能,请联系团队直接获取帮助。

优化器目前采用纯粹的贪婪策略,不进行任何回溯。

下面将解释基于 Yul 的优化器模块的所有组件。以下转换步骤是主要组件

  • SSA 转换

  • 公共子表达式消除器

  • 表达式简化器

  • 未使用分配消除器

  • 完整内联器

优化器步骤

这是基于 Yul 的优化器按照字母顺序排序的所有步骤的列表。您可以在下面找到有关各个步骤及其顺序的更多信息。

缩写

全名

f

块扁平化器

l

循环引用修剪器

c

公共子表达式消除器

C

条件简化器

U

条件非简化器

n

控制流简化器

D

死代码消除器

E

相同存储消除器

v

等效函数组合器

e

表达式内联器

j

表达式连接器

s

表达式简化器

x

表达式拆分器

I

将循环条件移入循环体

O

将循环条件移出循环体

o

循环初始化重写器

i

完整内联器

g

函数分组器

h

函数提升器

F

函数特化器

T

字面量再实例化器

L

加载解析器

M

循环不变代码移动器

m

再实例化器

V

SSA 逆转器

a

SSA 转换

t

结构化简化器

r

未用赋值消除器

p

未用函数参数修剪器

S

未用存储消除器

u

未用修剪器

d

变量声明初始化器

一些步骤取决于 BlockFlattenerFunctionGrouperForLoopInitRewriter 所确保的属性。因此,Yul 优化器始终在应用用户提供的任何步骤之前应用它们。

选择优化

默认情况下,优化器将预定义的优化步骤序列应用于生成的程序集。您可以使用 --yul-optimizations 选项覆盖此序列并提供您自己的序列

solc --optimize --ir-optimized --yul-optimizations 'dhfoD[xarrscLMcCTU]uljmul:fDnTOcmu'

步骤的顺序非常重要,它会影响输出的质量。此外,应用一个步骤可能会为已经应用的其他步骤发现新的优化机会,因此重复步骤通常是有益的。

[...] 内部的序列将在循环中多次应用,直到 Yul 代码保持不变或达到最大轮数(目前为 12 轮)为止。方括号 ([]) 可以在一个序列中多次使用,但不能嵌套。

需要注意的是,有一些硬编码步骤始终在用户提供的序列或用户未提供序列时的默认序列之前和之后运行。

清理序列分隔符 : 是可选的,用于提供自定义清理序列以替换默认序列。如果省略,优化器将简单地应用默认清理序列。此外,分隔符可以放在用户提供的序列的开头,这将导致优化序列为空,而相反,如果放在序列的末尾,则将被视为空清理序列。

预处理

预处理组件执行转换以使程序进入易于处理的特定规范形式。这种规范形式在优化过程的其余部分中保持不变。

消歧器

消歧器接受 AST,并返回一个新的副本,其中输入 AST 中的所有标识符具有唯一名称。这是所有其他优化器阶段的先决条件。其中一个好处是,标识符查找不需要考虑范围,这简化了其他步骤所需的分析。

所有后续阶段都具有以下属性:所有名称保持唯一。这意味着如果需要引入新的标识符,将生成新的唯一名称。

函数提升器

函数提升器将所有函数定义移到最顶层块的末尾。只要在消歧阶段之后执行此操作,这是一种语义等效的转换。原因是将定义移到更高级别的块不会降低其可见性,并且不可能引用不同函数中定义的变量。

此阶段的好处是,可以更容易地查找函数定义,并且可以独立优化函数,而无需完全遍历 AST。

函数分组器

函数分组器必须在消歧器和函数提升器之后应用。它的作用是将所有不是函数定义的最顶层元素移动到一个单独的块中,该块是根块的第一个语句。

经过此步骤,程序将具有以下规范形式

{ I F... }

其中 I 是一个(可能为空的)块,不包含任何函数定义(即使是递归的),F 是一个函数定义列表,这样就没有函数包含函数定义。

此阶段的好处是,我们始终知道函数列表从哪里开始。

ForLoopConditionIntoBody

此转换将 for 循环的循环迭代条件移到循环主体中。我们需要此转换,因为 ExpressionSplitter 将不会应用于迭代条件表达式(以下示例中的 C)。

for { Init... } C { Post... } {
    Body...
}

将转换为

for { Init... } 1 { Post... } {
    if iszero(C) { break }
    Body...
}

此转换与 LoopInvariantCodeMotion 配合使用时也可能很有用,因为循环不变条件中的不变式可以随后被移到循环外部。

ForLoopInitRewriter

此转换将 for 循环的初始化部分移到循环之前

for { Init... } C { Post... } {
    Body...
}

将转换为

Init...
for {} C { Post... } {
    Body...
}

这简化了其余的优化过程,因为我们可以忽略 for 循环初始化块的复杂范围规则。

VarDeclInitializer

此步骤重写变量声明,使所有变量都初始化。 诸如 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)
}

请注意,此变换不会更改操作码或函数调用的顺序。

它不会应用于循环迭代条件,因为循环控制流并不总是允许这种对内部表达式的“提炼”。 我们可以通过应用 ForLoopConditionIntoBody 将迭代条件移到循环主体中来绕过此限制。

最终程序应处于表达式拆分形式,其中(除循环条件外)函数调用不能嵌套在表达式中,所有函数调用的参数必须是变量。

此形式的好处是,它更容易重新排序操作码的顺序,也更容易进行函数调用内联。 此外,它更容易替换表达式的各个部分或重新组织“表达式树”。 缺点是,这种代码对于人类来说更难阅读。

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 的每个引用替换为 a_i。 在每个 a 被赋值的块的末尾,以及如果在 for 循环主体或 post 块中被赋值,则在 for 循环 init 块的末尾,变量 a 的当前值映射将被清除。 如果根据上述规则清除变量的值,并且该变量是在块之外声明的,则将在控制流合并的位置创建一个新的 SSA 变量,这包括循环 post/body 块的开头以及 If/Switch/ForLoop/Block 语句之后的那个位置。

在此阶段之后,建议使用 Unused Assign Eliminator 来删除不必要的中间赋值。

如果 Expression Splitter 和 Common Subexpression Eliminator 在此阶段之前运行,则此阶段将提供最佳效果,因为这样它不会生成过多的变量。 另一方面,Common Subexpression Eliminator 在 SSA 变换之后运行可能会更有效。

UnusedAssignEliminator

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)
}

Unused Assign Eliminator 删除了对 a 的所有三个赋值,因为 a 的值没有使用,因此将此代码段转换为严格的 SSA 形式

{
    let a_1 := 1
    let a_2 := mload(a_1)
    let a_3 := sload(a_2)
    sstore(a_3, 1)
}

当然,确定赋值是否未使用或不使用的复杂部分与控制流的合并有关。

该组件的详细工作原理如下

AST 被遍历两次:在信息收集步骤中以及在实际删除步骤中。 在信息收集过程中,我们维护一个从赋值语句到三个状态“未使用”、“未确定”和“已使用”的映射,这些状态表示以后是否会使用对该变量的引用来使用分配的值。

当访问一个赋值时,它将被添加到映射中,状态为“未确定”(见下面关于 for 循环的说明),并且对同一变量的所有其他赋值,如果仍然处于“未确定”状态,则更改为“未使用”。 当引用一个变量时,对该变量的任何赋值的状态(如果仍然处于“未确定”状态)将更改为“已使用”。

在控制流分支的地方,将映射的副本传递给每个分支。 在控制流合并的地方,来自两个分支的两个映射以以下方式组合:仅在一个映射中或具有相同状态的语句将不变地使用。 冲突值将以以下方式解决

  • “未使用”、“未确定” -> “未确定”

  • “未使用”、“已使用” -> “已使用”

  • “未确定”、“已使用” -> “已使用”

对于 for 循环,条件、主体和 post 部分被访问两次,考虑了在条件处的控制流合并。 换句话说,我们创建了三个控制流路径:循环运行零次、一次和两次,然后在最后将它们合并。

模拟第三次运行甚至更多次运行是不必要的,这可以通过以下方式来解释

迭代开始时赋值的状态将决定性地导致迭代结束时该赋值的状态。 让我们将此状态映射函数称为 f。 如上所述,三个不同状态 unusedundecidedused 的组合是 max 操作,其中 unused = 0undecided = 1used = 2

正确的方法是计算

max(s, f(s), f(f(s)), f(f(f(s))), ...)

作为循环后的状态。 由于 f 只有三个不同值的范围,因此对它的迭代必须在最多三次迭代后达到循环,因此 f(f(f(s))) 必须等于 sf(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 的生成。

工具

可移动性

可移动性是表达式的一种属性。 它大致意味着表达式没有副作用,其评估仅依赖于变量的值和环境的调用常量状态。 大多数表达式都是可移动的。 以下部分使表达式不可移动

  • 函数调用(如果函数中的所有语句都是可移动的,则将来可能会放宽限制)

  • 具有(可能)副作用的操作码(例如 callselfdestruct

  • 读取或写入内存、存储或外部状态信息的操作码

  • 依赖于当前 PC、内存大小或 returndata 大小的操作码

数据流分析器

数据流分析器本身不是一个优化步骤,而是其他组件的工具。在遍历 AST 时,它会跟踪每个变量的当前值,只要该值是可以移动的表达式。它会记录作为当前分配给其他变量的表达式的部分的变量。每次对变量 a 进行赋值时,都会更新 a 的当前存储值,并且只要 ab 当前存储的表达式的部分,就会清除所有变量 b 的所有存储值。

在控制流连接处,如果变量在任何控制流路径中都被赋值或将要被赋值,则关于变量的知识会被清除。例如,在进入循环时,所有将在循环体或后置块中被赋值的变量都会被清除。

表达式级简化

这些简化传递会更改表达式,并用等效的且希望更简单的表达式替换它们。

公共子表达式消除器

此步骤使用数据流分析器,用对变量当前值的语法匹配的子表达式替换子表达式。这是一种等价转换,因为这样的子表达式必须是可移动的。

如果值是标识符,所有自身是标识符的子表达式都会被其当前值替换。

上面两个规则的组合允许计算局部值编号,这意味着如果两个变量具有相同的值,则其中一个将始终未被使用。然后,未使用的修剪器或未使用的赋值消除器将能够完全消除此类变量。

如果表达式拆分器在之前运行,则此步骤特别有效。如果代码处于伪 SSA 形式,则变量的值会保留更长时间,因此我们有更高的几率让表达式可以替换。

如果在表达式简化器运行之前运行公共子表达式消除器,表达式简化器将能够执行更好的替换。

表达式简化器

表达式简化器使用数据流分析器,并利用表达式上的等价转换列表,例如 X + 0 -> X 来简化代码。

它试图在每个子表达式上匹配像 X + 0 这样的模式。在匹配过程中,它会将变量解析为其当前分配的表达式,以便即使代码处于伪 SSA 形式也能匹配更深层的嵌套模式。

X - X -> 0 这样的模式只能在表达式 X 是可移动的情况下应用,因为否则它将移除其潜在的副作用。由于变量引用始终是可移动的,即使其当前值可能不可移动,表达式简化器在拆分或伪 SSA 形式中也更强大。

文字重构器

待记录。

加载解析器

优化阶段,将类型为 sload(x)mload(x) 的表达式替换为当前存储在存储或内存中的值(如果已知)。

如果代码处于 SSA 形式,则效果最佳。

先决条件:消歧器、循环初始化重写器。

语句级简化

循环引用修剪器

此阶段会移除相互调用但既没有外部引用也没有从最外层上下文引用的函数。

条件简化器

条件简化器在可以从控制流确定条件变量的值时,会插入对条件变量的赋值。

破坏 SSA 形式。

目前,此工具非常有限,主要是因为我们还没有对布尔类型的支持。由于条件只检查表达式是否非零,因此我们无法分配特定值。

当前功能

  • switch case:插入“<condition> := <caseLabel>”

  • 在终止控制流的 if 语句之后,插入“<condition> := 0”

未来功能

  • 允许用“1”替换

  • 考虑用户定义函数的终止

在 SSA 形式以及在之前运行了死代码移除的情况下效果最佳。

先决条件:消歧器。

条件取消简化器

条件简化器的反向。

控制流简化器

简化多个控制流结构

  • 用 pop(condition) 替换具有空体的 if

  • 移除空的默认 switch case

  • 如果不存在默认 case,则移除空的 switch case

  • 用 pop(expression) 替换没有 case 的 switch

  • 将只有一个 case 的 switch 转换为 if

  • 用 pop(expression) 和 body 替换只有默认 case 的 switch

  • 用 const expr 替换具有匹配 case body 的 switch

  • if 替换具有终止控制流且没有其他 break/continue 的 for

  • 移除函数末尾的 leave

这些操作都不依赖于数据流。结构简化器执行依赖于数据流的类似任务。

控制流简化器在遍历期间会记录 breakcontinue 语句的存在或不存在。

先决条件:消歧器、函数提升器、循环初始化重写器。重要提示:引入了 EVM 操作码,因此目前只能用于 EVM 代码。

死代码消除器

此优化阶段会移除不可到达的代码。

不可到达的代码是块中任何在 leave、return、invalid、break、continue、selfdestruct、revert 之前,或在调用无限递归的用户定义函数之前出现的代码。

函数定义会保留,因为它们可能被更早的代码调用,因此被认为是可到达的。

由于在循环初始化块中声明的变量的范围扩展到循环体,因此我们要求 ForLoopInitRewriter 在此步骤之前运行。

先决条件:循环初始化重写器、函数提升器、函数分组器

相等存储消除器

此步骤会移除 mstore(k, v)sstore(k, v) 调用,如果之前存在对 mstore(k, v) / sstore(k, v) 的调用,并且两者之间没有其他存储,并且 kv 的值没有更改。

如果在 SSA 转换和公共子表达式消除器之后运行此简单步骤,它将非常有效,因为 SSA 会确保变量不会更改,并且公共子表达式消除器会重新使用完全相同的变量(如果已知值为相同)。

先决条件:消歧器、循环初始化重写器

未使用修剪器

此步骤会移除所有从未引用的函数的定义。

它还会移除从未引用的变量的声明。如果声明分配的值不可移动,则表达式将保留,但其值将被丢弃。

所有可移动表达式语句(未被赋值的表达式)都会被移除。

结构简化器

这是一个通用步骤,会在结构级别执行各种简化。

  • pop(condition) 替换具有空体的 if 语句

  • 用其主体替换具有真条件的 if 语句

  • 移除具有假条件的 if 语句

  • 将只有一个 case 的 switch 转换为 if

  • pop(expression) 和 body 替换只有默认 case 的 switch

  • 用匹配 case body 替换具有文字表达式的 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)
}

其他优化步骤将能够对函数进行更多简化。优化步骤主要对不会被内联的函数有用。

先决条件:消歧器、函数提升器

文字重构器建议作为先决条件,尽管它不是正确性所必需的。

未使用函数参数修剪器

此步骤会移除函数中未使用的参数。

如果一个参数没有被使用,例如 cyfunction f(a,b,c) -> x, y { x := div(a,b) } 中,我们会删除参数并创建一个新的“链接”函数,如下所示。

function f(a,b) -> x { x := div(a,b) }
function f2(a,b,c) -> x, y { x := f(a,b) }

并用 f2 替换所有对 f 的引用。内联器应该在之后运行,以确保所有对 f2 的引用都被替换为 f

前提条件:Disambiguator、FunctionHoister、LiteralRematerialiser。

LiteralRematerialiser 步骤不是必需的。它有助于处理诸如: function f(x) -> y { revert(y, y} } 这样的情况,其中字面量 y 将被其值 0 替换,允许我们重写函数。

UnusedStoreEliminator

优化器组件,用于删除多余的 sstore 和内存存储语句。在 sstore 的情况下,如果所有传出的代码路径都回退(由于显式的 revert()invalid() 或无限递归)或导致另一个 sstore,优化器可以判断该 sstore 将覆盖第一个存储,则该语句将被删除。但是,如果在初始 sstore 和回退之间,或覆盖 sstore 之间存在读操作,则该语句不会被删除。此类读操作包括:外部调用、具有任何存储访问权限的用户定义函数以及 sload 的槽位,不能被证明与初始 sstore 写入的槽位不同。

例如,以下代码

{
    let c := calldataload(0)
    sstore(c, 1)
    if c {
        sstore(c, 2)
    }
    sstore(c, 3)
}

在运行 Unused Store Eliminator 步骤后将被转换为下面的代码。

{
    let c := calldataload(0)
    if c { }
    sstore(c, 3)
}

对于内存存储操作,事情通常更简单,至少在最外层的 yul 块中,因为如果在任何代码路径中都没有从这些语句中读取数据,它们就会被删除。然而,在函数分析级别,方法类似于 sstore,因为我们不知道在离开函数的作用域后内存位置是否会被读取,因此只有当所有代码路径都导致内存被覆盖时,才会删除该语句。

最好在 SSA 形式下运行。

前提条件:Disambiguator、ForLoopInitRewriter。

EquivalentFunctionCombiner

如果两个函数在语法上等效,同时允许变量重命名但不允许任何重新排序,那么对其中一个函数的任何引用都会被替换为另一个函数。

函数的实际删除是由 Unused Pruner 执行的。

Function Inlining

ExpressionInliner

优化器此组件通过内联可以在函数表达式内内联的函数来执行受限函数内联,即

  • 返回单个值的函数。

  • 具有类似于 r := <functional expression> 的主体。

  • 既不引用自身也不引用 r 在右手边。

此外,对于所有参数,以下所有内容都必须为真

  • 参数是可移动的。

  • 该参数在函数体内被引用的次数少于两次,或者参数相当便宜(“成本”最多为 1,例如最多为 0xff 的常量)。

示例:要内联的函数具有 function f(...) -> r { r := E } 的形式,其中 E 是不引用 r 的表达式,并且函数调用中的所有参数都是可移动的表达式。

此内联的结果始终是单个表达式。

此组件只能用于具有唯一名称的源代码。

FullInliner

FullInliner 将某些函数的某些调用替换为该函数的主体。这在大多数情况下没什么帮助,因为它只会增加代码大小,但没有好处。此外,代码通常非常昂贵,我们通常更希望拥有更短的代码,而不是更有效的代码。然而,在某些情况下,内联函数可能会对后续的优化器步骤产生积极的影响。例如,当函数参数之一是常量时,就会出现这种情况。

在内联期间,使用启发式方法来判断函数调用是否应该被内联。目前的启发式方法不会内联到“大型”函数中,除非被调用函数很小。只使用一次的函数会被内联,中等大小的函数也会被内联,而具有常量参数的函数调用允许稍微更大的函数。

将来,我们可能会包含一个回溯组件,该组件不会立即内联函数,而是专门化它,这意味着生成函数的副本,其中某个参数始终被常量替换。之后,我们可以在此专门化的函数上运行优化器。如果它产生了巨大的收益,则保留专门化的函数,否则使用原始函数。

FunctionHoister 和 ExpressionSplitter 是推荐的前提条件,因为它们使该步骤更有效率,但它们不是正确性的必要条件。特别是,具有其他函数调用作为参数的函数调用不会被内联,但事先运行 ExpressionSplitter 确保输入中没有这样的调用。

Cleanup

清理工作是在优化器运行结束时执行的。它试图将拆分的表达式再次组合成深度嵌套的表达式,并通过尽可能地消除变量来提高对堆栈机的“可编译性”。

ExpressionJoiner

这是表达式拆分器的相反操作。它将一系列只有一个引用的变量声明转换为一个复杂的表达式。此阶段完全保留了函数调用和操作码执行的顺序。它不使用任何关于操作码的交换性的信息;如果将变量的值移动到其使用位置会改变任何函数调用或操作码执行的顺序,则不会执行转换。

请注意,该组件不会移动变量赋值或引用次数超过一次的变量的赋值值。

代码段 let x := add(0, 2) let y := mul(x, mload(2)) 不会被转换,因为它会导致对操作码 addmload 的调用的顺序被交换——即使这不会造成任何影响,因为 add 是可移动的。

当重新排序类似的操作码时,会忽略变量引用和字面量。因此,代码段 let x := add(0, 2) let y := mul(x, 3) 被转换为 let y := mul(add(0, 2), 3),即使 add 操作码将在评估字面量 3 之后执行。

SSAReverser

这是一个微小的步骤,它有助于在将 SSA 转换与 Common Subexpression Eliminator 和 Unused Pruner 相结合时,逆转 SSA 转换的效果。

我们生成的 SSA 形式不利于代码生成,因为它会产生许多局部变量。最好是重新使用现有变量进行赋值,而不是新的变量声明。

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

这是一个非常简单的等效转换,但是当我们现在运行 Common Subexpression Eliminator 时,它将用 a 替换 a_1 的所有出现(直到 a 被重新赋值)。然后,Unused Pruner 将完全消除变量 a_1,从而完全逆转 SSA 转换。

StackCompressor

使以太坊虚拟机代码生成变得困难的一个问题是,表达式堆栈向下访问的槽位数量存在硬性限制 16 个。这或多或少地转化为局部变量数量的限制 16 个。堆栈压缩器接收 Yul 代码并将其编译成 EVM 字节码。每当堆栈差太大时,它就会记录发生这种情况的函数。

对于每个导致此类问题的函数,Rematerialiser 会被调用,并带有特殊请求,要求积极消除按其值的成本排序的特定变量。

如果失败,则重复此过程多次。

Rematerialiser

重构阶段试图用最后赋值给变量的表达式来替换变量引用。当然,只有当此表达式计算起来相对便宜时,这才是有益的。此外,只有当表达式的值在赋值点和使用点之间没有发生改变时,它才是语义上等效的。此阶段的主要好处是,如果它导致变量被完全消除(见下文),它可以节省堆栈槽位,但如果表达式非常便宜,它也可以在 EVM 上节省一个 DUP 操作码。

Rematerialiser 使用数据流分析器来跟踪变量的当前值,这些变量始终是可移动的。如果该值非常便宜,或者明确要求消除该变量,则将用其当前值替换变量引用。

ForLoopConditionOutOfBody

逆转 ForLoopConditionIntoBody 的转换。

对于任何可移动的 c,它将

for { ... } 1 { ... } {
if iszero(c) { break }
...
}

转换为

for { ... } c { ... } {
...
}

它将

for { ... } 1 { ... } {
if c { break }
...
}

转换为

for { ... } iszero(c) { ... } {
...
}

转换为

LiteralRematerialiser 应该在此步骤之前运行。

目前,基于代码生成的优化器模块提供了两种优化。

第一种优化在传统代码生成器中可用,它将字面量移动到可交换二元运算符的右侧,这有助于利用它们的结合性。

第二种优化在基于 IR 的代码生成器中可用,它在为某些惯用 for 循环的计数器变量生成代码时,可以启用不检查算术运算。通过识别某些保证计数器变量不会溢出的条件,这避免了浪费 gas。这消除了在循环体中使用冗长的不检查算术块来递增计数器变量的必要性。

不检查循环递增

在 Solidity 0.8.22 中引入,溢出检查优化步骤关注识别在哪些条件下 for 循环计数器可以在没有溢出检查的情况下安全递增。

此优化**仅**应用于以下一般形式的 for 循环

for (uint i = X; i < Y; ++i) {
    // variable i is not modified in the loop body
}

该条件和计数器变量仅递增的事实保证了它永远不会溢出。循环有资格进行优化的具体要求如下:

  • 循环条件是 i < Y 形式的比较,其中 i 是一个局部计数器变量(在此称为“循环计数器”)和一个表达式 Y

  • 循环条件中必须使用内置运算符 <,并且是唯一触发优化的运算符。有意排除了 <= 等。此外,**不**允许使用用户定义的运算符。

  • 循环表达式是计数器变量的前缀或后缀递增,即 i++++i

  • 循环计数器是内置整数类型的局部变量。

  • 循环计数器**不会**被循环体或用作循环条件的表达式修改。

  • 比较是在与循环计数器相同的类型上执行的,这意味着右侧表达式的类型可以隐式转换为计数器的类型,这样在比较之前不会隐式加宽计数器。

为了澄清最后一个条件,请考虑以下示例:

for (uint8 i = 0; i < uint16(1000); i++) {
    // ...
}

在这种情况下,计数器 i 的类型在比较之前从 uint8 隐式转换为 uint16,实际上该条件永远不会为假,因此不能删除递增的溢出检查。