优化器

固态编译器使用两个不同的优化器模块:在操作码级别操作的“旧”优化器和在YUL IR代码上操作的“新”优化器。

基于操作码的优化器应用一组 simplification rules 转换为操作码。它还组合相等的代码集并删除未使用的代码。

基于YUL的优化器要强大得多,因为它可以跨函数调用工作。例如,在YUL中不可能进行任意跳转,因此可以计算每个函数的副作用。考虑两个函数调用,第一个不修改存储,第二个修改存储。如果它们的参数和返回值不相互依赖,我们可以重新排序函数调用。同样,如果函数没有副作用,并且其结果乘以零,则可以完全删除函数调用。

目前,该参数 --optimize 为生成的字节码激活基于操作码的优化器,为内部生成的YUL代码(例如ABI编码器v2)激活YUL优化器。一个人可以使用 solc --ir-optimized --optimize 为固体源生产优化的实验YUL IR。类似地,我们可以使用 solc --strict-assembly --optimize 用于独立的YUL模式。

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

优化固体代码的好处

总体而言,优化器试图简化复杂的表达式,从而降低代码大小和执行成本,也就是说,它可以减少合约部署以及对合约进行的外部调用所需的气体。它还专门化或内联函数。特别是,函数内联是一种可能导致更大代码的操作,但通常会这样做,因为它会带来更多简化的机会。

优化代码和非优化代码之间的差异

通常,最明显的区别是在编译时计算常量表达式。当谈到ASM输出时,还可以注意到等价或重复代码块的减少(比较标志的输出 --asm--asm --optimize )。然而,当涉及YUL/中间表示时,可能存在显著差异,例如,可以内联、组合或重写函数以消除冗余等(比较标志之间的输出 --ir--optimize --ir-optimized )。

优化器参数运行

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

注解

一个常见的误解是,此参数指定优化器的迭代次数。事实并非如此:优化器总是会尽可能多次地运行,只要它还能改进代码。

基于操作码的优化器模块

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

此外,基于操作码的优化器使用一个名为“CommonSubexpression”的组件,该组件除了执行其他任务外,还查找(在每一个输入上)始终相等的表达式,并将它们组合到一个表达式类中。它首先尝试在已知表达式列表中查找每个新表达式。如果没有找到这样的匹配项,它会根据如下规则简化表达式 constant + constant = sum_of_constantsX * 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散列。之间没有其他修改内存的指令时,这才起作用。 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开始,还有另一个优化器步骤,用这些指令的副本替换到包含以“跳转”结尾的“简单”指令的块的某些跳转。这对应于内联简单、小的实心度或YUL函数。特别地,序列 PUSHTAG(tag) JUMP 可以被替换,只要 JUMP 被标记为跳转“进入”函数并且位于 tag 有一个基本的挡路(如上所述的“CommonSubpression消除符”),它以另一个结尾 JUMP 它被标记为“跳出”函数。

具体地说,请考虑以下为调用内部实心函数生成的程序集的典型示例:

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

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

只要函数体是一个连续的基本挡路,“内联”就可以替换 tag_f jump 挡路旁的 tag_f 结果是:

  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 让它闲置着,S.T.可以将其移除,从而产生:

...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 optimizer docs

预处理

预处理组件执行转换以将程序转换为更容易使用的特定范式。此范式在睡觉的优化过程中保持不变。

歧义消除器

消歧程序获取一个AST并返回一个新副本,其中所有标识符在输入AST中都有唯一的名称。这是所有其他优化器阶段的先决条件。其中一个好处是标识符查找不需要考虑作用域,这简化了其他步骤所需的分析。

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

FunctionHoister

函数提升器将所有函数定义移动到最顶部挡路的末尾。只要在歧义消除阶段之后执行,这在语义上就是等价的转换。原因是,将定义移动到更高级别的挡路不会降低其可见性,并且不可能引用在不同函数中定义的变量。

此阶段的好处是可以更容易地查找函数定义,并且可以隔离优化函数,而不必完全遍历AST。

FunctionGrouper

功能分组器必须在消歧器和功能提升器之后应用。它的效果是将所有不是函数定义的最上面的元素移到单个挡路中,这是根挡路的第一条语句。

在此步骤之后,程序具有以下范式:

{ 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)形式,不同之处在于它不使用组合来自控制流的不同分支的值的显式“φ”函数,因为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 根据控制流的不同,具有不同的值。所有其他变量一旦定义,就永远不会更改它们的值。此属性的优点在于,变量可以自由移动,并且可以通过它们的初始值交换对它们的引用(反之亦然),只要这些值在新上下文中仍然有效。

当然,这里的代码还远远没有得到优化。相反,它要长得多。我们希望这段代码更容易使用,而且还有一些优化器步骤可以撤消这些更改,并在最后使代码再次更加紧凑。

ExpressionSplitter

表达式拆分器将表达式转换为 add(mload(0x123), mul(mload(0x456), 0x20)) 转换为唯一变量的声明序列,这些变量被分配给该表达式的子表达式,以便每个函数调用只有变量或文字作为参数。

以上内容将转化为

{
    let _1 := mload(0x123)
    let _2 := mul(_1, 0x20)
    let _3 := mload(0x456)
    let z := add(_3, _2)
}

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

它不适用于循环迭代条件,因为循环控制流并不允许在所有情况下都“勾勒”内部表达式。我们可以通过应用 ForLoopConditionIntoBody 将迭代条件移入循环体。

最终程序的形式应该是(循环条件除外)函数调用不能嵌套在表达式中,并且所有函数调用参数都必须是文字或变量。

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

SSATransform

此阶段尝试用新变量的声明尽可能地替换对现有变量的重复赋值。重新赋值仍然存在,但是对重新赋值变量的所有引用都被新声明的变量替换。

示例:

{
    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 还没有使用过。

此外,请始终记录的当前值 i 用于 a 并将每个引用替换为 a 通过 a_i 。为变量清除当前值映射 a 在分配给它的每个挡路的末尾以及在for循环的末尾初始化挡路(如果它是在for循环体内分配的)或发布挡路。如果按照上述规则清除变量的值,并在挡路外部声明该变量,则会在控制流连接的位置创建一个新的ssa变量,包括循环POST/Body挡路的开头和IF/SWITCH/FORLOOP/挡路语句后面的位置。

在此阶段之后,建议使用冗余赋值消除器来删除不必要的中间赋值。

如果表达式拆分器和公共子表达式消除器正好在此阶段之前运行,则此阶段可提供最佳结果,因为这样不会生成过多的变量。另一方面,如果在SSA转换之后运行公共子表达式消除器,则效率会更高。

RedundantAssignEliminator

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被遍历两次:在信息收集步骤中和在实际删除步骤中。在信息收集期间,我们维护从赋值语句到“未使用”、“未决定”和“已使用”三个状态的映射,这表示稍后是否将通过引用变量来使用赋值。

当访问赋值时,它被添加到处于“未决定”状态的映射中(参见下面的关于for循环的备注),并且仍然处于“未决定”状态的同一变量的每一个其他赋值都被更改为“未使用”。引用变量时,仍处于“未决定”状态的该变量的任何赋值状态都将更改为“已使用”。

在控制流拆分的点处,映射的副本将移交给每个分支。在控制流连接点,来自两个分支的两个映射以以下方式组合:只在一个映射中或具有相同状态的语句不变地使用。冲突值通过以下方式解决:

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

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

  • “未决定,”已用“->”已用“

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

没有必要模拟第三次或更多次运行,这可以看到如下所示:

迭代开始时的分配状态将确定地导致该分配在迭代结束时的状态。让此状态映射函数被调用 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语句,没有跳过开关的控制流部分。

当变量超出作用域时,所有仍处于“未决定”状态的语句都将更改为“未使用”,除非该变量是函数的返回参数-在那里,状态将更改为“已使用”。

在第二次遍历中,所有处于“未使用”状态的赋值都会被删除。

此步骤通常在SSA转换之后立即运行,以完成伪SSA的生成。

工具

可移动性

可移动性是表达式的一个属性。粗略地说,这意味着表达式是无副作用的,其求值仅取决于变量的值和环境的调用常量状态。大多数表情都是可移动的。以下部分使表达式不可移动:

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

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

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

  • 取决于当前PC、内存大小或返回数据大小的操作码

DataflowAnalyzer

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

在控制流联接时,如果变量在任何控制流路径中已赋值或将被赋值,则有关变量的知识将被清除。例如,在进入for循环时,将清除在Body或POST挡路期间分配的所有变量。

表达式规模简化

这些简化过程更改表达式,并用等价的(希望是更简单的)表达式替换它们。

CommonSubexpressionEliminator

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

如果值是标识符,则本身是标识符的所有子表达式都将替换为其当前值。

以上两个规则的组合允许计算本地值编号,这意味着如果两个变量具有相同的值,则其中一个变量将始终处于未使用状态。然后,未使用的修剪器或冗余赋值消除器将能够完全消除这些变量。

如果之前运行了表达式拆分器,则此步骤特别有效。如果代码是伪SSA形式的,变量的值可以使用更长的时间,因此我们有更高的机会替换表达式。

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

表达式简化器

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

它尝试匹配模式,例如 X + 0 在每个子表达式上。在匹配过程中,它将变量解析为其当前赋值的表达式,以便能够匹配更深层次的嵌套模式,即使代码是伪SSA形式的也是如此。

其中一些图案就像 X - X -> 0 只能在表达式 X 是可移动的,因为否则它将消除其潜在的副作用。由于变量引用总是可移动的,即使它们的当前值可能不是可移动的,表达式简化程序在拆分或伪SSA形式中也同样更加强大。

LiteralRematerialiser

需要记录在案。

LoadResolver

替换类型表达式的优化阶段 sload(x)mload(x) 由当前存储在存储器中的值分别计算。内存(如果已知)。

如果代码为SSA格式,则效果最好。

先决条件:消歧程序、ForLoopInitReWriter。

ReasoningBasedSimplifier

此优化器使用SMT解算器检查 if 条件是恒定的。

  • 如果 constraints AND condition 如果是UNSAT,条件永远不是真的,整个身体都可以移除。

  • 如果 constraints AND NOT condition 为UNSAT,则条件始终为真,并且可以替换为 1

只有在条件是可移动的情况下,才能应用上述简化。

它只在EVM方言上有效,但在其他方言上使用是安全的。

先决条件:消歧、SSATransform。

语句级简化

CircularReferencesPruner

此阶段删除彼此调用但既不是外部引用也不是从最外层上下文引用的函数。

ConditionalSimplifier

如果可以从控制流中确定值,则条件简单器向条件变量插入赋值。

销毁SSA表单。

目前,该工具非常有限,主要是因为我们还不支持布尔类型。因为条件只检查表达式是否是非零的,所以我们不能指定特定值。

当前功能:

  • 切换大小写:插入“<条件>:=<案例标签>”

  • 在控制流终止的IF语句后插入“<Condition>:=0”

未来功能:

  • 允许替换为“%1”

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

与SSA窗体配合使用效果最好,如果以前运行过死代码删除。

前提条件:消歧。

ConditionalUnsimplifier

条件简化式的逆。

ControlFlowSimplifier

简化了几种控制流结构:

  • 将IF替换为空正文,替换为POP(条件)

  • 删除空的默认开关盒

  • 如果不存在默认情况,请删除空的开关情况

  • 用POP(表达式)替换没有大小写的开关

  • 将单壳开关变为IF

  • 仅将开关替换为POP(表达式)和正文的默认大小写

  • 将开关替换为匹配案例正文的常量表达式

  • 替换 for 使用终止控制流,而不使用其他中断/继续方式 if

  • 删除 leave 在函数的末尾。

这些操作都不依赖于数据流。StructuralSimplator执行依赖于数据流的类似任务。

ControlFlowSimplizer确实记录了以下内容的存在或不存在 breakcontinue 语句。

先决条件:消歧程序、FunctionHoister、ForLoopInitReWriter。重要提示:引入EVM操作码,因此目前只能在EVM代码上使用。

DeadCodeEliminator

此优化阶段删除无法访问的代码。

不可达代码是挡路中前面带有离开、返回、无效、中断、继续、自销毁或恢复的任何代码。

函数定义被保留,因为它们可能被较早的代码调用,因此被认为是可访问的。

因为在for循环的初始化挡路中声明的变量的作用域扩展到循环体,所以我们要求在此步骤之前运行ForLoopInitReWriter。

先决条件:ForLoopInitReWriter、函数提升器、函数组合器

UnusedPruner

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

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

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

StructuralSimplifier

这是在结构级别上执行各种简化的一般步骤:

  • 将IF语句替换为空体 pop(condition)

  • 通过其正文将IF语句替换为TRUE条件

  • 具有假条件的Remove IF语句

  • 将单壳开关变为IF

  • 仅使用默认大小写替换开关 pop(expression) 和身体

  • 通过匹配案例正文将开关替换为文字表达式

  • 用其初始化部分替换条件为假的for循环

此组件使用数据流分析器。

BlockFlattener

此阶段通过在内部挡路的外部挡路的适当位置插入语句来消除嵌套块:

{
    let x := 2
    {
        let y := 3
        mstore(x, y)
    }
}

转换为

{
    let x := 2
    let y := 3
    mstore(x, y)
}

只要消除了代码的歧义,这就不会造成问题,因为变量的范围只会增长。

LoopInvariantCodeMotion

此优化将可移动的SSA变量声明移出循环。

只考虑循环体或挡路后顶层的语句,即条件分支内的变量声明不会移出循环。

要求:

  • 必须预先运行消歧程序、ForLoopInitReWriter和FunctionHoister。

  • 应该提前运行表达式拆分器和SSA变换,以获得更好的结果。

函数级优化

FunctionSpecializer

此步骤使用其文字参数专门化函数。

如果一个函数,比方说, 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)
}

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

前提条件:消歧器、FunctionHoister

尽管不是正确性所必需的,但仍建议您将其作为必备条件。

UnusedFunctionParameterPruner

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

如果某个参数未使用,如 cy 在……里面, function 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) }

并将所有引用替换为 f 通过 f2 。应该在之后运行内联程序,以确保所有对 f2 被替换为 f

先决条件:消歧程序、函数提升程序、文字修改器。

不需要使用Step WritalRematerialiser来保证正确性。它有助于处理以下情况: function f(x) -> y {{ revert(y, y}} }} 其中的文字 y 将被其值替换 0 ,允许我们重写函数。

EquivalentFunctionCombiner

如果两个函数在语法上等价,但允许重命名变量,但不允许重新排序,则对其中一个函数的任何引用都将替换为另一个函数。

该功能的实际删除是由未使用的修剪器执行的。

函数内联

ExpressionInliner

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

  • 返回单个值。

  • 拥有一个像这样的身体 r := <functional expression>

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

此外,对于所有参数,以下所有条件都需要为真:

  • 这个论点是可移动的。

  • 该参数在函数体中被引用的次数不到两次,或者该参数相当便宜(“成本”最多为1,就像一个最高可达0xff的常量)。

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

这种内联的结果总是一个表达式。

此组件只能在具有唯一名称的源上使用。

FullInliner

完整的内联程序用函数体替换了某些函数的某些调用。在大多数情况下,这不是很有帮助,因为它只会增加代码大小,而不会带来好处。此外,代码通常非常昂贵,我们通常宁愿使用更短的代码,而不是更高效的代码。不过,在相同的情况下,内联函数可能会对后续的优化器步骤产生积极影响。例如,如果其中一个函数参数是常量,就会出现这种情况。

在内联过程中,启发式用于告诉函数调用是否应该内联。除非被调用的函数很小,否则当前的启发式不会内联到“大”函数中。只使用一次的函数是内联的,中等大小的函数也是内联的,而使用常量参数的函数调用允许稍微大一点的函数。

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

清理

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

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转换的效果。

我们生成的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_1 通过 a (直到 a 重新分配)。然后,未使用的修剪程序将删除该变量 a_1 从而完全逆转SSA变换。

StackCompressor

使Etherum虚拟机的代码生成变得困难的一个问题是,向下延伸表达式堆栈有16个插槽的硬限制。这或多或少转化为16个局部变量的限制。堆栈压缩器获取YUL代码并将其编译为EVM字节码。每当堆栈差异太大时,它都会记录发生这种情况的函数。

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

失败时,此过程重复多次。

再材料机

重新物化阶段尝试用上次分配给变量的表达式替换变量引用。当然,只有当此表达式的计算成本相对较低时,这才是有益的。此外,只有当表达式的值在赋值点和使用点之间没有更改时,它才在语义上等价。这个阶段的主要好处是,如果它导致变量被完全消除(见下文),它可以节省堆栈槽,但如果表达式非常便宜,它还可以在EVM上保存一个DUP操作码。

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

ForLoopConditionOutOfBody

反转ForLoopConditionIntoBody的变换。

对于任何可移动的 c ,它会转向

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

变成

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

然后它就会转动

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

变成

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

应在此步骤之前运行WritalRematerialiser。

特定于WebAssembly的

MainFunction

将最上面的挡路更改为具有特定名称(“Main”)的函数,该函数既没有输入也没有输出。

取决于Grouper功能。