Appearance
L2-8: Gas Golf(ガス最適化)
1. 課題
イーサリアム上では、すべてのトランザクション実行にガス(gwei で価格付けされた EVM 計算リソース)が消費されます。最適化されていないコントラクトは 200,000 ガスを消費する可能性がありますが、専門的な最適化後はわずか 80,000 ガスで済むことがあります。2.5 倍以上の差が直接ユーザーの取引コストに跳ね返ります。DEX スワップや NFT ミントなどの高頻度呼び出しシナリオでは、ガス最適化は「あったら良いもの」ではなく、製品競争力の中核です。
このチャレンジ(src/level2/GasGolf.sol を参照)では、複数のガス最適化技術を体系的に習得し、比較関数を通じて各技術のガス節約効果を視覚的に示すことが求められます。チャレンジは 6 つの主要な技術次元をカバーします:immutable/constant vs storage、変数パッキング、calldata vs memory、unchecked 算術、カスタムエラー vs require 文字列、短絡評価です。
2. 理由
ガス最適化の本質は、EVM オペコードレベルのコスト制御です。各 SSTORE(ストレージ書き込み)は 20,000 ガス(コールド書き込み)を消費しますが、immutable 変数はコンストラクタで 1 回だけ書き込まれ、その後の読み取りは約 5 ガスです。この 4,000 倍の差は、データストレージメカニズムの理解があらゆるコードテクニックよりも重要であることを示しています。
Gas Golf は単にガスを節約する方法を教えるだけでなく、「ガスの代償とは何か」を教えます。例えば、calldata の非ゼロバイトは 16 ガス、ゼロバイトは 4 ガスかかります。これは address(0) がパラメータとしてむしろ安価である理由や、パラメータ型のサイズを圧縮する(uint256 → uint128)ことでガスが節約できる理由を説明します。同様に、unchecked ブロックは各算術演算のオーバーフローチェックオペコード(ADD vs unchecked_add)を省き、ループ累積シナリオで特に顕著なガス節約効果をもたらします。
さらに重要なのは、Gas Golf が「ガスを意識した」プログラミング習慣を育成することです。Solidity コードの各行を書くときに、「この行はどれだけのガスがかかるか?もっと安い方法はないか?」と無意識に考えるようになります。これが「動けばいい」から「プロフェッショナルなコントラクト開発」への質的飛躍です。
3. 解決策
GasGolf コントラクト(src/level2/GasGolf.sol を参照)は 6 つのガス最適化技術の比較実装を示します:
技術 1:immutable と constant vs storage
solidity
uint256 public constant MAX_SUPPLY = 10_000; // コンパイル時インライン、~5 gas/read
uint256 public immutable i_ownerFee; // 構築時設定、~5 gas/read
uint256 public storageFee = 100; // SSTORE ストレージ、2100+ gas/read
function readImmutable() public view returns (uint256) {
return i_ownerFee + MAX_SUPPLY; // 両方とも ~5 gas
}
function readStorage() public view returns (uint256) {
return storageFee + MAX_SUPPLY; // storageFee は SLOAD が必要(2100 gas)
}
原理:constant はコンパイル時に直接リテラルに置き換えられ(バイトコードにインライン化)、immutable はコントラクトデプロイ時にコントラクトコードの特別なセクションに書き込まれ(同様に約 5 gas の読み取り)、storage 変数は SLOAD オペコードの実行が必要です(コールド読み取り 2100 gas)。
技術 2:変数パッキング
solidity
struct PackedData {
uint128 valueA;
uint128 valueB;
} // 2 つの uint128 が 1 つの 32 バイトスロットを共有 → 1 回の SSTORE
struct UnpackedData {
uint256 valueA;
uint256 valueB;
} // 2 つの独立したスロット → 2 回の SSTORE
function writePacked(uint128 _a, uint128 _b) public {
packed = PackedData(_a, _b); // 1 × 20000 gas(コールド)
}
function writeUnpacked(uint256 _a, uint256 _b) public {
unpacked = UnpackedData(_a, _b); // 2 × 20000 gas(コールド)
}
原理:EVM のストレージスロットは 32 バイト(256 ビット)でアライメントされています。2 つの uint128(各 16 バイト)は同じ 32 バイトスロットに並べて配置でき、EVM コンパイラがそれらを 1 回の SSTORE にマージします。2 つの uint256 はそれぞれスロットを占有し、2 回の SSTORE が必要です。
技術 3:calldata vs memory
solidity
function sumCalldata(uint256[] calldata _data) public pure returns (uint256) {
uint256 total;
for (uint256 i; i < _data.length; ++i) {
total += _data[i]; // calldata から直接読み取り
}
return total;
}
function sumMemory(uint256[] memory _data) public pure returns (uint256) {
uint256 total;
for (uint256 i; i < _data.length; ++i) {
total += _data[i]; // memory から読み取り(事前コピーが必要)
}
return total;
}
原理:memory パラメータは関数のエントリ時点で calldata から memory に完全にコピーされます。配列が大きいほどコピーコストが高くなります。calldata は読み取り専用で、トランザクションの元の入力データを指し、コピーは一切不要です。
技術 4:unchecked 算術
solidity
function sumUnchecked(uint256[] calldata _data) public pure returns (uint256) {
uint256 total;
unchecked {
for (uint256 i; i < _data.length; ++i) {
total += _data[i]; // オーバーフローチェックをスキップ
}
}
return total;
}
原理:Solidity 0.8 以降は、デフォルトで各 +、-、* 演算にオーバーフローチェックを挿入します。ループ内では unchecked でラップしてこれらのチェックをスキップできます。ただし、オーバーフローが絶対に発生しないと確信できる場合に限ります(例:i が 2^256 に達することはありません)。
技術 5:カスタムエラー vs require 文字列
solidity
error InvalidValue(uint256 value);
function validateWithCustomError(uint256 _value) public pure returns (bool) {
if (_value == 0) revert InvalidValue(_value); // 4 バイトセレクタのみ
return true;
}
function validateWithRequire(uint256 _value) public pure returns (bool) {
require(_value != 0, "Value cannot be zero"); // 完全な文字列がバイトコードに保存
return true;
}
原理:require のエラー文字列はコントラクトのバイトコードに完全に保存され(各文字が 1 バイト、長いメッセージは数百バイトになることも)、revert 時にすべてが呼び出し元に返されます。カスタムエラーは 4 バイトの関数セレクタ + ABI エンコードされたパラメータのみを保存し、バイトコードサイズと返却データ量を大幅に削減します。
技術 6:短絡評価
solidity
// 安価なもの(constant 読み取り)を先に、高価なもの(storage 読み取り)を後に
function checkShortCircuit(address _addr, uint256 _amount) public view returns (bool) {
if (_amount <= MAX_SUPPLY && storageFee > 0 && _addr != address(0)) {
return true;
}
return false;
}
// 悪い習慣:storage を先に読む
function checkNoShortCircuit(address _addr, uint256 _amount) public view returns (bool) {
if (storageFee > 0 && _amount <= MAX_SUPPLY && _addr != address(0)) {
return true;
}
return false;
}
原理:Solidity の && は短絡演算子です。最初の条件が false の場合、後続の条件は評価されません。最も安価な条件(constant 読み取り、address(0) 比較)を最初に置き、最も高価な条件(storage 読み取り)を最後に置きます。
4. 遭遇した落とし穴
- constant は immutable と等しくない:
constantに割り当てる値はコンパイル時に決定できる必要があり(コンストラクタパラメータに依存できない)、そうでなければコンパイルに失敗します。一方、immutableはコンストラクタで代入できます - パックされた構造体の読み取りコスト:パッキングは書き込みガスを節約しますが、個々のフィールドを読み取る際には追加のシフト操作(マスク + シフト)が必要になり、シナリオによっては読み取りコストが増加する可能性があります
- unchecked によるサイレントオーバーフロー:
uncheckedブロック内でオーバーフローが発生した場合、revert せずに静かにラップアラウンドします。コントラクトが誤った状態で実行を継続する可能性があります - calldata 配列は変更不可:calldata 配列に対して書き込み操作(例:
arr[0] = 5)を試みるとコンパイルに失敗します。これは読み取り専用データへのポインタであるためです - 非ループシナリオでの
uncheckedの過剰使用:単一のa + b演算では、節約されるガスはごくわずか(約 3〜5 gas)ですが、導入されるオーバーフローリスクは大きくなる可能性があります
5. 落とし穴の原因
constant と immutable の根本的な違いは代入のタイミングにあります。constant はコンパイル時に評価され、コンパイル時定数式でなければなりません。immutable は構築時に評価され、コンストラクタパラメータに依存できます。コンパイル時評価とは、constant 変数がバイトコード内で直接リテラルに置き換えられることを意味します。構築時評価とは、immutable 変数がコントラクトコードセグメント(ROM のようなもの)に書き込まれ、その後変更できないことを意味します。したがって、constant は address(this) や block.chainid のような実行時にしか決まらない値にはできません。
パックされた構造体の読み取りオーバーヘッドは、EVM のワードサイズ(32 バイト)に起因します。同じスロットにパックされた uint128 を読み取る場合、EVM はまず 32 バイト全体を読み取り(SLOAD)、次にマスクを使用して下位 128 ビットを抽出し、右シフトで上位 128 ビットを抽出します。SLOAD のコストは AND/SHR 操作よりはるかに高いですが、パックされた変数を頻繁に読み取り、かつ書き込みが一切ない場合、パッキングは利益をもたらしません。
unchecked のオーバーフローが revert しない理由は、Solidity コンパイラが unchecked 内で EVM の ADD オペコード(自然なオーバーフローラップアラウンド動作を持つ)を直接使用し、ADD + オーバーフローチェックの組み合わせを使用しないためです。これは意図的なパフォーマンス設計ですが、開発者が自身で安全性を保証する必要があります。
6. 落とし穴の解決方法
構築時に決まる固定値(オーナー手数料率など)には immutable を、完全にコンパイル時に決まる値(固定供給上限など)には constant を使用します。宣言構文が異なります:
solidity
uint256 public constant MAX_SUPPLY = 10_000; // リテラル
uint256 public immutable i_ownerFee; // コンストラクタで代入
constructor(uint256 _ownerFee) { i_ownerFee = _ownerFee; }
構造体をパックする際は、読み取り/書き込み頻度を天秤にかけます。構造体の個々のフィールドが頻繁に読み取られる場合、パッキングする価値があるか検討します。バッチ初期化後にたまに読み取るだけなら、パッキングは常に得策です。memory キャッシュを使用して読み取りコストを償却できます:
solidity
function readPacked() public view returns (uint128 a, uint128 b) {
PackedData memory p = packed; // 1 回の SLOAD、その後は memory で操作
return (p.valueA, p.valueB);
}
unchecked ブロック内では、コメントで安全性の理由を注釈し、Foundry のファズテストを使用して境界条件がオーバーフローしないことを検証します。ループカウンタについてはほぼ常に安全ですが(2^256 に達することはありえない)、アキュムレータについては最大入力がオーバーフローしないことを確認する必要があります。
変更が本当に必要な場合を除き、外部関数の参照型パラメータには常に calldata を使用します。変更が必要なシナリオでは、関数内で memory コピーを作成してから変更します。
7. 技術的要点
| 要点 | 説明 |
|---|---|
| SSTORE(コールド 0→非0) | 20,000 gas -- 初回書き込みの最高コスト |
| SSTORE(ウォーム) | 5,000 gas -- 非ゼロから非ゼロへの変更 |
| SLOAD(コールド) | 2,100 gas -- 初回読み取り |
| immutable/constant | ~5 gas -- バイトコードにインライン化、ストレージをバイパス |
| calldata 非ゼロバイトあたり | 16 gas |
| calldata ゼロバイトあたり | 4 gas |
| 変数パッキング | 2 つの uint128 = 1 つの 32 バイトスロット → 1 回の SSTORE |
| unchecked 算術 | ADD/MUL/SUB のオーバーフローチェックオペコードをスキップ |
| カスタムエラー | 4 バイトセレクタ vs require 文字列の完全なテキスト |
| 短絡評価 | && と ` |