En esta página, se proporciona una descripción general de alto nivel de los problemas específicos y los desafíos de escribir reglas eficientes de Bazel.
Requisitos de resumen
- Suposición: Apunta a la exactitud, la capacidad de procesamiento, la facilidad de uso y la latencia
- Suposición: Repositorios a gran escala
- Suposición: Lenguaje de descripción similar a BUILD
- Histórico: la separación estricta entre la carga, el análisis y la ejecución está desactualizada, pero aún afecta a la API.
- Intrínseco: la ejecución remota y el almacenamiento en caché son difíciles
- Intrínseco: usar la información de cambio para compilaciones incrementales correctas y rápidas requiere patrones de codificación inusuales.
- Intrínseco: evitar el tiempo cuadrático y el consumo de memoria es difícil
Suposiciones
A continuación, se muestran algunas suposiciones sobre el sistema de compilación, como la necesidad de corrección, la facilidad de uso, la capacidad de procesamiento y los repositorios a gran escala. En las siguientes secciones, se abordan estas suposiciones y los lineamientos de la oferta para garantizar que las reglas estén escritas de manera efectiva.
Apunta a la exactitud, la capacidad de procesamiento, la facilidad de uso y la latencia
Suponemos que el sistema de compilación debe ser, ante todo, correcto en relación con las compilaciones incrementales. Para un árbol de fuentes determinado, el resultado de la misma compilación siempre debe ser el mismo, independientemente de cómo se vea el árbol de resultados. En la primera aproximación, esto significa que Bazel necesita conocer cada una de las entradas que van a un paso de compilación determinado, de modo que pueda volver a ejecutar ese paso si alguna de las entradas cambia. Existen límites sobre la forma correcta en que se puede obtener Bazel, ya que filtra parte de la información, como la fecha y la hora de la compilación, y, además, ignora ciertos tipos de cambios, como los cambios en los atributos de archivo. La zona de pruebas ayuda a garantizar la precisión, ya que evita las lecturas en archivos de entrada no declarados. Además de los límites intrínsecos del sistema, existen algunos problemas de corrección conocidos, la mayoría de los cuales están relacionados con el conjunto de archivos o las reglas de C++, que son problemas difíciles. Tenemos esfuerzos a largo plazo para corregirlos.
El segundo objetivo del sistema de compilación es tener una capacidad de procesamiento alta. Estamos ampliando de forma permanente los límites de lo que se puede hacer dentro de la asignación de máquinas actual para un servicio de ejecución remota. Si el servicio de ejecución remota se sobrecarga, nadie puede trabajar.
La facilidad de uso viene después. De varios enfoques correctos con la misma huella (o similar) del servicio de ejecución remota, elegimos el que sea más fácil de usar.
La latencia denota el tiempo que tarda desde que se inicia una compilación hasta obtener el resultado deseado, ya sea que se trate de un registro de prueba de una prueba correcta o fallida, o de un mensaje de error que indica que un archivo BUILD
tiene un error tipográfico.
Ten en cuenta que estos objetivos a menudo se superponen; la latencia es una función de la capacidad de procesamiento del servicio de ejecución remota que la precisión relevante para facilitar su uso.
Repositorios a gran escala
El sistema de compilación necesita operar a gran escala de repositorios grandes donde la gran escala significa que no cabe en un solo disco duro, por lo que es imposible hacer una confirmación de la compra completa en casi todas las máquinas de desarrolladores. Una compilación de tamaño mediano necesitará leer y analizar decenas de miles de archivos BUILD
, y evaluar cientos de miles de globs. Aunque es teóricamente posible leer todos los archivos BUILD
en una sola máquina, aún no hemos podido 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 se usa un lenguaje de configuración 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. Evitamos incluso mirar los archivos de origen siempre que sea posible (excepto por la existencia).
Lugar histórico
Existen diferencias entre las versiones de Bazel que generan 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 se envíe a la ejecución remota. Sin embargo, la base de código original de Bazel tenía una separación estricta de paquetes de carga, analizaba reglas con una configuración (las marcas de línea de comandos, básicamente) y solo ejecutaba acciones. Esta distinción sigue siendo parte de la API de reglas en la actualidad, a pesar de que el núcleo de Bazel ya no la requiere (más detalles a continuación).
Eso significa que la API de reglas requiere una descripción declarativa de la interfaz de las reglas (qué atributos tiene, tipos de atributos). Existen algunas excepciones en las que la API permite que se ejecute el código personalizado durante la fase de carga para calcular los nombres implícitos de los archivos de salida y los valores implícitos de los atributos. Por ejemplo, una regla java_library llamada "foo" genera de manera implícita una salida llamada "libfoo.jar", a la que se puede hacer referencia desde otras reglas en el grafo 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 su lugar, debe generar un grafo bipartita dirigido y parcial parcialmente de los pasos de compilación y los nombres de los archivos de salida que solo se determina a partir de la regla en sí y de sus dependencias.
Intrínseco
Existen algunas propiedades intrínsecas que dificultan la escritura de reglas y algunas de las más comunes se describen en las secciones siguientes.
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 en la que debe funcionar es sorprendente: el servicio de ejecución remota de Google está diseñado para manejar una gran cantidad de solicitudes por segundo, y el protocolo evita cuidadosamente las idas y vueltas innecesarias y los trabajos innecesarios en el servicio.
En este momento, el protocolo requiere que el sistema de compilación conozca todas las entradas de una acción dada de antemano; luego, el sistema de compilación calcule una huella digital de acción única y le solicite 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 en sí se abordan mediante el resumen más adelante. Sin embargo, esto impone restricciones a las reglas de Bazel, que deben declarar todos los archivos de entrada con anticipación.
El uso de la información de cambios para compilaciones incrementales rápidas y rápidas requiere patrones de codificación inusuales.
Arriba, argumentamos que, para ser correctos, Bazel necesita conocer todos los archivos de entrada que ingresan a un paso de compilación a fin de detectar si este paso aún está actualizado. Lo mismo ocurre con la carga de paquetes y el análisis de reglas, y diseñamos Skyframe para manejar esto en general. Skyframe es una biblioteca de gráficos y un marco de trabajo de evaluación que toma un nodo objetivo (como “compilar //foo con estas opciones”) y lo divide en las partes que lo conforman. Luego, se evalúan y combinan para generar este resultado. Como parte de este proceso, Skyframe lee paquetes, analiza reglas y ejecuta acciones.
En cada nodo, Skyframe realiza un seguimiento exacto de los nodos que un nodo determinado usó para procesar su propio resultado, desde el nodo objetivo hasta los archivos de entrada (que también son nodos de Skyframe). Tener este grafo representado de forma explícita en la memoria permite que el sistema de compilación identifique exactamente 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), lo que realiza la cantidad mínima de trabajo para restablecer el árbol de salida 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 dependencias. 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 cual no es fácil con la tecnología de Java actual (y, por razones históricas, en este momento estamos vinculados al uso de Java, por lo que no hay subprocesos ligeros ni continuación).
En su lugar, Bazel 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 hacer esto en exceso. Un nodo que declara N dependencias en serie puede reiniciarse N veces, lo que cuesta O(N^2) tiempo. En su lugar, apuntamos a 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 cambio, la API de reglas aún se define mediante los conceptos heredados de 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 marco de trabajo para poder realizar un seguimiento de las dependencias correspondientes. Sin importar el lenguaje en el que se implemente el sistema de compilación o en el que se escriban las reglas (no es necesario que sean iguales), los autores de las reglas no deben usar bibliotecas ni patrones estándar que omitan Skyframe. Para Java, eso significa evitar java.io.File, cualquier forma de reflexión y cualquier biblioteca que lo haga. Las bibliotecas que admiten la inyección de dependencias de estas interfaces de nivel bajo deben configurarse correctamente para Skyframe.
Esto sugiere que no debes exponer los autores de las reglas a un entorno de ejecución de lenguaje completo en primer lugar. El peligro de un uso accidental de esas API es demasiado grande. Muchos errores de Bazel en el pasado se debieron a reglas que usan API no seguras, a pesar de que las reglas fueron escritas por el equipo de Bazel o por otros expertos en dominios.
Evitar el tiempo cuadrático y el consumo de memoria son difíciles
Para empeorar la situación, además de los requisitos que impone Skyframe, las restricciones históricas del uso de Java y la antigüedad de la API de reglas, la introducción accidental de tiempo o consumo de memoria es un problema fundamental en cualquier sistema de compilación basado en bibliotecas y reglas binarias. Existen dos patrones muy comunes que introducen el consumo de memoria cuadrática (y, por lo tanto, el consumo de tiempo cuadrático).
Cadenas de reglas de biblioteca: Considera el caso de una cadena de reglas de biblioteca A depende de B, depende de C, y así sucesivamente. Luego, queremos calcular alguna propiedad sobre el cierre transitivo de estas reglas, como la ruta de clase del entorno de ejecución de Java o el comando del vinculador C++ de cada biblioteca. En principio, podríamos tomar una implementación de lista estándar; sin embargo, esto introduce el consumo de memoria cuadrática: la primera biblioteca contiene una entrada en la ruta de clase, la segunda dos, la tercera, etc., para un total de 1+2+3+...+N = O(N^2).
Reglas binarias según las mismas reglas de biblioteca: Considera el caso en el que un conjunto de objetos binarios que depende 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 las reglas N, la mitad de las reglas son reglas binarias y las otras reglas de la mitad de la biblioteca. Ahora, considera que cada objeto binario hace una copia de alguna propiedad calculada en el cierre transitivo de las reglas de la biblioteca, como la ruta de clase del entorno de ejecución de Java o la línea de comandos del vinculador C++. Por ejemplo, podría expandir la representación de la string de línea de comandos de la acción de vínculo de C++. N/2 copias de elementos N/2 es memoria O(N^2).
Clases de colecciones personalizadas para evitar la complejidad cuadrática
Bazel se ve muy afectado por estas dos situaciones, por lo que presentamos un conjunto de
clases de colección personalizadas que comprimen la información de la memoria de manera efectiva
evitando la copia en cada paso. Casi todas estas estructuras de datos tienen una semántica establecida, por lo que lo llamamos depset (también conocido como NestedSet
en la implementación interna). La mayoría de los
cambios para reducir el consumo de memoria de Bazel en los últimos años fueron
cambios en el uso de dependencias, en lugar de lo que se haya usado antes.
Por desgracia, el uso de dependencias no resuelve automáticamente todos los problemas; en particular, incluso solo la iteración de un conjunto en cada regla vuelve a introducir el consumo cuadrático del tiempo. A nivel interno, 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 de forma accidental conduce a una copia del comportamiento y vuelve a introducir el consumo de memoria cuadrática.