r - 为什么“矢量化”这个简单的 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]
是不同的。后者的含义很清楚:排列,因此许多人倾向于认为循环只是在做一些错误的事情。但永远不要如此确定;它可以是一些定义明确的动态过程。这个问答的目的不是判断哪个是正确的,而是解释为什么它们不等价。希望它为理解“矢量化”提供了一个可靠的案例研究。
解决方案
暖身
作为热身,考虑两个更简单的例子。
## 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]
在第 1 条指令中进行修改,然后将此修改后的值传递给x[3]
第 2 条指令。所以x[3]
将具有与 相同的值x[1]
。但是,在并行执行中,x[3]
等于x[2]
. 结果,这个循环不能被“矢量化”。
在“矢量化”理论中,
- 示例 1 在 data 中有一个“write-after-read”依赖:在
x[i]
读取后修改; - 示例 2 在数据中具有“写后读”依赖性:
x[i]
在修改后读取。
具有“读后写”数据依赖性的循环可以“矢量化”,而具有“写后读”数据依赖性的循环则不能。
深入
可能很多人到现在都糊涂了。“向量化”是“并行处理”吗?
是的。在 1960 年代,当人们想知道应该为高性能计算设计什么样的并行处理计算机时,Flynn 将设计思想分为 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”的类比。在之前的问答中:“向量化”一词在不同的上下文中是否意味着不同的东西?我试图解释这一点。下面的地图说明了这个类比是如何产生的:
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]
其中a
,b
和c
在内存中没有别名,即存储向量的内存块,a
不重叠。这是一个理想的情况,因为没有内存别名意味着没有数据依赖。b
c
除了“数据依赖”,还有“控制依赖”,即在“向量化”中处理“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 条指令之间存在“read-after-write”数据依赖性,因此循环不能“矢量化”(参见备注 1)。
那么,有什么作用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]
相当于
for (i in 1:10) tmp[i] <- x[sig[i]]
for (i in 1:10) x[i] <- tmp[i]
rm(tmp); gc()
这根本不是问题中给出的原始循环。
备注 1
如果在 Rcpp 中实现循环被视为“矢量化”,那就顺其自然吧。但是没有机会用“SIMD”进一步“矢量化”C/C++循环。
备注 2
这个问答是由这个问答推动的。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>"
<0x28f7340>
我的 R 会话分配了一个由地址指向的内存块,x
运行代码时您可能会看到不同的值。但是,tracemem
循环后的输出不会改变,这意味着没有复制x
。所以循环确实在不使用额外内存的情况下进行“就地”修改。
但是,循环并没有进行“就地”排列。“就地”置换是一个更复杂的操作。不仅x
需要沿着循环交换元素,sig
还需要交换元素(最后sig
是1:10
)。
推荐阅读
- jquery - 为什么 JQuery 不改变 CSS 类?
- json - dart 中 json 映射的工厂和命名构造函数
- python - 你如何做一个嵌套循环,其中外循环的变量之一在内循环中运行?
- php - WooCommerce Checkout:根据付款方式设置字段(不)
- java - 编译 Android 项目时出现问题
- c# - 基于时间的进程杀死 VB.NET
- reverse-proxy - Minio 通过 Web 界面上传,API 收到“未经授权的请求”。
- javascript - D3 更新模式未按预期工作
- haskell - 为什么 XMonad 的提示对我不起作用?
- java - fatjar 依赖关系的 Java 日志记录问题