Dart y los beneficios de rendimiento de los tipos sound

Usar soundness y null safety para generar código más rápido y más pequeño.

Usar soundness y null safety para generar código más rápido y más pequeño

#
Código generado a partir del mismo método de Dart en Dart 1.24, 2.0, y 2.12 (de izquierda a derecha) mostrando una reducción de tamaño.
El código generado a partir del mismo método de Dart en Dart 1.24, 2.0, y 2.12 (de izquierda a derecha) se ha vuelto más pequeño. Para ver por qué (y para ver el código generado real), sigue leyendo.

Hemos fortalecido el sistema de tipos de Dart durante los últimos años. El lenguaje Dart original (Dart 1) tenía un sistema de tipos opcional y no sound (similar a los dialectos tipados de JavaScript como el TypeScript de Microsoft o el Flow de Facebook). Dart 2 introdujo un sistema de tipos más estricto y sound. Durante los últimos dos años, hemos estado trabajando en extender aún más el sistema de tipos, mediante sound null safety.

Mientras que un sistema de tipos sound proporciona a los desarrolladores mayor confianza, también permite a nuestros compiladores usar los tipos de forma segura para optimizar el código generado. Con soundness, nuestras herramientas garantizan que los tipos son correctos mediante una combinación de verificación estática y (cuando es necesario) en tiempo de ejecución. Sin soundness, la verificación de tipos solo puede llegar hasta cierto punto, y los tipos estáticos pueden ser incorrectos en tiempo de ejecución.

En la práctica, soundness permite a nuestros compiladores generar código más pequeño y más rápido, particularmente en un entorno ahead-of-time (AOT), donde enviamos código nativo precompilado a los clientes.

Un ejemplo

#

El siguiente método de ejemplo demuestra cómo los tipos sound pueden tener un impacto dramático en código relativamente simple:

**int getAge(Animal a) {
  return a.age;
}**

En nuestra última versión estable de Dart 1 (1.24.3), este método se mapeaba a 26 instrucciones nativas x64 — y eso solo después de instrumentación y optimización guiada por perfiles, lo cual ralentizaba el inicio de la ejecución. Con sound null safety en Dart 2.12, este código se mapea a apenas 3 instrucciones, sin ninguna necesidad de optimización guiada por perfiles.

Dart compila tanto para arquitecturas ARM32/64 como x86/x64. En los ejemplos siguientes usamos x64, pero los resultados son similares en otros objetivos.

El código completo de Dart y el contexto para el método de ejemplo se muestran al final de este artículo, pero aquí están los puntos clave:

  • La clase Animal contiene un campo age de tipo int.

  • Animal tiene varias subclases (Cat, Dog, Snake, Hamster).

  • El método anterior se llama en muchos de estos tipos en tiempo de ejecución.

Estructura de objetos de Dart

#

La clase de Dart Animal, al compilarse a código nativo (x64), tiene una estructura simple:

Estructura de memoria de la clase Animal al compilarse a código nativo.

Los primeros 8 bytes son una cabecera que proporciona información de tipo reificada (es decir, el tipo en tiempo de ejecución del objeto). Los siguientes 8 bytes contienen el campo age. Todas las subclases preservan (y potencialmente añaden a) esta estructura: cualquier campo adicional se dispone después, preservando la estructura del tipo base. El método getAge, dada una instancia de Animal (o cualquier subclase), debería cargar el campo desde un offset de 8 bytes y devolverlo.

Dart 1: Tipos no sound

#

En Dart 1, sin embargo, los tipos estáticos no eran sound y se ignoraban efectivamente durante la compilación. En tiempo de ejecución, no podíamos asumir que el tipo estático era correcto (y, por lo tanto, que la estructura era la esperada). El acceso a age podría ser a un campo en un offset diferente, a un getter que disparaba más código ejecutable, o a un campo inexistente (provocando un error capturable en tiempo de ejecución).

Dart 1 fue diseñado para depender de un compilador just-in-time y una máquina virtual en el dispositivo cliente, que optimizaba el código usando información de tipos en tiempo de ejecución. En este esquema, en realidad compilábamos cada método dos veces: primero, para recolectar información, y segundo (para métodos calientes) para generar código más optimizado basado en el comportamiento observado en tiempo de ejecución.

Dart 1: Primera compilación

#

La primera compilación de getAge produjo las siguientes 47 instrucciones en x64:

Código ensamblador que muestra las 47 instrucciones producidas por la primera compilación de getAge en Dart 1.

Ten en cuenta que este código está instrumentado para determinar qué ocurre en tiempo de ejecución. No asume nada sobre el objeto pasado y efectivamente realiza el equivalente a una búsqueda en tabla hash para encontrar correctamente el campo, ejecutar un getter, o lanzar un error.

Dart 1: Segunda compilación

#

En este caso, el código se llama repetidamente y dispara una segunda compilación optimizadora que genera las siguientes 26 instrucciones:

Código ensamblador que muestra las 26 instrucciones producidas por la compilación optimizadora en Dart 1.

Este código optimizado sigue siendo bastante grande. Se basa en información de perfiles que encontró que el método solo se invocaba en instancias de Cat, Hamster, y Dog, y está optimizado con la suposición de que lo mismo será cierto en el futuro.

El código en azul es el prólogo y el epílogo del método (para configurar y restaurar el stack frame). El código en rojo verifica los casos esperados — que la instancia no sea nula y sea de uno de los tipos vistos previamente — e invoca un camino lento para otros casos. El código en negrita es el trabajo real para cargar el campo.

El código optimizado puede realmente ser más lento si el comportamiento futuro difiere del pasado: si getAge se invoca en una nueva instancia (como un Snake) el código realizará las verificaciones extra pero aún así caerá por el camino lento.

Problemas con el código generado por Dart 1

#

El código generado anteriormente es muy similar en estructura al que produce hoy V8, el motor de JavaScript en Chrome, al recibir un programa JavaScript/TypeScript/Flow más o menos equivalente. Aunque este enfoque (y el código generado correspondiente) puede dar buen rendimiento en muchos escenarios, no era adecuado cuando empezamos (con Flutter en particular) a apuntar a un conjunto más amplio de plataformas cliente, incluyendo dispositivos móviles más sensibles al tamaño y a la huella de memoria:

  • Primero, el coste de la compilación en el lado del cliente aumentaba la huella general de las aplicaciones de Dart.

  • Segundo, el coste de la compilación especulativa en dos fases era perjudicial para el inicio de la aplicación.

  • Tercero, la compilación just-in-time no está permitida en iOS: necesitaríamos una estrategia alternativa para al menos algunos objetivos.

Cambiamos en su lugar a un enfoque de compilación ahead-of-time, pero con Dart 1 resultaba en código considerablemente peor. Incluso con análisis sofisticado de todo el programa, no siempre podíamos determinar la información de tipos en tiempo de compilación, particularmente a medida que las aplicaciones crecían. Además, el coste de la especulación — el código en rojo de arriba — se volvió prohibitivo cuando toda la aplicación era precompilada.

Dart 2: Tipos sound

#

Con Dart 2, introdujimos soundness, lo que nos permitió compilar código de forma segura basándonos en información de tipos y reducir nuestra dependencia del profiling para el rendimiento. Con Dart 2, en una sola compilación ahead-of-time, generamos 10 instrucciones en x64:

Código ensamblador que muestra las 10 instrucciones generadas por Dart 2 en una sola compilación ahead-of-time.

Este código todavía realiza la verificación de nulos (en rojo) y llama a un método helper si encuentra un valor nulo.

Dart 2.12: Sound null safety

#

Con sound null safety, el sistema de tipos es más rico, y nuestro compilador puede aprovecharlo. El compilador puede confiar de forma segura en el tipo (ahora) no nulo y eliminar el código en rojo de arriba. En Dart 2.12 beta, generamos 3 instrucciones menos:

Instrucciones de ensamblador generadas para Dart 2.12 con sound null safety.

De hecho, a medida que el código se ha vuelto más simple, también hemos podido simplificar el prólogo y el epílogo. En nuestra próxima versión estable, generaremos apenas 3 instrucciones para el método de ejemplo:

Instrucciones de ensamblador simplificadas para sound null safety, reducidas a apenas 3 instrucciones.

Con sound null safety, podemos reducir el código generado para este método a su esencia: una carga de campo. En la práctica, una llamada a este método siempre será inlineada, ya que ahora es trivial para el compilador ver que la inlinación es tanto una ganancia de rendimiento como de tamaño de código. La verificación en tiempo de ejecución y el código de compensación ya no son necesarios: más del trabajo pesado ocurre en tiempo de compilación. Ya no necesitamos la sobrecarga de inicio y memoria de la compilación en el lado del cliente. Como resultado, nuestros usuarios obtienen código más pequeño y más rápido.

¡Pruébalo!

#

Te animamos a probar null safety. Está disponible en Dart 2.12, ahora en nuestro canal beta. Una vez que tus dependencias upstream estén migradas, podrás migrar tus propios paquetes y aplicaciones. Como ilustra el ejemplo aquí, puede que no necesites cambiar demasiado.

Recuerda, para obtener los beneficios de rendimiento de null safety, necesitarás una aplicación completamente migrada. Una vez que tu aplicación esté completamente migrada, nuestros compiladores aprovecharán automáticamente null safety para generar código mejor y más pequeño.

PD: El código

#

Aquí está el código completo de Dart que compilé para generar todo el código de este artículo. Aunque el ejemplo aquí es artificial, el patrón — un campo en una jerarquía de clases — es bastante común.

dart
int N = 1000000;

class Animal {
  int age = 0;
}

class Cat extends Animal {}

class Dog extends Animal {}

class Snake extends Animal {}

class Hamster extends Animal {}

List<Animal> _animals = [
  new Cat()..age = 1,
  new Hamster()..age = 2,
  new Dog()..age = 3
];
List<Animal> listOfA = [];
void init() {
  for (int i = 0; i < N; ++i) {
    listOfA.add(_animals[i % _animals.length]);
  }
}

int sum() {
  int k = 0;
  for (int i = 0; i < N; ++i) {
    k += getAge(listOfA[i]);
  }
  return k;
}

@pragma('vm:never-inline')
int getAge(Animal a) {
  return a.age;
}

void main() {
  init();
  print(sum());
  print(getAge(listOfA[0]));
}

Más de Dart