본문으로 건너뛰기

더 빠른 JavaScript 호출

· 약 15분
[Victor Gomes](https://twitter.com/VictorBFG), 프레임 제거기

JavaScript는 함수 호출 시 기대되는 매개변수의 수와 다른 수의 인수를 전달할 수 있도록 허용합니다. 즉, 선언된 형식 매개변수보다 적거나 많은 인수를 전달할 수 있습니다. 앞의 경우를 언더 어플리케이션(under-application), 뒤의 경우를 오버 어플리케이션(over-application)이라고 합니다.

언더 어플리케이션의 경우 나머지 매개변수는 undefined 값으로 할당됩니다. 오버 어플리케이션의 경우 남는 인수는 나머지 매개변수(rest parameter)와 arguments 속성을 사용하여 접근하거나 무시할 수 있는 불필요한 값이 됩니다. 요즘 많은 웹/Node.js 프레임워크는 이러한 JS 기능을 사용해 선택적 매개변수를 받아들이고 더 유연한 API를 생성합니다.

최근까지 V8은 인수 크기 불일치 문제를 해결하기 위해 특별한 메커니즘, 인수 어댑터 프레임(arguments adaptor frame)을 사용했습니다. 하지만 인수 어댑터는 성능 비용을 초래하며, 이는 현대 프론트엔드와 미들웨어 프레임워크에서 일반적으로 필요합니다. 그러나 똑똑한 방법을 사용하면 이 추가 프레임을 제거하고 V8 코드베이스를 단순화하며 거의 모든 오버헤드를 제거할 수 있습니다.

우리는 인수 어댑터 프레임을 제거함으로써 성능에 미치는 영향을 마이크로 벤치마크를 통해 계산할 수 있습니다.

console.time();
function f(x, y, z) {}
for (let i = 0; i < N; i++) {
f(1, 2, 3, 4, 5);
}
console.timeEnd();

마이크로 벤치마크를 통해 측정된 인수 어댑터 프레임 제거의 성능 영향.

그래프는 JIT-less 모드 (Ignition)에서 실행될 때 오버헤드가 더 이상 없으며 11.2%의 성능 향상이 있음을 보여줍니다. TurboFan을 사용할 경우 최대 40%의 속도 증가를 얻을 수 있습니다.

이 마이크로벤치마크는 자연스럽게 인수 어댑터 프레임의 영향을 최대화하도록 설계되었습니다. 그러나 우리는 내부 JSTests/Array 벤치마크 (7%) 또는 Octane2 (Richards에서 4.6%, EarleyBoyer에서 6.1%) 등의 많은 벤치마크에서도 상당한 개선을 확인할 수 있었습니다.

요약: 인수를 역순 배치

이 프로젝트의 핵심 목표는 호출 시 스택에서 인수에 접속할 때 일관된 인터페이스를 제공하는 인수 어댑터 프레임을 제거하는 것이었습니다. 이를 위해 스택에서 인수를 역순으로 배치하고, 실제 인수 수를 포함하는 새로운 슬롯을 호출 프레임에 추가해야 했습니다. 아래 그림은 변경 전과 후의 일반적인 프레임 예제를 보여줍니다.

인수 어댑터 프레임 제거 전후의 일반적인 JavaScript 스택 프레임.

JavaScript 호출 속도 향상

호출 속도를 높이기 위해 우리가 수행한 변경 사항을 이해하려면 V8이 호출을 수행하는 방법과 인수 어댑터 프레임이 어떻게 작동하는지 살펴보겠습니다.

JS에서 함수 호출을 실행할 때 V8 내부에서 무슨 일이 발생할까요? 다음 JS 스크립트를 가정해 보겠습니다:

function add42(x) {
return x + 42;
}
add42(3);

함수 호출 중 V8 내부 실행 흐름.

Ignition

V8은 멀티 티어 VM입니다. 첫 번째 티어는 Ignition이라고 불리며, 이는 누산기 레지스터를 사용하는 바이트코드 스택 머신입니다. V8은 코드를 Ignition 바이트코드로 컴파일하여 시작합니다. 위에 나와 있는 호출은 다음과 같이 컴파일됩니다:

0d              LdaUndefined              ;; 누산기에 undefined를 로드합니다.
26 f9 Star r2 ;; 이를 레지스터 r2에 저장합니다.
13 01 00 LdaGlobal [1] ;; const 1(add42)가 가리키는 글로벌을 로드합니다.
26 fa Star r1 ;; 이를 레지스터 r1에 저장합니다.
0c 03 LdaSmi [3] ;; 누산기에 소정수 3을 로드합니다.
26 f8 Star r3 ;; 이를 레지스터 r3에 저장합니다.
5f fa f9 02 CallNoFeedback r1, r2-r3 ;; 호출을 실행합니다.

호출의 첫 인수는 일반적으로 리시버라고 불립니다. 리시버는 JSFunction 내부의 this 객체이며, 모든 JS 함수 호출은 하나를 가져야 합니다. CallNoFeedback의 바이트코드 핸들러는 레지스터 목록 r2-r3에 있는 인수를 사용하여 객체 r1을 호출해야 합니다.

바이트코드 핸들러를 살펴보기 전에, 바이트코드에서 레지스터가 인코딩되는 방식을 주목하세요. 레지스터는 음수의 단일 바이트 정수로 인코딩됩니다: r1fa, r2f9, r3f8로 인코딩됩니다. 우리는 레지스터 rifb - i로 참조할 수 있습니다. 사실, 올바른 인코딩은 - 2 - kFixedFrameHeaderSize - i입니다. 레지스터 목록은 첫 번째 레지스터와 목록의 크기를 사용하여 인코딩되므로, r2-r3f9 02가 됩니다.

Ignition에는 많은 바이트코드 호출 핸들러가 있습니다. 그 목록은 여기에서 볼 수 있습니다. 각 핸들러는 약간씩 다릅니다. undefined 리시버를 사용하는 호출, 속성 호출, 고정된 매개변수 개수를 가진 호출 혹은 일반적인 호출에 최적화된 바이트코드가 있습니다. 여기에서는 실행에서 피드백을 축적하지 않는 일반 호출인 CallNoFeedback을 분석합니다.

이 바이트코드의 핸들러는 꽤 간단합니다. 핸들러는 CodeStubAssembler로 작성되었으며, 여기에서 확인할 수 있습니다. 본질적으로, 아키텍처에 의존적인 내장 함수 InterpreterPushArgsThenCall에 테일콜합니다.

내장 함수는 기본적으로 리턴 주소를 임시 레지스터에 팝(pop)한 다음, 모든 인수(리시버 포함)를 푸시하고 리턴 주소를 다시 푸시합니다. 이 시점에서는 호출 대상이 호출 가능한 객체인지, 호출 대상이 몇 개의 인수를 예상하는지(즉, 형식 매개변수 개수)를 알 수 없습니다.

InterpreterPushArgsThenCall 내장 함수 실행 후 프레임 상태.

결국, 실행은 Call 내장 함수로 테일콜합니다. 여기서 대상이 올바른 함수인지, 생성자인지 또는 호출 가능한 객체인지 확인합니다. 또한 shared function info 구조를 읽어 형식 매개변수 개수를 가져옵니다.

만약 호출 대상이 함수 객체라면, CallFunction 내장 함수로 테일콜합니다. 여기서는 여러 확인 단계가 포함되며, 그 중 하나로 리시버가 undefined 객체인지 확인합니다. 만약 리시버가 undefined 또는 null 객체라면 ECMA 명세에 따라 글로벌 프록시 객체를 참조하도록 수정해야 합니다.

그 후 실행은 InvokeFunctionCode 내장 함수로 테일콜합니다. 여기서는 인수 불일치가 없을 경우, 호출 대상 객체의 Code 필드가 가리키는 것을 단순히 실행합니다. 이는 최적화된 함수이거나 InterpreterEntryTrampoline 내장 함수일 수 있습니다.

아직 최적화되지 않은 함수를 호출한다고 가정하면, Ignition 트램펄린은 IntepreterFrame을 설정합니다. V8의 프레임 유형에 대한 간단한 설명은 여기에서 볼 수 있습니다.

다음에 일어나는 일에 대해 자세히 들어가지 않고, 호출 대상 실행 중 인터프리터 프레임의 스냅샷을 볼 수 있습니다.

add42(3) 호출에 대한 InterpreterFrame

프레임에 고정된 슬롯 수가 있는 것을 볼 수 있습니다: 리턴 주소, 이전 프레임 포인터, 컨텍스트, 현재 실행 중인 함수 객체, 이 함수의 바이트코드 배열, 현재 실행 중인 바이트코드의 오프셋. 마지막으로 이 함수에 전용된 레지스터 목록이 있습니다(이를 함수 로컬 변수처럼 생각하면 됩니다). add42 함수에는 실제로 레지스터가 없지만, 호출자는 레지스터 3개를 가진 유사한 프레임을 가지고 있습니다.

예상한 대로 add42는 간단한 함수입니다:

25 02             Ldar a0          ;; 첫 번째 인수를 누산기로 로드
40 2a 00 AddSmi [42] ;; 여기에 42를 더함
ab Return ;; 누산기를 반환

Ldar (Load Accumulator Register) 바이트코드에서 인수를 인코딩하는 방식을 주목하세요: 인수 1(a0)은 숫자 02로 인코딩됩니다. 실제로, 모든 인수는 [ai] = 2 + parameter_count - i - 1로 인코딩되며 리시버는 [this] = 2 + parameter_count입니다. 이 예에서는 [this] = 3입니다. 여기서 매개변수 개수는 리시버를 포함하지 않습니다.

우리가 이렇게 레지스터와 인수를 인코딩하는 이유를 이제 이해할 수 있습니다. 이는 단순히 프레임 포인터로부터의 오프셋을 나타냅니다. 따라서 인수/레지스터 로드 및 저장을 동일한 방식으로 처리할 수 있습니다. 프레임 포인터로부터 마지막 인수의 오프셋은 2입니다 (이전 프레임 포인터와 반환 주소). 이는 인코딩에서 2의 이유를 설명합니다. 인터프리터 프레임의 고정된 부분은 슬롯 6개 (프레임 포인터로부터 4개)로 되어 있으므로, 레지스터 0은 오프셋 -5, 즉 fb에 위치하고, 레지스터 1fa에 위치합니다. 똑똑하죠?

하지만 인수에 접근하려면 함수가 스택에 얼마나 많은 인수가 있는지 알아야 한다는 점에 유의하세요! 인덱스 2는 인수 개수와 무관하게 마지막 인수를 가리킵니다!

Return 바이트코드 핸들러는 내장 함수 LeaveInterpreterFrame을 호출하며 종료됩니다. 이 내장 함수는 기본적으로 프레임에서 함수 객체를 읽어 매개변수 개수를 가져오고, 현재 프레임을 팝하고, 프레임 포인터를 복구하고, 반환 주소를 임시 레지스터에 저장하고, 매개변수 개수에 따라 인수를 팝하고, 임시 레지스터의 주소로 점프합니다.

모든 흐름은 훌륭합니다! 그러나 매개변수 개수보다 적거나 많은 인수로 함수를 호출하면 어떻게 될까요? 똑똑하게 설계된 인수/레지스터 접근이 실패할 것이며, 호출이 끝날 때 인수를 정리하는 방법은 무엇인가요?

인수 어댑터 프레임

이제 add42를 더 적거나 많은 인수로 호출해 봅시다:

add42();
add42(1, 2, 3);

우리 중 JavaScript 개발자는 첫 번째 경우 xundefined로 할당되고, 함수는 undefined + 42 = NaN를 반환하며, 두 번째 경우 x1로 할당되고 함수는 43을 반환하며, 나머지 인수는 무시된다는 것을 알고 있을 것입니다. 호출자는 이러한 일이 발생하는지 알 수 없습니다. 호출자가 매개변수 개수를 확인하더라도, 호출된 함수는 나머지 매개변수 또는 인수 객체를 사용해 나머지 인수에 접근할 수 있습니다. 사실, 느슨한 모드에서 add42 외부에서도 인수 객체에 접근할 수 있습니다.

이전과 동일한 단계를 따르면, 먼저 내장 함수 InterpreterPushArgsThenCall을 호출합니다. 이 함수는 다음과 같이 스택에 인수를 푸시합니다:

InterpreterPushArgsThenCall 내장 함수 실행 후 프레임 상태.

이전과 동일한 절차를 계속 진행해, 호출된 함수가 함수 객체인지 확인하고, 매개변수 개수를 가져오고, 수신자를 글로벌 프록시로 패치합니다. 결국 InvokeFunctionCode에 도달합니다.

여기서 우리는 호출된 객체의 Code로 점프하는 대신, 인수 크기와 매개변수 개수가 일치하지 않는지 확인하고 ArgumentsAdaptorTrampoline으로 점프합니다.

이 내장 함수에서 추가 프레임, 악명 높은 인수 어댑터 프레임을 만듭니다. 내장 함수 내부에서 무슨 일이 발생하는지 설명하는 대신, 내장 함수가 호출된 함수의 Code를 호출하기 전 프레임 상태를 보여드리겠습니다. 주의할 점은 이것이 jmp가 아닌 적절한 x64 call이라는 점이며, 호출된 함수 실행 후에는 ArgumentsAdaptorTrampoline으로 돌아갑니다. 이것은 InvokeFunctionCode가 꼬리 호출(tailcall)하는 것과 대조적입니다.

인수 적응이 있는 스택 프레임.

우리는 호출된 함수의 프레임 위에 정확한 매개변수 개수의 인수를 갖기 위해 필요한 모든 인수를 복사하는 또 다른 프레임을 생성합니다. 이는 호출된 함수가 인수 개수를 알 필요가 없도록 인터페이스를 만듭니다. 호출된 함수는 이전과 동일한 계산으로 항상 자신의 매개변수에 접근할 수 있습니다: [ai] = 2 + parameter_count - i - 1.

V8에는 나머지 매개변수나 인수 객체를 통해 남은 인수에 접근해야 할 때 어댑터 프레임을 이해하는 특별한 내장 함수가 있습니다. 이러한 내장 함수는 항상 호출된 함수의 프레임 상단의 어댑터 프레임 유형을 확인하고 적절히 작동해야 합니다.

이처럼 우리가 인수/레지스터 접근 문제를 해결하지만, 많은 복잡성을 만듭니다. 모든 인수에 접근해야 하는 내장 함수는 어댑터 프레임의 존재를 이해하고 확인해야 합니다. 뿐만 아니라 오래된 데이터를 접근하지 않도록 조심해야 합니다. add42에 다음과 같은 변경 사항을 고려해 보세요:

function add42(x) {
x += 42;
return x;
}

바이트코드 배열은 이제 다음과 같습니다:

25 02             Ldar a0       ;; 첫 번째 인수를 누산기로 로드
40 2a 00 AddSmi [42] ;; 42를 더함
26 02 Star a0 ;; 첫 번째 인수 슬롯에 누산기 저장
ab Return ;; 누산기 반환

보시다시피, 이제 a0을 수정합니다. 따라서 호출 add42(1, 2, 3)의 경우 인수 어댑터 프레임의 슬롯이 수정되지만, 호출자 프레임은 여전히 숫자 1을 포함할 것입니다. 인수 객체가 오래된 값 대신 수정된 값에 접근하도록 주의해야 합니다.

함수에서 반환은 간단하지만 느립니다. LeaveInterpreterFrame의 작동을 기억하세요? 기본적으로 호출된 프레임과 매개변수 개수까지의 인수를 팝합니다. 따라서 인수 어댑터 스텁으로 돌아가면 스택은 다음과 같이 보입니다:

호출된 함수 add42 실행 후 프레임 상태.

우리는 단순히 인수의 개수를 팝하고, 어댑터 프레임을 팝하며, 실제 인수 개수에 따라 모든 인수를 팝하고 호출자의 실행으로 돌아가면 됩니다.

요약: 인수 어댑터 기계는 복잡할 뿐만 아니라 비용이 많이 듭니다.

인수 어댑터 프레임 제거

더 나은 방법이 있을까요? 어댑터 프레임을 제거할 수 있을까요? 실제로 가능합니다.

우리의 요구 사항을 검토해봅시다:

  1. 우리는 이전처럼 인수와 레지스터에 원활하게 접근할 수 있어야 합니다. 접근 시 아무런 확인도 이루어지면 안 됩니다. 이는 너무 비쌉니다.
  2. 스택에서 나머지 매개변수와 인수 객체를 구성할 수 있어야 합니다.
  3. 호출에서 반환할 때 알 수 없는 수의 인수를 쉽게 정리할 수 있어야 합니다.
  4. 물론, 추가 프레임 없이 이를 수행하고자 합니다!

추가 프레임을 제거하려면 인수를 어디에 배치할지 결정해야 합니다: 이를 호출자 프레임 또는 피호출자 프레임에 배치해야 합니다.

피호출자 프레임 내 인수

인수를 피호출자 프레임에 배치한다고 가정해봅시다. 이는 실제로 좋은 아이디어처럼 보입니다. 프레임을 팝할 때 모든 인수도 한꺼번에 팝됩니다!

인수는 저장된 프레임 포인터와 프레임 끝 사이 어딘가에 위치해야 합니다. 이는 프레임의 크기가 정적으로 알 수 없음을 의미합니다. 인수에 접근하는 것은 여전히 간단하며, 이는 프레임 포인터를 기준으로 한 간단한 오프셋입니다. 그러나 레지스터에 접근하는 것은 인수 개수에 따라 달라지기 때문에 훨씬 더 복잡합니다.

스택 포인터는 항상 마지막 레지스터를 가리키므로, 이를 사용하여 인수 개수를 알지 못한 상태에서도 레지스터에 접근할 수 있습니다. 이 접근법은 실제로 작동할 수 있지만, 주요 단점이 있습니다. 이를테면 레지스터와 인수에 접근할 수 있는 모든 바이트코드를 복제해야 합니다. 단순히 Ldar 대신에 LdaArgumentLdaRegister가 필요하게 됩니다. 물론, 인수인지 레지스터인지(양수 또는 음수 오프셋)를 확인할 수도 있지만, 이는 각 인수 및 레지스터 접근 시 확인을 요구합니다. 이는 너무 비쌉니다!

호출자 프레임 내 인수

좋습니다… 그렇다면 호출자 프레임에 인수를 유지한다면 어떻게 될까요?

프레임의 인수 i의 오프셋을 계산하는 방법을 기억하십시오: [ai] = 2 + parameter_count - i - 1. 모든 인수(매개 변수뿐만 아니라)가 있다면, 오프셋은 [ai] = 2 + argument_count - i - 1가 됩니다. 즉, 각 인수 접근마다 실제 인수 개수를 로드해야 합니다.

그런데 인수를 뒤집으면 어떻게 될까요? 이제 오프셋은 [ai] = 2 + i로 간단히 계산이 가능합니다. 스택에 얼마나 많은 인수가 있는지 알 필요가 없지만, 스택에 항상 최소한 매개변수 개수만큼 인수가 보장된다면, 우리는 항상 오프셋 계산에 이 체계를 사용할 수 있습니다.

다른 말로 하면, 스택에 푸시된 인수의 수는 항상 인수 개수와 형식 매개변수 개수 중 더 큰 값이 되며, 필요하면 undefined 객체로 패딩됩니다.

이 접근법은 또 다른 장점을 제공합니다! 리시버(receiver)는 항상 모든 JS 함수에 대해 동일한 오프셋, 즉 반환 주소 바로 위에 위치합니다: [this] = 2.

이는 우리의 요구 사항 번호 1과 번호 4에 대한 깔끔한 해결책입니다. 그렇다면 나머지 두 요구 사항은 어떨까요? 나머지 매개변수와 인수 객체를 어떻게 구성할까요? 호출자로 반환할 때 스택에서 인수를 어떻게 정리할 수 있을까요? 이를 위해 우리는 단순히 인수 개수가 필요합니다. 이를 저장할 장소를 선택해야 합니다. 이는 쉽게 접근할 수 있는 정보라는 점에서 약간 임의적으로 선택됩니다. 두 가지 기본 선택지는 호출자 프레임의 리시버 바로 뒤에 푸시하거나 고정 헤더 부분으로 피호출자 프레임의 일부로 만드는 것입니다. 우리는 후자를 구현했는데, 이는 인터프리터와 최적화된 프레임의 고정 헤더 부분을 통합하기 때문입니다.

만약 V8 v8.9에서 우리의 예제를 실행해본다면, InterpreterArgsThenPush 이후 다음과 같은 스택을 보게 될 것입니다(참고: 인수는 이제 뒤집혀 있습니다):

InterpreterPushArgsThenCall 내장 실행 후 프레임 상태.

모든 실행은 InvokeFunctionCode에 도달할 때까지 비슷한 경로를 따릅니다. 여기에서 부족한 적용 경우에 대해 필요한 만큼 undefined 객체를 푸시하여 인수를 조정합니다. 적용 초과의 경우에는 아무것도 변경하지 않습니다. 마지막으로 우리는 인수 개수를 피호출자의 Code로 레지스터를 통해 전달합니다. x64의 경우, 우리는 rax 레지스터를 사용합니다.

피호출자가 아직 최적화되지 않은 경우에는 InterpreterEntryTrampoline에 도달하게 되며, 이는 다음 스택 프레임을 구축합니다.

인수 어댑터 없이 스택 프레임.

피호출자 프레임에는 나머지 매개변수 또는 인수 객체를 구성하고 호출자로 반환하기 전에 스택에서 인수를 정리하는 데 사용할 수 있는 인수 개수를 포함하는 추가 슬롯이 있습니다.

복귀하려면, LeaveInterpreterFrame를 수정하여 스택에서 인수 개수를 읽고 인수 개수와 형식적 매개변수 개수 중 더 큰 값을 제거해야 합니다.

TurboFan

최적화된 코드에 대해서는 어떻게 해야 할까요? V8이 TurboFan을 사용하여 코드를 컴파일하도록 초기 스크립트를 약간 변경해 보겠습니다:

function add42(x) { return x + 42; }
function callAdd42() { add42(3); }
%PrepareFunctionForOptimization(callAdd42);
callAdd42();
%OptimizeFunctionOnNextCall(callAdd42);
callAdd42();

여기서 V8 내재 기능을 사용하여 호출을 최적화하도록 강제합니다. 그렇지 않으면 V8은 코드가 충분히 자주 사용될 때만 작은 함수를 최적화합니다. 최적화 이전에 한 번 호출하여 컴파일을 유도할 수 있는 타입 정보를 수집합니다. TurboFan에 대한 추가 정보는 여기를 참조하세요.

생성된 코드 중 우리에게 관련된 부분만 보여드리겠습니다.

movq rdi,0x1a8e082126ad    ;; 함수 객체 로드 <JSFunction add42>
push 0x6 ;; 인수로 SMI 3 푸시
movq rcx,0x1a8e082030d1 ;; <JSGlobal Object>
push rcx ;; 리시버 푸시 (글로벌 프록시 객체)
movl rax,0x1 ;; rax에 인수 개수 저장
movl rcx,[rdi+0x17] ;; rcx에 함수 객체 {Code} 필드 로드
call rcx ;; 코드 객체 호출!

어셈블러로 작성되었지만, 제 주석을 따라가면 읽기 어렵지 않을 것입니다. 기본적으로 호출을 컴파일할 때, TF는 InterpreterPushArgsThenCall, Call, CallFunction, InvokeFunctionCall 빌트인에서 수행된 모든 작업을 수행해야 합니다. 운 좋게도 TF는 더 적은 컴퓨터 명령어를 생성하도록 더 많은 정적 정보를 가지고 있습니다.

TurboFan 및 인수 어댑터 프레임

이제 인수와 매개변수 개수가 일치하지 않는 경우를 살펴보겠습니다. 호출 add42(1, 2, 3)은 다음과 같이 컴파일됩니다:

movq rdi,0x4250820fff1    ;; 함수 객체 로드 <JSFunction add42>
;; 리시버와 인수 SMIs 1, 2, 3 푸시
movq rcx,0x42508080dd5 ;; <JSGlobal Object>
push rcx
push 0x2
push 0x4
push 0x6
movl rax,0x3 ;; rax에 인수 개수 저장
movl rbx,0x1 ;; rbx에 형식적 매개변수 개수 저장
movq r10,0x564ed7fdf840 ;; <ArgumentsAdaptorTrampoline>
call r10 ;; ArgumentsAdaptorTrampoline 호출

보시다시피, TF에서 인수와 매개변수 개수 불일치에 대한 지원을 추가하는 것은 어렵지 않습니다. 그냥 ArgumentsAdaptorTrampoline을 호출하세요!

그러나 이는 비용이 많이 듭니다. 최적화된 호출마다 이제 ArgumentsAdaptorTrampoline에 진입하여 비최적화 코드에서처럼 프레임을 조작해야 합니다. 이는 최적화된 코드에서 어댑터 프레임을 제거했을 때의 성능 향상이 Ignition보다 훨씬 더 큰 이유를 설명합니다.

생성된 코드는 매우 간단합니다. 그리고 복귀하는 것은 매우 쉬운 작업입니다 (에필로그):

movq rsp,rbp   ;; 호출자 프레임 클린업
pop rbp
ret 0x8 ;; 단일 인수 (리시버) 팝

프레임을 제거하고 매개변수 개수에 따라 반환 명령을 생성합니다. 인수와 매개변수 개수가 일치하지 않으면 어댑터 프레임 트램폴린이 이를 처리합니다.

TurboFan 및 어댑터 프레임 제거

생성된 코드는 인수 개수가 일치하는 호출에서와 기본적으로 동일합니다. 호출 add42(1, 2, 3)은 다음을 생성합니다:

movq rdi,0x35ac082126ad    ;; 함수 객체 로드 <JSFunction add42>
;; 리시버와 인수 1, 2, 3 푸시 (역순)
push 0x6
push 0x4
push 0x2
movq rcx,0x35ac082030d1 ;; <JSGlobal Object>
push rcx
movl rax,0x3 ;; rax에 인수 개수 저장
movl rcx,[rdi+0x17] ;; rcx에 함수 객체 {Code} 필드 로드
call rcx ;; 코드 객체 호출!

함수의 에필로그는 어떻습니까? 이제 어댑터 어댑터트램폴린으로 돌아가지 않으므로 에필로그는 이전보다 약간 더 복잡합니다.

movq rcx,[rbp-0x18]        ;; (호출자 프레임에서) 인수 개수를 rcx로 로드
movq rsp,rbp ;; 호출자 프레임 팝
pop rbp
cmpq rcx,0x0 ;; 인수 개수와 형식적 매개변수 개수를 비교
jg 0x35ac000840c6 <+0x86>
;; 인수 개수가 형식적 매개변수 개수보다 적거나 같다면:
ret 0x8 ;; 일반적으로 반환 (형식적 매개변수 개수는 정적으로 알려져 있음)
;; 스택에 있는 인수가 형식적 매개변수보다 많으면:
pop r10 ;; 반환 주소 저장
leaq rsp,[rsp+rcx*8+0x8] ;; rcx에 따라 모든 인수 팝
push r10 ;; 반환 주소 복구
retl

결론