前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >智能合约gas评估与优化方法小结

智能合约gas评估与优化方法小结

原创
作者头像
davy the bot
发布2024-04-08 19:33:10
4550
发布2024-04-08 19:33:10

背景

以太坊上存储256 bit数据大约消耗20k Gas、如此换算,仅1 GB存储资源要花费32,000ETH,大约要花费超过1亿美元。且不说当前身为贵族链Gas费很有可能继续水涨船高,放在早些年其Gas消耗也不是一笔小数目。因此,以太坊Gas优化是Dapp开发一直难绕的问题,也是Solidity开发者的必备技能。

gas评估方法

在etherscan中查看gas

查看交易花费的总gas和价格,交易详情中直接查看

查看交易trace,在交易详情中点击Parity Trace, 可以看到每一个内部交易的gas,主要是call, delegatecall等

选择geth Trace 则可以看到Opcode层面的gas消耗情况

在remix中查看gas

remix中主要在交易执行结果console中查看。

Transaction Cost 基于将数据发送到区块链的成本。全部交易成本由 4 项组成:

  1. 交易的基本成本(21000 Gas)
  2. 合同部署成本(32000 Gas)
  3. 交易的每个零字节数据或代码的成本。
  4. 交易的每个非零字节数据或代码的成本。

Execution Cost 基于作为交易结果而在EVM执行的计算操作的成本。

仅使用remix内置的debug链能输出区分Transaction Cost,Execution Cost 如果连接其他的链只能获得总gas

在hardhat中查看gas

打印单次交易gas

hardhat不会直接给出gas情况,在执行部署合约以及合约交互时一般可以通过promise中的交易hash获取回执,从回执中得到结果。

合约交互的交易:

代码语言:javascript
复制
  let res = await contract.mint(user.address, 10000);
  let receipt = await hre.ethers.provider.getTransactionReceipt(res.hash);

  console.log("gas used: ", receipt.gasUsed);
  console.log("gas*price: ", receipt.gasUsed.mul(receipt.effectiveGasPrice));

部署合约的交易:

代码语言:javascript
复制
let res = await contract.deployed();

let receipt = await hre.ethers.provider.getTransactionReceipt(
  res.deployTransaction.hash
);

console.log("gas used: ", receipt.gasUsed);
console.log("gas*price: ", receipt.gasUsed.mul(receipt.effectiveGasPrice));

预估交易Gas(Estimate gas)

代码语言:javascript
复制
let contractFactory = await hre.ethers.getContractFactory("ActivityToken");
let contract = await contractFactory.deploy("ActivityToken", "AT");

// 预估部署gas,不是很准确,暂时没有更好的方法
console.log(
  "Deploy Estimated gas:",
  await ethers.provider.estimateGas(contractFactory.bytecode)
);

let res = await contract.deployed();

// 预估调用合约的gas
console.log(
  "Mint estimated Gas: ",
  await contract.estimateGas.mint(deployer.address, 100000)
);

生成gas report(test命令下)

在hardhat中主要是使用hardhat-gas-reporter插件,可以在运行单元测试时,同时生成执行的gas报告。报告中可以看到测试过程中每个函数的平均gas消耗以及,部署过程中的gas消耗。

Hardhat Gas Reporter 是一个 Hardhat 插件,可以用来在控制台中显示每个合约函数的 gas 使用量,以及整个合约的 gas 使用量。下面是使用 Hardhat Gas Reporter 的步骤:

  1. 安装 Hardhat Gas Reporter:使
代码语言:shell
复制
npm install --save-dev hardhat-gas-reporter
  1. 配置 Hardhat:在 Hardhat 项目的根目录下,打开hardhat.config.js 文件,并添加以下内容:
代码语言:javascript
复制
require("hardhat-gas-reporter");
module.exports = {
  gasReporter: {
    currency: 'CHF',
    gasPrice: 21
  }
}

效果如下:

  1. 实时计算价格 如果填入coinmarketcap的apikey, 插件还可以实时拉取到代币的价格,换算成为美元等价格填入表格中
代码语言:json
复制
gasReporter: {
    //doc:https://github.com/cgewecke/eth-gas-reporter
    enabled: true,

    currency: "USD", //默认EUR, 可选USD, CNY, HKD
    // 默认token是ETH, 更换成其他的会实时在coinmarketcap找价格
    // token: "MATIC",
    coinmarketcap: "59a52916-XXXX-XXXX-XXXX-2d6f56917aee", //https://coinmarketcap.com/

    // 默认从 eth gas station api 中获取eth价格,其他代币可自行输入gasPrice(推荐), 或填入gasPriceAPI(注意调用限制)
    // gasPrice: 30,
    // gasPriceApi:"https://api.etherscan.io/api?module=proxy&action=eth_gasPrice",
  },

https://www.npmjs.com/package/hardhat-gas-reporter

gas优化方法

gas优化基础知识

基础GAS计算公式:

gas = txGas + dataGas + opGas 如果交易没有创建新的合约,则txGas为 21000,否则txGas为 53000。交易中data的每个零字节需要花费 4 个 gas,每个非零字节需要花费 16 个 gas。opGas是指运行完所有的 op 所需要的 gas。

一般来说opGas的优化空间更大。

合约gas消耗:

  • 交易gas (Transaction Gas): 每次交易调用合约花费的gas.
  • 部署gas (Deployment Gas): 部署该合约时一次性花费的gas.

在评估gas时,往往要在上述二者间进行折中。

不同操作使用的gas

代码语言:txt
复制
Operation         Gas           Description

ADD/SUB           3             Arithmetic operation
MUL/DIV           5             Arithmetic operation
ADDMOD/MULMOD     8             Arithmetic operation
AND/OR/XOR        3             Bitwise logic operation
LT/GT/SLT/SGT/EQ  3             Comparison operation
POP               2             Stack operation 
PUSH/DUP/SWAP     3             Stack operation
MLOAD/MSTORE      3             Memory operation
CALLDATALOAD      3             Calldata operation
JUMP              8             Unconditional jump
JUMPI             10            Conditional jump
SLOAD             100/2100      Storage operation (热访问/冷访问)
SSTORE            5,000/20,000  Storage operation
BALANCE           400           Get balance of an account
CREATE            32,000        Create a new account using CREATE
CALL              25,000        Create a new account using CALL

 
KECCAK256     gas_cost = 30 + 6 * data_size_words + mem_expansion_cost
LOG     gas_cost = 375 + 375 * num_topics + 8 * data_size + mem_expansion_cost

详见:https://ethereum.org/en/developers/docs/evm/opcodes/

Solidity优化器(Optimizer)

优化器介绍

目前solidity有两种优化器:

  • 基于opcode的优化器:对于opcode执行一套简化规则,清理无用代码。针对汇编代码进行操作,在 JUMPs 和 JUMPDESTs 之间将指令序列分成基本块,然后对于每一个块进行表达式优化分析,如表示式有可能化简,或者提取通用子表达式,就修改迭代。
  • 基于Yul IR的优化器: 可以跨函数工作,更强大,函数可能被内联,调整顺序,合并或重写以消除冗余。yul优化器有多个模块,多个模块之间的序列还可以自己处理。

参考:

https://docs.soliditylang.org/zh/v0.8.19/internals/optimizer.html 优化器文档

https://docs.soliditylang.org/zh/v0.8.19/yul.html# Yul语言文档

https://learnblockchain.cn/article/6064 Yul入门指南

如何开启优化器

使用solc命令时:

目前 --optimize 启动了opcode-based optimizer用于bytecode优化, 同时启动Yul optimizer用于内部生成的Yul code的优化.

使用 solc --ir-optimized --optimize 可以生成Solidity源码对应的 optimized Yul IR .

使用 solc --strict-assembly --optimize 是启用专门的 Yul 模式优化.

使用hardhat时:

插件仅支持配置optimizer一个选择 ,即不能选择ir-optimized等选项,目前都是默认优化模式。

代码语言:javascript
复制
module.exports = {
  solidity: {
    version: "0.8.9",
    settings: {
      optimizer: {
        enabled: false,
        runs: 200,
      },
    },
  },
};

runs参数理解

运行次数( --optimize-runs )大致规定了在合约有效期(可以理解为1年)内, 所部署的代码的每个操作码被执行的频率。 这意味着它是代码大小(部署成本)和代码执行成本(部署后的成本)之间的一个折衷参数。 一个 “运行” 参数为 “1” 将产生简短的合约但昂贵的执行代码。相反, 一个较大的 “运行” 参数将产生较大的合约但更省gas的执行代码。 该参数的最大值为 2^32-1。

注意runs不是越大越好,也不是指运行多少次优化迭代

一种简单的理解是runs是是否要内联的启发式参数, runs越多,就越倾向于内联。

代码层gas优化方法:

使用immutable和constant

代码语言:javascript
复制
uint256 public v1;
uint256 public constant v2 = 1000;
function calculate() returns (uint256 result) {
    return v1 * v2 * 10000
}

此时v2,10000都是bytecode中的一员, 而v1是状态变量中的一员。

每次读取v1需要额外执行一次sload, 将花费200gas。

函数modifier指定external, view, pure

对于external:

public修饰等于external+internal , 如果仅指定extenral,这个函数的参数不需要存储在内存中,而是直接从calldata中读取, 相反如果public函数的参数就要存入内存中。

因此,当可以使用external时,不要使用public.

这个优化方式对于参数比较大的函数尤为有效。

对于view, pure:

view不会改变区块链上任何状态,但要注意只有external view函数或者public view函数被外部调用时才是免费的, 在交易中被调用任然需要正常扣费。 pure的情况类似。

使用Mapping而非Array

除非需要打包数据或者进行迭代,否则映射的成本更低。 使用映射也可以通过数字作为key索引的形式来迭代。

如果逻辑明确了数组长度,使用定长数组也可以。

传参数据类型calldata优于memory优于Storage

如果不改变参数内容,仅仅是读取数据, 优先指定为calldata。calldata的读取消耗是和memory基本一致的, 只是所有的入参本身作为calldata就已经占据了一份存储, 如果指定为memory的话还会将calldata数据拷贝如memory中存下其index,会多花费这一部分多出的资源。

代码语言:solidity
复制
// calldata
function func2 (uint[] calldata nums) external {
 for (uint i = 0; i < nums.length; ++i) {
    ...
 }
}

// Memory
function func1 (uint[] memory nums) external {
 for (uint i = 0; i < nums.length; ++i) {
    ...
 }
}

此外,对于状态变量,可以尽量减少其在循环中被反复使用

代码语言:solidity
复制
//优化后
uint sum = 0;
 function p3 ( uint x ){
     for ( uint i = 0 ; i < x ; i++)
         sum += i; }

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

删除未使用的变量,获得gas返还

代码语言:solidity
复制
//Using delete keyword
delete myVariable;

//Or assigning the value 0 if integer
myInt = 0;

//下列动作会清理状态变量中该数据原有的值;
myAddresses = new address[](0);

什么是gas refund 1. 合约调用的 selfdestruct 将合约销毁或者调用 sstore 将状态变量的值由非空变为空都可以得到 gas 退回。 2. gas 退回并不意味着账户里的以太币余额会增加,只是意味着这次交易所花费的 gas 量会减少。gas 退回有个上限,就是不能超过当前交易所花费 gas 的 50%,如果超过,就按 50% 算。 3. gas 退回也不意味着交易发起者账户里的余额可以少一点儿,余额校验仍然是基于 gas price * gas limit。

有效使用整数大小类型

单个变量尽量都使用unit256, 因为EVM单次操作32字节, 对于uint8和bool的数据还要进行填充,需要更多gas。(多用uint256也少很多类型转换和兼容性问题)

只有在连续存储时,特别是放入结构体中时,使用小结构体才是有效的,此时需要注意内存对齐的顺序。

代码语言:solidity
复制
contract Leggo {
  uint128 a;  
  uint128 c;  
  uint256 b; 
}

理解:什么是变量清理

清理变量(Cleaning Up Variables): 当一个值的占用位数小于32个字节,其中无用的位将会被清除。无论是加载到内存中或者是在存储中,都会这样做, 否则会影响计算hash或者生成calldata之类的逻辑。

https://docs.soliditylang.org/zh/v0.8.16/internals/variable_cleanup.html

同理 byte[] 的效率也因为清理变量的原因比较低下,尽量可以使用bytes32或者其他bytesX代替。

同理,对于string如果其长度低于32, 也可以考虑使用byteX代替。

代码语言:solidity
复制
// 599 gas
function useString() public returns(string memory a) {
  a = "hello world!";
}
// 196 gas
function useByte() public returns(bytes32 a) {
  a = bytes32("hello world!");
}

错误处理,多使用Error

替换require(isOwner(msg.sender), "Unauthorized")为 if (!isOwner(msg.sender)){revert Unauthorized();} 减少revert信息.

调整函数的顺序*

由于函数签名的不同,调用函数时,EVM需要帮你查找函数,因此按照函数签名的数值大小,先找到的花费的gas就更少,后找到的花费gas就更多,因此对于调用特别频繁的函数,我们可以考虑调整其优先级。 另外要注意,public状态变量,也参与查找计算(即uint256 public a 有一个 a() 函数)。

结论:

  • 减少public成员数量, constant如外面不需要读取可设置为private;
  • 常用的函数通过“改名字”的方式调整其优先级;

参考:

https://medium.com/joyso/solidity-how-does-function-name-affect-gas-consumption-in-smart-contract-47d270d8ac92

压缩input data

在有多个input参数时, 可以看见组织input data的时候是32byte为一组的。

代码语言:txt
复制
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

这个时候我们可以利用里面的空间,把多个像uint8, address这样不会占据全部空间的参数组合在一起,变成一个uint256, 在合约内部在通过mload解析开数据。

对于inputdata中,每多一个byte,会增加68gas(byte是全0则增加4gas), 对于频繁发生的调用,压缩inputdata是有必要的。

其他代码结构上的优化

判断时低成本的判断先做(短路模式,Short-circuiting rules): 如 f(x) || g(y) 应该让更容易判断为true的条件放在前边。

降低不必要的依赖。

避免在循环中做高消耗的动作,合并可以合并的循环, 提取循环不变的表达式到外部,循环中避免直接累加状态变量,避免在循环中多次调用arr.length。

++i 优于 i++ 优于 i+=1。

对于确定的计算,可以使用uncheck块: require(a <= b); unchecked { x = b - a }

合约代码的复用

部署时使用链接库

主要是对于library的复用, 一般来说对于库文件的internal调用会让调用花费更少,但由于库的嵌入和内联会导致部署时的花费增多; 而调用库文件的external方法会让调用花费更多,但部署文件的gas和体积更小,因此是一个折中。

特别注意: 如果lib中有externel或者public方法,则lib一定需要独立部署,remix会自动转化, 而hardhat中要注意填入link信息,否则会报错。

remix中
  • 如果使用了依赖的library的internal方法, 则编译器会将将库embed到合约里, 只会出现一个交易完成部署合约。

上图交易的花费为: 115901

调用bar的花费为:49858 ,26973

  • 如果只使用依赖library的extenal或public方法,则编译器会预留一个address位置到bytecode里, remix执行deploy时,会出现2个交易,分别部署库和合约。 后续再部署Bar,都只会出现1个交易,花费和第一次部署的Bar部署交易gas花费一样,而Foo无需再部署。

上图两个交易分别花费: 122886,146252, 后续再部署Bar都是花费 146252。

调用bar方法的gas为53650, 30765

  • 但目前没有功能可以直接由你来指定复用已经部署好的合约地址

https://ethereum.stackexchange.com/questions/72708/how-to-deploy-library-contract-separate-from-the-main-contract-and-link-it

hardhat中

部署lib

代码语言:javascript
复制
let libFactory = await hre.ethers.getContractFactory("IdentityLib");
let lib = await libFactory.deploy();
await lib.deployed();
console.log("library deployed to address: ", lib.address);

部署合约,并在部署时link lib

代码语言:javascript
复制
let contractFactory = await hre.ethers.getContractFactory(
    "CarbonIslandMethodology",
    {
      signer: deployer,
      libraries: {
        IdentityLib: lib.address,
      },
    }
  );

let contract = await contractFactory.deploy();
await contract.deployed();
console.log("contract deployed to address: ", contract.address);

ERC-1167 最小代理 克隆合约

最小代理提供了一个最精简的代理合约代码。

使用最小代理的注意事项 :最小代理的实现合约地址不能改变,这意味着你将不能升级他们的代码。

https://eips.ethereum.org/EIPS/eip-1167

https://mirror.xyz/xyyme.eth/mmUAYWFLfcHGCEFg8903SweY3Sl-xIACZNDXOJ3twz8

降低链上存储的gas优化方法

使用Log

log的gas使用参数如下

代码语言:txt
复制
LogDataGas            uint64 = 8     // Per byte in a LOG* operation's data.
LogGas                uint64 = 375   // Per LOG* operation.
LogTopicGas           uint64 = 375   
MemoryGas             uint64 = 3

其基础计算公式为

gas_cost = 375 + 375 * num_topics + 8 * data_size + mem_expansion_cost

一个例子:如果是2个topic + 200 bytes的log, 花费的gas为:

代码语言:txt
复制
375 (static cost)
200 = 200 bytes of memory for log.Data x 3 cost of memory = 600 gas for memory gas
2 x 375 = 750 for topic gas
8 x 200 = 1600 for log.Data cost

Total cost: 375 + 600 + 750 + 1600 = 3,325 gas units

可见相比存储一个uint256, 需要20000+ gas ,要节省很多。

小结:

  • 当log可以代替存储时(状态可以覆盖,无需链上读取),使用log更加节省成本。
  • 减少不必要的log也可以节约gas

使用MerkleProof

简而言之,默克尔证明使用单个数据块来证明大量数据的有效性。

使用无状态合约(Stateless contracts)

无状态合约利用了交易数据和事件调用等内容完全保存在区块链上的事实。因此,你不需要不断地改变合约的状态,而只需发送一笔交易并传递您想要存储的值即可。由于 SSTORE 操作通常占大部分交易成本,因此无状态合约仅消耗有状态合约的一小部分 Gas。

代码语言:solidity
复制
contract DataStore {
function save(bytes32 key, string value) {}
}

然后可以使用ethereum-input-data-decoder` 来直接解析交易的inputdata

npm install ethereum-input-data-decoder ### 使用链下数据源

如ipfs等,但存在大量以及非结构化数据时, 适合用链下数据源方式。我们可以将数据广播到 IPFS 网络,然后将相应的哈希值保存在合约中,以便稍后引用该信息。

降低链上存储方法小结:

Index Logs

Stateless Contract

Merkle Proof

Off-chain(IPFS)

数据规模

中小

gas节省度

小(log本身也需gas)

数据能否被合约使用

不能

不能

可以。(附带merkle路径)

比较麻烦,需要预言机等方式喂入数据使用

如何修改数据

链下认定新数据覆盖旧数据

链下认定新数据覆盖旧数据

链下修改,链上更新root

较复杂,要自行实现修改方式

适用场景

不需要再合约中使用变量,上链的最主要行为就是记录,其他行为由链下完成。有多重行为需要分类提醒链下处理

不需要再合约中使用变量,上链的最主要行为就是记录, 其他行为由链下完成

不需要频繁地访问和更改、添加链上变量,主要是批量数据的记录和偶然的单个验证查询

链下数据量大,且存在如图片等非结构化数据。主要用于辅助合约存储一些不在合约中写入和计算的额外数据

参考:

https://mirror.xyz/quentangle.eth/GxmosHtVYZaIkJjM9slpkKtZWfk8fU78FQ8oxoDNuFE

gas优化

https://ethereum.stackexchange.com/questions/28813/how-to-write-an-optimized-gas-cost-smart-contract

https://medium.com/layerx/how-to-reduce-gas-cost-in-solidity-f2e5321e0395

https://medium.com/coinmonks/8-ways-of-reducing-the-gas-consumption-of-your-smart-contracts-9a506b339c0a

https://www.alchemy.com/overviews/solidity-gas-optimization

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 背景
  • gas评估方法
    • 在etherscan中查看gas
      • 在remix中查看gas
        • 在hardhat中查看gas
          • 打印单次交易gas
          • 预估交易Gas(Estimate gas)
          • 生成gas report(test命令下)
      • gas优化方法
        • gas优化基础知识
          • Solidity优化器(Optimizer)
            • 优化器介绍
            • 如何开启优化器
            • runs参数理解
          • 代码层gas优化方法:
            • 使用immutable和constant
            • 函数modifier指定external, view, pure
            • 使用Mapping而非Array
            • 传参数据类型calldata优于memory优于Storage
            • 删除未使用的变量,获得gas返还
            • 有效使用整数大小类型
            • 错误处理,多使用Error
            • 调整函数的顺序*
            • 压缩input data
            • 其他代码结构上的优化
          • 合约代码的复用
            • 部署时使用链接库
            • ERC-1167 最小代理 克隆合约
          • 降低链上存储的gas优化方法
            • 使用Log
            • 使用MerkleProof
            • 使用无状态合约(Stateless contracts)
        • 参考:
        相关产品与服务
        区块链
        云链聚未来,协同无边界。腾讯云区块链作为中国领先的区块链服务平台和技术提供商,致力于构建技术、数据、价值、产业互联互通的区块链基础设施,引领区块链底层技术及行业应用创新,助力传统产业转型升级,推动实体经济与数字经济深度融合。
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
        http://www.vxiaotou.com