En esta página, se proporciona una descripción general de alto nivel de los problemas y desafíos específicos de escribir 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ó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: El uso de la información de cambios 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 que se hacen sobre el sistema de compilación, como la necesidad de exactitud, facilidad de uso, rendimiento 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 eficaz.
Intenta lograr la precisión, la capacidad de procesamiento, la facilidad de uso y la latencia.
Suponemos que el sistema de compilación debe ser, ante todo, correcto con respecto a las compilaciones incrementales. Para un árbol de origen determinado, el resultado de la misma compilación siempre debe ser el mismo, independientemente de cómo se vea el árbol de salida. En la primera aproximación, esto significa que Bazel necesita conocer cada entrada que ingresa a un paso de compilación determinado, de modo que pueda volver a ejecutar ese paso si cambia alguna de las entradas. Hay límites en la precisión que puede tener Bazel, ya que filtra información como la fecha o la hora de la compilación y omite ciertos tipos de cambios, como los cambios en los atributos de los archivos. La zona de pruebas ayuda a garantizar la exactitud, ya que evita las lecturas de archivos de entrada no declarados. Además de los límites intrínsecos del sistema, hay algunos problemas de exactitud conocidos, la mayoría de los cuales se relacionan con Fileset o las reglas de C++, que son problemas difíciles. Estamos trabajando a largo plazo para solucionarlos.
El segundo objetivo del sistema de compilación es tener una alta capacidad de procesamiento. Superamos 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 podrá realizar el trabajo.
La facilidad de uso es lo siguiente. De varios enfoques correctos con la misma (o similar) huella del servicio de ejecución remota, elegimos el que es más fácil de usar.
La latencia indica el tiempo que transcurre desde que se inicia una compilación hasta que se obtiene 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 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, donde la escala grande significa que no cabe en un solo disco duro, por lo que es imposible realizar una verificación completa en prácticamente todas las máquinas de los desarrolladores. Una compilación de tamaño mediano deberá 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 hemos podido hacerlo en un tiempo y una memoria razonables. 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 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 incluso mirar los archivos fuente siempre que podemos (excepto para verificar su existencia).
Lugar histórico
Hay 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 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 los paquetes de carga, luego analizaba las reglas con una configuración (indicadores de línea de comandos, en esencia) y, solo después, ejecutaba cualquier acción. 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 la regla (qué atributos tiene, 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 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 implícitamente un resultado llamado "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 fuente ni inspeccionar el resultado de una acción. En su lugar, debe generar un grafo bipartito dirigido parcial 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.
Intrínseco
Hay algunas propiedades intrínsecas que hacen que escribir reglas sea un desafío, y algunas de las más comunes se describen en las siguientes secciones.
La ejecución y el almacenamiento en caché remotos son difíciles
La ejecución y el almacenamiento en caché remotos mejoran los tiempos de compilación en repositorios grandes aproximadamente en 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 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 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 en sí se abordan por 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 información de cambios para compilaciones incrementales correctas y rápidas requiere patrones de codificación inusuales.
Anteriormente, argumentamos que, para ser correcto, Bazel necesita conocer todos los archivos de entrada que ingresan a un paso de compilación para detectar si ese paso de compilación 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 marco de evaluación que toma un nodo de objetivo (como "build //foo with these options") y lo divide 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 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 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) y realice la menor cantidad de trabajo para restablecer el árbol de salida en 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 ajusta bien a un modelo de subproceso por nodo. Sin embargo, las compilaciones de tamaño mediano 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 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). 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, en la actualidad, 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. Independientemente del lenguaje en el que se implemente el sistema de compilación o en el que se escriban las reglas (no tienen que ser las mismas), los autores de reglas no deben usar bibliotecas o patrones estándar que omitan Skyframe. En el caso de Java, eso significa evitar java.io.File, así como cualquier forma de reflexión y cualquier biblioteca que lo haga. Las bibliotecas que admiten la inserción de dependencias de estas interfaces de bajo nivel aún deben configurarse correctamente para Skyframe.
Esto sugiere enfáticamente evitar exponer a los autores de 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 bibliotecas en la que A depende de B, que depende de C, etcétera. 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 en el que un conjunto de objetos binarios depende de las mismas reglas de biblioteca, como si tuvieras 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 crea una copia de alguna propiedad calculada sobre la clausura transitiva de las reglas de la biblioteca, como la ruta de acceso 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 vinculación de C++. N/2 copias de N/2 elementos es O(N^2) de memoria.
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 recopilació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 tienen 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 para usar conjuntos de dependencias en lugar de lo que se usaba anteriormente.
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. De forma interna, NestedSets también tiene algunos métodos auxiliares para facilitar la interoperabilidad con las clases de colecciones normales. Lamentablemente, pasar accidentalmente un NestedSet a uno de estos métodos genera un comportamiento de copia y vuelve a introducir el consumo de memoria cuadrático.