R語言-內存管理


R語言內存管理


R之內存管理-轉載

引言

  R的內存管理機制究竟是什么樣子的?最近幾日在講一個分享會,被同學問到這方面的問題,可是到網上去查,終於找到一篇 R語言內存管理 不過講的不清不楚的,就拿memory.limit()函數來說,是在windows下才使用的,作者幾乎沒有提及,還有rm(),gc()函數到底怎么工作的,什么時候用,都無從提及。看來百度是解決不了了,關鍵時候還是靠google啊,這不,很快找到了一篇相當不錯的文章 Memory ,還是人家外國人比較注重細節,下面的部分幾乎是從那兒翻譯過來的。需要學習R的高級編程的同學,可以下載Advanced R,本文屬於其中的一個章節。另外值得一提的是在《R語言實踐》一書中的附錄G對於怎么高效編程,也有一些建議!

目錄

  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包。


總結:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM