Spec-Zone.ru › Julia 1.3

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

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

Избегайте глобальных переменных

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

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

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

const DEFAULT_VAL = 0

Использование неконстантных глобальных переменных может быть оптимизировано путем аннотирования их типов в месте использования:

global x = rand(1000)

function loop_over_global()
    s = 0.0
    for i in x::Vector{Float64}
        s += i
    end
    return s
end

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

Примечание

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

В следующем сеансе REPL:

julia> x = 1.0

эквивалентно:

julia> global x = 1.0

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

Измеряйте производительность с помощью @time и обращайте внимание на выделение памяти

Полезным инструментом для измерения производительности является макрос @time. Здесь мы повторяем пример с глобальной переменной выше, но на этот раз с удаленным аннотированием типа:

julia> x = rand(1000);

julia> function sum_global()
           s = 0.0
           for i in x
               s += i
           end
           return s
       end;

julia> @time sum_global()
  0.017705 seconds (15.28 k allocations: 694.484 KiB)
496.84883432553846

julia> @time sum_global()
  0.000140 seconds (3.49 k allocations: 70.313 KiB)
496.84883432553846

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

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

Если вместо этого мы передаем x в качестве аргумента функции, она больше не выделяет память (выделение, указанное ниже, связано с запуском макроса @time в глобальной области видимости) и значительно быстрее после первого вызова:

julia> x = rand(1000);

julia> function sum_arg(x)
           s = 0.0
           for i in x
               s += i
           end
           return s
       end;

julia> @time sum_arg(x)
  0.007701 seconds (821 allocations: 43.059 KiB)
496.84883432553846

julia> @time sum_arg(x)
  0.000006 seconds (5 allocations: 176 bytes)
496.84883432553846

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

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

julia> time_sum(x)
  0.000001 seconds
496.84883432553846

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

Примечание

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

Инструменты

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

julia> struct MyAmbiguousType
           a
       end

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

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

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

julia> typeof(b)
MyAmbiguousType

julia> typeof(c)
MyAmbiguousType

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

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

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

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

julia> mutable struct MyStillAmbiguousType
           a::AbstractFloat
       end

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

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

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

julia> typeof(m)
MyType{Float64}

julia> typeof(t)
MyStillAmbiguousType

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

julia> typeof(t.a)
Float64

julia> t.a = 4.5f0
4.5f0

julia> typeof(t.a)
Float32

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

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float64

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

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

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

julia> typeof(m.a)
Float64

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float32

Для всех практических целей такие объекты ведут себя идентично объектам MyStillAmbiguousType.

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

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

используя

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

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

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

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

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

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

Например:

julia> c = MySimpleContainer(1:3);

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

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

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

julia> b = MyAmbiguousContainer(1:3);

julia> typeof(b)
MyAmbiguousContainer{Int64}

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

julia> typeof(b)
MyAmbiguousContainer{Int64}

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

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

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

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

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

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

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

END_OF_DOCUMENT_MARKER
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.

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

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

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

using LinearAlgebra

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

julia> x = randn(10000);

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

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

Обратите внимание, что copy_cols намного быстрее, чем copy_rows. Это ожидаемо, поскольку copy_cols учитывает расположение в памяти массива по столбцам и заполняет его по одному столбцу за раз. Кроме того, 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) (см. также offset-arrays).

Примечание

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

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

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

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

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

timeit(1000, 1000)

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

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

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

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

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

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

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

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

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

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

    println(nu)
end

main()

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

$ julia wave.jl;
  1.207814709 seconds
4.443986180758249

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

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

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

Аннотация @fastmath переупорядочивает выражения с плавающей запятой, например, изменяя порядок вычисления или предполагая, что определенные специальные случаи (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 является то, что неконкретные типы отображаются красным цветом; в приведенном выше примере такой вывод показан заглавными буквами.

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

END_OF_DOCUMENT_MARKER

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

function abmult3(r::Int)
    if r < 0
        r = -r
    end
    f = let r = r
            x -> x * r
    end
    return f
end
блоков следующим образом.

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.3.1/manual/performance-tips/

Spec-Zone.ru

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