为什么“向量化”这个简单的R循环会得到不同的结果?

时间:2018-10-01 19:01:14

标签: r loops for-loop vectorization

也许是一个很愚蠢的问题。

我正在尝试“向量化”以下循环:

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]是不同的。后者的含义很清楚:置换,因此许多人倾向于认为循环只是在做一些错误的事情。但是永远不要这么确定。它可以是一些定义明确的动态过程。该问答的目的不是判断哪个是正确的,而是解释为什么它们不等效。希望它能为理解“向量化”提供坚实的案例研究。

4 个答案:

答案 0 :(得分:16)

热身

作为热身,请考虑两个简单的示例。

## 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

在第一个示例中“矢量化”成功,但在第二个示例中没有成功。为什么?

这是审慎的分析。 “向量化”从循环展开开始,然后并行执行几条指令。循环是否可以“向量化”取决于循环所携带的数据依赖性。

展开示例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]

一对一执行这些指令并同时执行它们会得到相同的结果。因此,此循环可以“矢量化”。

示例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]

不幸的是,一条一条地执行这些指令并同时执行它们不会得到相同的结果。例如,当一一执行它们时,在第一条指令中修改x[2],然后在第二条指令中将此修改后的值传递给x[3]。因此x[3]将具有与x[1]相同的值。但是,在并行执行中,x[3]等于x[2]。结果,该循环无法“向量化”。

在“向量化”理论中,

  • 示例1在数据中具有“读取后写入”依赖性:x[i]在读取后被修改;
  • 示例2在数据中具有“写入后读取”依赖性:x[i]在修改后被读取。

具有“读取后写入”数据相关性的循环可以被“向量化”,而具有“写入后读取”数据相关性的循环则不能。


深入

到目前为止,也许很多人感到困惑。 “矢量化”是“并行处理”吗?

是的。在1960年代,人们想知道为高性能计算设计哪种并行处理计算机时,弗林将设计思想分为4种类型。类别“ SIMD”(单指令,多个数据)称为“向量化”,而具有“ SIMD”可布线性的计算机称为“向量处理器”或“阵列处理器”。

在1960年代,编程语言并不多。人们编写了汇编程序(当发明了编译器时是FORTRAN)直接对CPU寄存器进行编程。 “ SIMD”计算机能够通过一条指令将多个数据加载到向量寄存器中,并同时对这些数据执行相同的算法。因此,数据处理确实是并行的。再次考虑示例1。假设向量寄存器可以容纳两个向量元素,则可以使用向量处理执行5次迭代,而不是像执行标量处理那样执行10次迭代。

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”。在先前的问答中:Does the term "vectorization" mean different things in different contexts?我试图解释这一点。下图说明了如何进行类比:

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]

其中abc在内存中没有别名,也就是说,存储矢量ab和{{1 }}不重叠。这是一个理想的情况,因为没有内存别名就意味着没有数据依赖性。

除“数据依赖性”外,还存在“控制依赖性”,即在“向量化”中处理“ if ... else ...”。但是,由于时间和空间的原因,我不会在这个问题上进行详细说明。


回到问题中的示例

现在是时候研究问题的循环了。

c

展开循环即可

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]]

第3条指令和第5条指令之间存在“先写后读”数据相关性,因此不能对循环进行“向量化”(请参见注释1 )。

那么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] 的作用是什么?首先让我们显式地写出临时向量:

x[] <- x[sig]

由于tmp <- x[sig] x[] <- tmp 被两次调用,因此在此“向量化”代码后面实际上存在两个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]

这根本不是问题中给出的原始循环。


备注1

如果在Rcpp中实现循环被视为“矢量化”,那就顺其自然了。但是没有机会用“ SIMD”进一步“向量化” C / C ++循环。


备注2

此问与答的动机是this Q & A。 OP最初呈现了一个循环

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

很容易将其“矢量化”为

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

,但这可能是错误的。后来,OP将循环更改为

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

消除了内存混叠问题,因为要替换的列是前for (i in 1:num) { for (j in 1:num) { mat[i, j] <- mat[i, 1 + num + mat[j, "rm"]] } } 列,而要查找的列在前num列之后。


备注3

关于问题中的循环是否正在对num进行“就地”修改,我有一些评论。是的。我们可以使用x

tracemem

我的R会话已为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>" 分配了一个由地址<0x28f7340>指向的存储块,运行代码时可能会看到一个不同的值。但是,x的输出在循环后将不会更改,这意味着不会复制tracemem。因此,该循环确实在不使用额外内存的情况下进行了“就地”修改。

但是,循环没有进行“就地”排列。 “就地”排列是一个更复杂的操作。不仅需要沿循环交换x的元素,还需要交换x的元素(最后,sig将是sig)。

答案 1 :(得分:3)

有一个更简单的解释。使用循环,您将在每一步覆盖x的一个元素,用x的其他元素之一替换其先前的值。这样您就可以满足您的要求。从本质上讲,这是一种带有替换(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的第一个元素,它们的名称就会更改。

答案 2 :(得分:3)

这与内存块别名无关(这是我以前从未遇到过的术语)。以一个特定的排列示例为例,并逐步进行将要发生的分配,而与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

...可以继续,但是这说明了这通常不是“真正的”排列,而且非常罕见地导致值的完全重新分配。我猜想只有一个完全有序的排列(我认为其中只有一个,即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 :(得分:2)

有趣的是,尽管R的“向量化”与“ SIMD”不同(正如OP很好地解释的),但在确定循环是否可“向量化”时,可以应用相同的逻辑。这是一个使用OP自助示例中的示例的演示(稍作修改)。

具有“后写后写入”依赖性的示例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是不可“可向量化的”。

// "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关键字来提示编译器在三个阵列之间没有内存块混叠。

// "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