V8

WebAssembly를 위한 Deopt 및 인라이닝을 활용한 추측 최적화

원문은 CC-BY-3.0
라이선스로 제공되며, 이 글은 한국어로 번역한 2차 저작물입니다.
이 블로그 게시물에서는 V8 엔진에 최근 구현되어 Google Chrome M137과 함께 출시된 WebAssembly를 위한 두 가지 최적화 기법, 즉 추측적 call_indirect 인라이닝과 WebAssembly에 대한 디최적화 지원에 대해 설명합니다. 이 두 가지 기법을 함께 사용하면 런타임 피드백을 기반으로 가정을 하여 더 나은 기계 코드를 생성할 수 있습니다. 이를 통해 WebAssembly 실행 속도가 향상되며, 특히 WasmGC
프로그램에 효과적입니다. 예를 들어 Dart 마이크로벤치마크 세트에서는 두 최적화를 모두 사용했을 때 평균 50% 이상의 속도 향상을 얻었으며, 더 크고 현실적인 애플리케이션 및 아래에 표시된 벤치마크에서는 1%에서 8% 사이의 속도 향상을 보였습니다. 디최적화는 또한 미래의 더 많은 최적화를 위한 중요한 구성 요소입니다.

배경

JavaScript의 빠른 실행은 추측 최적화
에 크게 의존합니다. 즉, JIT 컴파일러는 이전 실행 중에 수집된 피드백을 기반으로 기계 코드를 생성할 때 가정을 합니다. 예를 들어,
a + b
표현식을 가정할 때, 과거 피드백이
a
b
가 문자열, 부동 소수점 숫자 또는 다른 객체가 아니라 정수임을 나타내면 컴파일러는 정수 덧셈에 대한 기계 코드를 생성할 수 있습니다. 이러한 가정을 하지 않으면 컴파일러는 JavaScript에서 '+' 연산자의 전체 동작을 처리하는 일반적인 코드
를 내보내야 하는데, 이는 복잡하고 훨씬 느립니다. 나중에 프로그램이 다르게 동작하여 최적화된 코드를 생성할 때 내려진 가정을 위반하는 경우, V8은 디최적화 (또는 간단히 deopt)를 수행합니다. 이는 최적화된 코드를 폐기하고 최적화되지 않은 코드에서 실행을 계속하는 것을 의미합니다(나중에 다시 계층화할 수 있도록 더 많은 피드백을 수집합니다).
JavaScript와 달리 WebAssembly의 빠른 실행은 추측 최적화와 deopt를 필요로 하지 않았습니다. 한 가지 이유는 WebAssembly 프로그램이 함수, 명령어, 변수 등이 모두 정적으로 타입화되어 있어 이미 상당히 잘 최적화될 수 있기 때문입니다. 또 다른 이유는 WebAssembly 바이너리가 종종 C, C++ 또는 Rust에서 컴파일되기 때문입니다. 이러한 소스 언어는 JavaScript보다 정적 분석에 더 적합하며, 따라서 Emscripten
(LLVM 기반) 또는 Binaryen
과 같은 도구 체인은 미리 프로그램을 최적화할 수 있습니다. 이는, 특히 2017년에 출시된
WebAssembly 1.0을 대상으로 할 때 상당히 잘 최적화된 바이너리를 생성합니다.

동기

그렇다면 왜 V8에서 WebAssembly에 대해 더 많은 추측 최적화를 적용하는 것일까요? 한 가지 이유는 WebAssembly가 진화
하여 WasmGC, WebAssembly 가비지 컬렉션 제안
이 도입되었기 때문입니다. 이는 Java
, Kotlin
, Dart
와 같은 "관리형" 언어를 WebAssembly로 컴파일하는 데 더 나은 지원을 제공합니다. 결과적인 WasmGC 바이트코드는 Wasm 1.0보다 더 높은 수준입니다. 예를 들어, 구조체 및 배열과 같은 풍부한 타입, 서브타이핑 및 해당 타입에 대한 연산을 지원합니다. 따라서 WasmGC를 위한 생성된 기계 코드는 추측 최적화로부터 더 많은 이점을 얻습니다.
특히 중요한 최적화 중 하나는 인라이닝입니다. 즉, 호출 명령어를 호출된 함수의 본문으로 대체하는 것입니다. 이는 호출 자체와 관련된 관리 오버헤드를 제거할 뿐만 아니라 (매우 작은 함수에서는 실제 작업보다 높을 수 있는), 최적화가 함수 호출을 "통해 볼 수 있도록" 하여 많은 후속 최적화를 가능하게 합니다. 1971년에 Frances Allen의 영향력 있는 "최적화 변환 카탈로그"
에서 인라이닝이 가장 중요한 최적화 중 하나로 인식된 것도 당연합니다.
인라이닝의 복잡한 문제 중 하나는 간접 호출입니다. 즉, 호출 대상이 런타임에만 알려지고 여러 잠재적 대상 중 하나가 될 수 있는 호출입니다. 이는 WasmGC로 컴파일되는 언어에 특히 해당됩니다. Java나 Kotlin에서는 메소드가 기본적으로
virtual
이지만, C++에서는 명시적으로 표시하여 옵트인해야 합니다. 정적으로 알려진 호출 대상이 하나도 없는 경우, 인라이닝이 분명히 간단하지 않습니다.

추측적 인라이닝

여기서 추측적 인라이닝이 사용됩니다. 이론적으로 간접 호출은 여러 다른 함수를 대상으로 할 수 있지만, 실제로는 종종 단일 대상 (단형 함수 호출) 또는 몇 가지 선택된 경우 (다형 호출)만 대상으로 합니다. 최적화되지 않은 코드를 실행할 때 해당 대상을 기록한 다음, 최적화된 코드를 생성할 때 최대 네 개의 대상 함수를 인라인합니다.
추측적 인라이닝 개요
위 그림은 전체적인 그림을 보여줍니다. 왼쪽 상단에서 WebAssembly를 위한 기본 컴파일러인 Liftoff
에 의해 생성된 함수
func_a
의 최적화되지 않은 코드로 시작합니다. 각 호출 사이트에서 Liftoff는 피드백 벡터를 업데이트하는 코드도 내보냅니다. 이 메타데이터 배열은 함수당 하나씩 존재하며, 주어진 함수 내의 각 호출 명령어에 대한 항목이 포함됩니다. 각 항목은 이 특정 호출 사이트에 대한 호출 대상과 횟수를 기록합니다. 그림 하단의 예제 피드백은
func_a
call_indirect
에 대한 단형 항목을 보여줍니다. 여기서 호출 대상은 1337번
func_b
였습니다.
함수가 계층화할 만큼 충분히 뜨거워지면 TurboFan
으로 이동합니다. 즉, 최적화 컴파일러로 컴파일되면 두 번째 단계로 이동합니다. TurboFan은 해당 피드백 벡터를 읽고 각 호출 사이트에서 인라인할 대상이 있는지 여부와 어떤 대상을 인라인할지 결정합니다. 하나 또는 여러 개의 호출 대상을 인라인하는 것이 유익한지는 다양한 휴리스틱
에 따라 달라집니다. 예를 들어, 큰 함수는 절대 인라인되지 않고, 아주 작은 함수는 거의 항상 인라인되며, 일반적으로 함수에 대한 더 이상의 인라이닝이 발생하지 않는 최대 인라이닝 예산이 있습니다. 이는 컴파일 시간 및 생성된 기계 코드 크기에도 비용이 들기 때문입니다. 컴파일러, 특히 다중 계층 JIT의 많은 곳에서 이러한 절충안은 상당히 복잡하며 시간이 지남에 따라 조정됩니다. 이 예에서는 TurboFan이
func_b
func_a
에 인라인하기로 결정합니다.
그림의 오른쪽 상단에서 생성된 최적화된 코드에서 추측적 인라이닝의 결과를 볼 수 있습니다. 간접 호출 대신, 코드는 먼저 런타임의 대상이 컴파일 중에 가정했던 것과 일치하는지 확인합니다. 일치하는 경우, 해당 함수의 인라인된 본문 실행을 계속합니다. 후속 최적화는 이제 사용 가능한 주변 컨텍스트를 고려하여 인라인된 코드를 더 많이 변환할 수도 있습니다. 예를 들어, 상수 전파 및 상수 폴딩
은 이 특정 호출 사이트에 대해 코드를 특수화하거나 GVN
은 반복되는 계산을 끌어낼 수 있습니다. 다형적 피드백의 경우, TurboFan은 이 예에서처럼 하나가 아니라 일련의 대상 확인 및 인라인된 본문을 생성할 수도 있습니다.

기술 심층 분석

고수준 그림은 이 정도입니다. 구현에 관심 있는 독자를 위해 이 섹션에서 몇 가지 더 자세한 내용과 구체적인 코드를 살펴보겠습니다.
위 그림에서 피드백 벡터는 항목 배열로만 개념적으로 표시되며 한 가지 종류의 항목만 표시됩니다. 아래에서 각 항목은 실행 중에 네 가지 단계를 거칠 수 있습니다. 처음에는 모든 항목이 초기화되지 않음 (모든 호출 횟수가 0) 상태이며, 잠재적으로 단형 (단일 호출 대상이 기록됨), 다형 (최대 4개의 호출 대상), 그리고 최종적으로 초다형 (4개 이상의 대상, 더 이상 인라인하지 않으므로 호출 횟수를 기록할 필요도 없음)으로 전환됩니다. 각 항목은 실제로 객체 쌍이므로 가장 일반적인 단형 케이스에서는 호출 횟수와 대상을 벡터에 인라인으로 저장할 수 있습니다. 즉, 추가 할당 없이 저장됩니다. 다형적 케이스의 경우, 피드백은 아래 표시된 대로 오프라인 배열에 저장됩니다. 피드백 항목을 업데이트하는 빌트인
(첫 번째 그림의
update_feedback()
에 해당)은 Torque
로 작성되었습니다. (읽기 쉽습니다. 시도해 보세요!) 먼저 단형 또는 다형 "히트"를 확인하는데, 이때는 횟수만 증가시키면 됩니다. 가장 일반적인 경우이고 성능에 민감하기 때문입니다. 피드백 벡터와 해당 항목은 JavaScript 객체입니다 (예: 호출 횟수는 Smi
임). 따라서 관리형 힙에 존재합니다. 따라서 V8 샌드박스
의 일부이며 해당 Wasm 인스턴스 (아래 참조)에 더 이상 접근할 수 없으면 자동으로 정리됩니다.
피드백 벡터의 세부 정보
다음으로, 인라이닝이 실제 WebAssembly 프로그램에 미치는 영향을 살펴보겠습니다. 아래의
example
함수는 단일 대상
inlinee
에 대한 루프에서 200M의 간접 호출을 수행하며,
inlinee
는 덧셈을 포함합니다. 명백히 이것은 다소 단순화된 마이크로벤치마크이지만, 추측적 인라이닝의 이점을 잘 보여줍니다.
(func $example (param $iterations i32) (result i32)  (local $sum i32)  block $block    loop $loop      local.get $iterations      i32.eqz      br_if $block ;; terminate loop      local.get $iterations ;; update loop counter      i32.const 1      i32.sub      local.set $iterations      i32.const 7 ;; argument for the function call      i32.const 1 ;; table index, refering to $inlinee      call_indirect (param i32) (result i32)      local.get $sum      i32.add      local.set $sum      br $loop ;; repeat    end  end  local.get $sum)...(func $inlinee (param $x i32) (result i32)  local.get $x  i32.const 37  i32.add)
WebAssembly 텍스트 형식에 익숙하지 않은 독자를 위해 위의 프로그램의 대략적인 C 등가물은 다음과 같습니다.
int inlinee(int x) {  return x + 37;}int (*func_ptr)(int) = inlinee;int example(int iterations) {  int sum = 0;  while (iterations != 0) {    iterations--;    sum += func_ptr(7);  }  return sum;}
다음 그림은
example
함수에 대한 TurboFan의 중간 표현
의 발췌본으로, Turbolizer
를 사용하여 시각화되었습니다. 추측적 인라이닝과 Wasm deopt는 오른쪽에 활성화되어 있고 왼쪽에는 비활성화되어 있습니다. 두 버전 모두 WebAssembly 의미론
에 따라
call_indirect
명령어의 테이블 인덱스 인수가 올바른 범위 내에 있는지 확인해야 합니다 (양쪽 경우 모두 첫 번째 빨간색 상자). 인라이닝이 없으면 실제로 호출하기 전에 이 인덱스의 함수에 올바른 시그니처가 있는지 확인해야 합니다 (왼쪽의 두 번째 빨간색 상자). 마지막으로 왼쪽의 첫 번째 녹색 상자는 간접 호출이고, 두 번째 녹색 상자는 해당 호출 결과의 덧셈입니다. 오른쪽 녹색 상자에서 인라이닝 및 추가 최적화 후 호출이 완전히 사라지고
inlinee
의 덧셈과
example
의 덧셈이 단일 상수로 묶여 상수 덧셈으로 폴딩된 것을 볼 수 있습니다. 이 특정 마이크로벤치마크에서는 인라이닝, deopts 및 후속 최적화가 프로그램을 x64 워크스테이션에서 약 675ms에서 90ms 실행 시간으로 가속했습니다. 이 경우 인라이닝이 있는 최적화된 기계 코드는 인라이닝이 없는 코드보다 작습니다 (968 대 1572 바이트). 물론 그렇다고 해서 항상 그런 것은 아닙니다.
인라이닝이 있는 경우와 없는 경우의 TurboFan IR
마지막으로, 오른쪽의 추측적 인라이닝이 있는 코드에서 수행하는 Wasm 인스턴스 확인 및 대상 확인을 간략하게 설명하고자 합니다. 의미론적으로 Wasm 함수는 Wasm 인스턴스 (전역 변수, 테이블, 호스트에서 가져온 가져오기 등의 현재 상태를 "보유"함)에 대한 클로저입니다. 따라서 다른 인스턴스에 속하는 함수 (예: 가져온 테이블을 통해 호출되는 함수)를 올바르게 인라인하려면 추가 컴파일러 메커니즘과 일반적인 생성 코드 처리의 몇 가지 장애물을 해결해야 합니다. 다행히 대부분의 호출은 어차피 단일 인스턴스 내에서 발생하므로, 당분간은 호출 대상의 인스턴스가 현재 인스턴스와 일치하는지 확인하여 컴파일러가 두 인스턴스가 동일하다는 단순화된 가정을 할 수 있도록 합니다. 그렇지 않으면 블록 8 (잘못된 인스턴스로 인해) 또는 블록 6 (잘못된 대상으로 인해)에서 디최적화됩니다.
이 추가 Wasm 인스턴스 확인은 새로운
call_indirect
인라이닝을 위해 특별히 도입되었습니다. WebAssembly에는
call_ref
라는 다른 종류의 간접 호출도 있는데, WasmGC 구현
을 출시할 때 이미 인라이닝 지원을 추가했습니다.
call_ref
인라이닝의 빠른 경로는
call_ref
입력인
WasmFuncRef
객체가 함수가 클로저하는 인스턴스를 이미 포함
하고 있기 때문에 명시적인 인스턴스 확인이 필요하지 않으며, 대상의 동등성 비교가 두 확인을 모두 포함합니다.
새로운
call_indirect
인라이닝을 통해 V8은 이제 모든 종류의 호출 명령어에 대한 Wasm-to-Wasm 호출 인라이닝을 지원합니다. 직접
call
호출,
call_ref
,
call_indirect
및 해당 반환 호출 변형인
return_call
,
return_call_ref
,
return_call_indirect
까지 지원합니다.

디최적화

지금까지 우리는 인라이닝과 이것이 최적화된 코드를 어떻게 개선하는지에 중점을 두었습니다. 하지만 최적화 중에 내려진 가정이 런타임에 거짓으로 판명되면 빠른 경로를 유지할 수 없는 경우 어떻게 될까요? 이때 deopts가 사용됩니다.
이 게시물의 가장 첫 번째 그림은 deopt의 높은 수준의 아이디어를 이미 보여줍니다. 최적화된 코드에서 실행을 계속할 수 없습니다. 왜냐하면 해당 코드는 현재 무효화된 몇 가지 가정을 했기 때문입니다. 대신, 우리는 최적화되지 않은 기본 코드로 "돌아갑니다". 결정적으로, 최적화되지 않은 코드로의 전환은 현재 함수 실행 중에 발생합니다. 즉, 최적화된 코드가 이미 부작용이 있는 연산 (예: 기본 운영 체제 호출)을 수행했고, 이를 되돌릴 수 없으며, 중간 값이 레지스터와 스택에 저장되어 있는 동안 발생합니다. 따라서 deopt는 단순히 최적화되지 않은 함수의 시작 부분으로 점프하는 것이 아니라 훨씬 더 흥미로운 작업을 수행합니다.
  1. 먼저, 현재 프로그램 상태를 저장합니다. 최적화된 코드에서 런타임으로 호출하여 이를 수행합니다.
    Deoptimizer
    는 현재 프로그램 상태를 내부 데이터 구조인
    FrameDescription
    으로 직렬화합니다. 여기에는 CPU 레지스터를 읽고 디최적화될 함수의 스택 프레임을 검사하는 작업이 포함됩니다.
  2. 다음으로, 이 상태를 최적화되지 않은 코드가 이해할 수 있도록 변환합니다. 즉, 값을 올바른 레지스터와 스택 슬롯에 배치하여 Liftoff 생성 코드가 디최적화 지점에서 예상하는 값으로 만듭니다. 예를 들어, TurboFan 코드가 레지스터에 넣었던 값은 이제 스택 슬롯에 있을 수 있습니다. 기본 코드의 스택 레이아웃 및 예상 값 (디최적화 지점에 대한 "호출 규칙"이라고 할 수 있습니다)은 컴파일 중에 Liftoff가 생성한 메타데이터에서 읽어옵니다.
  3. 마지막으로, 최적화된 스택 프레임을 해당 최적화되지 않은 스택 프레임으로 대체하고, 디최적화가 트리거된 최적화된 코드의 중간 지점에 해당하는 기본 코드의 중간 부분으로 점프합니다.
분명히 이것은 상당히 복잡한 메커니즘입니다. 왜 이렇게 번거로운 작업을 수행하고 최적화된 코드에 느린 경로를 생성하지 않는지에 대한 질문을 제기합니다. 이전 예제 WebAssembly 코드에 대한 중간 표현을 비교해 봅시다. 왼쪽은 디최적화가 없는 경우이고 오른쪽은 디최적화가 있는 경우입니다. 세 개의 빨간색 상자 (테이블 경계 / Wasm 인스턴스 / 대상 확인)는 근본적으로 동일합니다. 차이점은 느린 경로 코드에 있습니다. 디최적화가 없으면 기본 코드로 돌아갈 옵션이 없으므로 최적화된 코드는 왼쪽의 노란색 상자에 표시된 대로 간접 호출의 전체적이고 일반적인 동작을 처리해야 합니다. 이는 불행히도 추가 최적화를 방해하여 실행 속도가 느려집니다.
디최적화가 있는 경우와 없는 경우의 TurboFan IR
디최적화가 없는 느린 경로가 최적화를 방해하는 데에는 두 가지 이유가 있습니다. 첫째, 더 많은 연산, 특히 호출을 포함하는데, 이는 어디든 갈 수 있고 임의의 부작용을 일으킨다고 가정해야 합니다. 둘째, 오른쪽 블록 8의
Deoptimize
작업에는 제어 흐름 그래프에 후속 작업이 없는 반면, 왼쪽의 노란색 느린 경로는 후속 작업이 있다는 점에 주목하십시오. 특히 루프의 경우, 디최적화 노드/블록은 루프의 다음 반복으로 전파되는 데이터 흐름 사실 (데이터 흐름 분석 및 최적화의 의미, 예: 라이브 변수
, 로드 제거 또는 탈출 분석
)을 생성하지 않습니다. 본질적으로 deopt 지점은 단지 함수의 실행을 종료하며, 주변 코드에 큰 영향을 미치지 않습니다. 이는 후속 최적화에 의해 멋지게 활용될 수 있습니다.
마지막으로, 이것이 추측적 최적화 (예: 인라이닝)와 디최적화의 조합이 왜 그렇게 유용한지를 설명합니다. 첫 번째는 추측적 가정을 기반으로 빠른 경로를 추가하고, deopts는 컴파일러가 가정이 틀린 경우를 많이 걱정하지 않도록 합니다. 구체적으로, 이전의 200M 간접 호출을 포함하는 마이크로벤치마크에서 deopts 없이 추측적 인라이닝만 수행하면 프로그램 속도가 "겨우" 약 180ms로 향상되었지만, 인라이닝과 deopts 모두를 사용하면 90ms (둘 다 사용하지 않으면 675ms)였습니다.

기술 심층 분석

관심 있는 독자를 위해 이제 구체적인 예와 기술적 세부 사항을 다시 살펴보겠습니다. 이번에는 deopt가 발생할 때에 대한 내용입니다.
위에서 본 최적화된 코드를 실행하지만 테이블 인덱스 1에 저장된 함수가 중간에 변경되었다고 가정해 봅시다. 테이블 경계 확인 및 Wasm 인스턴스 확인은 통과하지만, 인라인된 대상이 테이블에 있는 것과 다르면 프로그램의 현재 상태를 디최적화해야 합니다. 이를 위해 코드는 디최적화 종료라고 불리는 것을 포함합니다. 대상 확인은 조건부로 이러한 종료 지점으로 점프하며, 이는 자체적으로
DeoptimizationEntry
빌트인에 대한 호출입니다. 빌트인은 먼저 스택으로 모든 레지스터 값을 유출하여 저장합니다[1]. 그런 다음 C++
Deoptimizer
객체와 해당
FrameDescription
입력 객체를 할당합니다. 빌트인은 유출된 레지스터와 최적화된 프레임의 모든 다른 스택 슬롯을 힙의
FrameDescription
으로 복사하고, 이 과정에서 스택에서 해당 값을 팝합니다. (실행은 여전히 빌트인 내에 있으며, 이미 자신의 반환 주소를 스택에서 제거하고 호출 프레임을 언와인드하기 시작했습니다!) 그런 다음 빌트인은 출력 프레임을 계산합니다. 이를 위해 디최적화기는 최적화된 프레임에 대한
DeoptimizationData
를 로드하고, 디최적화 지점에 대한 정보를 추출하고, 이 호출 사이트에서 각 인라인된 함수를 Liftoff로 다시 컴파일합니다. 중첩 인라이닝으로 인해 인라인된 함수가 하나 이상 있을 수 있으며, 인라인된 반환 호출의 경우 최적화된 스택 프레임이 명목상 속했던 최적화된 함수가 구성될 최적화되지 않은 스택 프레임의 일부가 아닐 수도 있습니다. 컴파일하는 동안 Liftoff는 예상되는 스택 프레임 레이아웃을 계산하고, 디최적화기는 최적화된 프레임 설명을 Liftoff가 보고한 원하는 레이아웃으로 변환합니다. 빌트인은 이러한 출력
FrameDescription
객체를 읽고 스택에 값을 푸시한 다음 반환합니다. 마지막으로 빌트인은 가장 안쪽 출력
FrameDescription
에서 레지스터를 채웁니다.
위 예의 경우,
--trace-deopt-verbose
를 사용한 내부 추적은 다음과 같이 표시됩니다.
[bailout (kind: deopt-eager, reason: wrong call target, type: Wasm): begin. deoptimizing example, function index 2, bytecode offset 134, deopt exit 0, FP to SP delta 32, pc 0x14886e50cbb4]
  reading input for Liftoff frame => bailout_id=134, height=4, function_id=2 ; inputs:
     0: 4 ; rdx (int32)
     1: 0 ; rbx (int32)
     2: (wasm int32 literal 7)
     3: (wasm int32 literal 1)
  Liftoff stack & register state for function index 2, frame size 48, total frame size 64
     0: i32:rax
     1: i32:s0x28
     2: i32:c7
     3: i32:c1
[bailout end. took 0.082 ms]

먼저 Wasm에서
example
함수에 대한 잘못된 호출 대상으로 인해 deopt가 트리에거되었음을 볼 수 있습니다. 추적은 입력 (최적화된) 프레임에 대한 정보를 표시합니다. 이 프레임에는 네 개의 값이 있습니다. 함수의
iterations
매개변수 (값
4
,
rdx
레지스터에 저장됨),
sum
지역 변수 (
0
rbx
에 저장됨), 리터럴 7 및 리터럴 1은
call_indirect
명령어의 두 인수입니다. 이 간단한 예에서는 최적화되지 않은 출력 프레임이 하나뿐이므로 두 프레임 간에 1:1 매핑이 있습니다.
iterations
값은
rax
레지스터에 저장되어야 하고,
sum
값은
s0x28
스택 슬롯에 들어가야 합니다. 두 상수는 Liftoff에 의해 상수로 인식되며 스택 슬롯이나 레지스터로 전송할 필요가 없습니다.[2]
이러한 변환이 완료된 후 빌트인은 내부 최적화되지 않은 프레임으로 "반환"하여
Deoptimizer
객체를 정리하고 관리형 힙에 필요한 할당을 수행하는 최종 빌트인을 호출합니다.[3] 마지막으로 실행은 최적화되지 않은 코드에서 계속됩니다. 이 경우
call_indirect
를 실행하며, 이는 새로운 호출 대상을 피드백 벡터에 직접 기록하므로 나중에 계층화할 때 이 새로운 대상을 인식하게 됩니다.

결과

기술 설명 및 예제 외에도
call_indirect
인라이닝 및 Wasm deopt 지원의 유용성을 몇 가지 측정값으로 보여주고자 합니다.[4]
먼저 아래 그림에서 Dart 마이크로벤치마크
컬렉션을 살펴보겠습니다. 세 가지 구성을 서로 비교합니다. 모든 숫자는
call_indirect
인라이닝 및 Wasm deopts 이전의 V8 및 Chrome 동작에 대한 속도 향상입니다 (즉, 2배 속도 향상은 런타임이 기본값의 절반임을 의미합니다). 파란색 막대는
call_indirect
인라이닝이 활성화되었지만 Wasm deopts는 없는 구성을 보여줍니다. 즉, 최적화된 코드에 일반적인 느린 경로가 포함됩니다. 이러한 마이크로벤치마크 중 여러 개에서 이미 (때로는 상당한) 속도 향상을 얻을 수 있습니다.[5] 모든 항목에 대한 평균적으로
call_indirect
인라이닝은 기본값 없이 실행 속도를 1.19배 향상시킵니다. 마지막으로 빨간색 막대는 실제로 제공하는 구성으로, Wasm deopts와
call_indirect
인라이닝이 모두 활성화되어 있습니다. 기본값 대비 평균 1.59배의 속도 향상을 보이며, 이는 특히 추측 최적화 및 디최적화 지원의 조합이 매우 유익하다는 것을 보여줍니다.
자연스럽게 마이크로벤치마크는 최적화 효과를 상당히 분리하고 강조합니다. 이는 개발 중에 사용하거나 노이즈가 있는 측정으로 강력한 신호를 얻는 데 유용합니다. 그러나 더 현실적인 것은 더 크고 현실적인 애플리케이션 및 벤치마크에 대한 결과입니다. 다음 그림에 표시되어 있습니다. 가장 왼쪽에는 JetStream 벤치마크 제품군
의 워크로드인
richards-wasm
에 대해 2%의 런타임 속도 향상을 볼 수 있습니다. 다음으로, 널리 사용되는 SQLite 3 데이터베이스의 Wasm 빌드
에 대해 1%의 속도 향상, Flutter
와 유사한 UI 워크로드를 에뮬레이트하는 WasmGC 벤치마크인 Dart Flute
에 대해 8%의 속도 향상을 볼 수 있습니다. 마지막 두 결과는 WasmGC로 구동되는 Google Sheets 계산 엔진
에 대한 내부 벤치마크에서 가져온 것으로, deopts로 인한 속도 향상이 최대 7%입니다 (이 마지막 애플리케이션은 런타임 디스패치에
call_ref
만 사용하므로
call_indirect
는 없음).

결론 및 전망

WebAssembly를 위한 V8 엔진의 두 가지 새로운 최적화에 대한 게시물을 마칩니다. 요약하자면 다음과 같습니다.
  • 추측적 인라이닝이 간접 호출이 있는 경우에도 함수를 인라인하는 방법,
  • 피드백이 무엇인지, 어떻게 사용되고 업데이트되는지,
  • 최적화 중에 내려진 가정이 런타임에 유효하지 않을 때 어떻게 해야 하는지,
  • 디최적화가 어떻게 최적화된 코드를 종료하고 함수의 실행 중간에 기본 코드로 진입할 수 있는지, 그리고 마지막으로
  • 이것이 어떻게 실제 워크로드의 실행을 크게 개선하는지.
미래에는 WebAssembly에 대한 deopt 지원을 기반으로 경계 확인 제거 또는 WasmGC 객체에 대한 더 광범위한 로드 제거와 같은 추가적인 추측 최적화를 추가할 계획입니다. 또한 인라이닝 측면에서도 할 일이 더 많습니다. 현재 모든 종류의 호출 명령어에 대해 Wasm-to-Wasm 인라이닝을 지원하지만, JavaScript-to-Wasm 호출과 같이 언어 경계를 넘어서는 인라이닝을 확장할 수 있습니다. 앞으로 V8 블로그에서 흥미로운 업데이트를 확인해 주세요!

각주


  1. Wasm에 대한 SIMD 확장을 고려하면 이는 TurboFan에서 사용하는 모든 128비트 벡터 레지스터도 포함합니다. ↩︎
  2. 다른 경우, 상수 폴딩 후 최적화된 버전에서 값이 상수일 수 있지만, Liftoff를 위해 스택 슬롯이나 레지스터로 구체화되어야 하므로 디최적화 데이터에는 이러한 상수 값을 저장해야 합니다. ↩︎
  3. 디최적화 자체 중에는 힙에 할당할 수 없습니다. 할당은 가비지 컬렉터(GC)를 트리거할 수 있으며, 스택은 GC가 검사할 수 있는 상태가 아니기 때문입니다. (GC는 스택의 모든 힙 참조를 방문하고 객체를 이동할 때 잠재적으로 업데이트해야 합니다.) ↩︎
  4. 측정은 x64 워크스테이션에서 수행되었습니다. 그림은 N=21회 반복의 중앙값을 보여줍니다. ↩︎
  5. 약간의 회귀가 있는 세 가지
    Matrix4Benchmark
    항목의 경우,
    call_indirect
    인라이닝을 활성화하면 우리의 휴리스틱은 16개의 간접 호출 사이트를 다른 직접 호출 사이트보다 인라인하는 것을 선호합니다. 이로 인해 인라이닝 예산이 소진되어 (즉, 결과 코드가 너무 커져 인라인을 중지) 이전보다 직접 호출의 수가 적게 인라인됩니다. 이 특정 경우에는 휴리스틱이 한 인라이닝 결정이 다른 결정보다 얼마나 유익한지 완벽하게 예측하지 못하고 최적이 아닌 결과를 초래합니다. 이 휴리스틱을 개선하는 것은 흥미로운 미래 작업입니다. ↩︎