performance - quién - ¿Por qué Julia está tomando mucho tiempo en la primera llamada a mi módulo?
martin cooper (2)
La precompilación puede ser confusa. Intentaré explicar cómo funciona.
Julia carga los módulos analizándolos primero y luego ejecutando las llamadas declaraciones de "nivel superior", una a la vez. Cada declaración de nivel superior se reduce, luego se interpreta (si es posible) o se compila y ejecuta si el intérprete no admite esa declaración de nivel superior en particular.
Lo que hace __precompile__
es en realidad bastante simple (detalles de módulo): realiza todos los pasos mencionados anteriormente en el momento de la precompilación . Tenga en cuenta que los pasos anteriores incluyen la ejecución , lo que puede ser sorprendente si está más familiarizado con los idiomas compilados estáticamente. En general, no es posible precompilar el código dinámico sin ejecutarlo, ya que la ejecución del código puede generar cambios como la creación de nuevas funciones, métodos y tipos.
La diferencia entre una ejecución de precompilación y una ejecución regular es que la información serializable de una ejecución de precompilación se guarda en un caché. Las cosas que se pueden serializar incluyen ASTs de análisis y reducción y resultados de inferencia de tipos.
Esto significa que la precompilación de Julia va mucho más allá de la compilación de la mayoría de los lenguajes estáticos. Por ejemplo, considere el siguiente paquete de Julia que calcula el número 5000000050000000
de una manera bastante ineficiente:
module TestPackage
export n
n = 0
for i in 1:10^8
n += i
end
end
En mi máquina:
julia> @time using TestPackage
2.151297 seconds (200.00 M allocations: 2.980 GB, 8.12% gc time)
julia> workspace()
julia> @time using TestPackage
2.018412 seconds (200.00 M allocations: 2.980 GB, 2.90% gc time)
Ahora vamos a darle la __precompile__()
, cambiando el paquete a
__precompile__()
module TestPackage
export n
n = 0
for i in 1:10^8
n += i
end
end
Y mira la actuación durante y después de la precompilación:
julia> @time using TestPackage
INFO: Precompiling module TestPackage.
2.696702 seconds (222.21 k allocations: 9.293 MB)
julia> workspace()
julia> @time using TestPackage
0.000206 seconds (340 allocations: 16.180 KB)
julia> n
5000000050000000
Lo que sucedió aquí es que el módulo se ejecutó en el momento de la precompilación y se guardó el resultado. Esto es distinto de lo que suelen hacer los compiladores para lenguajes estáticos.
¿Puede la precompilación cambiar el comportamiento de un paquete? Ciertamente. La precompilación es, como se mencionó anteriormente, la ejecución efectiva del paquete en el tiempo de precompilación, en lugar de en el tiempo de carga. Eso no importa para funciones puras (ya que la transparencia referencial garantiza que su resultado siempre será el mismo), y no importa para la mayoría de las funciones impuras, pero sí importa en algunos casos. Supongamos que tenemos un paquete que no hace más que println("Hello, World!")
Cuando se carga. Sin precompilación, se ve así:
module TestPackage
println("Hello, World")
end
Y así se comporta:
julia> using TestPackage
Hello, World
julia> workspace()
julia> using TestPackage
Hello, World
Ahora agreguemos la __precompile__()
, y el resultado es ahora:
julia> using TestPackage
INFO: Precompiling module TestPackage.
Hello, World
julia> workspace()
julia> using TestPackage
No hay salida la segunda vez que se carga! Esto se debe a que el cálculo, println
, ya se realizó cuando se compiló el paquete, por lo que no se vuelve a hacer. Este es el segundo punto de sorpresa para aquellos que están acostumbrados a compilar lenguajes estáticos.
Por supuesto, esto plantea la cuestión de los pasos de inicialización que no se pueden realizar solo en tiempo de compilación; por ejemplo, si mi paquete necesita la fecha y la hora en que se inicializó, o si necesita crear, mantener o eliminar recursos como archivos y sockets. (O, en un caso simple, necesita imprimir información al terminal). Por lo tanto, existe una función especial que no se llama en el tiempo de precompilación, pero se llama en el tiempo de carga. Esta función se llama la función __init__
.
Rediseñamos nuestro paquete de la siguiente manera:
__precompile__()
module TestPackage
function __init__()
println("Hello, World")
end
end
dando el siguiente resultado:
julia> using TestPackage
INFO: Recompiling stale cache file /home/fengyang/.julia/lib/v0.6/TestPackage.ji for module TestPackage.
Hello, World
julia> workspace()
julia> using TestPackage
Hello, World
El punto de los ejemplos anteriores es posiblemente sorprender y, con suerte, iluminar. El primer paso para comprender la precompilación es comprender que es diferente de cómo se compilan los lenguajes estáticos. Lo que significa precompilación en un lenguaje dinámico como Julia es:
- Todas las declaraciones de nivel superior se ejecutan en tiempo de precompilación, en lugar de en tiempo de carga.
- Cualquier declaración que se ejecute en tiempo de carga debe moverse a la función
__init__
.
Esto también debería aclarar por qué la precompilación no está activada de forma predeterminada: no siempre es seguro hacerlo. Los desarrolladores de paquetes deben verificar para asegurarse de que no están usando declaraciones de nivel superior que tengan efectos secundarios o resultados variables, y moverlos a la función __init__
.
Entonces, ¿qué tiene esto que ver con el retraso en la primera llamada a un módulo? Bueno, veamos un ejemplo más práctico:
__precompile__()
module TestPackage
export cube
square(x) = x * x
cube(x) = x * square(x)
end
Y hacer la misma medida:
julia> @time using TestPackage
INFO: Recompiling stale cache file /home/fengyang/.julia/lib/v0.6/TestPackage.ji for module TestPackage.
0.310932 seconds (1.23 k allocations: 56.328 KB)
julia> workspace()
julia> @time using TestPackage
0.000341 seconds (352 allocations: 17.047 KB)
Después de la precompilación, la carga se vuelve mucho más rápida. Esto se debe a que durante la precompilación, se ejecutan las sentencias square(x) = x^2
y el cube(x) = x * square(x)
. Estas son declaraciones de alto nivel como cualquier otra, e implican un cierto grado de trabajo. La expresión debe analizarse, disminuirse, y los nombres deben encuadrarse y encuadernarse dentro del módulo. (También está la declaración de export
, que es menos costosa pero aún debe ejecutarse). Pero como notó:
julia> @time using TestPackage
INFO: Recompiling stale cache file /home/fengyang/.julia/lib/v0.6/TestPackage.ji for module TestPackage.
0.402770 seconds (220.37 k allocations: 9.206 MB)
julia> @time cube(5)
0.003710 seconds (483 allocations: 26.096 KB)
125
julia> @time cube(5)
0.000003 seconds (4 allocations: 160 bytes)
125
julia> workspace()
julia> @time using TestPackage
0.000220 seconds (370 allocations: 18.164 KB)
julia> @time cube(5)
0.003542 seconds (483 allocations: 26.096 KB)
125
julia> @time cube(5)
0.000003 seconds (4 allocations: 160 bytes)
125
¿Que está pasando aqui? ¿Por qué es necesario compilar el cube
de nuevo, cuando existe claramente una __precompile__()
? ¿Y por qué no se guarda el resultado de la compilación?
Las respuestas son bastante sencillas:
- El
cube(::Int)
nunca se compiló durante la precompilación. Esto se puede ver a partir de los siguientes tres hechos: la precompilación es la ejecución, la inferencia de tipo y el código no se producen hasta la ejecución (a menos que sea forzado), y el módulo no contiene una ejecución delcube(::Int)
. - Una vez que escribo el
cube(5)
en el REPL, esto ya no es tiempo de precompilación. Los resultados de mi ejecución REPL no se guardan.
Aquí es cómo solucionar el problema: ejecute la función de cubo en los tipos de argumentos deseados.
__precompile__()
module TestPackage
export cube
square(x) = x * x
cube(x) = x * square(x)
# precompile hints
cube(0)
end
Entonces
julia> @time using TestPackage
INFO: Recompiling stale cache file /home/fengyang/.julia/lib/v0.6/TestPackage.ji for module TestPackage.
0.411265 seconds (220.25 k allocations: 9.200 MB)
julia> @time cube(5)
0.003004 seconds (15 allocations: 960 bytes)
125
julia> @time cube(5)
0.000003 seconds (4 allocations: 160 bytes)
125
Todavía hay algunos gastos generales de primer uso; sin embargo, tenga en cuenta especialmente los números de asignación para la primera ejecución. Esta vez, ya hemos inferido y generado código para el método de cube(::Int)
durante la precompilación. Los resultados de esa inferencia y la generación de código se guardan, y se pueden cargar desde la memoria caché (que es más rápida y requiere mucho menos asignación de tiempo de ejecución) en lugar de rehacerla. Los beneficios son más significativos para las cargas del mundo real que para nuestro ejemplo de juguete, por supuesto.
Pero:
julia> @time cube(5.)
0.004048 seconds (439 allocations: 23.930 KB)
125.0
julia> @time cube(5.)
0.000002 seconds (5 allocations: 176 bytes)
125.0
Ya que solo hemos ejecutado el cube(0)
, solo hemos inferido y compilado el método del cube(::Int)
, por lo que la primera ejecución del cube(5.)
todavía requerirá inferencia y generación de código.
A veces, desea forzar a Julia a compilar algo (posiblemente guardarlo en la memoria caché, si esto sucede durante la precompilación) sin ejecutarlo realmente. Para eso es la función de precompile
, que puede agregarse a sus sugerencias de precompilación.
Como nota final, tenga en cuenta las siguientes limitaciones de la precompilación:
- La precompilación solo almacena en caché los resultados del módulo de su paquete, para las funciones de su paquete. Si depende de funciones de otros módulos, entonces no se precompilarán.
- La precompilación solo soporta resultados serializables. En particular, los resultados que son objetos C y contienen punteros C generalmente no son serializables. Esto incluye
BigInt
yBigFloat
.
Esencialmente la situación que tengo es esta. Tengo un módulo (que también importa un número de otros módulos).
Tengo un guión como:
import MyModule
tic()
MyModule.main()
tic()
MyModule.main()
En MyModule:
__precompile__()
module MyModule
export main
function main()
toc()
...
end
end
La primera llamada toc()
sale alrededor de 20 segundos. Las segundas salidas 2.3e-5. ¿Alguien puede adivinar dónde va el tiempo? ¿Hace Julia algún tipo de inicialización en la primera llamada a un módulo, y cómo puedo averiguar qué es eso?
La respuesta rápida es, la primera vez que ejecutas una función que tiene que compilar, entonces estás midiendo el tiempo de compilación. Si no está al tanto de esto, consulte los consejos de rendimiento .
Pero asumiré que lo sabes, pero todavía te molesta. La razón es porque los módulos en Julia no se compilan: los módulos son EL alcance dinámico. Cuando estás jugando en el REPL, estás trabajando en el módulo Principal. Cuando esté utilizando Juno y haga clic en el código de un módulo, evaluará ese código en el módulo, lo que le dará una forma rápida de jugar dinámicamente en un módulo que no sea Main (creo que puede cambiar el alcance REPL a otro módulo también). Los módulos son dinámicos, por lo que no pueden compilarse (cuando se ve que un módulo precompila, en realidad es solo precompilar muchas de las funciones definidas dentro de él). (Es por esto que cosas dinámicas como eval
suceden en el alcance global de un módulo).
Entonces cuando pones main
en un módulo, no es diferente a tenerlo en el REPL. Los alcances globales de los módulos tienen los mismos problemas de estabilidad de tipo / inferencia que el REPL (pero el REPL es solo el alcance global del módulo Main
). Entonces, al igual que en el REPL, la primera vez que llama a la función que tiene que compilar.