web-dev-qa-db-fra.com

Pourquoi Julia prend-elle du temps lors du premier appel dans mon module?

Essentiellement, la situation que j'ai est la suivante. J'ai un module (qui importe également un certain nombre d'autres modules).

J'ai un script comme:

import MyModule

tic()
MyModule.main()

tic()
MyModule.main()

Dans MyModule:

__precompile__()

module MyModule
    export main

    function main()
        toc()
        ...
    end
end

Le premier appel toc() sort environ 20 secondes. La deuxième sorties 2.3e-5. Est-ce que quelqu'un peut deviner où va le temps? Julia effectue-t-elle une sorte d'initialisation lors du premier appel dans un module, et comment savoir ce que c'est?

23
reveazure

La précompilation peut être source de confusion. Je vais essayer d'expliquer comment ça marche.

Julia charge les modules en les analysant d'abord, puis en exécutant les instructions dites de "haut niveau", une à la fois. Chaque instruction de niveau supérieur est abaissée, puis interprétée (si possible) ou compilée et exécutée si l'interpréteur ne prend pas en charge cette instruction de niveau supérieur particulière.

Ce que fait __precompile__ Est en fait assez simple (détails du module): il effectue toutes les étapes répertoriées ci-dessus au moment de la précompilation . Notez que les étapes ci-dessus incluent l'exécution , ce qui peut être surprenant si vous êtes plus familier avec les langages compilés statiquement. Il n'est pas possible, en général, de précompiler du code dynamique sans l'exécuter, car l'exécution de code peut entraîner des modifications telles que la création de nouvelles fonctions, méthodes et types.

La différence entre une exécution de précompilation et une exécution régulière est que les informations sérialisables d'une exécution de précompilation sont enregistrées dans un cache. Les éléments qui sont sérialisables incluent les AST provenant de l'analyse et de l'abaissement et les résultats de l'inférence de type.

Cela signifie que la précompilation de Julia va beaucoup plus loin que la compilation de la plupart des langages statiques. Par exemple, considérez le package Julia suivant qui calcule le nombre 5000000050000000 D'une manière assez inefficace:

module TestPackage

export n

n = 0
for i in 1:10^8
    n += i
end

end

Sur ma machine:

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)

Donnons maintenant la directive __precompile__(), en changeant le package en

__precompile__()

module TestPackage

export n

n = 0
for i in 1:10^8
    n += i
end

end

Et regardez les performances pendant et après la précompilation:

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

Ce qui s'est passé ici, c'est que le module a été exécuté au moment de la précompilation et que le résultat a été enregistré. Ceci est différent de ce que font généralement les compilateurs pour les langages statiques.


La précompilation peut-elle modifier le comportement d'un package? Certainement. Comme mentionné précédemment, la précompilation exécute efficacement le package au moment de la précompilation, plutôt qu'au moment du chargement. Cela n'a pas d'importance pour les fonctions pures (car transparence référentielle garantit que leur résultat sera toujours le même), et cela n'a pas d'importance pour la plupart des fonctions impures, mais cela a de l'importance dans certains cas. Supposons que nous avions un package qui ne fait rien d'autre que println("Hello, World!") lors de son chargement. Sans précompilation, cela ressemble à ceci:

module TestPackage

println("Hello, World")

end

Et voici comment cela se comporte:

Julia> using TestPackage
Hello, World

Julia> workspace()

Julia> using TestPackage
Hello, World

Ajoutons maintenant la directive __precompile__(), et le résultat est maintenant:

Julia> using TestPackage
INFO: Precompiling module TestPackage.
Hello, World

Julia> workspace()

Julia> using TestPackage

Il n'y a pas de sortie la deuxième fois qu'il est chargé! C'est parce que le calcul, println, était déjà fait lors de la compilation du paquet, donc il n'est pas refait. C'est le deuxième point de surprise pour ceux habitués à la compilation de langages statiques.

Cela soulève bien sûr la question des étapes d'initialisation qui ne peuvent pas simplement être effectuées au moment de la compilation; par exemple, si mon package a besoin de la date et de l'heure de son initialisation, ou doit créer, maintenir ou supprimer des ressources comme des fichiers et des sockets. (Ou, dans un cas simple, doit imprimer des informations sur le terminal.) Il existe donc une fonction spéciale qui n'est pas appelée au moment de la précompilation, mais est appelée au moment du chargement. Cette fonction est appelée la fonction __init__.

Nous repensons notre package comme suit:

__precompile__()

module TestPackage

function __init__()
    println("Hello, World")
end

end

donnant le résultat suivant:

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

Le but des exemples ci-dessus est peut-être de surprendre et, espérons-le, d'éclairer. La première étape pour comprendre la précompilation est de comprendre qu'elle est différente de la façon dont les langages statiques sont généralement compilés. La précompilation dans un langage dynamique comme Julia signifie:

  • Toutes les instructions de niveau supérieur sont exécutées au moment de la précompilation, plutôt qu'au moment du chargement.
  • Toutes les instructions qui doivent être exécutées au moment du chargement doivent être déplacées vers la fonction __init__.

Cela devrait également expliquer pourquoi la précompilation n'est pas activée par défaut: ce n'est pas toujours sûr! Les développeurs de packages doivent vérifier qu'ils n'utilisent pas d'instructions de niveau supérieur qui ont des effets secondaires ou des résultats variables, et les déplacer vers la fonction __init__.

Alors qu'est-ce que cela a à voir avec le retard au premier appel dans un module? Eh bien, regardons un exemple plus pratique:

__precompile__()

module TestPackage

export cube

square(x) = x * x
cube(x) = x * square(x)

end

Et faites la même mesure:

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)

Après la précompilation, le chargement devient beaucoup plus rapide. En effet, lors de la précompilation, les instructions square(x) = x^2 et cube(x) = x * square(x) sont exécutées. Ce sont des déclarations de haut niveau comme les autres, et elles impliquent un certain degré de travail. L'expression doit être analysée, abaissée et les noms square et cube liés à l'intérieur du module. (Il y a aussi l'instruction export, qui est moins coûteuse mais doit encore être exécutée.) Mais comme vous l'avez remarqué:

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 se passe t-il ici? Pourquoi cube doit-il être à nouveau compilé, alors qu'il existe clairement une directive __precompile__()? Et pourquoi le résultat de la compilation n'est-il pas enregistré?

Les réponses sont assez simples:

  • La cube(::Int) n'a jamais été compilée pendant la précompilation. Cela peut être vu à partir des trois faits suivants: la précompilation est l'exécution, l'inférence de type et le codegen ne se produisent pas avant l'exécution (sauf si forcé), et le module ne contient pas d'exécution de cube(::Int).
  • Une fois que j'ai tapé cube(5) dans le REPL, ce n'est plus du temps de précompilation. Les résultats de mon REPL run ne sont pas enregistrés.

Voici comment résoudre le problème: exécutez la fonction cube sur les types d'arguments souhaités.

__precompile__()

module TestPackage

export cube

square(x) = x * x
cube(x) = x * square(x)

# precompile hints
cube(0)

end

Ensuite

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

Il y a encore des frais généraux de première utilisation; cependant, notez en particulier les numéros d'allocation pour la première exécution. Cette fois, nous avons déjà déduit et généré du code pour la méthode cube(::Int) pendant la précompilation. Les résultats de cette inférence et de la génération de code sont enregistrés et peuvent être chargés à partir du cache (ce qui est plus rapide et nécessite beaucoup moins d'allocation d'exécution) au lieu d'être refait. Les avantages sont plus importants pour les charges réelles que pour notre exemple de jouet, bien sûr.

Mais:

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

Puisque nous n'avons exécuté que cube(0), nous avons seulement déduit et compilé la méthode cube(::Int), et donc la première exécution de cube(5.) nécessitera toujours l'inférence et la génération de code.

Parfois, vous voulez forcer Julia à compiler quelque chose (éventuellement l'enregistrer dans le cache, si cela se produit pendant la précompilation) sans l'exécuter réellement. C'est à cela que sert la fonction precompile, qui peut être ajoutée à vos conseils de précompilation.


Enfin, notez les limitations suivantes de la précompilation:

  • La précompilation ne met en cache que les résultats du module de votre package, pour les fonctions de votre package. Si vous dépendez des fonctions d'autres modules, celles-ci ne seront pas précompilées.
  • La précompilation prend uniquement en charge les résultats sérialisables. En particulier, les résultats qui sont des objets C et contiennent des pointeurs C ne sont généralement pas sérialisables. Cela inclut BigInt et BigFloat.
45
Fengyang Wang

La réponse rapide est que la première fois que vous exécutez une fonction, elle doit être compilée, vous mesurez donc le temps de compilation. Si vous n'êtes pas au courant de cela, consultez les conseils de performance .

Mais je suppose que vous le savez, mais cela vous dérange toujours. La raison en est que les modules de Julia ne se compilent pas: les modules sont LA portée dynamique. Lorsque vous jouez dans le REPL, vous travaillez dans le module principal. Lorsque vous utilisez Juno et cliquez sur le code dans un module, il évaluera ce code dans le module, vous donnant ainsi un moyen rapide de jouer dynamiquement dans un module non principal (je pense que vous pouvez changer le REPL portée à un autre module aussi.) Les modules sont dynamiques donc ils ne peuvent pas compiler (quand vous voyez un module précompiler, c'est en fait juste précompiler beaucoup de fonctions définies à l'intérieur de lui). (C'est pourquoi les choses dynamiques comme eval se produisent dans la portée globale d'un module).

Ainsi, lorsque vous mettez main dans un module, ce n'est pas différent que de l'avoir dans le REPL. Les portées globales des modules ont donc les mêmes problèmes de stabilité de type/inférence que le REPL (mais le REPL n'est que la portée globale du Main module). Ainsi, tout comme dans le REPL, la première fois que vous appelez la fonction, il doit être compilé.

8
Chris Rackauckas