Привет, Habr!
Очень давно хотел перевести статью Вячеслава Егорова и наконец добрался до этого. Меня зовут Алексей Зубанков, я Flutter-разработчик в ГК Gems. Оригинал статьи можно прочитать здесь, а я представляю вам первую часть описывающую принципы работы Dart VM с различных источников.
По ходу перевода буду оставлять некоторые заметки или пояснения в таком виде.
Также переводы слов или словосочетаний будут находиться рядом с оригиналом, дабы не потерять их смысл. Например, Runtime system (Среда выполнения).
Введение в Dart VM
Виртуальная машина Dart, далее: Dart VM или просто VM.
Этот документ разработан в качестве справки для новичков в команду Dart VM, потенциальных внешних контрибьюторов или просто людей, заинтересованных в устройстве VM. Мы начнём с общего обзора Dart VM и затем продолжим описывать различные компоненты VM в деталях.
Dart VM – это коллекция компонентов для выполнения Dart кода нативно. VM включает в себя:
Runtime system (Среда выполнения);
Object model (Модель объектов);
Garbage collection (Сборщик мусора);
Snapshots (Снимки кода);
Core libraries native methods (Нативные методы фундаментальных библиотек);
Development Experience components accessible via service protocol * Debugging * Profiling * Hot-reload (Компоненты, улучшающие опыт разработчиков, доступные через сервисный протокол);
Just-in-time (JIT) и Ahead-of-time (AOT) pipelines (конвейеры);
Интерпретатор;
ARM симуляторы.
Некоторые вещи переводятся коряво, поэтому дальше в статье я оставлю их английский вариант.
Название «Dart VM» историческое. Dart VM – это виртуальная машина в понимании того, что она обеспечивает среду выполнения для высокоуровневых языков программирования, однако Dart не всегда интерпретируется или JIT-компилируется, когда выполняется на Dart VM. Для примера, Dart код может быть скомпилирован в машинный код с использованием Dart VM AOT пайплайна и затем выполнен в stripped (урезанной) версии Dart VM, называемой precompiled runtime (прекомпилированная среда выполнения), которая не будет содержать никаких компонентов компилятора и будет не способна загружать Dart код динамически.
Как Dart VM выполняет твой код?
Dart VM имеет множество путей выполнения кода, например:
from source or Kernel binary using JIT (С исходников или двоичного кода используя JIT);
-
from snapshots (со снимков кода):
from AOT snapshot;
from AppJIT snapshot.
Однако основная разница между ними лежит в следующем: как и когда VM конвертирует исходный Dart-код в выполняемый код. Среда выполнения остается той же.
pseudo isolate for
shared immutable objects
like null, true, false.
┌────────────┐
│ VM Isolate │ heaps can reference
│ ╭────────╮ │ vm-isolate heap.
┏━━━━━━━━━▶│ Heap │◀━━━━━━━━━━━━━━━━┓
┃ │ ╰────────╯ │ ┃
┃ └────────────┘ ┃
┃ ┃
┌─────────────────────────┃────────┐ ┌───────────────┃──────────────────┐
│ IsolateGroup ┃ │ │ IsolateGroup ┃ │
│ ┃ │ │ ┃ │
│ ╭───────────────────────┃──────╮ │ │ ╭─────────────┃────────────────╮ │
│ │ GC managed Heap ┅┅┅┅┅┅┅┅╳┅┅┅┅┅┅┅▶│ GC managed Heap │ │
│ ╰──────────────────────────────╯ │ no cross │ ╰──────────────────────────────╯ │
│ ┌─────────┐ ┌─────────┐ │ group │ ┌─────────┐ ┌─────────┐ │
│ │┌─────────┐ │┌─────────┐ │ references │ │┌─────────┐ │┌─────────┐ │
│ ││┌─────────┐ ││┌─────────┐ │ │ ││┌─────────┐ ││┌─────────┐ │
│ │││Isolate │ │││ │ │ │ │││Isolate │ │││ │ │
│ │││ │ │││ │ │ │ │││ │ │││ │ │
│ │││ globals │ │││ helper │ │ │ │││ globals │ │││ helper │ │
│ │││ │ │││ thread │ │ │ │││ │ │││ thread │ │
│ └││ mutator │ └││ │ │ │ └││ mutator │ └││ │ │
│ └│ thread │ └│ │ │ │ └│ thread │ └│ │ │
│ └─────────┘ └─────────┘ │ │ └─────────┘ └─────────┘ │
└──────────────────────────────────┘ └──────────────────────────────────┘
Любой Dart код внутри VM выполняется в каком-либо изоляте, который лучше всего будет описан, как изолированная Dart-вселенная со своим глобальным состоянием и чаще всего со своим mutator thread (потоком контроля). Изоляты группируются вместе в группы изолятов. Изоляты внутри одной группы делят garbage collector managed heap (кучу, управляемую сборщиком мусора), используемую в качестве хранилища объектов, привязанных к изоляту. Совместное использование кучи между изолятами в одной группе – это деталь реализации, которую невозможно наблюдать из Dart кода. Даже изоляты в одной группе не могут делиться каким-либо изменяемым состоянием напрямую и могут лишь коммуницировать посредством сообщений отправляемых через порты (не путать с интернет портами!).
Изоляты в группе используют одну и ту же Dart программу. Isolate.spawn
создаёт изолят в этой же группе, а Isolate.spawnUri
создает новую группу.
Отношение между потоками ОС и изолятами немного размыто и сильно зависит от того, как VM встроена в приложение. Гарантировать можно только следующее:
потоки ОС могут входить только в один изолят в одно время. Потоку нужно покинуть изолят прежде, чем войти в другой изолят;
может быть только один mutator thread связанный с изолятом в одно время. Mutator thread – это поток, который выполняет Dart код и использует публичное C API виртуальной машины.
Однако один и тот же поток ОС может сначала войти в один изолят, выполнить Dart код, затем выйти из этого изолята и войти в другой изолят. В то же время множество различных потоков ОС могут войти в изолят и выполнить Dart код внутри, просто не одновременно.
В дополнение к единственному mutator thread, изолят может взаимодействовать со множеством helper threads, например:
фоновый поток JIT компилятора;
GC sweeper threads (поток сборщика мусора);
concurrent GC marker threads (параллельные потоки маркеров сборщика мусора).
VM использует пул потоков ThreadPool
для управления ОС потоками и код строится вокруг концепции ThreadPoll::Task
нежели вокруг концепции потоков ОС. Например, вместо создания выделенного потока для выполнения фонового очищения после того как сборщик мусора виртуальной машины отправляет ConcurrentSweeperTask
в глобальный пул потоков VM, реализация пула потоков выбирает простаивающий поток или создаёт новый поток, если нет доступного. Аналогично дефолтной реализация event loop для обработки сообщений между изолятами не создаётся выделенного event loop потока, вместо этого отправляется MessageHadlerTask
в пул потоков всякий раз, когда приходит сообщение.**
Выполнение с исходников с использованием JIT
Расскажу, что происходит, когда вы пытаетесь запустить Dart из командной строки:
// hello.dart
main() => print('Hello, World!');
$ dart hello.dart
> Hello, World!
Начиная с Dart 2, VM больше не имеет способности напрямую выполнять Dart с сырых исходников, вместо этого VM ожидает получения Kernel binaries (также называемых dill файлов), которые содержат сериализованные Abstract syntax tree (Kernel ASTs
) (абстрактные синтаксические деревья). Задача перевода Dart исходников в Kernel AST возлагается на common front-end (CFE)
(пакет содержащий низкоуровневое API, для реализации Dart кода), написанный на Dart и доступный для различных Dart утилит.
╭─────────────╮ ╭────────────╮
│╭─────────────╮ ╔═════╗ │╭────────────╮ ╔════╗
││╭─────────────╮┣━━━▶ ║ CFE ║ ┣━━━▶ ││╭────────────╮ ┣━━━▶ ║ VM ║
┆││ Dart Source │ ╚═════╝ │││ Kernel AST │ ╚════╝
┆┆│ │ ╰││ (binary) │
┆┆ ┆ ╰│ │
┆ ┆ ╰────────────╯
Для сохранения удобства выполнения Dart напрямую с исходников автономный исполняемый файл Dart создает вспомогательный изолят, называемый kernel service, который берет на себя компиляцию Dart исходника в Kernel. VM после этого будет запускать Kernel бинарник, полученный в итоге.
┌───────────────────────────────────────────────────┐
│ dart (cli) │
│ ┌─────────┐ ┌─────────┐ │
╭─────────────╮ │ │ kernel │ ╭────────────╮ │ main │ │
│╭─────────────╮ │ │ service │ │╭────────────╮ │ isolate │ │
││╭─────────────╮┣━━━▶│ isolate │┣━━━▶││╭────────────╮┣━━━▶│ │ │
┆││ Dart Source │ │ │ │ │││ Kernel AST │ │ │ │
┆┆│ │ │ │╔══════╗ │ ╰││ (binary) │ │ │ │
┆┆ ┆ │ │║ CFE ║ │ ╰│ │ │ │ │
┆ ┆ │ │╚══════╝ │ ╰────────────╯ │ │ │
│ │ │══════════════════════════│ │ │
│ └─────────┘ VM └─────────┘ │
│ ╚═════════════════════════════════╝ │
└───────────────────────────────────────────────────┘
Однако этот запуск не единственный способ подружить CFE и VM для запуска Dart кода. Например, Flutter полностью разделяет процессы компиляции в Kernel и выполнения из Kernel путём размещения их на различные девайсы: компиляция происходит на машине разработчика (хосте), а выполнение на мобильном девайсе, который получает Kernel бинарники, отправленные через flutter tool.
HOST ┆ DEVICE
┆
┌──────────────────────┐ ┆ ┌────────────────┐
╭─────────────╮ │frontend_server (CFE) │ ┆ │ Flutter Engine │
│╭─────────────╮ ┌──────────────────────┐ │ ┆ │ ╔════════════╗ │
││╭─────────────╮┣━━━▶│flutter run --debug │ │ ┆ │ ║ VM ║ │
┆││ Dart Source │ │ │─┘ ┆ │ ╚════════════╝ │
┆┆│ │ │ │ ┆ └────────────────┘
┆┆ ┆ └──────────────────────┘ ┆ ▲
┆ ┆ ┳ ┆ ┃
┃ ┆ ┃
┃ ╭────────────╮ ┃
┃ │╭────────────╮ ┃
┃ ││╭────────────╮ ┃
┗━━━━-━━━▶│││ Kernel AST │┣━━━┛
╰││ (binary) │
╰│ │
╰────────────╯
Отметим, что flutter tool не берёт на себя парсинг Dart – вместо этого он создаёт другой постоянный процесс frontend_server, который, по сути, является тонкой обёрткой вокруг CFE и некоторыми специфичными для Flutter Kernel-to-Kernel преобразованиями. frontend_server компилирует Dart исходник в Kernel файлы, которые flutter tool затем отправляет на девайс. Постоянный процесс frontend_server вступает в силу, когда разработчик запрашивает hot reload: в таком случае frontend_server может переиспользовать состояние CFE с предыдущей компиляции и перекомпилировать только те библиотеки, который на самом деле изменились.
Как только Kernel binary загружены на VM, они парсятся, чтобы создать объекты, отражающие различные программные сущности. Однако это делается lazy (лениво): сначала загружается только базовая информация о библиотеках и классах. Каждая сущность, получающаяся из Kernel binary, сохраняет указатель обратно в бинарник, благодаря этому в дальнейшем при необходимости может быть загружено больше информации.
KERNEL AST BINARY ┆ ISOLATE GROUP HEAP
┆
╭─────────────────╮ ┆ ┌───────┐
│ │ ┆ ┏━┥ Class │
├─────────────────┤ ┆ ┃ └───────┘╲ heap objects
AST node │(Class │◀━━┛ representing
representing │ (Field) │ ┆ ┌───────┐╱ a class
a class │ (Procedure │ ┆ ┏━┥ Class │
│ (FunctionNode))│ ┆ ┃ └───────┘
│ (Procedure │ ┆ ┃
│ (FunctionNode))│ ┆ ┃╲
├─────────────────┤ ┆ ┃ ╲ heap objects representing
│(Class │◀━━┛ program entities keep
│ (Field) │ ┆ pointers back into kernel
│ (Field) │ ┆ binary blob and are
│ (Procedure │ ┆ deserialized lazily
│ (FunctionNode))│ ┆
├─────────────────┤ ┆
┆
Информация о классе полностью десериализуется только тогда, когда она необходима в процессе выполнения (например, для поиска представителя класса, выделения экземпляра и т.д). На этой стадии представитель класса читается из Kernel бинарника. Однако целикомтела функций не десериализуются на этой стадии, только их сигнатуры.
KERNEL AST BINARY ┆ ISOLATE GROUP HEAP
┆
╭─────────────────╮ ┆ ┌───────┐
│ │ ┆ ┏━┥ Class ┝━━━━━━━┓
├─────────────────┤ ┆ ┃ └───────┘ ┃
│(Class │◀━━┛ ┌──────────┐ ┃
│ (Field) │◀━━━━━━┥ Field │◀━┫
│ (Procedure │◀━━━━┓ └──────────┘ ┃
│ (FunctionNode))│ ┆ ┃ ┌──────────┐ ┃
│ (Procedure │◀━━━┓┗━┥ Function │◀━┫
│ (FunctionNode))│ ┆ ┃ └──────────┘ ┃
├─────────────────┤ ┆ ┃ ┌──────────┐ ┃
│(Class │ ┆ ┗━━┥ Function │◀━┛
│ (Field) │ ┆ └──────────┘
│ (Field) │ ┆
│ (Procedure │ ┆
│ (FunctionNode))│ ┆
├─────────────────┤ ┆
┆
На этот момент уже достаточно информации загружено из Kernel binary для успешного определения и вызова методов во время выполнения программы. Например, можно найти и выполнить функцию main из библиотеки.
Изначально все функции имеют заглушку вместо самого выполняемого кода их тел: они указывают на LazyCompileStub, который просто просит среду выполнения генерировать исполняемый код для конкретной функции и затем tail-calls (вызывает хвостовой рекурсией) этот новый сгенерированный код.
┌──────────┐
│ Function │
│ │ LazyCompileStub
│ code_ ━━━━━━▶ ┌─────────────────────────────┐
│ │ │ code = CompileFunction(...) │
└──────────┘ │ return code(...); │
└─────────────────────────────┘
Когда функция компилируется первый раз – это происходит посредством unoptimizing compiler (неоптимизирующего компилятора).
Kernel AST Unoptimized IL Machine Code
╭──────────────╮ ╭──────────────────╮ ╭─────────────────────╮
│ FunctionNode │ │ LoadLocal('a') │ │ push [rbp + ...] │
│ │ │ LoadLocal('b') │ │ push [rbp + ...] │
│ (a, b) => │ ┣━━▶ │ InstanceCall('+')│ ┣━━▶ │ call InlineCacheStub│
│ a + b; │ │ Return │ │ retq │
╰──────────────╯ ╰──────────────────╯ ╰─────────────────────╯
Неоптимизирующий компилятор создает машинный код в две фазы:
Сериализованное AST для тела функции используется для создания control flow graph – CFG (графа потока управления) для тела функции. CFG состоит из базовых блоков, наполненных intermediate language IL инструкциями (инструкциями промежуточного языка). Инструкции IL, используемые на этой стадии, напоминают инструкции виртуальной машины на основе stack: они берут операнды из stack, выполняют операции и затем пушат результат обратно в stack.
CFG, полученный в результате, напрямую компилируется в машинный код, используя понижающие один-ко-многим IL инструкции: каждая IL инструкция расширяется на несколько инструкций машинного кода.
На этой стадии не происходит оптимизации. Основная цель неоптимизирующего компилятора состоит в быстром производстве выполняемого кода.
Это также означает, что неоптимизирующий компилятор не пытается статически resolve (решить) какие-либо вызовы, которые не решились в Kernel binary, поэтому вызовы (MethodInvocation or ProperyGetAST nodes) компилируются, как динамические. VM в настоящий момент не использует никаких форм диспетчеризации на основе виртуальных или интерфейсных таблиц и вместо этого реализует динамические вызовы, используя inline caching
(встроенное кэширование).
Основная идея встроенного кэширования состоит в кэшировании результатов выполнения методов в кэше конкретного участка вызова. Механизм встроенного кэширования используемый VM состоит из:
кэша конкретного участка вызова (
UntaggedICData
объект), сопоставляющего класс получателя к методу, который должен быть выполнен, если получатель относится к соответствующему классу. Кэш также хранит некоторую вспомогательную информацию, например, счётчик частоты вызова, который следит, как часто данный класс был замечен в этом участке вызова;a shared lookup stub (общая заглушка поиска), которая осуществляет быстрое нахождение пути к вызову метода. Эта заглушка пробегается по данному кэшу, чтобы определить содержит ли он записи соответствующие классу получателя. Если запись найдена, заглушка повышает на единицу счётчик частоты вызова и tail-calls закэшированный метод. В ином случае заглушка вызывает runtime system helper (помощника среды выполнения), который реализует логику разрешения метода. Если разрешение метода прошло успешно, тогда кэш обновляется и у последующих методов не будет необходимости входить в среду выполнения.
От себя. Inline caching - это механизм, который позовляет нам кэшировать выполняемый код, а также оптимизировать это выполнение. Этот механизм пробегается по коду и записывает сколько раз та или иная функция вызывается, и если функция встречается не единожды, мы ее кэшируем, чтобы в дальнейшем не выполнять ее снова, а просто подставить результат из кэша.
Картинка ниже иллюстрирует структуру и состояние встроенного кэша связанного с участком вызова animal.toFace()
, который был вызван дважды с инстансом Dog и однократно с Cat.
class Dog {
get face => '?';
}
class Cat {
get face => '?';
} ICData
┌─────────────────────────────────────┐
sameFace(animal, face) ┌─────────▶│// class, method, frequency │
animal.face == face; │ │[Dog, Dog.get:face, 2, │
┬ │ │ Cat, Cat.get:face, 1] │
└──────────────┤ └─────────────────────────────────────┘
sameFace(Dog(), ...); │ InlineCacheStub
sameFace(Dog(), ...); │ ┌─────────────────────────────────────┐
sameFace(Cat(), ...); └─────────▶│ idx = cache.indexOf(classOf(this)); │
│ if (idx != -1) { │
│ cache[idx + 2]++; // frequency++ │
│ return cache[idx + 1](...); │
│ } │
│ return InlineCacheMiss(...); │
└─────────────────────────────────────┘
Неоптимизирующего компилятора самого по себе достаточно, чтобы выполнить любой возможный Dart код. Однако код, который он производит весьма медленный, именно поэтому VM также реализует adaptive optimizing compilation pipeline (адаптивный оптимизирующий пайплайн оптимизации). Идея адаптивной оптимизации заключается в использовании профиля выполнения запущенной программы для принятия решения по оптимизации.
В процессе выполнения неоптимизированного кода собирается следующая информация:
встроенный кэш собирает информацию о типах получателей, обозреваемых на стороне вызовов;
счётчики выполнения, связанные с функциями и базовыми блоками внутри функций, следят за «горячими точками» кода.
Когда счётчики выполнения, связанные с функцией, доходят до определённого порога, эта функция отправляется в фоновый оптимизирующий компилятор для оптимизации.
Оптимизирующая компиляция начинаются с того же, с чего и неоптимизирующая: с прохода по сериализованным Kernel AST для построения неоптимизированных IL для функции, которая будет оптимизирована. Однако вместо понижения этих IL в машинный код напрямую, оптимизирующий компилятор приступает к переводу неоптимизированного IL в оптимизированный IL на базе формы static single assignment (SSA). IL на базе SSA затем подвергается теоретической специализации, основанной на type feedback (типах обратной связи) и проходит через последовательность классических и специфичных для Dart оптимизаций: например, встраивание, нумерация глобальных переменных, снижение уровня распределения и т.д. В конце оптимизированный IL преобразуется в машинный код с использованием распределителя регистров линейной развёртки и простого «один-ко-многим» преобразования инструкций IL.
Как только компиляция завершена, фоновый компилятор запрашивает поток выполнения для ввода safepoint (точки сохранения) и присоединяет оптимизированный код к функции.
В следующий раз, когда эта функция будет вызвана, будет использоваться оптимизированный код. Некоторые функции содержат очень длинные циклы выполнения и для них имеет смысл сменить выполнение с неоптимизированного кода на оптимизированный в процессе выполнения функции. Этот процесс называется on stack replacement (OSR), он получил своё название от того факта, что stack frame (кадр стака, участок стака) для одной версии функции прозрачно замещается stack frame'ом другой версии этой же функции.
in hot code ICs
Kernel AST Unoptimized IL have collected type
╭──────────────╮ ╭───────────────────────╮ ╱ feedback
│ FunctionNode │ │ LoadLocal('a') │ ICData
│ │ │ LoadLocal('b') ┌────▶┌─────────────────────┐
│ (a, b) => │ ┣━━▶ │ InstanceCall:1('+', ┴)│ │[(Smi, Smi.+, 10000)]│
│ a + b; │ │ Return ╱ │ └─────────────────────┘
╰──────────────╯ ╰────────────╱──────────╯
deopt id ┳
┃
SSA IL ▼
╭────────────────────────────────╮
│ v1<-Parameter('a') │
│ v2<-Parameter('b') │
│ v3<-InstanceCall:1('+', v1, v2)│
│ Return(v3) │
╰────────────────────────────────╯
┳
┃
Machine Code ▼ Optimized SSA IL
╭─────────────────────╮ ╭──────────────────────────────╮
│ movq rax, [rbp+...] │ │ v1<-Parameter('a') │
│ testq rax, 1 │ ◀━━┫│ v2<-Parameter('b') │
│ jnz ->deopt@1 │ │ CheckSmi:1(v1) │
│ movq rbx, [rbp+...] │ │ CheckSmi:1(v2) │
│ testq rbx, 1 │ │ v3<-BinarySmiOp:1(+, v1, v2) │
│ jnz ->deopt@1 │ │ Return(v3) │
│ addq rax, rbx │ ╰──────────────────────────────╯
│ jo ->deopt@1 │
│ retq │
╰─────────────────────╯
Важно заметить, что сгенерированный оптимизированным компилятором код специализируется на теоретических предположениях, основанных на профиле выполнения приложения. Например, часть динамического вызова, которая наблюдала экземпляр только класса С
в качестве получателе, будет конвертирована в прямой вызов, который будет предварительно проверять, что получатель является классом С
. Однако такие предположения могут быть нарушены во время выполнения программы:
void printAnimal(obj) {
print('Animal {');
print(' ${obj.toString()}');
print('}');
}
// Call printAnimal(...) a lot of times with an instance of Cat.
// As a result printAnimal(...) will be optimized under the
// assumption that obj is always a Cat.
for (var i = 0; i < 50000; i++)
printAnimal(Cat());
// Now call printAnimal(...) with a Dog - optimized version
// can not handle such an object, because it was
// compiled under assumption that obj is always a Cat.
// This leads to deoptimization.
printAnimal(Dog());
Всякий раз когда оптимизированный код делает оптимистичные предположения, которые могут быть нарушены во время выполнения, нужно быть защищённым от подобных нарушений и иметь возможность восстановиться после их возникновения.
Этот процесс восстановления называется деоптимизацией: каждый раз, когда оптимизированная версия попадает в ситуацию, которая не может быть решена, она просто перенаправляет выполнение в подходящую точку неоптимизированной функции и продолжает работу программы оттуда. Неоптимизированная версия функции не делает никаких предположений и может обработать все возможные входные данные.
VM обычно отбрасывает оптимизированную версию функции после деоптимизации и затем переоптимизирует её снова позже, используя обновлённый тип обратной связи.
Существует два способа, которыми VM защищает оптимистичные предположения, сделанные компилятором:
встроенные проверки (например,
CheckSmi
,CheckClass
IL инструкции) которые проверяют выполняются ли предположения на use site (стороне использования), где компилятор сделал эти предположения. Например, при преобразовании динамических вызовов в прямые компилятор добавляет эти проверки прямо перед прямым вызовом. Деоптимизация, которая производится при таких проверках, называется eager deoptimization (активной деоптимизацией), потому что она производится сразу, как только проверка достигнута.Global guards (глобальная защита), которые инструктируют среду выполнения отбрасывать оптимизированный код, когда изменено что-то, отчего этот оптимизированный код зависел. Например, оптимизирующий компилятор может отслеживать то, что некоторый класс C никогда не наследовался и использовать эту информации в процессе распространения типа. Однако, последующая динамическая загрузка кода или финализация класса может привнести подкласс класса
С
, который аннулирует предположение, получившееся ранее. В этот момент среде выполнения нужно найти и отбросить весь оптимизированный код, который был скомпилирован на основе предположения, что уС
нет никаких подклассов. Возможно, что среда выполнения найдёт некоторый более недействительный оптимизированный код в стаке выполнение – в этом случае затронутые фреймы помечаются для деоптимизации и будут деоптимизированы, когда выполнение вернется к ним. Этот способ деоптимизации называется lazy deoptimization (ленивой деоптимизацией), потому что он откладывается до момента возвращения к оптимизированному коду.
Выполнение со snapshot'а
VM имеет возможность сериализовать изолированную кучу, а точнее объектный граф, находящийся в куче, в бинарный snapshot (снимок данных). Snapshot затем может быть использован для воссоздания того же состояния при запуске изолятов VM.
┌──────────────┐
SNAPSHOT ┌──────────────┐│
┌──────────────┐ ╭─────────╮ ┌──────────────┐││
│ HEAP │ │ 0101110 │ │ HEAP │││
│ ██ │ │ 1011010 │ │ ██ │││
│ ╱ ╲ │ │ 1010110 │ │ ╱ ╲ │││
│ ██╲ ██ │ │ 1101010 │ │ ██╲ ██ │││
│ ╲ ╱ ╲ │┣━━━━━━━━━▶│ 0010101 │┣━━━━━━━━━▶│ ╲ ╱ ╲ │││
│ ╳ ██ │ serialize │ 0101011 │deserialize│ ╳ ██ │││
│ ╱ ╲ ╱ │ │ 1111010 │ │ ╱ ╲ ╱ │││
│ ██ ██ │ │ 0010110 │ │ ██ ██ ││┘
│ │ │ 0001011 │ │ │┘
└──────────────┘ ╰─────────╯ └──────────────┘
Snapshot имеет низкоуровневый и оптимизированный формат для быстрого запуска – это по сути список объектов для создания и инструкций о том, как соединить их вместе. Это была основная идея snapshot’ов: вместо парсинга Dart исходников и постепенного создания внутренних структур данных VM, VM может просто создать изолят со всеми быстрораспакованными из snapshot’а необходимыми структурами данных.
Изначально snapshot’ы не включали в себя машинный код, однако эта возможность позже была добавлена, когда разработали AOT компилятор. Мотивация разработки AOT компилятора и snapshots-with-code (снимков-с-кодом) заключалась в добавлении возможности использования VM на платформах, где невозможно использование JIT из-за их ограничений.
Snapshot’ы с кодом работают практически так же, как и обычные snapshot’ы, с незначительным отличием: они включают разделы кода, которые в отличие от остальной части snapshot’а не требует десериализации. Эти разделы кода располагаются таким образом, который позволяет им напрямую стать частью кучи после попадания в память.
┌──────────────┐
SNAPSHOT ┌──────────────┐│
┌──────────────┐ ╭────╮╭────╮ ┌──────────────┐││
│ HEAP │ │ 01 ││ │ │ HEAP │││
│ ██ │ │ 10 ││ ░░◀──┐ │ ██ │││
│ ╱ ╲ │ │ 10 ││ │ │ │ ╱ ╲ │││
│ ██╲ ██ │ │ 11 ││ │ └────────────██╲ ██ │││
│ ╱ ╲ ╱ ╲ │┣━━━━━━━━━▶│ 00 ││ │┣━━━━━━━━━▶│ ╲ ╱ ╲ │││
│ ░░ ╳ ██ │ serialize │ 01 ││ │deserialize│ ╳ ██ │││
│ ╱ ╲ ╱ │ │ 11 ││ │ │ ╱ ╲ ╱ │││
│ ██ ██╲ │ │ 00 ││ │ │ ██ ██ ││┘
│ ░░ │ │ 00 ││ ░░◀──────────────────────┘ │┘
└──────────╱───┘ ╰────╯╰────╯ └──────────────┘
code data code
Выполнение с AppJIT snapshots
AppJIT snapshot’ы были введены для сокращения времени подготовки JIT для больших Dart приложений, таких как dartanalyzer
или dart2js
. Когда эти утилиты используются на маленьких проектах, они тратят столько же времени для выполнения работы, сколько тратит VM JIT, компилируя эти приложения.
AppJIT snapshot’ы позволяют решить эту проблему: приложение может выполняться на VM, используя некоторые натренированные моковые данные и затем весь сгенерированный код и внутренние структуры данных VM сериализуются в AppJIT snapshot’ы. Затем эти snapshot’ы могут использоваться вместо распространения приложения в исходной форме (или Kernal бинарников). VM, запускаемая с этих snapshot’ов, до сих пор имеет возможность в JIT, если окажется, что профиль выполнения на реальных данных не соответствует профилю выполнения наблюдаемому во время тренировки.
┌──────────────┐
SNAPSHOT ┌──────────────┐│
┌──────────────┐ ╭────╮╭────╮ ┌──────────────┐││
│ HEAP │ │ 01 ││ │ │ HEAP │││
│ ██ │ │ 10 ││ ░░◀──┐ │ ██ │││
│ ╱ ╲ │ │ 10 ││ │ │ │ ╱ ╲ │││
│ ██╲ ██ │ │ 11 ││ │ └────────────██╲ ██ │││
│ ╱ ╲ ╱ ╲ │┣━━━━━━━━━▶│ 00 ││ │┣━━━━━━━━━▶│ ╲ ╱ ╲ │││
│ ░░ ╳ ██ │ serialize │ 01 ││ │deserialize│ ╳ ██ │││
│ ╱ ╲ ╱ │ │ 11 ││ │ │ ╱ ╲ ╱│ │││
│ ██ ██╲ │ │ 00 ││ │ │ ██ ██ │ ││┘
│ ░░ │ │ 00 ││ ░░◀──────────────────────┘ ░░ │┘
└──────────╱───┘ ╰────╯╰────╯ └──────────╱───┘
code data code isolate can JIT more
Выполнение с AppAOT spanshots
AOT snapshot’ы изначально были введены для платформ, не поддерживающих JIT компиляцию, однако, они также могут быть использованы для ситуаций, где быстрый запуск и стабильная производительность стоят потенциального снижения максимальной производительности.
Неспособность выполнения JIT подразумевает, что:
AOT snapshot’ы должны содержать выполняемый код для каждой без исключения функции, которая может быть затронута во время выполнения приложения;
выполняемый код не должен зависеть от каких-либо теоретических предположений, которые могут не подтвердиться во время выполнения.
Для соответствия этим требованиям в процессе AOT компиляции выполняется глобальный статистический анализ (type flow analysis or TFA (анализ потока типов или TFA)) для определения какие части приложения могут быть достигнуты из известного набора точек входа, экземпляры каких классов выделены и какие types flow through the program (типы, которые встречаются в ходе выполнения программы). Все эти методы анализа консервативны, что означает допущение ошибок со стороны корректности – это резко контрастирует с JIT-компиляцией, которая допускает ошибки со стороны производительности, потому что в любой момент может деоптимизировать код в неоптимизированный для исправления выполнения.
Все потенциально достижимые функции затем компилируются в машинный код без каких-либо теоретических оптимизаций. Однако информация о потоке типов в дальнейшем используется для специализации кода (например: девиртуализация вызовов).
Как только все функции скомпилированы можно делать snapshot кучи.
Snapshot, полученный в результате, затем может быть выполнен с использованием precompiled runtime (предварительно скомпилированной среды выполнения), специального варианта Dart VM который исключает такие компоненты, как JIT и средства динамической загрузки кода.
╭─────────────╮ ╭────────────╮
│╭─────────────╮ ╔═════╗ │ Kernel AST │
││╭─────────────╮┣━━━▶ ║ CFE ║ ┣━━━▶ │ │
┆││ Dart Source │ ╚═════╝ │ whole │
┆┆│ │ │ program │
┆┆ ┆ ╰────────────╯
┆ ┆ ┳
┃
▼
╔═════╗ type-flow analysis
║ TFA ║ propagates types globally
VM contains an ╚═════╝ through the whole program
AOT compilation ┳
pipeline which ┃
reuses parts of ▼
JIT pipeline ╭────────────╮
╭────────────╮ ╲ │ Kernel AST │
│AOT Snapshot│ ╔════╗ │ │
│ │◀━━━┫ ║ VM ║ ◀━━━┫ │ inferred │
│ │ ╚════╝ │ treeshaken │
╰────────────╯ ╰────────────╯
Заключение
На этом перевод раздела «Как Dart VM выполняет твой код?» закончен. В этом разделе также встречаются практические задания, однако на них я внимание не заострял, если захотите, можете посмотреть их в оригинальной статье. Буду очень рад комментариям, исправлениям, критике, упущенным мной пояснениям и т.д. Если мой вариант перевода будет кому-то полезен, могу продолжить переводить эту статью.
Спасибо за внимание.