Profiling
Модуль Profile
предоставляет инструменты, которые помогают разработчикам улучшить производительность их кода. При использовании он производит замеры выполняемого кода и создает вывод, который помогает вам понять, сколько времени затрачивается на отдельные строки. Наиболее распространенное использование заключается в выявлении "узких мест" в качестве целей для оптимизации.
Profile
реализует то, что известно как "сэмплирование" или statistical profiler. Он работает, периодически делая обратный трассировку во время выполнения любой задачи. Каждая обратная трассировка захватывает текущую выполняемую функцию и номер строки, а также полную цепочку вызовов функций, которые привели к этой строке, и, следовательно, является "снимком" текущего состояния выполнения.
Если большая часть вашего времени выполнения тратится на выполнение определенной строки кода, эта строка будет часто появляться в наборе всех трассировок. Другими словами, "стоимость" данной строки – или, на самом деле, стоимость последовательности вызовов функций до и включая эту строку – пропорциональна тому, как часто она появляется в наборе всех трассировок.
Сэмплирующий профайлер не обеспечивает полного построчного покрытия, поскольку обратные трассировки происходят с интервалами (по умолчанию 1 мс в системах Unix и 10 мс в Windows, хотя фактическое планирование зависит от нагрузки операционной системы). Более того, как будет обсуждено далее, поскольку образцы собираются на разреженном подмножестве всех точек выполнения, данные, собранные сэмплирующим профайлером, подвержены статистическому шуму.
Несмотря на эти ограничения, профайлеры выборки имеют значительные преимущества:
- Вам не нужно вносить изменения в ваш код, чтобы проводить измерения времени.
- Он может профилировать в основной код Julia и даже (по желанию) в библиотеки C и Fortran.
- Запуская "нечасто", вы получаете очень небольшие накладные расходы на производительность; во время профилирования ваш код может работать почти на родной скорости.
По этим причинам рекомендуется попробовать использовать встроенный профайлер выборки, прежде чем рассматривать какие-либо альтернативы.
Basic usage
Давайте поработаем с простым тестовым случаем:
julia> function myfunc()
A = rand(200, 200, 400)
maximum(A)
end
Хорошая идея сначала запустить код, который вы собираетесь профилировать, как минимум один раз (если вы не хотите профилировать JIT-компилятор Julia):
julia> myfunc() # run once to force compilation
Теперь мы готовы профилировать эту функцию:
julia> using Profile
julia> @profile myfunc()
Чтобы увидеть результаты профилирования, существует несколько графических браузеров. Одна "семья" визуализаторов основана на FlameGraphs.jl, при этом каждый член семьи предоставляет различный пользовательский интерфейс:
- VS Code является полноценной средой разработки с встроенной поддержкой визуализации профилей.
- ProfileView.jl является автономным визуализатором на основе GTK
- ProfileVega.jl использует VegaLight и хорошо интегрируется с Jupyter ноутбуками
- StatProfilerHTML.jl генерирует HTML и предоставляет некоторые дополнительные резюме, а также хорошо интегрируется с Jupyter notebooks
- ProfileSVG.jl отображает SVG
- PProf.jl служит локальным веб-сайтом для инспекции графиков, флеймграфиков и многого другого
- ProfileCanvas.jl является интерфейсом просмотра профиля на основе HTML canvas, используемым Julia VS Code extension, но также может генерировать интерактивные HTML файлы.
Совершенно независимый подход к визуализации профилей — это PProf.jl, который использует внешний инструмент pprof
.
Здесь, однако, мы будем использовать текстовый дисплей, который поставляется со стандартной библиотекой:
julia> Profile.print()
80 ./event.jl:73; (::Base.REPL.##1#2{Base.REPL.REPLBackend})()
80 ./REPL.jl:97; macro expansion
80 ./REPL.jl:66; eval_user_input(::Any, ::Base.REPL.REPLBackend)
80 ./boot.jl:235; eval(::Module, ::Any)
80 ./<missing>:?; anonymous
80 ./profile.jl:23; macro expansion
52 ./REPL[1]:2; myfunc()
38 ./random.jl:431; rand!(::MersenneTwister, ::Array{Float64,3}, ::Int64, ::Type{B...
38 ./dSFMT.jl:84; dsfmt_fill_array_close_open!(::Base.dSFMT.DSFMT_state, ::Ptr{F...
14 ./random.jl:278; rand
14 ./random.jl:277; rand
14 ./random.jl:366; rand
14 ./random.jl:369; rand
28 ./REPL[1]:3; myfunc()
28 ./reduce.jl:270; _mapreduce(::Base.#identity, ::Base.#scalarmax, ::IndexLinear,...
3 ./reduce.jl:426; mapreduce_impl(::Base.#identity, ::Base.#scalarmax, ::Array{F...
25 ./reduce.jl:428; mapreduce_impl(::Base.#identity, ::Base.#scalarmax, ::Array{F...
Каждая строка этого дисплея представляет собой определенное место (номер строки) в коде. Отступы используются для обозначения вложенной последовательности вызовов функций, при этом более отступленные строки находятся глубже в последовательности вызовов. В каждой строке первое "поле" — это количество обратных трассировок (образцов), взятых в этой строке или в любых функциях, выполняемых этой строкой. Второе поле — это имя файла и номер строки, а третье поле — это имя функции. Обратите внимание, что конкретные номера строк могут изменяться по мере изменения кода Julia; если вы хотите следить за этим, лучше всего запустить этот пример самостоятельно.
В этом примере мы можем увидеть, что функция верхнего уровня, которая вызывается, находится в файле event.jl
. Это функция, которая запускает REPL, когда вы запускаете Julia. Если вы посмотрите на строку 97 файла REPL.jl
, вы увидите, что именно здесь вызывается функция eval_user_input()
. Это функция, которая оценивает то, что вы вводите в REPL, и поскольку мы работаем интерактивно, эти функции были вызваны, когда мы ввели @profile myfunc()
. Следующая строка отражает действия, предпринятые в макросе @profile
.
Первая строка показывает, что 80 трассировок стека были сделаны на строке 73 файла event.jl
, но это не значит, что эта строка была "дорогой" сама по себе: третья строка показывает, что все 80 из этих трассировок стека на самом деле были вызваны внутри вызова eval_user_input
, и так далее. Чтобы выяснить, какие операции на самом деле занимают время, нам нужно углубиться в цепочку вызовов.
Первая "важная" строка в этом выводе - это эта:
52 ./REPL[1]:2; myfunc()
REPL
относится к тому факту, что мы определили myfunc
в REPL, а не поместили его в файл; если бы мы использовали файл, это показало бы имя файла. [1]
показывает, что функция myfunc
была первым выражением, оцененным в этой сессии REPL. Строка 2 myfunc()
содержит вызов rand
, и на этой строке произошло 52 (из 80) обратных трассировок. Ниже вы можете увидеть вызов dsfmt_fill_array_close_open!
внутри dSFMT.jl
.
Немного ниже вы видите:
28 ./REPL[1]:3; myfunc()
Строка 3 функции myfunc
содержит вызов maximum
, и здесь было сделано 28 (из 80) трассировок. Ниже вы можете увидеть конкретные места в base/reduce.jl
, которые выполняют ресурсоемкие операции в функции maximum
для этого типа входных данных.
В целом, мы можем предварительно заключить, что генерация случайных чисел примерно в два раза дороже, чем нахождение максимального элемента. Мы могли бы повысить нашу уверенность в этом результате, собрав больше образцов:
julia> @profile (for i = 1:100; myfunc(); end)
julia> Profile.print()
[....]
3821 ./REPL[1]:2; myfunc()
3511 ./random.jl:431; rand!(::MersenneTwister, ::Array{Float64,3}, ::Int64, ::Type...
3511 ./dSFMT.jl:84; dsfmt_fill_array_close_open!(::Base.dSFMT.DSFMT_state, ::Ptr...
310 ./random.jl:278; rand
[....]
2893 ./REPL[1]:3; myfunc()
2893 ./reduce.jl:270; _mapreduce(::Base.#identity, ::Base.#scalarmax, ::IndexLinea...
[....]
В общем, если у вас есть N
образцов, собранных на линии, вы можете ожидать неопределенность порядка sqrt(N)
(если не учитывать другие источники шума, такие как загруженность компьютера другими задачами). Основное исключение из этого правила — сбор мусора, который выполняется нечасто, но, как правило, является довольно затратным. (Поскольку сборщик мусора Julia написан на C, такие события можно обнаружить, используя режим вывода C=true
, описанный ниже, или с помощью ProfileView.jl.)
Это иллюстрирует стандартный дамп "дерева"; альтернативой является "плоский" дамп, который накапливает подсчеты независимо от их вложенности:
julia> Profile.print(format=:flat)
Count File Line Function
6714 ./<missing> -1 anonymous
6714 ./REPL.jl 66 eval_user_input(::Any, ::Base.REPL.REPLBackend)
6714 ./REPL.jl 97 macro expansion
3821 ./REPL[1] 2 myfunc()
2893 ./REPL[1] 3 myfunc()
6714 ./REPL[7] 1 macro expansion
6714 ./boot.jl 235 eval(::Module, ::Any)
3511 ./dSFMT.jl 84 dsfmt_fill_array_close_open!(::Base.dSFMT.DSFMT_s...
6714 ./event.jl 73 (::Base.REPL.##1#2{Base.REPL.REPLBackend})()
6714 ./profile.jl 23 macro expansion
3511 ./random.jl 431 rand!(::MersenneTwister, ::Array{Float64,3}, ::In...
310 ./random.jl 277 rand
310 ./random.jl 278 rand
310 ./random.jl 366 rand
310 ./random.jl 369 rand
2893 ./reduce.jl 270 _mapreduce(::Base.#identity, ::Base.#scalarmax, :...
5 ./reduce.jl 420 mapreduce_impl(::Base.#identity, ::Base.#scalarma...
253 ./reduce.jl 426 mapreduce_impl(::Base.#identity, ::Base.#scalarma...
2592 ./reduce.jl 428 mapreduce_impl(::Base.#identity, ::Base.#scalarma...
43 ./reduce.jl 429 mapreduce_impl(::Base.#identity, ::Base.#scalarma...
Если ваш код содержит рекурсию, одной из потенциально запутанных точек является то, что строка в "дочерней" функции может накапливать больше счетчиков, чем общее количество обратных трассировок. Рассмотрим следующие определения функций:
dumbsum(n::Integer) = n == 1 ? 1 : 1 + dumbsum(n-1)
dumbsum3() = dumbsum(3)
Если бы вы профилировали dumbsum3
, и был сделан обратный трассировщик во время его выполнения dumbsum(1)
, обратный трассировщик выглядел бы так:
dumbsum3
dumbsum(3)
dumbsum(2)
dumbsum(1)
Следовательно, эта дочерняя функция получает 3 счета, хотя родитель получает только один. Представление в виде "дерева" делает это гораздо яснее, и по этой причине (среди прочего) это, вероятно, самый полезный способ просмотра результатов.
Accumulation and clearing
Результаты из @profile
накапливаются в буфере; если вы запустите несколько фрагментов кода под 4d61726b646f776e2e436f64652822222c20224070726f66696c652229_40726566
, то Profile.print()
покажет вам объединенные результаты. Это может быть очень полезно, но иногда вы хотите начать с чистого листа; вы можете сделать это с помощью Profile.clear()
.
Options for controlling the display of profile results
Profile.print
имеет больше опций, чем мы описали до сих пор. Давайте посмотрим на полное объявление:
function print(io::IO = stdout, data = fetch(); kwargs...)
Сначала давайте обсудим два позиционных аргумента, а позже аргументы по ключевым словам:
io
– Позволяет сохранять результаты в буфер, например, в файл, но по умолчанию выводит наstdout
(консоль).data
– Содержит данные, которые вы хотите проанализировать; по умолчанию они получаются изProfile.fetch()
, который извлекает трассировки из заранее выделенного буфера. Например, если вы хотите профилировать профилировщик, вы можете сказать:data = copy(Profile.fetch()) Profile.clear() @profile Profile.print(stdout, data) # Prints the previous results Profile.print() # Prints results from Profile.print()
Ключевые аргументы могут быть любой комбинацией:
format
– Введённый выше, определяет, будут ли трассировки выводиться с (по умолчанию,:tree
) или без (:flat
) отступов, указывающих на структуру дерева.C
– Еслиtrue
, трассировки из кода C и Fortran отображаются (обычно они исключаются). Попробуйте запустить вводный пример сProfile.print(C = true)
. Это может быть чрезвычайно полезно для определения, является ли причиной узкого места код Julia или код C; установкаC = true
также улучшает интерпретируемость вложенности, но за счет более длинных дампов профиля.combine
– Некоторые строки кода содержат несколько операций; например,s += A[i]
содержит как ссылку на массив (A[i]
), так и операцию сложения. Эти операции соответствуют разным строкам в сгенерированном машинном коде, и, следовательно, может быть зафиксировано два или более различных адресов во время обратных трассировок по этой строке.combine = true
объединяет их вместе, и, вероятно, это то, что вы обычно хотите, но вы можете сгенерировать вывод отдельно для каждого уникального указателя инструкции сcombine = false
.maxdepth
– Ограничивает кадры на глубине вышеmaxdepth
в формате:tree
.sortedby
– Управляет порядком в формате:flat
.:filefuncline
(по умолчанию) сортирует по исходной строке, в то время как:count
сортирует по количеству собранных образцов.noisefloor
– Ограничивает кадры, которые ниже эвристического уровня шума образца (применяется только к формату:tree
). Рекомендуемое значение для этого - 2.0 (по умолчанию 0). Этот параметр скрывает образцы, для которыхn <= noisefloor * √N
, гдеn
- это количество образцов в этой строке, аN
- это количество образцов для вызываемого.mincount
– Ограничивает кадры с менее чемmincount
вхождениями.
Имена файлов/функций иногда обрезаются (с ...
), а отступы обрезаются с +n
в начале, где n
— это количество дополнительных пробелов, которые были бы вставлены, если бы было место. Если вы хотите получить полный профиль глубоко вложенного кода, часто хорошей идеей является сохранить в файл, используя широкий displaysize
в IOContext
:
open("/tmp/prof.txt", "w") do s
Profile.print(IOContext(s, :displaysize => (24, 500)))
end
Configuration
@profile
просто накапливает трассировки, а анализ происходит, когда вы вызываете Profile.print()
. Для длительных вычислений вполне возможно, что заранее выделенный буфер для хранения трассировок будет заполнен. Если это произойдет, трассировки остановятся, но ваши вычисления продолжатся. В результате вы можете пропустить некоторые важные данные профилирования (вы получите предупреждение, когда это произойдет).
Вы можете получить и настроить соответствующие параметры таким образом:
Profile.init() # returns the current settings
Profile.init(n = 10^7, delay = 0.01)
n
— это общее количество указателей на инструкции, которые вы можете сохранить, со значением по умолчанию 10^6
. Если ваш типичный обратный трассировщик содержит 20 указателей на инструкции, то вы можете собрать 50000 обратных трассировок, что предполагает статистическую неопределенность менее 1%. Это может быть достаточно для большинства приложений.
Следовательно, вам, вероятно, потребуется изменить delay
, выраженный в секундах, который устанавливает количество времени, которое получает Julia между снимками для выполнения запрашиваемых вычислений. Очень длительная задача может не нуждаться в частых обратных трассировках. Значение по умолчанию — delay = 0.001
. Конечно, вы можете уменьшить задержку, а также увеличить ее; однако накладные расходы на профилирование растут, как только задержка становится похожей на время, необходимое для выполнения обратной трассировки (~30 микросекунд на ноутбуке автора).
Memory allocation analysis
Одной из самых распространенных техник для улучшения производительности является снижение выделения памяти. Julia предоставляет несколько инструментов для измерения этого:
@time
Общая сумма выделения может быть измерена с помощью @time
, @allocated
и @allocations
, и конкретные строки, вызывающие выделение, часто можно вывести из профилирования через стоимость сборки мусора, которую эти строки вызывают. Однако иногда более эффективно напрямую измерять количество памяти, выделяемой каждой строкой кода.
GC Logging
В то время как @time
регистрирует высокоуровневую статистику об использовании памяти и сборке мусора в процессе оценки выражения, может быть полезно регистрировать каждое событие сборки мусора, чтобы получить интуитивное представление о том, как часто работает сборщик мусора, как долго он работает каждый раз и сколько мусора он собирает каждый раз. Это можно включить с помощью GC.enable_logging(true)
, что заставляет Julia регистрировать в stderr каждый раз, когда происходит сборка мусора.
Allocation Profiler
Эта функция требует как минимум Julia 1.8.
Профайлер распределения записывает трассировку стека, тип и размер каждого распределения во время его работы. Его можно вызвать с помощью Profile.Allocs.@profile
.
Эта информация об аллокациях возвращается в виде массива объектов Alloc
, обернутых в объект AllocResults
. Наилучший способ визуализировать их в настоящее время - с помощью пакетов PProf.jl и ProfileCanvas.jl, которые могут визуализировать стеки вызовов, производящие наибольшее количество аллокаций.
Профайлер распределения действительно имеет значительные накладные расходы, поэтому можно передать аргумент sample_rate
, чтобы ускорить его, заставив пропускать некоторые распределения. Передача sample_rate=1.0
заставит его записывать все (что медленно); sample_rate=0.1
будет записывать только 10% распределений (быстрее) и т. д.
Старые версии Julia не могли захватывать типы во всех случаях. В старых версиях Julia, если вы видите выделение типа Profile.Allocs.UnknownType
, это означает, что профайлер не знает, какой тип объекта был выделен. Это в основном происходило, когда выделение происходило из сгенерированного кода, созданного компилятором. См. issue #43688 для получения дополнительной информации.
Начиная с Julia 1.11, все выделения должны иметь указанный тип.
Для получения дополнительной информации о том, как использовать этот инструмент, пожалуйста, посмотрите следующий доклад с JuliaCon 2022: https://www.youtube.com/watch?v=BFvpwC8hEWQ
Allocation Profiler Example
В этом простом примере мы используем PProf для визуализации профиля аллокаций. Вы можете использовать другой инструмент визуализации вместо этого. Мы собираем профиль (указывая частоту выборки), а затем визуализируем его.
using Profile, PProf
Profile.Allocs.clear()
Profile.Allocs.@profile sample_rate=0.0001 my_function()
PProf.Allocs.pprof()
Вот более подробный пример, показывающий, как мы можем настроить частоту выборки. Хорошее количество образцов, на которое стоит ориентироваться, составляет около 1 - 10 тысяч. Слишком много, и визуализатор профиля может быть перегружен, а профилирование будет медленным. Слишком мало, и у вас не будет репрезентативной выборки.
julia> import Profile
julia> @time my_function() # Estimate allocations from a (second-run) of the function
0.110018 seconds (1.50 M allocations: 58.725 MiB, 17.17% gc time)
500000
julia> Profile.Allocs.clear()
julia> Profile.Allocs.@profile sample_rate=0.001 begin # 1.5 M * 0.001 = ~1.5K allocs.
my_function()
end
500000
julia> prof = Profile.Allocs.fetch(); # If you want, you can also manually inspect the results.
julia> length(prof.allocs) # Confirm we have expected number of allocations.
1515
julia> using PProf # Now, visualize with an external tool, like PProf or ProfileCanvas.
julia> PProf.Allocs.pprof(prof; from_c=false) # You can optionally pass in a previously fetched profile result.
Analyzing 1515 allocation samples... 100%|████████████████████████████████| Time: 0:00:00
Main binary filename not available.
Serving web UI on http://localhost:62261
"alloc-profile.pb.gz"
Затем вы можете просмотреть профиль, перейдя по адресу http://localhost:62261, и профиль сохранен на диск. См. пакет PProf для получения дополнительных опций.
Allocation Profiling Tips
Как указано выше, стремитесь к примерно 1-10 тысячам образцов в вашем профиле.
Обратите внимание, что мы равномерно выбираем образцы в пространстве всех распределений и не взвешиваем наши образцы по размеру распределения. Таким образом, данная профилировка распределения может не давать представительного профиля того, где большинство байтов выделяется в вашей программе, если вы не установили sample_rate=1
.
Аллокации могут происходить от пользователей, непосредственно создающих объекты, но также могут происходить изнутри среды выполнения или быть вставлены в скомпилированный код для обработки нестабильности типов. Просмотр "исходного кода" может быть полезен для их изоляции, а затем другие внешние инструменты, такие как Cthulhu.jl
, могут быть полезны для определения причины аллокации.
Allocation Profile Visualization Tools
Сейчас существует несколько инструментов визуализации профилирования, которые могут отображать профили распределения. Вот небольшой список некоторых из основных, о которых мы знаем:
Встроенный визуализатор профилей VSCode (
@profview_allocs
) [документация необходима]Просмотр результатов непосредственно в REPL
- Вы можете просмотреть результаты в REPL через
Profile.Allocs.fetch()
, чтобы увидеть трассировку стека и тип каждого выделения.
- Вы можете просмотреть результаты в REPL через
Line-by-Line Allocation Tracking
Альтернативный способ измерения распределений — это запустить Julia с параметром командной строки --track-allocation=<setting>
, для которого вы можете выбрать none
(по умолчанию, не измерять распределение), user
(измерять распределение памяти везде, кроме основного кода Julia) или all
(измерять распределение памяти на каждой строке кода Julia). Распределение измеряется для каждой строки скомпилированного кода. Когда вы выходите из Julia, накопленные результаты записываются в текстовые файлы с добавленным .mem
после имени файла, находящиеся в той же директории, что и исходный файл. Каждая строка перечисляет общее количество выделенных байтов. Coverage
package содержит некоторые элементарные инструменты анализа, например, для сортировки строк по количеству выделенных байтов.
В интерпретации результатов есть несколько важных деталей. В настройке user
первая строка любой функции, вызываемой напрямую из REPL, будет демонстрировать выделение памяти из-за событий, происходящих в самом коде REPL. Более существенно, JIT-компиляция также добавляет к счетчикам выделения, потому что большая часть компилятора Julia написана на Julia (а компиляция обычно требует выделения памяти). Рекомендуемая процедура заключается в том, чтобы принудительно выполнить компиляцию, выполнив все команды, которые вы хотите проанализировать, затем вызовите Profile.clear_malloc_data()
, чтобы сбросить все счетчики выделения. Наконец, выполните желаемые команды и выйдите из Julia, чтобы инициировать генерацию файлов .mem
.
--track-allocation
изменяет генерацию кода для ведения учета аллокаций, и поэтому аллокации могут отличаться от тех, что происходят без этой опции. Мы рекомендуем использовать allocation profiler вместо.
External Profiling
В настоящее время Julia поддерживает Intel VTune
, OProfile
и perf
в качестве внешних инструментов профилирования.
В зависимости от выбранного вами инструмента, скомпилируйте с установленными USE_INTEL_JITEVENTS
, USE_OPROFILE_JITEVENTS
и USE_PERF_JITEVENTS
равными 1 в Make.user
. Поддерживается несколько флагов.
Перед запуском Julia установите переменную окружения ENABLE_JITPROFILING
в 1.
Теперь у вас есть множество способов использовать эти инструменты! Например, с помощью OProfile
вы можете попробовать простую запись:
>ENABLE_JITPROFILING=1 sudo operf -Vdebug ./julia test/fastmath.jl
>opreport -l `which ./julia`
Или аналогично с perf
:
$ ENABLE_JITPROFILING=1 perf record -o /tmp/perf.data --call-graph dwarf -k 1 ./julia /test/fastmath.jl
$ perf inject --jit --input /tmp/perf.data --output /tmp/perf-jit.data
$ perf report --call-graph -G -i /tmp/perf-jit.data
Существует много других интересных вещей, которые вы можете измерить о вашей программе. Чтобы получить исчерпывающий список, пожалуйста, прочитайте Linux perf examples page.
Помните, что perf сохраняет для каждого выполнения файл perf.data
, который, даже для небольших программ, может стать довольно большим. Также модуль perf LLVM временно сохраняет объекты отладки в ~/.debug/jit
, не забывайте часто очищать эту папку.