はじめに
Solidity を勉強していると、「 storage はコストがかかるから 極力 memory を使うべき 」という話をよく聞きます。ほかにも、構造体の内容を変更する際など、対象のデータを memory 変数に取り出して編集し、最後に実体となるデータに上書きするようなコードもよく見かけます。
// 更新したいデータをメモリ変数に取り出す
SomeData memory temp = datas[_target];
// 内容の変更
temp.gold = _gold;
temp.coin = _coin;
temp.exp = _exp;
temp.friend = _friend;
// 実データへ反映
datas[_target] = temp;
なるほど、gold, coin, exp, friend の値をメモリ上で処理することで、ストレージ領域への書き込みを最後の1回で済ませています。見た目にもわかりやすい書き方ですし、これでコスト(ガスの消費)が抑えられると言うわけですね。
…ですが、実際のところ、この書き方はどれほどの効果があるのでしょうか?
例えば、ストレージを直接まさぐるような下記のコードと比較して、どれぐらいのコストが抑えられるのでしょうか?
datas[_target].gold = gold;
datas[_target].coin = coin;
datas[_target].exp = exp;
datas[_target].friend = friend;
SomeData storage temp = datas[_target];
temp.gold = _gold;
temp.coin = _coin;
temp.exp = _exp;
temp.friend = _friend;
実際のところどうなのでしょうか? 試しにテストしてみましょう。
テストに関して
さて、データの構成もガス消費に影響すると思うので、テストに使う構造体を3タイプ用意します。それぞれ、{A.変数の少ない1ワードの構造体}、{B.変数の少ない4ワードの構造体}、{C.変数の多い1ワードの構造体}としてみました。
そして、上記構造体の動的配列をデータとしてもつコントラクトに、5つの検証用の関数を用意することにします。
関数.1 … 追加要素を memory 変数に代入してデータ配列に push する
関数.2 … データ配列の length を+1して要素を増やし、storage 変数に取り出して代入する
関数.3 … 既存のデータに対して直接値を更新する
関数.4 … 既存のデータを storage 変数に取り出して値を更新する
関数.5 … 既存のデータを memory 変数に取り出して編集した後で上書きする
これで「3タイプの構造体 × 5つの関数 = 15種類」のガス消費が比較できる想定です。では、早速テストしていきましょう。
(※実際にテストしてみたい方は、こちらのプロジェクトをご利用ください)
(※iOS アプリ上でのイーサリアムへの接続に関してはこちらの記事を参照ください)
構造体A:変数の少ない1ワードの構造体
struct MyData{
uint128 serialNo;
uint64 ownerId;
uint32 maxPrice;
uint32 minPrice;
}
構造体Aに対するテスト結果が下記となります(ソースコードはこちら)。
・関数Aー1のガス消費:53,780(memory 変数の push によるデータ追加)
関数Aー1のコード(クリックで開きます)
//-------------------------------------------
// 関数Aー1:メモリ上でデータを作成したあとに追加
//-------------------------------------------
function createWithMemory( uint256 _val256 ) public{
// メモリ上で値を設定
MyData memory newData = MyData({
serialNo:uint128(_val256),
ownerId:uint64(_val256),
maxPrice:uint32(_val256),
minPrice:uint32(_val256)
});
// 設定したデータを追加
arrData.push( newData );
}
・関数Aー2のガス消費:55,885(空要素を追加した後にstorage経由でデータ設定)
関数Aー2のコード(クリックで開きます)
//-------------------------------------------
// 関数Aー2:データを追加した後でストレージ上で設定
//-------------------------------------------
function createWithStorage( uint256 _val256 ) public{
// 配列の要領を増やす(※末尾に空要素が追加される)
arrData.length += 1;
// ストレージ変数として取り出して設定
MyData storage lastData = arrData[arrData.length-1];
lastData.serialNo = uint128(_val256);
lastData.ownerId = uint64(_val256);
lastData.maxPrice = uint32(_val256);
lastData.minPrice = uint32(_val256);
}
・関数Aー3のガス消費:37,084(対象を直接いじるデータ更新)
関数Aー3のコード(クリックで開きます)
//-------------------------------------------
// 関数Aー3:データを直接更新
//-------------------------------------------
function updateDirect( uint256 _at, uint256 _val256 ) public{
require( _at >= 0 && _at < arrData.length );
arrData[_at].serialNo = uint128(_val256);
arrData[_at].ownerId = uint64(_val256);
arrData[_at].maxPrice = uint32(_val256);
arrData[_at].minPrice = uint32(_val256);
}
・関数Aー4のガス消費:34,414(storage 変数経由でのデータ更新)
関数Aー4のコード(クリックで開きます)
//-------------------------------------------
// 関数Aー4:ストレージ変数を利用して更新
//-------------------------------------------
function updateWithStorage( uint256 _at, uint256 _val256 ) public{
require( _at >= 0 && _at < arrData.length );
MyData storage storageData = arrData[_at];
storageData.serialNo = uint128(_val256);
storageData.ownerId = uint64(_val256);
storageData.maxPrice = uint32(_val256);
storageData.minPrice = uint32(_val256);
}
・関数Aー5のガス消費:39,365(memory 変数の代入によるデータ更新)
関数Aー5のコード(クリックで開きます)
//-------------------------------------------
// 関数Aー5:メモリ変数を利用して更新した後に上書き
//-------------------------------------------
function updateWithMemory( uint256 _at, uint256 _val256 ) public{
require( _at >= 0 && _at < arrData.length );
MyData memory memoryData = arrData[_at];
memoryData.serialNo = uint128(_val256);
memoryData.ownerId = uint64(_val256);
memoryData.maxPrice = uint32(_val256);
memoryData.minPrice = uint32(_val256);
arrData[_at] = memoryData;
}
構造体B:変数の少ない4ワードの構造体
struct MyData{
uint256 userId;
uint256 publicKey;
uint256 hash;
uint256 option;
}
構造体Bに対するテスト結果が下記となります(ソースコードはこちら)。
・関数Bー1のガス消費:107,749(memory 変数の push によるデータ追加)
関数Bー1のコード(クリックで開きます)
//-------------------------------------------
// 関数Bー1:メモリ上でデータを作成したあとに追加
//-------------------------------------------
function createWithMemory( uint256 _val256 ) public{
// メモリ上で値を設定
MyData memory newData = MyData({
userId:_val256,
publicKey:_val256,
hash:_val256,
option:_val256
});
// 設定したデータを追加
arrData.push( newData );
}
・関数Bー2のガス消費:109,934(空要素を追加した後にstorage経由でデータ設定)
関数Bー2のコード(クリックで開きます)
//-------------------------------------------
// 関数Bー2:データを追加した後でストレージ上で設定
//-------------------------------------------
function createWithStorage( uint256 _val256 ) public{
// 配列の要領を増やす(※末尾に空要素が追加される)
arrData.length += 1;
// ストレージ変数として取り出して設定
MyData storage lastData = arrData[arrData.length-1];
lastData.userId = _val256;
lastData.publicKey = _val256;
lastData.hash = _val256;
lastData.option = _val256;
}
・関数Bー3のガス消費:46,166(対象を直接いじるデータ更新)
関数Bー3のコード(クリックで開きます)
//-------------------------------------------
// 関数Bー3:データを直接更新
//-------------------------------------------
function updateDirect( uint256 _at, uint256 _val256 ) public{
require( _at >= 0 && _at < arrData.length );
arrData[_at].userId = _val256;
arrData[_at].publicKey = _val256;
arrData[_at].hash = _val256;
arrData[_at].option = _val256;
}
・関数Bー4のガス消費:43,451(storage 変数経由でのデータ更新)
関数Bー4のコード(クリックで開きます)
//-------------------------------------------
// 関数Bー4:ストレージ変数を利用して更新
//-------------------------------------------
function updateWithStorage( uint256 _at, uint256 _val256 ) public{
require( _at >= 0 && _at < arrData.length );
MyData storage storageData = arrData[_at];
storageData.userId = _val256;
storageData.publicKey = _val256;
storageData.hash = _val256;
storageData.option = _val256;
}
・関数Bー5のガス消費:47,943(memory 変数の代入によるデータ更新)
関数Bー5のコード(クリックで開きます)
//-------------------------------------------
// 関数Bー5:メモリ変数を利用して更新した後に上書き
//-------------------------------------------
function updateWithMemory( uint256 _at, uint256 _val256 ) public{
require( _at >= 0 && _at < arrData.length );
MyData memory memoryData = arrData[_at];
memoryData.userId = _val256;
memoryData.publicKey = _val256;
memoryData.hash = _val256;
memoryData.option = _val256;
arrData[_at] = memoryData;
}
構造体C:変数の多い1ワードの構造体
struct MyData{
uint32 rscId;
uint16 hp;
uint16 mp;
uint16 attack;
uint16 guard;
uint16 speed;
uint16 luck;
uint8[16] arrDNA;
}
構造体Cに対するテスト結果が下記となります(ソースコードはこちら)。
・関数Cー1のガス消費:136,636(memory 変数の push によるデータ追加)
関数Cー1のコード(クリックで開きます)
//-------------------------------------------
// 関数Cー1:メモリ上でデータを作成したあとに追加
//-------------------------------------------
function createWithMemory( uint256 _val256 ) public{
// メモリ上で値を設定
MyData memory newData = MyData({
rscId:uint32(_val256),
hp:uint16(_val256),
mp:uint16(_val256),
attack:uint16(_val256),
guard:uint16(_val256),
speed:uint16(_val256),
luck:uint16(_val256),
arrDNA:[
uint8(_val256), uint8(_val256), uint8(_val256), uint8(_val256),
uint8(_val256), uint8(_val256), uint8(_val256), uint8(_val256),
uint8(_val256), uint8(_val256), uint8(_val256), uint8(_val256),
uint8(_val256), uint8(_val256), uint8(_val256), uint8(_val256)
]
});
// 設定したデータを追加
arrData.push( newData );
}
・関数Cー2のガス消費:109,583(空要素を追加した後にstorage経由でデータ設定)
関数Cー2のコード(クリックで開きます)
//-------------------------------------------
// 関数Cー2:データを追加した後でストレージ上で設定
//-------------------------------------------
function createWithStorage( uint256 _val256 ) public{
// 配列の要領を増やす(※末尾に空要素が追加される)
arrData.length += 1;
// ストレージ変数として取り出して設定
MyData storage lastData = arrData[arrData.length-1];
lastData.rscId = uint32(_val256);
lastData.hp = uint16(_val256);
lastData.mp = uint16(_val256);
lastData.attack = uint16(_val256);
lastData.guard = uint16(_val256);
lastData.speed = uint16(_val256);
lastData.luck = uint16(_val256);
for( uint i=0; i<16; i++ ){
lastData.arrDNA[i] = uint8( _val256 );
}
}
・関数Cー3のガス消費:92,744(対象を直接いじるデータ更新)
関数Cー3のコード(クリックで開きます)
//-------------------------------------------
// 関数Cー3:データを直接更新
//-------------------------------------------
function updateDirect( uint256 _at, uint256 _val256 ) public{
require( _at >= 0 && _at < arrData.length );
arrData[_at].rscId = uint32(_val256);
arrData[_at].hp = uint16(_val256);
arrData[_at].mp = uint16(_val256);
arrData[_at].attack = uint16(_val256);
arrData[_at].guard = uint16(_val256);
arrData[_at].speed = uint16(_val256);
arrData[_at].luck = uint16(_val256);
for( uint i=0; i<16; i++ ){
arrData[_at].arrDNA[i] = uint8(_val256);
}
}
・関数Cー4のガス消費:73,100(storage 変数経由でのデータ更新)
関数Cー4のコード(クリックで開きます)
//-------------------------------------------
// 関数Cー4:ストレージ変数を利用して更新
//-------------------------------------------
function updateWithStorage( uint256 _at, uint256 _val256 ) public{
require( _at >= 0 && _at < arrData.length );
MyData storage storageData = arrData[_at];
storageData.rscId = uint32(_val256);
storageData.hp = uint16(_val256);
storageData.mp = uint16(_val256);
storageData.attack = uint16(_val256);
storageData.guard = uint16(_val256);
storageData.speed = uint16(_val256);
storageData.luck = uint16(_val256);
for( uint i=0; i<16; i++ ){
storageData.arrDNA[i] = uint8(_val256);
}
}
・関数Cー5のガス消費:127,765(memory 変数の代入によるデータ更新)
関数Cー5のコード(クリックで開きます)
//-------------------------------------------
// 関数Cー5:メモリ変数を利用して更新した後に上書き
//-------------------------------------------
function updateWithMemory( uint256 _at, uint256 _val256 ) public{
require( _at >= 0 && _at < arrData.length );
MyData memory memoryData = arrData[_at];
memoryData.rscId = uint32(_val256);
memoryData.hp = uint16(_val256);
memoryData.mp = uint16(_val256);
memoryData.attack = uint16(_val256);
memoryData.guard = uint16(_val256);
memoryData.speed = uint16(_val256);
memoryData.luck = uint16(_val256);
for( uint i=0; i<16; i++ ){
memoryData.arrDNA[i] = uint8(_val256);
}
arrData[_at] = memoryData;
}
意外な結果?
…あれ?
個人的には意外な結果になりました(※テスト自体は複数回行ったのですが、すべての結果で値が同じだったので誤差といこともないと思います)。
まず、データの追加をする関数1と関数2の結果を見ると、メンバー変数が少ない構造体Aと構造体Bの場合、memory 経由の処理が若干優位となっており、これは予想通りです(※とは言え期待したほどの差はでていません)。
逆に、メンバー変数の多い構造体Cの場合、memory 経由の処理である関数1が、strage 経由の関数2に比べて、20%以上ガスを浪費してしまっています。
そして、予想外だったのが、すべての構造体に対して、memory 経由でデータを更新する関数5のガス消費量が、関数3と関数4よりも多くなってしまていることです。さらに驚くべきことに、微増程度で済んでいる構造体Aと構造体Bに比べて構造体Cの消費量が、関数3に対して40%弱、関数4に対しては70%以上も増えてしまっている点です。
これはどうしたことでしょうか?
怪しむべきはやはりメンバー変数の数ということになりそうです。
memory 経由で読み書きする裏側
私の認識では、memory と storage 間における構造体の読み書きは、256 bit のワード単位で行われると思っていました。メンバー変数がいくつ存在しても、構造体のサイズが1ワードに収まっているならば、1ワード分のコストで入出力されるのではないかと…。
しかし、この認識は誤っていたようです。
テスト結果から、メンバー変数が多いほどオーバヘッドがでているのは明らかです。
では、関数Cー5の内部では何が起こっているのでしょう?
メモリに構造体のデータを取り出している下記のコードを、アセンブリで確認してみましょう。
MyData memory memoryData = arrData[_at];
アセンブリ(クリックで開きます)
/* "StorageCheckC.sol":4023:4062 MyData memory memoryData = arrData[_at] */
mload(0x40)
dup1
0x0100
add
0x40
mstore
swap1
dup2
0x00
dup3
add
0x00
swap1
sload
swap1
0x0100
exp
swap1
div
0xffffffff
and
0xffffffff
and
0xffffffff
and
dup2
mstore
0x20
add
0x00
dup3
add
0x04
swap1
sload
swap1
0x0100
exp
swap1
div
0xffff
and
0xffff
and
0xffff
and
dup2
mstore
0x20
add
0x00
dup3
add
0x06
swap1
sload
swap1
0x0100
exp
swap1
div
0xffff
and
0xffff
and
0xffff
and
dup2
mstore
0x20
add
0x00
dup3
add
0x08
swap1
sload
swap1
0x0100
exp
swap1
div
0xffff
and
0xffff
and
0xffff
and
dup2
mstore
0x20
add
0x00
dup3
add
0x0a
swap1
sload
swap1
0x0100
exp
swap1
div
0xffff
and
0xffff
and
0xffff
and
dup2
mstore
0x20
add
0x00
dup3
add
0x0c
swap1
sload
swap1
0x0100
exp
swap1
div
0xffff
and
0xffff
and
0xffff
and
dup2
mstore
0x20
add
0x00
dup3
add
0x0e
swap1
sload
swap1
0x0100
exp
swap1
div
0xffff
and
0xffff
and
0xffff
and
dup2
mstore
0x20
add
0x01
dup3
add
0x10
dup1
0x20
mul
mload(0x40)
swap1
dup2
add
0x40
mstore
dup1
swap3
swap2
swap1
dup3
0x10
dup1
iszero
tag_49
jumpi
0x20
mul
dup3
add
swap2
0x00
swap1
tag_50:
dup3
dup3
swap1
sload
swap1
0x0100
exp
swap1
div
0xff
and
0xff
and
dup2
mstore
0x20
add
swap1
0x01
add
swap1
0x20
dup3
0x00
add
div
swap3
dup4
add
swap3
0x01
sub
dup3
mul
swap2
pop
dup1
dup5
gt
tag_50
jumpi
swap1
pop
さて、アセンブリを眺めると、先頭から少ししたところに、なにやら 32 bit の値をいじくるコードが見受けられます…。
0xffffffff
and
0xffffffff
and
0xffffffff
そして、その少し下のあたりから、16 bit の値をこねくるコードが、1、2、3、4… 合計で6ブロック出現します。
0xffff
and
0xffff
and
0xffff
…この並びは、構造体Cのメンバー変数の型の並びと合致します。
となると、下記のコードは uint8[16] arrDNA の値を処理するループ部であることがわかります。
tag_50:
dup3
dup3
swap1
sload
swap1
0x0100
exp
swap1
div
0xff
and
0xff
and
なんということでしょう、構造体のデータを memory 変数として読み込む際は、構造体のメンバーが個別に読み込まれているではありませんか!
そして、memory 変数をストレージデータへ書き戻す下記のコードでも、同様の処理が行われています(アセンブリコードの全体はこちら)。
arrData[_at] = memoryData;
これで謎は解けました。
memory 変数に構造体を取り出す & 元のデータに書き戻す際の、メンバー変数の読み書きのオーバーヘッドにより、構造体C に対する処理のガス消費量が跳ね上がっていた訳です。
memory 変数経由で構造体の値を更新しようと思った際は、メンバー変数ごとにメモリへの読み込み & ストレージへの書き戻しが行われ、バカにならないレベルでガスを浪費させてしまうことを、肝に命じておきましょう。
まとめ
今回、3つの構造体でテストしてみました。
まず、構造体Aと構造体Bはメンバー変数の数が同じでワードサイズが1と4という違いによる比較でした。結果、ワードサイズの大きい構造体Bのほうがコスト(ガス消費量)が多くなりましたが、これは、予想通りといえるでしょう。
一方で、メンバー変数の数による比較では劇的な差が出ました。
23個のケース(構造体C)では memory 変数経由の処理が惨敗でしたが、4つのケース(構造体A/B)では、各種の値がほぼほぼ均衡した結果となりました。これは憶測ですが、メンバー変数が3つ/2つの場合、もしくは、代入以外の脂っこい処理が書き加わった状況であれば、memory 経由の処理の方が、コスト的に優位になってくると思います。
さて、ガスの消費は手数料に直結します。
だからこそ storage vs memory の悩みはつきそうにありません。
どちらを選択するか迷った時は、多少面倒でも2通りの処理を書いてみて、イーサスキャンで比較してみましょう。これが一番手っ取り早くて確実な答えを与えてくれるはずです。