Рекомендации по производительности
В следующих разделах мы кратко рассмотрим несколько техник, которые помогут сделать ваш код 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.009639 seconds (7.36 k allocations: 300.310 KiB, 98.32% compilation time)
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.006202 seconds (4.18 k allocations: 217.860 KiB, 99.72% compilation time)
496.84883432553846
julia> @time sum_arg(x)
0.000005 seconds (1 allocation: 16 bytes)
496.84883432553846
Единственное выделение, которое видно, происходит из запуска самого макроса @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[]
Real[]
julia> push!(a, 1); push!(a, 2.0); push!(a, π)
3-element Vector{Real}:
1
2.0
π = 3.1415926535897...
Поскольку a является массивом абстрактного типа Real, он должен быть способен содержать любое значение Real. Поскольку объекты Real могут иметь произвольный размер и структуру, a должен быть представлен в виде массива указателей на индивидуально выделенные объекты Real. Однако, если мы вместо этого будем разрешать хранение только чисел одного типа, например, Float64, в a, эти числа можно хранить более эффективно:
julia> a = Float64[]
Float64[]
julia> push!(a, 1); push!(a, 2.0); push!(a, π)
3-element Vector{Float64}:
1.0
2.0
3.141592653589793
Присваивание чисел в a теперь преобразует их в Float64, и a будет храниться как непрерывный блок 64-битных значений с плавающей точкой, которые можно эффективно обрабатывать.
Если вы не можете избежать контейнеров с абстрактными типами значений, иногда лучше параметризовать с Any, чтобы избежать проверки типа во время выполнения. Например, IdDict{Any, Any} выполняется лучше, чем IdDict{Type, Vector}
См. также обсуждение в разделе Параметризованные типы.
Объявления типов
Во многих языках с необязательными объявлениями типов добавление объявлений — основной способ ускорить выполнение кода. Это не относится к 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{Vector{Int64}}
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...)
Достаточно ввести один параметр типа, чтобы заставить специализироваться, даже если другие типы не ограничены. Например, это также будет специализироваться, что полезно, когда аргументы не все одного типа:
h_vararg(x::Vararg{Any, 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 Vector{Float64}:
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 Vector{Float64}:
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 Matrix{Float64}:
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 Matrix{Float64}:
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 Matrix{Float64}:
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 Matrix{Int64}:
1 2
3 4
julia> x[:]
4-element Vector{Int64}:
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) в десять раз быстрее и выделяет в 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 (3 allocations: 7.629 MB) julia> @time fview(x); 0.001020 seconds (1 allocation: 16 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
При условии достаточного объема памяти, затраты на копирование представления в массив значительно меньше, чем прирост скорости от выполнения умножения матриц на непрерывном массиве.
Использование StaticArrays.jl для операций с небольшими векторами/матрицами фиксированного размера
Если ваше приложение включает в себя много небольших (< 100 элемент) массивов фиксированных размеров (т. е. размер известен до выполнения), то вы можете рассмотреть использование пакета StaticArrays.jl. Этот пакет позволяет представлять такие массивы таким образом, чтобы избежать ненужных выделений памяти в куче и позволяет компилятору специализировать код для размера массива, например, путем полного разворачивания векторных операций (устранение циклов) и хранения элементов в регистрах ЦП.
Например, если вы выполняете вычисления с 2D-геометриями, у вас может быть много вычислений с векторами из 2 компонентов. Используя тип SVector из StaticArrays.jl, вы можете использовать удобную векторную запись и операции, такие как norm(3v - w) над векторами v и w, одновременно позволяя компилятору разворачивать код до минимального вычисления, эквивалентного @inbounds hypot(3v[1]-w[1], 3v[2]-w[2]).
Избегайте интерполяции строк для ввода/вывода
При записи данных в файл (или другое устройство ввода/вывода) формирование дополнительных промежуточных строк приводит к накладным расходам. Вместо:
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-mathclang. - Запишите
@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 переупорядочивает выражения с плавающей точкой, например, изменяя порядок вычислений или предполагая, что некоторые специальные случаи (inf, nan) не могут возникнуть. В этом случае (и на этом конкретном компьютере) основное отличие заключается в том, что выражение 1 / (2*dx) в функции deriv выносится за цикл (т. е. вычисляется вне цикла), как если бы вы написали idx = 1 / (2*dx). В цикле выражение ... / (2*dx) затем становится ... * idx, что намного быстрее для вычисления. Конечно, как сама оптимизация, применяемая компилятором, так и получаемое ускорение сильно зависят от оборудования. Вы можете изучить изменения в сгенерированном коде, используя функцию code_native Julia.
Обратите внимание, что @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.Const(f)
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 является то, что неконкретные типы отображаются красным цветом; так как этот документ написан в формате Markdown, который не поддерживает цвет, в этом документе красный текст обозначен заглавными буквами.
Вверху показан выведенный возвращаемый тип функции как 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–2021 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.6.0/manual/performance-tips/