Spec-Zone.ru › Julia 1.2

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 учитывает структуру памяти массива Matrix по столбцам и заполняет его столбец за столбцом. Кроме того, copy_col_row значительно быстрее, чем copy_row_col, потому что он следует нашему правилу большого пальца, что первый элемент, который появляется в выражении среза, должен быть связан с самым внутренним циклом.

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

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

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

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

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

с

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

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

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

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

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

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

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

Больше точек: Объединение векторизованных операций

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

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

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

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

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

julia> x = rand(10^6);

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

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

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

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

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

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

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

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

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

julia> x = rand(10^6);

julia> @time fcopy(x);
  0.003051 seconds (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) не будет иметь нестабильный тип в своём выходе, даже если некоторые промежуточные вычисления нестабильны по типу.

Как вы будете использовать эту информацию, зависит от вас. Очевидно, лучше всего исправить 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.

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

Spec-Zone.ru

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