web-dev-qa-db-ja.com

この単純なRループを「ベクトル化」すると異なる結果が得られるのはなぜですか?

おそらく非常に愚かな質問です。

次のループを「ベクトル化」しようとしています。

set.seed(0)
x <- round(runif(10), 2)
# [1] 0.90 0.27 0.37 0.57 0.91 0.20 0.90 0.94 0.66 0.63
sig <- sample.int(10)
# [1]  1  2  9  5  3  4  8  6  7 10
for (i in seq_along(sig)) x[i] <- x[sig[i]]
x
# [1] 0.90 0.27 0.66 0.91 0.66 0.91 0.94 0.91 0.94 0.63

単にx[sig]しかし、結果は一致しません。

set.seed(0)
x <- round(runif(10), 2)
x[] <- x[sig]
x
# [1] 0.90 0.27 0.66 0.91 0.37 0.57 0.94 0.20 0.90 0.63

どうしましたか?


備考

明らかに、出力から、forループとx[sig] 異なっています。後者の意味は明らかです。順列です。したがって、多くの人々は、ループが単に間違ったことをしていると信じがちです。しかし、決してそんなことは絶対にしないでください。明確に定義された動的プロセスである場合があります。このQ&Aの目的は、どちらが正しいかを判断することではなく、なぜそれらが同等でないのかを説明することです。 「ベクトル化」を理解するための堅実なケーススタディを提供できれば幸いです。

18
李哲源

準備し始める

ウォームアップとして、2つの簡単な例を考えてみましょう。

## example 1
x <- 1:11
for (i in 1:10) x[i] <- x[i + 1]
x
# [1]  2  3  4  5  6  7  8  9 10 11 11

x <- 1:11
x[1:10] <- x[2:11]
x
# [1]  2  3  4  5  6  7  8  9 10 11 11

## example 2
x <- 1:11
for (i in 1:10) x[i + 1] <- x[i]
x
# [1] 1 1 1 1 1 1 1 1 1 1 1

x <- 1:11
x[2:11] <- x[1:10]
x
# [1]  1  1  2  3  4  5  6  7  8  9 10

「ベクトル化」は最初の例では成功しますが、2番目の例では成功しません。どうして?

これが慎重な分析です。 「ベクトル化」は、ループの展開によって開始され、複数の命令を並行して実行します。ループを「ベクトル化」できるかどうかは、ループが保持するデータ依存性に依存します。

例1のループを展開すると、

x[1]  <- x[2]
x[2]  <- x[3]
x[3]  <- x[4]
x[4]  <- x[5]
x[5]  <- x[6]
x[6]  <- x[7]
x[7]  <- x[8]
x[8]  <- x[9]
x[9]  <- x[10]
x[10] <- x[11]

これらの命令を1つずつ実行し、同時に実行すると、同じ結果が得られます。したがって、このループは「ベクトル化」できます。

例2のループは

x[2]  <- x[1]
x[3]  <- x[2]
x[4]  <- x[3]
x[5]  <- x[4]
x[6]  <- x[5]
x[7]  <- x[6]
x[8]  <- x[7]
x[9]  <- x[8]
x[10] <- x[9]
x[11] <- x[10]

残念ながら、これらの命令を1つずつ実行して同時に実行しても、同じ結果にはなりません。たとえば、それらを1つずつ実行すると、1番目の命令でx[2]が変更され、2番目の命令でこの変更された値がx[3]に渡されます。したがって、x[3]x[1]と同じ値になります。ただし、並列実行では、x[3]x[2]と等しくなります。その結果、このループは「ベクトル化」できません。

「ベクトル化」理論では、

  • 例1には、データに「読み取り後書き込み」依存関係があります。x[i]は読み取り後に変更されます。
  • 例2には、データに「読み取り後書き込み」依存関係があります。x[i]は、変更後に読み取られます。

「読み取り後書き込み」データ依存性を持つループは「ベクトル化」できますが、「書き込み後読み取り」データ依存性を持つループはできません。


深さで

おそらく多くの人が今では混乱しているでしょう。 「ベクトル化」は「並列処理」ですか?

はい。 1960年代、高性能コンピューティング用にどのような並列処理コンピューターを設計するのかと人々が疑問に思ったとき、フリンは設計のアイデアを4つのタイプに分類しました。カテゴリ「SIMD」(単一命令、複数データ)は「ベクトル化」と呼ばれ、「SIMD」機能を備えたコンピュータは「ベクトルプロセッサ」または「アレイプロセッサ」と呼ばれます。

1960年代には、多くのプログラミング言語はありませんでした。 CPUレジスターを直接プログラムするために、人々はアセンブリー(当時はコンパイラーが発明されたときはFORTRAN)を書きました。 「SIMD」コンピュータは、単一の命令で複数のデータをベクトルレジスタにロードし、それらのデータに対して同時に同じ演算を実行できます。したがって、データ処理は確かに並列です。例1をもう一度考えてみましょう。ベクトルレジスタが2つのベクトル要素を保持できると仮定すると、ループは、スカラー処理のように10回の反復ではなく、ベクトル処理を使用して5回の反復で実行できます。

reg <- x[2:3]  ## load vector register
x[1:2] <- reg  ## store vector register
-------------
reg <- x[4:5]  ## load vector register
x[3:4] <- reg  ## store vector register
-------------
reg <- x[6:7]  ## load vector register
x[5:6] <- reg  ## store vector register
-------------
reg <- x[8:9]  ## load vector register
x[7:8] <- reg  ## store vector register
-------------
reg <- x[10:11] ## load vector register
x[9:10] <- reg  ## store vector register

今日、[〜#〜] r [〜#〜]のような多くのプログラミング言語があります。 「ベクトル化」は、「SIMD」を明確に指すものではなくなりました。 [〜#〜] r [〜#〜]はCPUレジスタをプログラムできる言語ではありません。 Rの「ベクトル化」は、「SIMD」に類似しています。以前のQ&Aで: 「ベクトル化」という用語は異なるコンテキストで異なることを意味しますか? これを説明しようとしました。次のマップは、この類推がどのように行われるかを示しています。

single (Assembly) instruction    -> single R instruction
CPU vector registers             -> temporary vectors
parallel processing in registers -> C/C++/FORTRAN loops with temporary vectors

したがって、例1のループのR "ベクトル化"は次のようなものです。

## the C-level loop is implemented by function "["
tmp <- x[2:11]  ## load data into a temporary vector
x[1:10] <- tmp  ## fill temporary vector into x

たいていの場合

x[1:10] <- x[2:10]

一時ベクトルを変数に明示的に割り当てずに。作成された一時メモリブロックは、R変数によってポイントされないため、ガベージコレクションの対象となります。


完全な写真

上記では、最も単純な例では「ベクトル化」は導入されていません。多くの場合、「ベクトル化」は次のようなもので導入されます

a[1] <- b[1] + c[1]
a[2] <- b[2] + c[2]
a[3] <- b[3] + c[3]
a[4] <- b[4] + c[4]

ここで、ab、およびcはメモリ内でエイリアスされていません。つまり、ベクトルabおよびc doを格納するメモリブロック重複しません。これは理想的なケースです。メモリのエイリアシングがないことは、データの依存関係がないことを意味します。

「データ依存関係」とは別に、「制御依存関係」、つまり「ベクトル化」の「if ... else ...」を処理します。ただし、時間と空間の理由から、この問題については詳しく説明しません。


質問の例に戻る

ここで、問題のループを調査します。

set.seed(0)
x <- round(runif(10), 2)
sig <- sample.int(10)
# [1]  1  2  9  5  3  4  8  6  7 10
for (i in seq_along(sig)) x[i] <- x[sig[i]]

ループを展開すると、

x[1]  <- x[1]
x[2]  <- x[2]
x[3]  <- x[9]   ## 3rd instruction
x[4]  <- x[5]
x[5]  <- x[3]   ## 5th instruction
x[6]  <- x[4]
x[7]  <- x[8]
x[8]  <- x[6]
x[9]  <- x[7]
x[10] <- x[10]

3番目と5番目の命令の間に「読み取り後書き込み」データ依存関係があるため、ループを「ベクトル化」できません(を参照してください。注1 )。

それでは、x[] <- x[sig]は何をしますか?最初に一時的なベクトルを明示的に書きましょう:

tmp <- x[sig]
x[] <- tmp

"["が2回呼び出されるため、実際にはこの「ベクトル化された」コードの背後に2つのCレベルのループがあります。

tmp[1]  <- x[1]
tmp[2]  <- x[2]
tmp[3]  <- x[9]
tmp[4]  <- x[5]
tmp[5]  <- x[3]
tmp[6]  <- x[4]
tmp[7]  <- x[8]
tmp[8]  <- x[6]
tmp[9]  <- x[7]
tmp[10] <- x[10]

x[1]  <- tmp[1]
x[2]  <- tmp[2]
x[3]  <- tmp[3]
x[4]  <- tmp[4]
x[5]  <- tmp[5]
x[6]  <- tmp[6]
x[7]  <- tmp[7]
x[8]  <- tmp[8]
x[9]  <- tmp[9]
x[10] <- tmp[10]

x[] <- x[sig]は次と同等です

for (i in 1:10) tmp[i] <- x[sig[i]]
for (i in 1:10) x[i] <- tmp[i]
rm(tmp); gc()

それは質問で与えられた元のループではありません。


備考1

Rcppでのループの実装が「ベクトル化」と見なされる場合は、そのままにしてください。ただし、C/C++ループを「SIMD」でさらに「ベクトル化」することはできません。


備考2

このQ&Aは、 this Q&A によって動機付けられています。 OPは元々ループを提示しました

for (i in 1:num) {
  for (j in 1:num) {
    mat[i, j] <- mat[i, mat[j, "rm"]]
  }
}

次のように「ベクトル化」したくなる

mat[1:num, 1:num] <- mat[1:num, mat[1:num, "rm"]]

しかし、それは潜在的に間違っています。後でOPがループを

for (i in 1:num) {
  for (j in 1:num) {
    mat[i, j] <- mat[i, 1 + num + mat[j, "rm"]]
  }
}

置換される列は最初のnum列であり、ルックアップされる列は最初のnum列の後にあるため、メモリエイリアシングの問題がなくなります。


備考3

質問のループがxの「インプレース」変更を行っているかどうかについてのコメントを受け取りました。はい、そうです。 tracememを使用できます:

set.seed(0)
x <- round(runif(10), 2)
sig <- sample.int(10)
tracemem(x)
#[1] "<0x28f7340>"
for (i in seq_along(sig)) x[i] <- x[sig[i]]
tracemem(x)
#[1] "<0x28f7340>"

私のRセッションは、アドレス<0x28f7340>が指すメモリブロックをxに割り当てました。コードを実行すると、異なる値が表示される場合があります。ただし、tracememの出力はループ後に変更されません。つまり、xのコピーは作成されません。したがって、ループは実際に余分なメモリを使用せずに「インプレース」変更を行っています。

ただし、ループは「インプレース」置換を実行していません。 「インプレース」置換は、より複雑な操作です。 xの要素だけがループに沿って交換される必要があるだけでなく、sigの要素も交換される必要があります(そして最終的に、sig1:10になります)。

16
李哲源

より簡単な説明があります。ループでは、ステップごとにxの1つの要素を上書きし、以前の値をxの他の要素の1つで置き換えます。だからあなたはあなたが求めたものを手に入れる。本質的に、それは置換(sample(x, replace=TRUE))を伴うサンプリングの複雑な形式です-そのような複雑さが必要かどうかは、達成したいものに依存します。

ベクトル化されたコードでは、xの特定の順列(置換なし)を求めているだけで、それが得られます。ベクトル化されたコードはnotループと同じことをしています。ループで同じ結果を得るには、最初にxのコピーを作成する必要があります。

set.seed(0)
x <- x2 <- round(runif(10), 2)
# [1] 0.90 0.27 0.37 0.57 0.91 0.20 0.90 0.94 0.66 0.63
sig <- sample.int(10)
# [1]  1  2  9  5  3  4  8  6  7 10
for (i in seq_along(sig)) x2[i] <- x[sig[i]]
identical(x2, x[sig])
#TRUE

ここでエイリアスの危険はありません:xx2は最初は同じメモリ位置を参照しますが、x2の最初の要素を変更するとすぐに変更されます。

3
lebatsnok

これは、メモリブロックのエイリアシングとは関係ありません(これまでに出会ったことのない用語です)。特定の順列の例を取り上げ、Cまたはアセンブリ(またはその他の)言語レベルでの実装に関係なく発生する割り当てを順を追って説明します。これは、シーケンシャルなforループの動作と「真の」置換(x[sig])が発生します:

sample(10)
 [1]  3  7  1  5  6  9 10  8  4  2

value at 1 goes to 3, and now there are two of those values
value at 2 goes to 7, and now there are two of those values
value at 3 (which was at 1) now goes back to 1 but the values remain unchanged

...続行できますが、これは、これが通常「真の」置換ではなく、非常にまれに値の完全な再配分をもたらすことを示しています。私は完全に順序付けられた順列のみを推測しています(そのうちの1つだけ、つまり10:1)は、一意のxの新しいセットになる可能性があります。

replicate( 100, {x <- round(runif(10), 2); 
                  sig <- sample.int(10); 
                  for (i in seq_along(sig)){ x[i] <- x[sig[i]]}; 
                  sum(duplicated(x)) } )
 #[1] 4 4 4 5 5 5 4 5 6 5 5 5 4 5 5 6 3 4 2 5 4 4 4 4 3 5 3 5 4 5 5 5 5 5 5 5 4 5 5 5 5 4
 #[43] 5 3 4 6 6 6 3 4 5 3 5 4 6 4 5 5 6 4 4 4 5 3 4 3 4 4 3 6 4 7 6 5 6 6 5 4 7 5 6 3 6 4
 #[85] 8 4 5 5 4 5 5 5 4 5 5 4 4 5 4 5

私は、重複カウントの分布が大規模なシリーズにあるのではないかと思い始めました。かなり対称的に見えます:

table( replicate( 1000000, {x <- round(runif(10), 5); 
                            sig <- sample.int(10); 
               for (i in seq_along(sig)){ x[i] <- x[sig[i]]}; 
                            sum(duplicated(x)) } ) )

     0      1      2      3      4      5      6      7      8 
     1    269  13113 126104 360416 360827 125707  13269    294 
3
42-

Rの「ベクトル化」は「SIMD」とは異なりますが(OPの説明どおり)、ループが「ベクトル化可能」であるかどうかを判断するときに同じロジックが適用されます。これは、OPの自己回答の例を使用したデモです(少し変更を加えています)。

「write-after-read」依存関係を持つ例1は「ベクトル化可能」です。

// "ex1.c"
#include <stdlib.h>
void ex1 (size_t n, size_t *x) {
  for (size_t i = 1; i < n; i++) x[i - 1] = x[i] + 1;
}

gcc -O2 -c -ftree-vectorize -fopt-info-vec ex1.c
#ex1.c:3:3: note: loop vectorized

「読み取り後書き込み」依存関係のある例2はnot "vectorizable"です。

// "ex2.c"
#include <stdlib.h>
void ex2 (size_t n, size_t *x) {
  for (size_t i = 1; i < n; i++) x[i] = x[i - 1] + 1;
}

gcc -O2 -c -ftree-vectorize -fopt-info-vec-missed ex2.c
#ex2.c:3:3: note: not vectorized, possible dependence between data-refs
#ex2.c:3:3: note: bad data dependence

C99 restrictキーワードを使用して、3つの配列間でメモリブロックのエイリアシングがないことをコンパイラに知らせます。

// "ex3.c"
#include <stdlib.h>
void ex3 (size_t n, size_t * restrict a, size_t * restrict b, size_t * restrict c) {
  for (size_t i = 0; i < n; i++) a[i] = b[i] + c[i];
}

gcc -O2 -c -ftree-vectorize -fopt-info-vec ex3.c
#ex3.c:3:3: note: loop vectorized
2
GingerCat