2
0

More than 3 years have passed since last update.

【 storage vs memory 】イーサリアム上でのガス消費量の比較

Last updated at Posted at 2020-03-12

はじめに

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;
変数をstorageで取り出して直接いじるのは高くつく?
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ワードの構造体

構造体A:変数は4つ、サイズは1ワード(256bit)
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ワードの構造体

構造体B:変数は4つ、サイズは4ワード(1024bit)
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ワードの構造体

構造体C:変数は配列要素込みで23、サイズは1ワード(256bit)

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 経由で読み書きする裏側

私の認識では、memorystorage 間における構造体の読み書きは、256 bit のワード単位で行われると思っていました。メンバー変数がいくつ存在しても、構造体のサイズが1ワードに収まっているならば、1ワード分のコストで入出力されるのではないかと…。

しかし、この認識は誤っていたようです。
テスト結果から、メンバー変数が多いほどオーバヘッドがでているのは明らかです。

では、関数Cー5の内部では何が起こっているのでしょう?

メモリに構造体のデータを取り出している下記のコードを、アセンブリで確認してみましょう。

MyData memory memoryData = arrData[_at];

アセンブリ(クリックで開きます)
関数Cー5の抜粋〜memoryデータの取り出し部分〜
        /* "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)では、各種の値がほぼほぼ均衡した結果となりました。これは憶測ですが、メンバー変数が3つ/2つの場合、もしくは、代入以外の脂っこい処理が書き加わった状況であれば、memory 経由の処理の方が、コスト的に優位になってくると思います。

さて、ガスの消費は手数料に直結します。
だからこそ storage vs memory の悩みはつきそうにありません。

どちらを選択するか迷った時は、多少面倒でも2通りの処理を書いてみて、イーサスキャンで比較してみましょう。これが一番手っ取り早くて確実な答えを与えてくれるはずです。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0