Spec-Zone.ru › Julia 1.5

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

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

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[]
Float64[]

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

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

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

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

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

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

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

julia> struct MyAmbiguousType
           a
       end

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

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

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

julia> typeof(b)
MyAmbiguousType

julia> typeof(c)
MyAmbiguousType

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

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

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

Это лучший выбор, чем

julia> mutable struct MyStillAmbiguousType
           a::AbstractFloat
       end

потому что первая версия определяет тип a по типу обертывающего объекта. Например:

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

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

julia> typeof(m)
MyType{Float64}

julia> typeof(t)
MyStillAmbiguousType

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

julia> typeof(t.a)
Float64

julia> t.a = 4.5f0
4.5f0

julia> typeof(t.a)
Float32

В отличие от этого, после того, как m создан, тип m.a не может измениться:

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float64

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

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

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

julia> typeof(m.a)
Float64

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float32

Практически эти объекты ведут себя так же, как и объекты MyStillAmbiguousType.

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

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

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

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

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

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

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

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

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

Например:

julia> c = MySimpleContainer(1:3);

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

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

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

julia> b = MyAmbiguousContainer(1:3);

julia> typeof(b)
MyAmbiguousContainer{Int64}

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

julia> typeof(b)
MyAmbiguousContainer{Int64}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Будьте внимательны к тому, когда Julia избегает специализации

В качестве эвристики, Julia избегает автоматической специализации по параметрам типа аргументов в трех конкретных случаях: Type, Function, и Vararg. Julia всегда будет специализироваться, когда аргумент используется внутри метода, но не если аргумент просто передается в другую функцию. Это обычно не влияет на производительность во время выполнения и улучшает производительность компилятора. Если вы обнаружите, что это оказывает влияние на производительность во время выполнения в вашем случае, вы можете инициировать специализацию, добавив параметр типа в объявление метода. Вот несколько примеров:

Это не будет специализироваться:

function f_type(t)  # or t::Type
    x = ones(t, 10)
    return sum(map(sin, x))
end

но это будет:

function g_type(t::Type{T}) where T
    x = ones(T, 10)
    return sum(map(sin, x))
end

Эти не будут специализироваться:

f_func(f, num) = ntuple(f, div(num, 2))
g_func(g::Function, num) = ntuple(g, div(num, 2))

но это будет:

h_func(h::H, num) where {H} = ntuple(h, div(num, 2))

Это не будет специализироваться:

f_vararg(x::Int...) = tuple(x...)

но это будет:

g_vararg(x::Vararg{Int, N}) where {N} = tuple(x...)

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

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 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 x 1 входной вектор:

julia> x = randn(10000);

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

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

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

Предварительное выделение выходов

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

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

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

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

со

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

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

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

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

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

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

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

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

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

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

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

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

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

julia> x = rand(10^6);

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

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

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

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

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

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

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

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

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

julia> x = rand(10^6);

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

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

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

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

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

Копирование данных с нерегулярным доступом в непрерывный массив перед выполнением операций с ним может привести к значительному ускорению, как в примере ниже. Здесь матрица и вектор обращаются к 800 000 своих случайным образом перемешанных индексов перед умножением. Копирование представлений в обычные массивы ускоряет умножение даже с затратами на операцию копирования.

julia> using Random

julia> x = randn(1_000_000);

julia> inds = shuffle(1:1_000_000)[1:800000];

julia> A = randn(50, 1_000_000);

julia> xtmp = zeros(800_000);

julia> Atmp = zeros(50, 800_000);

julia> @time sum(view(A, :, inds) * view(x, inds))
  0.412156 seconds (14 allocations: 960 bytes)
-4256.759568345458

julia> @time begin
           copyto!(xtmp, view(x, inds))
           copyto!(Atmp, view(A, :, inds))
           sum(Atmp * xtmp)
       end
  0.285923 seconds (14 allocations: 960 bytes)
-4256.759568345134

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

Избегайте интерполяции строк для ввода-вывода

При записи данных в файл (или другое устройство ввода-вывода) формирование дополнительных промежуточных строк приводит к накладным расходам. Вместо:

println(file, "$a $b")

используйте:

println(file, a, " ", b)

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

println(file, "$(f(a))$(f(b))")

против:

println(file, f(a), f(b))

Оптимизируйте сетевой ввод-вывод во время параллельного выполнения

При выполнении удаленной функции параллельно:

using Distributed

responses = Vector{Any}(undef, nworkers())
@sync begin
    for (idx, pid) in enumerate(workers())
        @async responses[idx] = remotecall_fetch(foo, pid, args...)
    end
end

быстрее, чем:

using Distributed

refs = Vector{Any}(undef, nworkers())
for (idx, pid) in enumerate(workers())
    refs[idx] = @spawnat pid foo(args...)
end
responses = [fetch(r) for r in refs]

В первом случае происходит одно сетевое обращение к каждому рабочему процессу, а во втором — два сетевых вызова — сначала от @spawnat, а второй из-за fetch (или даже wait). fetch/wait также выполняется последовательно, что приводит к более низкой общей производительности.

Исправить предупреждения о устаревании

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

Настройки

Вот некоторые незначительные моменты, которые могут помочь в плотных внутренних циклах.

  • Избегайте ненужных массивов. Например, вместо sum([x,y,z]) используйте x+y+z.
  • Используйте abs2(z) вместо abs(z)^2 для комплексных z. В общем, старайтесь переписать код, чтобы использовать abs2 вместо abs для комплексных аргументов.
  • Используйте div(x,y) для усечения целочисленного деления вместо trunc(x/y), fld(x,y) вместо floor(x/y) и cld(x,y) вместо ceil(x/y).

Аннотации производительности

Иногда вы можете включить лучшую оптимизацию, пообещав определённые свойства программы.

  • Используйте @inbounds для исключения проверки границ массива внутри выражений. Убедитесь в этом до начала. Если индексы выходят за пределы, вы можете столкнуться с ошибками или скрытыми повреждениями.
  • Используйте @fastmath для разрешения оптимизаций с плавающей запятой, которые верны для действительных чисел, но приводят к различиям для чисел IEEE. Будьте осторожны при этом, так как это может изменить числовые результаты. Это соответствует параметру -ffast-math clang.
  • Запишите @simd перед циклами for чтобы гарантировать, что итерации независимы и могут быть переупорядочены. Обратите внимание, что в многих случаях Julia может автоматически векторизовать код без макроса @simd; он полезен только в тех случаях, когда такое преобразование было бы иначе незаконным, включая случаи, такие как разрешение повторной ассоциативности с плавающей запятой и игнорирование зависимых обращений к памяти (@simd ivdep). Снова будьте очень осторожны при утверждении @simd, так как неверная аннотация цикла с зависимыми итерациями может привести к непредвиденным результатам. В частности, обратите внимание, что setindex! для некоторых AbstractArray подтипов по своей сути зависит от порядка итераций. Эта функция экспериментальная и может измениться или исчезнуть в будущих версиях Julia.

Общий прием использования 1:n для индексирования в AbstractArray небезопасен, если массив использует нестандартный индексирование и может вызвать ошибку сегментации, если проверка границ отключена. Используйте LinearIndices(x) или eachindex(x) вместо этого (см. также Массивы со своими индексами).

Хотя @simd необходимо помещать непосредственно перед внутренним for циклом, как @inbounds, так и @fastmath могут применяться к одному выражению или ко всем выражениям, которые появляются в вложенных блоках кода, например, используя @inbounds begin или @inbounds for ....

Вот пример с обеими метками @inbounds и @simd (мы здесь используем @noinline чтобы предотвратить попытки оптимизатора быть слишком умным и обойти наш эталонный тест):

@noinline function inner(x, y)
    s = zero(eltype(x))
    for i=eachindex(x)
        @inbounds s += x[i]*y[i]
    end
    return s
end

@noinline function innersimd(x, y)
    s = zero(eltype(x))
    @simd for i = eachindex(x)
        @inbounds s += x[i] * y[i]
    end
    return s
end

function timeit(n, reps)
    x = rand(Float32, n)
    y = rand(Float32, n)
    s = zero(Float64)
    time = @elapsed for j in 1:reps
        s += inner(x, y)
    end
    println("GFlop/sec        = ", 2n*reps / time*1E-9)
    time = @elapsed for j in 1:reps
        s += innersimd(x, y)
    end
    println("GFlop/sec (SIMD) = ", 2n*reps / time*1E-9)
end

timeit(1000, 1000)

На компьютере с процессором Intel Core i5 2.4 ГГц это дает:

GFlop/sec        = 1.9467069505224963
GFlop/sec (SIMD) = 17.578554163920018

(GFlop/sec измеряет производительность, и большие числа лучше.)

Вот пример со всеми тремя видами меток. Эта программа сначала вычисляет конечную разницу одномерного массива, а затем оценивает L2-норму результата:

function init!(u::Vector)
    n = length(u)
    dx = 1.0 / (n-1)
    @fastmath @inbounds @simd for i in 1:n #by asserting that `u` is a `Vector` we can assume it has 1-based indexing
        u[i] = sin(2pi*dx*i)
    end
end

function deriv!(u::Vector, du)
    n = length(u)
    dx = 1.0 / (n-1)
    @fastmath @inbounds du[1] = (u[2] - u[1]) / dx
    @fastmath @inbounds @simd for i in 2:n-1
        du[i] = (u[i+1] - u[i-1]) / (2*dx)
    end
    @fastmath @inbounds du[n] = (u[n] - u[n-1]) / dx
end

function mynorm(u::Vector)
    n = length(u)
    T = eltype(u)
    s = zero(T)
    @fastmath @inbounds @simd for i in 1:n
        s += u[i]^2
    end
    @fastmath @inbounds return sqrt(s)
end

function main()
    n = 2000
    u = Vector{Float64}(undef, n)
    init!(u)
    du = similar(u)

    deriv!(u, du)
    nu = mynorm(du)

    @time for i in 1:10^6
        deriv!(u, du)
        nu = mynorm(du)
    end

    println(nu)
end

main()

На компьютере с процессором Intel Core i7 2.7 ГГц это дает:

$ julia wave.jl;
  1.207814709 seconds
4.443986180758249

$ julia --math-mode=ieee wave.jl;
  4.487083643 seconds
4.443986180758249

Здесь параметр --math-mode=ieee отключает макрос @fastmath, чтобы мы могли сравнить результаты.

В этом случае ускорение за счёт @fastmath составляет примерно в 3,7 раза. Это необычно большое значение — обычно ускорение будет меньше. (В этом конкретном примере рабочая область эталонного теста достаточно мала, чтобы поместиться в кэш L1 процессора, поэтому задержка доступа к памяти не играет роли, и время вычисления определяется использованием процессора. В многих реальных программах это не так.) Также в этом случае эта оптимизация не изменяет результат — в общем случае результат будет немного отличаться. В некоторых случаях, особенно для алгоритмов с числовой нестабильностью, результат может сильно отличаться.

Аннотация @fastmath переупорядочивает выражения с плавающей запятой, например, изменяя порядок вычислений или предполагая, что определённые специальные случаи (inf, nan) не могут возникнуть. В этом случае (и на этом конкретном компьютере) главное отличие в том, что выражение 1 / (2*dx) в функции deriv вынесено за цикл (т. е. вычислено вне цикла), как если бы кто-то написал idx = 1 / (2*dx). В цикле выражение ... / (2*dx) тогда становится ... * idx, что намного быстрее для оценки. Конечно, как применяемая компилятором оптимизация, так и полученное ускорение сильно зависят от оборудования. Вы можете изучить изменения сгенерированного кода, используя функцию Julia code_native.

Обратите внимание, что @fastmath также предполагает, что NaN не будут возникать во время вычислений, что может привести к неожиданному поведению:

julia> f(x) = isnan(x);

julia> f(NaN)
true

julia> f_fast(x) = @fastmath isnan(x);

julia> f_fast(NaN)
false

Обращаться с поднормальными числами как с нулями

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

Ниже приведен пример, где поднормальные числа заметно влияют на производительность на некоторых аппаратных платформах:

function timestep(b::Vector{T}, a::Vector{T}, Δt::T) where T
    @assert length(a)==length(b)
    n = length(b)
    b[1] = 1                            # Boundary condition
    for i=2:n-1
        b[i] = a[i] + (a[i-1] - T(2)*a[i] + a[i+1]) * Δt
    end
    b[n] = 0                            # Boundary condition
end

function heatflow(a::Vector{T}, nstep::Integer) where T
    b = similar(a)
    for t=1:div(nstep,2)                # Assume nstep is even
        timestep(b,a,T(0.1))
        timestep(a,b,T(0.1))
    end
end

heatflow(zeros(Float32,10),2)           # Force compilation
for trial=1:6
    a = zeros(Float32,1000)
    set_zero_subnormals(iseven(trial))  # Odd trials use strict IEEE arithmetic
    @time heatflow(a,1000)
end

Это дает вывод, похожий на

  0.002202 seconds (1 allocation: 4.063 KiB)
  0.001502 seconds (1 allocation: 4.063 KiB)
  0.002139 seconds (1 allocation: 4.063 KiB)
  0.001454 seconds (1 allocation: 4.063 KiB)
  0.002115 seconds (1 allocation: 4.063 KiB)
  0.001455 seconds (1 allocation: 4.063 KiB)

Обратите внимание, как каждая четная итерация значительно быстрее.

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

Обращение с поднормальными числами как с нулями следует использовать с осторожностью, так как это нарушает некоторые тождества, например, x-y == 0 подразумевает x == y.

julia> x = 3f-38; y = 2f-38;

julia> set_zero_subnormals(true); (x - y, x == y)
(0.0f0, false)

julia> set_zero_subnormals(false); (x - y, x == y)
(1.0000001f-38, false)

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

a = rand(Float32,1000) * 1.f-9

@code_warntype

Макрос @code_warntype (или его функциональный вариант code_warntype) иногда может быть полезен для диагностики проблем, связанных с типами. Вот пример:

julia> @noinline pos(x) = x < 0 ? 0 : x;

julia> function f(x)
           y = pos(x)
           return sin(y*x + 1)
       end;

julia> @code_warntype f(3.2)
Variables
  #self#::Core.Compiler.Const(f, false)
  x::Float64
  y::UNION{FLOAT64, INT64}

Body::Float64
1 ─      (y = Main.pos(x))
│   %2 = (y * x)::Float64
│   %3 = (%2 + 1)::Float64
│   %4 = Main.sin(%3)::Float64
└──      return %4

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

Вверху показан выведенный тип возвращаемого значения функции как Body::Float64. Следующие строки представляют тело f в форме SSA IR Юлии. Нумерованные блоки являются метками и представляют цели для переходов (через 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, которые используются как внутренними функциями, так и содержащей их областью видимости, также выносятся в «ящик» (heap-allocated box), доступный как внутренним, так и внешним функциям, поскольку язык определяет, что r во внутренней области видимости должна быть идентичной r во внешней области видимости даже после того, как внешняя область видимости (или другая внутренняя функция) модифицирует r.

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

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

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

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

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

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

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

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

При проверке, равен ли значение какому-то одиночному объекту, с точки зрения производительности лучше использовать проверку идентичности (===) вместо проверки на равенство (==). Та же рекомендация относится к использованию !== вместо !=. Такие проверки часто встречаются, например, при реализации протокола итерации и проверке, возвращает ли nothing значение от iterate.

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

Spec-Zone.ru

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