【Gas优化】检测Gas低效模块

💡 本次解读的文章是 2022 年 Journal of Computer Science and Technology 的一篇与智能合约 Gas 优化相关的论文,这篇论文从超过 25,000 条帖子中定义了 6 种 gas 低效模式(gas-inefficient patterns),并提出了一种源代码级的优化方法,直观地指明了智能合约的优化部分。

一、背景介绍

在 Gas 优化研究上,现有的方法主要基于可满足性模理论(satisfiability modulo theories,SMT)来检测的,且是在字节码级别移除 gas 低效模块,这些方法通过尝试所有可能的操作码序列,试图找到所需资源更少且在语义上与原始操作码序列等价的操作码序列。然而,目前这些方法存在着一定的不足:1)这些优化方法无法对一些 gas 低效的智能合约片段进行优化;2)这些方法是字节码字节码的转换方法,这使得优化方法不透明,因为用户无法直观地知道对合约做了哪些更改,从而引起了用户的担心;3)这些方法都是基于耗时的可满足性模理论,这将导致无法耗尽搜索空间,使优化方法既不健全也不完整。

Fig.1 中显示了当前优化方法无法识别的 gas 低效模块,其中,低效(a)与高效(b)的区别在于是否对变量 num 进行了初始化。由于在 Solidity 中声明变量的默认值是 0,因此,(a)中的显式赋值是没有必要且浪费 gas 成本的。

二、本文贡献

(1)提出了一种利用抽象语法树(AST)从源代码层面优化智能合约并构建源代码源代码转换的 gas 优化方法,从源代码中抓取信息,在高层次进行优化,从而弥补现有优化方法优化结果不直观、无法对操作码序列较长的智能合约片段进行优化的不足。

(2)从超过 25,000 个帖子中定义了六种 gas 低效模式,并对超过 160,000 个真实智能合约进行了大规模的实证研究,以评估模式的普适性和经济效益。

(3)为了支持本文研究的独立验证或复制,本文提供了一个复制包,其包含源代码中基于 AST 的 gas 优化方法和已验证的以太坊中部署的 gas 低效模式智能合约,可供其他有兴趣研究智能合约气体优化的研究人员使用。

三、数据准备

本文在定义 Gas 低效模式前进行了如下准备工作:1)从以太坊交易所问答网站Ethereum Stack Exchange中爬取2020年7月9日之前在以太坊交易所发布的包含问题、答案和评论的帖子;2)利用关键词过滤掉不相关的帖子以提高效率;3)为每个帖子创建卡片,并将卡片分成几个主题,卡片记录了帖子中有关 gas 低效模式的信息;4)针对每个主题将卡片细分为若干类别。

3.1 过滤帖子

在完成数据爬取工作后,对爬取得到的原始数据直接进行分析存在着一定的不足:

  • 由于帖子数量较多,分析的过程会比较耗时;
  • 有些帖子只包含与 gas 优化无关关键词,却存在 gas 低效模式(数据与标签不对应)。

因此,对帖子进行过滤筛选和打标签是有必要的。本文首先通过对 gas 优化的调研来确定需要的关键词(标签),并对确定的关键词进行整合。之后,利用 32 个确定的关键词从 25,701 条帖子中筛选出 22,583 条无关帖子(包含少于 5 个关键词的帖子将被过滤),选择剩余与 gas 优化关键字相关的 3,118 条帖子。接着,考虑到筛选的结果中存在一些包含关键字但与 gas 优化不相关的帖子,本文通过人工方式对帖子的内容进行了检查,最后,手动过滤掉 2,644 个不相关的帖子,得到 474 个用于挖掘 gas 低效模式的帖子。

3.2 创建卡片

本文通过提取帖子中重要信息作为卡片的内容,为每个帖子创建了一张或多张卡片,该卡包含了 gas 低效模式标题描述信息以及解决方案(解决方案即为本文提及到的优化方法)。需要注意的是,对于目前 Solidity 能优化的 gas 低效模式,本文不过多讨论。

在创建了 532 与 gas 优化相关的卡片之后,本文基于卡片内容确定主题:首先在 532 张卡片中随机抽取 106 张卡片(约 20%),之后分析 106 张卡片 gas 低效的原因,基于分析确定卡片的主题,主要分为 不合理储存滥用昂贵操作无效操作码序列 以及 大规模数据存储,随后基于这些主题,对剩余未抽取的卡片进行归类,以此确定每张卡片归属的主题。

四、模式定义

不合理储存滥用昂贵操作,本文分别定义了两种和四种 gas 低效模式,对 无效操作码序列 ,本文不定义 gas 低效模式(由于该主题上的优化方法不在源代码中进行,而是在字节码中,因此,本文不予考虑),对 大规模数据存储,本文不定义 gas 低效模式(由于该主题的优化方法是降低存储数据的规模,例如只在区块链中存储重要数据,无法通过改变合同的源代码来解决,本文不予考虑)。综述可知,本文重点分析的 gas 低效模式为 6 种,涉及 不合理储存滥用昂贵操作 主题。

4.1 稀疏存储

  • 问题描述

Solidity 智能合约的存储是连续 32 字节(256位)的槽,状态变量按照定义的顺序排列成槽,对于一个状态变量,如果前一变量槽中剩余空间可以存储该变量,则该变量存储在该槽中,否则该变量存储在一个新的(未使用的)槽中。对于由槽中剩余空间造成的稀疏存储,将消耗不必要的 gas 费用,因此,合理安排状态变量(最小化浪费的空间)是有意义且有利的。

  • 具体实例
// Gas-Inefficient
uint8 public decimals;
uint256 public totalSupply;
address public owner;

// ...

// Gas-Efficient
uint8 public decimals;
address public owner;
uint256 public totalSupply;

上述例子中存在三个状态变量,分别是 decimalstotalSupplyowner,对应占用 8 位、256 位和 160位,gas 低效代码中需要使用三个槽(256 位)进行存储,如果变量 decimals 和变量 owner 可以在单个槽中存储,则合同可以仅使用两个槽,这样可以优化合同的 gas 消耗,节约资金。因此,优化的方案是选择正确的变量定义顺序(如 gas 高效代码对状态变量的顺序进行了调整)。

  • 优化方法

首先构造关于变量类型大小的映射表,智能合约状态变量有两类:reference data type(如 structarray 等)和 basic variable type(如 uintaddress 等)。对于 reference data type,将其大小设置为256 位,因为根据以太坊黄皮书,这种类型总是从一个新的存储槽开始。对于 basic variable type,则保持它们的大小与 Solidity developer 文档中描述的大小一致。然后,统计原始变量定义顺序中使用的槽数,最后,使用启发式规则来调整变量的顺序,直到找到比原来使用更少槽的顺序。

4.2 较小值存储

  • 问题描述

如果一个变量不能与邻居变量打包存储在同一个槽中,则该变量将单独存储在一个 256 位的槽中。对于小于 256 位的较小值,首先通过 EVM 将其转换为 256 位,然后存储在槽中,与在槽内单独存储 256 位变量相比,在槽内单独存储小于 256 位的变量会因为转换而耗费额外的 gas 成本。

  • 具体实例
// Gas-Inefficient
uint32 public contractVersion = 20191203;
string public contractClass = "xpetoTimestampLogger";
string public xpectoMandator = "xpecto";

// ...

// Gas-Efficient
uint256 public contractVersion = 20191203;
string public contractClass = "xpetoTimestampLogger";
string public xpectoMandator = "xpecto";

在 gas 低效代码中,第一个变量 contractVersion 的变量类型为 uint32,大小为 32 位,由于第二个变量的变量类型是一个大小为 256 位的字符串,不能与其他变量共享槽,因此变量 contractVersion 只能存储在一个槽中,在变量 contractVersion 存储到槽之前,变量 contractVersion 将通过填充0的方式扩展到 256位,相比于 gas 高效代码中直接将变量定义为 256 位的操作,这一扩展操作造成额外的 gas 消耗。

  • 优化方法

将未打包且小于 256 位的变量类型设置为原始变量类型对应的 256 位变量类型,比如改变 uint8uint256,改变 int8int256。另外,需要注意的是对于与类型发生变化的变量相互作用的变量,也需要改变它们的变量类型。

4.3 重复分配

  • 问题描述

根据 Solidity 开发者文档,编译器会生成一些智能合约部署的指令,这些指令实现了三项任务:

(1)将智能合约的字节码存储到区块链中;
(2)存储智能合约状态变量,如果状态变量在声明时没初始化,编译器会自动将其初始化为 0;
(3)执行智能合约的构造方法。

然而,由于传统编程语言(例如 Java)的编程习惯,很多合同开发人员在变量声明时都会立即对合同进行显式赋值,因此,状态变量显示赋值和构造函数中重复变量赋值的现象在智能合约中频繁出现,这种重复赋值的现象将造成不必要的 gas 消耗。

  • 具体实例
// Gas-Inefficient
contract ProofOfWeakFOMO {
    address owner = msg.sender;
    constructor () {owner = msg.sender;}
}

// ...

// Gas-Efficient
contract ProofOfWeakFOMO {
    address owner;
    constructor () {owner = msg.sender;}
}

在 gas 低效代码中,智能合约 ProofOfWeakFOM 有一个状态变量 owner,在声明时将 msg.sender(表示交易发送者)赋值给该变量,而在构造函数中,变量 owner 被重复修改为 msg.sender,导致了 gas 的浪费,因此,在 gas 高效代码中,选择在声明变量时忽略对变量的初始化操作。

  • 优化方法

首先,扫描合约并记录在声明时分配的状态变量和在构造函数中分配的状态变量,然后,通过比较找到在声明和构造函数中都赋值的状态变量,最后,删除状态变量在声明过程中的初始化操作。

4.4 频繁使用状态变量

  • 问题描述

根据以太坊黄皮书可知,与读写局部变量相比,读写状态变量是非常昂贵的,因为它涉及到操作码 SLOADSSTORE,与其他操作码相比消耗更多的 gas 成本。特别地,如果读写状态变量的操作在一个循环中进行,那么合同消耗的气体随着循环数的增加而增加。

  • 具体实例
// Gas-Inefficient
contract ShareTokenSale {
	uint256 public endTime;
	function startSale() {
		for(...){ 
			endTime=endTime.add(x);
		}
	}
}

// ...

// Gas-Efficient
contract ShareTokenSale {
	uint256 public endTime;
	function startSale() { 
		uint256 temp0 = endTime;
		for(...){ 
			temp0=temp0.add(x);
		} 
		endTime = temp0
	}
}

在 gas 低效代码中,智能合约 ShareTokenSalestartSale 函数循环中频繁使用状态变量 endTimes,状态变量 end Times 在每个循环中读写一次,导致 EVM 执行大量的 SLOADSSTORE 操作码,执行合约将消耗大量的 gas 成本。考虑到读写状态变量是非常昂贵的(每个 SLOAD 消耗 200 个单位 gas,每个 SSTORE 消耗 20,000 / 5,000 个单位 gas),但读写局部变量只涉及 MLOADMSTORE 操作码,它们在执行时消耗三个单位的 gas,因此,在 gas 高效代码中,利用局部变量作为辅助变量,来实现对状态变量的修改,从而减少 gas 的消耗。

  • 优化方法

在循环修改的操作中,利用局部变量暂时代替循环中的状态变量,即首先找出循环中使用的状态变量,然后声明新的局部变量来保存状态变量的值,并使用局部变量替换循环中的状态变量,最后,将局部变量的值重新赋值给状态变量。虽然这种优化在部署时,会因为代码的增加而增加 gas 消耗,但是由于只需要一次部署,因此,随着合约被多次调用后,这种优化的效果会更加明显。

4.5 忽视短路规则

  • 问题描述

根据 Solidity developer 文档,常见的短路规则适用于逻辑操作符 ||&&,即在表达式 f(x) ‖ g(y) 中,如果 f(x) 判断为真,则不会判断 g(y)。因此,如果将较为昂贵的操作设置为逻辑操作符的右操作数来尽量减少昂贵操作的执行次数,将会节省大量的 gas 消耗。然而,许多开发人员在开发智能合约时没有考虑短路规则,导致 gas 消耗量较高。

  • 具体实例
// Gas-Inefficient
if((msg.value>=this.balance) && (frozen == false)) {
	msg.sender.transfer(this.balance);
}

// ...

// Gas-Efficient
if((frozen == false) && (msg.value>=this.balance)){
	msg.sender.transfer(this.balance); 
}

由于逻辑操作符 && 使用了短路规则,且 balance 比较涉及到操作码 BALANCE,这是一个比较昂贵的操作码,即 balance 比较比变量比较昂贵,因此,在 gas 低效代码中,将 balance 比对操作设置在左操作数上是一种 gas 浪费。

  • 优化方法

该模式的优化方法是将较昂贵的运算作为逻辑运算的右操作数,具体来说,对于智能合约中的每个逻辑运算,首先计算逻辑运算的左操作数和右操作数的 gas 消耗量,如果左操作数的 gas 消耗量大于右操作数的 gas 消耗量,则交换两个操作数的位置,否则,保持不变。

4.6 函数可见性不合理

  • 问题描述

在 Solidity 中有三种数据存储位置,分别是 memorystoragecalldatacalldata 是一个只读的可按字节寻址的空间,其中保存了事务或调用的数据参数;memory 是一个易失性的读写可按字节寻址的空间,它主要用于存储执行过程中的数据,多用于向内部函数传递参数;storage 是一个持久化的读写按字寻址的空间,是每个合约存储其持久性信息的地方。

在 Solidity 中有四种函数可见性,分别是 public(公共)external(外部)internal(内部)private(私有),这种模式主要着眼于公共可见性和外部可见性。公共函数可以被所有人访问,外部函数无法被内部访问,这意味着只能通过事务来访问,如果未指定函数的可见性,则根据 Solidity 开发人员文档将其默认为公开。

由于开发人员没有主动设置函数可见性,也不知道关键字 external 这个在其他编程语言中并不常见的关键字,所以很多合约的可见性本来可以设置为 external 却被设置为 public,这就造成了额外的 gas 消耗。公共函数比外部函数消耗更多的 gas,因为一个公共函数需要从 calldata 中将所有函数输入参数复制到 memory 中,才能对公共函数进行内部调用。将具有公共性且不被同一合同的其他函数调用的函数的可见性改为公共的,可以有效节省 gas 消耗。

  • 具体实例
// Gas-Inefficient
contract ImmAirDropA {
	function signupUserWhitelist (address[] userlist) public{...}
}

// ...

// Gas-Efficient
contract ImmAirDropA {
	function signupUserWhitelist (address[] userlist) external{...}
}

在 gas 低效代码中,函数 signupUserWhitelist 在代码段中的可见性是公共的,该函数不被合同中的其他函数调用,却消耗较多的 gas 成本,因此,在 gas 高效代码中,将函数的可见性改成了外部可见。

  • 优化方法

当函数不需要被合同中的其他函数调用时,可以主动地将函数的可见性设置为 external,以此优化 gas 的消耗。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值