Почему Джулия занимает много времени при первом звонке в мой модуль?

По существу, у меня есть такая ситуация. У меня есть модуль (который также импортирует ряд других модулей).

У меня есть script как:

import MyModule

tic()
MyModule.main()

tic()
MyModule.main()

В MyModule:

__precompile__()

module MyModule
    export main

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

Первый вызов toc() выводит около 20 секунд. Второй вывод 2.3e-5. Может ли кто-нибудь предположить, где время идет? Джулия делает какую-то инициализацию при первом вызове в модуль, и как я могу понять, что это такое?

Ответы

Ответ 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.

Ответ 2

Быстрый ответ: при первом запуске функции, которую он должен компилировать, вы измеряете время компиляции. Если вы этого не знаете, см. советы по производительности.

Но я предполагаю, что вы это знаете, но это все равно беспокоит вас. Причина в том, что модули в Julia не компилируются: модули являются динамической областью. Когда вы играете в REPL, вы работаете в модуле Main. Когда вы используете Juno и нажимаете на код в модуле, он будет оценивать этот код в модуле, тем самым предоставляя вам быстрый способ динамически играть в модуле non-Main (я думаю, вы можете изменить область REPL на другую модуль тоже). Модули являются динамическими, поэтому они не могут скомпилироваться (когда вы видите, что модуль предварительно скомпилирован, на самом деле он просто прекомпилирует множество функций, определенных внутри него). (Вот почему динамические вещи, такие как eval, происходят в глобальной области модуля).

Поэтому, когда вы помещаете main в модуль, это не отличается от наличия в REPL. Глобальные области модулей, таким образом, имеют те же проблемы с типом стабильности/вывода, что и REPL (но REPL - это всего лишь глобальная область модуля main). Итак, как и в REPL, при первом вызове функции, которую он должен скомпилировать.