第三十课 以太坊智能合约solidity如何节省GAS费?

96
笔名辉哥
2018.11.14 14:58* 字数 5065

1,摘要

在以太坊上,代码即法律,交易即金钱。每一笔智能合约的运行,都要根据复杂度消耗一笔GAS费(ETH)。那么,智能合约solidity语言的编写,不仅要考虑安全,也要考虑语言的优化,以便高效便宜了。
本文将从以下一些方面分析如何节约GAS的编程总结:
1)如何在REMIX编译器上分析GAS/GAS LIMIT等信息
2) 如何优化节省GAS费用的方法

  • 创建合约优化
  • 存储优化
  • 变量排序优化
  • 交易输入数据优化
  • 转账优化
  • 部署合约优化
  • 调用合约函数的成本优化

2,如何在REMIX编译器上分析GAS/GAS LIMIT等信息

如果你想了解以太坊的账户、交易、Gas和Gas Limit等基本概念信息,可以阅读文章《以太坊的账户、交易、Gas和Gas Limit》
如果你不了解以太坊智能合约语言solidity编译IDE环境REMIX,可以阅读文章《第十课 Solidity语言编辑器REMIX指导大全》
本章节聚焦在如何通过REMIX编译器查看GAS/GAS LIMIT等信息。

2.1 简单智能合约样例

以太坊指令执行主要依靠GAS。当你执行智能合约时,它会消耗GAS。所以,如果你正在运行一个智能合约,那么每一条指令都要花费一定数量的GAS费。这有两个因素,即您发送的GAS数量和总区块GAS上限(a total block gas limit)。
举例来说,一个简单的智能合约,有一个保存无符号整数256值的函数。
合约代码如下:

pragma solidity ^0.4.19;
contract A {
    uint b;
    function saveB(uint _b) public {
        b = _b;
    }
}

如果你将此合约复制并粘贴到Remix中,则可以运行此合约。通过MIST或来自网站的MetaMask与此合同进行交互的方式类似。
让我们运行saveB(5)并查看日志窗口中发生的情况:

这儿有3个我们感兴趣的值:

  • GAS总量( "gas limit"): 3,000,000
  • 交易费用 ("transaction cost"): 41642 gas
  • 执行费用( "execution cost"): 20178 gas.

2.2 发送的GAS总量(Gas limit)


这儿显示的"Gas limit"是发送的GAS总量,Value是发给目标地址的ETH值。这2处的值可以被发送交易的用户修改。

2.3 交易成本(Transaction Cost)

交易成本,在Remix中显示,是实际交易成本加上执行成本的混合。我认为,这儿看起来有点误导。

如果您使用数据字段发送交易,那么交易包含一个基本成本和每个字节的附加成本(GAS计价)。看看以太坊黄纸的附录列出了每种的GAS费用:

一起来看看41642的交易成本是如何结合在一起的。这是Remix在交易中自动发送的数据字段:

input_remix

这儿是 Data-Field:

0x348218ec0000000000000000000000000000000000000000000000000000000000000005

数据字段是散列函数签名的前4个字节和32字节填充参数的组合。我们快速手动计算。
函数签名是saveB(uint256),如果我们用SHA3-256(或Keccak-256)散列函数,那么我们得到:348218ec5e13d72ab0b6b9db1556cba7b0b97f5626b126d748db81c97e97e43d如果我们取前4个字节(提醒:1个字节= 8位= 2个十六进制字符.1个十六进制字符= 4 bit = 0-15 = 0000到1111 = 0x0到0xF),然后我们得到348218ec。让我们0x在前面添加,我们得到0x348218ec。参数是一个256位的无符号整数,即32个字节。这意味着它将整数“5”填充到32个字节,换句话说,它将在数字前面添加63个零:
0000000000000000000000000000000000000000000000000000000000000005
从以太坊黄皮书上可以获得参考:

  • 每笔交易都有21000 GAS支付
  • 为交易的每个非零字节数据或代码支付68 GAS
  • 为交易的每个零字节数据或代码支付4 GAS

计算一下:
348218ec 是4个字节的数据,显然是非零的。
0000000000000000000000000000000000000000000000000000000000000005是31个字节的零数据和1个字节的非零数据的混合。
这使得总共5个字节的非零数据和31个字节的零数据。
(5 non-zero-bytes * 68 gas) + (31 zero-bytes * 4 gas) = 340 + 124 = 464 gas
对于我们的输入数据,我们必须支付464 GAS。除此之外,我们还要支付 21000 GAS,这是每笔交易支付的。因此总共需要21464用于交易。
让我们看看是否会增加。


Remix称“交易成本”为41642 gas,“执行成本”为 20178 gas。而在Remix中,“交易成本”实际上是交易成本加执行成本的总和。因此,如果我们从交易成本中减去执行成本,我们应该得到21464 gas。
41642 (交易成本”) - 20178 (执行成本) = 21464 gas
剩下的结果21464 gas为数据交易成本,同上计算公式。

2.4 执行成本(Execution Cost)

执行成本有点难以计算,因为发生了很多事情,辉哥试着告诉你合同执行时到底发生了什么。

让我们深入了解实际的事务并打开调试器。这可以通过单击事务旁边的“调试”按钮来完成。

可以打开指令折叠菜单和单步调试菜单。你将看到每一条指令以及每个指令在该特定步骤中花费的GAS费用。


这里看到的是所有以太坊汇编指令。因此,我们知道Solidity可以归结为EVM Assembly。这是矿工实际执行的智能合约运行看起来的实际情况。来看看前两个指令:

PUSH1 60
PUSH1 40

这意味着除了将值60和40推入堆栈之外别无其他。显然还有很多事情要做,你可以通过在单步调试器中移动蓝色滑块来完成它们的工作。
根据以太坊黄皮书将每个指令所需的确切气体量汇总在一起,以便将值5写入存储:

GAS Instruction
3   000 PUSH1 60
3   002 PUSH1 40
12  004 MSTORE
3   005 PUSH1 04
2   007 CALLDATASIZE
3   008 LT
3   009 PUSH1 3f
10  011 JUMPI
3   012 PUSH1 00
3   014 CALLDATALOAD
3   015 PUSH29 0100000000000000000000000000000000000000000000000000000000
3   045 SWAP1
5   046 DIV
3   047 PUSH4 ffffffff
3   052 AND
3   053 DUP1
3   054 PUSH4 348218ec
3   059 EQ
3   060 PUSH1 44
10  062 JUMPI
1   068 JUMPDEST
2   069 CALLVALUE
3   070 ISZERO
3   071 PUSH1 4e
10  073 JUMPI
3   074 PUSH1 00
3   076 DUP1
1   078 JUMPDEST
3   079 PUSH1 62
3   081 PUSH1 04
3   083 DUP1
3   084 DUP1
3   085 CALLDATALOAD
3   086 SWAP1
3   087 PUSH1 20
3   089 ADD
3   090 SWAP1
3   091 SWAP2
3   092 SWAP1
2   093 POP
2   094 POP
3   095 PUSH1 64
8   097 JUMP
1   100 JUMPDEST
3   101 DUP1
3   102 PUSH1 00
3   104 DUP2
3   105 SWAP1
20000   106 SSTORE
2   107 POP
2   108 POP
8   109 JUMP
1   098 JUMPDEST
0   099 STOP

合计为20178 GAS费。

2.5 GAS上限(Gas Limit)

所以,以太坊区块链上的每一条指令都会消耗一些GAS。如果你要将值写入存储,则需要花费很多。如果你只是使用堆栈,它的成本会低一些。但基本上所有关于EVM的指令都需要GAS。这意味着智能合约只能做有限的事情,直到发送的GAS用完为止。在样例这种情况下,我们发送了300万 GAS费。
当您返回REMIX的单步调试器,点击第一步时,您会看到每个步骤剩余多少GAS。辉哥在第一步打开它:



它已经从我们发送的300万(从3,000,000 - 21464 = 2,978,536)中扣除的交易成本开始。(说明:21464是之前2.3章节执行的数据执行成本。)
一旦此计数器达到零,那么合约执行将立即停止,所有存储的值将被回滚,你将获得“Out of Gas”异常告警。

2.6 区块GAS上限(Block Gas Limit)

除了通过交易设置的气Gas Limit外,还有一个所谓的“区块上限”。这是你可以发送的最大GAS量。目前,在Main-Net,该值大概为8M左右。

2.7 GAS退款(Gas Refund)

Gas Limit有一个好处:你不必自己计算它。如果你向合约发送8M的GAS,它耗尽41642 GAS,可以退还其余部分。因此,发送远远超过必要的GAS总会节省下来的,其余的将自动退还到你的账号地址。

2.8 GAS价格(Gas Price)

GAS价格决定了交易在能否被包含在下一个被挖出的区块中。
当你发送交易时,你可以激励矿工接下来处理您的交易。这种激励就是GAS PRICE。矿工一旦挖出新区块,也会将交易纳入该区块。哪些交易被纳入下一个区块是由矿工确定的 - 但他很可能将GAS PRICE从高到低排序。

假设有15笔未完成的交易,但只有12笔交易可以进入下一个区块。5个20 Gwei,5个15 Gwei和5个 5Gwei的GAS PRICE。矿工很可能按此顺序选择交易:5 * 20 + 5 * 15 + 2 * 5 Gwei并将它们合并到下一个挖掘区块中。

因此,GAS Limit基本上决定了以太坊虚拟机可以执行的指令数量,而GAS Price决定了矿工选择此交易的可能性。

大多数钱包将标准GAS Price设定为20Gwei左右(0.00000002 ETH)。如果您正在执行上述合约,那么您将支付约60-70美分(美元分),当前汇率为1 ETH = 800美元。所以它根本不便宜。

幸运的是,在网络拥塞期间,您只需要更高的GAS PRICE,那是因为许多人尝试同时发送交易。如果网络没有拥挤,那么您不需要支付这么多GAS。EthGasStation网站评估目前的交易价格为4 Gwei足够 。所以,凭借这个小功能,只需要4 Gwei的GAS,它将是16美分左右,而不是65美分。一个巨大的差异。

3,如何优化节省GAS费用的方法

GAS消耗可参考以下两个表: 表格1表格2 。下面提供一下优化GAS消耗的方法。

3.1 创建合约

创建合约对应CREATE和CODECOPY这两条指令。在合约中创建另一个空合约消耗42,901个GAS(总共64,173个GAS)。如果直接部署空白合约,共有68,653个GAS。
如果包含实施,可能会有数十万甚至数百万的GAS。它应该是所有指令中最昂贵的。如果创建多个合约实例,则GAS消耗可能很大。

建议: 避免将合约用作数据存储。

不好的代码实现:

contract User {
  uint256 public amount;
  bool public isAdmin;
  function User(uint256 _amount, bool _isAdmin) {
    amount = _amount;
    isAdmin = _isAdmin;
  }
}

好的代码实现:

contract MyContract {
  mapping(address => uint256) amount;
  mapping(address => bool) isAdmin;
}

另一种OK的代码实现:

contract MyContract {
  struct {
    uint256 amount;
    bool isAdmin;
  }
mapping(address => User) users;
}

3.2 存储

对应于SSTORE指令。存储新数据需要20,000 GAS。修改数据需要5000 GAS。一个例外是将非零变量更改为零。我们稍后会讨论这个问题。

建议: 避免重复写入,最好一次在最后尽可能多地写入到存储变量。
不好的代码样例:

uint256 public count;
// ...
for (uint256 i = 0; i < 10; ++i) {
  // ...  
  ++count;
}

好的代码样例:

for (uint256 i = 0; i < 10; ++i) {
  // ...
}
count += 10;

3.3 变量排序对GAS的影响

你可能不知道变量声明的顺序也会影响Gas的消耗。
由于EVM操作都是以32字节为单位执行的,因此编译器将尝试将变量打包成32字节集进行访问,以减少访问时间。
但是,编译器不够智能,无法自动优化变量分组。它将静态大小的变量分组为32个字节的组。例如:

contract MyContract {
  uint64 public a;
  uint64 public b;
  uint64 public c;
  uint64 public d;
function test() {
    a = 1;
    b = 2;
    c = 3;
    d = 4;
  }
}

执行test()时,看起来已经存储了四个变量。由于这四个变量之和恰好是32个字节,因此实际执行了一个SSTORE。这只需要20,000 GAS。

再看下一个例子:

contract MyContract {
  uint64 public a;
  uint64 public b;
  byte e;
  uint64 public c;
  uint64 public d;

function test() {
    a = 1;
    b = 2;
    c = 3;
    d = 4;
  }
}

中间插入了另一个变数,结果造成a,b,e和c会被分为一组,d独立为一组。同样的test()造成两次写入,消耗40000 Gas。

最后再看一个例子:

contract MyContract {
  uint64 public a;
  uint64 public b;
  uint64 public c;
  uint64 public d;
function test() {
    a = 1;
    b = 2;
    // ... do something
    c = 3;
    d = 4;
  }
}

这与第一个例子的区别在于,在存储a和b之后,完成了其他事情,最后存储了c和d。结果这次将导致两次写入。因为当执行“执行某事”时,编译器确定打包操作已结束,然后发送写入。但是,由于第二次写入是同一组数据,因此认为它是被修改的。将消耗总共25,000个气体。

建议:
根据上述原则,我们可以很容易地知道如何处理它。

  • 正确的排序和分组
    将数据大小分组为32个字节,并将通常同时更新的变量放在一起。
    不好的代码例子:
contract MyContract {
  uint128 public hp;
  uint128 public maxHp;
  uint32 level;
  uint128 public mp;
  uint128 public maxMp;
}

好的例子:

contract MyContract {
  uint128 public hp;
  uint128 public mp;
  uint128 public maxHp;
  uint128 public maxMp;
  uint32 level;
}

这里我们假设hp和mp更频繁地更新,并且maxHp和maxMp更频繁地一起更新。

  • 尽量一次访问
    不好的代码例子:
function test() {
    hp = 1;
    // ... do something
    mp = 2;
  }

好的例子:

function test() {
    // ... do something
    hp = 1;
    mp = 2;
  }

这个规则在struct上是一样的。

3.4 交易输入数据

合约交易的基本气体是21,000。输入数据为每字节68个GAS,如果字节为0x00则为4个GAS。

例如,如果数据为0x0dbe671f,则气体为68 * 4 = 272; 如果是0x0000001f,它是68 * 1 + 4 * 3 = 80。

由于所有参数都是32字节,因此当参数为零时,气体消耗最小。它将是32 * 4 = 128。最大值如下:

n * 68 +(32-n)* 4 的字节数 (n:参数)

例如,32字节输入参数的最大GAS为2,176 (3268 = 2176)。输入参数为地址,地址是20个字节,因此它是1,408 (2068+(32-20)*4 = 1408)。

建议: 可以通过更改排序来节省GAS消耗。
例如EtherScan有下一段交易记录:

Function: trade(address tokenGet, uint256 amountGet, address tokenGive, uint256 amountGive, uint256 expires, uint256 nonce, address user, uint8 v, bytes32 r, bytes32 s, uint256 amount) ***
MethodID: 0x0a19b14a
[0]:0000000000000000000000000000000000000000000000000000000000000000
[1]:000000000000000000000000000000000000000000000000006a94d74f430000
[2]:000000000000000000000000a92f038e486768447291ec7277fff094421cbe1c
[3]:0000000000000000000000000000000000000000000000000000000005f5e100
[4]:000000000000000000000000000000000000000000000000000000000024cd39
[5]:00000000000000000000000000000000000000000000000000000000e053cefa
[6]:000000000000000000000000a11654ff00ed063c77ae35be6c1a95b91ad9586e
[7]:000000000000000000000000000000000000000000000000000000000000001c
[8]:caa3a70dd8ab2ea89736d7c12c6a8508f59b68590016ed99b40af0bcc2de8dee
[9]:26e2347abfba108444811ae5e6ead79c7bd0434cf680aa3102596f1ab855c571
[10]:000000000000000000000000000000000000000000000000000221b262dd8000

所有参数都是256位,无论类型是byte32,address还是uint8。所以左边的大多数参数都有大量的“0”是未使用的位。很容易想到使用这些“空间”。
例如可以把tokenGive的高位字节用于存放下面吗一些变量,把命名改为uint256 tokenSellWithData。

nonce  - > 40位
takerFee  - > 16位
makerFee  - > 16位
uint256 joyPrice  - > 28位
isBuy  - > 4位(实际上,1位就足够了。只是为了方便呈现文档)

假如上面变量的值分别为:

nonce: 0181bfeb
takerFee: 0014
makerFee: 000a
joyPrice: 0000000
isBuy: 1

那么tokenSellWithData的存储可能如:

更多优化参考文章《[Solidity] Compress input in smart contract》

3.5 转账

Call, send 和transfer 函数对应于CALL指令。基本消耗是7,400 GAS。事实上,消费将近7,600 GAS。值得注意的是,如果转账到一个从未见过的地址,将额外增加25,000个GAS。

没有额外的消耗样例:

function withdraw(uint256 amount){ 
  msg.sender.transfer(amount); 
}

可能会有额外的消耗样例(receiver参数未被使用,多余参数):

function withdrawTo(uint256 amount, address receiver) {
  receiver.transfer(amount);
}

3.6 其他命令

3.6.1 ecrecover

对应CALL指令。此功能将消耗3700 GAS。

3.6.2调用外部合约

调用外部合约执行EXTCODESIZE和CALL指令。基本消耗1400 GAS。除非必要,否则不建议拆分多个合同。可以使用多个继承来管理代码。

3.6.3事件

对应于LOG1指令。没有参数的事件是750 GAS。理论上每个附加参数将增加256个GAS,但事实上,它会更多。

3.6.3哈希

你可以使用智能合约中的几个内置哈希函数:keccak256,sha256和ripemd160。参数越多,消耗的气体越多。耗气量:ripemd160> sha256> keccak256。因此,如果没有其他目的,建议使用keccak256函数。

3.7 部署合约优化

大部分的优化在编译时候已经完成了。

问题:
部署合同中是否包含注释,是否会增加部署气体?
回答:
不,在编译期间删除了执行时不需要的所有内容。其中包括注释,变量名和类型名称。

并且可以在此处文章找到优化程序的详细信息。

另一种通过删除无用代码来减小大小的方法,。例如:

1 function p1 ( uint x ){ 
2    if ( x > 5)
3     if ( x*x < 20)
4        XXX }

在上面的代码中,第3行和第4行永远不会执行,并且可以避免这些类型的无用代码仔细通过合同逻辑,这将减少智能合约的大小。

3.8 调用合约函数的成本优化

当调用合约额的功能时,为了执行功能,它需要GAS。因此,优化使用较少GAS的功能非常重要。在考虑每个合约时时,可以采用多种不同的方式。这里有一些可能在执行过程中节省GAS的方法。

3.8.1 减少昂贵的操作

昂贵的操作是指一些需要更多GAS值的操作码,例如SSTORE。以下是一些减少昂贵操作的方法。

A)使用短路规则

操作符 || 和&&适用常见的短路规则。这意味着在表达式f(x)|| g(y)中,如果f(x)的计算结果为真,即使它有副作用,也不会评估g(y)。

因此,如果逻辑操作包括昂贵的操作和低成本操作,那么以昂贵的操作可以短路的方式安排将在一些执行中减少GAS。

如果f(x)是便宜的并且g(y)是昂贵的,逻辑运算代码(便宜的放在前面):

  • OR : f(x) || g(y)
  • AND: f(x) && g(y)

如果短路,将节省更多的气体。

f(x)g(y)安排AND操作相比,如果返回错误的概率要高得多,f(x) && g(y)可能会导致通过短路节省更多的气体。

f(x)g(y)安排OR运算相比,如果返回真值的概率要高得多,f(x) || g(y)可能会导致通过短路节省更多气体。

B)循环中昂贵的操作

不好的代码,例如:

 uint sum = 0;
 function p3 ( uint x ){
     for ( uint i = 0 ; i < x ; i++)
         sum += i; }

在上面的代码中,由于sum每次在循环内读取和写入存储变量,所以在每次迭代时都会发生昂贵的存储操作。这可以通过引入如下的局部变量来节省GAS来避免。
好的代码,例如:

 uint sum = 0;
 function p3 ( uint x ){
     uint temp = 0;
     for ( uint i = 0 ; i < x ; i++)
         temp += i; }
     sum += temp;

3.8.2 其他循环相关模式

循环组合,不好的代码样例:

function p5 ( uint x ){
    uint m = 0;
    uint v = 0;
    for ( uint i = 0 ; i < x ; i++) //loop-1
        m += i;
    for ( uint j = 0 ; j < x ; j++) /loop-2
        v -= j; }

loop-1和loop-2可以组合,可以节省燃气。
好的代码样例:

 function p5 ( uint x ){
    uint m = 0;
    uint v = 0;
    for ( uint i = 0 ; i < x ; i++) //loop-1
       m += i;
       v -= j; }

这里文章可以找到更多的循环模式

3.8.3 使用固定大小的字节数组

可以使用一个字节数组作为byte [],但它在传入调用时浪费了大量空间,每个元素31个字节。最好使用bytes。

根据经验,对任意长度的原始字节数据使用 bytes标识符,对任意长度的字符串(UTF-8)数据使用 string标识符。如果您可以将长度限制为特定的字节数,请始终使用bytes1到bytes32之一,因为它们要便宜得多。

具有固定长度总是节省GAS。也请参考这个问题描述

3.8.4 删除无用的代码可以在执行时节省GAS

如前面在合同部署中所解释的那样删除无用的代码即使在执行函数时也会节省GAS。

3.8.5 在实现功能时不使用库对于简单的使用来说更便宜。

调用库以获得简单的用法可能代价高昂。如果功能在合同中实现简单且可行,因为它避免了调用库的步骤。两种功能的执行成本仍然相同。

4, 参考

(1)区块链系列十九:Gas优化
(2)How to write an optimized (gas-cost) smart contract?
(3)[Solidity] Optimize Smart Contract Gas Usage
(4)What exactly is the Gas Limit and the Gas Price in Ethereum
(5)【易错概念】以太坊的账户、交易、Gas和Gas Limit的概念


推荐一个好活动,区块链技术工坊线下聚会活动每周三晚上如期举行,进行深度分享区块链知识,实现小会技术交友,欢迎大家报名参加。

辉哥的技术投资之路
Web note ad 1