web-dev-qa-db-ja.com

Juliaがモジュールへの最初の呼び出しで長い時間がかかるのはなぜですか?

基本的に私が持っている状況はこれです。私はモジュールを持っています(これは他の多くのモジュールもインポートします)。

私は次のようなスクリプトを持っています:

_import MyModule

tic()
MyModule.main()

tic()
MyModule.main()
_

MyModule内:

___precompile__()

module MyModule
    export main

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

最初のtoc()呼び出しは約20秒を出力します。 2番目の出力は2.3e-5です。誰が時間の流れを推測できますか? Juliaはモジュールへの最初の呼び出しである種の初期化を行いますか、そしてそれが何であるかをどうやって理解できますか?

23
reveazure

プリコンパイルは混乱を招く可能性があります。それがどのように機能するかを説明しようと思います。

Juliaはまずモジュールを解析してから、いわゆる「トップレベル」ステートメントを一度に1つずつ実行して、モジュールをロードします。各トップレベルのステートメントは下げられ、解釈され(可能な場合)、インタープリターがその特定のトップレベルのステートメントをサポートしていない場合はコンパイルされて実行されます。

___precompile___が行うことは、実際にはかなり単純です(モジュロの詳細):上記のすべてのステップをプリコンパイル時に実行します。上記の手順にはexecutionが含まれていることに注意してください。静的にコンパイルされた言語に慣れている場合、これは驚くべきことです。コードを実行すると、新しい関数、メソッド、タイプの作成などの変更が生じる可能性があるため、一般に、動的コードを実行せずにプリコンパイルすることはできません。

プリコンパイル実行と通常の実行の違いは、プリコンパイル実行からのシリアライズ可能な情報がキャッシュに保存されることです。シリアライズ可能なものには、解析および低下からのASTと型推論の結果が含まれます。

つまり、Juliaのプリコンパイルは、ほとんどの静的言語のコンパイルよりもはるかに進んでいます。たとえば、数_5000000050000000_を非常に非効率的な方法で計算する次のJuliaパッケージを考えてみます。

_module TestPackage

export n

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

end
_

私のマシンでは:

_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)
_

次に、__precompile__()ディレクティブを指定して、パッケージを

___precompile__()

module TestPackage

export n

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

end
_

プリコンパイル中およびプリコンパイル後のパフォーマンスを確認します。

_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
_

ここで起こったことは、モジュールがプリコンパイル時に実行され、結果が保存されたことです。これは、静的言語のコンパイラが通常行うこととは異なります。


プリコンパイルはパッケージの動作を変更できますか?もちろん。前述のように、プリコンパイルとは、ロード時ではなくプリコンパイル時にパッケージを効果的に実行することです。これは純粋な関数には関係ありません( (参照の透明性 は結果が常に同じであることを保証するため))。ほとんどの不純な関数には関係ありませんが、場合によっては問題になります。読み込まれたときにprintln("Hello, World!")以外に何もしないパッケージがあるとします。プリコンパイルを行わないと、次のようになります。

_module TestPackage

println("Hello, World")

end
_

そして、これはそれがどのように動作するかです:

_Julia> using TestPackage
Hello, World

Julia> workspace()

Julia> using TestPackage
Hello, World
_

__precompile__()ディレクティブを追加すると、結果は次のようになります。

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

Julia> workspace()

Julia> using TestPackage
_

2回目にロードしても出力はありません。これは、パッケージのコンパイル時にprintlnがすでに実行されているため、再度実行されないためです。これは、静的言語のコンパイルに慣れている人にとって2つ目の驚きのポイントです。

もちろん、これはコンパイル時に実行することができない初期化ステップの問題を提起します。たとえば、パッケージが初期化された日時を必要とする場合、またはファイルやソケットなどのリソースを作成、維持、または削除する必要がある場合などです。 (または、単純なケースでは、端末に情報を出力する必要があります。)そのため、プリコンパイル時に呼び出されないが、ロード時に呼び出される特別な関数があります。この関数は、___init___関数と呼ばれます。

パッケージを次のように再設計します。

___precompile__()

module TestPackage

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

end
_

次の結果が得られます。

_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
_

上記の例のポイントは、おそらく驚き、そしてうまくいけば照らすことです。プリコンパイルを理解する最初のステップは、静的言語が通常コンパイルされる方法とは異なることを理解することです。 Juliaのような動的言語でのプリコンパイルの意味は次のとおりです。

  • トップレベルのステートメントはすべて、ロード時ではなくプリコンパイル時に実行されます。
  • ロード時に実行されるステートメントは、___init___関数に移動する必要があります。

これにより、プリコンパイルがデフォルトでオンにならない理由がより明確になります。常に安全であるとは限りません。パッケージ開発者は、副作用または結果が変化するトップレベルのステートメントを使用していないことを確認し、それらを___init___関数に移動する必要があります。

それで、これはモジュールへの最初の呼び出しの遅延と何が関係していますか?さて、より実用的な例を見てみましょう:

___precompile__()

module TestPackage

export cube

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

end
_

そして、同じ測定を行います:

_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)
_

プリコンパイル後、ロードははるかに速くなります。これは、プリコンパイル中に、ステートメントsquare(x) = x^2およびcube(x) = x * square(x)が実行されるためです。これらは他と同様にトップレベルのステートメントであり、ある程度の作業を伴います。式を解析して下げ、名前squarecubeをモジュール内にバインドする必要があります。 (exportステートメントもあり、コストはかかりませんが、実行する必要があります。)しかし、気づいたとおり:

_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
_

何が起きてる?明らかに__precompile__()ディレクティブがあるのに、cubeを再度コンパイルする必要があるのはなぜですか?そして、なぜコンパイルの結果が保存されないのですか?

答えはかなり簡単です:

  • cube(::Int)は、プリコンパイル中にコンパイルされたことはありません。これは、次の3つの事実から確認できます。プリコンパイルは実行であり、型推論とcodegenは(強制されない限り)実行されるまで発生せず、モジュールには実行cube(::Int)
  • REPLにcube(5)と入力すると、これはプリコンパイル時ではなくなります。 my REPL実行の結果は保存されません。

問題を修正する方法は次のとおりです。execute目的の引数タイプでキューブ関数を実行します。

___precompile__()

module TestPackage

export cube

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

# precompile hints
cube(0)

end
_

その後

_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
_

初回使用時のオーバーヘッドはまだ残っています。ただし、特に初回実行時の割り当て数に注意してください。今回は、プリコンパイル中にcube(::Int)メソッドのコードを推測して生成しました。その推論とコード生成の結果は保存され、やり直しの代わりにキャッシュからロードできます(高速で実行時の割り当てがはるかに少ない)。もちろん、このメリットは、私たちのおもちゃの例よりも実際の負荷に対してより重要です。

だが:

_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
_

cube(0)のみを実行したので、cube(::Int)メソッドのみを推論してコンパイルしたので、cube(5.)の最初の実行には、推論とコード生成が必要です。

Juliaを実際に実行せずに、何かをコンパイルする(プリコンパイル中に発生した場合は、キャッシュに保存する可能性があります)ことを強制したい場合があります。これが、プリコンパイルのヒントに追加できるprecompile関数の目的です。


最後に、プリコンパイルの次の制限に注意してください。

  • プリコンパイルは、パッケージの関数について、パッケージのモジュールからの結果のみをキャッシュします。他のモジュールの関数に依存している場合、それらはプリコンパイルされません。
  • プリコンパイルは、シリアライズ可能な結果のみをサポートします。特に、Cオブジェクトであり、Cポインターを含む結果は、通常、シリアライズ可能ではありません。これには、BigIntBigFloatが含まれます。
45
Fengyang Wang

簡単な答えは、コンパイルする必要がある関数を初めて実行するときなので、コンパイル時間を測定することです。これに気づかない場合は、 パフォーマンスのヒント を参照してください。

しかし、私はあなたがそれを知っていると仮定しますが、それでもあなたを悩ませています。その理由は、Juliaのモジュールがコンパイルされないためです。モジュールは動的スコープです。 REPLで遊んでいるときは、Mainモジュールで作業しています。 Junoを使用してモジュール内のコードをクリックすると、モジュール内のコードが評価されるため、メイン以外のモジュールで動的にすばやく再生できます(REPLスコープも別のモジュールにも適用されます。)モジュールは動的であるため、コンパイルできません(モジュールがプリコンパイルされているのを確認すると、モジュール内で定義されている多くの関数がプリコンパイルされているだけです)。 evalのように、モジュールのグローバルスコープで発生します)。

したがって、mainをモジュールに入れると、REPLに入れるのと同じです。したがって、モジュールのグローバルスコープには、REPLと同じ型の安定性/推論の問題があります(ただし、REPLは、Mainモジュール)。したがって、REPLと同様に、関数を初めて呼び出すときに、コンパイルする必要があります。

8