Spec-Zone.ru › Julia 1.7

Советы по производительности

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

Критически важный для производительности код должен находиться внутри функции

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

Использование функций важно не только для производительности: функции более переиспользуемы и тестируемы, и они проясняют, какие шаги выполняются и каковы их входные и выходные данные. Написание функций, а не просто скриптов — также рекомендация руководства по стилю 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.011539 seconds (9.08 k allocations: 373.386 KiB, 98.69% compilation time)
523.0007221951678

julia> @time sum_global()
  0.000091 seconds (3.49 k allocations: 70.156 KiB)
523.0007221951678

При первом вызове (@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.007551 seconds (3.98 k allocations: 200.548 KiB, 99.77% compilation time)
523.0007221951678

julia> @time sum_arg(x)
  0.000006 seconds (1 allocation: 16 bytes)
523.0007221951678

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

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

julia> time_sum(x)
  0.000002 seconds
523.0007221951678

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

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

Инструменты

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

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

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

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

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

julia> a = Real[]
Real[]

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

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

julia> a = Float64[]
Float64[]

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

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

Если вы не можете избежать контейнеров с абстрактными типами значений, иногда лучше параметризовать с Any, чтобы избежать проверки типа во время выполнения. Например, IdDict{Any, Any} работает лучше, чем IdDict{Type, Vector}

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

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

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

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

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

julia> struct MyAmbiguousType
           a
       end

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

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

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

julia> typeof(b)
MyAmbiguousType

julia> typeof(c)
MyAmbiguousType

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

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

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

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

julia> mutable struct MyStillAmbiguousType
           a::AbstractFloat
       end

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

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

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

julia> typeof(m)
MyType{Float64}

julia> typeof(t)
MyStillAmbiguousType

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

julia> typeof(t.a)
Float64

julia> t.a = 4.5f0
4.5f0

julia> typeof(t.a)
Float32

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

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float64

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

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

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

julia> typeof(m.a)
Float64

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float32

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

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

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

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

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

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

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

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

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

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

Например:

julia> c = MySimpleContainer(1:3);

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

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

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

julia> b = MyAmbiguousContainer(1:3);

julia> typeof(b)
MyAmbiguousContainer{Int64}

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

julia> typeof(b)
MyAmbiguousContainer{Int64}

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

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

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

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

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

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

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

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

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

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

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

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

Аннотировать значения, взятые из нетипизированных локаций

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

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

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

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

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

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

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

c = (b + 1.0f0)::Complex{T}

не снижает производительность (но и не помогает), так как компилятор может определить тип c в момент компиляции k.

Знайте, когда Julia избегает специализации

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

Это не приведет к специализации:

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

но это приведет:

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

Эти не приведут к специализации:

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

но это приведет:

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

Это не приведет к специализации:

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

но это приведет:

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

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

h_vararg(x::Vararg{Any, N}) where {N} = tuple(x...)

Обратите внимание, что @code_typed и аналогичные функции всегда будут показывать вам специализированный код, даже если Julia обычно не специализирует этот вызов метода. Вам нужно проверить внутренности метода, если вы хотите увидеть, генерируются ли специализации при изменении типов аргументов, т. е., если (@which f(...)).specializations содержит специализации для рассматриваемого аргумента.

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

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

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

using LinearAlgebra

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

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

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

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

Напишите "стабильные по типу" функции

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

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

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

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

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

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

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

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

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

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

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

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

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

julia> strange_twos(3)
3-element Vector{Int64}:
 2
 2
 2

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

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

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

julia> strange_twos(3)
3-element Vector{Int64}:
 2
 2
 2

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

julia> x = randn(10000);

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

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

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

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

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

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

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

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

с

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

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

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

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

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

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

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

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

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

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

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

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

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

julia> x = rand(10^6);

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

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

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

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

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

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

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

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

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

julia> x = rand(10^6);

julia> @time fcopy(x);
  0.003051 seconds (3 allocations: 7.629 MB)

julia> @time fview(x);
  0.001020 seconds (1 allocation: 16 bytes)

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

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

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

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

julia> using Random

julia> x = randn(1_000_000);

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

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

julia> xtmp = zeros(800_000);

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

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

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

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

Рассмотрите StaticArrays.jl для операций с небольшими векторами/матрицами фиксированного размера

Если ваше приложение использует много небольших (< 100 элементов) массивов фиксированных размеров (т. е. размер известен до выполнения), то вы можете рассмотреть использование пакета StaticArrays.jl. Этот пакет позволяет представлять такие массивы таким образом, что избегает необоснованных выделений памяти в куче и позволяет компилятору специализировать код для размера массива, например, путем полного разворачивания векторных операций (устранение циклов) и хранения элементов в регистрах процессора.

Например, если вы выполняете вычисления с двумерной геометрией, у вас может быть много вычислений с векторами из 2 компонентов. Используя тип SVector из StaticArrays.jl, вы можете использовать удобную векторную запись и операции, такие как norm(3v - w) над векторами v и w, одновременно позволяя компилятору разворачивать код до минимальных вычислений, эквивалентных @inbounds hypot(3v[1]-w[1], 3v[2]-w[2]).

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

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

println(file, "$a $b")

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

println(file, a, " ", b)

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

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

по сравнению с:

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

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

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

using Distributed

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

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

using Distributed

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

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

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

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

Настройки

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

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

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

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

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

Общий способ индексации AbstractArray с помощью 1:n небезопасен, если массив использует нестандартную индексацию, и может вызвать ошибку сегментации, если проверка границ отключена. Используйте 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's 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.Const(f)
  x::Float64
  y::UNION{FLOAT64, INT64}

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

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

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

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

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

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

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

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

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

    • Интерпретация: получение поля, которое имеет нелистовой тип. В этом случае тип x, скажем 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, которые совместно используются внутренними функциями и их внешней областью видимости, также извлекаются в размещённый в куче «ящик» (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.

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

Spec-Zone.ru

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