En esta página, se ofrece una descripción general de los problemas y desafíos específicos de escritura de reglas de Bazel eficientes.
Resumen de requisitos
- Suposición: Intenta lograr 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órica: La separación estricta entre carga, análisis y ejecución es Está desactualizada, pero aún afecta la API
- Intrínseco: La ejecución remota y el almacenamiento en caché son difíciles
- Funciones intrínsecas: Uso de la información de cambio para compilaciones incrementales correctas y rápidas requiere patrones de codificación inusuales
- Intrínseco: Es difícil evitar el consumo de tiempo y memoria cuadrático
Suposiciones
Estas son algunas suposiciones hechas sobre el sistema de compilación, como la necesidad de precisión, facilidad de uso, capacidad de procesamiento y repositorios a gran escala. El siguientes secciones abordan estas suposiciones y ofrecen lineamientos para garantizar las reglas están escritas de una manera efectiva.
Apunta a la precisión, la capacidad de procesamiento, la facilidad de uso y latencia
Suponemos que el sistema de compilación debe ser, ante todo, correcto con respecto a las compilaciones incrementales. Para un árbol de fuentes determinado, el resultado del La misma compilación siempre debería ser la misma, independientemente de cómo se vea el árbol de resultados. de Cymbal Direct. En la primera aproximación, esto significa que Bazel necesita saber cada uno de entrada que se incluye en un paso de compilación determinado, de modo que pueda volver a ejecutar ese paso si corresponde de las entradas cambia. Existen límites en cuanto a la forma correcta en que puede ser Bazel, debido a fugas. cierta información, como la fecha y la hora de la compilación, e ignora ciertos tipos de cambios, como cambios en los atributos del archivo. Zona de pruebas Ayuda a garantizar la precisión evitando lecturas a archivos de entrada no declarados. Además los límites intrínsecos del sistema, existen algunos problemas de corrección conocidos, la mayoría de las cuales están relacionadas con el conjunto de archivos o con las reglas de C++, que son duras problemas. A largo plazo, tenemos que tomar medidas para solucionarlos.
El segundo objetivo del sistema de compilación es tener una alta capacidad de procesamiento. somos superar de forma permanente los límites de lo que se puede hacer dentro del asignación de máquinas para un servicio de ejecución remota. Si la ejecución remota el servicio se sobrecarga, nadie puede realizar su trabajo.
La facilidad de uso es lo siguiente. De varios enfoques correctos con el mismo (o similar) del servicio de ejecución remota, elegimos el que sea y fácil de usar.
La latencia denota el tiempo que se tarda desde el inicio de una compilación hasta la obtención de la respuesta deseada.
resultado, ya sea un registro de prueba de una prueba aprobada o fallida, o un error
mensaje de que un archivo BUILD
tiene un error tipográfico.
Ten en cuenta que estos objetivos suelen superponerse. La latencia es tanto una función de la capacidad de procesamiento del servicio de ejecución remota como la corrección relevante para la facilidad de uso.
Repositorios a gran escala
El sistema de compilación debe operar a la escala de repositorios grandes en los que haya
escala significa que no cabe en un solo disco duro, por lo que es imposible
un proceso de pago completo
en casi todas las máquinas de los desarrolladores. Una compilación de tamaño medio
deberá leer y analizar decenas de miles de archivos BUILD
y evaluar
cientos de miles de globs. Si bien es teóricamente posible leer todas
BUILD
en una sola máquina, aún no hemos podido hacerlo en un
de tiempo y memoria razonables. Por lo tanto, es fundamental que los archivos BUILD
pueden cargarse y analizarse de forma independiente.
Lenguaje de descripción similar a BUILD
En este contexto, suponemos un lenguaje de configuración que es similar a los archivos BUILD
en la declaración de reglas binarias y de bibliotecas, y sus interdependencias. Los archivos BUILD
se pueden leer y analizar de forma independiente.
y evitamos siquiera mirar los archivos de origen siempre que podamos (excepto por
existencia).
Lugar histórico
Hay diferencias entre las versiones de Bazel que plantean desafíos y algunas de estos, 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 la acción se envíe a la ejecución remota. Sin embargo, el la base de código original de Bazel tenía una separación estricta de carga de paquetes y, luego, analizar reglas con una configuración (básicamente, marcas de la línea de comandos) para luego ejecutar acciones. Esta distinción sigue siendo parte de la API de reglas en la actualidad, aunque el núcleo de Bazel ya no lo requiere (más detalles a continuación).
Eso significa que la API de reglas requiere una descripción declarativa de la interfaz de la regla (qué atributos tiene, tipos de atributos). Existen algunos excepciones en las que la API permite la ejecución de código personalizado durante la fase de carga procesar nombres implícitos de archivos de salida y valores implícitos de atributos. Para ejemplo, una regla de java_library llamada "foo" genera implícitamente una salida llamada "libfoo.jar", al que se puede hacer referencia desde otras reglas del 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; sino que debe generar un bipartito dirigido gráfico de pasos de compilación y nombres de archivos de salida que solo se determina a partir de la regla y sus dependencias.
Intrínseco
Hay algunas propiedades intrínsecas que hacen que la escritura de reglas sea un desafío y algunos de los 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, ya que en aproximadamente dos órdenes de magnitud en comparación con ejecutar la compilación en un solo máquina. Sin embargo, la escala a la que debe realizar su trabajo es asombrosa: 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, así como el trabajo innecesario del servicio.
En este momento, el protocolo requiere que el sistema de compilación conozca todas las entradas de un realizar una acción con anticipación; el sistema de compilación procesa una acción única huella digital 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 en sí se abordan por resumen más adelante. Sin embargo, esto impone restricciones al archivo de entrada, que deben declarar todos los archivos de entrada con anticipación.
El uso de información de cambios para compilaciones incrementales rápidas y correctas requiere patrones de programación inusuales
Arriba, argumentamos que, para ser correcto, Bazel necesita conocer todas las entradas que se incluyen en un paso de compilación para detectar si ese paso aun 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 marco de evaluación que toma un objetivo (como “crear //foo con estas opciones”) y lo desglosa las partes que las conforman, que luego se evalúan y combinan para obtener este resultado. Como parte del proceso, Skyframe lee paquetes, analiza reglas y que ejecuta acciones.
En cada nodo, Skyframe realiza un seguimiento exacto de los nodos que un nodo determinado usó para calcular su propia salida, desde el nodo de destino 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 exactamente qué nodos se ven afectados por un en un archivo de entrada (incluida la creación o eliminación de un archivo de entrada), 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 dependencias. En principio, esto se relaciona bien modelo de subproceso por nodo. Sin embargo, las compilaciones medianas contienen cientos de miles de nodos de Skyframe, lo que no es fácil con la versión actual de Java (y por razones históricas, actualmente, estamos obligados a usar Java, por lo que sin subprocesos ligeros ni continuaciones).
En su lugar, Bazel usa un grupo 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 abortar 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 de forma excesiva. Un nodo que declara N dependencias de forma serial puede reiniciarse N veces, lo que cuesta O(N^2) de tiempo. En cambio, nuestro objetivo es la declaración masiva y anticipada de dependencias, lo que, a veces, requiere reorganizar el código o incluso dividir un nodo en varios para limitar la cantidad de reinicios.
Ten en cuenta que, actualmente, esta tecnología no está disponible en la API de reglas. En su lugar, la API de reglas aún 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 hacer un seguimiento de las dependencias correspondientes. Sin importar el lenguaje en que se use el sistema de compilación se implementa o en la que se escriben las reglas (no tienen que ser iguales), los autores de las reglas no deben usar bibliotecas ni patrones estándar que omitan Skyframe. Para Java, esto significa evitar java.io.File así como cualquier forma de reflexión y cualquier biblioteca que haga cualquiera de ellas. Las bibliotecas que admiten la inserción de dependencias de estas interfaces de bajo nivel aún deben configurarse correctamente para Skyframe.
Esto sugiere evitar exponer a los autores de las reglas a un entorno de ejecución de lenguaje completo. en primer lugar. El peligro del uso accidental de esas APIs es demasiado grande. En el pasado, varios errores de Bazel se debieron a reglas que usaban APIs no seguras, aunque el equipo de Bazel o algún otro experto en el dominio las haya escrito.
Evitar el tiempo cuadrático y el consumo de memoria es difícil.
Para empeorar las cosas, además de los requisitos que impone Skyframe, las restricciones históricas de usar Java y la obsolescencia de la API de reglas, introducir accidentalmente el tiempo cuadrático o el consumo de memoria es un problema fundamental en cualquier sistema de compilación basado en reglas de bibliotecas y binarios. Existen dos patrones muy comunes que introducen un consumo de memoria cuadrático (y, por lo tanto, un consumo de tiempo cuadrático).
Cadenas de reglas de bibliotecas Considera el caso de una cadena de reglas de biblioteca: A depende de B, depende de C, y y así sucesivamente. Luego, queremos calcular alguna propiedad sobre la clausura transitiva de estas reglas, como la ruta de acceso de clases del entorno de ejecución de Java o el comando del vinculador de C++ para cada biblioteca. De manera ingenua, podríamos 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 acceso de clases, la segunda dos, la tercera tres, y así sucesivamente, para un total de 1+2+3+...+N = O(N2) entradas.
Reglas binarias que dependen de las mismas reglas de biblioteca: Considera el caso de un conjunto de objetos binarios que dependen de las mismas reglas de biblioteca, como si tuvieras varias reglas de prueba que prueban el mismo código de biblioteca. Digamos que, de N reglas, la mitad de las reglas son binarias y la otra mitad de las reglas de la 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 del tiempo de ejecución de Java o la línea de comandos del vinculador C++. Por ejemplo, podría expandir la representación de cadena de línea de comandos de la acción de vinculación de C++. N/2 copias de N/2 elementos es la memoria O(N^2).
Clases de colecciones personalizadas para evitar la complejidad cuadrática
Bazel se ve muy afectado por ambas situaciones, por lo que presentamos un conjunto de
clases de colección personalizadas que comprimen de manera eficaz la información en la memoria evitando
la copia en cada paso. Casi todas estas estructuras de datos han establecido
semántica, así que la llamamos
depset
(también conocido como NestedSet
en la implementación interna). La mayoría de
para reducir el consumo de memoria de Bazel en los últimos años
para usar depsets en lugar de lo que se haya usado antes.
Lamentablemente, el uso de conjuntos de dependencias no resuelve automáticamente todos los problemas. En particular, incluso si solo se itera sobre un conjunto de dependencias en cada regla, se vuelve a introducir el 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 accidentalmente un NestedSet a uno de estos métodos genera copias y vuelve a introducir el consumo de memoria cuadrática.