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計價)??纯?a target="_blank">以太坊黃紙的附錄列出了每種的GAS費用:
一起來看看41642的交易成本是如何結合在一起的。這是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指令?;鞠氖?,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的概念