Introducción a Dart VM

Cesar Vega
Introducción a Dart VM
22 min readJan 23, 2019

--

Agradecimientos a Vyacheslav Egorov por su autorización para traducir este artículo al idioma español. El artículo original lo puedes encontrar en https://mrale.ph/dartvm/

Advertencia: Este documento es un trabajo en progreso y se está escribiendo actualmente. Póngase en contacto con Vyacheslav Egorov (por correo o @mraleph) si tiene alguna pregunta, sugerencia o informe de errores. Última actualización: 6 de enero de 2019.

Propósito de este documento

Este documento pretende ser una referencia para los nuevos miembros del equipo de Dart VM, para los potenciales colaboradores externos o para cualquier persona interesada en los productos internos de VM. Comienza con una visión general de alto nivel de la VM de Dart y luego procede a describir varios componentes de la VM en más detalles.

Dart VM es una colección de componentes para ejecutar el código de Dart de forma nativa. En particular, incluye lo siguiente:

#1 Runtime System

  • Modelo Objeto
  • Recolección de basura (Garbage Collection)
  • Instantáneas (Snapshots)

#2 Métodos nativos de las Bibliotecas Core

#3 Componentes de la Experiencia de Desarrollo accesibles a través del protocolo de servicio

  • Depuración (Debugging)
  • Creación de perfiles (Profiling)
  • Recarga en caliente (Hot-Reload)

#4 Pipelines (Segmentado de instrucciones) de compilación Just-in-Time (JIT) y Ahead-of-Time (AOT)

#5 Intérprete

#6 Simuladores ARM

El nombre “Dart VM” es histórico. Dart VM es una máquina virtual en el sentido de que proporciona un entorno de ejecución para un lenguaje de programación de alto nivel, sin embargo, no implica que Dart sea siempre interpretado o compilado JIT, cuando se ejecuta en Dart VM. Por ejemplo, el código de Dart puede ser compilado en código máquina usando el pipeline AOT de Dart VM y luego ejecutado dentro de una versión decapada de Dart VM, llamada pre-compilada en tiempo de ejecución, que no contiene ningún componente del compilador y es incapaz de cargar dinámicamente el código fuente de Dart.

¿Cómo ejecuta Dart VM tu código?

Dart VM tiene múltiples maneras de ejecutar el código, por ejemplo:

  • desde el código fuente o binario del kernel usando JIT;
  • desde los snapshots

desde un snapshot AOT;

desde un snapshot de AppJIT.

Sin embargo, la diferencia principal entre estos radica en cuándo y cómo la máquina virtual (VM) convierte el código fuente de Dart en código ejecutable. El entorno de ejecución que facilita la ejecución sigue siendo el mismo.

Cualquier código de Dart dentro de la VM se está ejecutando dentro de algún isolate (Un Thread process en Dart), que puede ser mejor descrito como un universo aislado de Dart con su propia memoria (heap) y usualmente con su propio hilo de control (mutator thread). Puede haber muchos isolates ejecutando código de Dart concurrentemente, pero no pueden compartir ningún estado directamente y sólo pueden comunicarse por medio de mensajes que pasan a través de puertos (¡no confundir con los puertos de red!).

La relación entre OS threads e isolates es un poco difusa y depende en gran medida de cómo se integra la máquina virtual en una aplicación. Sólo se garantiza lo siguiente:

  • un OS thread sólo puede entrar en un isolate a la vez. Tiene que dejar el isolate actual si quiere entrar en otro isolate;
  • sólo puede haber un único mutator thread asociado a un isolate a la vez. Mutator thread es un hilo que ejecuta código Dart y utiliza la API C pública de VM.

Sin embargo, el mismo OS thread puede primero ingresar un isolate, ejecutar el código de Dart, luego dejar este isolate e ingresar otro isolate. Alternativamente, muchos OS threads diferentes pueden entrar en un isolate y ejecutar código de Dart dentro de él, pero no simultáneamente.

Además de un único mutator thread, un isolate también puede asociarse con varios hilos auxiliares, por ejemplo:

  • Un JIT compiler thread en background;
  • GC threads de limpieza;
  • Marcadores de GC threads concurrentes.

Internamente, VM utiliza un thread pool (TreadPool) para gestionar los OS threads y el código está estructurado alrededor del concepto ThreadPool::Task en lugar de alrededor de un concepto de OS thread. Por ejemplo, en lugar de generar un hilo dedicado para realizar una limpieza en background después de que un GC VM publique un SweeperTasken el pool de hilos VM global y la implementación del pool de hilos seleccione un hilo en espera o genere un nuevo hilo si no hay hilos disponibles. Del mismo modo, la implementación predeterminada de un bucle de eventos para el procesamiento de mensajes isolate, actualmente no genera un bucle de eventos dedicado del thread, sino que publica un MessageHandlerTaskal pool de threads cada vez que llega un mensaje nuevo.

Fuente para leer Class Isolate representa un isolate, class Heap— apila al isolate. Class Thread describe el estado asociado con un thread conectado a un isolate. Tenga en cuenta que el nombre Thread es algo confuso porque todos los OS threads conectados al mismo isolate como un mutator reutilizarían la misma instancia de un Thread. Ve Dart_RunLoop y MessageHandler para la implementación predeterminada del manejo de mensajes de un isolate.

Ejecutar desde la fuente a través de JIT

Esta sección trata de cubrir lo que sucede cuando intentas ejecutar Dart desde la línea de comandos:

// hello.dartmain() => print('Hello, World!');$ dart hello.dartHello, World!

Dado que Dart 2 VM ya no tiene la capacidad de ejecutar directamente Dart desde el código fuente puro, en su lugar VM espera que se le den binarios de kernel (también llamados archivos dill) que contienen ASTs de kernel serializados. La tarea de traducir el código fuente de Dart a Kernel AST es manejada por el front-end común (CFE) escrito en Dart y compartido entre diferentes herramientas de Dart (por ejemplo, VM, dart2js, Dart Dev Compiler).

Para preservar la conveniencia de ejecutar Dart directamente desde la fuente, los ejecutables de Dart independientes tienen un helper isolate denominado Kernel Service, que maneja la compilación del código fuente Dart en el Kernel. VM entonces ejecutaría el Kernel binary resultante.

Sin embargo, esta configuración no es la única manera de organizar CFE y VM para ejecutar el código Dart. Por ejemplo, Flutter separa completamente la compilación al Kernel y la ejecución del Kernel poniéndolos en diferentes dispositivos: la compilación ocurre en la máquina del desarrollador (host) y la ejecución se maneja en el dispositivo móvil destino, que recibe los Kernel binaries que se le envían por medio de la herramienta flutter.

Ten en cuenta que la herramienta flutter no maneja el análisis de Dart en sí, sino que genera otro proceso persistente frontend_server, que es esencialmente una delgada envoltura alrededor de CFE y algunas transformaciones de kernel a kernel específicas de Flutter. frontend_server compila el código fuente de Dart en los archivos del Kernel, que la herramienta flutter envía al dispositivo. La persistencia del proceso frontend_server entra en juego cuando el desarrollador solicita hot-reload: en este caso, el frontend_server puede reutilizar el estado CFE de la compilación anterior y sólo recompilar las partes que realmente han cambiado.

Una vez que el binario del kernel se carga en la VM, se analiza para crear objetos que representen varias entidades del programa. Sin embargo, esto se hace vagamente: al principio sólo se carga información básica sobre bibliotecas y clases. Cada entidad que se origina a partir de un binario del kernel mantiene un puntero de vuelta al binario, de modo que más tarde se puede cargar más información según sea necesario.

Usamos Raw… el prefijo siempre que hablamos de objetos específicos asignados internamente por la VM. Esto sigue la convención de nomenclatura propia de la VM: la disposición de los objetos internos de la VM se define utilizando clases C++ con nombres que empiezan con Raw en la cabecera del archivo raw_object.h. Por ejemplo, RawClass es un objeto VM que describe una clase Dart, RawField es un objeto VM que describe un campo Dart dentro de una clase Dart, etc. Volveremos sobre esto en una sección que cubre el sistema runtime y el modelo de objetos.

La información sobre la clase se deserializa completamente sólo cuando el runtime lo necesite posteriormente (por ejemplo, para buscar a un miembro de la clase, para asignar una instancia, etc.). En esta etapa, los miembros de la clase se leen desde el binario del kernel. Sin embargo, los cuerpos completos de las funciones no se deserializan en esta etapa, sino sólo sus firmas.

En este punto se carga suficiente información desde el binario del kernel para que el runtime resuelva e invoque métodos con éxito. Por ejemplo, podría resolver e invocar la función principal desde una biblioteca.

Fuente para leer package:kernel/ast.dart define las clases que describen el Kernel AST. package:front_end maneja el análisis del código fuente de Dart y la construcción del Kernel AST a partir de él. kernel::KernelLoader::LoadEntireProgrames un punto de entrada para la deserialización del Kernel AST en los objetos VM correspondientes. pkg/vm/bin/kernel_service.dart implementa el Kernel Service isolate, runtime/vm/kernel_isolate.cc encola la implementación de Dart al resto de la VM. package:vmaloja la mayoría de las funcionalidades específicas de VM basadas en el Kernel, por ejemplo varias transformaciones de Kernel a Kernel. Sin embargo, algunas transformaciones específicas de la VM todavía residen en el package:kernel por razones históricas. Un buen ejemplo de una transformación complicada es package:kernel/transformations/continuation.dart, el cual traduce el código fuente de un programa de computadora (o su especificación) a una forma más rigurosa sintácticamente de las funciones async, async* y sync*.

Probándolo. Si estás interesado en el formato del kernel y su uso específico de la VM, puedes usar pkg/vm/bin/grn_kernel.dart para producir un archivo binario del kernel a partir del código fuente Dart. El binario resultante puede ser descartado usando pkg/vm/bin/dump_kernel.dart.

# Toma hello.dart y compilalo al Kernel binary hello.dill usando CFE.$ dart pkg/vm/bin/gen_kernel.dart \— platform out/ReleaseX64/vm_platform_strong.dill \-o hello.dill \hello.dart# Descarte de la representación textual de Kernel AST.$ dart pkg/vm/bin/dump_kernel.dart hello.dill hello.kernel.txt

Cuando intentes usar gen_kernel.dart notarás que requiere algo llamado platform, un binario del kernel que contiene AST para todas las librerías principales (dart:core, dart:async,etc). Si tienes configurado el build del SDK de Dart, puedes utilizar el archivo de plataforma desde el directorio out, por ejemplo, out/ReleaseX64/vm_platform_strong.dill. Alternativamente puedes usar pkg/front_end/tool/_fasta/compile_platform.dart para generar la plataforma.

# Producir archivos de esquema y platforma usando la lista de bibliotecas dada.$ dart pkg/front_end/tool/_fasta/compile_platform.dart \dart:core                                       \sdk/lib/libraries.json                          \vm_outline.dill vm_platform.dill vm_outline.dill

Inicialmente todas las funciones tienen un marcador de posición en lugar de un código realmente ejecutable para sus cuerpos: apuntan a LazyCompileStub, que simplemente pide al sistema runtime que genere código ejecutable para la función actual y luego llama a la cola este código recién generado.

Cuando la función se compila por primera vez, esto se hace no optimizando el compilador.

El compilador no optimizador produce código de máquina en dos pasadas:

  1. El AST serializado para el cuerpo de la función se camina para generar un gráfico de control de flujo (CFG) para el cuerpo de la función. CFG consiste de bloques básicos rellenados con instrucciones de lenguaje intermedio (IL). Las instrucciones IL utilizadas en esta etapa se asemejan a las instrucciones de una máquina virtual basada en stack: toman operandos del stack, realizan operaciones y luego empujan los resultados al mismo stack.
  2. El CFG resultante se compila directamente en código de máquina mediante la reducción de muchas instrucciones IL: cada instrucción IL se expande a instrucciones en múltiples lenguajes de máquina.

En realidad, no todas las funciones tienen cuerpos reales de Dart / Kernel AST, por ejemplo, nativos definidos en C++ o funciones artificiales de arranque generadas por Dart VM, en estos casos IL se crea justamente a partir de la nada, en lugar de generarse a partir del Kernel AST.

No hay optimizaciones realizadas en esta etapa. El objetivo principal de que el compilador no optimize es producir código ejecutable rápidamente.

Esto también significa que el compilador que no optimiza, no intenta resolver estáticamente ninguna llamada que no se haya resuelto en el binario del kernel, por lo que las llamadas (nodos MethodInvocation o PropertyGet AST) se compilan como si fueran completamente dinámicas. Actualmente, VM no utiliza ninguna forma de tabla virtual o de despacho basado en tablas de interfaz y en su lugar implementa llamadas dinámicas utilizando el almacenamiento en inline caching.

La idea central detrás del inline caching es almacenar en caché los resultados de la resolución del método en un caché específico de un sitio de llamadas. El mecanismo de inline caching utilizado por la VM consiste en:

  • una caché específica del sitio de llamada (objeto RawICData) que asigna la clase del receptor a un método, que debería ser invocado si el receptor tiene una clase coincidente. La caché almacena información auxiliar, por ejemplo, contadores de frecuencia de invocación, que rastrean la frecuencia con la que se veía la clase dada en este sitio de llamada;
  • un stube de búsqueda compartido, que implementa la ruta rápida de invocación de métodos. Este stube busca en la caché dada para ver si contiene una entrada que coincida con la clase del receptor. Si se encuentra la entrada, entonces stub incrementaría el contador de frecuencia y el método de caché de llamadas de cola. De lo contrario, stub invocaría a un ayudante del sistema en tiempo de ejecución que implementa la lógica de resolución de métodos. Si la resolución del método tiene éxito, la caché se actualizará y las invocaciones posteriores no necesitarán entrar en el sistema en tiempo de ejecución.

Las implementaciones originales del inline caching en realidad parcheaban el código nativo de la función, de ahí el nombre de inline caching. La idea del inline caching se remonta a Smalltalk-80, ve Implementación eficiente del sistema Smalltalk-80

La imagen a continuación ilustra la estructura y el estado de un inline cache asociada con el sitio de llamadas animal.toFace(), que se ejecutó dos veces con una instancia de Dog y una vez con una instancia de Cat.

El compilador no optimizado por sí mismo es suficiente para ejecutar cualquier código de Dart posible. Sin embargo, el código que produce es bastante lento, es por eso que VM también implementa la optimización adaptativa del pipeline de compilación. La idea detrás de la optimización adaptativa es utilizar el perfil de ejecución de un programa en ejecución para tomar decisiones de optimización.

Como el código no optimizado se está ejecutando, recopila la siguiente información:

  • Las inline caches asociadas a cada sitio de llamadas dinámicas recopilan información sobre los tipos de receptores observados;
  • Los contadores de ejecución asociados con las funciones y los bloques básicos dentro de las funciones siguen las regiones activas del código.

Cuando un contador de ejecución asociado a una función alcanza cierto umbral, esta función se envía a un compilador de optimización en background para su optimización.

La optimización de las compilaciones comienza de la misma manera que la compilación no optimizada: caminando por el Kernel AST serializado para construir IL no optimizado para la función que se está optimizando. Sin embargo, en lugar de reducir directamente esa IL en código de máquina, la optimización del compilador procede a traducir la IL no optimizada en una IL optimizada basada en forma de asignación única estática (SSA). El IL basado en SSA se somete entonces a una especialización especulativa basada en la retroalimentación del tipo recopilado y se pasa a través de una secuencia de optimizaciones clásicas y específicas de Dart: por ejemplo, inline expansión o inlining (optimización que reemplaza el lugar del function call con el cuerpo de la función llamada), análisis de rango, propagación de tipos, selección de representación, reenvío de store-to-load y de load-to-load, numeración del valor global, allocation sinking, etc. Al final, la IL optimizada se reduce en código máquina utilizando un asignador de registro de barrido lineal y una simple reducción uno a muchos de las instrucciones de IL.

Una vez completada la compilación, el compilador en background solicita al mutator thread que introduzca un punto seguro y adjunta un código optimizado a la función. La próxima vez que se llame a la función, ésta utilizará el código optimizado.

Algunas funciones contienen bucles de ejecución muy largos, por lo que tiene sentido cambiar la ejecución de un código no optimizado a uno optimizado mientras la función sigue funcionando. Este proceso se llama on stack replacement (OSR) debido al hecho de que un stack frame para una versión de la función es reemplazado de forma transparente por un stack frame para otra versión de la misma función.

Fuente para leer Las fuentes del compilador están en el directorio runtime/vm/compiler. El punto de entrada al pipeline de compilación es CompileParsedFunctionHelper::Compile. IL se define en runtime/vm/compiler/backend/il.h. La traducción de Kernel a IL comienza en el kernel::StreamingFlowGraphBuilder::BuildGrpah, y esta función también maneja la construcción de IL para varias funciones artificiales. StubeCode::GenerateNArgsCheckInlineCacheStubgenera código de máquina para el inline cache stube, mientras que InlineCacheMissHandlermaneja las fallas de IC. runtime/vm/compiler/compiler_pass.ccdefine la optimización de las pasadas del compilador y su orden. JitCallSpecializer realiza la mayoría de las especializaciones basadas en la retroalimentación de tipo.

Por ejemplo

# Ejecuta test.dart y vuelca el IL optimizado y el código de máquina para# la(s) funcione(s) que contienen "myFunction" en su nombre.# Deshabilita la compilación en background para el determinismo.$ dart --print-flow-graph-optimized         \--disassemble-optimized              \--print-flow-graph-filter=myFunction \--no-background-compilation          \test.dart

Es importante destacar que el código generado por la optimización del compilador está especializado bajo supuestos especulativos basados en el perfil de ejecución de la aplicación. Por ejemplo, un sitio de llamada dinámica que sólo observó instancias de una sola clase C como receptor se convertirá en una llamada directa precedida por un chequeo de verificación que valida que el receptor tiene una clase C esperada. Sin embargo, estas suposiciones pueden violarse más adelante durante la ejecución del programa:

void printAnimal(obj) {print('Animal {');print('  ${obj.toString()}');print('}');}// Llama a printAnimal(...) muchas veces con una instancia de Cat.// Como resultado printAnimal(...) será optimizado bajo la// presunción de que obj es siempre un Cat.for (var i = 0; i < 50000; i++)printAnimal(Cat());// Agora llama a printAnimal(...) con un Dog - versión optimizada// no puede manejarlo como un object, debido a que fué compilado// bajo la presunción de que obj es siempre un Cat.// Esto nos lleva a la desoptimización.printAnimal(Dog());

Cuando el código optimizado está haciendo algunas suposiciones que no pueden ser derivadas de información estáticamente inmutable, necesita protegerse contra la violación de esas suposiciones y ser capaz de recuperarse si tal violación ocurre.

Este proceso de recuperación se conoce como desoptimización: siempre que la versión optimizada llegue a un caso que no puede manejar, simplemente transfiere la ejecución al punto de correspondencia de la función no optimizada y continúa la ejecución allí. La versión no optimizada de una función no hace suposiciones y puede manejar todas las entradas posibles.

Introducir una función no optimizada en el punto correcto es absolutamente crucial porque el código tiene efectos secundarios (por ejemplo, en la función anterior, la desoptimización ocurre después de que ya hemos ejecutado la primera impresión). Las instrucciones de emparejamiento que desoptimizan a las posiciones en el código no optimizado en la VM se hacen usando deopt ids

VM generalmente descarta la versión optimizada de la función después de la desoptimización y luego la reoptimiza de nuevo más tarde , usando retroalimentación de tipo actualizada.

Hay dos maneras en que VM protege las suposiciones especulativas hechas por el compilador:

  • Comprobaciones en línea (por ejemplo, CheckSmi, instrucciones de CheckClass IL) que verifican si la suposición se mantiene en el sitio de uso donde el compilador hizo esta suposición. Por ejemplo, al convertir llamadas dinámicas en llamadas directas, el compilador agrega estas comprobaciones justo antes de una llamada directa. La desoptimización que se produce en estas verificaciones se denomina desoptimización ansiosa, ya que se produce de forma ansiosa a medida que se alcanza la verificación.
  • Guardias globales que indican al runtime que descarte el código optimizado cuando cambia algo en lo que se basa el código optimizado. Por ejemplo, la optimización del compilador podría observar que alguna clase C nunca se extiende y usa esta información durante el paso de propagación de tipos. Sin embargo, la posterior carga dinámica de código o la finalización de la clase pueden introducir una subclase de C, lo que invalida la suposición. En este punto el tiempo de ejecución debe encontrar y descartar todo el código optimizado que fue compilado bajo el supuesto de que C no tiene subclases. Es posible que el runtime encuentre algo del código optimizado, ahora no válido, en la pila de ejecución, en cuyo caso las tramas afectadas se marcarán para desoptimización y se desoptimizarán cuando la ejecución vuelva a ellas. Este tipo de desoptimización se llama desoptimización perezosa: porque se retrasa hasta que el control vuelve al código optimizado.

Fuente para leer La maquinaria del desoptimizador reside en runtime/vm/deopt_intructions.cc. Es esencialmente un mini-intérprete para instrucciones de desoptimización que describen cómo reconstruir el estado necesario del código no optimizado a partir del estado del código optimizado. Las instrucciones de desoptimización son generadas por CompilerDeoptInfo::CreateDeoptInfo para cada ubicación potencial de desoptimización en código optimizado durante la compilación.

Probándolo. Flag — trace-deoptimization hace que la VM imprima información sobre la causa y la ubicación de cada desoptimización que se produce.

— trace-deoptimization-verbose hace que VM imprima una línea para cada instrucción de desoptimización que ejecuta durante la desoptimización.

Ejecutando desde Snapshots

VM tiene la capacidad de serializar isoleate’s heap o, con mayor precisión, el gráfico de objetos que reside en el heap en un snapshot binario. El Snapshot se puede utilizar para recrear el mismo estado cuando se inician los VM isolates.

El formato de Snapshot es de bajo nivel y está optimizado para un inicio rápido. Es esencialmente una lista de objetos a crear e instrucciones sobre cómo conectarlos entre sí. Esa era la idea original detrás de las snapshots: en lugar de analizar la fuente de Dart y crear gradualmente estructuras de datos internas de la VM, la VM puede simplemente aislar todas las estructuras de datos necesarias rápidamente desempaquetadas del snapshot.

Inicialmente, los snapshots no incluían código de máquina, sin embargo, esta capacidad se añadió más tarde cuando se desarrolló el compilador AOT. La motivación para desarrollar el compilador AOT y los snapshots con código, fue permitir que la VM se utilizara en las plataformas en las que el JIT es imposible debido a las restricciones de nivel de la plataforma.

Los snapshots con código funcionan casi de la misma manera que los snapshots normales con una pequeña diferencia: incluyen una sección de código que, a diferencia del resto de los snapshots, no requiere deserialización. Esta sección del código se establece de manera que le permite convertirse directamente en parte del montón después de que se asignó a la memoria.

Fuente para leer runtime/vm/clustered_snapshot.cc maneja serialización y deserialización de snapshots. Una familia de functiones API. Dart_CreateXyzSnapshot[AsAssembly]es responsable por escribir snapshots del heap (p.ej. Dart_CreateAppJITSnapshotAsBlobs y Dart_CreateAppAOTSnapshotAsAssembly).

De otra parte, Dart_CreateIsolateopcionalmente toma snapshot data para iniciar desde un isolate.

Ejecución desde snapshots de AppJIT

Se introdujeron instantáneas de AppJIT para reducir el tiempo de calentamiento de JIT para aplicaciones de Dart de gran tamaño como dartanalyzer o dart2js. Cuando estas herramientas se utilizan en pequeños proyectos, dedican tanto tiempo al trabajo real como VM dedica a la compilación JIT de estas aplicaciones.

Los snapshot de AppJIT permiten abordar este problema: se puede ejecutar una aplicación en la VM utilizando algunos datos de entrenamiento simulados y luego todo el código generado y las estructuras de datos internos de la VM se serializan en un snapshot de AppJIT. Este snapshot puede ser distribuido en lugar de distribuir la aplicación en el formulario fuente (o binario del Kernel). VM a partir de este snapshot todavía puede seguir JIT, si resulta que el perfil de ejecución en los datos reales no coincide con el perfil de ejecución observado durante el entrenamiento.

Probándolo. dart binary generará una instantánea de AppJIT después de ejecutar la aplicación si le pasas --snapshot-kind=app-jit --snapshot=path-to-snapshot to-snapshot

He aquí un ejemplo de cómo generar y utilizar un snapshot de AppJIT para dart2js.

# Ejecuta desde el fuente en modo JIT.$ dart pkg/compiler/lib/src/dart2js.dart -o hello.js hello.dartCompiled 7,359,592 characters Dart to 10,620 characters JavaScript in 2.07 secondsDart file (hello.dart) compiled to JavaScript: hello.js# Ejecución de entrenamiento para generar un snapshot app-jit$ dart --snapshot-kind=app-jit --snapshot=dart2js.snapshot \pkg/compiler/lib/src/dart2js.dart -o hello.js hello.dartCompiled 7,359,592 characters Dart to 10,620 characters JavaScript in 2.05 secondsDart file (hello.dart) compiled to JavaScript: hello.js# Ejecutar desde el snapshot app-jit.$ dart dart2js.snapshot -o hello.js hello.dartCompiled 7,359,592 characters Dart to 10,620 characters JavaScript in 0.73 secondsDart file (hello.dart) compiled to JavaScript: hello.js

Ejecutar desde snapshots de AppAOT

Los snapshots de AOT se introdujeron originalmente para plataformas que hacen que la compilación JIT sea imposible, pero también se pueden utilizar en situaciones en las que un inicio rápido y un rendimiento consistente merecen una posible penalización de rendimiento.

Por lo general, hay mucha confusión sobre cómo se comparan las características de rendimiento de JIT y AOT. JIT tiene acceso a información precisa de tipo local y al perfil de ejecución de la aplicación en ejecución, sin embargo, tiene que pagar por ello con calentamiento. AOT puede inferir y probar varias propiedades globalmente (para las cuales tiene que pagar con el tiempo de compilación), pero no tiene información de cómo se ejecutará realmente el programa, por otro lado, el código compilado por AOT alcanza su máximo rendimiento casi inmediatamente sin necesidad de calentamiento virtual. Actualmente Dart VM JIT tiene el mejor rendimiento pico, mientras que Dart VM AOT tiene el mejor tiempo de arranque.

La incapacidad para JIT implica que:

  • El snapshot AOT debe contener código ejecutable para todas y cada una de las funciones que podrían invocarse durante la ejecución de la aplicación;
  • el código ejecutable no debe basarse en ninguna suposición especulativa que pueda ser violada durante la ejecución;

Para satisfacer estos requisitos, el proceso de compilación de AOT realiza un análisis estático global (análisis de flujo de tipo o TFA) para determinar qué partes de la aplicación son accesibles a partir de un conjunto conocido de puntos de entrada, instancias de qué clases se asignan y cómo fluyen los tipos a través del programa. Todos estos análisis son conservadores: lo que significa que se equivocan en el lado de la corrección, lo que contrasta con el JIT, que puede equivocarse en el lado del rendimiento, ya que siempre puede desoptimizarse en código no optimizado para implementar un comportamiento correcto.

Todas las funciones potencialmente accesibles se compilan en código nativo sin necesidad de realizar optimizaciones especulativas. Sin embargo, el tipo de información de flujo todavía se utiliza para especializar el código (p. ej., desvirtualizar llamadas).

Una vez compiladas todas las funciones, se puede tomar un snapshot del montón.

El snapshot resultante puede ser ejecutada usando el runtime precompilado, una variante especial de la VM de Dart que excluye componentes como JIT e instalaciones de carga de código dinámico.

Fuente para leer package:vm/tranformations/type_flow/tranformer.dart es un punto de entrada al tipo de análisis y transformación de flujos basado en los resultados del TFA. Precompiler::DoCompileAll es un punto de entrada al bucle de compilación AOT en la VM.

Probándolo. El pipeline de compilación de AOT no está actualmente empaquetado en el SDK de Dart y los proyectos que dependen de él (como Flutter) deben construirlo a mano a partir de las piezas proporcionadas por el SDK. El script pkg/vm/tool/precompiler2 es una buena referencia de cómo está estructurado el pipeline y qué artefactos binarios deben ser construidos para usarlo.

# Necesidad de construir un ejecutable Dart normal y un runtime para ejecutar el código AOT.$ tool/build.py -m release -a x64 runtime dart_precompiled_runtime# Ahora compila una aplicación usando el compilador AOT$ pkg/vm/tool/precompiler2 hello.dart hello.aot# Ejecuta el snapshot de AOT utilizando el runtime para el código AOT$ out/ReleaseX64/dart_precompiled_runtime hello.aotHello, World!

Tenga en cuenta que es posible pasar opciones como — print-flow-graph-optimized y — disassemble-optimized al script precompilador2 si desea inspeccionar el código AOT generado.

Llamadas conmutables

Incluso con análisis globales y locales, el código compilado por AOT puede contener sitios de llamada que no pueden ser devirtualizados estáticamente. Para compensar este código compilado AOT y el tiempo de ejecución, utiliza una extensión de la técnica de inline caching utilizada en JIT. Esta versión extendida se denomina llamadas conmutables.

La sección JIT ya describió que cada inline cache asociada a un sitio de llamada consta de dos partes: un objeto caché (representado por una instancia de RawICData) y una porción de código nativo para invocar (p.ej. , un InlineCacheStub). En modo JIT, el runtime sólo actualizaría la propia caché. Sin embargo, en AOT runtime se puede optar por reemplazar tanto la caché como el código nativo a invocar dependiendo del estado del inline cache.

Inicialmente todas las llamadas dinámicas comienzan en el estado unlinked. Cuando se llega a tal sitio de llamada por primera vez se invoca a UnlinkedCallStub, el cual simplemente llama al runtime helper DRT_UnlinkedCall para enlazar este sitio de llamada.

Si es posible, DRT_UnlinkedCall intenta realizar la transición del sitio de llamada a un estado monomórfico. En este estado, el lugar de la llamada se convierte en una llamada directa, que ingresa el método a través de un punto de entrada especial que verifica que el receptor tenga la clase esperada.

En el ejemplo anterior, asumimos que cuando obj.method se ejecutó por primera vez, obj era una instancia de C y obj.method se resolvió como C.method.

La próxima vez que ejecutemos la misma llamada, invocará C.method directamente, evitando cualquier tipo de proceso de búsqueda de métodos. Sin embargo, entraría en C.method a través de un punto de entrada especial, que verificaría que obj sigue siendo una instancia de C. Si no es así, se invocaría DRT_MonomorphicMiss y trataría de seleccionar el siguiente estado del sitio de llamada.

C.method podría seguir siendo un objetivo válido para una invocación, por ejemplo, obj es una instancia de la clase D que se extiende a C pero no anula C.method. En este caso, verificamos si el sitio de llamada podría realizar la transición a un solo estado de destino, implementado por SingleTargetCallStube (ve también RawSingleTargetCache).

Este stub se basa en el hecho de que, para la compilación de AOT, a la mayoría de las clases se les asignan ids enteros que utilizan el recorrido primero en profundidad de la jerarquía de herencia. Si C es una clase base con subclases D0,…,Dn y ninguna de ellas sobreescribe el C.method, entonces C.:cid <= classId(obj) <= max(D0.:cid,...,Dn.:cid ) implica que el método obj.method resuelve a C.method. En estas circunstancias, en lugar de comparar con una sola clase (estado monomórfico), podemos utilizar el rango de verificación del class id (estado objetivo único), que debería funcionar para todas las subclases de la C.

De lo contrario, el sitio de llamada se cambiaría para utilizar un inline cache de búsqueda lineal, similar a la utilizada en el modo JIT (ve ICCallThroughCodeStube, RawICData y DRT_MegamorphicCacheMissHandler ).

Finalmente, si el número de comprobaciones en la matriz lineal supera el umbral, el sitio de llamada se cambia para utilizar una estructura similar a un diccionario (ve MegamorphicCallStube, RawMegamorphicCache y DRT_MegamorphicCacheMissHandler).

Runtime System

Advertencia

Esta sección será escrita próximamente.

Object Model

--

--

Cesar Vega
Introducción a Dart VM

Systems Engineer, postgrade in Software Development, postgrade in Information Security