Эти пять советов значительно ускорят ваши циклы в Джулии!

Введение

Когда большинство людей знакомятся с языком Julia, они, вероятно, сразу же осознают скорость, которую предлагает этот язык. Хотя Джулия часто пишется так же, как любой интерпретируемый язык, язык компилируется. Более того, язык компилируется JIT, поэтому скорость, очевидно, является большим потрясением, когда речь заходит о сравнении Julia с более традиционным декларативным языком, обычно используемым в научной сфере. При этом те, кто переходят на Julia из таких языков, скорее всего, ищут скорости.

В программном обеспечении одной из наиболее распространенных операций, замедляющих код, является зацикливание. Это верно как на самом низком уровне в ассемблере с подпрограммами и .loops, так и на самом высоком уровне в таких языках, как Julia и Python. Если вы хотите, чтобы ваш код делал много вещей быстро, то более чем вероятно, что это улучшение скорости упадет на качество ваших циклов. При этом есть несколько очень простых способов получить максимальную отдачу от Джулии, уделив немного больше внимания этим петлям. Хотя язык Julia, естественно, является более быстрым языком, чем тот, который полезен среднему мигрирующему ученому, код Julia действительно мощный, когда он написан хорошо, поэтому давайте кратко рассмотрим, как мы можем улучшить наши циклы с помощью пяти различных важных вещей!

1: Когда аннотировать инварианты цикла

Одним из способов значительного ускорения циклов в Julia является использование аннотаций. Аннотирование значений позволяет компилятору Julia очень легко узнать, для какого типа значений он выделяет память и диспетчеризируется. В общем, кое-что, что нужно помнить о Джулии, это то, что компиляция в языке вращается вокруг набора текста. Julia — невероятно явный язык типов, особенно по сравнению с другими языками с динамической типизацией, такими как Python или R. Явные типы — это то, что мне лично нравится в этом языке, поскольку я твердо убежден, что независимо от того, насколько сильно вы пытаетесь абстрагировать программиста из типов, они все еще важны! При этом гораздо проще просто дать программистам инструменты для работы с типами, и аннотации — отличный тому пример.

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

using BenchmarkTools
array1 = randn(5000000)
array2 = randn(5000000)

Сравнительный анализ случайного цикла с этими значениями заканчивается временем расчета около 1,5 с.

@benchmark for (x, y) in zip(array1, array2)
    h = x + y
    z = x * y + 1 - y 
end

Как ни странно, эталонный макрос, кажется, ломается при использовании в циклах с аннотированными типами:

@benchmark for (x::Float64, y::Float64) in zip(array1, array2)
    h = x + y
    z = x * y + 1 - y 
end
LoadError: MethodError: Cannot `convert` an object of type Expr to an object of type Symbol
Closest candidates are:
  convert(::Type{T}, ::T) where T at /opt/julia-1.6.3/julia-1.6.3/share/julia/base/essentials.jl:218
  Symbol(::Any...) at /opt/julia-1.6.3/julia-1.6.3/share/julia/base/strings/basic.jl:229

Интересно, что в таком случае для этого конкретного примера я буду использовать макрос времени.

@time for (x::Float64, y::Float64) in zip(array1, array2)
    h = x + y
    z = x * y + 1 - y 
end
2.629487 seconds (35.09 M allocations: 996.940 MiB, 5.12% gc time, 1.79% compilation time)

Сравнивая это с не очень аннотированной версией, мы видим довольно значительное падение производительности, даже на этом относительно спокойном примере.

@time for (x, y) in zip(array1, array2)
    h = x + y
    z = x * y + 1 - y 
end
1.671793 seconds (55.00 M allocations: 1.267 GiB, 7.26% gc time, 0.25% compilation time)

В большинстве ситуаций имеет смысл аннотировать вещи в Джулии. Однако это один из немногих случаев, когда это, вероятно, не так хорошо, просто из-за значительного снижения производительности. Конечно, могут быть некоторые ситуации, когда аннотирование этих инвариантов может значительно повысить производительность. Один из тестов, который я хотел попробовать, заключается в том, будет ли это хорошо применяться к вектору типа Vector{Any}.

array1 = Vector{Any}(randn(5000000))
array2 = Vector{Any}(randn(5000000))

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

@time for (x, y) in zip(array1, array2)
    h = x + y
    z = x * y + 1 - y 
end
3.722158 seconds (65.01 M allocations: 1.565 GiB, 28.10% gc time, 0.83% compilation time)
@time for (x::Float64, y::Float64) in zip(array1, array2)
    h = x + y
    z = x * y + 1 - y 
end
3.532182 seconds (45.00 M allocations: 1.267 GiB, 4.84% gc time)

2: продолжить/перерыв

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



3: Понимание

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

@benchmark y = [i * 5 for i in array1]

Сравнивая это с тем, что было бы в обычном цикле for, мы видим, что разница на самом деле очень существенна.

new = []
@benchmark for x in array1
    push!(new, x * 5)
end

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



4: Аннотируйте ВСЕ

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

function compparser(s::String)
    tagpos::Vector{UnitRange{Int64}} = [f[1]:e[1] for (f, e) in zip(findall("<", s), findall(">", s))]
    comps = Vector{Servable}()
    for tag in tagpos
       if contains(s[tag], "/") || ~(contains(s[tag], " id="))
            continue
        end
        tagr::UnitRange = findnext(" ", s, tag[1])
        nametag = s[minimum(tag) + 1:maximum(tagr) - 1]
        tagtext::String = ""
        try
            textr::UnitRange = maximum(tag) + 1:minimum(findnext("</$nametag", s, tag[1])[1]) - 1
            tagtext = s[textr]
        catch
            tagtext = ""
        end
        propvec = split(s[maximum(tagr) + 1:maximum(tag) - 1], " ")
        properties = Dict()
        for segment in propvec
            ppair = split(segment, "=")
            if length(ppair) != 2
                continue
            end
            push!(properties, string(ppair[1]) => string(ppair[2]))
        end
        name::String = properties["id"]
        delete!(properties, "id")
        properties["text"] = tagtext
        push!(comps, Component(name, string(name), properties))
    end
    return(comps)::Vector{Servable}
end

Запуск теста для этого показывает довольно хорошую производительность:

Однако удаление аннотаций немного замедляет этот код:

@benchmark x = compparsernoa(compdata)

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



5. Меньше используйте UnitRanges

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

  • Производительность. Ранее мы коснулись инвариантов цикла, того, как они аннотируются и как они работают наиболее эффективно, когда аннотируются типы значений. Если вместо этого вы установите для инвариантов цикла тип Int64, потому что вы выбрали цикл по диапазону, то вы создаете медлительность, потому что теперь Джулия понятия не имеет, какого типа значение, над которым мы работаем, и вместо этого индекс просто аннотируется.
  • Ошибки. Использование диапазонов вместо векторов, особенно в случае, когда вы не знаете размер предоставленного массива, может привести к серьезным ошибкам. Никому не нравятся сложные циклы for, которые везде используют индексы со случайным сложением и вычитанием и крайне подвержены ошибкам IndexError. При этом отказ от использования диапазонов — отличный способ избежать этих ошибок, потому что в этом случае фактически не происходит индексация.

Отличный способ избежать зацикливания диапазонов почти в любой ситуации — просто изменить кортеж спецификации итерации и использовать метод enumerate, например:

for (index, x) in enumerate(array1)
    println(index => x)
end

Заключение

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