El modelo de evaluación paralela y de incrementalidad de Bazel.
Modelo de datos
El modelo de datos consta de los siguientes elementos:
SkyValue. También llamados nodos.SkyValuesson objetos inmutables que contienen todos los datos compilados durante la compilación y las entradas de la compilación. Algunos ejemplos son los archivos de entrada, los archivos de salida, los destinos y los destinos configurados.SkyKey. Un nombre corto e inmutable para hacer referencia a unSkyValue, por ejemplo,FILECONTENTS:/tmp/foooPACKAGE://foo.SkyFunction. Compila nodos en función de sus claves y nodos dependientes.- Gráfico de nodos. Una estructura de datos que contiene la relación de dependencia entre los nodos.
Skyframe. Nombre de código para el framework de evaluación incremental en el que se basa Bazel.
Evaluación
Una compilación consiste en evaluar el nodo que representa la solicitud de compilación (este es el estado al que aspiramos, pero hay mucho código heredado en el camino). Primero, se encuentra y se llama a su SkyFunction con la clave del SkyKey de nivel superior. Luego, la función solicita la evaluación de los nodos que necesita para evaluar el nodo de nivel superior, lo que, a su vez, genera otras invocaciones de funciones, y así sucesivamente, hasta que se alcanzan los nodos hoja (que suelen ser nodos que representan archivos de entrada en el sistema de archivos). Por último, obtenemos el valor del SkyValue de nivel superior, algunos efectos secundarios (como archivos de salida en el sistema de archivos) y un gráfico acíclico dirigido de las dependencias entre los nodos que participaron en la compilación.
Un SkyFunction puede solicitar SkyKeys en varias pasadas si no puede indicar con anticipación todos los nodos que necesita para hacer su trabajo. Un ejemplo simple es evaluar un nodo de archivo de entrada que resulta ser un vínculo simbólico: la función intenta leer el archivo, se da cuenta de que es un vínculo simbólico y, por lo tanto, recupera el nodo del sistema de archivos que representa el destino del vínculo simbólico. Pero eso en sí mismo puede ser un vínculo simbólico, en cuyo caso la función original también deberá recuperar su destino.
Las funciones se representan en el código mediante la interfaz SkyFunction y los servicios que le proporciona una interfaz llamada SkyFunction.Environment. Estas son las acciones que pueden realizar las funciones:
- Solicitar la evaluación de otro nodo llamando a
env.getValue. Si el nodo está disponible, se muestra su valor; de lo contrario, se muestranully se espera que la función muestrenull. En este último caso, se evalúa el nodo dependiente y, luego, se vuelve a invocar el compilador de nodos original, pero esta vez la misma llamadaenv.getValuemostrará un valor que no esnull. - Solicitar la evaluación de varios nodos llamando a
env.getValues(). Esto hace esencialmente lo mismo, excepto que los nodos dependientes se evalúan en paralelo. - Realizar cálculos durante su invocación
- Tener efectos secundarios, por ejemplo, escribir archivos en el sistema de archivos. Se debe tener cuidado de que dos funciones diferentes no se superpongan. En general, los efectos secundarios de escritura (en los que los datos fluyen hacia afuera de Bazel) son correctos, mientras que los efectos secundarios de lectura (en los que los datos fluyen hacia adentro de Bazel sin una dependencia registrada) no lo son, ya que son una dependencia no registrada y, como tal, pueden causar compilaciones incrementales incorrectas.
Las implementaciones de SkyFunction no deben acceder a los datos de ninguna otra manera que no sea solicitando dependencias (como leer directamente el sistema de archivos), ya que esto hace que Bazel no registre la dependencia de datos en el archivo que se leyó, lo que genera compilaciones incrementales incorrectas.
Una vez que una función tiene suficientes datos para hacer su trabajo, debe mostrar un valor que no sea null que indique que se completó.
Esta estrategia de evaluación tiene varios beneficios:
- Hermeticidad. Si las funciones solo solicitan datos de entrada dependiendo de otros nodos, Bazel puede garantizar que, si el estado de entrada es el mismo, se mostrarán los mismos datos. Si todas las funciones de Sky son deterministas, esto significa que toda la compilación también será determinista.
- Incrementalidad correcta y perfecta. Si se registran todos los datos de entrada de todas las funciones, Bazel puede invalidar solo el conjunto exacto de nodos que deben invalidarse cuando cambian los datos de entrada.
- Paralelismo. Dado que las funciones solo pueden interactuar entre sí solicitando dependencias, las funciones que no dependen entre sí se pueden ejecutar en paralelo, y Bazel puede garantizar que el resultado sea el mismo que si se ejecutaran de forma secuencial.
Incrementalidad
Dado que las funciones solo pueden acceder a los datos de entrada dependiendo de otros nodos, Bazel puede crear un gráfico de flujo de datos completo desde los archivos de entrada hasta los archivos de salida y usar esta información para volver a compilar solo los nodos que realmente necesitan volver a compilarse: el cierre transitivo inverso del conjunto de archivos de entrada modificados.
En particular, existen dos posibles estrategias de incrementalidad: la ascendente y la descendente. La que es óptima depende de cómo se vea el gráfico de dependencias.
Durante la invalidación ascendente, después de que se compila un gráfico y se conoce el conjunto de entradas modificadas, se invalidan todos los nodos que dependen de forma transitiva de los archivos modificados. Esto es óptimo si sabemos que se volverá a compilar el mismo nodo de nivel superior. Ten en cuenta que la invalidación ascendente requiere ejecutar
stat()en todos los archivos de entrada de la compilación anterior para determinar si se modificaron. Esto se puede mejorar usandoinotifyo un mecanismo similar para obtener información sobre los archivos modificados.Durante la invalidación descendente, se verifica el cierre transitivo del nodo de nivel superior y solo se conservan los nodos cuyo cierre transitivo está limpio. Esto es mejor si sabemos que el gráfico de nodos actual es grande, pero solo necesitamos un subconjunto pequeño en la próxima compilación: la invalidación ascendente invalidaría el gráfico más grande de la primera compilación, a diferencia de la invalidación descendente, que solo recorre el gráfico pequeño de la segunda compilación.
Actualmente, solo realizamos la invalidación ascendente.
Para obtener más incrementalidad, usamos la poda de cambios: si se invalida un nodo, pero, cuando se vuelve a compilar, se descubre que su valor nuevo es el mismo que el anterior, los nodos que se invalidaron debido a un cambio en este nodo se “resucitan”.
Esto es útil, por ejemplo, si se cambia un comentario en un archivo C++: entonces, el archivo .o que se genera a partir de él será el mismo, por lo que no necesitamos volver a llamar al vinculador.
Vinculación / compilación incremental
La principal limitación de este modelo es que la invalidación de un nodo es una cuestión de todo o nada: cuando cambia una dependencia, el nodo dependiente siempre se vuelve a compilar desde cero, incluso si existiera un algoritmo mejor que mutara el valor anterior del nodo en función de los cambios. Estos son algunos ejemplos en los que esto sería útil:
- Vinculación incremental
- Cuando cambia un solo archivo
.classen un archivo.jar, teóricamente podríamos modificar el archivo.jaren lugar de volver a compilarlo desde cero.
El motivo por el que Bazel actualmente no admite estas acciones de manera fundamentada (tenemos cierto nivel de compatibilidad con la vinculación incremental, pero no está implementada en Skyframe) es doble: solo obtuvimos ganancias de rendimiento limitadas y fue difícil garantizar que el resultado de la mutación sea el mismo que el de una recompilación limpia, y Google valora las compilaciones que se pueden repetir bit por bit.
Hasta ahora, siempre podíamos lograr un rendimiento lo suficientemente bueno simplemente descomponiendo un paso de compilación costoso y logrando una reevaluación parcial de esa manera: divide todas las clases de una app en varios grupos y realiza la dexificación en ellas por separado. De esta manera, si las clases de un grupo no cambian, no es necesario volver a realizar la dexificación.
Asignación a conceptos de Bazel
Esta es una descripción general aproximada de algunas de las SkyFunction implementaciones que usa Bazel para realizar una compilación:
- FileStateValue. El resultado de un
lstat(). Para los archivos existentes, también calculamos información adicional para detectar cambios en el archivo. Este es el nodo de nivel más bajo en el gráfico de Skyframe y no tiene dependencias. - FileValue. Lo usa todo lo que se preocupa por el contenido real o la ruta resuelta de un archivo. Depende del
FileStateValuecorrespondiente y de cualquier vínculo simbólico que deba resolverse (como elFileValuedea/bnecesita la ruta resuelta deay la ruta resuelta dea/b). La distinción entreFileStateValuees importante porque, en algunos casos (por ejemplo, cuando se evalúan los comodines del sistema de archivos, comosrcs=glob(["*/*.java"])), no se necesita el contenido del archivo. - DirectoryListingValue. Esencialmente, el resultado de
readdir(). Depende delFileValueasociado con el directorio. - PackageValue. Representa la versión analizada de un archivo BUILD. Depende del
FileValuedel archivoBUILDasociado y, de forma transitiva, de cualquierDirectoryListingValueque se use para resolver los comodines en el paquete (la estructura de datos que representa el contenido de un archivoBUILDde forma interna). - ConfiguredTargetValue. Representa un destino configurado, que es una tupla del conjunto de acciones generadas durante el análisis de un destino y la información proporcionada a los destinos configurados que dependen de este. Depende del
PackageValueen el que se encuentra el destino correspondiente, losConfiguredTargetValuesde las dependencias directas y un nodo especial que representa la configuración de compilación. - ArtifactValue. Representa un archivo en la compilación, ya sea un artefacto fuente o de salida (los artefactos son casi equivalentes a los archivos y se usan para hacer referencia a los archivos durante la ejecución real de los pasos de compilación). Para los archivos fuente, depende del
FileValuedel nodo asociado; para los artefactos de salida, depende delActionExecutionValuede cualquier acción que genere el artefacto. - ActionExecutionValue. Representa la ejecución de una acción. Depende del
ArtifactValuesde sus archivos de entrada. La acción que ejecuta actualmente está contenida en su clave de Sky, lo que es contrario al concepto de que las claves de Sky deben ser pequeñas. Estamos trabajando para resolver esta discrepancia (ten en cuenta queActionExecutionValueyArtifactValueno se usan si no ejecutamos la fase de ejecución en Skyframe).