Рекомендации по производительности
В следующих разделах мы кратко рассмотрим несколько техник, которые могут помочь сделать ваш код на 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
π
Поскольку 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)
Это сохраняет простоту, позволяя компилятору генерировать оптимизированный код во всех случаях.
Однако есть случаи, когда вам может потребоваться объявить разные версии внешней функции для разных типов элементов или типов 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.
Будьте внимательны к тому, когда Julia избегает специализации
В качестве эвристики, Julia избегает автоматической специализации по параметрам типа аргумента в трёх конкретных случаях: Type, Function, и Vararg. Julia всегда будет специализировать, когда аргумент используется внутри метода, но не если аргумент просто передаётся другой функции. Это обычно не влияет на производительность во время выполнения и улучшает производительность компилятора. Если вы обнаружите, что это влияет на производительность во время выполнения в вашем случае, вы можете вызвать специализацию, добавив параметр типа в объявление метода. Вот несколько примеров:
Это не будет специализироваться:
function f_type(t) # or t::Type
x = ones(t, 10)
return sum(map(sin, x))
end
но это будет:
function g_type(t::Type{T}) where T
x = ones(T, 10)
return sum(map(sin, x))
end
Эти не будут специализироваться:
f_func(f, num) = ntuple(f, div(num, 2)) g_func(g::Function, num) = ntuple(g, div(num, 2))
но это будет:
h_func(h::H, num) where {H} = ntuple(h, div(num, 2))
Это не будет специализироваться:
f_vararg(x::Int...) = tuple(x...)
но это будет:
g_vararg(x::Vararg{Int, N}) where {N} = tuple(x...)
Обратите внимание, что @code_typed и аналогичные функции всегда покажут вам специализированный код, даже если Julia обычно не специализирует этот вызов метода. Вам нужно проверить внутренности метода, если вы хотите увидеть, генерируются ли специализации при изменении типов аргументов, то есть, если (@which f(...)).specializations содержит специализации для рассматриваемого аргумента.
Разбить функции на несколько определений
Написание функции как множества маленьких определений позволяет компилятору напрямую вызывать наиболее подходящий код или даже встраивать его.
Вот пример «составной функции», которая должна быть написана как несколько определений:
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 может «найти» правильные вызовы методов во время компиляции функции (избегая проверки во время выполнения) и тем самым выдать эффективный код для обработки всего списка.
Когда это не так, скорее всего, пользы не будет; хуже того, возникнет «комбинаторный взрыв типов», что будет контрпродуктивно. Если 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, [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 учитывает структуру памяти по столбцам Matrix и заполняет её по одному столбцу за раз. Кроме того, 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) в десять раз быстрее и выделяет в 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 функции.
Копирование данных не всегда плохо
Массивы хранятся в памяти непрерывно, что способствует векторизации CPU и уменьшению числа обращений к памяти из-за кэширования. По этим же причинам рекомендуется обращаться к массивам в порядке следования столбцов (см. выше). Неправильные шаблоны доступа и несмежные представления могут значительно замедлить вычисления с массивами из-за не последовательного доступа к памяти.
Копирование данных с нерегулярным доступом в непрерывный массив перед выполнением операций над ним может привести к значительному ускорению, как показано в примере ниже. Здесь матрица и вектор обрабатываются по 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(foo, pid, 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-mathв clang. - Запишите
@simdперед цикламиfor, чтобы гарантировать, что итерации независимы и могут быть переупорядочены. Обратите внимание, что во многих случаях Julia может автоматически векторизировать код без макроса@simd; он полезен только в тех случаях, когда такая трансформация в противном случае была бы незаконной, включая такие случаи, как разрешение повторной ассоциативности чисел с плавающей запятой и игнорирование зависимых обращений к памяти (@simd ivdep). Опять же, будьте очень осторожны при утверждении@simd, так как неверная аннотация цикла с зависимыми итерациями может привести к неожиданным результатам. В частности, обратите внимание, чтоsetindex!для некоторыхAbstractArrayподтипов изначально зависит от порядка итераций. **Эта функция экспериментальна** и может быть изменена или удалена в будущих версиях Julia.
Общий прием использования 1:n для индексирования в AbstractArray небезопасен, если массив использует нестандартный индексирование, и может привести к ошибке сегментации, если проверка границ отключена. Используйте LinearIndices(x) или eachindex(x) вместо этого (см. также Массивы с пользовательскими индексами).
Хотя @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)
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 переупорядочивает выражения с плавающей запятой, например, изменяет порядок вычисления или предполагает, что некоторые специальные случаи (бесконечность, 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)
return sin(y*x + 1)
end;
julia> @code_warntype f(3.2)
Variables
#self#::Core.Compiler.Const(f, false)
x::Float64
y::Union{Float64, Int64}
Body::Float64
1 ─ (y = Main.pos(x))
│ %2 = (y * x)::Float64
│ %3 = (%2 + 1)::Float64
│ %4 = Main.sin(%3)::Float64
└── return %4
Интерпретация вывода @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.
Проверка на равенство с одиночным элементом
При проверке, является ли значение равным некоторому одиночному элементу, для повышения производительности лучше проверять идентичность (===) вместо равенства (==). Такой же совет относится к использованию !== вместо != Такие проверки часто встречаются, например, при реализации протокола итерации и проверке, является ли nothing возвращённым значением из iterate.
© 2009–2020 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.4.2/manual/performance-tips/