최적화 프로그램

최적화 프로그램 (The Optimizer)

Solidity 컴파일러는 (실행 순서로) 세 단계에서 최적화를 수행해요. Solidity 코드의 직접 분석에 기반한 코드 생성 중 최적화, Yul IR 코드에 대한 최적화 변환, opcode 수준의 최적화가 그것이에요. opcode 기반 최적화 프로그램은 opcode에 일련의 단순화 규칙을 적용하고, 동일한 코드 집합을 결합하며 사용되지 않는 코드를 제거해요. Yul 기반 최적화 프로그램은 함수 호출을 가로질러 작업할 수 있기 때문에 훨씬 더 강력해요.

출처: 문서

본문

개요 (Overview)

Solidity 컴파일러는 (실행 순서로) 세 단계에서 최적화를 수행해요:

  • Solidity 코드의 직접 분석에 기반한 코드 생성 중 최적화.
  • Yul IR 코드에 대한 최적화 변환.
  • opcode 수준의 최적화.

opcode 기반 최적화 프로그램은 opcode에 일련의 단순화 규칙을 적용해요. 또한 동일한 코드 집합을 결합하고 사용되지 않는 코드를 제거해요. Yul 기반 최적화 프로그램은 함수 호출을 가로질러 작업할 수 있기 때문에 훨씬 더 강력해요. 예를 들어 Yul에서는 임의의 점프가 불가능하므로 각 함수의 부작용을 계산할 수 있어요. 두 함수 호출을 고려해봐요. 첫 번째는 스토리지를 수정하지 않고 두 번째는 스토리지를 수정한다고 해요. 그 인자와 반환 값이 서로 의존하지 않는다면 함수 호출을 재정렬할 수 있어요. 마찬가지로 함수가 부작용이 없고 그 결과가 0으로 곱해진다면 함수 호출을 완전히 제거할 수 있어요.

codegen 기반 최적화 프로그램은 Solidity 입력에서 생성된 초기 저수준 코드에 영향을 줘요. 레거시 파이프라인에서는 바이트코드가 즉시 생성되고 이러한 종류의 최적화 대부분은 암시적이며 구성할 수 없어요. 유일한 예외는 이진 연산에서 리터럴의 순서를 바꾸는 최적화예요. IR 기반 파이프라인은 다른 접근을 취하며 Solidity 코드의 구조와 밀접하게 일치하는 Yul IR을 생성해, 거의 모든 최적화를 Yul 최적화 프로그램 모듈로 연기해요. 그 경우 codegen 수준 최적화는 Yul IR에서 처리하기 어렵지만 분석 단계의 고수준 정보로는 간단한 아주 제한된 경우에만 수행돼요. 그러한 최적화의 예는 특정 관용적 for 루프에서 카운터를 증가시킬 때 checked 산술을 우회하는 것이에요.

현재 --optimize 파라미터는 생성된 바이트코드에 대한 opcode 기반 최적화 프로그램과 내부적으로 생성된 Yul 코드(예: ABI coder v2)에 대한 Yul 최적화 프로그램을 활성화해요. solc --ir-optimized --optimize를 사용해 Solidity 소스에 대한 최적화된 Yul IR을 생성할 수 있어요. 마찬가지로 독립 실행 Yul 모드에는 solc --strict-assembly --optimize를 사용할 수 있어요.

참고: peephole 최적화 프로그램과 unchecked 루프 증가 최적화 같은 일부 최적화 단계는 기본적으로 항상 활성화되어 있으며 표준 JSON(Standard JSON)을 통해서만 끌 수 있어요. 참고: --optimize 없이도 빈 최적화 시퀀스, 즉 :는 사용자 제공 Yul 최적화 시퀀스 부분을 완전히 비활성화하기 위해 허용돼요. 기본적으로 최적화 프로그램이 켜져 있지 않아도 unused pruner 단계가 실행되기 때문이에요.

두 최적화 프로그램 모듈과 그 최적화 단계에 대한 자세한 내용은 아래에서 찾을 수 있어요.

Solidity 코드 최적화의 이점 (Benefits of Optimizing Solidity Code)

전반적으로 최적화 프로그램은 복잡한 표현식을 단순화해 코드 크기와 실행 비용을 모두 줄여요. 즉 컨트랙트 배포에 필요한 가스와 컨트랙트에 대한 외부 호출 비용을 줄일 수 있어요. 또한 함수를 특수화하거나 인라인하기도 해요. 특히 함수 인라인은 코드를 훨씬 크게 만들 수 있는 작업이지만, 더 많은 단순화 기회를 만들기 때문에 종종 수행돼요.

최적화 코드와 비최적화 코드의 차이 (Differences between Optimized and Non-Optimized Code)

일반적으로 가장 눈에 띄는 차이는 상수 표현식이 컴파일 시점에 평가된다는 것이에요. ASM 출력의 경우 동등하거나 중복된 코드 블록이 줄어드는 것도 알 수 있어요(--asm과 --asm --optimize 플래그의 출력 비교). 그러나 Yul/중간 표현의 경우 함수가 인라인되거나 결합되거나 중복 제거를 위해 재작성되는 등 상당한 차이가 있을 수 있어요(--ir과 --optimize --ir-optimized 플래그 출력 비교).

최적화 프로그램 파라미터 runs (Optimizer Parameter Runs)

runs 수(--optimize-runs)는 배포된 코드의 각 opcode가 컨트랙트 수명 동안 대략 몇 번 실행될지 지정해요. 이는 코드 크기(배포 비용)와 코드 실행 비용(배포 후 비용) 사이의 트레이드오프 파라미터라는 뜻이에요. "runs" 파라미터 "1"은 짧지만 비싼 코드를 생성해요. 반대로 더 큰 "runs" 파라미터는 더 길지만 가스 효율이 더 높은 코드를 생성해요. 파라미터의 최대값은 2**32-1이에요.

참고: 이 파라미터가 최적화 프로그램의 반복 횟수를 지정한다는 것은 흔한 오해예요. 사실이 아니에요: 최적화 프로그램은 코드를 더 개선할 수 있는 한 항상 그만큼 여러 번 실행돼요.

Opcode 기반 최적화 프로그램 모듈 (Opcode-Based Optimizer Module)

opcode 기반 최적화 프로그램 모듈은 어셈블리 코드에 대해 작동해요. JUMP와 JUMPDEST에서 명령 시퀀스를 기본 블록으로 분할해요. 이 블록 내부에서 최적화 프로그램은 명령을 분석하고 스택, 메모리, 스토리지에 대한 모든 수정을 명령과 다른 표현식에 대한 포인터인 인자 목록으로 구성된 표현식으로 기록해요.

추가로 opcode 기반 최적화 프로그램은 다른 작업 중에서도 항상 (모든 입력에서) 동일한 표현식을 찾아 표현식 클래스로 결합하는 "CommonSubexpressionEliminator"라는 구성 요소를 사용해요. 먼저 이미 알려진 표현식 목록에서 각 새 표현식을 찾으려 해요. 일치하는 것이 없으면 constant + constant = sum_of_constants나 X * 1 = X 같은 규칙에 따라 표현식을 단순화해요. 이는 재귀적 과정이므로 두 번째 인자가 항상 1로 평가되는 것을 아는 더 복잡한 표현식일 때도 후자 규칙을 적용할 수 있어요.

일부 최적화 단계는 스토리지와 메모리 위치를 기호적으로 추적해요. 예를 들어 이 정보는 컴파일 시점에 평가할 수 있는 Keccak-256 해시를 계산하는 데 사용돼요. 다음 시퀀스를 고려해봐요:

PUSH 32
PUSH 0
CALLDATALOAD
PUSH 100
DUP2
MSTORE
KECCAK256

또는 동등한 Yul:

let x := calldataload(0)
mstore(x, 100)
let value := keccak256(x, 32)

이 경우 최적화 프로그램은 메모리 위치 calldataload(0)의 값을 추적한 다음 Keccak-256 해시를 컴파일 시점에 평가할 수 있음을 깨달아요. 이것은 mstore와 keccak256 사이에 메모리를 수정하는 다른 명령이 없을 때만 작동해요. 따라서 메모리(또는 스토리지)에 쓰는 명령이 있다면 현재 메모리(또는 스토리지)에 대한 지식을 지워야 해요. 그러나 명령이 특정 위치에 쓰지 않는다는 것을 쉽게 알 수 있을 때는 이 지우기에 예외가 있어요. 예를 들어,

let x := calldataload(0)
mstore(x, 100)
// 현재 지식 메모리 위치 x -> 100
let y := add(x, 32)
// y가 [x, x + 32)에 쓰지 않으므로 x -> 100 지식을 지우지 않음
mstore(y, 200)
// 이 Keccak-256은 이제 평가할 수 있음
let value := keccak256(x, 32)

따라서 위치 l의 스토리지와 메모리 위치에 대한 수정은 l과 같을 수 있는 스토리지나 메모리 위치에 대한 지식을 지워야 해요. 더 구체적으로 스토리지의 경우 최적화 프로그램은 l과 같을 수 있는 모든 기호 위치의 지식을 지워야 하고, 메모리의 경우 적어도 32바이트 떨어져 있지 않을 수 있는 모든 기호 위치의 지식을 지워야 해요. m이 임의의 위치라면 이 지우기 결정은 sub(l, m) 값을 계산해 수행돼요. 스토리지의 경우 이 값이 0이 아닌 리터럴로 평가되면 m에 대한 지식이 유지돼요. 메모리의 경우 값이 32와 2**256 - 32 사이의 리터럴로 평가되면 m에 대한 지식이 유지돼요. 다른 모든 경우 m에 대한 지식은 지워져요.

이 과정 후에 끝에 스택에 있어야 하는 표현식이 무엇인지 알게 되고, 메모리와 스토리지에 대한 수정 목록을 가지게 돼요. 이 정보는 기본 블록과 함께 저장되고 블록을 연결하는 데 사용돼요. 또한 스택, 스토리지, 메모리 구성에 대한 지식이 다음 블록(들)으로 전달돼요.

모든 JUMP와 JUMPI 명령의 대상을 안다면 프로그램의 완전한 제어 흐름 그래프를 만들 수 있어요. 모르는 대상이 하나만 있다면(원칙적으로 점프 대상은 입력에서 계산될 수 있으므로 이런 일이 발생할 수 있음) 블록이 알 수 없는 JUMP의 대상이 될 수 있으므로 블록의 입력 상태에 대한 모든 지식을 지워야 해요. opcode 기반 최적화 프로그램 모듈이 조건이 상수로 평가되는 JUMPI를 찾으면 이를 무조건 점프로 변환해요.

마지막 단계로 각 블록의 코드가 다시 생성돼요. 최적화 프로그램은 블록 끝의 스택 표현식에서 의존성 그래프를 만들고, 이 그래프의 일부가 아닌 모든 연산을 버려요. 원래 코드에서 만든 순서대로 메모리와 스토리지에 대한 수정을 적용하는 코드를 생성해요(필요하지 않다고 판단된 수정은 버림). 마지막으로 스택에 있어야 하는 모든 값을 올바른 위치에 생성해요.

이 단계들은 각 기본 블록에 적용되고 새로 생성된 코드가 더 작으면 대체물로 사용돼요. 기본 블록이 JUMPI에서 분할되고 분석 중에 조건이 상수로 평가되면 JUMPI는 상수 값에 따라 대체돼요. 따라서 다음과 같은 코드

uint x = 7;
data[7] = 9;
if (data[x] != x + 2) // 이 조건은 절대 참이 아님
  return 2;
else
  return 1;

은 다음과 같이 단순화돼요:

data[7] = 9;
return 1;

단순 인라인 (Simple Inlining)

Solidity 버전 0.8.2부터 "jump"로 끝나는 "단순한" 명령을 담은 블록으로의 특정 점프를 이 명령의 복사본으로 대체하는 또 다른 최적화 단계가 있어요. 이는 단순하고 작은 Solidity 또는 Yul 함수의 인라인에 해당해요. 특히 PUSHTAG(tag) JUMP 시퀀스는 JUMP가 함수 "안으로"의 점프로 표시되고 tag 뒤에 (위 "CommonSubexpressionEliminator"에서 설명한 대로) 함수 "밖으로"의 점프로 표시된 또 다른 JUMP로 끝나는 기본 블록이 있을 때마다 대체될 수 있어요.

특히 내부 Solidity 함수 호출에 대해 생성된 어셈블리의 전형적인 예를 고려해봐요:

  tag_return
  tag_f
  jump      // in
tag_return:
  ...opcodes after call to f...

tag_f:
  ...body of function f...
  jump      // out

함수의 본문이 연속적인 기본 블록인 한, "Inliner"는 tag_f jump를 tag_f의 블록으로 대체해 다음을 생성할 수 있어요:

  tag_return
  ...body of function f...
  jump
tag_return:
  ...opcodes after call to f...

tag_f:
  ...body of function f...
  jump      // out

이제 이상적으로 위에서 설명한 다른 최적화 단계들이 return 태그 푸시를 남은 점프 쪽으로 이동시켜 다음을 만들 수 있어요:

  ...body of function f...
  tag_return
  jump
tag_return:
  ...opcodes after call to f...

tag_f:
  ...body of function f...
  jump      // out

이 상황에서 "PeepholeOptimizer"는 return 점프를 제거해요. 이상적으로 이 모든 것은 tag_f에 대한 모든 참조에 대해 수행되어 사용되지 않게 만들고, 그래서 제거될 수 있어 다음이 나와요:

...body of function f...
...opcodes after call to f...

그래서 함수 f에 대한 호출이 인라인되고 f의 원래 정의는 제거될 수 있어요. 이와 같은 인라인은 휴리스틱이 인라인하지 않는 것보다 컨트랙트 수명 동안 인라인이 더 저렴하다고 제안할 때마다 시도돼요. 이 휴리스틱은 함수 본문의 크기, 해당 태그에 대한 다른 참조 수(함수 호출 수 근사), 컨트랙트의 예상 실행 횟수(전역 최적화 프로그램 파라미터 "runs")에 의존해요.

Yul 기반 최적화 프로그램 모듈 (Yul-Based Optimizer Module)

Yul 기반 최적화 프로그램은 모두 의미상 동등한 방식으로 AST를 변환하는 여러 단계와 구성 요소로 구성돼요. 목표는 더 짧거나 적어도 약간만 더 길어 추가 최적화 단계를 허용하는 코드로 끝나는 것이에요.

경고: 최적화 프로그램은 활발히 개발 중이므로 여기의 정보가 최신이 아닐 수 있어요. 특정 기능에 의존한다면 팀에 직접 연락하세요. 최적화 프로그램은 현재 순수하게 탐욕적(greedy) 전략을 따르며 어떤 역추적(backtracking)도 하지 않아요.

Yul 기반 최적화 프로그램 모듈의 모든 구성 요소는 아래에 설명돼요. 다음 변환 단계가 주요 구성 요소예요:

  • SSATransform
  • CommonSubexpressionEliminator
  • ExpressionSimplifier
  • UnusedAssignEliminator
  • FullInliner

최적화 단계 (Optimizer Steps)

이것은 Yul 기반 최적화 프로그램의 모든 단계를 알파벳순으로 정렬한 목록이에요. 개별 단계와 그 시퀀스에 대한 자세한 내용은 아래에서 확인할 수 있어요.

약어(Abbreviation) 전체 이름(Full name)
f BlockFlattener
l CircularReferencesPruner
c CommonSubexpressionEliminator
C ConditionalSimplifier
U ConditionalUnsimplifier
n ControlFlowSimplifier
D DeadCodeEliminator
E EqualStoreEliminator
v EquivalentFunctionCombiner
e ExpressionInliner
j ExpressionJoiner
s ExpressionSimplifier
x ExpressionSplitter
I ForLoopConditionIntoBody
O ForLoopConditionOutOfBody
o ForLoopInitRewriter
i FullInliner
g FunctionGrouper
h FunctionHoister
F FunctionSpecializer
T LiteralRematerialiser
L LoadResolver
M LoopInvariantCodeMotion
m Rematerialiser
V SSAReverser
a SSATransform
t StructuralSimplifier
r UnusedAssignEliminator
p UnusedFunctionParameterPruner
S UnusedStoreEliminator
u UnusedPruner
d VarDeclInitializer

일부 단계는 BlockFlattener, FunctionGrouper, ForLoopInitRewriter가 보장하는 속성에 의존해요. 그래서 Yul 최적화 프로그램은 항상 사용자가 제공하는 어떤 단계를 적용하기 전에 이들을 적용해요.

최적화 선택 (Selecting Optimizations)

기본적으로 최적화 프로그램은 미리 정의된 최적화 단계 시퀀스를 생성된 어셈블리에 적용해요. --yul-optimizations 옵션으로 이 시퀀스를 재정의하고 자신의 것을 제공할 수 있어요:

solc --optimize --ir-optimized --yul-optimizations 'dhfoD[xarrscLMcCTU]uljmul:fDnTOcmu'

단계의 순서는 중요하며 출력 품질에 영향을 줘요. 게다가 단계를 적용하면 이미 적용된 다른 단계에 대한 새로운 최적화 기회가 드러날 수 있으므로 단계를 반복하는 것이 종종 유익해요. [...] 안의 시퀀스는 Yul 코드가 변경되지 않거나 최대 라운드 수(현재 12)에 도달할 때까지 루프에서 여러 번 적용돼요. 대괄호([])는 시퀀스에서 여러 번 사용할 수 있지만 중첩될 수는 없어요. 주목해야 할 중요한 점은 사용자 제공 시퀀스나 사용자가 제공하지 않은 경우 기본 시퀀스 앞뒤로 항상 실행되는 하드코딩된 단계가 있다는 것이에요. 정리(cleanup) 시퀀스 구분자 :는 선택 사항이며 기본 정리 시퀀스를 대체할 커스텀 정리 시퀀스를 제공하는 데 사용돼요. 생략하면 최적화 프로그램은 단순히 기본 정리 시퀀스를 적용해요. 추가로 구분자는 사용자 제공 시퀀스의 시작에 놓을 수 있는데, 그러면 최적화 시퀀스가 비게 되고, 반대로 시퀀스 끝에 두면 빈 정리 시퀀스로 취급돼요.

전처리 (Preprocessing)

전처리 구성 요소는 프로그램을 작업하기 쉬운 특정 정규형으로 만드는 변환을 수행해요. 이 정규형은 나머지 최적화 과정 동안 유지돼요.

Disambiguator

disambiguator는 AST를 취해 입력 AST의 모든 식별자가 고유한 이름을 가진 새로운 복사본을 반환해요. 이는 다른 모든 최적화 단계의 전제 조건이에요. 이점 중 하나는 식별자 조회가 스코프를 고려할 필요가 없어 다른 단계에 필요한 분석을 단순화한다는 것이에요. 모든 후속 단계는 모든 이름이 고유하게 유지되는 속성을 가져요. 즉 새 식별자를 도입해야 한다면 새 고유 이름이 생성돼요.

FunctionHoister

function hoister는 모든 함수 정의를 최상위 블록의 끝으로 이동해요. 이는 disambiguation 단계 후에 수행되는 한 의미상 동등한 변환이에요. 그 이유는 정의를 더 높은 수준의 블록으로 이동해도 가시성이 줄어들 수 없고, 다른 함수에 정의된 변수를 참조하는 것은 불가능하기 때문이에요. 이 단계의 이점은 함수 정의를 더 쉽게 찾을 수 있고 AST를 완전히 순회하지 않고 함수를 개별적으로 최적화할 수 있다는 것이에요.

FunctionGrouper

function grouper는 Disambiguator와 FunctionHoister 후에 적용되어야 해요. 그 효과는 함수 정의가 아닌 모든 최상위 요소를 루트 블록의 첫 문장인 단일 블록으로 이동한다는 것이에요. 이 단계 후에 프로그램은 다음 정규형을 가져요:

{ I F... }

여기서 I는 함수 정의를 포함하지 않는 (잠재적으로 빈) 블록이고, F는 어떤 함수도 함수 정의를 포함하지 않도록 하는 함수 정의 목록이에요. 이 단계의 이점은 함수 목록이 어디서 시작하는지 항상 안다는 것이에요.

ForLoopConditionIntoBody

이 변환은 for 루프의 반복 조건을 루프 본문으로 이동해요. ExpressionSplitter가 반복 조건 표현식(다음 예의 C)에 적용되지 않기 때문에 이 변환이 필요해요.

for { Init... } C { Post... } {
    Body...
}

는 다음으로 변환돼요:

for { Init... } 1 { Post... } {
    if iszero(C) { break }
    Body...
}

이 변환은 LoopInvariantCodeMotion과 짝지을 때도 유용할 수 있는데, 루프 불변 조건의 불변식은 루프 밖으로 옮길 수 있기 때문이에요.

ForLoopInitRewriter

이 변환은 for 루프의 초기화 부분을 루프 앞으로 이동해요:

for { Init... } C { Post... } {
    Body...
}

는 다음으로 변환돼요:

Init...
for {} C { Post... } {
    Body...
}

이는 for 루프 초기화 블록의 복잡한 스코프 규칙을 무시할 수 있으므로 나머지 최적화 과정을 용이하게 해요.

VarDeclInitializer

이 단계는 모든 변수 선언이 초기화되도록 재작성해요. let x, y 같은 선언은 여러 선언 문장으로 분할돼요. 지금은 0 리터럴로 초기화하는 것만 지원해요.

Pseudo-SSA 변환 (Pseudo-SSA Transformation)

이 구성 요소의 목적은 프로그램을 더 긴 형태로 만들어 다른 구성 요소가 더 쉽게 작업할 수 있게 하는 것이에요. 최종 표현은 정적 단일 할당(SSA) 형태와 유사하며, 제어 흐름의 다른 분기에서 값을 결합하는 명시적 "phi" 함수를 사용하지 않는다는 차이가 있어요. 그런 기능이 Yul 언어에 없기 때문이에요. 대신 제어 흐름이 합쳐질 때 변수가 한 분기에서 재할당되면 현재 값을 담기 위해 새 SSA 변수가 선언되어, 이후 표현식이 여전히 SSA 변수만 참조하도록 해요.

변환의 예는 다음과 같아요:

{
    let a := calldataload(0)
    let b := calldataload(0x20)
    if gt(a, 0) {
        b := mul(b, 0x20)
    }
    a := add(a, 1)
    sstore(a, add(b, 0x20))
}

다음 모든 변환 단계가 적용되면 프로그램은 다음과 같아 보여요:

{
    let _1 := 0
    let a_9 := calldataload(_1)
    let a := a_9
    let _2 := 0x20
    let b_10 := calldataload(_2)
    let b := b_10
    let _3 := 0
    let _4 := gt(a_9, _3)
    if _4
    {
        let _5 := 0x20
        let b_11 := mul(b_10, _5)
        b := b_11
    }
    let b_12 := b
    let _6 := 1
    let a_13 := add(a_9, _6)
    let _7 := 0x20
    let _8 := add(b_12, _7)
    sstore(a_13, _8)
}

이 조각에서 재할당되는 유일한 변수는 b라는 점을 참고하세요. 이 재할당은 b가 제어 흐름에 따라 다른 값을 가지므로 피할 수 없어요. 다른 모든 변수는 정의되면 절대 값을 바꾸지 않아요. 이 속성의 이점은 변수를 자유롭게 옮길 수 있고, 그 값이 새 컨텍스트에서 여전히 유효한 한 그 참조를 초기 값으로 교환할 수 있다는 것이에요(그 반대도). 물론 여기 코드는 최적화와는 거리가 멀어요. 오히려 훨씬 더 길어요. 이 코드가 작업하기 더 쉬울 것이고, 게다가 마지막에 이러한 변경을 되돌려 코드를 다시 더 컴팩트하게 만드는 최적화 단계가 있기를 바라는 것이에요.

ExpressionSplitter

expression splitter는 add(mload(0x123), mul(mload(0x456), 0x20)) 같은 표현식을, 각 함수 호출이 인자로 변수만 가지도록 해당 표현식의 하위 표현식을 할당 받는 고유 변수 선언 시퀀스로 바꿔요. 위는 다음과 같이 변환돼요:

{
    let _1 := 0x20
    let _2 := 0x456
    let _3 := mload(_2)
    let _4 := mul(_3, _1)
    let _5 := 0x123
    let _6 := mload(_5)
    let z := add(_6, _4)
}

이 변환은 opcode나 함수 호출의 순서를 바꾸지 않는다는 점을 참고하세요. 루프 제어 흐름이 모든 경우에 내부 표현식의 "아우트라이닝(outlining)"을 허용하지 않으므로 루프 반복 조건에는 적용되지 않아요. ForLoopConditionIntoBody를 적용해 반복 조건을 루프 본문으로 옮기면 이 제한을 우회할 수 있어요. 최종 프로그램은 표현식 분할 형태(expression-split form)여야 하는데, 여기서 (루프 조건 제외) 함수 호출은 표현식 안에 중첩되어 나타날 수 없고 모든 함수 호출 인자는 변수여야 해요. 이 형태의 이점은 opcode 시퀀스를 재정렬하기 훨씬 쉽고 함수 호출 인라인을 수행하기도 더 쉽다는 것이에요. 게다가 표현식의 개별 부분을 대체하거나 "표현식 트리"를 재구성하기가 더 단순해요. 단점은 그러한 코드는 인간이 읽기 훨씬 어렵다는 것이에요.

SSATransform

이 단계는 기존 변수에 대한 반복 할당을 가능한 한 새 변수의 선언으로 대체하려 해요. 재할당은 여전히 있지만 재할당된 변수에 대한 모든 참조는 새로 선언된 변수로 대체돼요. 예:

{
    let a := 1
    mstore(a, 2)
    a := 3
}

은 다음으로 변환돼요:

{
    let a_1 := 1
    let a := a_1
    mstore(a_1, 2)
    let a_3 := 3
    a := a_3
}

정확한 의미: 코드 어딘가에서 할당되는 임의의 변수 a에 대해(값으로 선언되고 재할당되지 않는 변수는 수정되지 않음) 다음 변환을 수행해요:

  • let a := v를 let a_i := v let a := a_i로 대체.
  • a := v를 let a_i := v a := a_i로 대체. 여기서 i는 a_i가 아직 사용되지 않는 숫자예요.

게다가 항상 a에 사용된 현재 i 값을 기록하고 a에 대한 각 참조를 a_i로 대체해요. 현재 값 매핑은 변수 a가 할당된 각 블록의 끝과, a가 for 루프 본문이나 post 블록 안에서 할당된 경우 for 루프 init 블록의 끝에서 지워져요. 변수의 값이 위 규칙에 따라 지워지고 변수가 블록 밖에서 선언되면 제어 흐름이 합쳐지는 위치에 새 SSA 변수가 생성돼요. 여기에는 루프 post/body 블록의 시작과 if/switch/for/블록 문장 바로 뒤 위치가 포함돼요. 이 단계 후에는 불필요한 중간 할당을 제거하기 위해 UnusedAssignEliminator를 권장해요. 이 단계는 ExpressionSplitter와 CommonSubexpressionEliminator가 바로 앞에서 실행되면 최상의 결과를 제공하는데, 그 이유는 과도한 양의 변수를 생성하지 않기 때문이에요. 반면 CommonSubexpressionEliminator는 SSA 변환 후에 실행하면 더 효율적일 수 있어요.

UnusedAssignEliminator

SSA 변환은 많은 경우 불필요할 수 있는 a := a_i 형태의 할당을 항상 생성해요. 다음 예처럼요:

{
    let a := 1
    a := mload(a)
    a := sload(a)
    sstore(a, 1)
}

SSA 변환은 이 조각을 다음으로 변환해요:

{
    let a_1 := 1
    let a := a_1
    let a_2 := mload(a_1)
    a := a_2
    let a_3 := sload(a_2)
    a := a_3
    sstore(a_3, 1)
}

UnusedAssignEliminator는 a의 값이 사용되지 않으므로 a에 대한 세 할당을 모두 제거하고 다음 엄격한 SSA 형태로 만듭니다:

{
    let a_1 := 1
    let a_2 := mload(a_1)
    let a_3 := sload(a_2)
    sstore(a_3, 1)
}

물론 할당이 사용되지 않는지 여부를 결정하는 복잡한 부분은 제어 흐름 합치기와 연결돼요.

이 구성 요소는 자세히 다음과 같이 작동해요. AST는 정보 수집 단계와 실제 제거 단계 두 번 순회돼요. 정보 수집 중에 할당 문장에서 "unused"(미사용), "undecided"(미결정), "used"(사용) 세 상태로의 매핑을 유지하는데, 이는 할당된 값이 나중에 변수 참조로 사용될지 나타내요. 할당을 방문하면 "undecided" 상태로 매핑에 추가되고(아래 for 루프에 대한 언급 참고) 같은 변수에 대한 아직 "undecided" 상태인 다른 모든 할당은 "unused"로 변경돼요. 변수가 참조되면 그 변수에 할당된 상태가 여전히 "undecided"인 모든 할당의 상태가 "used"로 변경돼요. 제어 흐름이 분기되는 지점에서 매핑의 복사본이 각 분기에 전달돼요. 제어 흐름이 합쳐지는 지점에서 두 분기에서 온 두 매핑이 다음 방식으로 결합돼요: 한 매핑에만 있는 문장이나 같은 상태를 가진 문장은 변경 없이 사용돼요. 충돌하는 값은 다음 방식으로 해결돼요:

  • "unused", "undecided" -> "undecided"
  • "unused", "used" -> "used"
  • "undecided", "used" -> "used"

for 루프의 경우 조건, 본문, post 부분이 조건에서 합쳐지는 제어 흐름을 고려해 두 번 방문돼요. 다시 말해 루프의 0회 실행, 1회 실행, 2회 실행의 세 제어 흐름 경로를 만들고 끝에 결합해요. 세 번째 실행이나 그 이상을 시뮬레이션하는 것은 불필요한데, 다음과 같이 볼 수 있어요. 반복 시작 시의 할당 상태는 반복 끝 시의 할당 상태를 결정론적으로 낳아요. 이 상태 매핑 함수를 f라고 불러요. 위에서 설명한 unused, undecided, used 세 상태의 결합은 unused = 0, undecided = 1, used = 2인 max 연산이에요. 올바른 방법은 루프 후 상태로 다음을 계산하는 것이에요.

max(s, f(s), f(f(s)), f(f(f(s))), ...)

f는 세 가지 다른 값의 범위만 가지므로 반복하면 최대 세 번 후에 사이클에 도달해야 하고, 따라서 f(f(f(s)))는 s, f(s), f(f(s)) 중 하나와 같아야 하며 그래서

max(s, f(s), f(f(s))) = max(s, f(s), f(f(s)), f(f(f(s))), ...)

요약하면 상태가 세 가지뿐이므로 루프를 최대 두 번 실행하면 충분해요. default 케이스가 있는 switch 문의 경우 switch를 건너뛰는 제어 흐름 부분이 없어요. 변수가 스코프를 벗어나면 아직 "undecided" 상태인 모든 문장은 "unused"로 변경되지만, 변수가 함수의 반환 파라미터이면 상태가 "used"로 변경돼요. 두 번째 순회에서 "unused" 상태의 모든 할당이 제거돼요. 이 단계는 보통 SSA 변환 직후 실행되어 pseudo-SSA 생성을 완료해요.

도구 (Tools)

Movability

Movability는 표현식의 속성이에요. 대략 표현식이 부작용이 없고 그 평가가 변수 값과 환경의 호출 상수 상태에만 의존한다는 뜻이에요. 대부분의 표현식은 movable이에요. 다음 부분은 표현식을 non-movable로 만들어요:

  • 함수 호출(함수의 모든 문장이 movable이면 미래에 완화될 수 있음).
  • (부작용을 가질 수 있는) opcode, 예: call이나 selfdestruct.
  • 메모리, 스토리지, 외부 상태 정보를 읽거나 쓰는 opcode.
  • 현재 PC, 메모리 크기, returndata 크기에 의존하는 opcode.
DataflowAnalyzer

DataflowAnalyzer는 그 자체로 최적화 단계가 아니라 다른 구성 요소가 도구로 사용해요. AST를 순회하면서 값이 movable 표현식인 한 각 변수의 현재 값을 추적해요. 현재 각 변수에 할당된 표현식의 일부인 변수를 기록해요. 변수 a에 각 할당이 있을 때마다 a의 현재 저장 값을 갱신하고, a가 b에 대한 현재 저장 표현식의 일부일 때마다 모든 변수 b의 저장 값을 지워요. 제어 흐름 합치기에서 변수에 대한 지식은 어떤 제어 흐름 경로에서도 할당되거나 할당되었을 경우 지워져요. 예를 들어 for 루프에 들어갈 때 본문이나 post 블록에서 할당될 모든 변수가 지워져요.

표현식 규모 단순화 (Expression-Scale Simplifications)

이 단순화 패스는 표현식을 변경하고 동등하고 바라건대 더 단순한 표현식으로 대체해요.

CommonSubexpressionEliminator

이 단계는 DataflowAnalyzer를 사용하고 변수의 현재 값과 구문적으로 일치하는 하위 표현식을 그 변수에 대한 참조로 대체해요. 그러한 하위 표현식은 movable이어야 하므로 이는 동등성 변환이에요. 식별자 자체인 모든 하위 표현식은 값이 식별자이면 현재 값으로 대체돼요. 위 두 규칙의 결합은 지역 값 번호 매기기(local value numbering)를 계산할 수 있게 해요. 즉 두 변수가 같은 값을 가지면 둘 중 하나는 항상 사용되지 않게 돼요. 그러면 UnusedPruner나 UnusedAssignEliminator가 그러한 변수를 완전히 제거할 수 있어요. 이 단계는 ExpressionSplitter가 앞에서 실행되면 특히 효율적이에요. 코드가 pseudo-SSA 형태이면 변수 값이 더 오래 사용 가능하므로 표현식이 대체될 가능성이 더 높아요. ExpressionSimplifier는 CommonSubexpressionEliminator가 바로 앞에서 실행되면 더 나은 대체를 수행할 수 있어요.

ExpressionSimplifier

ExpressionSimplifier는 DataflowAnalyzer를 사용하고 X + 0 -> X 같은 표현식에 대한 동등 변환 목록을 사용해 코드를 단순화해요. 각 하위 표현식에서 X + 0 같은 패턴을 맞추려 해요. 매칭 과정에서 변수를 현재 할당된 표현식으로 해결해 코드가 pseudo-SSA 형태일 때도 더 깊이 중첩된 패턴을 맞출 수 있어요. X - X -> 0 같은 일부 패턴은 표현식 X가 movable인 한에서만 적용될 수 있는데, 그렇지 않으면 잠재적 부작용이 제거되기 때문이에요. 변수 참조는 현재 값이 아닐지라도 항상 movable이므로 ExpressionSimplifier는 split 또는 pseudo-SSA 형태에서 다시 더 강력해요.

LiteralRematerialiser

문서화 예정.

LoadResolver

sload(x)와 mload(x) 타입의 표현식을 알려진 경우 스토리지 각각 메모리에 현재 저장된 값으로 대체하는 최적화 단계예요. 코드가 SSA 형태이면 가장 잘 작동해요. 전제 조건: Disambiguator, ForLoopInitRewriter.

문장 규모 단순화 (Statement-Scale Simplifications)

CircularReferencesPruner

이 단계는 서로 호출하지만 외부에서도 가장 바깥쪽 컨텍스트에서도 참조되지 않는 함수를 제거해요.

ConditionalSimplifier

ConditionalSimplifier는 제어 흐름에서 값을 결정할 수 있으면 조건 변수에 할당을 삽입해요. SSA 형태를 파괴해요. 현재 이 도구는 부울 타입에 대한 지원이 아직 없어서 매우 제한적이에요. 조건이 표현식이 0이 아닌지만 검사하므로 특정 값을 할당할 수 없어요. 현재 기능:

  • switch 케이스: <condition> := <caseLabel> 삽입.
  • 종료 제어 흐름을 가진 if 문장 후: <condition> := 0 삽입.

미래 기능:

  • 1로 대체 허용.
  • 사용자 정의 함수의 종료 고려.

SSA 형태와 죽은 코드 제거가 먼저 실행됐을 때 가장 잘 작동해요. 전제 조건: Disambiguator.

ConditionalUnsimplifier

ConditionalSimplifier의 역.

ControlFlowSimplifier

여러 제어 흐름 구조를 단순화해요:

  • 빈 본문의 if를 pop(condition)으로 대체.
  • 빈 default switch 케이스 제거.
  • default 케이스가 없으면 빈 switch 케이스 제거.
  • 케이스가 없는 switch를 pop(expression)으로 대체.
  • 단일 케이스의 switch를 if로 전환.
  • default 케이스만 있는 switch를 pop(expression)과 본문으로 대체.
  • const 표현식의 switch를 매칭 케이스 본문으로 대체.
  • 종료 제어 흐름을 가진 for를 다른 break/continue 없이 if로 대체.
  • 함수 끝의 leave 제거.

이러한 연산 중 어떤 것도 데이터 흐름에 의존하지 않아요. StructuralSimplifier는 데이터 흐름에 의존하는 유사한 작업을 수행해요. ControlFlowSimplifier는 순회 중에 break와 continue 문장의 유무를 기록해요. 전제 조건: Disambiguator, FunctionHoister, ForLoopInitRewriter. 중요: EVM opcode를 도입하므로 지금은 EVM 코드에만 사용할 수 있어요.

DeadCodeEliminator

이 최적화 단계는 도달할 수 없는 코드를 제거해요. 도달할 수 없는 코드는 블록 안에서 leave, return, invalid, break, continue, selfdestruct, revert 또는 무한히 재귀하는 사용자 정의 함수 호출이 앞에 오는 모든 코드예요. 함수 정의는 이전 코드에서 호출될 수 있으므로 도달 가능한 것으로 간주되어 유지돼요. for 루프의 init 블록에 선언된 변수는 루프 본문으로 스코프가 확장되므로 이 단계 전에 ForLoopInitRewriter가 실행돼야 해요. 전제 조건: ForLoopInitRewriter, FunctionHoister, FunctionGrouper.

EqualStoreEliminator

이 단계는 mstore(k, v)와 sstore(k, v)가 이전에 호출된 적이 있고, 그 사이에 다른 store가 없고, k와 v의 값이 변하지 않았다면 그 호출을 제거해요. 이 단순한 단계는 SSATransform과 CommonSubexpressionEliminator 후에 실행하면 효과적인데, SSA가 변수가 변하지 않도록 보장하고 CommonSubexpressionEliminator가 값이 같다고 알려지면 정확히 같은 변수를 재사용하기 때문이에요. 전제 조건: Disambiguator, ForLoopInitRewriter.

UnusedPruner

이 단계는 절대 참조되지 않는 모든 함수의 정의를 제거해요. 절대 참조되지 않는 변수의 선언도 제거해요. 선언이 movable이 아닌 값을 할당하면 표현식은 유지되지만 그 값은 버려져요. 모든 movable 표현식 문장(할당되지 않은 표현식)이 제거돼요.

StructuralSimplifier

이것은 구조적 수준에서 다양한 종류의 단순화를 수행하는 일반 단계예요:

  • 빈 본문의 if 문장을 pop(condition)으로 대체.
  • 참 조건의 if 문장을 본문으로 대체.
  • 거짓 조건의 if 문장 제거.
  • 단일 케이스의 switch를 if로 전환.
  • default 케이스만 있는 switch를 pop(expression)과 본문으로 대체.
  • 리터럴 표현식의 switch를 매칭 케이스 본문으로 대체.
  • 거짓 조건의 for 루프를 초기화 부분으로 대체.

이 구성 요소는 DataflowAnalyzer를 사용해요.

BlockFlattener

이 단계는 내부 블록의 문장을 외부 블록의 적절한 위치에 삽입해 중첩 블록을 제거해요. FunctionGrouper에 의존하며 FunctionGrouper가 만든 형태를 유지하기 위해 가장 바깥쪽 블록은 평탄화하지 않아요.

{
    {
        let x := 2
        {
            let y := 3
            mstore(x, y)
        }
    }
}

는 다음으로 변환돼요:

{
    {
        let x := 2
        let y := 3
        mstore(x, y)
    }
}

코드가 disambiguation되어 있으면 변수의 스코프는 커질 수만 있으므로 이는 문제를 일으키지 않아요.

LoopInvariantCodeMotion

이 최적화는 movable SSA 변수 선언을 루프 밖으로 이동해요. 루프 본문이나 post 블록의 최상위 문장만 고려돼요. 즉 조건부 분기 안의 변수 선언은 루프 밖으로 이동되지 않아요. 더 나은 결과를 얻으려면 ExpressionSplitter와 SSATransform을 미리 실행해야 해요. 전제 조건: Disambiguator, ForLoopInitRewriter, FunctionHoister.

함수 수준 최적화 (Function-Level Optimizations)

FunctionSpecializer

이 단계는 함수를 리터럴 인자로 특수화해요. 예를 들어 function f(a, b) { sstore (a, b) } 같은 함수가 리터럴 인자, 예를 들어 x가 식별자인 f(x, 5)로 호출되면, 하나의 인자만 받는 새 함수 f_1을 만들어 특수화할 수 있어요. 즉,

function f_1(a_1) {
    let b_1 := 5
    sstore(a_1, b_1)
}

다른 최적화 단계가 함수에 더 많은 단순화를 적용할 수 있게 돼요. 이 최적화 단계는 주로 인라인되지 않을 함수에 유용해요. 전제 조건: Disambiguator, FunctionHoister. LiteralRematerialiser는 정확성에 필요하지는 않지만 전제 조건으로 권장돼요.

UnusedFunctionParameterPruner

이 단계는 함수의 사용되지 않는 파라미터를 제거해요. function f(a,b,c) -> x, y { x := div(a,b) }의 c와 y처럼 파라미터가 사용되지 않으면 파라미터를 제거하고 다음과 같이 새 "링킹" 함수를 만들어요:

function f(a,b) -> x { x := div(a,b) }
function f2(a,b,c) -> x, y { x := f(a,b) }

그리고 f에 대한 모든 참조를 f2로 대체해요. 그 후에는 f2에 대한 모든 참조가 f로 대체되도록 inliner를 실행해야 해요. 전제 조건: Disambiguator, FunctionHoister, LiteralRematerialiser. LiteralRematerialiser 단계는 정확성에 필요하지 않아요. function f(x) -> y { revert(y, y) } 같은 경우를 처리하는 데 도움이 돼요. 여기서 리터럴 y는 그 값 0으로 대체되어 함수를 재작성할 수 있게 해요.

UnusedStoreEliminator

중복 sstore와 메모리 store 문장을 제거하는 최적화 구성 요소예요. sstore의 경우 모든 나가는 코드 경로가 되돌아가거나(명시적 revert(), invalid(), 또는 무한 재귀로 인해) 최적화 프로그램이 첫 store를 덮어쓸 것이라고 말할 수 있는 다른 sstore로 이어진다면 문장이 제거돼요. 그러나 초기 sstore와 revert 또는 덮어쓰는 sstore 사이에 읽기 연산이 있으면 문장은 제거되지 않아요. 그러한 읽기 연산에는 외부 호출, 스토리지 접근이 있는 사용자 정의 함수, 그리고 초기 sstore가 쓴 슬롯과 다르다고 증명할 수 없는 슬롯의 sload가 포함돼요. 예를 들어 다음 코드

{
    let c := calldataload(0)
    sstore(c, 1)
    if c {
        sstore(c, 2)
    }
    sstore(c, 3)
}

는 UnusedStoreEliminator 단계를 실행한 후 다음 코드로 변환돼요:

{
    let c := calldataload(0)
    if c { }
    sstore(c, 3)
}

메모리 store 연산의 경우, 적어도 가장 바깥쪽 Yul 블록에서는 일반적으로 더 단순해요. 모든 그러한 문장은 어떤 코드 경로에서도 읽히지 않으면 제거되기 때문이에요. 그러나 함수 분석 수준에서는 함수 스코프를 벗어나면 메모리 위치가 읽힐지 모르기 때문에 접근이 sstore와 유사해요. 그래서 모든 코드 경로가 메모리 덮어쓰기로 이어지는 경우에만 문장이 제거돼요. SSA 형태에서 가장 잘 실행돼요. 전제 조건: Disambiguator, ForLoopInitRewriter.

EquivalentFunctionCombiner

두 함수가 구문적으로 동등하고(변수 이름 바꾸기는 허용되지만 재정렬은 허용되지 않음) 어느 한 함수에 대한 참조가 다른 것으로 대체되면, 함수의 실제 제거는 UnusedPruner가 수행해요.

함수 인라인 (Function Inlining)

ExpressionInliner

이 최적화 구성 요소는 함수형 표현식 안에서 인라인될 수 있는 함수를 인라인해 제한된 함수 인라인을 수행해요. 즉, 다음에 해당하는 함수예요:

  • 단일 값을 반환.
  • r := <functional expression> 같은 본문을 가짐.
  • 오른쪽에서 자기 자신이나 r을 참조하지 않음.

게다가 모든 파라미터에 대해 다음이 모두 참이어야 해요:

  • 인자가 movable.
  • 파라미터가 함수 본문에서 두 번 미만 참조되거나, 인자가 상당히 저렴함("비용"이 최대 1, 예: 0xff까지의 상수).

예: 인라인될 함수는 function f(...) -> r { r := E } 형태를 가지며, 여기서 E는 r을 참조하지 않고 함수 호출의 모든 인자가 movable 표현식인 표현식이에요. 이 인라인의 결과는 항상 단일 표현식이에요. 이 구성 요소는 고유한 이름을 가진 소스에만 사용할 수 있어요.

FullInliner

FullInliner는 특정 함수의 특정 호출을 함수 본문으로 대체해요. 대부분의 경우 크기만 늘릴 뿐 이득이 없으므로 그다지 도움이 되지 않아요. 게다가 코드는 보통 매우 비싸고 우리는 종종 더 효율적인 코드보다 더 짧은 코드를 원해요. 하지만 어떤 경우 함수를 인라인하면 후속 최적화 단계에 긍정적 영향을 미칠 수 있어요. 예를 들어 함수 인자 중 하나가 상수인 경우예요. 인라인 중에 함수 호출을 인라인할지 여부를 알려주는 휴리스틱이 사용돼요. 현재 휴리스틱은 호출된 함수가 아주 작지 않으면 "큰" 함수로 인라인하지 않아요. 한 번만 사용되는 함수는 인라인되고, 중간 크기 함수도 인라인되며, 상수 인자를 가진 함수 호출은 약간 더 큰 함수를 허용해요. 미래에는 함수를 즉시 인라인하는 대신 특수화만 하는 역추적 구성 요소를 포함할 수도 있는데, 즉 특정 파라미터가 항상 상수로 대체되는 함수 복사본이 생성된다는 뜻이에요. 그 후 이 특수화된 함수에 최적화 프로그램을 실행할 수 있어요. 상당한 이득이 나오면 특수화된 함수가 유지되고, 그렇지 않으면 원래 함수가 대신 사용돼요. FunctionHoister와 ExpressionSplitter는 이 단계를 더 효율적으로 만들기 때문에 전제 조건으로 권장되지만 정확성에는 필요하지 않아요. 특히 인자로 다른 함수 호출을 가진 함수 호출은 인라인되지 않지만, ExpressionSplitter를 먼저 실행하면 입력에 그러한 호출이 없도록 보장해요.

정리 (Cleanup)

정리는 최적화 프로그램 실행이 끝날 때 수행돼요. 분할된 표현식을 다시 깊게 중첩된 것으로 결합하려 하고, 변수를 가능한 한 많이 제거해 스택 머신에 대한 "컴파일 가능성"을 개선하려 해요.

ExpressionJoiner

이것은 ExpressionSplitter의 반대 연산이에요. 정확히 한 번 참조되는 변수 선언 시퀀스를 복잡한 표현식으로 바꿔요. 이 단계는 함수 호출과 opcode 실행의 순서를 완전히 보존해요. opcode의 교환성에 관한 어떤 정보도 사용하지 않아요. 변수의 값을 사용 위치로 옮기면 함수 호출이나 opcode 실행의 순서가 바뀔 경우 변환은 수행되지 않아요. 이 구성 요소는 변수 할당의 할당된 값이나 두 번 이상 참조되는 변수를 이동하지 않는다는 점을 참고하세요. let x := add(0, 2) let y := mul(x, mload(2)) 조각은 변환되지 않는데, add와 mload opcode 호출의 순서가 바뀌기 때문이에요. add가 movable이어서 차이가 없더라도 말이죠. 이렇게 opcode를 재정렬할 때 변수 참조와 리터럴은 무시돼요. 그래서 let x := add(0, 2) let y := mul(x, 3) 조각은 add opcode가 리터럴 3의 평가 후에 실행되더라도 let y := mul(add(0, 2), 3)으로 변환돼요.

SSAReverser

CommonSubexpressionEliminator와 UnusedPruner와 결합될 때 SSATransform의 효과를 되돌리는 데 도움이 되는 아주 작은 단계예요. 우리가 생성하는 SSA 형태는 지역 변수를 많이 만들기 때문에 코드 생성에 해로워요. 새 변수 선언 대신 할당으로 기존 변수를 재사용하는 것이 더 좋아요. SSATransform은

let a := calldataload(0)
mstore(a, 1)

를 다음으로 재작성해요:

let a_1 := calldataload(0)
let a := a_1
mstore(a_1, 1)
let a_2 := calldataload(0x20)
a := a_2

문제는 a 대신 a가 참조될 때마다 변수 a_1이 사용된다는 것이에요. SSATransform은 선언과 할당을 바꿔치기함으로써 이 형태의 문장을 변경해요. 위 조각은 다음으로 바뀌어요:

let a := calldataload(0)
let a_1 := a
mstore(a_1, 1)
a := calldataload(0x20)
let a_2 := a

이것은 아주 단순한 동등 변환이지만, 이제 CommonSubexpressionEliminator를 실행하면 a가 재할당될 때까지 a_1의 모든 발생을 a로 대체해요. 그러면 UnusedPruner가 변수 a_1을 완전히 제거해 SSATransform을 완전히 되돌려요.

StackCompressor

이더리움 가상 머신용 코드 생성을 어렵게 만드는 한 가지 문제는 표현식 스택을 내려가는 데 하드 한계 16개 슬롯이 있다는 사실이에요. 이는 대략 지역 변수 16개의 한계로 변환돼요. 스택 압축기는 Yul 코드를 가져와 EVM 바이트코드로 컴파일해요. 스택 차이가 너무 커지면 그런 일이 발생한 함수를 기록해요. 그런 문제를 일으킨 각 함수에 대해 Rematerialiser가 값 비용으로 정렬된 특정 변수를 공격적으로 제거하라는 특별한 요청과 함께 호출돼요. 실패하면 이 절차가 여러 번 반복돼요.

Rematerialiser

재물질화(rematerialisation) 단계는 변수 참조를 변수에 마지막으로 할당된 표현식으로 대체하려 해요. 물론 이 표현식이 상대적으로 평가 비용이 저렴한 경우에만 유익해요. 게다가 할당 시점과 사용 시점 사이에 표현식의 값이 변하지 않은 경우에만 의미상 동등해요. 이 단계의 주요 이점은 변수가 완전히 제거되도록 이끌면(아래 참고) 스택 슬롯을 절약할 수 있지만, 표현식이 아주 저렴하면 EVM에서 DUP opcode를 절약할 수도 있다는 것이에요. Rematerialiser는 DataflowAnalyzer를 사용해 항상 movable인 변수의 현재 값을 추적해요. 값이 아주 저렴하거나 변수가 명시적으로 제거되도록 요청받았다면 변수 참조가 현재 값으로 대체돼요.

ForLoopConditionOutOfBody

ForLoopConditionIntoBody의 변환을 되돌려요. 임의의 movable c에 대해

for { ... } 1 { ... } {
if iszero(c) { break }
...
}

를

for { ... } c { ... } {
...
}

로 만들고,

for { ... } 1 { ... } {
if c { break }
...
}

를

for { ... } iszero(c) { ... } {
...
}

로 만들어요. 이 단계 전에 LiteralRematerialiser를 실행해야 해요.

Codegen 기반 최적화 프로그램 모듈 (Codegen-Based Optimizer Module)

현재 codegen 기반 최적화 프로그램 모듈은 두 가지 최적화를 제공해요. 첫 번째는 레거시 코드 생성기에서 사용할 수 있는 것으로, 교환 가능한(commutative) 이진 연산자의 오른쪽으로 리터럴을 이동해 그 결합성을 활용해요. 다른 하나는 IR 기반 코드 생성기에서 사용할 수 있는 것으로, 특정 관용적 for 루프의 카운터 변수를 증가시킬 때 코드를 생성할 때 unchecked 산술을 사용할 수 있게 해요. 이는 카운터 변수가 오버플로우할 수 없음을 보장하는 일부 조건을 식별해 가스를 낭비하지 않아요. 이는 카운터 변수를 증가시키기 위해 루프 본문 안에 장황한 unchecked 산술 블록을 사용할 필요를 없애요.

Unchecked 루프 증가 (Unchecked Loop Increment)

Solidity 0.8.22에서 도입된 이 오버플로우 검사 최적화 단계는 for 루프 카운터를 오버플로우 검사 없이 안전하게 증가시킬 수 있는 조건을 식별하는 데 관련돼요. 이 최적화는 일반적인 형태의 for 루프에만 적용돼요:

for (uint i = X; i < Y; ++i) {
    // 변수 i는 루프 본문에서 수정되지 않음
}

조건과 카운터 변수가 오직 증가만 된다는 사실은 절대 오버플로우하지 않음을 보장해요. 루프가 최적화 대상이 되기 위한 정확한 요구사항은 다음과 같아요:

  • 루프 조건은 지역 카운터 변수 i(이하 "루프 카운터")와 표현식 Y에 대한 i < Y 형태의 비교예요.
  • 내장 연산자 <가 루프 조건에 반드시 사용되어야 하며 최적화를 촉발하는 유일한 연산자예요. <= 같은 것은 의도적으로 제외돼요. 추가로 사용자 정의 연산자는 대상이 아니에요.
  • 루프 표현식은 카운터 변수의 접두사 또는 접미사 증가, 즉 i++ 또는 ++i예요.
  • 루프 카운터는 내장 정수 타입의 지역 변수예요.
  • 루프 카운터는 루프 본문이나 루프 조건으로 사용되는 표현식에 의해 수정되지 않아요.
  • 비교는 루프 카운터와 같은 타입으로 수행돼요. 즉 우변 표현식의 타입이 카운터의 타입으로 암시적으로 변환 가능해, 비교 전에 후자가 암시적으로 넓혀지지 않아요.

마지막 조건을 명확히 하기 위해 다음 예를 고려해봐요:

for (uint8 i = 0; i < uint16(1000); i++) {
    // ...
}

이 경우 카운터 i는 비교 전에 uint8에서 uint16으로 암시적으로 타입이 변환되고 조건은 사실 결코 거짓이 아니므로, 증가에 대한 오버플로우 검사를 제거할 수 없어요.

더 알아보기 (Learn more)