Web 開発で「この関数の実行コストは何円か」を意識することは、まずありません。スマートコントラクトではそれが一次的な設計制約になります。

この章では、素朴に書いた uint8[9] の盤面をビットマスクに置き換えます。ただし最初に言っておくと、最適化は測ってからやることです。推測でやると、読みにくくして遅くするだけに終わります。

まず測る

forge test --gas-report
| Function Name | Min   | Avg    | Median | Max    |
| createGame    | 21906 | 114165 | 114825 | 115065 |
| joinGame      | 26469 |  38787 |  38896 |  38908 |
| play          | 28502 |  42535 |  39408 |  91978 |
| withdraw      | 23534 |  27905 |  30091 |  30091 |
| claimTimeout  | 28357 |  53527 |  66110 |  66114 |

createGame が突出しています。新しい Game 構造体を配列に push する = 未使用のストレージスロットを 3 つ新規に埋めるからです。

料金表を頭に入れておくと、この数字が読めるようになります。

操作ガス
トランザクションの基本料金21,000
未使用スロットへの書き込み(0 → 非 0)20,000
既存スロットの更新(非 0 → 非 0)2,900
スロットの読み出し(初回・cold)2,100
スロットの読み出し(2 回目以降・warm)100
メモリ・演算3 前後

この表は Berlin(EIP-2929)以降の値で、Cancun・Prague・Fusaka を通して変わっていません。ただし将来の Glamsterdam で基本料金が 12,000 に下がる提案(EIP-2780)があるので、時点の値として読んでください。

そして加算に注意してください。スロットに初めて触るときは、書き込み料金に cold 読み出しの 2,100 が乗ります。だから 0 → 非 0 の新規書き込みは実質 22,100、既存スロットの更新は 5,000 です。同じトランザクション内で 2 回目以降に触る場合は加算されません。

桁が 3 つ半違います。 ストレージ 1 スロットの新規書き込み(20,000)は、算術演算 6,600 回分に相当します。

だから最適化の方針は 1 つに絞られます。計算を減らすのではなく、ストレージへの出入りを減らす。

実測: 勝敗判定の 2 通り

素朴な実装はこうでした。

uint8[9] public cells;

function hasWon(uint8 mark) external view returns (bool) {
    uint8[3][8] memory lines = [[uint8(0),1,2], /* ... 8 本 ... */];
    for (uint256 i = 0; i < 8; ++i) {
        if (cells[lines[i][0]] == mark && cells[lines[i][1]] == mark && cells[lines[i][2]] == mark) return true;
    }
    return false;
}

ビットマスク版はこうです。プレイヤーごとに 9 ビットのマスクを持ちます。

uint16 public board;   // ビット i = マス i は自分のもの

// src/Board.sol
function lines() internal pure returns (uint16[8] memory) {
    return [uint16(0x007), 0x038, 0x1C0, 0x049, 0x092, 0x124, 0x111, 0x054];
}

function isWin(uint16 board) internal pure returns (bool) {
    uint16[8] memory all = lines();
    for (uint256 i = 0; i < 8; ++i) {
        if (board & all[i] == all[i]) return true;
    }
    return false;
}

8 本の勝利ラインも 9 ビットのマスクにしておき、& で包含判定するだけです。

ベンチマークを書いて測りました。

ガス
配列版 hasWon4,156
ビットマスク版 isWin2,854

31% 削減(勝ちラインが 1 本目で見つかる場合。勝ちがなく 8 本すべてを走査すると 6,886 → 4,046 で 41%)。

ただし差の出どころは、直感とは違いました。走査表をやめて if を並べた配列版を作って測ると 2,840 ガスで、ビットマスク版とほぼ同じになります。つまり差の大半は「パックされたバイトへのアクセス」ではなく、配列版が uint8[3][8] の走査表を毎回メモリに組み立てていることでした。ストレージ読み出しの差は 200 ガス程度しかありません(9 個の uint8 は同じスロットにいるので、cold なのは 1 回目だけです)。

ビットマスク版も走査表を組み立てています。 lines() は呼ばれるたびに uint16[8] をメモリへ作り直します。それでも安いのは、要素が 24 個から 8 個に減り、各要素の比較が 3 回から 1 回になったからです。「ストレージよりメモリが安い」ではなく、触る量そのものが減ったのが効いています。

マスクの作り方

盤面のマス番号とビット位置を一致させます。左上が 0、右下が 8。

 0 | 1 | 2          bit0 | bit1 | bit2
---+---+---         -----+------+-----
 3 | 4 | 5    →     bit3 | bit4 | bit5
---+---+---         -----+------+-----
 6 | 7 | 8          bit6 | bit7 | bit8

勝利ライン 8 本を 16 進で書き下します。

0x007   // 0,1,2 → 0b000000111  上の横
0x038   // 3,4,5 → 0b000111000  中の横
0x1C0   // 6,7,8 → 0b111000000  下の横
0x049   // 0,3,6 → 1 + 8 + 64   左の縦
0x092   // 1,4,7 → 2 + 16 + 128 中の縦
0x124   // 2,5,8 → 4 + 32 + 256 右の縦
0x111   // 0,4,8 → 1 + 16 + 256 斜め
0x054   // 2,4,6 → 4 + 16 + 64  逆斜め

chisel(Foundry の REPL)で確かめられます。

➜ uint16 top = 0x007; uint16 mine = 0x007;
➜ mine & top == top
true

空きマスは 2 つのマスクの論理和の補集合です。

function emptyMask(uint16 a, uint16 b) internal pure returns (uint16) {
    return FULL & ~(a | b);   // FULL = 0x1FF
}

この 1 行が、配列版なら 9 回のループになります。

構造体のパッキング

もう一つ大きいのが、構造体を 32 バイト境界に詰めることです。

ストレージは 32 バイトのスロットが並んだものです。小さい型を隣り合わせに宣言すると、コンパイラが同じスロットに詰めてくれます。逆に、宣言順が悪いと詰められません。

本書の Game はこうなっています。

struct Game {
    address playerX;    // 20 バイト ┐ スロット 0(ちょうど 32)
    uint96  stake;      // 12 バイト ┘

    address playerO;    // 20 バイト ┐
    uint64  deadline;   //  8 バイト ├ スロット 1(ちょうど 32)
    uint32  timeout;    //  4 バイト ┘

    address winner;     // 20 バイト ┐
    uint16  boardX;     //  2 バイト │
    uint16  boardO;     //  2 バイト ├ スロット 2(27 バイト)
    uint8   moves;      //  1 バイト │
    Status  status;     //  1 バイト │
    Outcome outcome;    //  1 バイト ┘
}

3 スロットです。型の幅は、この境界に合わせて選んでいます。

  • stakeuint96 にしたのは、address(20 バイト)と足して 32 になるから
  • timeoutuint32 にしたのは、address + uint64 の残りが 4 バイトだから
  • 盤面 2 つが 4 バイトしかないので、winner と同じスロットに同居できる

もし宣言順をバラバラにすると、同じ情報が 5 スロットに膨らみます(11 個のメンバーの並べ方 554,400 通りを総当たりした結果、最大は 5 でした)。新規書き込み 1 スロット = 20,000 ガスなので、並べ替えるだけで数万ガス変わります

配置は forge inspect で確認できます。

forge inspect TicTacToe storageLayout

ここで重要な注意があります。 固定長配列を構造体のメンバにすると、必ず新しいスロットから始まり、他のメンバと同居できません。つまり uint8[9] board; を構造体に入れると、それだけで 1 スロットを専有し、後続のメンバもまた別のスロットから始まります。ビットマスクにした本当の利得は、勝敗判定の 31% よりむしろこのパッキングの自由度のほうにあります。

保存しない、という選択

一番安い最適化は、そもそも書かないことです。

前章までで、いくつか意図的に省いています。

  • 手番moves % 2 から導出。1 バイトも使わない
  • 空きマス数9 - moves で分かる
  • プレイヤーの戦績 — イベントから復元できる。オンチェーンの索引を持たない

最後のものは第 16 章で扱うトレードオフです。mapping(address => uint256[]) gamesOf を持てば履歴取得は 1 回の eth_call で済みますが、ゲームを 1 件作るたびに配列 push で 20,000 ガス超かかります。読む側の便利さを、書く側の全員が負担する構図です。

イベントは LOG 命令そのものが 375 ガス、トピック 1 つにつき 375 ガス、データ 1 バイトにつき 8 ガスです。非匿名イベントは topic0(シグネチャのハッシュ)で 1 枠を必ず使うので、引数なしでも 750 ガス、indexed を 1 つ付けて 32 バイト載せると約 1,260 ガス。それでも新規ストレージ 1 スロット(22,100)の 20 分の 1 以下なので、「後から知りたいだけ」の情報はイベントに寄せます。

やらなかった最適化

いくつか、あえて見送っています。

via_ir = true — Solidity の IR ベース最適化を有効にすると数 % 縮むことがありますが、コンパイルが目に見えて遅くなります。本書の規模では割に合いません。

アセンブリassembly { } でストレージを直接叩けば削れますが、可読性と安全性を大きく損ないます。お金を扱うコードでこれをやる前に、削った額とバグのコストを比べてください。

ループの展開 — 8 本の勝利ラインを if 8 個に展開すると、Board.isWin 単体では 2,854 → 2,487 ガス(−367)です。ただし呼ばれる回数で効き方がまったく変わります。対人戦の play では −1,447 ガス(−3.7%)ですが、CPU 戦は isWin を何千回も呼ぶので play の平均が 71,994 → 42,395 ガス(−41%) まで落ちます。本書では読みやすさを取りましたが、CPU 戦を本気で安くするならここが最初の一手です。

判断の基準は「その最適化が、いくら節約して、いくら読みにくくするか」です。対人戦の play で 1,400 ガスほどのために勝敗判定を目視で追えなくするのは割に合いませんが、CPU 戦なら 4 割減るので話が変わります。同じ最適化でも、どこで効くかを測ってから決めるということです。

L2 では話が変わる

Base のような L2 では、手数料が 2 段構えです。

  • 実行費 — L2 上での計算とストレージ
  • L1 データ費 — トランザクションを L1 に載せる費用。EIP-4844 以降は calldata ではなく blob に載るので、blob の基本手数料で値段が決まる

かつては L1 データ費が支配的で、「L2 では計算量より calldata のサイズ」と言われていました。blob が使えるようになってから逆転しています。 実測すると、Base メインネットの直近ブロックでは L1 データ費は手数料総額の 0.2% 程度でした。

つまり いまは L1 でも L2 でも、まず実行ガスを見るのが正解です。比率は receipt の l1FeegasUsed × effectiveGasPrice を比べれば自分で確かめられます。blob の需要が高い時期には比率が跳ね上がるので、思い込まずに測ってください。

本書の play(uint256 gameId, uint8 cell) は calldata が 4 + 32 + 32 = 68 バイト。gameIduint256 から uint32 に変えても、ABI エンコーディングが 32 バイト境界に揃えるのでcalldata は縮みません。本気で削るなら関数セレクタと引数のパッキングを自前でやることになりますが、それは可読性を捨てる話です。

デプロイ先を決めてから最適化する。 L1 向けの努力が L2 ではほぼ無意味、ということが普通に起きます。

まとめ

  • 料金表を覚える。ストレージ書き込み 20,000、演算 3。桁が 4 つ違う
  • 最適化は測ってからやるforge test --gas-report と、必要ならベンチ用の小さなコントラクト
  • ビットマスクは勝敗判定を 31% 安くした。それ以上に構造体パッキングの自由度が効く
  • 宣言順を 32 バイト境界に合わせる。並べ替えるだけで数万ガス変わる
  • 導出できるものは保存しない
  • 可読性を捨てる最適化は、削った額と釣り合うか確認してから
  • L2 では calldata のサイズが支配的。最適化の指針が変わる

次章は、ここまでのコードをどう検証するかです。まるばつは小さいので、「テストする」を超えて「証明する」ができます。