Desafíos de la redacción de reglas

Informar un problema Ver fuente

En esta página, se proporciona una descripción general de alto nivel de los problemas y desafíos específicos de la escritura de reglas eficientes de Bazel.

Resumen de requisitos

  • Suposición: Apunta a la precisión, la capacidad de procesamiento, la facilidad de uso y la latencia
  • Suposición: repositorios de gran escala
  • Suposición: Lenguaje de descripción similar a BUILD
  • Histórica: La separación estricta entre la carga, el análisis y la ejecución está desactualizada, pero aún afecta a la API
  • Funciones intrínsecas: la ejecución remota y el almacenamiento en caché son difíciles
  • Funciones intrínsecas: el uso de la información de cambio para compilaciones incrementales correctas y rápidas requiere patrones de codificación inusuales
  • Funciones intrínsecas: es difícil evitar el tiempo cuadrático y el consumo de memoria

Supuestos

Estas son algunas suposiciones sobre el sistema de compilación, como la necesidad de precisión, facilidad de uso, capacidad de procesamiento y repositorios a gran escala. En las siguientes secciones, se abordan estas suposiciones y se ofrecen lineamientos para garantizar que las reglas se escriban de manera efectiva.

Apunta a la precisión, la capacidad de procesamiento, la facilidad de uso y la latencia.

Suponemos que, en primer lugar, el sistema de compilación debe ser correcto con respecto a las compilaciones incrementales. Para un árbol de fuentes determinado, el resultado de la misma compilación siempre debe ser el mismo, sin importar cómo se vea el árbol de resultados. En la primera aproximación, esto significa que Bazel necesita conocer cada entrada que entra en un paso de compilación determinado, de modo que pueda volver a ejecutar ese paso si alguna de las entradas cambia. Existen límites para el nivel de precisión de Bazel, ya que filtra cierta información, como la fecha y hora de la compilación, y omite ciertos tipos de cambios, como los cambios en los atributos del archivo. La zona de pruebas ayuda a garantizar la precisión, ya que evita las lecturas de archivos de entrada no declarados. Además de los límites intrínsecos del sistema, existen algunos problemas de precisión conocidos, la mayoría de los cuales están relacionados con Fileset o las reglas de C++, que son problemas difíciles. Nos esforzamos a largo plazo para solucionar estos problemas.

El segundo objetivo del sistema de compilación es tener una alta capacidad de procesamiento. Estamos ampliando de forma permanente los límites de lo que se puede hacer dentro de la asignación actual de máquinas para un servicio de ejecución remota. Si se sobrecarga el servicio de ejecución remota, nadie podrá realizar el trabajo.

Lo que sigue es la facilidad de uso. De los múltiples enfoques correctos que tienen la misma huella (o similar) que el servicio de ejecución remota, elegimos el que sea más fácil de usar.

La latencia se refiere al tiempo que lleva iniciar una compilación hasta obtener el resultado deseado, ya sea un registro de prueba de una prueba aprobada o fallida, o un mensaje de error que indica que un archivo BUILD tiene un error tipográfico.

Ten en cuenta que estos objetivos se suelen superponer. La latencia depende tanto de la capacidad de procesamiento del servicio de ejecución remota como de la precisión relevante para facilitar el uso.

Repositorios a gran escala

El sistema de compilación necesita operar a la escala de repositorios grandes, donde a gran escala significa que no cabe en un solo disco duro, por lo que es imposible realizar un proceso de confirmación de la compra completo en prácticamente todas las máquinas de desarrollador. Una compilación de tamaño mediano necesitará leer y analizar decenas de miles de archivos BUILD, y evaluar cientos de miles de globs. Si bien en teoría es posible leer todos los archivos BUILD en una sola máquina, aún no pudimos hacerlo en una cantidad razonable de tiempo y memoria. Por lo tanto, es fundamental que los archivos BUILD se puedan cargar y analizar de forma independiente.

Lenguaje de descripción similar a BUILD

En este contexto, suponemos que el lenguaje de configuración es similar a los archivos BUILD en la declaración de las reglas binarias y de biblioteca, y sus interdependencias. Los archivos BUILD se pueden leer y analizar de forma independiente, y evitamos incluso mirar los archivos de origen siempre que podemos (excepto por existencia).

Lugar histórico

Existen diferencias entre las versiones de Bazel que causan desafíos y algunas de ellas se describen en las siguientes secciones.

La separación estricta entre la carga, el análisis y la ejecución está desactualizada, pero aún afecta a la API

Técnicamente, es suficiente que una regla conozca los archivos de entrada y salida de una acción justo antes de que esta se envíe a ejecución remota. Sin embargo, la base de código original de Bazel tenía una separación estricta de los paquetes de carga, luego analizaban las reglas mediante una configuración (básicamente marcas de línea de comandos) y solo ejecutaba acciones. Esta distinción todavía forma parte de la API de Rules, aunque el núcleo de Bazel ya no la requiere (más detalles a continuación).

Esto significa que la API de reglas requiere una descripción declarativa de la interfaz de la regla (qué atributos tiene y tipos de atributos). Existen algunas excepciones en las que la API permite que se ejecute código personalizado durante la fase de carga para calcular nombres implícitos de archivos de salida y valores implícitos de atributos. Por ejemplo, una regla java_library llamada "foo" genera de forma implícita un resultado denominado "libfoo.jar", al que se puede hacer referencia desde otras reglas en el gráfico de compilación.

Además, el análisis de una regla no puede leer ningún archivo de origen ni inspeccionar el resultado de una acción. En cambio, necesita generar un gráfico bipartito parcial dirigido de pasos de compilación y nombres de archivos de salida que solo se determina a partir de la regla en sí y sus dependencias.

Funciones intrínsecas

Existen algunas propiedades intrínsecas que hacen que las reglas de escritura sean desafiantes y algunas de las más comunes se describen en las siguientes secciones.

La ejecución remota y el almacenamiento en caché son difíciles

La ejecución remota y el almacenamiento en caché mejoran los tiempos de compilación en repositorios grandes en aproximadamente dos órdenes de magnitud en comparación con la ejecución de la compilación en una sola máquina. Sin embargo, la escala a la que debe realizar es sorprendente: el servicio de ejecución remota de Google está diseñado para controlar una gran cantidad de solicitudes por segundo, y el protocolo evita cuidadosamente los recorridos innecesarios y el trabajo innecesario del servicio.

En este momento, el protocolo requiere que el sistema de compilación conozca todas las entradas de una acción determinada con anticipación. Luego, el sistema de compilación calcula una huella digital de acción única y le solicita al programador un acierto de caché. Si se encuentra un acierto de caché, el programador responde con los resúmenes de los archivos de salida; los archivos se abordan en el resumen más adelante. Sin embargo, esto impone restricciones en las reglas de Bazel, que deben declarar todos los archivos de entrada con anticipación.

El uso de información de cambios para compilaciones incrementales correctas y rápidas requiere patrones de codificación inusuales

Arriba, argumentamos que, para ser correcto, Bazel necesita conocer todos los archivos de entrada que se incluyen en un paso de compilación a fin de detectar si ese paso aún está actualizado. Lo mismo sucede con la carga de paquetes y el análisis de reglas, y diseñamos Skyframe para controlar esto en general. Skyframe es una biblioteca de gráficos y un framework de evaluación que toma un nodo objetivo (como "build //foo with these options") y lo desglosa en sus partes constituyentes, que luego se evalúan y combinan para obtener este resultado. Como parte de este proceso, Skyframe lee paquetes, analiza reglas y ejecuta acciones.

En cada nodo, Skyframe realiza un seguimiento exacto de qué nodos usó un nodo determinado para procesar su propio resultado, desde el nodo objetivo hasta los archivos de entrada (que también son nodos de Skyframe). Tener este gráfico representado explícitamente en la memoria permite que el sistema de compilación identifique con exactitud qué nodos se ven afectados por un cambio determinado en un archivo de entrada (incluida la creación o eliminación de un archivo de entrada) y realiza la cantidad mínima de trabajo para restablecer el árbol de resultados a su estado previsto.

Como parte de esto, cada nodo realiza un proceso de descubrimiento de dependencias. Cada nodo puede declarar dependencias y, luego, usar el contenido de esas dependencias para declarar aún más. En principio, esto se asigna bien a un modelo de subproceso por nodo. Sin embargo, las compilaciones medianas contienen cientos de miles de nodos de Skyframe, lo que no es posible con la tecnología actual de Java (y, por razones históricas, actualmente estamos vinculados al uso de Java, por lo que no hay subprocesos básicos ni continuaciones).

En su lugar, usa un conjunto de subprocesos de tamaño fijo. Sin embargo, eso significa que, si un nodo declara una dependencia que aún no está disponible, es posible que debamos anular esa evaluación y reiniciarla (posiblemente en otro subproceso), cuando la dependencia esté disponible. Esto, a su vez, significa que los nodos no deben hacerlo de forma excesiva. Un nodo que declara N dependencias en serie puede reiniciarse N veces, lo que costaría O(N^2). En cambio, nuestro objetivo es la declaración masiva de dependencias por adelantado, que a veces requiere reorganizar el código o incluso dividir un nodo en varios nodos para limitar la cantidad de reinicios.

Ten en cuenta que esta tecnología no está disponible actualmente en la API de reglas. En su lugar, esta API todavía se define con los conceptos heredados de las fases de carga, análisis y ejecución. Sin embargo, una restricción fundamental es que todos los accesos a otros nodos deben pasar por el framework para que pueda realizar un seguimiento de las dependencias correspondientes. Independientemente del lenguaje en el que se implemente el sistema de compilación o en el que estén escritas las reglas (no tienen que ser las mismas), los autores de reglas no deben usar bibliotecas ni patrones estándar que omitan Skyframe. En el caso de Java, eso significa evitar java.io.File, cualquier forma de reflexión y cualquier biblioteca que realice cualquiera de estas acciones. Las bibliotecas que admiten la inserción de dependencias de estas interfaces de bajo nivel deben configurarse correctamente para Skyframe.

Esto sugiere evitar, en primer lugar, exponer a los autores de reglas a un entorno de ejecución de lenguaje completo. El peligro del uso accidental de esas APIs es demasiado grande: varios errores de Bazel en el pasado se producían por reglas que usaban APIs no seguras, incluso si bien las escribieron el equipo de Bazel o otros expertos en dominios.

Evitar el consumo cuadrático de tiempo y memoria es difícil

Para empeorar la situación, además de los requisitos impuestos por Skyframe, las limitaciones históricas del uso de Java y la anticuada API de las reglas, introducir accidentalmente el consumo cuadrático de tiempo o memoria es un problema fundamental en cualquier sistema de compilación basado en reglas binarias y de bibliotecas. Hay dos patrones muy comunes que introducen un consumo de memoria cuadrático (y, por lo tanto, un consumo de tiempo cuadrático).

  1. Cadenas de reglas de biblioteca: Considera el caso de una cadena de reglas de biblioteca A depende de B, depende de C, etcétera. Luego, vamos a calcular alguna propiedad sobre el cierre transitivo de estas reglas, como la ruta de clase del tiempo de ejecución de Java o el comando del vinculador de C++ para cada biblioteca. En primer lugar, podemos tomar una implementación de lista estándar; sin embargo, esto ya introduce un consumo de memoria cuadrático: la primera biblioteca contiene una entrada en la ruta de clase, la segunda dos, la tercera tres y así sucesivamente, para un total de 1+2+3+...+N = O(N^2).

  2. Reglas de objetos binarios según las reglas de la misma biblioteca: Considera el caso en el que un conjunto de objetos binarios que dependen de las mismas reglas de biblioteca (por ejemplo, si tienes varias reglas de prueba que prueban el mismo código de biblioteca). Supongamos que, de N reglas, la mitad son reglas binarias y la otra mitad son reglas de biblioteca. Ahora, considera que cada objeto binario hace una copia de alguna propiedad calculada sobre el cierre transitivo de las reglas de la biblioteca, como la ruta de clase de tiempo de ejecución de Java o la línea de comandos del vinculador de C++. Por ejemplo, podría expandir la representación de cadena de línea de comandos de la acción de vínculo de C++. Las copias N/2 de elementos N/2 son memoria O(N^2).

Clases de colecciones personalizadas para evitar una complejidad cuadrática

Bazel se ve muy afectado por ambas situaciones, por lo que presentamos un conjunto de clases de colecciones personalizadas que comprimen de manera efectiva la información en la memoria evitando la copia en cada paso. Casi todas estas estructuras de datos tienen semántica establecida, por lo que la llamamos depset (también conocida como NestedSet en la implementación interna). La mayoría de los cambios para reducir el consumo de memoria de Bazel durante los últimos años fueron cambios en el uso de dependencias en lugar de lo que se usaba antes.

Por desgracia, el uso de dependencias no resuelve automáticamente todos los problemas; en particular, incluso solo la iteración sobre un depset en cada regla vuelve a introducir un consumo de tiempo cuadrático. Internamente, NestedSets también tiene algunos métodos auxiliares para facilitar la interoperabilidad con clases de colecciones normales. Lamentablemente, pasar un NestedSet a uno de estos métodos por accidente provoca un comportamiento de copia y vuelve a generar un consumo de memoria cuadrático.