Solidity IR 기반 코드 생성 변경

Solidity IR 기반 코드 생성 변경 (Solidity IR-based Codegen Changes)

Solidity는 EVM 바이트코드를 두 가지 방식으로 생성할 수 있어요. Solidity에서 직접 EVM opcode로("old codegen"), 또는 Yul의 중간 표현(IR, "new codegen" 또는 "IR-based codegen")을 거쳐서요. IR 기반 코드 생성기는 코드 생성을 더 투명하고 감사 가능하게 만들 뿐 아니라, 함수를 가로지르는 더 강력한 최적화 패스를 가능하게 하려는 목적으로 도입됐어요.

출처: 문서

본문

Solidity는 EVM 바이트코드를 두 가지 방식으로 생성할 수 있어요. Solidity에서 직접 EVM opcode로("old codegen"), 또는 Yul의 중간 표현(IR)을 통해("new codegen" 또는 "IR-based codegen")요. IR 기반 코드 생성기는 코드 생성을 더 투명하고 감사 가능하게 만들 뿐 아니라, 함수를 가로지르는 더 강력한 최적화 패스를 가능하게 하려는 목적으로 도입됐어요.

명령줄에서 --via-ir 옵션 또는 표준 JSON의 {"viaIR": true} 옵션으로 활성화할 수 있으며, 모두 한번 시도해 보길 권장해요!

여러 이유로 기존 코드와 IR 기반 코드 생성기 사이에 아주 작은 의미적(semantic) 차이가 있어요. 대부분 사람들이 이 동작에 의존하리라 기대하지 않는 영역이에요. 이 절은 기존 코드 생성기와 IR 기반 코드 생성기 사이의 주요 차이를 강조해요.

의미만 바뀐 변경 (Semantic Only Changes)

이 절은 의미적으로만 바뀌어 기존 코드에서 새롭고 다른 동작을 숨길 수 있는 변경을 나열해요.

  • 상속 시 상태 변수 초기화 순서가 바뀌었어요. 예전 순서는 다음과 같았어요: 모든 상태 변수를 시작 시 0으로 초기화. 기본 생성자 인자를 가장 파생된 컨트랙트에서 가장 기본 컨트랙트로 평가. 가장 기본에서 가장 파생으로 전체 상속 계층의 모든 상태 변수 초기화. 선형화된 계층의 모든 컨트랙트에 대해 가장 기본에서 가장 파생으로 생성자(있으면) 실행. 새 순서: 모든 상태 변수를 시작 시 0으로 초기화. 기본 생성자 인자를 가장 파생된 컨트랙트에서 가장 기본 컨트랙트로 평가. 선형화된 계층에서 가장 기본에서 가장 파생으로 각 컨트랙트에 대해: 상태 변수 초기화. 생성자(있으면) 실행. 이는 상태 변수의 초기 값이 다른 컨트랙트의 생성자 결과에 의존하는 컨트랙트에서 차이를 만들어요:

open in Remix

// 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();
}

이전에는 y가 0으로 설정됐어요. 먼저 상태 변수를 초기화했기 때문이에요. 먼저 x가 0으로 설정되고, y를 초기화할 때 f()가 0을 반환해 y도 0이 됐어요. 새 규칙에서는 y가 42로 설정돼요. 먼저 x를 0으로 초기화한 다음 A의 생성자를 호출해 x를 42로 설정해요. 마지막으로 y를 초기화할 때 f()가 42를 반환해 y가 42가 돼요.

  • 스토리지 구조체가 삭제될 때, 구조체 멤버를 포함하는 모든 스토리지 슬롯이 완전히 0으로 설정돼요. 이전에는 패딩 공간이 건드려지지 않았어요. 결과적으로 구조체 안의 패딩 공간이 데이터 저장에 사용된다면(예: 컨트랙트 업그레이드 맥락에서), delete가 이제 추가된 멤버도 지운다는 점을 알아야 해요(과거에는 지워지지 않았을 거예요).

open in Remix

// 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
    }
}

구조체 배열이 축소될 때의 암시적 삭제에도 같은 동작이 적용돼요.

  • 함수 수정자는 함수 매개변수와 반환 변수에 관해 약간 다른 방식으로 구현돼요. 이는 특히 수정자에서 플레이스홀더 _;가 여러 번 평가될 때 영향을 줘요. 기존 코드 생성기에서는 각 함수 매개변수와 반환 변수가 스택에 고정 슬롯을 가져요. _;가 여러 번 사용되거나 루프에서 사용돼 함수가 여러 번 실행되면, 함수 매개변수나 반환 변수 값의 변경이 함수의 다음 실행에서 보여요. 새 코드 생성기는 수정자를 실제 함수로 구현하고 함수 매개변수를 전달해요. 즉 함수 본문을 여러 번 평가해도 매개변수에 대해 같은 값을 얻고, 반환 변수에 대한 효과는 각 실행마다 기본(0) 값으로 리셋된다는 뜻이에요.

open in Remix

// 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을 반환해요.

open in Remix

// 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;로 덮어써지기 때문. 두 번째 _; 평가를 위해 다시 초기화되지 않고 foo()가 명시적으로 할당하지도 않으므로(active == false 때문에) 첫 번째 값을 유지해요.

  • 새 코드 생성기: 0. 반환 매개변수를 포함한 모든 매개변수가 각 _; 평가 전에 재초기화되기 때문.

  • 기존 코드 생성기의 경우 표현식의 평가 순서는 지정되지 않았어요. 새 코드 생성기의 경우 소스 순서(왼쪽에서 오른쪽)로 평가하려 하지만 보장하지 않아요. 이는 의미적 차이를 만들 수 있어요. 예:

open in Remix

// 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)이지만 반환 값은 보장되지 않음.

반면 함수 인자 표현식은 전역 함수 addmod와 mulmod를 제외하고 두 코드 생성기 모두 같은 순서로 평가돼요. 예:

open in Remix

// 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이지만 반환 값은 보장되지 않음.

전역 함수 addmod와 mulmod의 인자는 기존 코드 생성기에서는 오른쪽에서 왼쪽으로, 새 코드 생성기에서는 왼쪽에서 오른쪽으로 평가돼요. 예:

open in Remix

// 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 = 0, mMod = 2.

  • 새 코드 생성기: aMod = 4, mMod = 0.

  • 새 코드 생성기는 자유 메모리 포인터에 type(uint64).max(0xffffffffffffffff)의 하드 한도를 부과해요. 그 값이 이 한도를 넘어 증가하게 하는 할당은 revert해요. 기존 코드 생성기에는 이 한도가 없어요. 예:

open in Remix

// 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()는 다음과 같이 동작해요:

  • 기존 코드 생성기: 큰 메모리 할당 후 배열 내용을 0으로 만드는 동안 가스가 부족해져요.
  • 새 코드 생성기: 자유 메모리 포인터 오버플로우로 인해 revert해요(가스가 부족해지지 않음).

내부 구조 (Internals)

내부 함수 포인터 (Internal function pointers)

기존 코드 생성기는 내부 함수 포인터의 값에 코드 오프셋 또는 태그를 사용해요. 이 오프셋은 생성 시점과 배포 후에 다르고 값이 스토리지를 통해 이 경계를 넘을 수 있기 때문에 특히 복잡해요. 그 때문에 두 오프셋 모두 생성 시점에 같은 값으로(다른 바이트로) 인코딩돼요.

새 코드 생성기에서 함수 포인터는 순차적으로 할당되는 내부 ID를 사용해요. 점프로 호출하는 것이 불가능하므로, 함수 포인터를 통한 호출은 항상 switch 문으로 올바른 함수를 선택하는 내부 디스패치 함수를 사용해야 해요. ID 0은 초기화되지 않은 함수 포인터를 위해 예약되며, 호출 시 디스패치 함수에서 panic을 일으켜요.

기존 코드 생성기에서 내부 함수 포인터는 항상 panic을 일으키는 특별한 함수로 초기화돼요. 이는 스토리지의 내부 함수 포인터에 대해 생성 시점에 스토리지 쓰기가 발생하게 해요.

참고 (Note)

컴파일러는 이름으로 명시적으로 참조된 적 없는 내부 함수를 생략할 자유가 있어요. 결과적으로 인라인 어셈블리에서 함수 타입 변수에 할당하는 것은 할당된 값이 내부 디스패치에 포함될 것을 보장하지 않아요. 그 함수는 코드의 다른 곳에서도 명시적으로 참조되어야 해요.

정리 (Cleanup)

기존 코드 생성기는 더티 비트의 값에 결과가 영향을 받을 수 있는 연산 전에만 정리를 수행해요. 새 코드 생성기는 더티 비트를 만들 수 있는 어떤 연산 후에도 정리를 수행해요. 최적화 프로그램이 중복 정리 연산을 제거할 만큼 강력해지길 기대해요. 예:

open in Remix

// 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에 할당되기 전에는 정리를 수행해요.

더 알아보기 (Learn more)