Советы по производительности
В следующих разделах мы кратко рассмотрим несколько техник, которые помогут сделать ваш код Julia максимально быстрым.
Избегайте глобальных переменных
Значение и, следовательно, тип глобальной переменной могут измениться в любой момент. Это затрудняет компилятору оптимизацию кода, использующего глобальные переменные. Переменные должны быть локальными или передаваться в качестве аргументов функциям, когда это возможно.
Любой код, критически важный для производительности или проходящий бенчмаркинг, должен находиться внутри функции.
Мы обнаружили, что глобальные имена часто являются константами, и объявление их как констант значительно улучшает производительность:
const DEFAULT_VAL = 0
Использование неконстантных глобальных переменных можно оптимизировать, добавив аннотации типов в месте использования:
global x = rand(1000)
function loop_over_global()
s = 0.0
for i in x::Vector{Float64}
s += i
end
return s
end
Передача аргументов функциям — это лучший стиль. Это приводит к более многократно используемому коду и проясняет, каковы входные и выходные данные.
Весь код в REPL вычисляется в глобальной области видимости, поэтому переменная, определённая и назначенная на верхнем уровне, будет глобальной переменной. Переменные, определённые на верхнем уровне в модулях, также являются глобальными.
В следующей сессии REPL:
julia> x = 1.0
эквивалентно:
julia> global x = 1.0
поэтому все проблемы производительности, обсуждавшиеся ранее, применимы.
Измеряйте производительность с помощью @time и уделяйте внимание выделению памяти
Полезным инструментом для измерения производительности является макрос @time. Здесь мы повторяем пример с глобальной переменной выше, но на этот раз с удаленной аннотацией типа:
julia> x = rand(1000);
julia> function sum_global()
s = 0.0
for i in x
s += i
end
return s
end;
julia> @time sum_global()
0.017705 seconds (15.28 k allocations: 694.484 KiB)
496.84883432553846
julia> @time sum_global()
0.000140 seconds (3.49 k allocations: 70.313 KiB)
496.84883432553846
При первом вызове (@time sum_global()) функция компилируется. (Если вы ещё не использовали @time в этой сессии, она также скомпилирует функции, необходимые для измерения времени.) Результаты этого выполнения не стоит принимать всерьёз. При втором запуске обратите внимание, что помимо отчета о времени, он также указал, что было выделено значительное количество памяти. Мы здесь просто вычисляем сумму по всем элементам вектора 64-битных чисел с плавающей точкой, поэтому выделение памяти не должно потребоваться (по крайней мере, не в куче, что и сообщает @time).
Неожиданное выделение памяти почти всегда указывает на какую-то проблему с вашим кодом, обычно проблему со стабильностью типа или создание многих маленьких временных массивов. Поэтому помимо самого выделения, очень вероятно, что сгенерированный для вашей функции код далек от оптимального. Возьмите такие показатели всерьёз и следуйте советам ниже.
Если вместо этого мы передаём x в качестве аргумента функции, то выделение памяти больше не происходит (выделение, указанное ниже, связано с выполнением макроса @time в глобальной области видимости), и она значительно быстрее после первого вызова:
julia> x = rand(1000);
julia> function sum_arg(x)
s = 0.0
for i in x
s += i
end
return s
end;
julia> @time sum_arg(x)
0.007701 seconds (821 allocations: 43.059 KiB)
496.84883432553846
julia> @time sum_arg(x)
0.000006 seconds (5 allocations: 176 bytes)
496.84883432553846
5 выделений наблюдаются из-за выполнения макроса @time в глобальной области видимости. Если мы выполним измерение времени в функции, мы увидим, что, действительно, выделения не выполняются:
julia> time_sum(x) = @time sum_arg(x); julia> time_sum(x) 0.000001 seconds 496.84883432553846
В некоторых ситуациях ваша функция может нуждаться в выделении памяти как части своей работы, и это может усложнить простую картину выше. В таких случаях рассмотрите возможность использования одного из инструментов ниже для диагностики проблем или создания версии вашей функции, которая разделяет выделение от её алгоритмических аспектов (см. Предварительное выделение выходов).
Для более серьёзного бенчмаркинга рассмотрите пакет BenchmarkTools.jl, который, среди прочего, несколько раз выполняет функцию, чтобы уменьшить шум.
Инструменты
Julia и её экосистема пакетов включают инструменты, которые могут помочь вам диагностировать проблемы и улучшить производительность вашего кода:
- Профилирование позволяет измерить производительность вашего выполняемого кода и определить строки, которые являются узкими местами. Для сложных проектов пакет ProfileView может помочь визуализировать результаты вашего профилирования.
- Пакет Traceur может помочь вам найти распространённые проблемы производительности в вашем коде.
- Неожиданно большие выделения памяти — как сообщается
@time,@allocatedили профилировщиком (через вызовы процедур сбора мусора) — указывают на то, что могут быть проблемы с вашим кодом. Если вы не видите другой причины для выделения, подозревайте проблему с типом. Вы также можете запустить Julia с опцией--track-allocation=userи просмотреть полученные*.memфайлы, чтобы увидеть информацию о том, где происходят эти выделения. См. Анализ выделения памяти. -
@code_warntypeгенерирует представление вашего кода, которое может помочь в поиске выражений, которые приводят к неопределённости типа. См.@code_warntypeниже.
Избегайте контейнеров с абстрактными параметрами типа
При работе с параметризованными типами, включая массивы, лучше избегать параметризации абстрактными типами, где это возможно.
Рассмотрим следующее:
julia> a = Real[]
0-element Array{Real,1}
julia> push!(a, 1); push!(a, 2.0); push!(a, π)
3-element Array{Real,1}:
1
2.0
π = 3.1415926535897...
Поскольку a — это массив абстрактного типа Real, он должен уметь содержать любое значение Real. Так как объекты Real могут иметь произвольный размер и структуру, a должен быть представлен как массив указателей на индивидуально выделенные объекты Real. Однако, если мы разрешим хранить только числа одного типа, например, Float64, в a, то их можно хранить более эффективно:
julia> a = Float64[]
0-element Array{Float64,1}
julia> push!(a, 1); push!(a, 2.0); push!(a, π)
3-element Array{Float64,1}:
1.0
2.0
3.141592653589793
Присвоение чисел в a теперь преобразует их в Float64, и a будут храниться как непрерывный блок 64-битных значений с плавающей точкой, которые могут быть эффективно обработаны.
См. также обсуждение в разделе Параметризованные типы.
Объявления типов
Во многих языках с необязательными объявлениями типов добавление объявлений — это основной способ ускорения работы кода. В Julia это не так. В Julia компилятор, как правило, знает типы всех аргументов функций, локальных переменных и выражений. Однако есть несколько конкретных случаев, когда объявления полезны.
Избегайте полей с абстрактным типом
Типы могут быть объявлены без указания типов их полей:
julia> struct MyAmbiguousType
a
end
Это позволяет a быть любого типа. Это часто может быть полезно, но у этого есть недостаток: для объектов типа MyAmbiguousType, компилятор не сможет сгенерировать высокопроизводительный код. Причина в том, что компилятор использует типы объектов, а не их значения, чтобы определить, как построить код. К сожалению, о объекте типа MyAmbiguousType можно сделать очень мало выводов:
julia> b = MyAmbiguousType("Hello")
MyAmbiguousType("Hello")
julia> c = MyAmbiguousType(17)
MyAmbiguousType(17)
julia> typeof(b)
MyAmbiguousType
julia> typeof(c)
MyAmbiguousType
Значения b и c имеют один и тот же тип, но их внутреннее представление данных в памяти очень различается. Даже если вы сохранили только числовые значения в поле a, тот факт, что представление в памяти UInt8 отличается от Float64, также означает, что процессору нужно обрабатывать их с использованием двух разных типов инструкций. Поскольку необходимая информация недоступна в типе, такие решения должны приниматься во время выполнения. Это замедляет производительность.
Вы можете улучшить ситуацию, объявив тип a. Здесь мы сосредоточены на случае, когда a может быть любым из нескольких типов, в этом случае естественным решением является использование параметров. Например:
julia> mutable struct MyType{T<:AbstractFloat}
a::T
end
Это лучший выбор, чем
julia> mutable struct MyStillAmbiguousType
a::AbstractFloat
end
потому что в первом варианте указывается тип a по типу обертки объекта. Например:
julia> m = MyType(3.2)
MyType{Float64}(3.2)
julia> t = MyStillAmbiguousType(3.2)
MyStillAmbiguousType(3.2)
julia> typeof(m)
MyType{Float64}
julia> typeof(t)
MyStillAmbiguousType
Тип поля a легко определяется по типу m, но не по типу t. Действительно, в t возможно изменить тип поля a.
julia> typeof(t.a) Float64 julia> t.a = 4.5f0 4.5f0 julia> typeof(t.a) Float32
В отличие от этого, после создания m, тип m.a не может измениться:
julia> m.a = 4.5f0 4.5f0 julia> typeof(m.a) Float64
Тот факт, что тип m.a известен по типу m, а также тот факт, что его тип не может измениться в середине функции, позволяет компилятору генерировать высокооптимизированный код для объектов, таких как m, но не для объектов, таких как t.
Конечно, всё это верно только если мы создаём m с конкретным типом. Мы можем нарушить это, явно создав его с абстрактным типом:
julia> m = MyType{AbstractFloat}(3.2)
MyType{AbstractFloat}(3.2)
julia> typeof(m.a)
Float64
julia> m.a = 4.5f0
4.5f0
julia> typeof(m.a)
Float32
Практически такие объекты ведут себя идентично объектам MyStillAmbiguousType.
Довольно показательно сравнить объём кода, сгенерированного для простой функции
func(m::MyType) = m.a+1
используя
code_llvm(func, Tuple{MyType{Float64}})
code_llvm(func, Tuple{MyType{AbstractFloat}})
По соображениям длины результаты здесь не показаны, но вы можете попробовать это самостоятельно. Поскольку тип полностью указан в первом случае, компилятору не нужно генерировать код для разрешения типа во время выполнения. Это приводит к более короткому и быстрому коду.
Избегайте полей с абстрактными контейнерами
Такие же лучшие практики также работают для типов контейнеров:
julia> struct MySimpleContainer{A<:AbstractVector}
a::A
end
julia> struct MyAmbiguousContainer{T}
a::AbstractVector{T}
end
Например:
julia> c = MySimpleContainer(1:3);
julia> typeof(c)
MySimpleContainer{UnitRange{Int64}}
julia> c = MySimpleContainer([1:3;]);
julia> typeof(c)
MySimpleContainer{Array{Int64,1}}
julia> b = MyAmbiguousContainer(1:3);
julia> typeof(b)
MyAmbiguousContainer{Int64}
julia> b = MyAmbiguousContainer([1:3;]);
julia> typeof(b)
MyAmbiguousContainer{Int64}
Для MySimpleContainer, объект полностью определён своим типом и параметрами, поэтому компилятор может генерировать оптимизированные функции. В большинстве случаев этого, вероятно, будет достаточно.
Хотя компилятор теперь может прекрасно выполнять свою работу, есть случаи, когда *вам* может потребоваться, чтобы ваш код мог делать разные вещи в зависимости от *типа элемента* a. Обычно лучший способ достичь этого — обернуть вашу конкретную операцию (здесь, foo) в отдельную функцию:
julia> function sumfoo(c::MySimpleContainer)
s = 0
for x in c.a
s += foo(x)
end
s
end
sumfoo (generic function with 1 method)
julia> foo(x::Integer) = x
foo (generic function with 1 method)
julia> foo(x::AbstractFloat) = round(x)
foo (generic function with 2 methods)
Это сохраняет простоту, при этом позволяя компилятору генерировать оптимизированный код во всех случаях.
END_OF_DOCUMENT_MARKERОднако, есть случаи, когда вам может потребоваться объявить разные версии внешней функции для разных типов элементов или типов AbstractVector поля a в MySimpleContainer. Вы можете сделать это так:
julia> function myfunc(c::MySimpleContainer{<:AbstractArray{<:Integer}})
return c.a[1]+1
end
myfunc (generic function with 1 method)
julia> function myfunc(c::MySimpleContainer{<:AbstractArray{<:AbstractFloat}})
return c.a[1]+2
end
myfunc (generic function with 2 methods)
julia> function myfunc(c::MySimpleContainer{Vector{T}}) where T <: Integer
return c.a[1]+3
end
myfunc (generic function with 3 methods)
julia> myfunc(MySimpleContainer(1:3)) 2 julia> myfunc(MySimpleContainer(1.0:3)) 3.0 julia> myfunc(MySimpleContainer([1:3;])) 4
Аннотирование значений, взятых из нетипизированных расположений
Часто удобно работать со структурами данных, которые могут содержать значения любого типа (массивы типа Array{Any}). Но если вы используете одну из этих структур и знаете тип элемента, это помогает поделиться этими знаниями с компилятором:
function foo(a::Array{Any,1})
x = a[1]::Int32
b = x+1
...
end
Здесь мы знаем, что первый элемент a будет Int32. Добавление такой аннотации имеет дополнительное преимущество, заключающееся в том, что она вызовет ошибку во время выполнения, если значение не соответствует ожидаемому типу, что может помочь поймать определенные ошибки раньше.
В случае, если тип a[1] точно неизвестен, x может быть объявлен с помощью x = convert(Int32, a[1])::Int32. Использование функции convert позволяет a[1] быть любым объектом, преобразуемым в Int32 (таким как UInt8), что увеличивает обобщённость кода, ослабляя требование к типу. Обратите внимание, что convert само по себе нуждается в аннотации типа в этом контексте для достижения стабильности типов. Это связано с тем, что компилятор не может вывести тип возвращаемого значения функции, даже convert, если типы всех аргументов функции не известны.
Аннотация типа не улучшит (и может даже ухудшить) производительность, если тип создаётся во время выполнения. Это происходит потому, что компилятор не может использовать аннотацию для специализации последующего кода, и проверка типа сама по себе занимает время. Например, в коде:
function nr(a, prec)
ctype = prec == 32 ? Float32 : Float64
b = Complex{ctype}(a)
c = (b + 1.0f0)::Complex{ctype}
abs(c)
end
аннотация c ухудшает производительность. Чтобы написать высокопроизводительный код, связанный с типами, созданными во время выполнения, используйте технику «барьера функций» (описанную ниже), и убедитесь, что созданный тип появляется среди типов аргументов ядра функции, чтобы операции ядра были должным образом специализированы компилятором. Например, в приведенном фрагменте кода, как только b создан, его можно передать в другую функцию k, ядро. Если, например, функция k объявляет b как аргумент типа Complex{T}, где T является параметром типа, то аннотация типа, появляющаяся в операторе присваивания внутри k, не ухудшает производительность (но и не улучшает её), поскольку компилятор может определить тип c в момент компиляции k.
Объявлять типы ключевых аргументов
Ключевые аргументы могут иметь объявленные типы:
function with_keyword(x; name::Int = 1)
...
end
Функции специализируются по типам ключевых аргументов, поэтому эти объявления не повлияют на производительность кода внутри функции. Однако они уменьшат накладные расходы вызовов функции, которые включают ключевые аргументы.
Функции с ключевыми аргументами имеют практически нулевые накладные расходы для мест вызова, которые передают только позиционные аргументы.
Передача динамических списков ключевых аргументов, как в f(x; keywords...), может быть медленной и следует избегать в коде, чувствительном к производительности.
Разбивать функции на несколько определений
Разбиение функции на множество небольших определений позволяет компилятору напрямую вызывать наиболее подходящий код или даже встраивать его.
Вот пример «составной функции», которую лучше всего записать как несколько определений:
using LinearAlgebra
function mynorm(A)
if isa(A, Vector)
return sqrt(real(dot(A,A)))
elseif isa(A, Matrix)
return maximum(svdvals(A))
else
error("mynorm: invalid argument")
end
end
Это можно записать более кратко и эффективно как:
norm(x::Vector) = sqrt(real(dot(x, x))) norm(A::Matrix) = maximum(svdvals(A))
Однако следует отметить, что компилятор достаточно эффективен в оптимизации мёртвых ветвей в коде, написанном в стиле примера mynorm.
Писать «стабильные» функции
По возможности следует гарантировать, что функция всегда возвращает значение одного и того же типа. Рассмотрим следующее определение:
pos(x) = x < 0 ? 0 : x
Хотя это кажется достаточно безобидным, проблема в том, что 0 — целое число (типа Int), а x может быть любого типа. Таким образом, в зависимости от значения x, эта функция может вернуть значение одного из двух типов. Это поведение разрешено и может быть желательным в некоторых случаях. Но это легко исправить следующим образом:
pos(x) = x < 0 ? zero(x) : x
Также существует функция oneunit и более общая функция oftype(x, y), которая возвращает y, преобразованное к типу x.
Избегайте изменения типа переменной
Аналогичная проблема «стабильности типа» существует для переменных, используемых многократно внутри функции:
function foo()
x = 1
for i = 1:10
x /= rand()
end
return x
end
Локальная переменная x начинается как целое число, а после одной итерации цикла становится числом с плавающей точкой (результат оператора /). Это затрудняет оптимизацию тела цикла компилятором. Существует несколько возможных решений:
- Инициализировать
xзначениемx = 1.0 - Объявить тип
x:x::Float64 = 1 - Использовать явное преобразование:
x = oneunit(Float64) - Инициализировать с первой итерации цикла значением
x = 1 / rand(), затем циклfor i = 2:10
Разделение функций ядра (т.е., барьеры функций)
Многие функции следуют схеме выполнения некоторых подготовительных работ и последующего выполнения многих итераций для выполнения основного вычисления. По возможности следует помещать эти основные вычисления в отдельные функции. Например, следующая условная функция возвращает массив случайного типа:
julia> function strange_twos(n)
a = Vector{rand(Bool) ? Int64 : Float64}(undef, n)
for i = 1:n
a[i] = 2
end
return a
end;
julia> strange_twos(3)
3-element Array{Float64,1}:
2.0
2.0
2.0
Это должно быть записано как:
julia> function fill_twos!(a)
for i = eachindex(a)
a[i] = 2
end
end;
julia> function strange_twos(n)
a = Vector{rand(Bool) ? Int64 : Float64}(undef, n)
fill_twos!(a)
return a
end;
julia> strange_twos(3)
3-element Array{Float64,1}:
2.0
2.0
2.0
Компилятор Julia специализирует код для типов аргументов на границах функций, поэтому в исходной реализации он не знает тип a во время цикла (поскольку он выбирается случайным образом). Поэтому второй вариант, как правило, быстрее, так как внутренний цикл может быть перекомпилирован как часть fill_twos! для разных типов a.
Второй вариант также часто является лучшим стилем и может привести к большему повторному использованию кода.
Этот шаблон используется в нескольких местах в Julia Base. Например, см. vcat и hcat в abstractarray.jl, или функцию fill!, которую мы могли бы использовать вместо написания собственной fill_twos!.
Функции, подобные strange_twos, возникают при работе с данными неопределённого типа, например, с данными, загруженными из входного файла, которые могут содержать целые числа, числа с плавающей точкой, строки или что-то ещё.
Типы со значениями в качестве параметров
Предположим, вы хотите создать массив N размерностью 3 по каждой оси. Такие массивы можно создать так:
julia> A = fill(5.0, (3, 3))
3×3 Array{Float64,2}:
5.0 5.0 5.0
5.0 5.0 5.0
5.0 5.0 5.0
Этот подход работает очень хорошо: компилятор может определить, что A является массивом Array{Float64,2}, потому что он знает тип заполняющего значения (5.0::Float64) и размерность ((3, 3)::NTuple{2,Int}). Это означает, что компилятор может сгенерировать очень эффективный код для любого последующего использования A в той же функции.
Но теперь предположим, что вы хотите написать функцию, которая создаёт массив 3×3×... произвольной размерности; вы, возможно, захотите написать функцию
julia> function array3(fillval, N)
fill(fillval, ntuple(d->3, N))
end
array3 (generic function with 1 method)
julia> array3(5.0, 2)
3×3 Array{Float64,2}:
5.0 5.0 5.0
5.0 5.0 5.0
5.0 5.0 5.0
Это работает, но (как вы можете проверить самостоятельно с помощью @code_warntype array3(5.0, 2)) проблема заключается в том, что тип результата не может быть выведен: аргумент N — это значение типа Int, а вывод типа не предсказывает его значение заранее. Это означает, что код, использующий результат этой функции, должен быть консервативным, проверяя тип при каждом обращении к A; такой код будет очень медленным.
Сейчас очень хороший способ решения таких проблем — использование техники «барьер функций». Однако в некоторых случаях вы можете захотеть полностью устранить нестабильность типа. В таких случаях одним из подходов является передача размерности как параметра, например, через Val{T}() (см. "Типы значений"):
julia> function array3(fillval, ::Val{N}) where N
fill(fillval, ntuple(d->3, Val(N)))
end
array3 (generic function with 1 method)
julia> array3(5.0, Val(2))
3×3 Array{Float64,2}:
5.0 5.0 5.0
5.0 5.0 5.0
5.0 5.0 5.0
Julia имеет специализированную версию ntuple, которая принимает экземпляр Val{::Int} в качестве второго параметра; передавая N как параметр типа, вы делаете его «значение» известным компилятору. В результате эта версия array3 позволяет компилятору предсказать тип возвращаемого значения.
Однако использование таких техник может быть неожиданно тонким. Например, это не помогло бы, если бы вы вызывали array3 из функции вроде этой:
function call_array3(fillval, n)
A = array3(fillval, Val(n))
end
Здесь вы создали ту же проблему: компилятор не может угадать, что такое n, поэтому он не знает тип Val(n). Попытка использовать Val, но сделать это неправильно, может значительно ухудшить производительность во многих ситуациях. (Только в ситуациях, когда вы эффективно сочетаете Val с трюком барьера функций, чтобы сделать ядро функции более эффективным, следует использовать код, подобный приведенному выше.)
Примером правильного использования Val был бы:
function filter3(A::AbstractArray{T,N}) where {T,N}
kernel = array3(1, Val(N))
filter(A, kernel)
end
В этом примере N передаётся как параметр, поэтому его «значение» известно компилятору. По сути, Val(T) работает только тогда, когда T является либо жёстко заданным/литеральным (Val(3)) или уже указанным в области типов.
Опасности злоупотребления множественным диспетчером (т.е., больше о типах со значениями в качестве параметров)
После того, как вы научитесь ценить множественный диспетчер, возникает понятное стремление использовать его для всего. Например, вы можете представить себе использование его для хранения информации, например
struct Car{Make, Model}
year::Int
...more fields...
end
а затем использовать диспетчер по объектам, подобным Car{:Honda,:Accord}(year, args...).
Это может быть полезно, если верно хотя бы одно из следующих условий:
- Вам требуется ресурсоёмкая обработка на каждом
Car, и она становится намного эффективнее, если вы знаетеMakeиModelво время компиляции, а также общее количество различныхMakeилиModel, которые будут использоваться, не слишком велико. - У вас есть однородные списки одного и того же типа
Carдля обработки, так что вы можете хранить их все вArray{Car{:Honda,:Accord},N}.
Когда последнее утверждение верно, функцию, обрабатывающую такой однородный массив, можно эффективно специализировать: Julia знает тип каждого элемента заранее (все объекты в контейнере имеют один и тот же конкретный тип), поэтому Julia может «найти» правильные вызовы методов во время компиляции (что исключает необходимость проверки во время выполнения) и тем самым сгенерировать эффективный код для обработки всего списка.
Когда это не так, вероятно, вы не получите никакой выгоды; что хуже, resulting «комбинаторный взрыв типов» будет контрпродуктивным. Если items[i+1] имеет тип, отличающийся от item[i], Julia должна искать тип во время выполнения, искать подходящий метод в таблицах методов, определять (через пересечение типов), какой из них соответствует, определять, был ли он уже скомпилирован JIT (и делать это, если нет), а затем совершать вызов. По сути, вы просите всю систему типов и механизм JIT-компиляции выполнить примерно то же самое, что оператор switch или поиск в словаре в вашем собственном коде.
Некоторые бенчмарки времени выполнения, сравнивающие (1) диспетчеризацию типов, (2) поиск в словаре и (3) оператор «switch», можно найти на списке рассылки.
Возможно, еще хуже, чем влияние на время выполнения, это влияние на время компиляции: Julia будет компилировать специализированные функции для каждого различного Car{Make, Model}; если у вас есть сотни или тысячи таких типов, то каждая функция, которая принимает такой объект в качестве параметра (от пользовательской функции get_year , которую вы можете написать сами, до универсальной функции push! в Julia Base), будет иметь сотни или тысячи вариантов, скомпилированных для неё. Каждый из них увеличивает размер кэша скомпилированного кода, длину внутренних списков методов и т. д. Чрезмерный энтузиазм по поводу значений в качестве параметров может легко привести к огромным потерям ресурсов.
Доступ к массивам в порядке памяти, по столбцам
Многомерные массивы в Julia хранятся в порядке следования столбцов. Это означает, что массивы укладываются один столбец за другим. Это можно проверить с помощью функции vec или синтаксиса [:], как показано ниже (обратите внимание, что массив упорядочен [1 3 2 4], а не [1 2 3 4]):
julia> x = [1 2; 3 4]
2×2 Array{Int64,2}:
1 2
3 4
julia> x[:]
4-element Array{Int64,1}:
1
3
2
4
Эта конвенция для упорядочения массивов распространена во многих языках, таких как Fortran, Matlab и R (и не только). Альтернативой порядку следования столбцов является порядок следования строк, который используется в C и Python (numpy) и некоторых других языках. Запоминание порядка массивов может существенно повлиять на производительность при циклическом проходе по массивам. Необходимо помнить, что для массивов со следованием столбцов первый индекс изменяется быстрее всего. Это фактически означает, что циклический проход будет быстрее, если индекс внутреннего цикла — первый, появляющийся в выражении среза.
Рассмотрим следующий искусственный пример. Предположим, что мы хотели написать функцию, которая принимает Vector и возвращает квадратный Matrix с заполненными строками или столбцами копиями входного вектора. Предположим, что порядок, в котором строки или столбцы заполняются этими копиями, не важен (возможно, остальной код можно легко адаптировать соответственно). Мы можем сделать это по крайней мере четырьмя способами (в дополнение к рекомендуемому вызову встроенной функции repeat):
function copy_cols(x::Vector{T}) where T
inds = axes(x, 1)
out = similar(Array{T}, inds, inds)
for i = inds
out[:, i] = x
end
return out
end
function copy_rows(x::Vector{T}) where T
inds = axes(x, 1)
out = similar(Array{T}, inds, inds)
for i = inds
out[i, :] = x
end
return out
end
function copy_col_row(x::Vector{T}) where T
inds = axes(x, 1)
out = similar(Array{T}, inds, inds)
for col = inds, row = inds
out[row, col] = x[row]
end
return out
end
function copy_row_col(x::Vector{T}) where T
inds = axes(x, 1)
out = similar(Array{T}, inds, inds)
for row = inds, col = inds
out[row, col] = x[col]
end
return out
end
Теперь мы измерим время работы каждой из этих функций с использованием того же случайного 10000 на 1 входного вектора:
julia> x = randn(10000); julia> fmt(f) = println(rpad(string(f)*": ", 14, ' '), @elapsed f(x)) julia> map(fmt, Any[copy_cols, copy_rows, copy_col_row, copy_row_col]); copy_cols: 0.331706323 copy_rows: 1.799009911 copy_col_row: 0.415630047 copy_row_col: 1.721531501
Обратите внимание, что copy_cols намного быстрее, чем copy_rows. Это ожидаемо, поскольку copy_cols учитывает структуру памяти массива по столбцам и заполняет его столбец за столбцом. Кроме того, copy_col_row намного быстрее, чем copy_row_col, так как он следует нашему правилу, что первый элемент, появляющийся в выражении среза, должен быть связан с внутренним циклом.
Предварительное выделение памяти для выходных данных
Если ваша функция возвращает Array или другой сложный тип, ей может потребоваться выделить память. К сожалению, часто выделение памяти и его обратный процесс, сборка мусора, являются существенными узкими местами.
Иногда можно избежать необходимости выделять память при каждом вызове функции, предварительно выделив память для выходных данных. В качестве тривиального примера сравните
julia> function xinc(x)
return [x, x+1, x+2]
end;
julia> function loopinc()
y = 0
for i = 1:10^7
ret = xinc(i)
y += ret[2]
end
return y
end;
с
julia> function xinc!(ret::AbstractVector{T}, x::T) where T
ret[1] = x
ret[2] = x+1
ret[3] = x+2
nothing
end;
julia> function loopinc_prealloc()
ret = Vector{Int}(undef, 3)
y = 0
for i = 1:10^7
xinc!(ret, i)
y += ret[2]
end
return y
end;
Результаты измерения времени:
julia> @time loopinc() 0.529894 seconds (40.00 M allocations: 1.490 GiB, 12.14% gc time) 50000015000000 julia> @time loopinc_prealloc() 0.030850 seconds (6 allocations: 288 bytes) 50000015000000
Предварительное выделение памяти имеет и другие преимущества, например, позволяет вызывающей стороне контролировать тип «выходных данных» алгоритма. В приведённом выше примере мы могли бы передать SubArray вместо Array, если бы этого захотели.
В крайнем случае, предварительное выделение может сделать ваш код менее читаемым, поэтому могут потребоваться измерения производительности и некоторое суждение. Однако для «векторизованных» (элементных) функций можно использовать удобный синтаксис x .= f.(y) для операций на месте с объединёнными циклами и без временных массивов (см. синтаксис точки для векторизации функций).
Ещё точки: Объединение векторизованных операций
Julia имеет специальный синтаксис точки, который преобразует любую скалярную функцию в вызов «векторизованной» функции, а любой оператор — в «векторизованный» оператор со специальным свойством, что вложенные вызовы «точки» объединяются: они объединяются на уровне синтаксиса в один цикл без выделения временных массивов. Если вы используете .= и аналогичные операторы присваивания, результат также может быть сохранён на месте в предварительно выделенном массиве (см. выше).
В контексте линейной алгебры это означает, что даже если такие операции, как vector + vector и vector * scalar определены, может быть выгодно вместо этого использовать vector .+ vector и vector .* scalar, поскольку полученные циклы могут быть объединены с окружающими вычислениями. Например, рассмотрите две функции:
julia> f(x) = 3x.^2 + 4x + 7x.^3; julia> fdot(x) = @. 3x^2 + 4x + 7x^3 # equivalent to 3 .* x.^2 .+ 4 .* x .+ 7 .* x.^3;
И f , и fdot вычисляют одно и то же. Однако fdot (определённая с помощью макроса @.) значительно быстрее при применении к массиву:
julia> x = rand(10^6); julia> @time f(x); 0.019049 seconds (16 allocations: 45.777 MiB, 18.59% gc time) julia> @time fdot(x); 0.002790 seconds (6 allocations: 7.630 MiB) julia> @time f.(x); 0.002626 seconds (8 allocations: 7.630 MiB)
То есть, fdot(x) в десять раз быстрее и выделяет в 1/6 раз меньше памяти, чем f(x), так как каждая операция * и + в f(x) выделяет новый временный массив и выполняется в отдельном цикле. (Конечно, если вы просто сделаете f.(x), то это так же быстро, как fdot(x) в этом примере, но во многих контекстах гораздо удобнее просто добавлять точки в свои выражения, чем определять отдельную функцию для каждой векторизованной операции.)
Использование представлений для срезов
В Julia выражение среза массива, например, array[1:5, :], создаёт копию этих данных (кроме левой части оператора присваивания, где array[1:5, :] = ... выполняет присваивание на месте для этой части array). Если вы выполняете множество операций над срезом, это может быть полезно для производительности, так как работа со меньшей непрерывной копией эффективнее, чем индексирование в исходном массиве. С другой стороны, если вы выполняете только несколько простых операций над срезом, затраты на выделение памяти и копирование данных могут быть существенными.
Альтернативой является создание «представления» массива, которое представляет собой объект массива (SubArray) , который фактически ссылается на данные исходного массива на месте, не создавая копий. (Если вы записываете в представление, это также изменяет данные исходного массива.) Это можно сделать для отдельных срезов, вызвав view, или проще для всего выражения или блока кода, поместив @views перед этим выражением. Например:
julia> fcopy(x) = sum(x[2:end-1]); julia> @views fview(x) = sum(x[2:end-1]); julia> x = rand(10^6); julia> @time fcopy(x); 0.003051 seconds (7 allocations: 7.630 MB) julia> @time fview(x); 0.001020 seconds (6 allocations: 224 bytes)
Обратите внимание на ускорение в 3 раза и уменьшение выделения памяти в версии функции fview.
Копирование данных — это не всегда плохо
Массивы хранятся непрерывно в памяти, что позволяет использовать векторные инструкции процессора и уменьшить количество обращений к памяти за счёт кэширования. Это те же причины, по которым рекомендуется обращаться к массивам в порядке следования столбцов (см. выше). Неправильные шаблоны доступа и несмежные представления могут значительно замедлить вычисления с массивами из-за не последовательного доступа к памяти.
Копирование данных с нерегулярным доступом в непрерывный массив перед их обработкой может привести к значительному увеличению скорости, например, в приведённом ниже примере. Здесь массив и вектор доступны по 800 000 случайных перетасованных индексов перед умножением. Копирование представлений в обычные массивы ускоряет умножение даже с затратами на операцию копирования.
julia> using Random
julia> x = randn(1_000_000);
julia> inds = shuffle(1:1_000_000)[1:800000];
julia> A = randn(50, 1_000_000);
julia> xtmp = zeros(800_000);
julia> Atmp = zeros(50, 800_000);
julia> @time sum(view(A, :, inds) * view(x, inds))
0.412156 seconds (14 allocations: 960 bytes)
-4256.759568345458
julia> @time begin
copyto!(xtmp, view(x, inds))
copyto!(Atmp, view(A, :, inds))
sum(Atmp * xtmp)
end
0.285923 seconds (14 allocations: 960 bytes)
-4256.759568345134
При условии, что для копий достаточно памяти, стоимость копирования представления в массив значительно меньше, чем прирост скорости от выполнения умножения матриц на непрерывный массив.
Избегайте интерполяции строк для ввода-вывода
При записи данных в файл (или другое устройство ввода-вывода) формирование дополнительных промежуточных строк приводит к накладным расходам. Вместо:
println(file, "$a $b")
используйте:
println(file, a, " ", b)
В первой версии кода формируется строка, а затем она записывается в файл, тогда как во второй версии значения записываются непосредственно в файл. Также обратите внимание, что в некоторых случаях интерполяция строк может быть сложнее для чтения. Рассмотрите:
println(file, "$(f(a))$(f(b))")
против:
println(file, f(a), f(b))
Оптимизация сетевого ввода-вывода во время параллельного выполнения
При выполнении удалённой функции в параллельном режиме:
using Distributed
responses = Vector{Any}(undef, nworkers())
@sync begin
for (idx, pid) in enumerate(workers())
@async responses[idx] = remotecall_fetch(pid, foo, args...)
end
end
быстрее, чем:
using Distributed
refs = Vector{Any}(undef, nworkers())
for (idx, pid) in enumerate(workers())
refs[idx] = @spawnat pid foo(args...)
end
responses = [fetch(r) for r in refs]
В первом случае происходит единственное сетевое обращение ко всем рабочим узлам, а во втором — два сетевых вызова: сначала от @spawnat, а затем из-за fetch (или даже wait). fetch/wait также выполняется последовательно, что приводит к более низкой общей производительности.
Исправление предупреждений о устаревших функциях
Устаревшая функция выполняет внутренний поиск, чтобы вывести соответствующее предупреждение только один раз. Этот дополнительный поиск может значительно замедлить выполнение, поэтому все использования устаревших функций должны быть изменены так, как это предлагается в предупреждениях.
Настройки
Вот несколько незначительных моментов, которые могут помочь в узких внутренних циклах.
- Избегайте ненужных массивов. Например, вместо
sum([x,y,z])используйтеx+y+z. - Используйте
abs2(z)вместоabs(z)^2для комплексныхz. В общем, старайтесь переписать код, чтобы использоватьabs2вместоabsдля комплексных аргументов. - Используйте
div(x,y)для усечения целочисленного деления вместоtrunc(x/y),fld(x,y)вместоfloor(x/y)иcld(x,y)вместоceil(x/y).
Аннотации производительности
Иногда вы можете включить улучшенную оптимизацию, гарантируя определенные свойства программы.
- Используйте
@inbounds, чтобы исключить проверку границ массивов внутри выражений. Будьте уверены перед этим. Если индексы выходят за пределы границ, могут возникнуть сбои или скрытые повреждения. - Используйте
@fastmath, чтобы разрешить оптимизации с плавающей запятой, которые верны для вещественных чисел, но приводят к различиям для чисел IEEE. Будьте осторожны при выполнении этого действия, так как это может изменить числовые результаты. Это соответствует параметру-ffast-mathclang. - Напишите
@simdпередforциклами, чтобы гарантировать, что итерации независимы и могут быть переупорядочены. Обратите внимание, что во многих случаях Julia может автоматически векторизировать код без макроса@simd; он полезен только в тех случаях, когда такая трансформация в противном случае была бы незаконной, включая случаи, такие как разрешение переассоциативности с плавающей запятой и игнорирование зависимых обращений к памяти (@simd ivdep). Еще раз, будьте очень осторожны при применении@simd, так как ошибочная аннотация цикла с зависимыми итерациями может привести к непредвиденным результатам. В частности, обратите внимание, чтоsetindex!на некоторыхAbstractArrayподтипах изначально зависит от порядка итераций. Эта функция экспериментальная и может быть изменена или удалена в будущих версиях Julia.
Общий прием использования 1:n для индексирования в AbstractArray небезопасен, если массив использует нестандартный индексирование, и может привести к ошибке сегментации, если проверка границ выключена. Используйте LinearIndices(x) или eachindex(x) вместо этого (см. также offset-arrays).
!!!note Хотя @simd необходимо разместить непосредственно перед внутренним for циклом, @inbounds и @fastmath могут быть применены как к отдельным выражениям, так и ко всем выражениям, которые появляются внутри вложенных блоков кода, например, используя @inbounds begin или @inbounds for ....
Вот пример с обоими маркерами @inbounds и @simd (здесь мы используем @noinline чтобы предотвратить попытки оптимизатора быть слишком умным и нарушить наш эталон):
@noinline function inner(x, y)
s = zero(eltype(x))
for i=eachindex(x)
@inbounds s += x[i]*y[i]
end
return s
end
@noinline function innersimd(x, y)
s = zero(eltype(x))
@simd for i = eachindex(x)
@inbounds s += x[i] * y[i]
end
return s
end
function timeit(n, reps)
x = rand(Float32, n)
y = rand(Float32, n)
s = zero(Float64)
time = @elapsed for j in 1:reps
s += inner(x, y)
end
println("GFlop/sec = ", 2n*reps / time*1E-9)
time = @elapsed for j in 1:reps
s += innersimd(x, y)
end
println("GFlop/sec (SIMD) = ", 2n*reps / time*1E-9)
end
timeit(1000, 1000)
На компьютере с процессором Intel Core i5 2,4 ГГц это дает:
GFlop/sec = 1.9467069505224963 GFlop/sec (SIMD) = 17.578554163920018
(GFlop/sec измеряет производительность, и большие числа лучше.)
Вот пример со всеми тремя типами маркеров. Эта программа сначала вычисляет конечную разность одномерного массива, а затем вычисляет L2-норму результата:
function init!(u::Vector)
n = length(u)
dx = 1.0 / (n-1)
@fastmath @inbounds @simd for i in 1:n #by asserting that `u` is a `Vector` we can assume it has 1-based indexing
u[i] = sin(2pi*dx*i)
end
end
function deriv!(u::Vector, du)
n = length(u)
dx = 1.0 / (n-1)
@fastmath @inbounds du[1] = (u[2] - u[1]) / dx
@fastmath @inbounds @simd for i in 2:n-1
du[i] = (u[i+1] - u[i-1]) / (2*dx)
end
@fastmath @inbounds du[n] = (u[n] - u[n-1]) / dx
end
function mynorm(u::Vector)
n = length(u)
T = eltype(u)
s = zero(T)
@fastmath @inbounds @simd for i in 1:n
s += u[i]^2
end
@fastmath @inbounds return sqrt(s/n)
end
function main()
n = 2000
u = Vector{Float64}(undef, n)
init!(u)
du = similar(u)
deriv!(u, du)
nu = mynorm(du)
@time for i in 1:10^6
deriv!(u, du)
nu = mynorm(du)
end
println(nu)
end
main()
На компьютере с процессором Intel Core i7 2,7 ГГц это дает:
$ julia wave.jl; 1.207814709 seconds 4.443986180758249 $ julia --math-mode=ieee wave.jl; 4.487083643 seconds 4.443986180758249
Здесь параметр --math-mode=ieee отключает макрос @fastmath, чтобы мы могли сравнить результаты.
В этом случае ускорение за счет @fastmath составляет примерно 3,7. Это необычно большое значение; обычно ускорение будет меньше. (В этом конкретном примере рабочая область эталона достаточно мала, чтобы поместиться в кэш L1 процессора, поэтому задержка доступа к памяти не играет роли, и время выполнения доминирует потребление ЦП. Во многих реальных программах это не так.) Кроме того, в этом случае эта оптимизация не изменяет результат; обычно результат будет немного отличаться. В некоторых случаях, особенно для числовых неустойчивых алгоритмов, результат может быть очень разным.
Аннотация @fastmath переупорядочивает выражения с плавающей запятой, например, изменяя порядок оценки или предполагая, что определенные особые случаи (inf, nan) не могут произойти. В этом случае (и на этом конкретном компьютере) основное отличие заключается в том, что выражение 1 / (2*dx) в функции deriv выносится за цикл (то есть вычисляется вне цикла), как если бы мы написали idx = 1 / (2*dx). В цикле выражение ... / (2*dx) становится ... * idx, которое намного быстрее для оценки. Конечно, как реальная оптимизация, применяемая компилятором, так и полученное ускорение сильно зависят от оборудования. Вы можете изучить изменение сгенерированного кода, используя функцию Julia code_native.
Обратите внимание, что @fastmath также предполагает, что NaN не будут возникать во время вычисления, что может привести к неожиданному поведению:
julia> f(x) = isnan(x); julia> f(NaN) true julia> f_fast(x) = @fastmath isnan(x); julia> f_fast(NaN) false
Обращение с субнормальными числами как с нулями
Субнормальные числа, ранее называвшиеся денормализованными числами, полезны во многих контекстах, но наносят производительности штраф на некоторых устройствах. Вызов set_zero_subnormals(true) разрешает для операций с плавающей запятой обрабатывать субнормальные входные или выходные данные как нули, что может улучшить производительность на некоторых устройствах. Вызов set_zero_subnormals(false) обеспечивает строгое поведение IEEE для субнормальных чисел.
Ниже приведен пример, где субнормальные числа заметно влияют на производительность на некоторых устройствах:
function timestep(b::Vector{T}, a::Vector{T}, Δt::T) where T
@assert length(a)==length(b)
n = length(b)
b[1] = 1 # Boundary condition
for i=2:n-1
b[i] = a[i] + (a[i-1] - T(2)*a[i] + a[i+1]) * Δt
end
b[n] = 0 # Boundary condition
end
function heatflow(a::Vector{T}, nstep::Integer) where T
b = similar(a)
for t=1:div(nstep,2) # Assume nstep is even
timestep(b,a,T(0.1))
timestep(a,b,T(0.1))
end
end
heatflow(zeros(Float32,10),2) # Force compilation
for trial=1:6
a = zeros(Float32,1000)
set_zero_subnormals(iseven(trial)) # Odd trials use strict IEEE arithmetic
@time heatflow(a,1000)
end
Это дает вывод, похожий на
0.002202 seconds (1 allocation: 4.063 KiB) 0.001502 seconds (1 allocation: 4.063 KiB) 0.002139 seconds (1 allocation: 4.063 KiB) 0.001454 seconds (1 allocation: 4.063 KiB) 0.002115 seconds (1 allocation: 4.063 KiB) 0.001455 seconds (1 allocation: 4.063 KiB)
Обратите внимание, как каждая чётная итерация значительно быстрее.
Этот пример генерирует множество субнормальных чисел, потому что значения в a становятся экспоненциально убывающей кривой, которая медленно выравнивается со временем.
Обработка субнормальных чисел как нулей должна применяться с осторожностью, потому что это нарушает некоторые тождества, такие как x-y == 0 подразумевает x == y:
julia> x = 3f-38; y = 2f-38; julia> set_zero_subnormals(true); (x - y, x == y) (0.0f0, false) julia> set_zero_subnormals(false); (x - y, x == y) (1.0000001f-38, false)
В некоторых приложениях альтернативой нулевому представлению субнормальных чисел является введение небольшого шума. Например, вместо инициализации a нулями, инициализируйте его:
a = rand(Float32,1000) * 1.f-9
@code_warntype
Макрос @code_warntype (или его функция code_warntype) иногда полезен для диагностики проблем, связанных с типами. Вот пример:
julia> @noinline pos(x) = x < 0 ? 0 : x;
julia> function f(x)
y = pos(x)
sin(y*x + 1)
end;
julia> @code_warntype f(3.2)
Body::Float64
2 1 ─ %1 = invoke Main.pos(%%x::Float64)::UNION{FLOAT64, INT64}
3 │ %2 = isa(%1, Float64)::Bool
└── goto 3 if not %2
2 ─ %4 = π (%1, Float64)
│ %5 = Base.mul_float(%4, %%x)::Float64
└── goto 6
3 ─ %7 = isa(%1, Int64)::Bool
└── goto 5 if not %7
4 ─ %9 = π (%1, Int64)
│ %10 = Base.sitofp(Float64, %9)::Float64
│ %11 = Base.mul_float(%10, %%x)::Float64
└── goto 6
5 ─ Base.error("fatal error in type inference (type bound)")
└── unreachable
6 ┄ %15 = φ (2 => %5, 4 => %11)::Float64
│ %16 = Base.add_float(%15, 1.0)::Float64
│ %17 = invoke Main.sin(%16::Float64)::Float64
└── return %17
Интерпретация вывода @code_warntype, как и его родственников @code_lowered, @code_typed, @code_llvm и @code_native, требует некоторой практики. Ваш код представлен в форме, которая была сильно обработана на пути к генерации скомпилированного машинного кода. Большинство выражений аннотированы типом, указанным в ::T (где T может быть, например, Float64). Наиболее важная особенность @code_warntype заключается в том, что неконкретные типы отображаются красным цветом; в приведенном выше примере такой вывод показан заглавными буквами.
Вверху показан выведенный возвращаемый тип функции, как Body::Float64. Следующие строки представляют тело f в форме SSA-IR Julia. Номерные блоки - метки и представляют цели для переходов (через goto) в вашем коде. Глядя на тело, вы можете увидеть, что первое, что происходит, это вызов pos, и возвращаемое значение было определено как тип Union UNION{FLOAT64, INT64}, отображаемый заглавными буквами, поскольку это неконкретный тип. Это означает, что мы не можем узнать точный возвращаемый тип pos на основе входных типов. Однако результат y*x является Float64, независимо от того, является ли y типом Float64 или Int64. В итоге f(x::Float64) не будет иметь неустойчивого типа в выходе, даже если некоторые промежуточные вычисления имеют неустойчивый тип.
Как вы будете использовать эту информацию — решать вам. Очевидно, было бы намного лучше исправить pos для обеспечения типовой устойчивости: если вы это сделаете, все переменные в f станут конкретными, и её производительность будет оптимальной. Однако есть ситуации, когда такая эпизодическая неустойчивость типов может не иметь особого значения: например, если pos никогда не используется изолированно, тот факт, что выход f устойчив к типам (для входных данных Float64) защитит последующий код от распространяющегося влияния неустойчивости типов. Это особенно актуально в тех случаях, когда исправление неустойчивости типов затруднено или невозможно. В таких случаях указанные выше советы (например, добавление аннотаций типов и/или разделение функций) являются вашими лучшими инструментами для ограничения «повреждений» от неустойчивости типов. Также обратите внимание, что даже Julia Base имеет функции, которые неустойчивы к типам. Например, функция findfirst возвращает индекс в массиве, где найден ключ, или nothing в случае, если он не найден, — явный пример неустойчивости к типам. Чтобы легче находить неустойчивости типов, которые, скорее всего, будут важны, Union содержащие либо missing, либо nothing, выделяются жёлтым цветом, а не красным.
Следующие примеры могут помочь вам интерпретировать выражения, помеченные как содержащие нелистовые типы:
-
Тело функции, начинающееся с
Body::UNION{T1,T2})- Интерпретация: функция с неустойчивым типом возвращаемого значения
- Рекомендация: сделайте тип возвращаемого значения устойчивым к типам, даже если вам придётся его аннотировать
-
invoke Main.g(%%x::Int64)::UNION{FLOAT64, INT64}- Интерпретация: вызов функции с неустойчивым типом
g. - Рекомендация: исправьте функцию или, при необходимости, аннотируйте возвращаемое значение
- Интерпретация: вызов функции с неустойчивым типом
-
invoke Base.getindex(%%x::Array{Any,1}, 1::Int64)::ANY- Интерпретация: доступ к элементам массивов с плохо определёнными типами
- Рекомендация: используйте массивы с более чётко определёнными типами или, при необходимости, аннотируйте тип отдельных элементов доступа
-
Base.getfield(%%x, :(:data))::ARRAY{FLOAT64,N} WHERE N- Интерпретация: получение поля, которое имеет тип, не являющийся листом. В этом случае у
ArrayContainerбыло полеdata::Array{T}. НоArrayтакже требует размерностьN, чтобы быть конкретным типом. - Рекомендация: используйте конкретные типы, такие как
Array{T,3}илиArray{T,N}, гдеNтеперь является параметромArrayContainer
- Интерпретация: получение поля, которое имеет тип, не являющийся листом. В этом случае у
Производительность захваченной переменной
Рассмотрим следующий пример, определяющий внутреннюю функцию:
function abmult(r::Int)
if r < 0
r = -r
end
f = x -> x * r
return f
end
Функция abmult возвращает функцию f, которая умножает свой аргумент на абсолютное значение r. Внутренняя функция, назначенная f, называется «замыканием». Внутренние функции также используются языком для do-блоков и для генераторных выражений.
Этот стиль кода представляет проблемы производительности для языка. Парсер, при переводе его в инструкции более низкого уровня, существенно переупорядочивает приведенный выше код, извлекая внутреннюю функцию в отдельный блок кода. «Захваченные» переменные, такие как r, которые используются как внутренними функциями, так и содержащей их областью видимости, также извлекаются в «ящик» со значениями в куче, доступный как внутренним, так и внешним функциям, поскольку язык определяет, что r во внутренней области видимости должна быть идентична r во внешней области видимости даже после того, как внешняя область видимости (или другая внутренняя функция) изменяет r.
В предыдущем абзаце упоминался «парсер», то есть стадия компиляции, которая происходит при первом загрузке модуля, содержащего abmult, в отличие от более поздней стадии, когда он впервые вызывается. Парсер не «знает», что Int — это фиксированный тип, или что утверждение r = -r преобразует Int в другой Int. Волшебство вывода типов происходит на более поздней стадии компиляции.
Таким образом, парсер не знает, что r имеет фиксированный тип (Int), или что r не меняет своего значения после создания внутренней функции (так что ящик не нужен). Поэтому парсер генерирует код для ящика, который содержит объект с абстрактным типом, таким как Any, что требует диспетчеризации типов во время выполнения для каждого появления r Это можно проверить, применив @code_warntype к указанной выше функции. Как бокс, так и диспетчеризация типов во время выполнения могут привести к потере производительности.
Если захваченные переменные используются в критически важной для производительности части кода, следующие советы помогут обеспечить их эффективное использование. Во-первых, если известно, что захваченная переменная не меняет свой тип, это можно явно указать с помощью аннотации типа (для переменной, а не для правой части):
function abmult2(r0::Int)
r::Int = r0
if r < 0
r = -r
end
f = x -> x * r
return f
end
Аннотация типа частично восстанавливает потерянную производительность из-за захвата, поскольку парсер может связать конкретный тип с объектом в ящике. Далее, если захваченная переменная вообще не нуждается в боксировании (потому что она не будет переназначена после создания замыкания), это можно указать с помощью let блоков следующим образом.
function abmult3(r::Int)
if r < 0
r = -r
end
f = let r = r
x -> x * r
end
return f
end
Блок let создаёт новую переменную r, область видимости которой ограничена только внутренней функцией. Второй метод восстанавливает полную производительность языка при наличии захваченных переменных. Обратите внимание, что этот аспект компилятора быстро развивается, и, вероятно, будущие версии не потребуют такого уровня аннотаций программиста для достижения производительности. В то же время некоторые пакеты, созданные пользователями, такие как FastClosures, автоматизируют вставку let утверждений, как в abmult3.
© 2009–2019 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v0.7.0/manual/performance-tips/