R之内存管理

引言

R的内存管理机制究竟是什么样子的?最近几日在讲一个分享会,被同学问到这方面的问题,可是到网上去查,终于找到一篇R语言内存管理不过讲的不清不楚的,就拿memory.limit()函数来说,是在windows下才使用的,作者几乎没有提及,还有rm(),gc()函数到底怎么工作的,什么时候用,都无从提及。看来百度是解决不了了,关键时候还是靠google啊,这不,很快找到了一篇相当不错的文章Memory,还是人家外国人比较注重细节,下面的部分几乎是从那儿翻译过来的。需要学习R的高级编程的同学,可以下载Advanced R,本文属于其中的一个章节。另外值得一提的是在R语言实践一书中的附录G对于怎么高效编程,也有一些建议!大数据中怎么使用R,打算下篇博客来写。

目录

  1. Object size 查看对象在内存中占用的空间大小。
  2. Memory used & garbage collection 主要介绍mem_used()和mem_changed()函数来阐述R内存的分配和释放的具体工作机制。
  3. Memory profiling with lineprof 使用lineprof包,对代码运行中内存的分配和释放,以及花费的实践统计分析。
  4. Modification in place 介绍R对象什么时候才会被拷贝?

准备工作

下面的部分要用到的包为pryr,lineprof,以及ggplot2包中的数据集,因此如果还没有安装的可以运行下面的代码安装:

install.packages("ggplot2")
install.packages("pryr")
devtools::install_github("hadley/lineprof")

1. Object size

      这一节用到的第一个重要的函数为pryr包中的object_size(),这个函数返回R对象占用的内存空间。object_size()函数与object.size()相比,能够计算R对象内部共享部分的内存空间以及R对象的上下文环境的大小。下面的代码分别计算了vector,函数,数据集的大小,在R语言中函数也是一个对象。

> library(pryr)
> object_size(1:10)
#>88 B
> object_size(mean)
#>832 B
> object_size(mtcars)
#>6.74 kB

     R对象所占资源内存的分配并不是线性的,例如一个空的向量被分配的资源并不是0,我们做下面的实验:

sizes <- sapply(0:50, function(n) object_size(seq_len(n)))
plot(0:50, sizes, xlab = "Length", ylab = "Size (bytes)", 
  type = "s")

       

一个空的R对象分配到的内存空间并不是0,下面的代码充分说明了这一点:

> object_size(numeric())
#>40 B
> object_size(logical())
#>40 B
> object_size(raw())
#>40 B
> object_size(list())
#>40 B

40B大小的空间,到底存了哪些内容?主要分两大部分:

  • R空对象数据
  1. R对象的元数据(4 Bytes),包括基础的数据类型(例如 integer)和用于调试和内存管理的一些信息数据。
  2. 两个指针(2*8 Bytes),一个指针指向内存中的前一个对象,另外一个指针指向内存中下一个对象,由于是双指针的,所以使得循环变得简单。
  3. 一个指针指向attributes(8 Bytes)。
  • R向量对象额外的数据
  1. 向量的大小信息(4 Bytes),占用4Bytes空间,所能表示的最大的空间为24 × 8 − 1 (231, 大约为2百万),R3.0.0或者以后这个数可能更大,详细可以参见Read R-internals
  2. 向量“True”的大小(4 Bytes)。这个数据一般都用不到,但是当向量用作hash表时,那么该值反映的是真正占用的大小。
  3. 数据块(?? Bytes)。对于空向量,该值为0;另外该值随着数据类型的不同,每个元素占用的长度不同,例如numeric为8 Bytes,integer为4 Bytes,复杂的向量为16 Bytes。

       除去向量对象的数据块,我们计算一下空向量占用的大小为(4+2*8+8+4+4=36 Bytes),那么剩下的4 Bytes在哪里?熟悉C语言的人,可能会知道“字节填充”的概念,对于64位系统来说,系统访问内存最好为8 Bytes的边界,否则会访问两次才能访问到数据,造成不能在一个读周期内完全能读取到数据;另外考虑到不同的平台,可能访问规则不同,字节填充有利于平台的移植。如果你感兴趣,可以查阅C structure packing

      上面的部分解释了,空向量占用40 Bytes的原因。那么为什么上面图中向量的占用空间的大小,为什么不是线性的呢?原来是R申请内存空间函数(malloc())是一个非常昂贵的操作,如果每次申请的空间都很小,会导致R比较慢。作为一种替代高效的方法,R每次申请一块空间(小于128 Bytes)作为池子,并自助维护,为了高效和简单,申请的空间大小为8, 16, 32, 48, 64, 或者128 Bytes。如果我们减去空对象占用的40 Bytes的空间,上面的图就会变成下面的那样:

>plot(0:50, sizes - 40, xlab = "Length", 
+   ylab = "Bytes excluding overhead", type = "s")
> abline(h=0,col="green")
> abline(h=c(8,16,32,48,64,128),col="green")
> abline(a=0,b=4,col="red",lwd=4)

      当向量的大小超过128 Bytes后,就不再需要R去管理了,对于操作系统而言,申请一个大的数据块,还是比较在行的。超过后,操作系统每次申请8 Bytes的倍数的空间。

      R对象的内部部件可以与其他对象进行共享,我们看下面的代码:

> x <- 1:1e6
> object_size(x)
#>4 MB
> y <- list(x, x, x)
> object_size(y)
#>4 MB
>object.size(y)
#>12000192 bytes
>z<-list(rep(x,3))
>object_size(z)
#>12 MB
> y1=y[[1]]
> y11=y[1]
> address(x)
#[1] "0x73dee60"
> address(y1)
#[1] "0x73dee60"
> address(y11)
#[1] "0x1a4e7468"

       y的实际大小并不是x的3倍,y并没有将x复制了三次,而是维护一个指针指向x,y1的地址和x的相同,而y11则不是,y[1]访问会重新申请一个新的空间。rep函数会重新复制和神奇你个空间,这也是z空间是y的3倍的原因;另外object.size(y)3倍于object_size函数,原因区别在于上面所说的那样。

      如果y的内部数据变化了,情况会怎么样呢?看下面的代码:

> x<-1:1e6
> y<-list(x,x,x)
> y[[1]][1]=2
> y1=y[[1]]
> y2=y[[2]]
> address(x)
#[1] "0x1a5156f0"
> address(y2)
#[1] "0x1a5156f0"
> address(y1)
#[1] "0x6c3dc30"
> object_size(y)
#12 MB
> object_size(y1)
#8 MB
> object_size(x)
#4 MB

       更改y第一部分,然后这部分的地址就发生了变化,大小居然变成了2倍;y的剩下的2部分数据,仍然指向原来的x。这就是所谓的“变化时拷贝”的概念,第四部分会重点介绍,那么令热纳闷的是,大小居然变大了一倍,我们看看它们的类型,原来是赋值之后,数值类型由integer变成了numeric:

> class(x)
#[1] "integer"
> class(y1)
#[1] "numeric"
> class(y[[1]])="integer"

这种情况仍然适用于字符串变量,R内部有一个全局的字符串池子,也就是说一个独特的字符串只存储了一份,所以字符串向量占用的空间大小,往往比想象的要小很多,例如:

> object_size("apple")
#96 B
> object_size(rep("apple",10))
#216 B

2. Memory used & garbage collection

 object_size()告诉我们单个对象占用的内存大小,pryr::mem_used()可以给出R所有对象占用的内存大小:

> library(pryr)
> mem_used()
#59 MB

       这个数字可能和操作系统报告给我们的不太一致,具体原因如下:

  1. 该函数返回R对象的空间大小,并不包括R的编译器等部分。
  2. R和操作系统都是“懒惰”的,直到再次被用到,它们不会回收内存资源。在操作系统没有要求回收内存之前,R要一直占用已有的空间。
  3. R计数的对象,可能会因为被删除存在间隙,也就是我们常说的“内存碎片”。

       mem_change()函数给出一段执行语句下来,内存的变化情况,甚至在什么都不做的情况下,内存都会发生变化,那是因为R会跟踪用户做的操作,所以会保留下来操作的历史。对于内存改变在2KB左右的语句,几乎都可以忽略。

> mem_change(x<-1:1e6)
#4 MB
> mem_change(rm(x))
#-4 MB
> mem_change(NULL)
#1.68 kB

      在一些语言中,有时候必须显式删除没用的对象去回收内存资源,R提供了一种选择:garbage collection 。可以人为调用gc()函数,其实大部分时候它能够自动运行,如果一个内存对象没有被任何其他对象引用,那么R就会自动回收它。在99%的时候都不需要人工去调用gc()函数,因为R一旦检测到没用的对象就会回收它,除非要人为告诉操作系统要回收内存资源,不过即使调用了也没什么差别,只是在老的windows版本中,没有一种方式将资源交还给操作系统。例如:

> mem_change(x<-1:1e6)
#4 MB
> mem_change(y<-x)
#1.62 kB
> mem_change(rm(x))
#1.62 kB
> mem_change(rm(y))
#-4 MB

      学习过C/C++或者其他语言的人,可能对“内存泄露”或者“内存漏洞”不会陌生,内存泄露的产生往往是因为一个指向对象的指针始终都没有得到释放。那么在R中,往往存在2种方式可以导致内存泄露:formulas和closures,因为两者保存了上下文的环境信息,而这部分往往是没有用的,而且得不到释放。如下面例子所示,在函数f1内部,1:1e6并没有被返回,当函数执行完毕之后,函数内部变量占用的空间被释放,但是在函数f2和f3内部,返回值都包含了“环境”上下文信息,所以当函数结束之后,函数内部的空间作为返回值返回了,然而确是没有用的。

> f1<-function(){
+ x<-1:1e6
+ 10
+ }
> f2<-function(){
+ x<-1:1e6
+ a~b
+ }
> f3<-function(){
+ x<-1:1e6
+ function() 10
+ }
> mem_change(x<-f1())
#1.74 kB
> object_size(x)
#48 B
> mem_change(y<-f2())
#4 MB
> object_size(y)
#4 MB
> y
#a ~ b
#<environment: 0x1ab75660>
> mem_change(z<-f3())
#4 MB
> object_size(z)
#4 MB
> z
#function() 10
#<environment: 0x19cfd1a8>

3. Memory profiling with lineprof

        mem_change()反映的是运行一块代码,内存变化的情况。有时候,我们想要知道一大块代码中内存一点一点增加的情况,那就需要使用“memory profiling”去抓取每毫秒内存的变化情况。utils::Rprof()函数能够完成这项工作,但是函数的返回结果不能很好的展示,所以这里使用lineprof包,它提供更好的结果展示。为了展示lineprof::lineprof()是怎么工作的,下面我们拿read.delim()函数为例:

read_delim <- function(file, header = TRUE, sep = ",") {
  # Determine number of fields by reading first line
  first <- scan(file, what = character(1), nlines = 1,
    sep = sep, quiet = TRUE)
  p <- length(first)

  # Load all fields as character vectors
  all <- scan(file, what = as.list(rep("character", p)),
  sep = sep, skip = if (header) 1 else 0, quiet = TRUE)

  # Convert from strings to appropriate types (never to factors)
  all[] <- lapply(all, type.convert, as.is = TRUE)

  # Set column names
  if (header) {
    names(all) <- first
  } else {
    names(all) <- paste0("V", seq_along(all))
  }

  # Convert list into data frame
  as.data.frame(all)
}

测试读入的数据来自ggplot2包:

library(ggplot2)
write.csv(diamonds, "diamonds.csv", row.names = FALSE)

       使用lineprof时,需要以下几个步骤,首先使用source(),将源代码加载进内存,然后用lineprof()包含调用的语句,最后shine()展示返回的结果。需要注意的是,必须使用source()去加载代码,这是因为lineprof使用srcrefs去匹配代码和运行时间的,而srcrefs是在代码从硬盘加载的时候创建的。shine()函数会打开一个浏览器的网页(RStudio则新生成一个pane)展示lineprof返回的结果,如下图所示,并且阻塞现有的R对话,可以通过 Escape / Ctrl + C 来退出。

library(lineprof)

source("readcsv.R")
prof <- lineprof(read_delim("diamonds.csv"))
shine(prof)

      图中四列列出了每一行代码的性能:

  • t,每一行代码运行的时间花费,单位为s,具体见measuring performance
  • a,每一行代码运行过程中分配的内存的大小。
  • r,每一行代码释放的内存的大小。分配的是确定的,释放空间则是随机的,这个依赖于gc()函数,也就是说函数只告诉你空间不再使用了。
  • d,向量的复制发生的次数。R向量的复制发生在向量发生了变化的时候。

你可以在每一个颜色段上停留,网页会给出详细的运行参数,也可以在R会话中输出返回的结果:

> prof
Reducing depth to 2 (from 10)
Common path: readcsv.R
   time alloc release dups          ref                      src
1 0.042 1.939   0.271   19  readcsv.R#8 read_delim/scan         
2 0.018 0.786   0.562  156 readcsv.R#12 read_delim/lapply       
3 0.002 0.416   0.000  300 readcsv.R#22 read_delim/as.data.frame
  • scan()函数,分配的空间大小为1.9M,而硬盘上的文件大小为2.8M,那么缺少的部分在哪里呢?首先,R不需要存储csv文件中的逗号,再者就是R存在全局的字符串线程池,相同的字符串在内存中只分配一次空间。
  • lapply()函数,分配了0.7M的空间,同时释放了0.5M的空间,那是因为字符串转化为numeric或者integer空间会变小。
  • as.data.frame(),复制的次数是最多的,而且花费了大约0.4M的内存空间,那是因为as.data.frame函数是个比较糟糕的函数,先看看由list转化为data.frame函数的相关代码:
    x <- eval(as.call(c(expression(data.frame), x, check.names = !optional, 
            stringsAsFactors = stringsAsFactors)))
    这个部分x被复制了很多次,具体关于复制的问题,我们下面讨论。

      这种方法,存在一些弊端,例如:

  • read.delim()函数总共运行0.062s,即使是每毫秒收集一次数据,仅仅有62个样本。
  • 另外GC函数是懒惰的,我们始终都不知道哪些是不需要的内存。不过可以人为强制在每行代码运行后,执行gc函数,只要在lineprof函数中加入torture=T,不过会导致运行时间非常慢,正如参数的含义一样“折磨=True”,下面是执行的结果:
> prof<-lineprof(read.delim("diamonds.csv"),torture=T)
> prof
Reducing depth to 2 (from 10)
    time alloc release dups                                    ref
1  0.001 0.001   0.000    0                           character(0)
2  0.009 0.001   0.004    0                      "lazyLoadDBfetch"
3  0.001 0.000   0.000    1 c("lazyLoadDBfetch", "..getNamespace")
4  0.004 0.003   0.000    0                      "lazyLoadDBfetch"
5  0.002 0.007   0.000    0                           "read.delim"
6  0.291 0.105   0.004    1     c("read.delim", "lazyLoadDBfetch")
7  0.001 0.001   0.000    0                           "read.delim"
8 19.703 3.900   0.375  175          c("read.delim", "read.table")
9  0.002 0.000   0.000    0                           character(0)
                             src
1                               
2 lazyLoadDBfetch               
3 lazyLoadDBfetch/..getNamespace
4 lazyLoadDBfetch               
5 read.delim                    
6 read.delim/lazyLoadDBfetch    
7 read.delim                    
8 read.delim/read.table         
9        

4. Modification in place

 下面代码中的x会发生什么?

> x<-1:10
> address(x)
#[1] "0x4533b50"
> x[4]<-as.integer(5)

        有两种情况:

  1. R会在x[4]原来的地方,将值更改为5;
  2. R将x复制一份,在新的地址上进行更改,然后将x的指针指向新开辟的空间地址。

         实际上R到底会使用哪一种方式,依赖于上下文的环境。上面的代码R会在原先的地址上对数据进行更改。但是,当有另外一个变量指向了x的地址空间,那么x会重新生成一个新的copy。我们看下面的例子:

library(pryr)
x <- 1:10
c(address(x), refs(x))
# [1] "0x103100060" "1"

y <- x
c(address(y), refs(y))
# [1] "0x103100060" "2"

       如果使用的是RStudio,refs始终都是2,那是因为环境浏览器也会指向各个对象。refs()函数只是一个判断,它仅仅可以区分1和多于1的引用数量,也就是说下面的例子refs始终会返回2。

> x<-1:5
> y<-x
> rm(y)
#本应该会变成1,因为我们删除了y
> refs(x)
#[1] 2

> x<-1:5
> y<-x
> z<-x
#本应该会变成3
> refs(x)
#[1] 2

        只要refs函数返回的值为1时,x会在原始的地方发生变化,而返回值为2时,就会发生copy,我们看下面例子,x的引用次数本来在删除y之后会变为1,但是refs返回2,而且更改x的值,x会发生拷贝,这个有待研究。

> x<-1:5
> y<-x
> refs(x)
#[1] 2
> rm(y)
> refs(x)
#[1] 2
> address(x)
#[1] "0x69d7560"
> x[4]<-as.integer(10)
> address(x)
#[1] "0x69d7758"

       函数tracemem会在监视变量地址发生变化时,输出变化前后不同的地址,当监视的变量被重新赋值时,则取消监视。下面的代码为什么x[2]<-10之后为什么会发生2次的地址变迁?第一次是由于refs返回值为2,更改x的值需要重新生成一份copy;第二次地址变迁,是由于x的类型发生变化,由integer变成了numeric,这也是上面的代码中一直加上as.integer的原因。

> x<-1:5
> y<-x
> tracemem(x)
#[1] "<0x6a53768>"
> x[2]<-10
#tracemem[0x6a53768 -> 0x6a53888]: 
#tracemem[0x6a53888 -> 0x6303e80]: 

     非R的原函数访问R的对象,都会使得该对象的引用次数增加,而原函数则不会。一般而言,如果一个R对象的被引用次数为1,那么R的原函数都不会发生拷贝,在原先的地址空间对数据进行更改。这样的原函数一般包括[[<-, [<-, @<-, $<-, attr<-, attributes<-, class<-, dim<-, dimnames<-, names<-, and levels<-。如果想需求其他的方式防止拷贝的次数过多,那么RCpp包是个不错的选择。

> f<-function(x) return(x[1])
> {x<-1:10;f(x);refs(x)}
#[1] 2
> {x<-1:10;sum(x);refs(x)}
#[1] 1

5. Loops

R的循环那是出了名的慢啊,究竟是为什么,我们看下面的代码,每次循环过程中x的地址都在发生变化:

> x<-1:100
> tracemem(x)
[1] "<0x622bc90>"
> for(i in 1:5)
+ x[i]<-x[i]-median(x)
#tracemem[0x622bc90 -> 0x60fd910]: sort.int sort.default sort mean median.default median #median内部的原因引起refs值变为2,同时引起了复制。
#tracemem[0x622bc90 -> 0x60fde20]:                                                       #x类型变换引起复制
#tracemem[0x60fde20 -> 0x53d2610]:                                                       #[<-和refs值为2,引起复制
#tracemem[0x53d2610 -> 0x66fa270]: sort.int sort.default sort mean median.default median 
#tracemem[0x53d2610 -> 0x6692f40]: 
#tracemem[0x6692f40 -> 0x5277820]: sort.int sort.default sort mean median.default median 
#tracemem[0x6692f40 -> 0x5bb7b70]: 
#tracemem[0x5bb7b70 -> 0x68506f0]: sort.int sort.default sort mean median.default median 
#tracemem[0x5bb7b70 -> 0x46ec9d0]: 
#tracemem[0x46ec9d0 -> 0x3e54170]: sort.int sort.default sort mean median.default median 
#tracemem[0x46ec9d0 -> 0x2ce85a0]: 

下面的代码一个简单的赋值操作,x的地址却变化了三次,那是因为[<-.data.frame并不是R的原函数:

> x <- data.frame(matrix(runif(100 * 1e4), ncol = 100))
> medians <- vapply(x, median, numeric(1))
> tracemem(x)
[1] "<0x6692f40>"
> for(i in 1:5){  x[,i]<-x[,i]-median(i)  }
tracemem[0x6692f40 -> 0x46ec9d0]: 
tracemem[0x46ec9d0 -> 0x3e54170]: [<-.data.frame [<- 
tracemem[0x3e54170 -> 0x66f9ec0]: [<-.data.frame [<- 
tracemem[0x66f9ec0 -> 0x66fa270]: 
tracemem[0x66fa270 -> 0x3943410]: [<-.data.frame [<- 
tracemem[0x3943410 -> 0x3c1d620]: [<-.data.frame [<- 
tracemem[0x3c1d620 -> 0x3f39a50]: 
tracemem[0x3f39a50 -> 0x6c08650]: [<-.data.frame [<- 
tracemem[0x6c08650 -> 0x4b22f50]: [<-.data.frame [<- 
tracemem[0x4b22f50 -> 0x44d5120]: 
tracemem[0x44d5120 -> 0x3a63420]: [<-.data.frame [<- 
tracemem[0x3a63420 -> 0x2ee6d40]: [<-.data.frame [<- 
tracemem[0x2ee6d40 -> 0x3be2650]: 
tracemem[0x3be2650 -> 0x506ae30]: [<-.data.frame [<- 
tracemem[0x506ae30 -> 0x2dea9c0]: [<-.data.frame [<- 

那么将data.frame换成list则不会出现上面的问题:

> y<-as.list(x)
tracemem[0x2dea9c0 -> 0x45b28b0]: as.list.data.frame as.list 
> tracemem(y)
[1] "<0x45b28b0>"
> for(i in 1:5)
+ y[[i]]<-y[[i]]-median(i)
tracemem[0x45b28b0 -> 0x5018fa0]: 

发生复制很多的地方,建议使用RCpp.

原文地址:https://www.cnblogs.com/wzyj/p/4544642.html