在 R 的替换函数中,数据真的被复制了四次吗?

Posted

技术标签:

【中文标题】在 R 的替换函数中,数据真的被复制了四次吗?【英文标题】:Is data really copied four times in R's replacement functions? 【发布时间】:2014-07-16 22:19:10 【问题描述】:

考虑这个变量

a = data.frame(x=1:5,y=2:6)

当我使用替换函数改变a的第一个元素时,多少次 是否复制了与a 相同大小的内存?

tracemem(a)
"change_first_element<-" = function(x, value) 
  x[1,1] = value
  return(x)

change_first_element(a) = 3
# tracemem[0x7f86028f12d8 -> 0x7f86028f1498]: 
# tracemem[0x7f86028f1498 -> 0x7f86028f1508]: change_first_element<- 
# tracemem[0x7f86028f1508 -> 0x7f8605762678]: [<-.data.frame [<- change_first_element<- 
# tracemem[0x7f8605762678 -> 0x7f8605762720]: [<-.data.frame [<- change_first_element<- 

有四种复制操作。我知道 R 不会改变对象或通过引用传递(是的,有例外),但为什么有四个副本?一份还不够吗?

第 2 部分:

如果我调用替换函数的方式不同,那么复制操作只有三个?

tracemem(a)
a = `change_first_element<-`(a,3)
# tracemem[0x7f8611f1d9f0 -> 0x7f8607327640]: change_first_element<- 
# tracemem[0x7f8607327640 -> 0x7f8607327758]: [<-.data.frame [<- change_first_element<- 
# tracemem[0x7f8607327758 -> 0x7f8607327800]: [<-.data.frame [<- change_first_element<-

【问题讨论】:

这是一个旁注,但我认为您可以通过使用 tracemem 并查看未包含在您自己的函数中的赋值和子集函数来更清楚地了解复制。跨度> 谢谢乔兰。我不知道tracemem。我注意到以正常方式使用替换功能,tracemem 给了我四行(大概是四份?)。以非标准方式调用替换函数给了我三行。如果我以错误的方式使用inspect,我会在答案中纠正我。 我不认为你使用inspect 是错误的,我只是认为使用tracemem 可以更清楚地提出问题,仅此而已。 是的,我认为你是对的。我将问题更改为使用tracemem 阅读 this post 的 Simon Urbanek 和 Luke Tierney 的回答。 【参考方案1】:

注意:除非另有说明,否则以下所有解释均适用于 R 版本

为了回答您的第一个问题,“为什么要四份,一份不够?”,我们首先引用R-internals 的相关部分:

“命名”值为 2,即 NAM(2),表示对象在更改之前必须被复制。 (注意,这并不是说必须复制,只是不管是否必要都应该复制。)值为 0 表示已知没有其他 SEXP 与该对象共享数据,因此它可以安全地改变。

值 1 用于像dim(a) &lt;- c(7, 2) 这样的情况,其中原则上在计算期间存在两个副本(原则上) a &lt;- dim(a, c(7, 2)) 但不再适用,因此可以优化一些原始函数以避免在这种情况下复制。

南美(1):

让我们从NAM(1) 对象开始。这是一个例子:

x <- 1:5 # (1)
.Internal(inspect(x))
# @10374ecc8 13 INTSXP g0c3 [NAM(1)] (len=5, tl=0) 1,2,3,4,5
tracemem(x)
# [1] "<0x10374ecc8>"

x[2L] <- 10L # (2)
.Internal(inspect(x))
# @10374ecc8 13 INTSXP g0c3 [MARK,NAM(1),TR] (len=5, tl=0) 1,10,3,4,5

这里发生了什么?我们使用: 创建了一个整数向量,它是一个原语,产生一个 NAM(1) 对象。当我们在那个对象上使用[&lt;- 时,值就地改变了(注意指针是相同的,(1)和(2))。这是因为[&lt;- 作为一个原语非常清楚如何处理其输入,并且针对这种情况下的无副本进行了优化。

y = x # (3)
.Internal(inspect(x))
# @10374ecc8 13 INTSXP g0c3 [MARK,NAM(2),TR] (len=5, tl=0) 1,10,3,4,5

x[2L] <- 20L # (4)
.Internal(inspect(x))
# tracemem[0x10374ecc8 -> 0x10372f328]:
# @10372f328 13 INTSXP g0c3 [NAM(1),TR] (len=5, tl=0) 1,20,3,4,5

现在同样的分配结果是一个副本,为什么?通过执行 (3),'named' 字段将递增到 NAM(2),因为多个对象指向相同的数据。即使[&lt;- 被优化,它是NAM(2) 的事实意味着对象必须被复制。这就是为什么在分配之后它现在又是一个NAM(1) 对象。那是因为,调用 duplicate 会将 named 设置为 0,而新的分配会将其重新设置为 1。

注意:Peter Dalgaard 很好地解释了这个案例 in this link 为何 x = 2L 会导致 NAM(2) 对象。


南美(2):

现在让我们回到您的关于在data.frame 上调用*&lt;- 的问题,这是一个NAM(2) 对象。

那么第一个问题是,为什么data.frame()NAM(2) 对象?为什么不像之前的案例 x &lt;- 1:5 那样使用NAM(1)? Duncan Murdoch 在same post 上很好地回答了这个问题:

data.frame() 是一个普通的 R 函数,因此它的处理方式与任何用户编写的函数没有区别。另一方面,实现:操作符的内部函数是一个原语,所以它可以完全控制它的返回值,并且可以最有效地设置NAMED

这意味着任何更改值的尝试都将导致触发duplicate深拷贝)。来自?tracemem

... C 函数duplicate 对对象的任何复制都会在标准输出中生成一条消息。

所以来自tracemem 的消息有助于了解副本的数量。要了解tracemem 输出的第一行,让我们构造函数f&lt;-,它没有实际替换。另外,让我们构造一个足够大的data.frame,以便我们可以测量该data.frame 的单个副本所花费的时间。

## R v 3.0.3
`f<-` = function(x, value) 
    return(x) ## no actual replacement


df <- data.frame(x=1:1e8, y=1:1e8) # 762.9 Mb
tracemem(df) # [1] "<0x7fbccd2f4ae8>"

require(data.table)
system.time(copy(df)) 
# tracemem[0x7fbccd2f4ae8 -> 0x7fbccd2f4ff0]: copy system.time 
#   user  system elapsed 
#  0.609   0.484   1.106 

system.time(f(df) <- 3)
# tracemem[0x7fbccd2f4ae8 -> 0x7fbccd2f4f10]: system.time 
#   user  system elapsed 
#  0.608   0.480   1.101 

我使用了来自data.table 的函数copy()(它基本上调用了C 的duplicate 函数)。复制的时间或多或少相同。所以,第一步显然是深拷贝,即使它什么也没做

这解释了您帖子中来自tracemem 的前两条详细消息:

(1) 从我们称为f(df) &lt;- 3) 的全局环境中。这是一个副本。 (2) 在函数f&lt;- 中,另一个赋值x[1,1] &lt;- 3 将调用[&lt;-(因此调用[&lt;-.data.frame 函数)。这使得 second 立即复制。

[&lt;-.data.frame 上输入debugonce() 很容易找到其余的副本。也就是说,做:

debugonce(`[<-`)
df <- data.frame(x=1:1e8, y=1:1e8)
`f<-` = function(x, value) 
    x[1,1] = value
    return(x)

tracemem(df)
f(df) = 3

# first three lines:

# tracemem[0x7f8ba33d8a08 -> 0x7f8ba33d8d50]:      (1)
# tracemem[0x7f8ba33d8d50 -> 0x7f8ba33d8a78]: f<-  (2)
# debugging in: `[<-.data.frame`(`*tmp*`, 1L, 1L, value = 3L)

点击enter,你会发现另外两个副本在这个函数里面:

# debug: class(x) <- NULL
# tracemem[0x7f8ba33d8a78 -> 0x7f8ba3cd6078]: [<-.data.frame [<- f<-     (3)

# debug: x[[jj]][iseq] <- vjj
# tracemem[0x7f8ba3cd6078 -> 0x7f882c35ed40]: [<-.data.frame [<- f<-     (4)

请注意,class 是原始的,但它是在 NAM(2) 对象上调用的。我怀疑这就是那里复制的原因。最后一个副本是不可避免的,因为它会修改列。

所以,你去吧。


现在在R v3.1.0上做一个小笔记:

我也在R V3.1.0 中进行了相同的测试。 tracemem 提供所有四行。但是,唯一耗时的步骤是 (4)。 IIUC,其余的情况,都是由于[&lt;- / class&lt;- 应该触发浅拷贝而不是深拷贝。令人敬畏的是,即使在 (4) 中,也只有正在修改的列似乎是深度复制。 R 3.1.0 有了很大的改进!

这意味着tracemem 也由于 浅拷贝 提供了输出 - 这有点令人困惑,因为文档没有明确说明这一点,并且很难区分浅拷贝和深拷贝,除了测量时间。也许这是我的(不正确的)理解。请随时纠正我。


在您的第二部分,我将引用 here 的 Luke Tierney 的话:

直接调用foo&lt;- 函数不是一个好主意,除非您真正了解一般分配机制和特定foo&lt;- 函数中发生的情况。除非您喜欢令人不快的惊喜,否则绝对不是常规编程中要做的事情。

但我无法判断这些令人不快的意外是否会扩展到已经是 NAM(2) 的对象。因为,Matt 在 list 上调用它,这是一个原语,因此是 NAM(1),并且直接调用 foo&lt;- 不会增加它的“命名”值。

但是,R v3.1.0 有很大改进的事实应该已经让您相信,不再需要这样的函数调用了。

HTH。

PS:请随时纠正我(如果可能,请帮助我缩短这个答案):)。


编辑:我似乎错过了直接致电f&lt;- 时减少副本的要点,如评论中所示。使用帖子中使用的函数 Simon Urbanek 很容易看到(现在已多次链接):

# rm(list=ls()) # to make sure there' no other object in your workspace
`f<-` <- function(x, value) 
    print(ls(env = parent.frame()))


df <- data.frame(x=1, y=2)
tracemem(df) # [1] "<0x7fce01a65358>"

f(df) = 3
# tracemem[0x7fce0359b2a0 -> 0x7fce0359ae08]: 
# [1] "*tmp*" "df"    "f<-"  

df <- data.frame(x=1, y=2)
tracemem(df) # [1] "<0x7fce03c505c0>"
df <- `f<-`(df, 3)
# [1] "df"  "f<-"

如您所见,在第一种方法中创建了一个对象*tmp*,而在第二种情况下则没有。似乎为NAM(2) 输入对象创建*tmp* 对象会在*tmp* 分配给函数参数之前触发输入的副本。但这就是我的理解。

【讨论】:

整洁!所以我猜我离得太远了。我不太明白为什么调用方法似乎删除了其中一个副本(即a[1,1] &lt;- 3'[&lt;-(a,1,1,3))。我错过了吗?

以上是关于在 R 的替换函数中,数据真的被复制了四次吗?的主要内容,如果未能解决你的问题,请参考以下文章

mysql语句——字符串多次替换,七日排重

如何使用 R 在 SQL 查询中执行参数替换? [复制]

R无需替换即可复制样本功能

R语言使用R基础安装中的glm函数构建乳腺癌二分类预测逻辑回归模型分类预测器(分类变量)被自动替换为一组虚拟编码变量summary函数查看检查模型使用table函数计算混淆矩阵评估分类模型性能

美团二面:TCP 四次挥手,可以变成三次吗?

我的应用被拒绝了四次