Документация Engee
Notebook

Представления (views) как способ повышения производительности кода

В данном скрипте рассматривается применение представлений - механизма, позволяющего обращаться к элементам массива, не создавая их копий. Будут затронуты темы:

  • отличие копии среза (slicing) от представления (view)
  • использование макросов @view и @views и их различия.

Для того чтобы проверить эффективность использования представлений - подключим библиотеки BenchmarkTools

In [ ]:
import Pkg; Pkg.add("BenchmarkTools")
   Resolving package versions...
  No Changes to `~/.project/Project.toml`
  No Changes to `~/.project/Manifest.toml`

Проблема копирования

Когда мы используем синтаксис b = a[1:5], то b становится копией первых пяти элементов a, а не "связывается по адресу" с элементами a.

In [ ]:
a = collect(1:10)
b = a[1:5]           # [1, 2, 3, 4, 5] 
println(pointer(a))
println(pointer(b))
b .= 0               # [0, 0, 0, 0, 0]
a'                   # как видим, матрица не поменялась, что и логично
Ptr{Int64} @0x00007fb4fa4125f0
Ptr{Int64} @0x00007fb548a22980
Out[0]:
1×10 adjoint(::Vector{Int64}) with eltype Int64:
 1  2  3  4  5  6  7  8  9  10

Чтобы изменить наш исходный вектор, мы вынуждены делать лишнее действие:

In [ ]:
a[1:5] .= b[1:5]
a'
Out[0]:
1×10 adjoint(::Vector{Int64}) with eltype Int64:
 0  0  0  0  0  6  7  8  9  10

Функция view

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

In [ ]:
a = collect(1:10000)
# '÷' не то же, что и '/' (÷ = div()) 
view_of_a = view(a,1:length(a)÷2) # end здесь не сработает
view_of_a .= 0
a
Out[0]:
10000-element Vector{Int64}:
     0
     0
     0
     0
     0
     0
     0
     0
     0
     0
     0
     0
     0
     ⋮
  9989
  9990
  9991
  9992
  9993
  9994
  9995
  9996
  9997
  9998
  9999
 10000
In [ ]:
pointer(view_of_a) == pointer(a)
Out[0]:
true

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

In [ ]:
println(@allocated (subarray_of_a = a[1:end÷2]))
println(@allocated (view_of_a = view(a,1:length(a)÷2)))
40112
112

@view

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

In [ ]:
a = repeat(1:10,inner=3)
b = @view a[end-3:end]
b .= 0
a'
Out[0]:
1×30 adjoint(::Vector{Int64}) with eltype Int64:
 1  1  1  2  2  2  3  3  3  4  4  4  5  …  7  7  7  8  8  8  9  9  0  0  0  0

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

a = repeat(1:10,inner=3)
a[end-3,3] .= 0

Ответом на это можно сформулировать так:

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

Пусть есть задача вывести и посчитать сумму тройки (triplet) элементов.

for i in 0:(length(a)÷3-1)
    println("sum of $(a[3i+1:3i+3]) -> $(sum(a[3i+1:3i+3]))")
end

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

In [ ]:
for i in 0:(length(a)÷3-1)
    triplet = a[3i+1:3i+3]
    println("sum of $triplet -> $(sum(triplet))")
end
sum of [1, 1, 1] -> 3
sum of [2, 2, 2] -> 6
sum of [3, 3, 3] -> 9
sum of [4, 4, 4] -> 12
sum of [5, 5, 5] -> 15
sum of [6, 6, 6] -> 18
sum of [7, 7, 7] -> 21
sum of [8, 8, 8] -> 24
sum of [9, 9, 0] -> 18
sum of [0, 0, 0] -> 0

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

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

In [ ]:
using BenchmarkTools

function tripletssum_subarray(v)
for i in 0:(length(v)÷3-1)
    triplet = v[3i+1:3i+3]
end
end

function tripletssum_view(v)
for i in 0:(length(v)÷3-1)
    triplet = @view v[3i+1:3i+3]
end
end

 
a = repeat(1:10000,inner=3)

println(@btime tripletssum_subarray(a))
println(@btime tripletssum_view(a))
  442.980 μs (10000 allocations: 781.25 KiB)
nothing
  11.152 μs (0 allocations: 0 bytes)
nothing

@views

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

In [ ]:
Pkg.add("LinearAlgebra")
using LinearAlgebra
@btime dot( a[1:end÷2], a[end÷2+1:end])
   Resolving package versions...
  No Changes to `~/.project/Project.toml`
  No Changes to `~/.project/Manifest.toml`
  51.050 μs (13 allocations: 234.64 KiB)
Out[0]:
312575002500

И, казалось бы, мы знаем, как можно улучшить этот код:

In [ ]:
try
# ОСНОВНОЙ КОД
#------------------------------------------------------
  @btime dot(@view a[1:end÷2], @view a[end÷2+1:end])  
#------------------------------------------------------
# ОБРАБОТКА ИСКЛЮЧЕНИЯ
catch e
	io = IOBuffer();
	showerror(io, e)
	error_msg = String(take!(io))
end
Out[0]:
"LoadError: ArgumentError: Invalid use of @view macro: argument must be a reference expression A[...].\nin expression starting at In[53]:4"

В ошибке говорится, что мы неправильно используем макрос @view.

Хотя вроде бы наше выражение a[1:end÷2] соответствует выражению A[...].

Проблема заключатеся в том, что мы неправильно использовали макрос.

Для того чтобы исправить эту ситуацию - поместим вектора, к которым хотим применить представление, в скобки:

In [ ]:
@btime dot(@view(a[1:end÷2]) ,@view(a[end÷2+1:end]))
  4.153 μs (11 allocations: 272 bytes)
Out[0]:
312575002500

Но чтобы для каждого среза (slicing) не писать @view мы можем использовать макрос @views

In [ ]:
@btime @views dot((a[1:end÷2]), (a[end÷2+1:end]))
  3.801 μs (11 allocations: 272 bytes)
Out[0]:
312575002500

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

In [ ]:
@views function tripletssum_views(v)
    for i in 0:(length(v)÷3-1)
        triplet = v[3i+1:3i+3]
    end
end
a = repeat(1:10000,inner=3)

println(@btime tripletssum_views(a))
  11.047 μs (0 allocations: 0 bytes)
nothing

В каких случаях применять представления?

Представления стоит применять там, где:

  • это повышает читабельность
  • это влияет на производительность
  • вы понимаете, в чём различие работы с копией и с оригиналом
In [ ]:
a = rand(1000)
println(@allocated sum(a))
println(@allocated sum(a[1:end]))
println(@allocated sum(copy(a[1:end])))
println(@allocated sum(@view a[1:end]))
16
8192
17152
2824
In [ ]:
import Pkg; Pkg.add(["OrdinaryDiffEq","Plots"])
using OrdinaryDiffEq
using Plots
gr()
function lotka(du, u, p, t) 
    du[1] = p[1] * u[1] - p[2] * u[1] * u[2]
    du[2] = p[4] * u[1] * u[2] - p[3] * u[2]  
end
α = 1; β = 0.01; γ = 1; δ = 0.02;
p = [α, β, γ, δ]
tspan = (0.0, 6.5)
u0 = [50; 50]
prob = ODEProblem(lotka, u0, tspan, p)
sol = solve(prob,saveat=0.001)
   Resolving package versions...
  No Changes to `~/.project/Project.toml`
  No Changes to `~/.project/Manifest.toml`
Out[0]:
retcode: Success
Interpolation: 1st order linear
t: 6501-element Vector{Float64}:
 0.0
 0.001
 0.002
 0.003
 0.004
 0.005
 0.006
 0.007
 0.008
 0.009
 0.01
 0.011
 0.012
 ⋮
 6.489
 6.49
 6.491
 6.492
 6.493
 6.494
 6.495
 6.496
 6.497
 6.498
 6.499
 6.5
u: 6501-element Vector{Vector{Float64}}:
 [50.0, 50.0]
 [50.02500624847936, 50.000012502345534]
 [50.05002498979869, 50.00005001769738]
 [50.07505621775588, 50.00011255855343]
 [50.1000999261168, 50.000200137444956]
 [50.125156108615315, 50.00031276693659]
 [50.1502247589533, 50.00045045962633]
 [50.17530587080062, 50.000613228145575]
 [50.20039943779515, 50.00080108515906]
 [50.22550545354273, 50.001014043364925]
 [50.25062391161723, 50.00125211549466]
 [50.27575480556051, 50.001515314313124]
 [50.30089812888241, 50.00180365261858]
 ⋮
 [50.05479160200564, 49.98705194268289]
 [50.079830802201556, 49.987119920075195]
 [50.104882567590614, 49.987212877939285]
 [50.12994689359235, 49.987330827635944]
 [50.15502377561346, 49.98747378054721]
 [50.18011320904711, 49.98764174807645]
 [50.20521518927415, 49.98783474164827]
 [50.23032971166218, 49.988052772708585]
 [50.2554567715658, 49.98829585272456]
 [50.28059636432637, 49.98856399318467]
 [50.305748485272886, 49.98885720559867]
 [50.330913129720685, 49.98917550149757]

При отрисовке графиков будут использоваться зависимости переменных от времени $x(t)$ и $y(t)$.

In [ ]:
plot(sol)
Out[0]:

Но если мы захотим отрисовать зависимость $y(x)$, то мы вынуждены будем использовать срезы sol[1,:] и sol[2,:]. Что как раз напоминает нам нашу вышеупомянутую проблему.

In [ ]:
@btime plot(sol[1,:],sol[2,:])
  582.602 μs (494 allocations: 451.46 KiB)
Out[0]:

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

In [ ]:
@btime @views plot(sol[1,:],sol[2,:])
  488.246 μs (492 allocations: 102.46 KiB)
Out[0]:

Выводы

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