TurboFan: анализ оптимизаций в V8 с помощью ассемблера (часть 1)

Автор: S0ER

Чем глубже мы узнаем языки программирования, тем больше начинаем любить и ценить ассемблер. Сегодня покажу, как знание ассемблера помогает в изучении возможностей движка V8.

V8 — это движок для работы с языком JavaScript, он используется в NodeJS и браузере Chrome. Одна из особенностей этого движка — представление «горячего» кода в виде оптимизированных машинных инструкций. Для этого в движок интегрирован оптимизирующий JIT-компилятор TurboFan.

Основные задачи, которые решает TurboFan:

  • Анализ и оптимизация «горячих» функций (которые вызываются часто);
  • Специализация кода под конкретные типы данных;
  • Удаление избыточных операций;
  • Векторизация вычислений;
  • Инлайнинг функций.

Для работы с машинным кодом традиционно используется ассемблер, поэтому давайте разберем небольшой пример, который позволит лучше понять, как работает TurboFan.

Основные моменты, которые нужны для старта:

  1. Мы будем использовать nodejs для получения машинного кода функции, для этого нужно запомнить следующий шаблон команды:
node --print-opt-code --code-comments -allow-natives-syntax your_script.js
  1. Мы будем использовать специальную инструкцию %OptimizeFunctionOnNextCall, без нее ничего не получится.

Давайте сделаем простой скрипт:

// sum.js
function sum(a, b) {
return a + b;
}

// Прогреваем функцию (вызываем много раз, чтобы V8 её оптимизировал)
for (let i = 0; i < 10000; i++) {
sum(i, i + 1); // используем целые числа, чтобы TurboFan сделал оптимизацию именно под них
}

// Явно просим V8 оптимизировать функцию (требует --allow-natives-syntax)
%OptimizeFunctionOnNextCall(sum);
// Вызываем ещё раз (теперь с оптимизацией)
sum(1, 2);

Теперь запустим скрипт node --print-opt-code --code-comments -allow-natives-syntax sum.js, если мы сделали всё правильно, то получим огромный вывод на экран, из которого интересна вот эта часть:

--- Raw source ---
(a, b) {
return a + b;
}

--- Optimized code ---
optimization_id = 1
source_position = 22
kind = TURBOFAN
name = sum
stack_slots = 6
compiler = turbofan
address = 0x2edae09247a1

Instructions (size = 184)

; загрузка hidden класса объекта (проверка структуры)
0x1097064c0     0  488b59f8             REX.W movq rbx, [rcx-0x8]

; проверка контекста 
0x1097064c4     4  f6433501             testb [rbx+0x35],0x1
0x1097064c8     8  0f85f2e03dfb         jnz 0x104ae45c0  (CompileLazyDeoptimizedCode)    ;; деоптимизация

; пролог
0x1097064ce     e  55                   push rbp
0x1097064cf     f  4889e5               REX.W movq rbp, rsp
0x1097064d2    12  56                   push rsi
0x1097064d3    13  57                   push rdi
0x1097064d4    14  50                   push rax

; выравнивание стека и проверка лимитов
0x1097064d5    15  4883ec08             REX.W subq rsp,0x8
0x1097064d9    19  488975e0             REX.W movq [rbp-0x20],rsi
0x1097064dd    1d  493b65a0             REX.W cmpq rsp, [r13-0x60] (external value (StackGuard::address_of_jslimit()))
0x1097064e1    21  0f8653000000         jna 0x10970653a  <+0x7a> ; если стек переполнен, переходим

;  ====== Основная функция ======

; Загрузка аргумента "a"
0x1097064e7    27  488b5518             REX.W movq rdx, [rbp+0x18]
0x1097064eb    2b  f6c201               testb rdx,0x1
0x1097064ee    2e  0f8572000000         jnz 0x109706566  <+0xa6>

; Загрузка аргумента "b"
0x1097064f4    34  488b4d20             REX.W movq rcx, [rbp+0x20]
0x1097064f8    38  48c1f920             REX.W sarq rcx, 32 ; сразу распаковка SMI для "b"

; Подготовка аргументов к сложению
0x1097064fc    3c  488bfa               REX.W movq rdi,rdx
0x1097064ff    3f  48c1ff20             REX.W sarq rdi, 32 ; распаковка для "a"

; проверка типа второго аргумента "b"
0x109706503    43  4c8b4520             REX.W movq r8, [rbp+0x20]
0x109706507    47  41f6c001             testb r8,0x1
0x10970650b    4b  0f8559000000         jnz 0x10970656a  <+0xaa>

; само сложение
0x109706511    51  03cf                 addl rcx,rdi

; если переполнение, то переход
0x109706513    53  0f8055000000         jo 0x10970656e  <+0xae> 

; упаковка результата
0x109706519    59  48c1e120             REX.W shlq rcx, 32

; результат кладем в rax
0x10970651d    5d  488bc1               REX.W movq rax,rcx

;  ====== эпилог ==========
0x109706520    60  488b4de8             REX.W movq rcx, [rbp-0x18]
0x109706524    64  488be5               REX.W movq rsp,rbp
0x109706527    67  5d                   pop rbp

0x109706528    68  4883f903             REX.W cmpq rcx,0x3
0x10970652c    6c  7f03                 jg 0x109706531  <+0x71>
0x10970652e    6e  c21800               ret 0x18

0x109706531    71  415a                 pop r10
0x109706533    73  488d24cc             REX.W leaq rsp, [rsp+rcx*8]
0x109706537    77  4152                 push r10
0x109706539    79  c3                   retl

0x10970653a    7a  48ba0000000010000000 REX.W movq rdx,0x1000000000
0x109706544    84  52                   push rdx
0x109706545    85  48bb107d7a0401000000 REX.W movq rbx,0x1047a7d10
0x10970654f    8f  b801000000           movl rax,0x1
0x109706554    94  48bef911d866da2e0000 REX.W movq rsi,0x2eda66d811f9    ;; object: 0x2eda66d811f9 <NativeContext[280]>
0x10970655e    9e  e89db146fb           call 0x104b71700  (CEntry_Return1_ArgvOnStack_NoBuiltinExit)    ;; near builtin entry
0x109706563    a3  eb82                 jmp 0x1097064e7  <+0x27>
0x109706565    a5  90                   nop

Как видите, здесь довольно длинный ASM-кусок, в котором я добавил пояснения, чтобы было понятно по смыслу, что происходит, само сложение выполняется одной командой addl, всё остальное — это подготовки и обработка исключений, давайте коротко подведем итоги:

  • сначала идет пролог — это стандартная часть, которая сохраняет значения регистров;
  • далее идет загрузка аргументов функции «a» и «b»;
  • проверка, что загруженные аргументы — это числа;
  • далее идет распаковка (обратите внимание, что на самом деле распаковка "b" сделана раньше, чем реальная проверка типа, это спекулятивная оптимизация, которая требует отдельного рассмотрения, как и само понятие "распаковка");
  • делаем вычисление;
  • проверяем, что всё ок;
  • упаковываем результат;
  • финальные проверки и эпилог.

Более детально каждую из частей мы рассмотрим в будущих статьях, а пока ставь лайк, если тема тебе интересна.