部分配列
Julia の SubArray
型は親の AbstractArray
に対する "ビュー" として振る舞うコンテナです。このページには SubArray
の設計指針と実装に関するドキュメントを示します。
主な設計目標の一つは、IndexLinear
型の配列と IndexCartesian
型の配列の両方に対して高い性能を保証することです。さらに IndexLinear
配列のビューは可能なかぎり IndexLinear
であるべきです。
添え字の置換
三次元配列の二次元スライスを作ることを考えます:
julia> A = rand(2,3,4);
julia> S1 = view(A, :, 1, 2:3)
2×2 view(::Array{Float64,3}, :, 1, 2:3) with eltype Float64:
0.200586 0.066423
0.298614 0.956753
julia> S2 = view(A, 1, :, 2:3)
3×2 view(::Array{Float64,3}, 1, :, 2:3) with eltype Float64:
0.200586 0.066423
0.246837 0.646691
0.648882 0.276021
view
が "シングルトン次元" (Int
によって指定される、サイズが 1 の次元) を落とすので、S1
と S2
は両方とも二次元の SubArray
となります。使うときは S1[i,j]
, S2[i,j]
のようにします。親の配列 A
から値を取り出すときは、S1[i,j]
を A[i,1,(2:3)[j]]
と置き換え、S[i,j]
を A[1,i,(2:3)[j]]
と置き換えるのが自然なアプローチです。
SubArray
の設計が持つ大きな特徴は、添え字の入れ替えにランタイムのオーバーヘッドが存在しないことです。
SubArray
の設計
型パラメータとフィールド
採用された戦略を何よりもまず型の定義によって表されます:
struct SubArray{T,N,P,I,L} <: AbstractArray{T,N}
parent::P
indices::I
offset1::Int # L==true のときにだけ、線形添え字アクセスとポインタで使われる。
stride1::Int # 線形添え字にだけ使われる。
...
end
SubArray
は五つの型パラメータを持ちます。最初の二つは要素型と次元であり、通常の配列と同じです。次の三つ目のパラメータは親の AbstractArray
の型です。最も頻繁に使われるのが四つ目のパラメータであり、これは各次元の添え字の型からなる Tuple
です。最後のパラメータ L
はディスパッチで使うためだけに存在し、添え字の型が効率的な線形添え字アクセスをサポートするかどうかを表します (後述)。
上記の例で A
は Array{Float64, 3}
型の値であり、S1
の型を確認すれば SubArray{Float64, 2, Array{Float64,3}, Tuple{Base.Slice{Base.OneTo{Int64}},Int64,UnitRange{Int64}}, false}
となります。S1
を作るときに使われた添え字の型が格納されたタプルのパラメータに特に注目してください。例えば次のようになります:
julia> S1.indices
(Base.Slice(Base.OneTo(2)), 1, 2:3)
これらの値を格納することで添え字の置き換えが可能になり、型をパラメータとしてエンコードすることで効率的なアルゴリズムへのディスパッチが可能になります。
添え字の変換
添え字の変換を行うときは、SubArray
の部分具象型それぞれに対して異なる計算が必要です。例えば S1
に対する添え字 (i,j)
は親配列の一番目と三番目の次元の添え字として使う必要があり、S2
に対する添え字は二番目の三番目の添え字として使う必要があります。添え字アクセスの最も簡単なアプローチは、型の解析を実行時に行うというものです:
parentindices = Vector{Any}()
for thisindex in S.indices
...
if isa(thisindex, Int)
# この次元では入力の添え字を使わない。
push!(parentindices, thisindex)
elseif isa(thisindex, AbstractVector)
# 入力の添え字を使う。
push!(parentindices, thisindex[inputindex[j]])
j += 1
elseif isa(thisindex, AbstractMatrix)
# 入力の添え字を使う。
push!(parentindices, thisindex[inputindex[j], inputindex[j+1]])
j += 2
elseif ...
end
S.parent[parentindices...]
残念ながら、このアプローチは性能が絶望的です。あらゆる要素へのアクセスがメモリをアロケートし、ほとんど型が付かないコードを大量に実行しなけばなりません。
これより優れたアプローチは、保存された添え字の型をそれぞれ処理する特定のメソッドにディスパッチするというものです。reindex
関数がまさにこれを行います: この関数は保存された最初の添え字の型でディスパッチを行い、入力の添え字を適切な個数だけ使い、残りの添え字に対して再帰的に同じ処理を行います。S1
の場合であれば、次のように展開されます1:
Base.reindex(S1.indices, (i, j)) == (i, S1.indices[2], S1.indices[3][j])
ここで i
と j
は添え字を表す任意の型の値です (ただし後述するように、CartesianIndex
および CartesianIndex
の配列であってはいけません)。
以上が SubArray
のコアです: 添え字アクセスメソッドが reindex
を使って添え字の変換を行います。ただし場合よっては、間接的なアクセスを使わずにさらに高速化できる場合があります。
線形添え字アクセス
配列全体が適当なオフセットから始まって任意の連続する二つの要素を分ける単一の歩長を持つなら、線形添え字アクセスを効率的に実装できます。この条件が成り立つときは歩長を事前に計算することで線形添え字アクセスを加算と乗算を使った添え字アクセスに変換できるためです。特に reindex
の呼び出しと (より重要なこととして) 時間のかかる格子添え字の計算を完全に省略できます。
SubArray
型に対しては、効率的な線形添え字アクセスが利用可能かどうかは添え字の型だけで決定し、親の配列のサイズのような他の値には影響を受けません。添え字の集合が効率的な線形添え字アクセスをサポートするかどうかは内部の Base.viewindexing
関数で取得できます:
julia> Base.viewindexing(S1.indices)
IndexCartesian()
julia> Base.viewindexing(S2.indices)
IndexLinear()
この真偽値は SubArray
の構築時に計算され、効率的な線形添え字アクセスのサポートを表す型パラメータ L
に保存されます。これは間に関数を挟むことない SubArray{T,N,A,I,true}
への直接的なディスパッチを定義できることを意味します (ただし厳密に言えば必要ではありません)。
この計算は実行時の値に依存しないので、歩長がたまたま同じになるケースを見落とす可能性があります:
julia> A = reshape(1:4*2, 4, 2)
4×2 reshape(::UnitRange{Int64}, 4, 2) with eltype Int64:
1 5
2 6
3 7
4 8
julia> diff(A[2:2:4,:][:])
3-element Array{Int64,1}:
2
2
2
view(A, 2:2:4, :)
で構築されるビューは一様な歩長を持つので、線形添え字アクセスを効率的に行うことができます。しかし、それが可能かどうかは配列のサイズに依存します。例えば、もし一番目の次元のサイズが奇数なら
julia> A = reshape(1:5*2, 5, 2)
5×2 reshape(::UnitRange{Int64}, 5, 2) with eltype Int64:
1 6
2 7
3 8
4 9
5 10
julia> diff(A[2:2:4,:][:])
3-element Array{Int64,1}:
2
3
2
となって A[2:2:4,:]
は一様な歩長を持たず、効率的な線形添え字アクセスは保証できません。線形添え字アクセスを行うかどうかの判断は SubArray
のパラメータにエンコードされた型だけを使って行う必要があることから、S = view(A, 2:2:4, :)
には効率的な線形添え字アクセスを実装できません。
さらに詳しい情報
-
Base.reindex
は入力の添え字の型を関知しないことに注意してください。この関数は受け取った添え字を並べ替える方法を定めるだけです。サポートされるのは整数の添え字だけですが、整数の非スカラー添え字はサポートされます。これはビューのビューが二段階の間接参照を行わないことを意味します: 外側のビューによって変換された添え字が、内側のビューによって元の親配列への添え字にもう一度変換されるのです! -
スライスをサポートすると、パラメータ
N
が与える次元数が親の配列の次元数あるいはindices
タプルの長さと一致する必要はないことが、これまでにほぼ明らかになっています。またユーザーが指定する添え字がindices
タプルと同じ形状をしている必要もありません (例えばユーザーが指定した添え字の二番目の次元が親配列の三番目の次元とindices
タプルの第三要素に対応するなど)。これより明らかでないのは、保存された親配列の次元数が
indices
タプルに含まれる実効上の添え字の個数と一致しなけらばならないという事実です。例を示します:A = reshape(1:35, 5, 7) # 親の二次元配列 S = view(A, 2:7) # 線形添え字アクセスを使った一次元ビュー S = view(A, :, :, 1:1) # 最後に次元を付けてもよい
ナイーブに考えると
S.parent = A
とS.indices = (:,;,1,1)
を設定するだけで済むと思うかもしれませんが、これをサポートすると特にビューのビューに対して添え字変換処理が格段に複雑になります。保存された添え字の型でディスパッチする必要があるだけではなく、与えられた添え字が最後であり残りは "マージ" してよいかどうかを調べる必要もあります。これは簡単なタスクではなく、さらに悪いことに、暗黙の内に線形添え字アクセスに依存しているために低速です。幸いこれは
ReshapedArray
が行う計算そのものであり、この型は可能な場合には線形に計算を行います。そのためview
は親配列が与えられた添え字に対して適切な次元を持つことを必要なら変形することで保証できます。SubArray
の内部コンストラクタはこの不変条件を保つ処理を行います。 -
このため
CartesianIndex
と配列はreindex
に厄介な問題を持ち込みます。reindex
は保存された添え字の型でディスパッチして使うべき添え字の個数とその使い方を決めると説明しました。しかしCartesianIndex
の場合には、渡された引数の個数とアクセスされる次元の個数に一対一の対応関係が存在しません。上記の例に戻れば、Base.reindex(S1.indices, (i, j))
がi, j = CartesianIndex(), CartesianIndex(2,1)
のとき正しくないことが分かるでしょう。この場合CartesianIndex()
を飛ばして得られるのが正しい添え字です:(CartesianIndex(2,1)[1], S1.indices[2], S1.indices[3][CartesianIndex(2,1)[2]])
しかし、次の添え字が得られてしまいます:
(CartesianIndex(), S1.indices[2], S1.indices[3][CartesianIndex(2,1)])
この処理を正しく行うには、保存された添え字と渡された添え字の両方に関して全ての次元の組み合わせでディスパッチしなければならず、この組み合わせは多すぎて手に負えません。そのため
reindex
ではCartesianIndex
の添え字を使ってはいけないことになっています。幸いスカラーの場合はCartesianIndex
の引数を整数にならすことで簡単に処理できますが、CartesianIndex
の配列を格子添え字に分けるのはそう簡単ではありません。そのためreindex
を使う前に、view
は引数リストがCartesianIndex
の配列を含まないことを確認する必要があります。もしCartesianIndex
の配列があるなら、入れ子になったSubArray
による二段階の参照を使ってreindex
の計算を避けることで "迂回" が可能です。