Ответ 1
Предварительная компиляция может сбить с толку. Я попытаюсь объяснить, как это работает.
Julia загружает модули, сначала анализируя их, а затем запуская так называемые операторы верхнего уровня, по одному за раз. Каждый оператор верхнего уровня опускается, затем интерпретируется (если возможно) или компилируется и выполняется, если интерпретатор не поддерживает этот конкретный оператор верхнего уровня.
Что делает __precompile__
на самом деле довольно просто (по модулю): он выполняет все шаги, перечисленные выше, во время прекомпиляции. Обратите внимание, что приведенные выше шаги включают выполнение, что может быть неожиданным, если вы более знакомы со статически скомпилированными языками. В общем случае невозможно предварительно скомпилировать динамический код без его выполнения, поскольку выполнение кода может привести к изменениям, таким как создание новых функций, методов и типов.
Разница между предварительным запуском и регулярным прогоном заключается в том, что сериализуемая информация из прекомпиляционного прогона сохраняется в кеше. Вещества, которые могут быть сериализуемыми, включают в себя АСТ от разбора и опускания и результатов вывода типа.
Это означает, что прекомпиляция Джулии идет намного дальше, чем компиляция большинства статических языков. Например, рассмотрим следующий пакет Julia, который вычисляет число 5000000050000000
довольно неэффективно:
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
Во второй раз он не загружается! Это потому, что вычисление println
уже было выполнено, когда пакет был скомпилирован, поэтому он не будет выполнен снова. Это вторая точка удивления для тех, которые используются для компиляции статических языков.
Это, конечно, ставит вопрос о шагах инициализации, которые нельзя просто выполнить во время компиляции; например, если моему пакету требуется дата и время его инициализации или необходимо создавать, поддерживать или удалять такие ресурсы, как файлы и сокеты. (Или, в простом случае, необходимо распечатать информацию на терминале.) Таким образом, существует специальная функция, которая не вызывается в прекомпиляцию, но вызывается во время загрузки. Эта функция называется функцией __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)
. Это заявления высшего уровня, как и любые другие, и они связаны с определенной степенью работы. Выражение должно быть проанализировано, опущено, а имена square
и cube
связаны внутри модуля. (Существует также оператор 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
Что здесь происходит? Почему cube
необходимо скомпилировать снова, когда есть явно директива __precompile__()
? И почему не удалось сохранить результат компиляции?
Ответы довольно просты:
- никогда не был скомпилирован во время предварительной компиляции. Это можно увидеть из следующих трех фактов: предварительная компиляция - это выполнение, вывод типа и codegen не выполняются до выполнения (если не принудительно), а модуль не содержит выполнения
cube(::Int)
. - Как только я напечатаю
cube(5)
в REPL, это уже не прекомпиляция. Результаты моего выполнения REPL не сохраняются.
Вот как исправить проблему: выполните функцию куба по желаемым типам аргументов.
__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-указатели, обычно не являются сериализуемыми. Это включает
BigInt
иBigFloat
.