Spec-Zone.ru › Julia 1.0

Рекомендации по производительности

В следующих разделах мы кратко рассмотрим несколько техник, которые могут помочь сделать ваш код 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

Выделения, которые видны, — это выделение при выполнении макроса @time в глобальной области. Если мы вместо этого запустим измерение времени внутри функции, мы увидим, что выделение памяти, действительно, не происходит:

julia> time_sum(x) = @time sum_arg(x);

julia> time_sum(x)
  0.000001 seconds
496.84883432553846

В некоторых ситуациях вашей функции может потребоваться выделять память в ходе ее работы, и это может усложнить простую картину выше. В таких случаях рассмотрите возможность использования одного из инструментов, описанных ниже, для диагностики проблем или написания версии вашей функции, которая разделяет выделение памяти от ее алгоритмических аспектов (см. Предварительное выделение выходов).

Примечание

Для более серьезного бенчмаркинга рассмотрите пакет BenchmarkTools.jl, который, помимо прочего, многократно оценивает функцию, чтобы уменьшить шум.

Инструменты

Julia и ее экосистема пакетов включают инструменты, которые могут помочь вам диагностировать проблемы и улучшить производительность вашего кода:

  • Профилирование позволяет измерить производительность вашего работающего кода и найти строки, являющиеся узкими местами. Для сложных проектов пакет ProfileView может помочь визуализировать результаты профилирования.
  • Пакет Traceur может помочь вам найти распространенные проблемы с производительностью в вашем коде.
  • Неожиданно большие выделения памяти — как сообщается @time, @allocated или профилировщиком (через вызовы процедур сбора мусора) — подсказывают, что могут быть проблемы с вашим кодом. Если вы не видите другой причины выделений, подозревайте проблему с типом. Вы также можете запустить Julia с опцией --track-allocation=user и проверить полученные *.mem файлы, чтобы увидеть информацию о том, где произошли эти выделения. См. Анализ выделения памяти.
  • @code_warntype генерирует представление вашего кода, которое может быть полезно для поиска выражений, приводящих к неопределенности типа. См. @code_warntype ниже.

Избегайте контейнеров с параметрами абстрактного типа

При работе с параметризованными типами, включая массивы, лучше всего избегать параметризации с абстрактными типами, где это возможно.

Рассмотрим следующее:

julia> a = Real[]
0-element Array{Real,1}

julia> push!(a, 1); push!(a, 2.0); push!(a, π)
3-element Array{Real,1}:
  1
  2.0
 π = 3.1415926535897...

Поскольку a представляет собой массив абстрактного типа Real, он должен уметь хранить любое значение Real. Поскольку объекты Real могут иметь произвольный размер и структуру, a должен быть представлен как массив указателей на индивидуально выделенные объекты Real. Однако, если мы вместо этого разрешим хранить только числа одного типа, например, Float64, в a, то их можно хранить более эффективно:

julia> a = Float64[]
0-element Array{Float64,1}

julia> push!(a, 1); push!(a, 2.0); push!(a,  π)
3-element Array{Float64,1}:
 1.0
 2.0
 3.141592653589793

Присвоение чисел в a теперь приведет к их преобразованию в Float64, а a будет храниться как непрерывный блок 64-битных значений с плавающей точкой, которые можно эффективно обрабатывать.

См. также обсуждение в разделе Параметризованные типы.

Объявления типов

Во многих языках с необязательными объявлениями типов добавление объявлений является основным способом ускорения выполнения кода. Это не так в Julia. В Julia компилятор, как правило, знает типы всех аргументов функций, локальных переменных и выражений. Однако есть несколько конкретных случаев, когда объявления полезны.

Избегайте полей с абстрактным типом

Типы могут быть объявлены без указания типов их полей:

julia> struct MyAmbiguousType
           a
       end

Это позволяет a быть любого типа. Это часто бывает полезно, но у этого есть недостаток: для объектов типа MyAmbiguousType, компилятор не сможет сгенерировать код высокой производительности. Причина в том, что компилятор использует типы объектов, а не их значения, чтобы определить, как создавать код. К сожалению, о объекте типа MyAmbiguousType можно сделать очень мало выводов:

julia> b = MyAmbiguousType("Hello")
MyAmbiguousType("Hello")

julia> c = MyAmbiguousType(17)
MyAmbiguousType(17)

julia> typeof(b)
MyAmbiguousType

julia> typeof(c)
MyAmbiguousType

Значения b и c имеют одинаковый тип, но их внутреннее представление данных в памяти сильно различается. Даже если вы храните только числовые значения в поле a, тот факт, что представление в памяти UInt8 отличается от Float64, также означает, что процессору необходимо обрабатывать их с помощью двух различных типов инструкций. Поскольку необходимая информация недоступна в типе, такие решения должны приниматься во время выполнения. Это снижает производительность.

Вы можете добиться лучшего, объявив тип a. Здесь мы сосредоточены на случае, когда a может быть любого из нескольких типов, в этом случае естественным решением является использование параметров. Например:

julia> mutable struct MyType{T<:AbstractFloat}
           a::T
       end

Это лучший вариант, чем

julia> mutable struct MyStillAmbiguousType
           a::AbstractFloat
       end

потому что в первом варианте тип a определяется типом объекта-оболочки. Например:

julia> m = MyType(3.2)
MyType{Float64}(3.2)

julia> t = MyStillAmbiguousType(3.2)
MyStillAmbiguousType(3.2)

julia> typeof(m)
MyType{Float64}

julia> typeof(t)
MyStillAmbiguousType

Тип поля a легко определяется по типу m, но не по типу t. Действительно, в t возможно изменить тип поля a:

julia> typeof(t.a)
Float64

julia> t.a = 4.5f0
4.5f0

julia> typeof(t.a)
Float32

В противоположность этому, после создания m, тип m.a не может измениться:

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float64

Тот факт, что тип m.a известен из типа m, а также тот факт, что его тип не может измениться внутри функции, позволяет компилятору генерировать высокооптимизированный код для объектов, подобных m, но не для объектов, подобных t.

Конечно, все это верно только в том случае, если мы создаем m с конкретным типом. Мы можем нарушить это, явно создав его с абстрактным типом:

julia> m = MyType{AbstractFloat}(3.2)
MyType{AbstractFloat}(3.2)

julia> typeof(m.a)
Float64

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float32

Практически такие объекты ведут себя идентично объектам MyStillAmbiguousType.

Очень показательно сравнить объем кода, сгенерированного для простой функции

func(m::MyType) = m.a+1

с использованием

code_llvm(func, Tuple{MyType{Float64}})
code_llvm(func, Tuple{MyType{AbstractFloat}})

По причинам объема результаты здесь не показаны, но вы можете попробовать это самостоятельно. Поскольку тип полностью указан в первом случае, компилятору не нужно генерировать код для разрешения типа во время выполнения. Это приводит к более короткому и быстрому коду.

Избегайте полей с абстрактными контейнерами

Те же лучшие практики работают и для типов контейнеров:

julia> struct MySimpleContainer{A<:AbstractVector}
           a::A
       end

julia> struct MyAmbiguousContainer{T}
           a::AbstractVector{T}
       end

Например:

julia> c = MySimpleContainer(1:3);

julia> typeof(c)
MySimpleContainer{UnitRange{Int64}}

julia> c = MySimpleContainer([1:3;]);

julia> typeof(c)
MySimpleContainer{Array{Int64,1}}

julia> b = MyAmbiguousContainer(1:3);

julia> typeof(b)
MyAmbiguousContainer{Int64}

julia> b = MyAmbiguousContainer([1:3;]);

julia> typeof(b)
MyAmbiguousContainer{Int64}

Для MySimpleContainer, объект полностью определяется своим типом и параметрами, поэтому компилятор может сгенерировать оптимизированные функции. В большинстве случаев этого, вероятно, будет достаточно.

Хотя компилятор теперь может выполнять свою работу идеально, есть случаи, когда вам может понадобиться, чтобы ваш код мог делать разные вещи в зависимости от типа элемента a. Обычно лучший способ достичь этого — обернуть вашу специфическую операцию (здесь, foo) в отдельную функцию:

julia> function sumfoo(c::MySimpleContainer)
           s = 0
           for x in c.a
               s += foo(x)
           end
           s
       end
sumfoo (generic function with 1 method)

julia> foo(x::Integer) = x
foo (generic function with 1 method)

julia> foo(x::AbstractFloat) = round(x)
foo (generic function with 2 methods)

Это сохраняет простоту, одновременно позволяя компилятору генерировать оптимизированный код во всех случаях.

END_OF_DOCUMENT_MARKER

Однако, существуют случаи, когда вам может потребоваться объявить различные версии внешней функции для разных типов элементов или типов AbstractVector поля a в MySimpleContainer. Вы можете сделать это так:

julia> function myfunc(c::MySimpleContainer{<:AbstractArray{<:Integer}})
           return c.a[1]+1
       end
myfunc (generic function with 1 method)

julia> function myfunc(c::MySimpleContainer{<:AbstractArray{<:AbstractFloat}})
           return c.a[1]+2
       end
myfunc (generic function with 2 methods)

julia> function myfunc(c::MySimpleContainer{Vector{T}}) where T <: Integer
           return c.a[1]+3
       end
myfunc (generic function with 3 methods)
julia> myfunc(MySimpleContainer(1:3))
2

julia> myfunc(MySimpleContainer(1.0:3))
3.0

julia> myfunc(MySimpleContainer([1:3;]))
4

Аннотирование значений, взятых из нетипизированных расположений

Часто удобно работать со структурами данных, которые могут содержать значения любого типа (массивы типа Array{Any}). Но если вы используете одну из этих структур и знаете тип элемента, это помогает сообщить эту информацию компилятору:

function foo(a::Array{Any,1})
    x = a[1]::Int32
    b = x+1
    ...
end

Здесь нам известно, что первый элемент a будет Int32. Такая аннотация имеет дополнительное преимущество, заключающееся в том, что она вызовет ошибку во время выполнения, если значение не соответствует ожидаемому типу, потенциально обнаруживая определенные ошибки на ранней стадии.

В случае, если тип a[1] не известен точно, x можно объявить с помощью x = convert(Int32, a[1])::Int32. Использование функции convert позволяет a[1] быть любым объектом, преобразуемым в Int32 (таким как UInt8), тем самым увеличивая общность кода, ослабляя требования к типу. Обратите внимание, что convert в этом контексте также нуждается в аннотации типа для обеспечения стабильности типа. Это связано с тем, что компилятор не может вывести тип возвращаемого значения функции, даже convert, если не известны типы всех аргументов функции.

Аннотация типов не улучшит (и может фактически ухудшить) производительность, если тип создается во время выполнения. Это связано с тем, что компилятор не может использовать аннотацию для специализации последующего кода, а сама проверка типа занимает время. Например, в коде:

function nr(a, prec)
    ctype = prec == 32 ? Float32 : Float64
    b = Complex{ctype}(a)
    c = (b + 1.0f0)::Complex{ctype}
    abs(c)
end

аннотация c ухудшает производительность. Чтобы написать производительный код, связанный с типами, созданными во время выполнения, используйте технику "барьер функций", обсуждаемую ниже, и убедитесь, что созданный тип появляется среди типов аргументов ядровой функции, чтобы ядреные операции были должным образом специализированы компилятором. Например, в приведенном выше фрагменте, как только b создан, он может быть передан в другую функцию k, ядро. Если, например, функция k объявляет b как аргумент типа Complex{T}, где T - параметр типа, то аннотация типа, появляющаяся в операторе присваивания внутри k, не снижает производительность (но и не улучшает ее), так как компилятор может определить тип c на момент компиляции k.

Объявление типов ключевых аргументов

Ключевые аргументы могут иметь объявленные типы:

function with_keyword(x; name::Int = 1)
    ...
end

Функции специализируются по типам ключевых аргументов, поэтому эти объявления не повлияют на производительность кода внутри функции. Однако они уменьшат издержки вызовов функции, которые включают ключевые аргументы.

Функции с ключевыми аргументами имеют практически нулевые издержки для мест вызова, которые передают только позиционные аргументы.

Передача динамических списков ключевых аргументов, как в f(x; keywords...), может быть медленной и следует избегать в коде, критичном к производительности.

Разбиение функций на несколько определений

Написание функции как многих небольших определений позволяет компилятору напрямую вызывать наиболее подходящий код или даже встраивать его.

Вот пример «составной функции», которую следует написать как несколько определений:

using LinearAlgebra

function mynorm(A)
    if isa(A, Vector)
        return sqrt(real(dot(A,A)))
    elseif isa(A, Matrix)
        return maximum(svdvals(A))
    else
        error("mynorm: invalid argument")
    end
end

Это можно записать более лаконично и эффективно как:

norm(x::Vector) = sqrt(real(dot(x, x)))
norm(A::Matrix) = maximum(svdvals(A))

Однако следует отметить, что компилятор довольно эффективен в оптимизации «мертвых» ветвей в коде, написанном в виде примера mynorm.

Написание «стабильных по типу» функций

Когда это возможно, полезно обеспечить, чтобы функция всегда возвращала значение одного и того же типа. Рассмотрим следующее определение:

pos(x) = x < 0 ? 0 : x

Хотя это кажется безобидным, проблема в том, что 0 - целое число (типа Int), а x может быть любого типа. Таким образом, в зависимости от значения x, эта функция может вернуть значение одного из двух типов. Такое поведение допустимо и может быть желательно в некоторых случаях. Но это легко исправить следующим образом:

pos(x) = x < 0 ? zero(x) : x

Также существует функция oneunit и более общая функция oftype(x, y), которая возвращает y, преобразованное к типу x.

Избегайте изменения типа переменной

Аналогичная проблема «стабильности типа» существует для переменных, используемых многократно в функции:

function foo()
    x = 1
    for i = 1:10
        x /= rand()
    end
    return x
end

Локальная переменная x начинается как целое число, а после одной итерации цикла становится числом с плавающей запятой (результат оператора /). Это затрудняет компилятору оптимизировать тело цикла. Существует несколько возможных решений:

  • Инициализировать x значением x = 1.0
  • Объявить тип x: x::Float64 = 1
  • Использовать явное преобразование: x = oneunit(Float64)
  • Инициализировать с первой итерации цикла, значением x = 1 / rand(), затем цикл for i = 2:10

Отдельные ядреные функции (также известные как барьеры функций)

Многие функции следуют шаблону выполнения подготовительной работы и последующего выполнения многих итераций для выполнения основного вычисления. В тех случаях, когда это возможно, полезно вынести эти основные вычисления в отдельные функции. Например, следующая искусственная функция возвращает массив случайного типа:

julia> function strange_twos(n)
           a = Vector{rand(Bool) ? Int64 : Float64}(undef, n)
           for i = 1:n
               a[i] = 2
           end
           return a
       end;

julia> strange_twos(3)
3-element Array{Float64,1}:
 2.0
 2.0
 2.0

Это следует написать как:

julia> function fill_twos!(a)
           for i = eachindex(a)
               a[i] = 2
           end
       end;

julia> function strange_twos(n)
           a = Vector{rand(Bool) ? Int64 : Float64}(undef, n)
           fill_twos!(a)
           return a
       end;

julia> strange_twos(3)
3-element Array{Float64,1}:
 2.0
 2.0
 2.0

Компилятор Julia специализирует код для типов аргументов на границах функций, поэтому в исходной реализации он не знает тип a во время цикла (так как он выбран случайным образом). Поэтому второй вариант, как правило, быстрее, так как внутренний цикл может быть перекомпилирован как часть fill_twos! для различных типов a.

Второй вариант также часто более понятен и может привести к большей повторной употреблемости кода.

Этот шаблон используется в нескольких местах в Julia Base. Например, см. vcat и hcat в abstractarray.jl, или функцию fill!, которую мы могли бы использовать вместо написания собственной fill_twos!.

Функции, подобные strange_twos, возникают при работе с данными неопределенного типа, например, данными, загруженными из входного файла, которые могут содержать целые числа, числа с плавающей точкой, строки или что-то еще.

Типы со значениями в качестве параметров

Предположим, что вы хотите создать N-мерный массив, размер которого равен 3 по каждой оси. Такие массивы можно создать так:

julia> A = fill(5.0, (3, 3))
3×3 Array{Float64,2}:
 5.0  5.0  5.0
 5.0  5.0  5.0
 5.0  5.0  5.0

Этот подход работает очень хорошо: компилятор может определить, что A является Array{Float64,2}, поскольку он знает тип значения заполнения (5.0::Float64) и размерность ((3, 3)::NTuple{2,Int}). Это означает, что компилятор может сгенерировать очень эффективный код для любого последующего использования A в той же функции.

Но теперь предположим, что вы хотите написать функцию, которая создает массив 3×3×... в произвольных измерениях; вы можете быть искушены написать функцию

julia> function array3(fillval, N)
           fill(fillval, ntuple(d->3, N))
       end
array3 (generic function with 1 method)

julia> array3(5.0, 2)
3×3 Array{Float64,2}:
 5.0  5.0  5.0
 5.0  5.0  5.0
 5.0  5.0  5.0

Это работает, но (как вы можете проверить самостоятельно, используя @code_warntype array3(5.0, 2)) проблема в том, что тип результата не может быть выведен: аргумент N - это значение типа Int, и вывод типа не (и не может) предсказать его значение заранее. Это означает, что код, использующий результат этой функции, должен быть консервативным, проверяя тип при каждом обращении к A; такой код будет очень медленным.

В настоящее время одним из очень хороших способов решения таких проблем является использование техники «барьер функций». Однако в некоторых случаях вы можете захотеть устранить нестабильность типа полностью. В таких случаях один из подходов заключается в передаче размерности в качестве параметра, например, через Val{T}() (см. "Типы значений"):

julia> function array3(fillval, ::Val{N}) where N
           fill(fillval, ntuple(d->3, Val(N)))
       end
array3 (generic function with 1 method)

julia> array3(5.0, Val(2))
3×3 Array{Float64,2}:
 5.0  5.0  5.0
 5.0  5.0  5.0
 5.0  5.0  5.0

Julia имеет специализированную версию ntuple, которая принимает экземпляр Val{::Int} в качестве второго параметра; передавая N как параметр типа, вы делаете его «значение» известным компилятору. В результате эта версия array3 позволяет компилятору предсказать возвращаемый тип.

Однако использование таких техник может быть удивительно тонким. Например, это было бы бесполезно, если бы вы вызывали array3 из функции такого рода:

function call_array3(fillval, n)
    A = array3(fillval, Val(n))
end

Здесь вы создали ту же проблему снова: компилятор не может догадаться, что такое n, поэтому он не знает тип Val(n) . Попытка использовать Val, но сделать это неправильно, может легко ухудшить производительность во многих ситуациях. (Только в тех ситуациях, где вы фактически комбинируете Val с техникой барьера функций, чтобы сделать ядреную функцию более эффективной, следует использовать код такого типа.)

Пример правильного использования Val был бы:

function filter3(A::AbstractArray{T,N}) where {T,N}
    kernel = array3(1, Val(N))
    filter(A, kernel)
end

В этом примере N передается в качестве параметра, поэтому его «значение» известно компилятору. По существу, Val(T) работает только тогда, когда T либо жестко закодирован/литеральный (Val(3)), либо уже указан в области типов.

Опасности злоупотребления множественным диспетчерированием (т.е., больше о типах со значениями в качестве параметров)

После того, как кто-то научится ценить множественное диспетчерирование, есть понятная тенденция сойти с ума и попытаться использовать его для всего. Например, вы можете представить себе использование его для хранения информации, например,

struct Car{Make, Model}
    year::Int
    ...more fields...
end

и затем диспетчировать по объектам, таким как Car{:Honda,:Accord}(year, args...).

Это может быть целесообразно, если выполняется хотя бы одно из следующих условий:

  • Вам требуется ресурсоёмкая обработка на каждом Car, и она становится намного эффективнее, если вы знаете Make и Model во время компиляции, а также общее количество различных Make или Model, которые будут использованы, не слишком велико.
  • У вас есть однородные списки одного и того же типа Car для обработки, так что вы можете сохранить их все в Array{Car{:Honda,:Accord},N}.

Когда это выполняется, функцию обработки такого однородного массива можно продуктивно специализировать: Julia знает тип каждого элемента заранее (все объекты в контейнере имеют один и тот же конкретный тип), поэтому Julia может «найти» правильные вызовы методов во время компиляции функции (избегая проверки во время выполнения) и тем самым выдать эффективный код для обработки всего списка.

Когда этого не происходит, вы, вероятно, не получите преимуществ; что хуже, возникнет «комбинаторный взрыв типов», что будет контрпродуктивно. Если items[i+1] имеет другой тип, чем item[i], Julia должна найти тип во время выполнения, найти соответствующий метод в таблицах методов, решить (через пересечение типов), какой из них соответствует, определить, был ли он уже скомпилирован JIT (и сделать это, если нет), а затем выполнить вызов. По сути, вы просите всю систему типов и механизм JIT-компиляции в основном выполнить эквивалент оператора switch или поиска в словаре в вашем собственном коде.

Некоторые эталонные измерения времени выполнения, сравнивающие (1) диспетчеризацию типов, (2) поиск в словаре и (3) оператор «switch», можно найти на списке рассылки.

Возможно, еще хуже, чем влияние на время выполнения, это влияние на время компиляции: Julia будет компилировать специализированные функции для каждого различного Car{Make, Model}; если у вас есть сотни или тысячи таких типов, то каждая функция, которая принимает такой объект в качестве параметра (от пользовательской функции get_year, которую вы можете написать сами, до универсальной функции push! в Julia Base) будет иметь сотни или тысячи вариантов, скомпилированных для неё. Каждый из них увеличивает размер кэша скомпилированного кода, длину внутренних списков методов и т. д. Чрезмерное увлечение значениями в качестве параметров может легко потратить огромные ресурсы.

Доступ к массивам в порядке памяти, по столбцам

Многомерные массивы в Julia хранятся в порядке следования столбцов. Это означает, что массивы складываются один столбец за раз. Это можно проверить, используя функцию vec или синтаксис [:], как показано ниже (обратите внимание, что массив отсортирован по [1 3 2 4], а не по [1 2 3 4]):

julia> x = [1 2; 3 4]
2×2 Array{Int64,2}:
 1  2
 3  4

julia> x[:]
4-element Array{Int64,1}:
 1
 3
 2
 4

Эта конвенция для упорядочения массивов распространена во многих языках, таких как Fortran, Matlab и R (и не только). Альтернатива порядку следования столбцов — порядок следования строк, который используется в C и Python (numpy) и некоторых других языках. Память об упорядочивании массивов может иметь существенное влияние на производительность при итерациях по массивам. Правило хорошего тона, которое следует помнить, заключается в том, что при массивах со следованием столбцов первый индекс изменяется наиболее быстро. В сущности это означает, что итерация будет быстрее, если индекс внутреннего цикла является первым, который появляется в выражении среза.

Рассмотрим следующий искусственный пример. Представьте, что мы хотим написать функцию, которая принимает Vector и возвращает квадратную Matrix, где либо строки, либо столбцы заполняются копиями входного вектора. Предположим, что порядок, в котором заполняются строки или столбцы этими копиями, не имеет значения (возможно, остальной код можно легко адаптировать соответственно). Мы могли бы сделать это по крайней мере четырьмя способами (в дополнение к рекомендуемому вызову встроенной функции repeat):

function copy_cols(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for i = inds
        out[:, i] = x
    end
    return out
end

function copy_rows(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for i = inds
        out[i, :] = x
    end
    return out
end

function copy_col_row(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for col = inds, row = inds
        out[row, col] = x[row]
    end
    return out
end

function copy_row_col(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for row = inds, col = inds
        out[row, col] = x[col]
    end
    return out
end

Теперь мы измерим время выполнения каждой из этих функций, используя тот же случайный 10000 × 1 входной вектор:

julia> x = randn(10000);

julia> fmt(f) = println(rpad(string(f)*": ", 14, ' '), @elapsed f(x))

julia> map(fmt, Any[copy_cols, copy_rows, copy_col_row, copy_row_col]);
copy_cols:    0.331706323
copy_rows:    1.799009911
copy_col_row: 0.415630047
copy_row_col: 1.721531501

Обратите внимание, что copy_cols намного быстрее, чем copy_rows. Это ожидаемо, потому что copy_cols учитывает расположение в памяти массива по столбцам и заполняет его один столбец за раз. Кроме того, copy_col_row намного быстрее, чем copy_row_col, потому что он следует нашему правилу, что первый элемент, который появляется в выражении среза, должен быть связан с внутренним циклом.

Предварительное выделение памяти для результатов

Если ваша функция возвращает Array или какой-либо другой сложный тип, ей может потребоваться выделение памяти. К сожалению, выделение памяти и его обратный процесс, сборка мусора, часто являются существенными узкими местами.

Иногда можно обойти необходимость выделения памяти при каждом вызове функции, предварительно выделив память для результата. В качестве тривиального примера сравните

julia> function xinc(x)
           return [x, x+1, x+2]
       end;

julia> function loopinc()
           y = 0
           for i = 1:10^7
               ret = xinc(i)
               y += ret[2]
           end
           return y
       end;

с

julia> function xinc!(ret::AbstractVector{T}, x::T) where T
           ret[1] = x
           ret[2] = x+1
           ret[3] = x+2
           nothing
       end;

julia> function loopinc_prealloc()
           ret = Vector{Int}(undef, 3)
           y = 0
           for i = 1:10^7
               xinc!(ret, i)
               y += ret[2]
           end
           return y
       end;

Результаты измерений времени выполнения:

julia> @time loopinc()
  0.529894 seconds (40.00 M allocations: 1.490 GiB, 12.14% gc time)
50000015000000

julia> @time loopinc_prealloc()
  0.030850 seconds (6 allocations: 288 bytes)
50000015000000

Предварительное выделение памяти имеет и другие преимущества, например, позволяя вызывающей стороне контролировать «тип результата» алгоритма. В приведенном выше примере мы могли бы передать SubArray вместо Array, если бы захотели.

В крайнем случае предварительное выделение памяти может усложнить ваш код, поэтому могут потребоваться измерения производительности и некоторые суждения. Однако для «векторизованных» (элементных) функций удобный синтаксис x .= f.(y) можно использовать для операций на месте с объединенными циклами и без временных массивов (см. синтаксис точки для векторизации функций).

Ещё точки: Объединение векторизованных операций

Julia имеет специальный синтаксис точки, который преобразует любую скалярную функцию в вызов «векторизованной» функции, а любой оператор — в «векторизованный» оператор с особым свойством: вложенные «вызовы с точкой» объединяются: они объединяются на уровне синтаксиса в один цикл без выделения временных массивов. Если вы используете .= и подобные операторы присваивания, результат также может быть сохранён непосредственно в предварительно выделенном массиве (см. выше).

В контексте линейной алгебры это означает, что хотя такие операции, как vector + vector и vector * scalar, определены, использование vector .+ vector и vector .* scalar может быть предпочтительнее, поскольку результирующие циклы могут быть объединены с окружающими вычислениями. Например, рассмотрите две функции:

julia> f(x) = 3x.^2 + 4x + 7x.^3;

julia> fdot(x) = @. 3x^2 + 4x + 7x^3 # equivalent to 3 .* x.^2 .+ 4 .* x .+ 7 .* x.^3;

Обе f и fdot вычисляют одно и то же. Однако, fdot (определённая с помощью макроса @.) значительно быстрее при применении к массиву:

julia> x = rand(10^6);

julia> @time f(x);
  0.019049 seconds (16 allocations: 45.777 MiB, 18.59% gc time)

julia> @time fdot(x);
  0.002790 seconds (6 allocations: 7.630 MiB)

julia> @time f.(x);
  0.002626 seconds (8 allocations: 7.630 MiB)

То есть, fdot(x) в десять раз быстрее и выделяет в 1/6 меньше памяти, чем f(x), потому что каждая * и + операция в f(x) выделяет новый временный массив и выполняется в отдельном цикле. (Конечно, если вы просто сделаете f.(x), то она будет так же быстрой, как и fdot(x) в этом примере, но во многих контекстах удобнее просто расставить точки в выражениях, чем определять отдельную функцию для каждой векторизованной операции.)

Рассмотрите использование представлений для срезов

В Julia выражение среза массива, например array[1:5, :], создаёт копию этих данных (за исключением левой части присваивания, где array[1:5, :] = ... выполняет присваивание на месте в этой части array). Если вы выполняете много операций со срезом, это может быть выгодно с точки зрения производительности, так как эффективнее работать с более компактной копией, чем индексировать в исходный массив. С другой стороны, если вы выполняете только несколько простых операций со срезом, стоимость операций выделения и копирования может быть значительной.

Альтернативный вариант — создание «представления» массива, которое является объектом массива (SubArray), фактически ссылающимся на данные исходного массива непосредственно, без создания копии. (Если вы записываете в представление, это также изменяет данные исходного массива.) Это можно сделать для отдельных срезов, вызвав view, или более просто для всего выражения или блока кода, поместив @views перед этим выражением. Например:

julia> fcopy(x) = sum(x[2:end-1]);

julia> @views fview(x) = sum(x[2:end-1]);

julia> x = rand(10^6);

julia> @time fcopy(x);
  0.003051 seconds (7 allocations: 7.630 MB)

julia> @time fview(x);
  0.001020 seconds (6 allocations: 224 bytes)

Обратите внимание на ускорение в 3 раза и уменьшение выделения памяти в версии функции fview.

Копирование данных не всегда плохо

Массивы хранятся непрерывно в памяти, что способствует векторизации на уровне 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) вместо этого (см. также offset-arrays).

Примечание

В то время как @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)
           sin(y*x + 1)
       end;

julia> @code_warntype f(3.2)
Body::Float64
2 1 ─ %1  = invoke Main.pos(%%x::Float64)::UNION{FLOAT64, INT64}
3 │   %2  = isa(%1, Float64)::Bool
  └──       goto 3 if not %2
  2 ─ %4  = π (%1, Float64)
  │   %5  = Base.mul_float(%4, %%x)::Float64
  └──       goto 6
  3 ─ %7  = isa(%1, Int64)::Bool
  └──       goto 5 if not %7
  4 ─ %9  = π (%1, Int64)
  │   %10 = Base.sitofp(Float64, %9)::Float64
  │   %11 = Base.mul_float(%10, %%x)::Float64
  └──       goto 6
  5 ─       Base.error("fatal error in type inference (type bound)")
  └──       unreachable
  6 ┄ %15 = φ (2 => %5, 4 => %11)::Float64
  │   %16 = Base.add_float(%15, 1.0)::Float64
  │   %17 = invoke Main.sin(%16::Float64)::Float64
  └──       return %17

Интерпретация вывода @code_warntype, как и его аналогов @code_lowered, @code_typed, @code_llvm и @code_native, требует некоторой практики. Ваш код представлен в форме, которая была сильно обработана на пути к генерации скомпилированного машинного кода. Большинство выражений снабжены типом, указанным ::T, (например, T может быть Float64). Наиболее важной характеристикой @code_warntype является то, что неконкретные типы отображаются красным цветом; в вышеприведённом примере такой вывод показан заглавными буквами.

Вверху показан выведенный возвращаемый тип функции Body::Float64. Следующие строки представляют тело f в форме SSA-IR Julia. Номерные ящики – метки и представляют цели переходов (через goto) в вашем коде. Посмотрев на тело, вы увидите, что в первую очередь вызывается pos, и возвращаемое значение было определено как тип Union UNION{FLOAT64, INT64}, показанный заглавными буквами, поскольку это неконкретный тип. Это означает, что мы не можем определить точный тип возвращаемого значения pos на основе входных типов. Однако, результат вычисления y*x - это Float64 независимо от того, является ли y Float64 или Int64. Итоговым результатом является то, что f(x::Float64) не будет иметь проблем с типом в своём выводе, даже если некоторые промежуточные вычисления имеют проблемы с типом.

END_OF_DOCUMENT_MARKER

Как использовать эту информацию, решать вам. Очевидно, было бы намного лучше исправить pos так, чтобы тип был устойчивым: если вы это сделаете, все переменные в f будут конкретными, и её производительность будет оптимальной. Однако есть ситуации, когда такая изменчивость типа может не иметь большого значения: например, если pos никогда не используется изолированно, тот факт, что вывод f устойчив к типу (для входных данных Float64) защитит последующий код от распространяющихся последствий изменчивости типа. Это особенно актуально в тех случаях, когда исправление изменчивости типа затруднено или невозможно. В таких случаях описанные выше советы (например, добавление аннотаций типов и/или разделение функций) являются лучшими инструментами для ограничения «вреда» от изменчивости типа. Также обратите внимание, что даже в Julia Base есть функции, которые нестабильны с точки зрения типов. Например, функция findfirst возвращает индекс в массиве, где найден ключ, или nothing если он не найден, — явный пример изменчивости типа. Чтобы было проще найти изменчивости типов, которые, вероятно, важны, Union содержащие либо missing, либо nothing, выделяются жёлтым цветом вместо красного.

Следующие примеры могут помочь вам интерпретировать выражения, помеченные как содержащие нелистовые типы:

  • Тело функции, начинающееся с Body::UNION{T1,T2})

    • Интерпретация: функция с неустойчивым возвращаемым типом
    • Рекомендация: сделайте возвращаемое значение устойчивым к типу, даже если потребуется его аннотировать
  • invoke Main.g(%%x::Int64)::UNION{FLOAT64, INT64}

    • Интерпретация: вызов функции с неустойчивым типом g.
    • Рекомендация: исправьте функцию или, при необходимости, аннотируйте возвращаемое значение
  • invoke Base.getindex(%%x::Array{Any,1}, 1::Int64)::ANY

    • Интерпретация: доступ к элементам массивов с плохо определёнными типами
    • Рекомендация: используйте массивы с более чётко определёнными типами или, при необходимости, аннотируйте тип отдельных обращений к элементам
  • Base.getfield(%%x, :(:data))::ARRAY{FLOAT64,N} WHERE N

    • Интерпретация: получение поля, которое имеет нелистовой тип. В этом случае, ArrayContainer имело поле data::Array{T}. Но Array также нуждается в размерности N, чтобы быть конкретным типом.
    • Рекомендация: используйте конкретные типы, такие как Array{T,3} или Array{T,N}, где N теперь является параметром ArrayContainer

Производительность захваченной переменной

Рассмотрим следующий пример, в котором определена внутренняя функция:

function abmult(r::Int)
    if r < 0
        r = -r
    end
    f = x -> x * r
    return f
end

Функция abmult возвращает функцию f которая умножает свой аргумент на абсолютное значение r. Внутренняя функция, присвоенная f называется "замыканием". Внутренние функции также используются языком для do-блоков и для генераторных выражений.

Этот стиль кода представляет проблемы производительности для языка. Парсер, при переводе его в инструкции более низкого уровня, существенно перестраивает вышеприведённый код, вынося внутреннюю функцию в отдельный блок кода. "Захваченные" переменные, такие как r которые совместно используются внутренними функциями и их внешней областью видимости, также извлекаются в "ящик" на куче, доступный как внутренним, так и внешним функциям, потому что язык требует, чтобы r во внутренней области видимости были идентичны r во внешней области видимости, даже после того, как внешняя область видимости (или другая внутренняя функция) изменяет r.

Обсуждение в предыдущем абзаце относилось к "парсеру", то есть к фазе компиляции, которая происходит, когда модуль, содержащий abmult загружается впервые, в отличие от последующей фазы, когда он вызывается впервые. Парсер не "знает", что Int имеет фиксированный тип или что оператор r = -r преобразует Int в другой Int . Магия вывода типов происходит на более поздней стадии компиляции.

Таким образом, парсер не знает, что r имеет фиксированный тип (Int), ни того, что r не изменяет значение после создания внутренней функции (так что ящик не нужен). Поэтому парсер генерирует код для ящика, содержащего объект с абстрактным типом, таким как Any, что требует динамического диспетчеризации типа для каждого экземпляра r . Это можно проверить, применив @code_warntype к вышеуказанной функции. И упаковка, и динамическая диспетчеризация типов могут привести к потерям производительности.

Если захваченные переменные используются в критически важном для производительности разделе кода, следующие советы помогут обеспечить эффективное их использование. Во-первых, если известно, что захваченная переменная не изменяет свой тип, это можно явно объявить с помощью аннотации типа (к переменной, а не к правой части):

function abmult2(r0::Int)
    r::Int = r0
    if r < 0
        r = -r
    end
    f = x -> x * r
    return f
end

Аннотация типа частично восстанавливает потерянную производительность из-за захвата, потому что парсер может сопоставить конкретный тип с объектом в ящике. Дальше, если захваченная переменная вообще не нуждается в упаковке (потому что она не будет перезаписываться после создания замыкания), это можно указать с помощью блоков let следующим образом.

function abmult3(r::Int)
    if r < 0
        r = -r
    end
    f = let r = r
            x -> x * r
    end
    return f
end

Блок let создаёт новую переменную r, область видимости которой ограничена только внутренней функцией. Второй приём восстанавливает полную производительность языка в присутствии захваченных переменных. Обратите внимание, что этот аспект компилятора быстро развивается, и в будущих версиях, вероятно, не потребуется такое количество аннотаций для достижения производительности. В то же время некоторые пользовательские пакеты, например FastClosures, автоматизируют вставку инструкций let как в abmult3.

© 2009–2019 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.0.4/manual/performance-tips/

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API