Solidity IRベースのCodegenの変更点

Solidityは、2つの異なる方法でEVMバイトコードを生成できます。 Solidityから直接EVMのオペコードを生成する方法「old codegen」と、Yulの中間表現「IR」を介して生成する方法(「new codegen」または「IR-based codegen」)です。

IRベースのコードジェネレーターを導入したのは、コード生成の透明性や監査性を高めるだけでなく、関数を跨いだより強力な最適化パスを可能にすることを目的としています。

コマンドラインで --via-ir を使って有効にしたり、スタンダードJSONで {"viaIR": true} オプションを使って有効にできますので、ぜひ皆さんに試していただきたいと思います。

いくつかの理由により、従来のコードジェネレーターとIRベースのコードジェネレーターの間にはわずかなセマンティックな違いがありますが、そのほとんどは、いずれにしても人々がこの動作に頼ることはないだろうと思われる領域です。 このセクションでは、旧来のコードジェネレーターとIRベースのコードジェネレーターの主な違いを紹介します。

セマンティックのみの変更

このセクションでは、セマンティックのみの変更点をリストアップしています。 そのため、既存のコードの中に新しい、あるいは異なる動作が隠されている可能性があります。

  • 継承した場合の状態変数の初期化の順序が変更されました。

    以前の順序:

    • すべての状態変数は、最初にゼロ初期化されます。

    • ベースコンストラクタの引数を、最も派生したものから最もベースとなるコントラクトまで評価します。

    • 最も基本的なものから最も派生的なものまで、継承階層全体のすべての状態変数を初期化します。

    • 最も基本的なものから最も派生したものまで、線形化された階層内のすべてのコントラクトについて、コンストラクタが存在する場合はそれを実行します。

    新しい順序:

    • すべての状態変数が最初にゼロ初期化されます。

    • ベースコンストラクタの引数を、最も派生したコントラクトから最もベースとなるコントラクトまで評価します。

    • 線形化された階層で、最も基本から最も派生した順に、すべてのコントラクトについて:

      1. 状態変数を初期化します。

      2. コンストラクタを実行します(存在する場合)。

    このため、コントラクトで状態変数の初期値が異なる場合があります。 変数は、別のコントラクトのコンストラクタの結果に依存しています:

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.7.1;
    
    contract A {
        uint x;
        constructor() {
            x = 42;
        }
        function f() public view returns(uint256) {
            return x;
        }
    }
    contract B is A {
        uint public y = f();
    }
    

    これは、最初に状態変数を初期化することに起因しています: まず、 x は0に設定され、 y を初期化する際に、 f() は0を返し、 y も0になります。 新しいルールでは、 y は 42 に設定されます。 まず x を 0 に初期化し、次に A のコンストラクタをコールして x を 42 に設定します。 最後に y を初期化する際に f() が 42 を返すので y は 42 になります。

  • ストレージ構造体が削除されると、その構造体のメンバーを含むすべてのストレージスロットが完全にゼロになります。 以前は、パディングスペースはそのまま残されていました。 そのため、構造体内のパディングスペースがデータの保存に使用されている場合(コントラクトのアップグレードなど)、 delete では追加されたメンバーもクリアされてしまうことに注意する必要があります(以前はクリアされませんでしたが)。

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.7.1;
    
    contract C {
        struct S {
            uint64 y;
            uint64 z;
        }
        S s;
        function f() public {
            // ...
            delete s;
            // s occupies only first 16 bytes of the 32 bytes slot
            // delete will write zero to the full slot
        }
    }
    

    暗黙の削除は、例えば構造体の配列が短くなったときにも同じ動作をします。

  • 関数モディファイアは、関数のパラメータとリターン変数に関して、若干異なる方法で実装されています。 これは特に、プレースホルダー _; がモディファイアの中で複数回評価される場合に影響を及ぼします。 古いコードジェネレーターでは、各関数パラメータとリターン変数はスタック上に固定されたスロットを持っています。 もし _; が複数回使われたり、ループ内で使われたりして関数が複数回実行されると、関数パラメータの値やリターン変数の値の変化は、関数の次の実行で見えるようになります。 新しいコードジェネレータでは、実際の関数を使用してモディファイアを実装し、関数パラメータを渡します。 つまり、関数本体を複数回評価しても、パラメータは同じ値になり、リターン変数は実行ごとにデフォルト(ゼロ)値にリセットされるという効果があります。

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.7.0;
    contract C {
        function f(uint a) public pure mod() returns (uint r) {
            r = a++;
        }
        modifier mod() { _; _; }
    }
    

    古いコードジェネレータで f(0) を実行すると 1 が返され、新しいコードジェネレータを使うと 0 が返されます。

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.7.1 <0.9.0;
    
    contract C {
        bool active = true;
        modifier mod()
        {
            _;
            active = false;
            _;
        }
        function foo() external mod() returns (uint ret)
        {
            if (active)
                ret = 1; // Same as ``return 1``
        }
    }
    

    関数 C.foo() は以下の値を返します:

    • 古いコードジェネレータ: 戻り値の変数である 1 は、最初の _; 評価の前に一度だけ 0 に初期化され、その後 return 1; によって上書きされます。 2回目の _; 評価では再び初期化されず、 foo() も明示的に代入しないので( active == false のため)、最初の値を保持します。

    • 新しいコードジェネレータ: 0 は、リターンパラメータを含むすべてのパラメータが、各 _; 評価の前に再初期化されるからです。

  • 旧コードジェネレータの場合、式の評価順は不定です。 新しいコードジェネレータでは、ソース順(左から右)に評価するようにしていますが、保証はしません。 このため、意味上の差異が生じることがあります。

    例えば、以下のようなものです:

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.8.1;
    contract C {
        function preincr_u8(uint8 a) public pure returns (uint8) {
            return ++a + a;
        }
    }
    

    関数 preincr_u8(1) は、以下の値を返します: - 古いコード生成器: 3 (1 + 2)。ただし、一般に戻り値は不定です。 - 新しいコードジェネレーター: 4 (2 + 2)。ただし、戻り値は保証されません。

    一方、関数の引数の式は、グローバル関数 addmodmulmod を除いて、両方のコードジェネレータで同じ順序で評価されます。 例えば、以下のようになります:

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.8.1;
    contract C {
        function add(uint8 a, uint8 b) public pure returns (uint8) {
            return a + b;
        }
        function g(uint8 a, uint8 b) public pure returns (uint8) {
            return add(++a + ++b, a + b);
        }
    }
    

    関数 g(1, 2) は以下の値を返します:

    • 古いコードジェネレータ: 10 (add(2 + 3, 2 + 3))。ただし、一般に戻り値は不特定です。

    • 新しいコードジェネレーター: 10 。ただし、戻り値は保証されません。

    グローバル関数 addmodmulmod の引数は、古いコードジェネレータでは右から左に、新しいコードジェネレータでは左から右に評価されます。 例えば、以下のようになります:

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.8.1;
    contract C {
        function f() public pure returns (uint256 aMod, uint256 mMod) {
            uint256 x = 3;
            // Old code gen: add/mulmod(5, 4, 3)
            // New code gen: add/mulmod(4, 5, 5)
            aMod = addmod(++x, ++x, x);
            mMod = mulmod(++x, ++x, x);
        }
    }
    

    関数 f() は以下の値を返します:

    • 旧コードジェネレーター: aMod = 0mMod = 2 です。

    • 新しいコードジェネレーター: aMod = 4mMod = 0 です。

  • 新しいコードジェネレーターでは、空きメモリポインタの上限が type(uint64).max (0xffffffffffff) に設定されました。 この制限を越えて値を増やすような割り当ては、リバートされます。 古いコードジェネレーターには、この制限はありません。

    例えば:

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >0.8.0;
    contract C {
        function f() public {
            uint[] memory arr;
            // allocation size: 576460752303423481
            // assumes freeMemPtr points to 0x80 initially
            uint solYulMaxAllocationBeforeMemPtrOverflow = (type(uint64).max - 0x80 - 31) / 32;
            // freeMemPtr overflows UINT64_MAX
            arr = new uint[](solYulMaxAllocationBeforeMemPtrOverflow);
        }
    }
    

    関数 f() は以下のような挙動をします: - 古いコードジェネレータ: 大きなメモリ割り当ての後、配列の内容をゼロにするときにガス欠になります。 - 新しいコードジェネレータ: フリーメモリポインタのオーバーフローによりリバートします(ガス欠はしない)。

内部構造

内部の関数ポインタ

古いコードジェネレータは、内部の関数ポインタの値にコードオフセットまたはタグを使用しています。 特に、これらのオフセットは構築時とデプロイ後では異なり、値はストレージを介してこの境界を越えることができるので、これは複雑です。 そのため、構築時には両方のオフセットが同じ値に(異なるバイトに)エンコードされます。

新しいコードジェネレータでは、関数ポインタは、順番に割り当てられる内部IDを使用します。 ジャンプによる呼び出しができないため、関数ポインタによる呼び出しは、常に switch 文を使って正しい関数を選択する内部ディスパッチ関数を使用する必要があります。

ID 0 は、初期化されていない関数ポインタ用に予約されており、このポインタが呼び出されると、ディスパッチ関数でパニックが発生します。

古いコードジェネレータでは、内部関数ポインタは、常にパニックを起こす特別な関数で初期化されます。 このため、ストレージ内の内部関数ポインタの構築時にストレージへの書き込みが発生します。

クリーンアップ

古いコードジェネレータは、ダーティビットの値によって結果が影響を受ける可能性のある操作の前にのみ、クリーンアップを行います。 新しいコードジェネレータでは、ダーティビットが発生する可能性のある操作の後にクリーンアップを行います。 オプティマイザが強力になり、冗長なクリーンアップ処理がなくなることを期待しています。

例えば、以下のようになります。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.1;
contract C {
    function f(uint8 a) public pure returns (uint r1, uint r2)
    {
        a = ~a;
        assembly {
            r1 := a
        }
        r2 = a;
    }
}

関数 f(1) は以下の値を返します。

  • 古いコードジェネレータ: ( fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe , 00000000000000000000000000000000000000000000000000000000000000fe )

  • 新しいコードジェネレータ: ( 00000000000000000000000000000000000000000000000000000000000000fe , 00000000000000000000000000000000000000000000000000000000000000fe )

なお、新コードジェネレータとは異なり、旧コードジェネレータでは、ビットの否定(not)の割り当て( a = ~a )の後にクリーンアップを行いません。 このため、新旧のコードジェネレータでは、インラインアセンブリブロック内で戻り値 r1 に割り当てられる値が異なります。 しかし、どちらのコードジェネレータも、 a の新しい値が r2 に割り当てられる前に、クリーンアップを実行します。